Flyway를 사용한 Spring Boot 데이터베이스 마이그레이션
1. 개요
애플리케이션이 변경되면 데이터베이스 스키마도 함께 변경해야 합니다. 각 환경에서 손으로 ALTER TABLE 문을 실행하다 보면 개발, 스테이징, 프로덕션 환경의 스키마가 어느새 달라져 있을 것입니다. Flyway는 이 문제를 해결합니다. 각 스키마 변경은 버전이 있는 마이그레이션 파일이 되어 자동으로 올바른 순서대로 적용됩니다.
이 글에서는 Spring Boot 3 애플리케이션에 Flyway를 설정하고, SQL과 Java로 마이그레이션을 작성하며, JUnit 5로 테스트하고, checksum mismatch 같은 일반적인 오류를 처리하는 방법을 배우게 됩니다.
2. 의존성 설치
Spring Boot가 Flyway의 버전을 관리하므로 버전을 직접 지정할 필요가 없습니다. 아래 예는 PostgreSQL을 사용합니다:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Flyway 10 이상(Spring Boot 3.3+과 함께 제공)부터는 각 데이터베이스에 대한 지원이 별도 모듈로 분리되었습니다. flyway-database-postgresql이 없으면 애플리케이션이 시작되지 않고 오류가 발생합니다:
org.flywaydb.core.api.FlywayException: Unsupported Database: PostgreSQL 16.4
H2는 여전히 flyway-core에 포함되어 있으므로 테스트 쪽에서는 추가 모듈이 필요하지 않습니다.
3. 기본 설정
classpath에 flyway-core만 있으면 Spring Boot가 자동으로 Flyway bean을 만들고 EntityManagerFactory가 초기화되기 전에 마이그레이션을 실행합니다. application.properties의 최소 설정:
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.locations=classpath:db/migration
spring.flyway.validate-on-migrate=true
중요한 설정값들:
spring.jpa.hibernate.ddl-auto=validate: Hibernate는 엔티티가 스키마와 일치하는지만 확인합니다. 스키마를 자동으로 수정하지 않습니다. Flyway가 스키마에 대한 유일한 진실 공급원입니다.spring.flyway.locations: 마이그레이션이 있는 디렉토리입니다. 기본값은 이미classpath:db/migration이지만 명확성을 위해 명시합니다.spring.flyway.validate-on-migrate=true: 마이그레이션 전에 Flyway가 이미 실행된 마이그레이션의 checksum을 확인합니다. 이것도 기본값입니다.
주의: Flyway와 함께
ddl-auto=update또는create를 사용하지 마세요. Hibernate와 Flyway가 동시에 스키마를 수정하면 결과를 예측하기 어렵습니다.
4. SQL로 마이그레이션 작성
4.1. 네이밍 규칙
Flyway는 파일명으로 마이그레이션을 식별합니다:
V1__create_customer_table.sql
│ │ └── 설명 (밑줄은 공백으로 해석)
│ └── 두 개의 밑줄로 구분
└── V = versioned migration, 뒤에 버전 번호가 옴
버전은 1, 2, 1.1 또는 20261016.1일 수 있습니다. Flyway는 버전을 숫자값으로 비교하며, 알파벳 순서로 비교하지 않습니다. 따라서 V10은 V9 이후에 실행됩니다.
4.2. 첫 번째 마이그레이션
src/main/resources/db/migration/V1__create_customer_table.sql 파일을 만드세요:
CREATE TABLE customer (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
full_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
다음은 V2__create_orders_table.sql입니다:
CREATE TABLE orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customer (id),
total NUMERIC(12, 2) NOT NULL,
status VARCHAR(20) NOT NULL
);
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
애플리케이션을 시작하면 로그가 다음과 같이 나타납니다:
Flyway Community Edition 10.x by Redgate
Database: jdbc:postgresql://localhost:5432/shop (PostgreSQL 16.4)
Successfully validated 2 migrations (execution time 00:00.012s)
Creating Schema History table "public"."flyway_schema_history" ...
Current version of schema "public": << Empty Schema >>
Migrating schema "public" to version "1 - create customer table"
Migrating schema "public" to version "2 - create orders table"
Successfully applied 2 migrations to schema "public", now at version v2
4.3. flyway_schema_history 테이블
Flyway는 실행된 모든 마이그레이션을 flyway_schema_history 테이블에 기록합니다. 직접 쿼리할 수 있습니다:
SELECT installed_rank, version, description, checksum, success
FROM flyway_schema_history
ORDER BY installed_rank;
installed_rank | version | description | checksum | success
----------------+---------+-----------------------+-------------+---------
1 | 1 | create customer table | -1253721480 | t
2 | 2 | create orders table | 873416502 | t
다음 시작 시 Flyway는 스키마가 이미 버전 2에 있음을 확인하고 다시 실행하지 않습니다. checksum 컬럼이 섹션 7의 핵심입니다.
4.4. Repeatable 마이그레이션
R__로 시작하는 파일은 repeatable 마이그레이션입니다. 버전이 없습니다. Flyway는 checksum이 변경될 때마다 이 파일을 다시 실행하며, 항상 모든 versioned 마이그레이션 후에 실행됩니다. 이 유형은 뷰, 함수 또는 저장 프로시저에 적합합니다:
-- R__order_summary_view.sql
CREATE OR REPLACE VIEW order_summary AS
SELECT c.id AS customer_id, c.full_name, COUNT(o.id) AS order_count
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.full_name;
5. Java로 마이그레이션 작성
일부 변경은 순수 SQL로 작성하기 어렵습니다. 예를 들어 문자열 처리 로직이 필요한 데이터 backfill이 그렇습니다. 이 경우 BaseJavaMigration을 상속한 Java로 마이그레이션을 작성합니다.
먼저 V3__add_customer_email.sql에서 SQL로 컬럼을 추가합니다:
ALTER TABLE customer ADD COLUMN email VARCHAR(150);
그 다음 src/main/java/db/migration/V4__Backfill_customer_email.java에 다음 클래스를 package db.migration에 넣습니다:
package db.migration;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.SingleConnectionDataSource;
import java.text.Normalizer;
public class V4__Backfill_customer_email extends BaseJavaMigration {
@Override
public void migrate(Context context) {
var jdbc = new JdbcTemplate(
new SingleConnectionDataSource(context.getConnection(), true));
jdbc.query("SELECT id, full_name FROM customer WHERE email IS NULL", rs -> {
long id = rs.getLong("id");
String email = toSlug(rs.getString("full_name")) + "." + id + "@example.com";
jdbc.update("UPDATE customer SET email = ? WHERE id = ?", email, id);
});
}
private static String toSlug(String name) {
String noAccent = Normalizer.normalize(name, Normalizer.Form.NFD)
.replaceAll("\\p{M}", "")
.replace('đ', 'd').replace('Đ', 'D');
return noAccent.toLowerCase().trim().replaceAll("\\s+", ".");
}
}
설명:
- 클래스명은 SQL 파일과 동일한 규칙을 따릅니다:
V4__Backfill_customer_email. context.getConnection()은 Flyway가 사용 중인 connection을 반환하며, 이는 마이그레이션의 동일한 트랜잭션 내에 있습니다.SingleConnectionDataSource의true파라미터는JdbcTemplate이 이 connection을 닫지 못하게 합니다.- Flyway는
classpath:db/migration에 해당하는db.migrationpackage를 스캔합니다.
id가 1인 고객 Nguyễn Văn An의 경우 결과는 nguyen.van.an.1@example.com입니다.
Spring Boot는 또한 모든 JavaMigration 타입의 bean을 자동으로 Flyway에 등록합니다. 이덕분에 마이그레이션을 @Component로 선언할 수 있습니다. 그러나 마이그레이션에 repository나 service를 inject해서는 안 됩니다. 비즈니스 코드는 시간이 지나면서 변하지만 마이그레이션은 영원히 동일하게 실행되어야 합니다.
6. 마이그레이션 테스트
스키마가 비어 있는 상태에서 모든 마이그레이션이 실행되고 Hibernate 검증이 성공하는지 확인하는 테스트가 최소한 하나는 있어야 합니다. src/test/resources/application-test.properties를 만드세요:
spring.datasource.url=jdbc:h2:mem:shop;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
테스트:
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
class FlywayMigrationTest {
@Autowired
private Flyway flyway;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void whenContextStarts_thenAllMigrationsAreApplied() {
var current = flyway.info().current();
assertThat(current).isNotNull();
assertThat(current.getVersion().getVersion()).isEqualTo("4");
assertThat(flyway.info().pending()).isEmpty();
}
@Test
void givenNewCustomer_whenInsert_thenEmailColumnExists() {
jdbcTemplate.update(
"INSERT INTO customer (full_name, email) VALUES (?, ?)",
"Trần Thị Bình", "binh@example.com");
String email = jdbcTemplate.queryForObject(
"SELECT email FROM customer WHERE full_name = ?",
String.class, "Trần Thị Bình");
assertThat(email).isEqualTo("binh@example.com");
}
}
첫 번째 테스트는 스키마가 버전 4에 도달했고 대기 중인 마이그레이션이 없는지 확인합니다. SQL 파일에 구문 오류가 있으면 context가 시작되지 않고 테스트가 즉시 실패합니다. 두 번째 테스트는 V3의 email 컬럼이 실제로 존재하는지 확인합니다.
주의:
MODE=PostgreSQL모드의 H2는 PostgreSQL 구문만 부분적으로 에뮬레이트합니다. 마이그레이션이JSONB, 부분 인덱스 또는CREATE INDEX CONCURRENTLY같은 고유한 기능을 사용한다면 Testcontainers를 사용하여 실제 PostgreSQL에서 테스트해야 합니다.
7. 일반적인 오류 및 처리
7.1. 실행된 마이그레이션 수정
이것이 가장 흔한 오류입니다. V2__create_orders_table.sql을 staging에서 실행된 후에 수정했다고 가정해보세요. 다음 시작 시 다음과 같은 오류가 발생합니다:
Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 2
-> Applied to database : 873416502
-> Resolved locally : -402981177
규칙은: 공유되는 어떤 환경에서든 한 번 실행된 마이그레이션은 수정하면 안 된다는 것입니다. 변경하려면 새 마이그레이션을 만드세요. 예를 들어 V5__alter_orders_status_length.sql처럼요.
변경이 주석이나 공백만이고 스키마가 실제로 변경되지 않았다면 확신할 수 있다면 repair 명령으로 checksum을 업데이트할 수 있습니다. Flyway Maven plugin의 예:
mvn flyway:repair -Dflyway.url=jdbc:postgresql://localhost:5432/shop \
-Dflyway.user=shop -Dflyway.password=secret
repair는 또한 실패한 마이그레이션 레코드를 flyway_schema_history에서 삭제합니다. 이것은 MySQL에서 특히 중요한데, MySQL은 트랜잭션 내에서 DDL을 롤백할 수 없기 때문입니다.
7.2. 팀 작업 중 버전 중복
두 개발자가 서로 다른 브랜치에서 V5__...를 만들면 merge 후 Found more than one migration with version 5 오류가 발생합니다.
두 가지 방법으로 예방할 수 있습니다:
- 타임스탐프 기반 버전을 사용하세요. 예:
V20261016_0930__add_order_note.sql - 파일을 만들기 전에 팀에서 버전을 “예약”하도록 조율하세요.
또 다른 경우가 있습니다: 브랜치 A가 V7을 먼저 merge하고 그 후 브랜치 B가 V6을 merge합니다. Flyway는 V6이 무시되었다고 보고합니다(ignored). spring.flyway.out-of-order=true를 활성화할 수 있지만, 새 파일의 버전을 현재 버전보다 크게 변경하는 것이 더 좋습니다.
7.3. 기존 데이터베이스에 Flyway 적용
프로덕션 데이터베이스에 이미 테이블은 있지만 flyway_schema_history가 없으면 Flyway는 스키마가 비어있지 않다며 실행을 거부합니다. 이 경우 baseline을 사용합니다:
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1
Flyway는 현재 스키마를 버전 1로 표시합니다. 그 후 버전 1보다 큰 마이그레이션만 실행합니다. 이때 V1은 현재 스키마의 dump여야 하므로 새 환경에서도 처음부터 구축할 수 있습니다.
주의:
baseline-on-migrate은 처음 마이그레이션할 때만 활성화하세요. 오래 활성화 상태로 두었다가 실수로 이미 테이블이 있는 다른 데이터베이스를 가리키면 Flyway는 오류를 보고하는 대신 baseline을 진행해버립니다.
7.4. clean 명령
flyway clean은 스키마의 모든 객체를 삭제합니다. Spring Boot 3은 기본적으로 spring.flyway.clean-disabled=true로 설정되어 있습니다. 프로덕션에 접근할 수 있는 어떤 프로필에서도 이 플래그를 비활성화하지 마세요.
7.5. 오래 걸리는 마이그레이션
Spring Boot는 시작 시 마이그레이션을 실행합니다. 수천만 행의 테이블에 대한 ALTER TABLE은 테이블을 잠금 상태로 만들고 헬스 체크를 실패하게 할 수 있습니다. 이 경우 다음을 고려하세요:
- 변경을 여러 단계로 나누세요: nullable 컬럼 추가, 배치로 backfill, 그 다음
NOT NULL추가. spring.flyway.enabled=false로 자동 마이그레이션을 비활성화하고 애플리케이션 배포 전 Flyway를 배포 파이프라인의 별도 단계로 실행하세요.
8. Flyway를 기본값대로 사용하면 안 되는 경우
Flyway는 대부분의 Spring Boot 애플리케이션에 매우 적합합니다. 하지만 ‘시작 시 마이그레이션’ 방식에는 한계가 있습니다:
- 여러 인스턴스 동시 실행: Flyway는 데이터베이스 잠금을 사용하므로 마이그레이션이 중복 실행되지 않습니다. 하지만 다른 인스턴스들은 대기해야 하고 시작 시간이 증가합니다.
- 애플리케이션 계정에 DDL 권한이 없음: 이는 프로덕션의 일반적인 보안 요구사항입니다. 이 경우 파이프라인에서 별도 계정으로 Flyway를 실행합니다.
- 자동 rollback이 필요함: Community 버전에는 undo 마이그레이션이 없습니다. 실행 취소할 “forward” 마이그레이션을 직접 작성해야 합니다.
9. 결론
이 글에서 Spring Boot 3에 Flyway를 통합했습니다. Versioned 마이그레이션, repeatable 마이그레이션, Java 마이그레이션을 작성하고 JUnit 5와 AssertJ로 테스트했으며, checksum mismatch, 버전 중복, 레거시 데이터베이스 baseline 같은 일반적인 오류를 처리하는 방법을 배웠습니다.
기억해야 할 세 가지 원칙이 있습니다:
- Flyway가 스키마를 변경하는 유일한 곳이며, Hibernate는 오직
validate만 합니다. - 실행된 마이그레이션은 절대 수정하지 마세요.
- 항상 빈 스키마에서 모든 마이그레이션을 실행하는 테스트를 가지세요.