서론
프로젝트 적용 속도가 빨랐을 당시에는, 데이터베이스에 대한 형상 관리에 대해서 생각해보지 않았었다. 변화하는 테이블이 그리 많지 않았었고 속성이 몇 개 정도 추가되는 거라 DBeaver이나 IntelliJ 콘솔에서 그냥 밀어넣는 식으로 작업을 했었다.
그런데, 꽤 큰 규모의 업데이트를 진행하면서 속성 자체가 사라지거나, 신규 테이블들이 줄줄이 소세지처럼 생겨나니, 모든 테이블을 다 기억하고 스크립트를 작성하는 게 어려워졌다. 특히, 한두 개 빠지는 속성이나 테이블들이 생겨나면서 앱에서 오류를 발생시키는 경우가 발생했다. 어떻게 해결해야하나 고민하던 중, DB 형상 관리를 도와주는 툴인 Flyway를 알게되어 적용하게 되었다.
Flyway란?
Flyway는 데이터베이스 형상 관리 도구이다. 데이터베이스의 스키마나 데이터를 관리할 때, 버전 관리나 마이그레이션 등의 일을 자동화할 수 있다. Flyway를 사용하면 데이터베이스 관리가 편리해진다. 버전 관리와 마이그레이션을 자동화할 수 있어 개발자가 실수를 줄일 수 있으며, 여러 개발자들이 협업을 할 때, 데이터베이스의 상태를 일관되게 유지할 수 있다는 장점이 있다.
Flyway의 구조
Flyway는 간단한 구조와 명확한 규칙으로 이루어져 있다. 데이터베이스에 적용할 SQL 스크립트를 마이그레이션 파일로 작성하고, Flyway가 이를 실행해 적용하는 방식이다. 마이그레이션 파일은 버전 관리 시스템으로 관리할 수 있다.
Flyway 적용 방법
Flyway를 적용하는 방법은 간단하다.
- 프로젝트에 Flyway 라이브러리를 추가한다.
dependencies {
implementation 'org.flywaydb:flyway-core'
implementation "org.flywaydb:flyway-mysql"
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'mysql:mysql-connector-java'
}
내가 진행하는 프로젝트에서는 data-flyway
모듈의 의존성은 위와 같이 추가해주었다.
- 설정 파일 추가
server:
port: 9000
spring:
config:
activate:
on-profile: local
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/kurrant_local
username: root
password: 1234
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration/local,classpath:db/callback
baseline-version: 1
fail-on-missing-locations: true
jpa:
show-sql: true
hibernate:
ddl-auto: validate
properties:
hibernate:
format_sql: true
dialect: co.dalicious.data.mysql.dialect.CustomMySQLDialect
spring.flyway.enabled
: Flyway를 사용할지 여부를 설정한다. 기본값은true
이다.spring.flyway.baseline-on-migrate
: Flyway가 초기화되지 않은 데이터베이스에 마이그레이션을 적용할 때 초기 버전으로 마이그레이션 파일을 적용할지 여부를 설정한다. 기본값은false
이다.spring.flyway.locations
: 마이그레이션 파일이 저장된 위치를 설정한다. 여러 개의 위치를 지정할 수 있다.spring.flyway.baseline-version
: Flyway가 초기화되지 않은 데이터베이스에 마이그레이션을 적용할 때 버전을 설정합니다. 기본값은1
이다.spring.flyway.fail-on-missing-locations
: 스크립트 파일을 저장하는 위치를 찾지 못할 때 에러를 발생시킨다. 기본값은 false이며 선택사항이다.spring.jpa.hibernate.ddl-auto: validate
: 속성은 Hibernate (Java 객체-관계 매핑 도구)에게 시작시 데이터베이스 스키마를 애플리케이션에서 정의한 엔티티 클래스와 대조하여 유효성을 검증하고 필요하다면 업데이트하도록 지시하는 설정이다.
- 데이터베이스에 적용할 마이그레이션 파일을 작성한다. 마이그레이션 파일은 V숫자__{파일이름}.sql 형식으로 작성한다.
U는 유료 모드에서 사용할 수 있고, R은 반복 가능한 스크립트에 사용하는데 버전관리만 해도 충분하다고 생각한다.
⚠️ 유의할 점
데이터베이스에 기존 데이터가 존재할 경우 db/migration
위치에 반드시 스크립트 파일이 있어야 한다. 특별한 변동 없이, 그냥 기존 테이블과 데이터를 유지하고자 해도 빈 script를 작성해야 한다.
Flyway Rollback
MySQL은 트랜잭션 DDL을 지원하지 않는다. MySQL에서 마이그레이션이 실패할 경우, 실패하기 전에 변경한 내용이 그대로 남아 데이터베이스가 잠재적으로 일관되지 않은 상태로 유지된다. 이 문제를 해결하려면, 마이그레이션을 주의 깊게 스크립팅하고 실패할 수 있는 모든 단계에 대해 필요한 보정 작업(repair)을 포함해야 한다.
설정 파일 스크립트 중,
spring:
flyway:
locations: classpath:db/migration/local,classpath:db/callback
와 같이 classPath를 2개 추가 한 이유는 db/migration/local
에서는 스크립트 관리를, db/callback
에서는 DDL 실패시 flyway_schema_history
에서 저장하는 실패 로그를 삭제하기 위함이다.
afterMigrateError_repair.sql
DELETE FROM flyway_schema_history WHERE success=false;
하지만 여전히 문제가 있다. 스크립트가 어느 정도 진행된 후 에러가 발생하면, 에러 이전에 실행된 것들은 이미 데이터베이스에 적용된 상태이기 때문에 삭제해야 하는 번거로움이 있다.
따라서, 실패할 때마다 flyway_schema_history
테이블에 접속하여 일일이 삭제하는 것보다는 위와 같이 사용하는 것이 더 나은 방법이라고 생각하여, 위와 같이 적용중이다.
Flyway 적용 중 발생한 문제
문제 발생1: BigInteger → UNSIGNED BIGINT 변환 문제
현 프로젝트의 코드 컨벤션 중 하나는 ID는 BigInteger 타입으로, 데이터베이스에는 BIGINT UNSIGNED 로 저장하고 있었다.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(columnDefinition = "BIGINT UNSIGNED")
@Comment("유저 그룹 정보 PK")
private BigInteger id;
문제는 BigInteger 객체와 UNSIGNED BIGINT
를 매핑할 수 없다는 에러를 발생시켰다. Hibernate 타입이 BIGINT
에 대해서는 Long
인데, 프로젝트에서는 UNSIGNED BIGINT
로 저장되고 있기 때문이었다.모든 id를 BigInteger로 구성하는 프로젝트에서 일일이 변경해야 하는 것은 수고스러운 일이었고, @Type
어노테이션을 모든 id에게 붙이는 것은 매번 Entity 생성할 때마다 신경써줘야 하는 부분이 늘어나는 일이었다. 그래서 기존 코드 형식을 유지하기 위해 사용자 정의 Hibernate Dialect를 생성하기로 결정했다.
spring:
jpa:
properties:
hibernate:
dialect: co.dalicious.data.mysql.dialect.CustomMySQLDialect
위 설정 파일의 일부 코드이다. CustomMySQLDialect 라는 Hibernate Dialet 클래스를 상속하는 클래스를 만들어 dialect로 설정해준 것이다.
CustomMySQLDialect.class
public class CustomMySQLDialect extends MySQL8SpatialDialect {
public CustomMySQLDialect() {
super();
registerHibernateType(Types.BIGINT, BigIntegerUserType.class.getName());
registerHibernateType(Types.LONGVARBINARY, GeometryUserType.class.getName());
registerHibernateType(Types.BIT, BooleanBit1Type.class.getName());
}
}
registerHibernateType(Types.BIGINT, BigIntegerUserType.class.getName());
코드는 BIGINT
컬럼을 BigInteger
자바 객체와 매핑하기 위한 사용자 정의 Hibernate 타입을 등록하는 코드이다. 이러한 사용자 정의 타입을 등록함으로써 Hibernate는 BIGINT
컬럼을 올바르게 BigInteger
자바 객체와 매핑할 수 있다.
BigIntegerUserType.class
public class BigIntegerUserType implements UserType {
public int[] sqlTypes() { return new int[]{Types.BIGINT}; }
public Class returnedClass() { return BigInteger.class; }
public boolean equals(Object x, Object y) throws HibernateException {
if (x == y) {
return true;
}
if (x == null || y == null) {
return false;
}
BigInteger bx = (BigInteger) x;
BigInteger by = (BigInteger) y;
return bx.equals(by);
}
public int hashCode(Object x) throws HibernateException {
return ((BigInteger) x).hashCode();
}
public Object nullSafeGet(ResultSet rs, String[] names, SharedSessionContractImplementor session, Object owner) throws HibernateException, SQLException {
long value = rs.getLong(names[0]);
return rs.wasNull() ? null : BigInteger.valueOf(value);
}
public void nullSafeSet(PreparedStatement st, Object value, int index, SharedSessionContractImplementor session) throws HibernateException, SQLException {
if (value == null) {
st.setNull(index, Types.BIGINT);
} else {
st.setLong(index, ((BigInteger) value).longValue());
}
}
public Object deepCopy(Object value) throws HibernateException {
return value;
}
public boolean isMutable() { return false; }
public Serializable disassemble(Object value) throws HibernateException {
return (Serializable) value;
}
public Object assemble(Serializable cached, Object owner) throws HibernateException {
return cached;
}
public Object replace(Object original, Object target, Object owner) throws HibernateException {
return original;
}
}
BigIntegerUserType
클래스는 Hibernate에서 BIGINT
컬럼을 BigInteger
자바 객체와 매핑하기 위한 사용자 정의 Hibernate 타입을 구현한 클래스이다. 이 클래스는 UserType
인터페이스를 구현하며, sqlTypes
, returnedClass
, equals
, hashCode
, nullSafeGet
, nullSafeSet
, deepCopy
, isMutable
, disassemble
, assemble
, replace
등의 메서드를 구현하여 Hibernate에서 BIGINT
컬럼을 올바르게 BigInteger
자바 객체와 매핑할 수 있도록 한다.
문제 발생2: Geometry → TINYBLOB(LONGVARBINARY) 변환 문제
문제 1과 비슷한 문제였다. 초반에 원인을 찾지 못했던 것은 MySQL에서 지원하는 GEOMETRY 컬럼이 존재해서 따로 사용자 정의를 할 필요가 없다 생각을 했지만 에러를 띄웠고, 변환이 필요하다 생각해서 스크립트를 작성했지만 Geometry 객체를 BlOB타입으로 변환 시키니 변환하기 전과 같은 에러가 발생했다.
Caused by: org.hibernate.tool.schema.spi.SchemaManagementException: Schema-validation: wrong column type encountered in column [address_location] in table [client__group]; found [blob (Types#LONGVARBINARY)], but expecting [geometry (Types#ARRAY)]
문제는, BLOB
으로 형변환을 해야 했던 것이 아니라, LONGVARBINARY
로 변환해야 했던 것이다.
public class GeometryUserType implements UserType {
@Override
public int[] sqlTypes() {
// This is the "default" type we will map to.
return new int[]{Types.LONGVARBINARY};
}
@Override
public Class returnedClass() {
return Geometry.class;
}
@Override
public Object nullSafeGet(ResultSet rs, String[] names, SharedSessionContractImplementor session, Object owner)
throws HibernateException, SQLException {
byte[] bytes = rs.getBytes(names[0]);
try {
return new WKBReader().read(bytes);
} catch (ParseException e) {
throw new HibernateException("Failed to convert String to Geometry: " + Arrays.toString(bytes), e);
}
}
@Override
public void nullSafeSet(PreparedStatement st, Object value, int index, SharedSessionContractImplementor session)
throws HibernateException, SQLException {
if (value == null) {
st.setNull(index, Types.LONGVARBINARY);
} else {
Geometry geometry = (Geometry) value;
byte[] bytes = new WKBWriter().write(geometry);
if (bytes.length <= 255) {
st.setBinaryStream(index, new ByteArrayInputStream(bytes), bytes.length);
} else {
st.setBinaryStream(index, new ByteArrayInputStream(bytes), bytes.length);
}
}
}
@Override
public Object deepCopy(Object value) throws HibernateException {
return value;
}
@Override
public boolean isMutable() {
return false;
}
@Override
public Serializable disassemble(Object value) throws HibernateException {
return (Geometry) deepCopy(value);
}
@Override
public Object assemble(Serializable cached, Object owner) throws HibernateException {
return deepCopy(cached);
}
@Override
public Object replace(Object original, Object target, Object owner) throws HibernateException {
return deepCopy(original);
}
@Override
public boolean equals(Object x, Object y) throws HibernateException {
if (x == null) {
return y == null;
} else {
return x.equals(y);
}
}
@Override
public int hashCode(Object x) throws HibernateException {
return x.hashCode();
}
}
문제 발생3: 모듈을 추가했음에도 불구하고 validate 에러가 발생하지 않는 문제.
이 문제는 우리 프로젝트가 멀티 모듈 프로젝트로 진행되고 있었기에 발생하는 상황이었다. 나는 data-flyway 모듈을 만들어, 이 안에 FlywayApplication 클래스를 생성하여 실제로 서비스를 배포하기 이전에 Entity 클래스와 DB 테이블 매핑 일치를 확인하고 바로 종료하는 아키텍처를 구축하고 싶었다. 이것 저것 다 작업하다 보니 갑자기 작동해서 정확한 원인이 무엇인지는 모르겠으나, 일단 @EntityScan
어노테이션 하나로 Entity 스캔 문제를 해결했다.
@EntityScan(basePackages = {"co.dalicious.*"})
@SpringBootApplication
public class FlywayApplication {
public static void main(String[] args) {
SpringApplication.run(FlywayApplication.class, args);
}
}
🍯 ddl-auto:validate 사용시 IntelliJ DDL 꿀팁 및 추가 설정 사항
위를 클릭하면
아래와 같이 나타나는데, 스크립트를 적용할 DB를 선택하고 확인을 누르면
위와 같은 창이 나타나난다. Entity와 데이터베이스 테이블을 자동으로 매핑시키고 스크립트를 생성해준다. 여기서 문제는, 일치하지 않는 부분을 DROP 시켜버리고 ALTER ADD를 추가하는 경우가 종종 있다. 그래서 Merge with addColumn 부분을 선택하면 DROP 후 ADD가 아닌 MODIFY로 변경할 수 있다.