JPA(Hibernate) HBM2DDL 컬럼 순서 지정하기

Brant Hwang
QueryPie
Published in
22 min readMay 12, 2016

하이버네이트의 hbm2ddl을 통해 데이터베이스에 테이블을 생성할 경우, 하이버네이트는 엔티티의 컬럼들을 알파벳 순(PK는 앞쪽에 생성)으로 생성합니다.

@Entity
public class CompanyTest {
@Id
private String companyCode;
private String companyName;private String address;private String zipCode;private String ceoName;}

이렇게 생긴 엔티티 클래스를 생성하면 데이터베이스에는 companyCode, address, ceoName, companyName, zipCode 순으로 컬럼이 생성됩니다. ㅠ.ㅠ
종종 데이터베이스에 테이블을 복구할때, hbm2ddl을 유용하게 사용하고 있는데, 매번 컬럼순서를 조정해주는 불편함때문에 애너테이션으로 컬럼순서를 원하는대로 조정 할 수 있는 간단한 유틸리티를 만들었습니다.유틸리티는 다음과 같은 방식으로 동작합니다.
  • SchemaExport를 생성합니다.
  • SchemaExport를 통해 DDL Script를 임시경로에 만듭니다.
  • DDL Script를 읽어와서 create table로 시작하는 DDL을 검사합니다.
  • create table로 시작하는 DDL의 경우, 해당 엔티티 정보를 하이버네이트 ClassMetadata를 통해 가져옵니다.
  • 리플렉션을 이용해 해당 엔티티 클래스의 필드정보와 해당 필드의 애너테이션 검사를 통해 해당 필드가 몇번째 컬럼으로 만들어 져야하는지 확인합니다.
  • 순서가 모두 결정되면 jdbcTemplate을 통해 DDL을 실행합니다.
SpringBoot 기반의 간단한 테스트를 통해 유틸리티를 실행할 수 있도록 코드를 만들었습니다.spring.datasource.username=sa
spring.datasource.password=
spring.datasource.url=jdbc:h2:~/hbm2ddl/db
spring.jpa.hibernate.ddl-auto=none
spring.jpa.database-platform=H2
먼저 스프링 부트가 기동되면서 hbl2ddl이 실행되지 않도록 ddl-auto 프로퍼티 값을 none으로 설정했습니다.
그리고 테스트를 위해 3개의 엔티티 클래스를 생성했습니다.@Entity
public class CompanyTest {
@Id
@ColumnPosition(1)
private String companyCode;
@ColumnPosition(2)
private String companyName;
@ColumnPosition(3)
private String address;
@ColumnPosition(4)
private String zipCode;
@ColumnPosition(5)
private String ceoName;
}@Entity
public class Store {
@Id
@ColumnPosition(1)
private String storeCode;
@ColumnPosition(2)
private String storeName;
private String address;@ColumnPosition(3)
private String zipCode;
private String ceoName;}@DynamicInsert
@DynamicUpdate
@Entity
public class User {
@Id
@Column(name = "USER_CD", length = 20)
@ColumnPosition(1)
private String userCd;
@Column(name = "USER_NM", length = 30)
@ColumnPosition(2)
private String userNm;
@Column(name = "USER_PS", length = 128)
@ColumnPosition(3)
private String userPs;
@Column(name = "USER_TYPE", length = 15)
@ColumnPosition(4)
private String userType;
@Column(name = "EMAIL", length = 30)
@ColumnPosition(5)
private String email;
@Column(name = "HP_NO", length = 15)
@ColumnPosition(6)
private String hpNo;
@Column(name = "LAST_LOGIN_AT")
@ColumnPosition(8)
private LocalDateTime lastLoginAt;
@Column(name = "PASSWORD_UPDATED_AT")
@ColumnPosition(9)
private LocalDateTime passwordUpdatedAt;
@Column(name = "USE_YN", length = 1)
@ColumnPosition(10)
private String useYn;
@Column(name = "REMARK", length = 200)
@ColumnPosition(11)
private String remark;
}

