Split View: Spring Boot Flyway 완전 가이드: DB 마이그레이션 전략과 실전 패턴
Spring Boot Flyway 완전 가이드: DB 마이그레이션 전략과 실전 패턴
- 1. Flyway란?
- 2. 의존성 및 기본 설정
- 3. 마이그레이션 파일 네이밍 규칙
- 4. 실전 마이그레이션 예제
- 5. Java 기반 마이그레이션
- 6. 멀티 환경 설정
- 7. 테스트 전략
- 8. 운영 환경 주의사항
- 9. CI/CD 통합
- 10. 자주 발생하는 문제와 해결책
- 퀴즈: Flyway 지식 점검
1. Flyway란?
Flyway는 데이터베이스 스키마의 버전 관리를 자동화하는 오픈소스 마이그레이션 도구입니다. 애플리케이션 코드와 DB 스키마 변경을 함께 추적하고, 팀원 모두가 동일한 DB 상태를 유지할 수 있도록 돕습니다.
Flyway vs Liquibase 비교
| 항목 | Flyway | Liquibase |
|---|---|---|
| 마이그레이션 형식 | SQL 또는 Java | XML, YAML, JSON, SQL |
| 학습 곡선 | 낮음 | 보통 |
| 롤백 지원 | 유료(Teams) | 무료(내장) |
| Spring Boot 통합 | 자동 설정 | 자동 설정 |
| 커뮤니티 | 매우 활발 | 활발 |
Flyway는 SQL 중심의 단순한 접근 방식과 낮은 학습 곡선으로 많은 Spring Boot 프로젝트에서 선호됩니다.
flyway_schema_history 테이블
Flyway는 flyway_schema_history 테이블을 통해 마이그레이션 실행 이력을 관리합니다. 각 마이그레이션의 버전, 체크섬, 실행 시각, 성공 여부를 기록합니다.
-- flyway_schema_history 테이블 구조 예시
SELECT installed_rank, version, description, type, script, checksum, installed_on, success
FROM flyway_schema_history
ORDER BY installed_rank;
2. 의존성 및 기본 설정
Maven 의존성
<!-- Maven pom.xml -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- MySQL 사용 시 추가 -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<!-- PostgreSQL은 flyway-core만으로 충분 -->
Gradle 의존성
// build.gradle
dependencies {
implementation 'org.flywaydb:flyway-core'
// MySQL 사용 시
implementation 'org.flywaydb:flyway-mysql'
}
application.yml 기본 설정
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: myuser
password: mypassword
driver-class-name: org.postgresql.Driver
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
validate-on-migrate: true
out-of-order: false
table: flyway_schema_history
encoding: UTF-8
connect-retries: 3
주요 설정 항목 설명:
enabled: Flyway 자동 실행 활성화 여부locations: 마이그레이션 스크립트 위치 (복수 지정 가능)baseline-on-migrate: 기존 DB에 Flyway를 처음 적용할 때 현재 상태를 베이스라인으로 설정validate-on-migrate: 마이그레이션 전 체크섬 검증out-of-order: 버전 순서 무시 허용 여부 (기본값 false)table: 히스토리 테이블 이름 커스터마이즈
3. 마이그레이션 파일 네이밍 규칙
마이그레이션 타입별 규칙
Versioned Migration (버전 마이그레이션):
V{version}__{description}.sql
예시:
V1__create_users_table.sqlV2__add_email_index.sqlV1.2.3__fix_user_status_column.sql
Undo Migration (실행 취소, Flyway Teams 전용):
U{version}__{description}.sql
예시:
U2__add_email_index.sql
Repeatable Migration (반복 실행 가능):
R__{description}.sql
예시:
R__create_views.sqlR__update_stored_procedures.sql
네이밍 규칙 상세
- 버전 구분자는 숫자와 점/언더스코어 허용:
V1,V1.1,V1_1모두 유효 - 설명 구분자는 언더스코어 2개 (
__) - 설명 내 언더스코어(
_)는 공백으로 표시됨 - 대소문자 구분:
V는 대문자 필수
src/main/resources/
└── db/
└── migration/
├── V1__init_schema.sql
├── V2__add_user_status.sql
├── V3__insert_seed_data.sql
├── V4__add_indexes.sql
└── R__create_reporting_views.sql
4. 실전 마이그레이션 예제
V1__init_schema.sql - 초기 스키마 생성
-- V1__init_schema.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL CHECK (price >= 0),
stock INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
total DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
V2__add_user_status.sql - 컬럼 추가
-- V2__add_user_status.sql
ALTER TABLE users
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
ADD COLUMN last_login TIMESTAMP,
ADD COLUMN phone_number VARCHAR(20);
-- 기존 데이터에 기본값 적용 (이미 DEFAULT로 설정되어 있지만 명시적으로)
UPDATE users SET status = 'ACTIVE' WHERE status IS NULL;
V3__insert_seed_data.sql - 초기 데이터 삽입
-- V3__insert_seed_data.sql
INSERT INTO products (name, description, price, stock) VALUES
('노트북', '고성능 개발자용 노트북', 1500000, 50),
('마우스', '무선 에르고노믹 마우스', 80000, 200),
('키보드', '기계식 RGB 키보드', 120000, 150);
-- 관리자 계정 생성
INSERT INTO users (username, email, password, status) VALUES
('admin', 'admin@example.com', '$2a$10$hashedpassword', 'ACTIVE');
V4__add_indexes.sql - 인덱스 최적화
-- V4__add_indexes.sql
-- 자주 조회되는 컬럼에 인덱스 추가
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_status ON users(status);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created ON orders(created_at DESC);
-- 복합 인덱스
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
5. Java 기반 마이그레이션
SQL만으로 처리하기 어려운 복잡한 데이터 변환은 Java 마이그레이션으로 구현합니다.
BaseJavaMigration 구현
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.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
/**
* V5__MigrateUserData
* 기존 users 테이블의 full_name 컬럼을 first_name, last_name으로 분리
*/
public class V5__MigrateUserData extends BaseJavaMigration {
@Override
public void migrate(Context context) throws Exception {
JdbcTemplate jdbcTemplate = new JdbcTemplate(
new SingleConnectionDataSource(context.getConnection(), true)
);
// 1단계: full_name 컬럼이 있는 경우에만 마이그레이션 실행
List<String[]> users = new ArrayList<>();
try (Statement stmt = context.getConnection().createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT id, full_name FROM users WHERE full_name IS NOT NULL")) {
while (rs.next()) {
long id = rs.getLong("id");
String fullName = rs.getString("full_name");
String[] parts = fullName.split(" ", 2);
users.add(new String[]{
String.valueOf(id),
parts[0],
parts.length > 1 ? parts[1] : ""
});
}
}
// 2단계: 분리된 이름 저장
for (String[] user : users) {
jdbcTemplate.update(
"UPDATE users SET first_name = ?, last_name = ? WHERE id = ?",
user[1], user[2], Long.parseLong(user[0])
);
}
// 3단계: 마이그레이션 결과 로깅
System.out.printf("Migrated %d user records%n", users.size());
}
}
Spring Bean 주입이 필요한 경우
package db.migration;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class V6__EncryptSensitiveData extends BaseJavaMigration {
// 주의: Spring Bean 주입을 위해 @Component 필요
// FlywayAutoConfiguration에서 JavaMigration Bean을 자동 인식
@Autowired
private EncryptionService encryptionService;
@Override
public void migrate(Context context) throws Exception {
// encryptionService 사용 가능
var jdbcTemplate = new org.springframework.jdbc.core.JdbcTemplate(
new org.springframework.jdbc.datasource.SingleConnectionDataSource(
context.getConnection(), true)
);
jdbcTemplate.query(
"SELECT id, phone_number FROM users WHERE phone_number IS NOT NULL",
(rs) -> {
long id = rs.getLong("id");
String phone = rs.getString("phone_number");
String encrypted = encryptionService.encrypt(phone);
jdbcTemplate.update(
"UPDATE users SET phone_number = ? WHERE id = ?",
encrypted, id
);
}
);
}
}
6. 멀티 환경 설정
Spring Profile 별 Flyway 설정
# application.yml (공통)
spring:
flyway:
enabled: true
locations: classpath:db/migration
---
# application-dev.yml
spring:
config:
activate:
on-profile: dev
flyway:
locations:
- classpath:db/migration
- classpath:db/migration/dev
clean-on-validation-error: true # 개발 환경에서만 허용
---
# application-test.yml
spring:
config:
activate:
on-profile: test
flyway:
locations:
- classpath:db/migration
- classpath:db/migration/test
clean-disabled: false # 테스트 전 DB 초기화 허용
---
# application-prod.yml
spring:
config:
activate:
on-profile: prod
flyway:
locations: classpath:db/migration
clean-disabled: true # 운영 환경에서 clean 명령 비활성화
out-of-order: false
validate-on-migrate: true
벤더별 마이그레이션 분기
spring:
flyway:
locations:
- classpath:db/migration
- classpath:db/migration/{vendor}
db/migration/
├── V1__create_tables.sql # 공통
├── V2__add_indexes.sql # 공통
├── postgresql/
│ └── V3__add_pg_specific.sql # PostgreSQL 전용
└── mysql/
└── V3__add_mysql_specific.sql # MySQL 전용
Java Config를 통한 세밀한 설정
@Configuration
public class FlywayConfig {
@Bean
public FlywayMigrationStrategy migrationStrategy() {
return flyway -> {
// 마이그레이션 전 커스텀 로직
System.out.println("Starting Flyway migration...");
flyway.migrate();
System.out.println("Flyway migration completed.");
};
}
@Bean
@Profile("!prod")
public FlywayMigrationStrategy devMigrationStrategy() {
return flyway -> {
// 개발 환경: 스키마 초기화 후 재실행
flyway.clean();
flyway.migrate();
};
}
}
7. 테스트 전략
@FlywayTest 어노테이션 활용
<!-- flywaydb-test 라이브러리 -->
<dependency>
<groupId>org.flywaydb.flyway-test-extensions</groupId>
<artifactId>flyway-spring-test</artifactId>
<version>9.5.0</version>
<scope>test</scope>
</dependency>
@SpringBootTest
@FlywayTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
@FlywayTest(locationsForMigrate = {"classpath:db/migration/test"})
void testUserCreation() {
User user = new User("testuser", "test@example.com");
User saved = userRepository.save(user);
assertThat(saved.getId()).isNotNull();
}
}
H2 인메모리 DB 테스트
# application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
flyway:
enabled: true
locations: classpath:db/migration
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Test
void findByStatus_returnsActiveProducts() {
List<Product> products = productRepository.findByStatus("ACTIVE");
assertThat(products).isNotEmpty();
}
}
TestContainers + Flyway (권장)
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class IntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass");
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
void flywayMigration_appliesAllScripts() {
// Flyway가 적용된 실제 PostgreSQL 컨테이너에서 테스트
assertThat(userRepository.count()).isGreaterThanOrEqualTo(0);
}
}
8. 운영 환경 주의사항
컬럼 삭제 3단계 전략
운영 중인 시스템에서 컬럼을 즉시 삭제하면 배포 중 애플리케이션 오류가 발생할 수 있습니다. 3단계 접근법을 권장합니다.
1단계: 컬럼 NULL 허용으로 변경 (배포 N)
-- V10__make_old_column_nullable.sql
ALTER TABLE users ALTER COLUMN old_field DROP NOT NULL;
2단계: 애플리케이션 코드에서 해당 컬럼 참조 제거 (배포 N+1)
- Java 엔티티, 쿼리, 리포지토리에서 참조 삭제
3단계: 컬럼 실제 삭제 (배포 N+2)
-- V12__drop_old_column.sql
ALTER TABLE users DROP COLUMN IF EXISTS old_field;
PostgreSQL 대용량 인덱스 생성 전략
-- V15__add_large_index.sql
-- CONCURRENTLY 옵션: 테이블 잠금 없이 인덱스 생성 (운영 환경 권장)
-- 주의: Flyway 트랜잭션 내에서는 CONCURRENTLY 사용 불가
-- Java 마이그레이션이나 별도 스크립트로 실행 필요
CREATE INDEX CONCURRENTLY idx_orders_large ON orders(created_at, status);
Flyway 트랜잭션 외부에서 실행하기 위한 Java 마이그레이션:
public class V15__AddLargeIndexConcurrently extends BaseJavaMigration {
@Override
public boolean canExecuteInTransaction() {
return false; // 트랜잭션 외부 실행
}
@Override
public void migrate(Context context) throws Exception {
try (Statement stmt = context.getConnection().createStatement()) {
stmt.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS " +
"idx_orders_large ON orders(created_at, status)"
);
}
}
}
대용량 테이블 마이그레이션 전략
-- V16__migrate_large_table.sql
-- 1. 새 컬럼 추가 (NULL 허용)
ALTER TABLE orders ADD COLUMN new_status_code INTEGER;
-- 2. 배치 업데이트 (한 번에 1000건씩)
-- 이 방식은 Flyway SQL에서 직접 사용 어려우므로 Java 마이그레이션 권장
public class V16__BatchMigrateLargeTable extends BaseJavaMigration {
private static final int BATCH_SIZE = 1000;
@Override
public void migrate(Context context) throws Exception {
JdbcTemplate jdbc = new JdbcTemplate(
new SingleConnectionDataSource(context.getConnection(), true)
);
long maxId = jdbc.queryForObject("SELECT MAX(id) FROM orders", Long.class);
long processedCount = 0;
for (long offset = 0; offset <= maxId; offset += BATCH_SIZE) {
final long batchOffset = offset;
int updated = jdbc.update(
"UPDATE orders SET new_status_code = CASE status " +
"WHEN 'PENDING' THEN 1 WHEN 'COMPLETED' THEN 2 ELSE 0 END " +
"WHERE id > ? AND id <= ? AND new_status_code IS NULL",
batchOffset, batchOffset + BATCH_SIZE
);
processedCount += updated;
System.out.printf("Batch progress: %d records updated%n", processedCount);
}
}
}
flyway repair 명령
마이그레이션 실패 후 히스토리를 복구할 때 사용합니다.
# Maven
./mvnw flyway:repair
# Gradle
./gradlew flywayRepair
# CLI
flyway -url=jdbc:postgresql://localhost:5432/mydb \
-user=myuser \
-password=mypassword \
repair
// 애플리케이션 코드에서 repair 실행
@Autowired
private Flyway flyway;
public void repairMigration() {
flyway.repair();
}
9. CI/CD 통합
GitHub Actions에서 Flyway 검증
# .github/workflows/flyway-validate.yml
name: Flyway Validation
on:
pull_request:
paths:
- 'src/main/resources/db/migration/**'
- 'src/main/java/db/migration/**'
jobs:
flyway-validate:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Validate Flyway migrations
run: |
./mvnw flyway:validate \
-Dflyway.url=jdbc:postgresql://localhost:5432/testdb \
-Dflyway.user=testuser \
-Dflyway.password=testpass
- name: Run migration
run: |
./mvnw flyway:migrate \
-Dflyway.url=jdbc:postgresql://localhost:5432/testdb \
-Dflyway.user=testuser \
-Dflyway.password=testpass
- name: Verify migration info
run: |
./mvnw flyway:info \
-Dflyway.url=jdbc:postgresql://localhost:5432/testdb \
-Dflyway.user=testuser \
-Dflyway.password=testpass
프로덕션 배포 전 검증 스크립트
#!/bin/bash
# scripts/pre-deploy-flyway-check.sh
set -e
echo "Running Flyway validation against production database..."
./mvnw flyway:validate \
-Dflyway.url="${PROD_DB_URL}" \
-Dflyway.user="${PROD_DB_USER}" \
-Dflyway.password="${PROD_DB_PASSWORD}" \
-Dflyway.cleanDisabled=true
echo "Flyway validation passed. Safe to deploy."
# Docker Compose 개발 환경
version: '3.8'
services:
app:
build: .
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/appdb
SPRING_FLYWAY_ENABLED: 'true'
depends_on:
db:
condition: service_healthy
db:
image: postgres:15
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: apppass
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U appuser -d appdb']
interval: 5s
timeout: 5s
retries: 5
10. 자주 발생하는 문제와 해결책
체크섬 불일치 오류
ERROR: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 3
원인: 이미 적용된 마이그레이션 파일을 수정한 경우
해결책:
# 수정된 파일의 체크섬으로 히스토리 업데이트
./mvnw flyway:repair
또는 새로운 마이그레이션 파일로 수정 내용 적용:
-- V3_1__fix_incorrect_v3_migration.sql
-- V3의 잘못된 부분을 수정하는 새 마이그레이션
ALTER TABLE users MODIFY COLUMN email VARCHAR(500);
베이스라인 설정 방법
기존 DB에 Flyway를 처음 도입할 때:
spring:
flyway:
baseline-on-migrate: true
baseline-version: 1 # 현재 상태를 V1로 베이스라인 설정
baseline-description: 'Initial baseline'
# CLI로 베이스라인 설정
flyway -url=jdbc:postgresql://localhost:5432/mydb \
-user=myuser \
-password=mypassword \
baseline
퀴즈: Flyway 지식 점검
Q1. Flyway의 flyway_schema_history 테이블은 어떤 역할을 하나요?
정답: 마이그레이션 실행 이력을 추적하는 메타데이터 테이블입니다.
설명: flyway_schema_history 테이블은 적용된 각 마이그레이션 스크립트의 버전, 파일명, 체크섬(파일 변경 감지용), 실행 시각, 성공 여부 등을 저장합니다. Flyway는 이 테이블을 기반으로 어떤 마이그레이션이 이미 실행되었는지 파악하고, 새로 추가된 스크립트만 순서대로 실행합니다. 체크섬을 이용해 기 적용된 스크립트의 변경도 감지합니다.
Q2. baseline-on-migrate 옵션은 언제 사용하나요?
정답: 이미 운영 중인 기존 데이터베이스에 Flyway를 처음 도입할 때 사용합니다.
설명: 기존 DB에 flyway_schema_history 테이블이 없는 상태에서 Flyway를 처음 적용하면 오류가 발생할 수 있습니다. baseline-on-migrate: true로 설정하면 첫 실행 시 현재 DB 상태를 베이스라인으로 설정하고, 이후의 마이그레이션만 적용합니다. 단, 이미 Flyway가 관리 중인 DB에서는 사용하지 않는 것이 좋습니다.
Q3. Repeatable Migration(R__ 접두사)은 어떤 경우에 사용하나요?
정답: 뷰(View), 저장 프로시저(Stored Procedure), 함수처럼 내용이 변경될 때마다 재실행이 필요한 SQL 객체 관리에 사용합니다.
설명: Versioned Migration은 한 번만 실행되지만, Repeatable Migration은 파일의 체크섬이 변경될 때마다 재실행됩니다. 따라서 R__create_views.sql에 뷰 정의를 넣으면, 뷰 내용이 변경될 때 파일만 수정하면 자동으로 재적용됩니다. 단, Repeatable Migration은 항상 Versioned Migration이 모두 실행된 후에 실행됩니다.
Q4. 운영 환경에서 대용량 테이블의 인덱스를 추가할 때 Flyway 트랜잭션 처리를 어떻게 해야 하나요?
정답: canExecuteInTransaction() 메서드를 false로 오버라이드한 Java 마이그레이션을 사용하고, PostgreSQL의 CREATE INDEX CONCURRENTLY를 활용합니다.
설명: PostgreSQL의 CREATE INDEX CONCURRENTLY는 테이블 잠금 없이 인덱스를 생성하지만, 트랜잭션 내에서는 사용할 수 없습니다. Java 마이그레이션에서 canExecuteInTransaction()을 false로 반환하면 Flyway가 해당 마이그레이션을 트랜잭션 외부에서 실행합니다. 이를 통해 운영 중 서비스 중단 없이 안전하게 인덱스를 생성할 수 있습니다.
Q5. 이미 적용된 마이그레이션 파일을 수정했을 때 "checksum mismatch" 오류가 발생하면 어떻게 해결하나요?
정답: flyway repair 명령을 실행하거나, 새로운 버전의 마이그레이션 파일을 추가합니다.
설명: Flyway는 이미 적용된 마이그레이션 파일이 변경되면 체크섬 불일치 오류를 발생시킵니다. 이는 DB 일관성을 보호하기 위한 안전장치입니다. 해결 방법은 두 가지입니다. 첫째, flyway repair로 히스토리 테이블의 체크섬을 현재 파일과 동기화합니다(오직 실수로 파일을 수정한 개발 환경에서만 권장). 둘째, 절대 기존 파일을 수정하지 않고, 수정 내용을 담은 새로운 버전의 마이그레이션 파일을 추가합니다(운영 환경 필수).
Spring Boot Flyway Complete Guide: Database Migration Strategy and Production Patterns
- 1. What is Flyway?
- 2. Dependencies and Basic Configuration
- 3. Migration File Naming Conventions
- 4. Practical Migration Examples
- 5. Java-Based Migrations
- 6. Multi-Environment Configuration
- 7. Testing Strategies
- 8. Production Considerations
- 9. CI/CD Integration
- 10. Common Issues and Solutions
- Quiz: Test Your Flyway Knowledge
1. What is Flyway?
Flyway is an open-source database migration tool that automates schema version control. It keeps DB schema changes in sync with application code changes, ensuring all team members work against a consistent database state.
Flyway vs Liquibase Comparison
| Feature | Flyway | Liquibase |
|---|---|---|
| Migration format | SQL or Java | XML, YAML, JSON, SQL |
| Learning curve | Low | Moderate |
| Rollback support | Paid (Teams) | Free (built-in) |
| Spring Boot integration | Auto-configured | Auto-configured |
| Community | Very active | Active |
Flyway is preferred in many Spring Boot projects for its SQL-centric simplicity and minimal learning curve.
The flyway_schema_history Table
Flyway uses the flyway_schema_history table to track migration execution history, recording each migration's version, checksum, execution time, and success status.
-- Querying flyway_schema_history
SELECT installed_rank, version, description, type, script, checksum, installed_on, success
FROM flyway_schema_history
ORDER BY installed_rank;
2. Dependencies and Basic Configuration
Maven Dependency
<!-- Maven pom.xml -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Required for MySQL -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<!-- PostgreSQL only needs flyway-core -->
Gradle Dependency
// build.gradle
dependencies {
implementation 'org.flywaydb:flyway-core'
// For MySQL
implementation 'org.flywaydb:flyway-mysql'
}
application.yml Basic Configuration
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: myuser
password: mypassword
driver-class-name: org.postgresql.Driver
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
validate-on-migrate: true
out-of-order: false
table: flyway_schema_history
encoding: UTF-8
connect-retries: 3
Key Configuration Options:
enabled: Whether to activate Flyway auto-executionlocations: Migration script location(s), multiple paths allowedbaseline-on-migrate: Treat current state as baseline when applying Flyway to an existing DBvalidate-on-migrate: Verify checksums before migrationout-of-order: Allow applying migrations out of version order (default false)table: Customize the history table name
3. Migration File Naming Conventions
Migration Types
Versioned Migration:
V{version}__{description}.sql
Examples:
V1__create_users_table.sqlV2__add_email_index.sqlV1.2.3__fix_user_status_column.sql
Undo Migration (Flyway Teams only):
U{version}__{description}.sql
Example:
U2__add_email_index.sql
Repeatable Migration:
R__{description}.sql
Examples:
R__create_views.sqlR__update_stored_procedures.sql
Naming Rules in Detail
- Version separators: digits with dots or underscores —
V1,V1.1,V1_1all valid - Description separator: double underscore (
__) - Underscores in description are displayed as spaces
- Case sensitivity:
Vmust be uppercase
src/main/resources/
└── db/
└── migration/
├── V1__init_schema.sql
├── V2__add_user_status.sql
├── V3__insert_seed_data.sql
├── V4__add_indexes.sql
└── R__create_reporting_views.sql
4. Practical Migration Examples
V1__init_schema.sql - Initial Schema Creation
-- V1__init_schema.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL CHECK (price >= 0),
stock INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
total DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
V2__add_user_status.sql - Adding Columns
-- V2__add_user_status.sql
ALTER TABLE users
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
ADD COLUMN last_login TIMESTAMP,
ADD COLUMN phone_number VARCHAR(20);
UPDATE users SET status = 'ACTIVE' WHERE status IS NULL;
V3__insert_seed_data.sql - Initial Data
-- V3__insert_seed_data.sql
INSERT INTO products (name, description, price, stock) VALUES
('Laptop', 'High-performance developer laptop', 1500.00, 50),
('Mouse', 'Wireless ergonomic mouse', 80.00, 200),
('Keyboard', 'Mechanical RGB keyboard', 120.00, 150);
INSERT INTO users (username, email, password, status) VALUES
('admin', 'admin@example.com', '$2a$10$hashedpassword', 'ACTIVE');
V4__add_indexes.sql - Index Optimization
-- V4__add_indexes.sql
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_status ON users(status);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created ON orders(created_at DESC);
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
5. Java-Based Migrations
Complex data transformations that are difficult to express in SQL can be implemented as Java migrations.
Implementing BaseJavaMigration
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.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
/**
* V5__MigrateUserData
* Splits the full_name column in the users table into first_name and last_name
*/
public class V5__MigrateUserData extends BaseJavaMigration {
@Override
public void migrate(Context context) throws Exception {
JdbcTemplate jdbcTemplate = new JdbcTemplate(
new SingleConnectionDataSource(context.getConnection(), true)
);
List<String[]> users = new ArrayList<>();
try (Statement stmt = context.getConnection().createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT id, full_name FROM users WHERE full_name IS NOT NULL")) {
while (rs.next()) {
long id = rs.getLong("id");
String fullName = rs.getString("full_name");
String[] parts = fullName.split(" ", 2);
users.add(new String[]{
String.valueOf(id),
parts[0],
parts.length > 1 ? parts[1] : ""
});
}
}
for (String[] user : users) {
jdbcTemplate.update(
"UPDATE users SET first_name = ?, last_name = ? WHERE id = ?",
user[1], user[2], Long.parseLong(user[0])
);
}
System.out.printf("Migrated %d user records%n", users.size());
}
}
Injecting Spring Beans into Java Migrations
package db.migration;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class V6__EncryptSensitiveData extends BaseJavaMigration {
// @Component enables Spring bean injection
// FlywayAutoConfiguration auto-discovers JavaMigration beans
@Autowired
private EncryptionService encryptionService;
@Override
public void migrate(Context context) throws Exception {
var jdbcTemplate = new org.springframework.jdbc.core.JdbcTemplate(
new org.springframework.jdbc.datasource.SingleConnectionDataSource(
context.getConnection(), true)
);
jdbcTemplate.query(
"SELECT id, phone_number FROM users WHERE phone_number IS NOT NULL",
(rs) -> {
long id = rs.getLong("id");
String phone = rs.getString("phone_number");
String encrypted = encryptionService.encrypt(phone);
jdbcTemplate.update(
"UPDATE users SET phone_number = ? WHERE id = ?",
encrypted, id
);
}
);
}
}
6. Multi-Environment Configuration
Spring Profile-Specific Flyway Settings
# application.yml (shared)
spring:
flyway:
enabled: true
locations: classpath:db/migration
---
# application-dev.yml
spring:
config:
activate:
on-profile: dev
flyway:
locations:
- classpath:db/migration
- classpath:db/migration/dev
clean-on-validation-error: true # only for development
---
# application-test.yml
spring:
config:
activate:
on-profile: test
flyway:
locations:
- classpath:db/migration
- classpath:db/migration/test
clean-disabled: false # allow DB reset before tests
---
# application-prod.yml
spring:
config:
activate:
on-profile: prod
flyway:
locations: classpath:db/migration
clean-disabled: true # prevent clean command in production
out-of-order: false
validate-on-migrate: true
Vendor-Specific Migration Branching
spring:
flyway:
locations:
- classpath:db/migration
- classpath:db/migration/{vendor}
db/migration/
├── V1__create_tables.sql # shared
├── V2__add_indexes.sql # shared
├── postgresql/
│ └── V3__add_pg_specific.sql # PostgreSQL only
└── mysql/
└── V3__add_mysql_specific.sql # MySQL only
Fine-Grained Java Configuration
@Configuration
public class FlywayConfig {
@Bean
public FlywayMigrationStrategy migrationStrategy() {
return flyway -> {
System.out.println("Starting Flyway migration...");
flyway.migrate();
System.out.println("Flyway migration completed.");
};
}
@Bean
@Profile("!prod")
public FlywayMigrationStrategy devMigrationStrategy() {
return flyway -> {
// Development: clean schema then re-migrate
flyway.clean();
flyway.migrate();
};
}
}
7. Testing Strategies
Using @FlywayTest
<dependency>
<groupId>org.flywaydb.flyway-test-extensions</groupId>
<artifactId>flyway-spring-test</artifactId>
<version>9.5.0</version>
<scope>test</scope>
</dependency>
@SpringBootTest
@FlywayTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
@FlywayTest(locationsForMigrate = {"classpath:db/migration/test"})
void testUserCreation() {
User user = new User("testuser", "test@example.com");
User saved = userRepository.save(user);
assertThat(saved.getId()).isNotNull();
}
}
H2 In-Memory Database Testing
# application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
flyway:
enabled: true
locations: classpath:db/migration
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Test
void findByStatus_returnsActiveProducts() {
List<Product> products = productRepository.findByStatus("ACTIVE");
assertThat(products).isNotEmpty();
}
}
TestContainers + Flyway (Recommended)
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class IntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass");
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
void flywayMigration_appliesAllScripts() {
assertThat(userRepository.count()).isGreaterThanOrEqualTo(0);
}
}
8. Production Considerations
Three-Step Column Deletion Strategy
Deleting a column immediately in a live system can cause application errors during rolling deployments. Use the three-step approach:
Step 1: Make column nullable (Deploy N)
-- V10__make_old_column_nullable.sql
ALTER TABLE users ALTER COLUMN old_field DROP NOT NULL;
Step 2: Remove all references in application code (Deploy N+1)
- Remove from JPA entity, queries, and repositories
Step 3: Drop the column (Deploy N+2)
-- V12__drop_old_column.sql
ALTER TABLE users DROP COLUMN IF EXISTS old_field;
PostgreSQL Large Index Creation Strategy
public class V15__AddLargeIndexConcurrently extends BaseJavaMigration {
@Override
public boolean canExecuteInTransaction() {
return false; // execute outside transaction
}
@Override
public void migrate(Context context) throws Exception {
try (Statement stmt = context.getConnection().createStatement()) {
// CONCURRENTLY avoids table locking but cannot run inside a transaction
stmt.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS " +
"idx_orders_large ON orders(created_at, status)"
);
}
}
}
Large Table Batch Migration
public class V16__BatchMigrateLargeTable extends BaseJavaMigration {
private static final int BATCH_SIZE = 1000;
@Override
public void migrate(Context context) throws Exception {
JdbcTemplate jdbc = new JdbcTemplate(
new SingleConnectionDataSource(context.getConnection(), true)
);
Long maxId = jdbc.queryForObject("SELECT MAX(id) FROM orders", Long.class);
if (maxId == null) return;
long processedCount = 0;
for (long offset = 0; offset <= maxId; offset += BATCH_SIZE) {
final long batchOffset = offset;
int updated = jdbc.update(
"UPDATE orders SET new_status_code = CASE status " +
"WHEN 'PENDING' THEN 1 WHEN 'COMPLETED' THEN 2 ELSE 0 END " +
"WHERE id > ? AND id <= ? AND new_status_code IS NULL",
batchOffset, batchOffset + BATCH_SIZE
);
processedCount += updated;
System.out.printf("Progress: %d records updated%n", processedCount);
}
}
}
flyway repair Command
Use this to recover the migration history after a failed migration:
# Maven
./mvnw flyway:repair
# Gradle
./gradlew flywayRepair
# CLI
flyway -url=jdbc:postgresql://localhost:5432/mydb \
-user=myuser \
-password=mypassword \
repair
9. CI/CD Integration
GitHub Actions Flyway Validation
# .github/workflows/flyway-validate.yml
name: Flyway Validation
on:
pull_request:
paths:
- 'src/main/resources/db/migration/**'
- 'src/main/java/db/migration/**'
jobs:
flyway-validate:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Validate Flyway migrations
run: |
./mvnw flyway:validate \
-Dflyway.url=jdbc:postgresql://localhost:5432/testdb \
-Dflyway.user=testuser \
-Dflyway.password=testpass
- name: Run migration
run: |
./mvnw flyway:migrate \
-Dflyway.url=jdbc:postgresql://localhost:5432/testdb \
-Dflyway.user=testuser \
-Dflyway.password=testpass
- name: Verify migration info
run: |
./mvnw flyway:info \
-Dflyway.url=jdbc:postgresql://localhost:5432/testdb \
-Dflyway.user=testuser \
-Dflyway.password=testpass
Pre-Deployment Validation Script
#!/bin/bash
# scripts/pre-deploy-flyway-check.sh
set -e
echo "Running Flyway validation against production database..."
./mvnw flyway:validate \
-Dflyway.url="${PROD_DB_URL}" \
-Dflyway.user="${PROD_DB_USER}" \
-Dflyway.password="${PROD_DB_PASSWORD}" \
-Dflyway.cleanDisabled=true
echo "Flyway validation passed. Safe to deploy."
10. Common Issues and Solutions
Checksum Mismatch Error
ERROR: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 3
Cause: A previously applied migration file was modified.
Solution: Never modify already-applied migration files. Either run flyway repair to sync the checksum in the history table (development only), or create a new migration file with the corrective changes (recommended for all environments).
Baseline Setup for Existing Databases
spring:
flyway:
baseline-on-migrate: true
baseline-version: 1
baseline-description: 'Initial baseline'
# Set baseline via CLI
flyway -url=jdbc:postgresql://localhost:5432/mydb \
-user=myuser \
-password=mypassword \
baseline
Quiz: Test Your Flyway Knowledge
Q1. What role does the flyway_schema_history table play?
Answer: It tracks migration execution history as a metadata table.
Explanation: The flyway_schema_history table stores the version, filename, checksum (for detecting file changes), execution time, and success status of each applied migration script. Flyway uses this table to determine which migrations have already been run and only executes newly added scripts in order. It also uses checksums to detect unauthorized changes to previously applied scripts.
Q2. When should you use the baseline-on-migrate option?
Answer: When introducing Flyway to an existing database that is already in production and has no Flyway history table.
Explanation: When you first apply Flyway to an existing database without a flyway_schema_history table, errors can occur. Setting baseline-on-migrate: true causes Flyway to treat the current DB state as a baseline on first run and only apply subsequent migrations. However, avoid using this option on a database that is already managed by Flyway.
Q3. When should you use Repeatable Migrations (R__ prefix)?
Answer: For managing SQL objects like views, stored procedures, and functions that need to be re-executed whenever their content changes.
Explanation: While Versioned Migrations run only once, Repeatable Migrations re-execute whenever their checksum changes. This means you can put a view definition in R__create_views.sql and just update the file content when the view needs to change — Flyway will automatically re-apply it. Repeatable Migrations always run after all Versioned Migrations have been applied.
Q4. How should you handle Flyway transactions when adding an index to a large table in production?
Answer: Use a Java migration that overrides canExecuteInTransaction() to return false, and use PostgreSQL's CREATE INDEX CONCURRENTLY.
Explanation: PostgreSQL's CREATE INDEX CONCURRENTLY creates an index without locking the table, but cannot be used inside a transaction. By returning false from canExecuteInTransaction() in a Java migration, Flyway executes that migration outside a transaction. This allows you to safely add indexes to production tables without service interruption.
Q5. How do you resolve a "checksum mismatch" error that occurs after modifying an already-applied migration file?
Answer: Run flyway repair or add a new versioned migration file with the corrective changes.
Explanation: Flyway raises a checksum mismatch error when an already-applied migration file is changed — this is a safety guard to protect DB consistency. There are two resolutions. First, run flyway repair to synchronize the checksums in the history table with the current files (recommended only in development when a file was changed by mistake). Second, never modify existing files and instead add a new versioned migration with the corrective changes (mandatory for production environments).