
- 브랜치별로 개발 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태스크가VERSION→app-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 주의사항: MigrationType이 org.flywaydb.core.extensibility로 이동했고, baseline 엔트리는 isBaseline()으로 거른다.
8. 정리 — 이 경험에서 건진 4가지
- 제약이 나쁜 설계를 걸러준다. "DB 스키마 안 건드림"이라는 제약 한 줄이 메타 테이블 안과 일부 우회안을 자동으로 탈락시켰다.
- 도착지만으론 부족하다. 업그레이드 승인은 출발지 + 도착지 둘 다 명시해야 한다. 암묵적 가정은 결국 새는 곳이 된다.
- 환경 분기는 파일 존재 여부로 충분하다. Spring Profile 안 써도 된다.
- 논리적 추론은 실제 로그로 확인한다. "이렇게 동작할 것이다"와 "실제로 이렇게 동작했다" 사이의 간격은 항상 있다.
함께 보면 좋은 글
- Flyway 공식 문서 — Migration Strategies
- Spring Boot Reference —
FlywayMigrationStrategy - Gradle Build Cache — Task Inputs and Outputs
'Infra' 카테고리의 다른 글
| Rocky Linux LVM 디스크 확장, 왜 명령을 4번 쳐야 할까 (0) | 2026.05.10 |
|---|---|
| Nexus Repository Manager에 HTTPS 적용하고 Docker Registry 추가하기 (0) | 2026.04.28 |
| Nexus Repository Manager 구축하기 (0) | 2026.04.25 |