← 개발 로그 목록

website: 마이그레이션 버전 번호가 레이스 컨디션이었던 이유

/ 5분 분량 / 개발 로그

서로 무관한 두 PR(지원폼 마이그레이션, 모집상태 마이그레이션)이 각자 `V7__...`으로 파일을 만들었는데, dev에 합치는 순간 마이그레이션과 전혀 상관없어 보이는 테스트들이 무더기로 깨졌다.

처음엔 각 PR 코드 자체를 의심했다. MemberUniqueConstraintUpgradeTest, PostAuthorUpgradeTest, AiMemberRoleUpgradeTest처럼 이번 변경과 관련 없는 테스트들까지 실패하고 있어서, 뭔가 공통 설정이 깨진 건가 싶었다. 로그를 따라가보니 Flyway의 CompositeMigrationResolver가 "Found more than one migration with version 7" FlywayException을 던지고 있었다. dev에는 이미 V7__add_staff_activities.sql이 먼저 머지돼 있었고, 두 PR 모두 각자 로컬/dev 브랜치 기준으로 "다음 번호는 7이겠지"라고 판단해 같은 번호를 잡은 것이었다. 마이그레이션 SQL 자체는 멀쩡했다. 문제는 Flyway가 버전 중복을 감지하면 그 인스턴스 생성 자체를 실패시키고, Flyway를 쓰는 모든 테스트가 Spring 컨텍스트 기동 단계에서 도미노처럼 같이 죽는다는 점이었다.

각 PR을 단독으로 보면 아무 문제가 없다는 게 이 버그를 더 성가시게 만들었다. ls backend/src/main/resources/db/migration/로 다음 번호를 확인하고 파일을 만드는 절차 자체는 틀리지 않았다 — 다만 그 사이 다른 브랜치가 같은 걸 보고 같은 결론을 내릴 수 있다는 전제가 빠져 있었다. 두 PR이 동시에 개발되는 이상, 순번 방식은 근본적으로 "누가 먼저 머지되는지"에 따라 결과가 갈리는 레이스였다.

당장 든 방법은 두 개였다. 하나는 PR 리뷰 단계에서 사람이 버전 번호 충돌을 눈으로 확인하는 것, 다른 하나는 애초에 충돌이 날 수 없는 컨벤션으로 바꾸는 것. 사람이 매번 확인하는 방식은 지금 이 사고가 왜 일어났는지를 보면 신뢰하기 어려웠다 — 각자 자기 브랜치 기준으로는 문제가 없어 보이니 놓치기 쉽다. 그래서 버전 번호를 순번(V{n+1}) 대신 타임스탬프(V<yyyyMMddHHmmss>)로 바꾸기로 했다. Flyway는 버전을 숫자로만 비교하기 때문에 기존 V1~V7을 리네임할 필요 없이 타임스탬프 버전이 그 뒤에 자연스럽게 이어 붙는다. 타임스탬프는 각자 로컬 시계만 보면 되니, 다른 브랜치가 뭘 하고 있는지 조율할 필요 자체가 없어진다.

다만 같은 초에 커밋하는 것처럼 극단적인 경우엔 타임스탬프도 겹칠 수 있어서, 이걸로 끝내지 않고 CI에 기계적인 가드를 하나 더 붙였다. .github/workflows/ci.yml에 마이그레이션 버전 중복을 스캔하는 스텝을 추가했는데, 여기서 좀 신경 쓴 부분이 있다. actions/checkout이 pull_request 이벤트에서 받는 건 PR merge ref라서, 이 시점 마이그레이션 디렉터리에는 머지 대상 브랜치 파일과 이 PR 파일이 전부 같이 존재한다. 그래서 별도로 git diff를 떠서 비교할 필요 없이, 지금 디렉터리에 있는 파일들만 보고 V 다음 __ 앞 버전 번호가 중복되는지 셸 스크립트로 훑으면 된다.

declare -A seen
DUPES=()
for f in backend/src/main/resources/db/migration/V*.sql; do
  base=f")
  version=base" | sed -E 's/^V([0-9]+(\.[0-9]+)*)__.*/\1/')
  if [ -n "{seen[version]}" ]; then
    DUPES+=("버전 {seen[version]} ↔base")
  else
    seen[base"
  fi
done

이 기존 migration-guard CI 잡은 원래 재생성·행 삭제 패턴에 업그레이드 테스트가 빠졌는지만 검사하고 있었는데, 이번에 버전 번호 중복 검사가 하나 더 추가된 셈이다. 두 검사 모두 실패하면 CI 단계에서 바로 막히도록 했다.

문서도 같이 손봤다. db-man 스킬 문서의 절차 2번(마이그레이션 파일 작성)을 "다음 번호 확인 후 작성"에서 "타임스탬프로 작성"으로 바꾸고, 왜 순번이 위험한지에 대한 설명을 그대로 남겨뒀다. backend/CLAUDE.md의 마이그레이션 파일 작성 안내도 같은 이유로 문구를 수정했다. pm/docs/learnings.md에는 이번 사고의 전말 — 두 PR이 각자 V7을 잡았던 것, Flyway가 그걸 어떻게 처리해서 무관한 테스트까지 깨뜨렸는지 — 을 그대로 기록해뒀다. 이 문서엔 이미 Flyway 관련 사고 기록이 여러 건 쌓여 있는데(baseline 검증 미비, clean-on-validation-error 제거 등), 이번 것도 같은 계열의 "Flyway는 설정 하나가 어긋나면 실패가 국소적이지 않고 전역적으로 퍼진다"는 패턴이었다.

되짚어보면 이번 사고는 코드 로직 문제가 아니라 컨벤션 자체의 구조적 결함이었다. 개별 PR 단위로 아무리 꼼꼼히 봐도 못 잡는 종류의 문제라서, 사람의 확인보다 애초에 충돌 여지가 없는 컨벤션(타임스탬프)과 그래도 놓쳤을 때를 대비한 기계적 가드(CI 스캔), 이 두 겹으로 막아두는 게 맞다고 판단했다.