@ColumnPosition 애너테이션을 다음과 같이 간단하게 만들었습니다.
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ColumnPosition {
int value();
}
이제 hbm2ddl이 DDL을 뽑아내는 클래스인 SchemaExport를 생성합니다.Properties prop = new Properties();
prop.put("hibernate.dialect", getSessionFactory().getDialect().toString());
prop.put("hibernate.hbm2ddl.auto", "create");
prop.put("hibernate.show_sql", "true");
prop.put("hibernate.connection.username", environment.getProperty("spring.datasource.username", ""));
prop.put("hibernate.connection.password", environment.getProperty("spring.datasource.password", ""));
prop.put("hibernate.connection.url", environment.getProperty("spring.datasource.url", ""));
PersistenceUnitInfo info = localContainerEntityManagerFactoryBean.getPersistenceUnitInfo();
PersistenceUnitInfoDescriptor persistenceUnitInfoDescriptor = new PersistenceUnitInfoDescriptor(info);
EntityManagerFactoryBuilderImpl entityManagerFactoryBuilder = new EntityManagerFactoryBuilderImpl(persistenceUnitInfoDescriptor, prop);
ServiceRegistry serviceRegistry = entityManagerFactoryBuilder.buildServiceRegistry();
Configuration configuration = entityManagerFactoryBuilder.buildHibernateConfiguration(serviceRegistry);
configuration.setNamingStrategy(new SpringNamingStrategy());
new SchemaExport(serviceRegistry, configuration);
하이버네이트 4버전을 기준으로는 위와 같이 작성하면 되고,
하이버네이트 5에서는 몇몇 클래스들이 변경되어 다음과 같이 작성하면 됩니다.BootstrapServiceRegistry bsr = new BootstrapServiceRegistryBuilder().build();
StandardServiceRegistryBuilder ssrBuilder = new StandardServiceRegistryBuilder( bsr );
Properties prop = new Properties();
prop.put("hibernate.dialect", getSessionFactory().getDialect().toString());
prop.put("hibernate.hbm2ddl.auto", "create");
prop.put("hibernate.show_sql", "true");
prop.put("hibernate.connection.username", environment.getProperty("spring.datasource.username", ""));
prop.put("hibernate.connection.password", environment.getProperty("spring.datasource.password", ""));
prop.put("hibernate.connection.url", environment.getProperty("spring.datasource.url", ""));
ssrBuilder.applySettings( prop );
StandardServiceRegistry standardServiceRegistry = ssrBuilder.build();
MetadataSources metadataSources = new MetadataSources( standardServiceRegistry );
MetadataBuilder metadataBuilder = metadataSources.getMetadataBuilder();
EnumSet<TargetType> targetTypes = EnumSet.of(TargetType.DATABASE, TargetType.STDOUT);
new SchemaExport().createOnly(targetTypes, metadataBuilder.build());
public void createSchema() throws IOException, ClassNotFoundException {
SchemaExport schemaExport = getSchemaExport();
String scriptOutputPath = System.getProperty("java.io.tmpdir") + "/schema.sql";
schemaExport.setOutputFile(scriptOutputPath);
schemaExport.create(false, true);
List<String> DDLs = IOUtils.readLines(new FileInputStream(scriptOutputPath), "UTF-8");
List<String> convertedDDLs = new ArrayList<>();
for (String DDL : DDLs) {
if (DDL.toLowerCase().startsWith("create table")) {
convertedDDLs.add(convert(DDL));
} else {
convertedDDLs.add(DDL);
}
}
for (String convertedDDL : convertedDDLs) {
System.out.println(convertedDDL);
jdbcTemplate.execute(convertedDDL);
}
}
SchemaExport를 이용해 임시경로에 schema.sql 파일을 생성하고, 생성한 파일을 읽어온 후, create table로 시작하는 DDL을 찾아서 convert() 메서드를 호출하도록 했습니다.
convert() 메서드를 살펴보면,private String convert(String ddl) throws ClassNotFoundException {
StringBuilder convertedDDL = new StringBuilder();
int startColumnBody = ddl.indexOf('(');
int endColumnBody = ddl.lastIndexOf(')');
String tableName = ddl.substring("create table ".length(), startColumnBody).trim();
String columnBody = ddl.substring(startColumnBody + 1, endColumnBody);
List<ColumnDefinition> columnDefinitions = Arrays.stream(columnBody.split(", ")).map(ColumnDefinition::new).collect(toList());ClassMetadata classMetadata = getClassMetaData(tableName);Class<?> clazz = Class.forName(classMetadata.getEntityName());
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
setPosition(field, columnDefinitions);
}
convertedDDL
.append("create table")
.append(" ")
.append(tableName)
.append(" ")
.append("(");
StringJoiner columns = new StringJoiner(", ");columnDefinitions
.stream()
.sorted(
Comparator.comparingInt(ColumnDefinition::getPosition)
.thenComparing(ColumnDefinition::getColumnName))
.forEach(entityField -> {
columns.add(entityField.getColumnDefinition());
});
convertedDDL.append(columns.toString());convertedDDL.append(")");return convertedDDL.toString();
}
DDL을 substring 해서 테이블 이름과, 컬럼들의 선언부를 찾은 후 컬럼들은 ColumnDefinition 타입의 클래스로 생성했습니다.
이후 테이블 이름으로 ClassMetadata를 가져와서 엔티티 클래스를 생성했고, 해당 엔티티 클래스에 있는 필드들을 가져와 setPosition() 메서드를 호출하고 있습니다.ColumnDefinition 클래스와 setPosition() 메서드를 살펴보면,public class ColumnDefinition {private String columnName;private String definition;private String columnDefinition;private int position = Integer.MAX_VALUE - 10;public ColumnDefinition(String columnDefinition) {
this.columnDefinition = columnDefinition;
this.columnName = columnDefinition.split(" ")[0];
this.definition = columnDefinition.split(" ")[1];
if (columnDefinition.toLowerCase().startsWith("primary key")) {
position = Integer.MAX_VALUE;
}
}
public String getColumnDefinition() {
return columnDefinition;
}
public String getColumnName() {
return columnName;
}
public int getPosition() {
return position;
}
public String getDefinition() {
return definition;
}
public void setPosition(int position) {
this.position = position;
}
}

