← all posts
DEV 2026.05.02 · 13 min read Intermediate

DB 마이그레이션을 안전하게 배포하려면 무엇이 필요한가

Spring Boot + Flyway 자동 설정의 함정부터 대용량 배치 처리, Dark Launch, 실전 케이스 스터디까지 — 프로덕션 마이그레이션의 핵심 패턴을 추적한다.


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의 품질 제어가 불가능하고 버전 관리가 어렵다. 장기적으로 기술 부채가 쌓인다.

같은 PR에 SQL + Entity를 묶어라

마이그레이션 파일과 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는 프로덕션과 호환성 차이가 있다.