본문 바로가기
Infra

다중 브랜치 개발 환경에서 Flyway 교차 오염 막기

by Machine-Geon 2026. 5. 4.
반응형

  • 브랜치별로 개발 DB가 다른 환경에서, 잘못된 DB에 Flyway 마이그레이션이 적용되는 사고를 기동 시점에 구조적으로 막는 방법을 설계했다.
  • flyway.target은 "얼마나 멀리"만 제한하고 "어느 DB냐"는 모르기 때문에 부족하다.
  • 해법은 VERSION 파일(앱)flyway_schema_history(DB) 를 비교하고, 업그레이드 시 from + to 둘 다 일치를 요구하는 가드 빈을 두는 것.
  • 추가로 Gradle incremental build의 stale 빌드를 런타임에서 한 번 더 검증해 잘못된 판정을 차단했다.
  • DB 스키마 무수정. 코드 4파일, 설정 1섹션.

1. 문제 상황

dev3.0, dev3.1, dev3.2 브랜치를 병행 개발하고 브랜치마다 다른 개발 DB를 쓴다. 사고 패턴은 이렇다.

git checkout dev3.2
./gradlew bootRun
# 설정 파일은 여전히 dev3.1 DB를 가리킴
# → Flyway가 V30200을 dev3.1 DB에 적용 → 오염

"DB 설정 잘 확인하세요"는 해결이 아니다. 사람의 주의력에 기대는 룰은 결국 무너진다. 기동 시점에 구조적으로 막아야 한다.

이 글은 그 가드를 설계하면서 거쳐 간 시행착오 기록이다.


2. 제약 조건

설계 전에 못 박은 한 가지 제약:

DB 스키마는 손대지 않는다.

Flyway가 이미 flyway_schema_history를 관리 중이라 새 메타 테이블을 추가할 이유가 없다. 이 제약이 결과적으로 나쁜 설계 후보들을 깔끔하게 걸러줬다.


3. 설계 변천 — 4번 갈아엎은 기록

시도 1. DB에 메타 테이블 추가

CREATE TABLE db_meta (major INT, minor INT, patch INT);

기각. 제약 조건 위반. flyway_schema_history가 이미 있는데 또 만들 이유가 없다.

시도 2. Flyway target 옵션

flyway.target으로 마이그레이션 상한을 설정하는 방법.

시나리오 Flyway 동작
구앱 → 신 DB Flyway validation이 자동 차단 (target 없이도)
신앱 → 구 DB target=30299로 둬도 V30200이 범위 내 → 적용 → 오염 그대로

기각. target은 "얼마나 멀리"만 제한하지 "어느 DB냐"는 모른다. 핵심 사고 시나리오를 못 막는다.

시도 3. VERSION 파일 + flyway_schema_history 비교

  • 앱 버전: VERSION 파일 → 빌드 시 app-version.properties로 변환
  • DB 버전: flyway_schema_history 최대 version
  • FlywayMigrationStrategy 빈에서 migrate() 호출 직전에 비교

DB 무수정 조건 만족. 기본 틀 확정.

시도 4. allow-upgrade-to 단일 필드

업그레이드 승인용 설정 하나로 가는 안:

schema-guard.allow-upgrade-to: "3.1.0"

허점이 있다. 도착지만 검사하면 출발지가 무엇이든 통과한다.

DB App allow-upgrade-to 결과
3.0.8 3.1.0 "3.1.0" 통과 (의도된 업그레이드)
3.0.5 3.1.0 "3.1.0" 통과 (큰 점프도 허용됨)
2.5.0 3.1.0 "3.1.0" 통과 (말이 안 됨)

출발지 가정이 암묵적이라 구멍이 남는다.

시도 5 (최종). from + to 둘 다 요구

schema-guard:
  enabled: true
  allow-upgrade:
    from: ""   # DB 최종 migration 버전
    to:   ""   # 앱 VERSION

둘 다 정확히 일치해야 업그레이드 통과. 한쪽만 있거나 값이 어긋나면 차단.

판정 테이블:

단계 조건 결과
1 enabled=false 통과
2 DB 이력 없음 통과 (신규)
3 app == db 통과
4 app < db 차단 (다운그레이드)
5 from/to 둘 다 일치 통과
5' 이외 차단

4. 자주 받는 질문 — 재기동 시 설정 갱신 필요?

불필요. 업그레이드 성공 후 DB는 앱과 같은 버전이 되므로 3단계 app == db에서 짧게 끝난다. from이 stale해도 도달조차 안 한다.