ColumnDefinition 클래스는 user_name varchar(100)과 같은 DDL을, user_name, varchar(100)과 같이 이름/타입으로 구분하도록 하고, 해당 컬럼이 어디에 위치해야할지 저장할 position 필드를 가지고 있습니다. 이때 PK를 생성하는 구문이면 가장 마지막에 위치하도록 예외처리를 했습니다.
public void setPosition(Field field, List<ColumnDefinition> columnDefinitions) {
String name = field.getName();
String columnName;
int position = Integer.MAX_VALUE - 10;
if (field.getAnnotation(Transient.class) == null) {
Column column = field.getAnnotation(Column.class);
ColumnPosition columnPosition = field.getAnnotation(ColumnPosition.class);
if (column != null && !"".equals(column.name())) {
columnName = column.name();
} else {
columnName = new SpringNamingStrategy().columnName(name);
}
if (columnPosition != null && columnPosition.value() > 0) {
position = columnPosition.value();
}
for (ColumnDefinition columnDefinition : columnDefinitions) {
if (columnDefinition.getColumnName().toLowerCase().equals(columnName.toLowerCase())) {
columnDefinition.setPosition(position);
}
}
}
}
다음은 setPosition() 메서드입니다. 엔티티의 해당 필드를 검사하여 컬럼 위치 애너테이션이 있는지 찾아서 ColumnDefinition의 컬럼이름과 일치하는 녀석의 position 값을 설정하도록 했습니다.
for (Field field : fields) {
setPosition(field, columnDefinitions);
}
convertedDDL
.append("create table")
.append(" ")
.append(tableName)
.append(" ")
.append("(");
StringJoiner columns = new StringJoiner(", ");columnDefinitions
.stream()
.sorted(
Comparator.comparingInt(ColumnDefinition::getPosition)
.thenComparing(ColumnDefinition::getColumnName))
.forEach(entityField -> {
columns.add(entityField.getColumnDefinition());
});
convertedDDL.append(columns.toString());convertedDDL.append(")");return convertedDDL.toString();
리스트의 ColumnDefinition들을 position 순서대로 정렬 후, @ColumnPosition 애너테이션이 없는 필드는 알파베 순으로 정렬되도록 했습니다.
이제 하이버네이트가 생성했던 알파벳순서의 DDL은, 엔티티 클래스의 @ColumnPosition 애너테이션 유무와, position 값에 따라 순서있는 DDL로 변환이 되었습니다.마지막으로 jdbcTemplate을 통해 변환된 DDL을 실행해주면, 다음과 같이 지정한 컬럼순서대로 테이블이 생성됩니다!// 하이버네이트 SchemaExport가 생성한 DDL
Hibernate: drop table company_test if exists
Hibernate: drop table store if exists
Hibernate: drop table user if exists
Hibernate: create table company_test (company_code varchar(255) not null, address varchar(255), ceo_name varchar(255), company_name varchar(255), zip_code varchar(255), primary key (company_code))
Hibernate: create table store (store_code varchar(255) not null, address varchar(255), ceo_name varchar(255), store_name varchar(255), zip_code varchar(255), primary key (store_code))
Hibernate: create table user (user_cd varchar(20) not null, email varchar(30), hp_no varchar(15), last_login_at binary(255), password_updated_at binary(255), remark varchar(200), use_yn varchar(1), user_nm varchar(30), user_ps varchar(128), user_type varchar(15), primary key (user_cd))
2016-05-13 05:51:44.801 INFO 60497 --- [ main] org.hibernate.tool.hbm2ddl.SchemaExport : HHH000230: Schema export complete
// 지정한 컬럼 순서대로 변환된 DDL
drop table company_test if exists
drop table store if exists
drop table user if exists
create table company_test (company_code varchar(255) not null, company_name varchar(255), address varchar(255), zip_code varchar(255), ceo_name varchar(255), primary key (company_code))
create table store (store_code varchar(255) not null, store_name varchar(255), zip_code varchar(255), address varchar(255), ceo_name varchar(255), primary key (store_code))
create table user (user_cd varchar(20) not null, user_nm varchar(30), user_ps varchar(128), user_type varchar(15), email varchar(30), hp_no varchar(15), last_login_at binary(255), password_updated_at binary(255), use_yn varchar(1), remark varchar(200), primary key (user_cd))
Screen Shot 2016-05-13 at 5.52.31 AM
예제 소스코드는 Github에 올려두었습니다.https://github.com/brant-hwang/hbm2ddl-column-position

--

--