들어가며
"운영 DB 스키마가 개발 환경이랑 달라요." 배포일 아침에 이 말을 들으면 등골이 서늘해집니다. 누군가 ALTER TABLE을 직접 운영 DB에 실행했고, 그 변경이 코드 저장소에는 반영되지 않은 것입니다. 더 큰 문제는 개발자 A가 추가한 컬럼과 개발자 B가 추가한 컬럼이 로컬에서는 각각 잘 동작하는데, 통합하면 스키마가 충돌하는 상황입니다.
3~7년차 백엔드 개발자라면 한 번쯤 겪어보셨을 이 문제의 근본 원인은 DB 스키마 변경을 코드처럼 버전 관리하지 않기 때문입니다. 코드는 Git으로 관리하면서 스키마는 수동으로 관리하면, 환경 간 불일치와 배포 사고가 반복됩니다.
이 글에서는 Java/Spring 진영에서 가장 많이 사용되는 DB 마이그레이션 도구인 Flyway의 설정부터 실무 운영 전략까지 다룹니다. Flyway vs Liquibase 비교, Spring Boot 자동 설정, 멀티 환경 전략, 롤백 방법, 운영 DB 안전 마이그레이션 노하우까지 팀 개발에서 바로 적용할 수 있는 내용으로 구성했습니다.
1. Flyway vs Liquibase 비교
Java 진영의 대표 DB 마이그레이션 도구 두 가지를 비교합니다.
| 항목 | Flyway | Liquibase |
|---|---|---|
| 마이그레이션 방식 | SQL 파일 기반 | XML/YAML/JSON/SQL 다중 포맷 |
| 학습 곡선 | 낮음 (SQL만 알면 됨) | 중간 (DSL 학습 필요) |
| DB 독립성 | 낮음 (DB별 SQL 작성) | 높음 (추상화된 changeset) |
| 롤백 | 유료(Teams 이상) 또는 수동 | 자동 롤백 지원 (무료) |
| Spring Boot 통합 | 기본 내장 | 기본 내장 |
| 커뮤니티 | 매우 활발 | 활발 |
| 사용 기업 | 대다수 Spring Boot 프로젝트 | 멀티 DB 환경 프로젝트 |
언제 어떤 도구를 선택할까
- Flyway 추천: 단일 DB(MySQL, PostgreSQL 등), SQL에 익숙한 팀, Spring Boot 기본 설정으로 빠르게 시작하고 싶을 때
- Liquibase 추천: 여러 종류의 DB를 동시에 지원해야 할 때, 자동 롤백이 필수인 환경, XML/YAML 기반 선언적 관리를 선호할 때
대부분의 Spring Boot 프로젝트에서는 Flyway가 더 직관적이고 관리가 쉽습니다. 이 글에서는 Flyway에 집중합니다.
2. Spring Boot에서 Flyway 설정
의존성 추가
// build.gradle (Gradle)
dependencies {
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql' // MySQL 사용 시
// implementation 'org.flywaydb:flyway-database-postgresql' // PostgreSQL 사용 시
}
// pom.xml (Maven)
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
application.yml 설정
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb?useSSL=false&characterEncoding=UTF-8
username: myuser
password: mypassword
flyway:
enabled: true
locations: classpath:db/migration # 마이그레이션 파일 위치
baseline-on-migrate: true # 기존 DB에 Flyway 도입 시
baseline-version: 0 # 베이스라인 버전
validate-on-migrate: true # 마이그레이션 전 검증
out-of-order: false # 순서 외 마이그레이션 허용 여부
table: flyway_schema_history # 히스토리 테이블명
clean-disabled: true # clean 명령 비활성화 (운영 안전)
디렉토리 구조
src/main/resources/
└── db/
└── migration/
├── V1__create_customers_table.sql
├── V2__create_orders_table.sql
├── V3__add_email_to_customers.sql
├── V4__create_order_items_table.sql
└── R__update_views.sql # 반복 마이그레이션
3. 네이밍 규칙
Flyway의 네이밍 규칙은 엄격하며, 이를 지키지 않으면 마이그레이션이 실행되지 않습니다.
버전 마이그레이션 (V__)
-- 형식: V{버전}__{설명}.sql
-- 버전: 숫자, 점(.) 허용
-- 구분자: 언더스코어 2개(__)
-- 설명: 영문, 언더스코어
-- 올바른 예시
V1__create_customers_table.sql
V1.1__add_phone_to_customers.sql
V2__create_orders_table.sql
V3__add_index_on_orders_status.sql
-- 잘못된 예시
V1_create_table.sql -- 언더스코어 1개 (2개 필요)
v1__create_table.sql -- 소문자 v
V1__Create Table.sql -- 공백 포함
1__create_table.sql -- V 접두사 없음
버전 번호 전략
-- 전략 1: 순차 번호 (소규모 팀)
V1__create_users.sql
V2__create_orders.sql
V3__add_email_to_users.sql
-- 전략 2: 타임스탬프 기반 (대규모 팀, 충돌 방지)
V20250401120000__create_users.sql
V20250401130000__create_orders.sql
V20250402090000__add_email_to_users.sql
-- 전략 3: 의미적 버전 (릴리즈 단위 관리)
V1.0.1__create_users.sql
V1.0.2__create_orders.sql
V1.1.0__add_payment_module.sql
V2.0.0__major_refactoring.sql
팀 규모가 5명 이상이라면 타임스탬프 기반 전략을 권장합니다. 여러 개발자가 동시에 마이그레이션을 작성해도 버전 번호가 충돌하지 않기 때문입니다.
반복 마이그레이션 (R__)
반복 마이그레이션은 매번 실행되는 스크립트로, 뷰(View), 함수(Function), 프로시저(Procedure) 관리에 적합합니다.
-- R__create_order_summary_view.sql
-- 파일 내용이 변경될 때마다 다시 실행됨 (체크섬 기반)
CREATE OR REPLACE VIEW order_summary AS
SELECT
c.id AS customer_id,
c.name AS customer_name,
COUNT(o.order_id) AS total_orders,
SUM(o.total_amount) AS total_amount,
MAX(o.created_at) AS last_order_at
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name;
-- R__create_utility_functions.sql
DELIMITER //
CREATE FUNCTION IF NOT EXISTS calculate_discount(
total_amount DECIMAL(10,2),
grade VARCHAR(20)
) RETURNS DECIMAL(10,2)
DETERMINISTIC
BEGIN
RETURN CASE grade
WHEN 'VIP' THEN total_amount * 0.10
WHEN 'GOLD' THEN total_amount * 0.05
ELSE 0
END;
END //
DELIMITER ;
4. 마이그레이션 스크립트 작성 실전
테이블 생성
-- V1__create_customers_table.sql
CREATE TABLE customers (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
phone VARCHAR(20),
grade VARCHAR(20) NOT NULL DEFAULT 'NORMAL',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_customers_email (email),
INDEX idx_customers_grade (grade)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- V2__create_orders_table.sql
CREATE TABLE orders (
order_id BIGINT AUTO_INCREMENT PRIMARY KEY,
customer_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
total_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_orders_customer (customer_id),
INDEX idx_orders_status_created (status, created_at),
CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id)
REFERENCES customers(id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
컬럼 추가/수정 (안전한 방법)
-- V3__add_address_to_customers.sql
-- 1단계: NULL 허용 컬럼으로 추가 (기존 행에 영향 없음)
ALTER TABLE customers
ADD COLUMN address VARCHAR(500) NULL AFTER phone;
-- V4__add_default_to_address.sql
-- 2단계: 데이터 채운 후 NOT NULL로 변경 (필요 시)
UPDATE customers SET address = '' WHERE address IS NULL;
ALTER TABLE customers
MODIFY COLUMN address VARCHAR(500) NOT NULL DEFAULT '';
-- 주의: 대용량 테이블에서 ALTER TABLE은 락 발생 가능
-- MySQL 8.0에서도 일부 ALTER는 테이블 재구성 필요
-- pt-online-schema-change 또는 gh-ost 사용 검토
5. Flyway 콜백(Callback)
Flyway는 마이그레이션 생명주기의 특정 시점에 콜백을 실행할 수 있습니다.
SQL 콜백
-- 디렉토리 구조
src/main/resources/db/
├── migration/
│ ├── V1__create_tables.sql
│ └── V2__add_columns.sql
└── callback/
├── beforeMigrate.sql # 마이그레이션 시작 전
├── afterMigrate.sql # 마이그레이션 완료 후
├── beforeEachMigrate.sql # 각 마이그레이션 실행 전
└── afterEachMigrate.sql # 각 마이그레이션 실행 후
-- afterMigrate.sql 예시: 마이그레이션 후 통계 갱신
ANALYZE TABLE customers;
ANALYZE TABLE orders;
Java 콜백
@Component
public class FlywayCallbackConfig implements Callback {
private static final Logger log = LoggerFactory.getLogger(FlywayCallbackConfig.class);
@Override
public boolean supports(Event event, Context context) {
return event == Event.AFTER_MIGRATE || event == Event.AFTER_MIGRATE_ERROR;
}
@Override
public boolean canHandleInTransaction(Event event, Context context) {
return true;
}
@Override
public void handle(Event event, Context context) {
if (event == Event.AFTER_MIGRATE) {
log.info("Flyway 마이그레이션 완료. 현재 버전: {}",
context.getMigrationInfo() != null ?
context.getMigrationInfo().getVersion() : "N/A");
// Slack 알림 발송, 메트릭 기록 등
} else if (event == Event.AFTER_MIGRATE_ERROR) {
log.error("Flyway 마이그레이션 실패!");
// PagerDuty 알림, 롤백 트리거 등
}
}
@Override
public String getCallbackName() {
return "FlywayCallbackConfig";
}
}
6. 멀티 환경(dev/staging/prod) 전략
프로파일별 설정
# application-dev.yml
spring:
flyway:
locations: classpath:db/migration,classpath:db/seed # 시드 데이터 포함
clean-disabled: false # 개발 환경에서는 clean 허용
baseline-on-migrate: true
# application-staging.yml
spring:
flyway:
locations: classpath:db/migration
clean-disabled: true
validate-on-migrate: true
out-of-order: false
# application-prod.yml
spring:
flyway:
locations: classpath:db/migration
clean-disabled: true # 절대 clean 불가
validate-on-migrate: true
out-of-order: false
# 운영에서는 자동 마이그레이션 비활성화 고려
# enabled: false # CI/CD에서 별도 실행
시드 데이터 관리
-- src/main/resources/db/seed/V1000__seed_test_data.sql
-- dev 환경에서만 실행되는 테스트 데이터
INSERT INTO customers (name, email, grade) VALUES
('테스트 사용자1', 'test1@example.com', 'VIP'),
('테스트 사용자2', 'test2@example.com', 'GOLD'),
('테스트 사용자3', 'test3@example.com', 'NORMAL');
INSERT INTO orders (customer_id, status, total_amount) VALUES
(1, 'COMPLETED', 150000),
(1, 'PENDING', 35000),
(2, 'COMPLETED', 89000);
CI/CD 파이프라인 통합
# .github/workflows/deploy.yml
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Flyway Migration
run: |
./gradlew flywayInfo -Dflyway.url=${{ secrets.DB_URL }} \
-Dflyway.user=${{ secrets.DB_USER }} \
-Dflyway.password=${{ secrets.DB_PASSWORD }}
# Dry-run: 적용할 마이그레이션 확인
./gradlew flywayValidate -Dflyway.url=${{ secrets.DB_URL }} \
-Dflyway.user=${{ secrets.DB_USER }} \
-Dflyway.password=${{ secrets.DB_PASSWORD }}
# 실제 마이그레이션 실행
./gradlew flywayMigrate -Dflyway.url=${{ secrets.DB_URL }} \
-Dflyway.user=${{ secrets.DB_USER }} \
-Dflyway.password=${{ secrets.DB_PASSWORD }}
deploy:
needs: migrate # 마이그레이션 성공 후 배포
runs-on: ubuntu-latest
steps:
- name: Deploy Application
run: |
# 마이그레이션 완료 후 애플리케이션 배포
./deploy.sh
7. 롤백 전략
Flyway Community Edition은 자동 롤백을 지원하지 않습니다. 하지만 실무에서 사용할 수 있는 롤백 전략이 있습니다.
전략 1: 보상 마이그레이션 (Forward-only Rollback)
-- V5에서 문제가 발생했을 때, V6으로 되돌리기
-- V5__add_discount_column.sql (문제 발생)
ALTER TABLE orders ADD COLUMN discount_rate DECIMAL(5,2) DEFAULT 0;
-- V6__rollback_discount_column.sql (보상 마이그레이션)
ALTER TABLE orders DROP COLUMN discount_rate;
-- 항상 앞으로만 진행 (버전을 되돌리지 않음)
전략 2: 안전한 마이그레이션 패턴
-- 컬럼 이름 변경 시: 한 번에 하지 말고 단계별로
-- V5: 새 컬럼 추가
ALTER TABLE customers ADD COLUMN full_name VARCHAR(200);
-- V6: 데이터 복사
UPDATE customers SET full_name = name;
-- V7: 애플리케이션 코드가 full_name을 사용하도록 변경 + 배포
-- (이 단계는 코드 변경이므로 SQL이 아님)
-- V8: 이전 컬럼 삭제 (코드 배포 완료 확인 후)
ALTER TABLE customers DROP COLUMN name;
-- 이렇게 하면 각 단계에서 문제가 발생해도
-- 이전 상태와 호환되므로 안전하게 롤백 가능
전략 3: DB 백업 + 복원
# 마이그레이션 전 백업 (CI/CD에서 자동화)
#!/bin/bash
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="backup_before_migration_${TIMESTAMP}.sql"
# 스키마 + 데이터 백업
mysqldump -h $DB_HOST -u $DB_USER -p$DB_PASS \
--single-transaction --routines --triggers \
$DB_NAME > $BACKUP_FILE
# S3에 업로드
aws s3 cp $BACKUP_FILE s3://my-db-backups/
# Flyway 마이그레이션 실행
flyway -url=$DB_URL -user=$DB_USER -password=$DB_PASS migrate
# 실패 시 복원
if [ $? -ne 0 ]; then
echo "마이그레이션 실패! 백업에서 복원합니다."
mysql -h $DB_HOST -u $DB_USER -p$DB_PASS $DB_NAME < $BACKUP_FILE
fi
8. Baseline과 Cherry Pick
기존 DB에 Flyway 도입하기 (Baseline)
# 기존 운영 DB가 있는 상태에서 Flyway를 처음 도입할 때
# Step 1: 현재 스키마를 V1으로 덤프
mysqldump -h prod-db -u admin --no-data mydb > V1__baseline.sql
# Step 2: Flyway baseline 실행
# 이미 적용된 것으로 간주할 버전 설정
flyway -url=jdbc:mysql://prod-db/mydb \
-user=admin -password=secret \
-baselineVersion=1 \
-baselineDescription="Baseline existing schema" \
baseline
# Step 3: 이후 변경사항은 V2부터 작성
# V2__add_new_feature.sql
Cherry Pick (Flyway Teams 기능 대안)
# Flyway Community에서 Cherry Pick이 필요한 경우
# 특정 마이그레이션만 선택적으로 적용
# 방법 1: out-of-order 활성화
spring:
flyway:
out-of-order: true # 순서와 관계없이 누락된 마이그레이션 적용
# 방법 2: 핫픽스 마이그레이션
# 긴급 패치가 필요한 경우 현재 버전 사이에 마이그레이션 추가
# V10이 최신이고 V7과 V8 사이에 핫픽스가 필요한 경우:
# → V7.1__hotfix_add_index.sql 생성 + out-of-order=true
# 방법 3: 조건부 마이그레이션 (프로그래매틱)
@Bean
public FlywayMigrationStrategy flywayMigrationStrategy() {
return flyway -> {
// 특정 조건에서만 마이그레이션 실행
MigrationInfoService info = flyway.info();
MigrationInfo[] pending = info.pending();
log.info("적용 대기 중인 마이그레이션: {}개", pending.length);
for (MigrationInfo migration : pending) {
log.info(" - {} : {}", migration.getVersion(), migration.getDescription());
}
flyway.migrate();
};
}
9. 실무 주의사항: 운영 DB 마이그레이션 안전하게
체크리스트
- 백업 필수: 마이그레이션 전 반드시 DB 백업. 스냅샷 또는 mysqldump
- 스테이징에서 먼저 실행: 운영과 동일한 데이터 규모의 스테이징에서 테스트
- 락 타임아웃 확인: ALTER TABLE이 대용량 테이블에서 얼마나 걸리는지 사전 측정
- 피크 시간대 피하기: 새벽 시간대 또는 트래픽이 적은 시간에 실행
- 모니터링 준비: 마이그레이션 중 DB 커넥션, 락 상태, 슬로우 쿼리 모니터링
대용량 테이블 마이그레이션
-- 1000만 행 이상 테이블에 컬럼 추가 시
-- MySQL 8.0의 Instant DDL 활용 (지원되는 경우)
ALTER TABLE large_table
ADD COLUMN new_col VARCHAR(100) DEFAULT NULL,
ALGORITHM=INSTANT; -- 메타데이터만 변경, 즉시 완료
-- Instant DDL이 지원되지 않는 경우 (NOT NULL, 인덱스 추가 등)
-- pt-online-schema-change 사용
pt-online-schema-change \
--alter "ADD COLUMN new_col VARCHAR(100) NOT NULL DEFAULT ''" \
--host=prod-db \
--user=admin \
--ask-pass \
--execute \
D=mydb,t=large_table
# pt-online-schema-change는:
# 1. 새 테이블 생성 (변경된 스키마)
# 2. 트리거로 실시간 동기화
# 3. 데이터 복사
# 4. 원자적 테이블 교체
# → 서비스 중단 없이 스키마 변경 가능
마이그레이션 실패 시 대응
-- Flyway 마이그레이션이 실패하면 해당 버전이 'failed' 상태로 기록됨
-- 이 상태에서는 다음 마이그레이션이 실행되지 않음
-- 상태 확인
SELECT * FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 5;
-- 실패한 레코드 삭제 (문제 해결 후)
DELETE FROM flyway_schema_history
WHERE success = 0;
-- 또는 Flyway repair 명령 사용
flyway -url=jdbc:mysql://localhost/mydb \
-user=admin -password=secret \
repair
-- repair는:
-- 1. 실패한 마이그레이션 기록 제거
-- 2. 체크섬 불일치 수정
-- 3. 누락된 마이그레이션 기록 정리
마치며
DB 마이그레이션은 팀 개발에서 반드시 필요한 인프라입니다. Flyway를 도입하면 스키마 변경을 코드처럼 관리할 수 있고, 환경 간 불일치 문제를 근본적으로 해결할 수 있습니다. 핵심 내용을 정리합니다.
- SQL 기반의 직관적 관리: Flyway는 SQL 파일을 작성하는 것만으로 마이그레이션이 됩니다. 학습 곡선이 낮아 팀 전체가 빠르게 적응할 수 있습니다.
- 네이밍 규칙을 팀에서 합의: V{버전}__{설명}.sql 형태를 지키되, 버전 체계(순차/타임스탬프)는 팀 규모에 맞게 선택합니다.
- 환경별 전략을 분리: dev에서는 시드 데이터와 clean 허용, staging에서는 운영과 동일한 조건으로 검증, prod에서는 clean-disabled=true 필수입니다.
- 롤백은 보상 마이그레이션으로: Forward-only 방식이 가장 안전합니다. 컬럼 이름 변경 같은 위험한 작업은 단계별로 나누어 진행합니다.
- 운영 DB는 항상 신중하게: 백업, 스테이징 테스트, 피크 시간대 회피를 반드시 지킵니다. 대용량 테이블은 pt-online-schema-change를 검토합니다.
Flyway 도입의 가장 큰 효과는 "배포일 아침에 스키마 불일치로 고생하는 일"이 사라진다는 것입니다. DB 스키마도 코드와 함께 Git으로 관리하면, 어떤 환경에서든 동일한 스키마가 보장됩니다. 아직 도입하지 않았다면 다음 프로젝트에서 시작해보세요.
'Database' 카테고리의 다른 글
| MySQL 성능 튜닝 실전 - 슬로우 쿼리부터 인덱스 최적화까지 (1) | 2026.04.14 |
|---|---|
| Elasticsearch 입문 - Spring Boot로 검색 엔진 구축하기 (0) | 2026.04.06 |
| MongoDB 실전 가이드 - 도큐먼트 설계부터 인덱싱 전략까지 (0) | 2026.04.06 |
| PostgreSQL 성능 튜닝 완벽 가이드 - 쿼리 최적화부터 파티셔닝까지 (0) | 2026.04.03 |
| 트랜잭션 격리 수준과 동시성 제어 - 실무에서 겪는 문제들 (0) | 2026.03.31 |