다음 업그레이드를 할 때만 from/to를 새 값으로 갱신한다. 각 업그레이드는 독립 계약이라는 의도된 동작이다.

실측 로그로 확인:

# VERSION=3.1.0, DB=3.1.0, config에 stale from="3.0.8" 남아 있음
Schema guard: version matched. appVersion=3.1.0, dbVersion=3.1.0, proceeding with migrate.

5. 함정 — Gradle Incremental Build의 stale 빌드

테스트 중 VERSION 파일을 바꿨는데 앱이 이전 버전으로 인식하는 현상을 만났다.

원인 추적:

  • 앱은 VERSION 직접 안 읽음. 빌드 시 createProperties 태스크가 VERSIONapp-version.properties 변환
  • 그 태스크에 inputs.file("VERSION") 선언이 없음
  • Gradle incremental build가 "입력 변경 없음"으로 판정 → 스킵 → properties stale

이 상태에서 스키마 가드가 돌면 잘못된 앱 버전으로 잘못된 판정이 나올 수 있다. 가드 자체보다 더 위험하다 — 가드가 거짓 안전감을 준다.

해결: 런타임에서 한 번 더 비교.

private void verifyBuildIsFreshInDev() {
    Path versionFilePath = Path.of("VERSION");
    if (!Files.exists(versionFilePath)) return;

    String fromFile  = Files.readString(versionFilePath).trim();
    String fromProps = appVersionProperties.getVersion();
    if (fromFile.equals(fromProps)) return;

    throw new IllegalStateException(
        "Build is stale. VERSION=" + fromFile + ", built=" + fromProps +
        ". Run './gradlew processResources --rerun-tasks'.");
}

VERSION 파일이 프로젝트 루트에 있는 환경(개발)에서만 체크. JAR 배포 환경에선 VERSION 파일이 working directory에 없어 자동 스킵된다. 환경 분기가 자연스럽게 된다 — Profile 불필요.


6. 최종 플로우

기동
  │
  ├─ stale build 체크 (VERSION 파일 있을 때만)
  │    ├─ 일치 or 파일 없음 → 통과
  │    └─ 불일치 → 차단 + 재빌드 커맨드 안내
  │
  ├─ 스키마 버전 가드
  │    ├─ enabled=false      → 통과
  │    ├─ DB 이력 없음         → 통과
  │    ├─ app == db          → 통과
  │    ├─ app < db           → 차단
  │    └─ app > db           → from/to 검사
  │
  └─ flyway.migrate()

7. 핵심 코드 조각

7.1 FlywayMigrationStrategy 빈으로 교체

Callback도 되지만 Spring Boot에선 FlywayMigrationStrategy 빈을 재정의하는 쪽이 더 명확하다.

@Bean
public FlywayMigrationStrategy schemaGuardMigrationStrategy() {
    return flyway -> {
        // 1) stale build 체크
        // 2) 스키마 버전 가드
        // 통과한 경우에만 ↓
        flyway.migrate();
    };
}

빈이 등록돼 있으면 Spring Boot의 기본 마이그레이션 동작을 대체한다. 검증에서 예외를 던지면 migrate() 호출 자체가 일어나지 않는다.

7.2 flyway_schema_history에서 DB 버전 조회

Optional<MigrationInfo> maxApplied = Arrays.stream(flyway.info().applied())
    .filter(info -> info.getType() == null || !info.getType().isBaseline())
    .filter(info -> info.getVersion() != null)
    .max(Comparator.comparing(MigrationInfo::getVersion));

Flyway 11 주의사항: MigrationTypeorg.flywaydb.core.extensibility로 이동했고, baseline 엔트리는 isBaseline()으로 거른다.


8. 정리 — 이 경험에서 건진 4가지

  1. 제약이 나쁜 설계를 걸러준다. "DB 스키마 안 건드림"이라는 제약 한 줄이 메타 테이블 안과 일부 우회안을 자동으로 탈락시켰다.
  2. 도착지만으론 부족하다. 업그레이드 승인은 출발지 + 도착지 둘 다 명시해야 한다. 암묵적 가정은 결국 새는 곳이 된다.
  3. 환경 분기는 파일 존재 여부로 충분하다. Spring Profile 안 써도 된다.
  4. 논리적 추론은 실제 로그로 확인한다. "이렇게 동작할 것이다"와 "실제로 이렇게 동작했다" 사이의 간격은 항상 있다.

함께 보면 좋은 글

  • Flyway 공식 문서 — Migration Strategies
  • Spring Boot Reference — FlywayMigrationStrategy
  • Gradle Build Cache — Task Inputs and Outputs
반응형