DB 마이그레이션을 안전하게 배포하려면 무엇이 필요한가
Spring Boot + Flyway 자동 설정의 함정부터 대용량 배치 처리, Dark Launch, 실전 케이스 스터디까지 — 프로덕션 마이그레이션의 핵심 패턴을 추적한다.
- 01 스키마도 코드다 — DB 마이그레이션은 왜 필수인가
- 02 Flyway는 어떻게 마이그레이션을 신뢰하는가
- 03 DDL 마이그레이션, 왜 이렇게 어려운가
- 04 DB 마이그레이션은 왜 되돌릴 수 없는가
- 05 마이그레이션 버전 충돌은 왜 배포 직전까지 보이지 않는가
- 06 DB 마이그레이션은 배포 파이프라인의 어디에 있어야 하는가
- 07 DB 마이그레이션을 안전하게 배포하려면 무엇이 필요한가
Spring Boot는 flyway-core가 클래스패스에 있으면 애플리케이션 시작 시 자동으로 마이그레이션을 실행한다. 편리하지만, 이 “관례에 의한 설정”이 프로덕션에서 예상치 못한 DDL을 실행하거나, 멀티 DataSource 환경에서 읽기 전용 레플리카에 쓰기를 시도하거나, 테스트 간 데이터를 오염시킬 수 있다. 수백만 건 데이터를 단일 UPDATE로 밀어버릴 때 어떤 일이 벌어지는가?
자동 설정의 출발점과 함정
FlywayAutoConfiguration은 세 가지가 갖춰지면 켜진다 — flyway-core 의존성, JDBC 드라이버, DataSource 빈. 기본값은 spring.flyway.enabled=true이므로 별도 설정 없이도 마이그레이션이 실행된다.
문제는 프로덕션에서도 동일하게 동작한다는 점이다.
# application-prod.yml
spring:
flyway:
enabled: false # 프로덕션은 수동 배포 파이프라인에서 실행
jpa:
hibernate:
ddl-auto: validate
프로덕션에서는 enabled: false로 두고, CI/CD 파이프라인에서 명시적으로 flyway:migrate를 호출하는 것이 표준 패턴이다. 자동 설정은 개발·테스트 환경의 편의를 위한 것이지, 프로덕션 배포 전략이 아니다.
멀티 DataSource 환경에서는 각 DataSource마다 Flyway 빈과 FlywayMigrationInitializer를 명시적으로 선언해야 한다. 레플리카(읽기 전용)는 Primary의 마이그레이션 후 Replication으로 동기화되므로 별도 Flyway 설정이 불필요하다 — 오히려 있으면 쓰기 권한 오류가 발생한다.
Entity와 마이그레이션의 동기화 원칙
ddl-auto=validate는 Entity 필드가 DB에 존재하는지 검사하지만, 검사하지 않는 것도 많다.
- ✅ 컬럼 존재 여부, 기본 타입 호환성
- ❌ VARCHAR 길이, DECIMAL 자릿수, NULL 제약의 정확한 일치, 인덱스, 외래 키
@Column(nullable = false)를 선언해도 DB가 NULL을 허용하면 validate는 통과한다. 이 간극은 마이그레이션 파일에서 직접 닫아야 한다.
Schema-First 워크플로우는 이 문제를 구조적으로 해결한다.
SQL 마이그레이션 파일 작성
→ 로컬에서 테스트
→ Entity 클래스 업데이트
→ ddl-auto=validate 통과 확인
→ 마이그레이션 파일 + Entity를 같은 커밋에 포함
Entity-First(ddl-auto=update로 스키마를 자동 생성)는 초기 속도가 빠르지만, 생성된 DDL의 품질 제어가 불가능하고 버전 관리가 어렵다. 장기적으로 기술 부채가 쌓인다.
마이그레이션 파일과 Entity 변경을 별도 PR로 나누면, 둘이 다른 시점에 배포될 수 있다. 스키마만 변경된 상태나 코드만 변경된 상태는 모두 서비스 장애로 이어진다.
대용량 마이그레이션 — 단일 UPDATE의 대가
수백만 건을 단일 UPDATE로 처리하면 어떤 일이 생기는가.
-- ❌ 절대 금지
UPDATE orders SET status = 'COMPLETED' WHERE status = 'DONE';
-- 500만 건 × 락 보유 시간 = 수 분간 모든 쓰기 차단
-- Undo Log 수백 MB, Replication 지연 심각
배치 처리는 간단한 원칙이다 — 1,000~5,000건씩 나눠 처리하고, 각 배치마다 커밋한다.
for (int i = 0; i < totalBatches; i++) {
int affected = jdbc.update(
"UPDATE orders SET status = 'COMPLETED' " +
"WHERE status = 'DONE' LIMIT ?", BATCH_SIZE
);
if (affected > 0) Thread.sleep(100); // 레플리카 따라잡을 시간
}
락 보유 시간이 분 단위에서 밀리초 단위로 줄고, Undo Log는 배치 크기로 제한된다. 실패 시 마지막 배치부터 재시작도 가능하다.
컬럼 이름 변경처럼 코드와 스키마가 동시에 바뀌어야 하는 경우에는 Dark Launch 패턴이 유효하다.
배포 1 (Expand): 신규 컬럼 추가 + 양쪽 쓰기
배포 2 (Switch): 신규 컬럼에서 읽기 전환
배포 3 (Contract): 구 컬럼 제거
각 배포 사이에 최소 1주일의 안정화 기간을 두는 것이 실무 표준이다. Contract 단계에서 롤백은 사실상 불가능하므로, Expand·Switch 단계를 충분히 검증해야 한다.
테스트 격리 전략
테스트 환경에서 데이터 격리가 안 되면 비결정적 테스트가 생긴다 — 실행 순서에 따라 성공하거나 실패하는 테스트.
| 방식 | 격리 수준 | 프로덕션 유사도 | 비고 |
|---|---|---|---|
| Testcontainers + MySQL | 높음 | 최고 | 실제 MySQL 8.0 |
| @Transactional 롤백 | 높음 | 낮음 | Lazy Loading 주의 |
| @Sql BEFORE/AFTER | 중간 | 높음 | 실행 순서 명확히 |
| H2 인메모리 | 높음 | 낮음 | JSON 함수 등 호환성 차이 |
Testcontainers는 Docker 의존성이 생기지만, 실제 MySQL을 쓰므로 “테스트에서는 되는데 프로덕션에서 실패” 현상을 방지한다. 첫 실행은 느리지만 Docker 캐싱으로 이후 실행은 빠르다.
Repeatable 마이그레이션(R__ 파일)은 시드 데이터 관리에 유용하다. V 마이그레이션 완료 후 매번 실행되므로, DELETE + INSERT 패턴으로 멱등성을 보장하면 스키마가 변경되어도 시드 데이터가 자동으로 재적용된다.
트레이드오프
자동 설정의 편의성 vs 명시성: 기본값은 개발 생산성을 위한 것이다. 프로덕션에서는 enabled: false + 명시적 파이프라인이 안전하다.
배치 크기 선택: 작은 배치(1,000)는 락이 짧지만 오버헤드가 크고, 큰 배치(10,000)는 처리가 빠르지만 락이 길다. 트래픽이 많은 서비스는 1,000~5,000이 균형점이다.
Dark Launch의 복잡성: 무중단 마이그레이션이 가능하지만, 코드에서 두 필드를 동시에 관리해야 하는 기간이 생긴다. 3단계 배포와 2~3주의 기간을 감수해야 한다.
ddl-auto=validate의 한계: 컬럼 존재와 기본 타입은 검사하지만, VARCHAR 길이나 NULL 제약의 세부 사항은 검사하지 않는다. 마이그레이션 파일에서 직접 명시해야 한다.
정리
- Spring Boot의 Flyway 자동 설정은 프로덕션에서
enabled: false로 끄고, 배포 파이프라인에서 명시적으로 실행해야 한다. - Schema-First 워크플로우로 SQL 마이그레이션과 Entity 변경을 같은 커밋에 묶어라.
ddl-auto=validate는 보조 안전망이지 완전한 검증 도구가 아니다. - 수백만 건 마이그레이션은 반드시 배치 처리(1,000~5,000건씩 커밋)로 처리하고, 컬럼 이름 변경 같은 복잡한 변경은 Expand → Switch → Contract 3단계로 나눠 배포하라.
- 테스트 격리는 Testcontainers + MySQL 조합이 가장 신뢰할 수 있다. H2는 프로덕션과 호환성 차이가 있다.