website: stage DB 전체삭제 사고 재발 방지 — clean()을 repair()로, CD에 사전 차단 추가
2026-07-31에 stage DB가 통째로 비어버린 사고가 있었다. #320 검증 작업을 하던 중이었고, 처음엔 컨테이너가 재생성되면서 뭔가 꼬인 줄 알았다. 세션 로그를 다시 짚어보니 원인은 다른 데 있었다.
FlywayConfig.java에 stage 전용으로 넣어둔 검증 실패 복구 전략이 문제였다. Flyway 10에서 spring.flyway.clean-on-validation-error 프로퍼티가 아예 제거돼서("has been removed" 예외를 던짐), 같은 동작을 직접 재구현해둔 코드가 있었는데, 그 내용이 flyway.clean()(DB 전체 삭제) 후 migrate()였다. 이미 배포된 테스트 마이그레이션 파일을 git rm으로 정리하는, 평소라면 아무 문제 없을 작업만으로도 Flyway validate()가 "장부(flyway_schema_history)엔 있는데 파일이 없음"을 검증 오류로 잡아냈고, catch 블록이 그걸 그대로 "DB 전체 삭제 후 재구성"으로 처리해버렸다. 멤버, 게시글, 프로젝트 등 stage의 실제 데이터가 그렇게 다 날아갔다. 더 골치 아팠던 건 헬스체크가 빈 스키마 위에서도 정상 통과한다는 점이었다 — 스키마 자체는 마이그레이션이 다시 적용되면서 멀쩡하게 만들어지니까. 그래서 CD 로그엔 이상 신호가 전혀 안 남았고, 사고를 바로 알아채지 못했다.
원인을 특정하고 나니 고쳐야 할 게 두 층위로 보였다. 하나는 이 catch 블록 자체가 데이터를 지우면 안 된다는 것, 다른 하나는애초에 이 블록에 들어갈 상황(이미 배포된 마이그레이션 파일이 삭제·수정되는 것)을 배포 단계에서 막아야 한다는 것.
첫 번째는 단순했다. clean() 대신 repair()를 쓰면 데이터는 그대로 두고 flyway_schema_history 장부만 실제 파일 상태에 맞게 정정한다. 검증 실패의 원인이 "장부와 파일이 안 맞는다"는 것이었으니, 장부를 고치는 게 데이터를 지우는 것보다 정확한 대응이다. 코드 자체는 한 줄 바꾸는 수준이지만, 이 판단이 다시 뒤집히지 않게 단위 테스트를 하나 추가했다. FlywayValidateException을 강제로 던지게 mock을 세팅하고 repair()가 정확히 한 번 호출되는지, clean()은 한 번도 호출되지 않는지를 고정하는 테스트다. migrate()가 void가 아니라 MigrateResult를 반환하는 타입이라 doNothing()을 못 쓰고, 첫 호출은 예외를 던지고 두 번째 호출(재시도)은 null을 반환하도록 doThrow().doReturn()을 이어붙여야 했다. 이런 세부는 코드를 봐야 눈에 들어오는 부분이라 넘어가려다 그냥 여기 남긴다.
두 번째가 더 신경 쓰였다. repair()로 바꾼다고 해도 애초에 "이미 배포된 마이그레이션 파일을 지우거나 고치는" 행위 자체가 정상적인 배포 흐름에서는 나올 이유가 없는 실수다. 이 실수가 CD를 그냥 통과해서 stage에 배포되는 구조를 그대로 두는 게 맞나 싶었다. 그래서 cd.yml의 "마이그레이션 위험도 판단" job에 체크를 하나 더 넣었다.
기존 로직은 git ls-tree로 배포 전/후의 마이그레이션 파일 목록을 비교해서 새로 추가된 파일(ADDED)만 뽑아 그 안에서 파괴적 SQL 패턴을 grep하는 식이었다. 여기에 반대 방향 비교(REMOVED, 배포 전엔 있었는데 후엔 없는 파일)와, 공통으로 존재하는 파일들의 내용 변경 여부(git diff)를 추가했다.
BEFORE_FILES=BEFORE_SHA" -- "$MIGRATION_DIR" | sort)
AFTER_FILES={{ github.sha }}" -- "$MIGRATION_DIR" | sort)
ADDED=BEFORE_FILES") <(echo "$AFTER_FILES"))
REMOVED=BEFORE_FILES") <(echo "$AFTER_FILES"))
COMMON=BEFORE_FILES") <(echo "$AFTER_FILES"))
MODIFIED=""
for f in $COMMON; do
if ! git diff --quiet "{{ github.sha }}" -- "$f"; then
MODIFIED="f"
fi
done
if [ -n "MODIFIED" ]; then
echo "❌ 이미 적용된 마이그레이션 파일이 삭제되거나 수정됨"
exit 1
fi
REMOVED나 MODIFIED가 하나라도 있으면 destructive=true로 표시해서 사람이 나중에 보게 하는 게 아니라 그 자리에서 exit 1로 배포를 막는다. 이미 검증 실패가 확정된 상황이라 굳이 진행시킬 이유가 없다고 판단했다. stage를 정말로 초기화해야 하는 경우는 이미 별도로 만들어둔 Reset Stage DB 워크플로(사람이 직접 "RESET"을 입력해야 실행됨)가 있으니 그쪽을 쓰면 된다.
파일 리네임 케이스도 걸린다. 파일 경로 목록 자체를 이전/이후로 비교하는 방식이라 git의 리네임 추정 로직에 흔들리지 않는데, 이건 사실 이번 PR 이전부터 있던 설계였다 — V5→V6 리네임 커밋에서 git이 리네임으로 판단해버리면 새 파일이 안 잡히는 문제를 이미 겪어서 그렇게 짜둔 코드였다. 이번에 추가한 REMOVED/MODIFIED 체크도 같은 방식 위에 얹었기 때문에 리네임은 자연스럽게 REMOVED로 잡힌다.
검증은 두 갈래로 했다. FlywayConfigTest는 4개 테스트 전부 통과했고 ./gradlew compileJava compileTestJava도 정상이었다. cd.yml에 넣은 bash 로직은 로컬에 임시 git 저장소를 만들어서 실제 CD와 같은 git ls-tree/comm/git diff 조합으로 4가지 케이스를 돌려봤다 — 이미 적용된 파일 수정(차단), 삭제(이번 사고 재현, 차단), 신규 파일 추가만(정상 흐름, 통과), 리네임(차단, REMOVED로 잡힘). false positive 없이 정상 흐름은 통과시키는 걸 확인했다.
다만 실제 stage에 push해서 CD가 도는 걸 보는 실측 검증까지는 이번 PR에서 하지 않았다. #320 때는 A~F 시나리오로 dev에 직접 push해가며 실측했었는데, 이건 팀이 같이 쓰는 인프라를 재배포시키는 일이라 사람 확인 없이 임의로 건드리면 안 된다고 판단해서 로컬 시뮬레이션까지만 하고 PR을 올렸다. 리뷰 후 다이렉트 push든 이 PR 머지든 어느 쪽으로 진행되든, 그 배포에서 헬스체크와 "판단 결과" 로그를 같이 확인하면 되는 상황이었다.
PR은 08-01 09시 38분에 열려서 09시 41분에 바로 merge됐다.
남는 한계도 문서에 같이 적었다. 삭제·수정 감지는 마이그레이션 폴더 안에서 git이 추적하는 파일 기준이라, 그 밖의 경로에서 벌어지는 조작(예: 서버에서 직접 SQL로 히스토리를 만지는 경우)은 이 체크 범위 밖이다. 그리고 repair()가 체크섬 불일치를 장부를 새 내용 기준으로 조용히 맞추는 방식이라, ①의 CD 차단을 우회해서 들어오는 경로(예: workflow_dispatch 수동 배포)로 체크섬 불일치가 발생하면 데이터는 안전하지만 왜 불일치가 났는지는 log.warn 한 줄로만 남는다. 이번엔 데이터 유실을 막는 데 집중했고, 그 우회 경로에 대한 가시성은 다음에 볼 부분으로 남겨뒀다.