website: V6 마이그레이션 사고 이후, 테스트 완전성과 CI 가드로 재발을 막다
07-27에 dev→stage 배포가 CHECK 위반으로 실패했던 사고의 후속 조치로, 업그레이드 테스트 커버리지를 넓히고 "테스트 없이 위험한 마이그레이션이 나가는" 상황 자체를 CI가 막도록 만든 PR이다.
사고 자체는 이미 그날 핫픽스(#253)로 막았다. MemberRole을 6종에서 14종으로 확장하는 V6 마이그레이션이 배포되다가 CHECK 위반으로 CD가 자동 롤백된 사건인데, 원인을 찾아보니 마이그레이션 SQL 자체는 문제가 없었다. 문제는 업그레이드 테스트의 시드 데이터였다. 이전 CHECK가 허용하던 값이 6개였는데, 테스트는 그중 DESIGN 하나만 심어놓고 통과 판정을 내리고 있었다. 실제 stage DB에 남아있던 BE/FE/PM/INFRA는 이 테스트를 한 번도 통과해본 적이 없는 상태로 배포된 셈이다. 핫픽스로 급한 불은 껐지만, "왜 이게 미리 안 걸렸나"는 별도로 풀어야 할 문제였다.
시드 완전성부터 다시 세우기
첫 커밋은 MigrationUpgradeHarness에 seedEach()라는 헬퍼를 추가하는 것부터 시작했다.
public static void seedEach(String jdbcUrl, String insertSqlTemplate, Collection<String> legacyValues) {
int seq = 1;
for (String value : legacyValues) {
execute(jdbcUrl, insertSqlTemplate.formatted(seq++, value));
}
}
이전 버전의 CHECK가 허용했던 값 전체를 한 행씩 반복 삽입하는 단순한 코드다. 핵심은 "사람이 고른 부분집합이 아니라, 이전 마이그레이션 파일의 CHECK 절 그대로"라는 점이었다. 이번 사고가 사람이 6개 중 1개만 골라 심어서 난 문제였으니, 다음에 또 CHECK를 좁히는 마이그레이션을 쓸 사람이 이 헬퍼만 쓰면 자동으로 전체를 시드하게 만드는 게 목표였다.
이 과정에서 애초에 따로 추가하려던 "완전성 선언" 메타 테스트(예전 값 집합과 매핑/삭제 선언을 대조하는 테스트)는 제거했다. 처음엔 이것도 필요하다고 생각했는데, 곰곰이 보니 V3_LEGACY_ROLES의 출처인 V3~V5 마이그레이션 파일은 머지된 뒤 다시 바뀔 일이 없다. 대조 대상 자체가 이후에 변하지 않으니, 실제 DB 실행 없이 값 목록만 비교하는 순수 코드는 굳이 유지할 이유가 없었다. 남긴 두 테스트는 실제로 Flyway를 돌려서 V6를 실행하고 결과를 확인하는, 이 레포에서 유일한 "실데이터 위 업그레이드 경로" 검증이다.
posts.author_part — CHECK가 없어서 오히려 위험한 케이스
member_roles와 project_participants는 seedEach로 커버했지만, 커밋을 하나 더 추가해서 posts.author_part → author_part_json 변환도 별도로 검증했다. 이 컬럼은 CHECK 제약이 없는 자유 텍스트라는 게 문제였다. CHECK가 있으면 매핑을 빠뜨렸을 때 배포 중 바로 죽어서 사고가 눈에 보이는데, CHECK가 없으니 값 매핑을 빠뜨려도 조용히 잘못된 데이터를 만들고 넘어갈 수 있다. "안 죽는다"와 "맞게 변환된다"는 완전히 다른 질문이라는 걸 이때 정리했다.
그래서 레거시 역할 값 전부에 더해 null, 빈 문자열, 매핑표에 아예 없는 임의값(GUEST)까지 심어서 V6의 CASE 절이 의도한 JSON 배열을 만드는지 확인하는 테스트를 추가했다. 매핑표에 없는 값이 유실되지 않고 그대로 보존되는지도 같이 봤다. 여기서 한 가지 전제를 코드 주석으로 남겨뒀는데, author_part가 콤마로 여러 값을 이어붙인 문자열이었던 적은 없다는 가정이다. V6 이전 코드가 항상 findFirst()로 값을 하나만 뽑아 저장했고, 배포 직전 실제 stage 백업을 로컬로 받아 확인해도 NULL 1건뿐이었다. 이 가정이 나중에 깨진다면(콤마 포함 값이 실측되면) 그때 테스트 케이스를 추가하기로 하고 넘어갔다.
되돌릴 SQL로도 못 살리는 데이터
테스트 작업과 별개로, 이번 사고를 계기로 문서 쪽도 손봤다. 행을 삭제하는 마이그레이션(V6의 where role not in ('PM','INFRA') 같은 패턴)은 되돌릴 SQL을 아무리 잘 짜놔도 복구가 안 된다는 사실이다. 지워진 행의 원래 값 자체가 DB 어디에도 안 남기 때문에, Flyway 유료 버전의 undo migration을 쓰더라도 그건 테이블 구조를 되돌리는 것이지 삭제된 데이터를 되살리는 게 아니다. 유일한 복구 경로는 삭제 이전 시점의 백업뿐인데, 정기 백업이 하루 1회라 배포 시각에 따라 최대 24시간치 데이터가 무방비 상태일 수 있다는 것도 이번에 짚었다.
그래서 이런 마이그레이션을 stage/prod에 배포하기 직전에는 backup-db.sh를 수동으로 한 번 더 돌리도록 절차를 명시했다. 실제 절차는 db-access.md 한 곳에만 적어두고, infra/CLAUDE.md(CD 자동 롤백이 DB는 안 건드린다는 사실)와 db-migration.md에서는 거기로 가리키게 해서 단일 출처를 유지했다.
시나리오표와 CI 가드
문서 작업을 하다 보니, 기존 db-man 스킬 문서는 순서대로 다 읽어야 어떤 케이스에 뭐가 필수인지 알 수 있는 구조였다. 컬럼 삭제 하나만 하려는 사람이 절차 전체를 훑어야 하는 게 번거로워 보여서, 시나리오별로 SQLite ALTER로 가능한지, 어떤 패턴을 쓰는지, 필수 검증·백업이 뭔지, CI가 기계적으로 강제하는지를 표 하나로 정리했다. 이 표를 만들면서 확인한 게 하나 있는데, 컬럼 추가(nullable)와 컬럼 이름 변경 두 케이스만 빼면 나머지 전부가 DROP TABLE이나 DELETE FROM이 들어가는 재생성 패턴을 쓴다는 사실이었다. 그리고 CHECK를 좁힐 때 "이전 값을 새 값에 매핑할지 아니면 삭제할지"는 기술 판단이 아니라 도메인 판단이라는 점도 명시했다. V6에서 PM·INFRA가 삭제로 처리된 것도 기술적 필연이 아니라 "역할 체계상 더는 유효하지 않다"는 결정이었기 때문이다.
이 확인 덕분에 CI 가드를 만들 근거가 생겼다. migration-guard라는 잡을 추가해서, 새로 추가/수정된 마이그레이션 파일에 재생성 패턴이 있는데 같은 PR에 마이그레이션 테스트 변경이 없으면 CI가 실패하게 했다.
CHANGED_MIGRATIONS=BASE" "$HEAD" -- 'backend/src/main/resources/db/migration/V*.sql')
이걸 검증하려고 실제 V6 사고 커밋과 핫픽스(#253)를 로컬로 재실행해봤는데, 여기서 예상치 못한 걸 발견했다. --no-renames 옵션 없이 돌리니 사고 커밋이 가드에 안 걸렸다. V6 첫 시도가 V5와 파일명이 충돌하면서 git이 이걸 "이름이 바뀐 파일"로 인식했고, --diff-filter=AM은 rename을 A(추가)나 M(수정) 어느 쪽으로도 잡지 않는다. 실제로 위험한 값 삭제가 들어있는 파일이 rename 감지 때문에 검사망을 그냥 통과해버리는 상황이었다. --no-renames를 추가하고 나서야 사고 커밋은 막히고 핫픽스는 통과하는 걸 확인할 수 있었다.
이 가드에는 분명한 한계도 있다. "테스트가 아예 없는 것"만 잡을 수 있지, 이번 V6처럼 테스트가 있는데 시드가 불완전한 경우는 여전히 사람이 봐야 한다. 기계적으로 막을 수 있는 선과 도메인 판단이 필요한 선을 나눠서, 전자는 CI에 맡기고 후자는 리뷰와 문서(시나리오표의 "PM 확인 필요" 문구)로 남겨두는 쪽으로 정리했다.
검토했지만 안 한 것
작업 중에 "CI에서 실제 stage/prod 백업으로 상시 검증하는 단계를 넣자"는 안도 고려했다. 그런데 stage DB에는 실제 회원 이름·학번이 들어있어서 CI에 반입하면 PII 노출면이 넓어지고, 또 ci.yml이 dev/main PR에 동일한 잡을 돌리는 구조라 stage 스냅샷만으로는 dev→main이 실제로 위협하는 prod 리스크를 못 잡는다는 문제가 있었다. prod 스냅샷까지 넣자니 더 민감해진다. 그래서 상시 자동화는 "과거 마이그레이션이 선언한 값 집합 전체"를 쓰는 정적 방식(seedEach + migration-guard)으로 하고, 실데이터 검증은 이번처럼 특히 위험한 마이그레이션에 한해서만 필요할 때 수동으로 하는 걸로 유지했다. 이번 PR에서도 V6 배포 직전 stage 백업을 로컬로 받아 마이그레이션을 재실행해서 테스트 예측과 실데이터 결과가 일치하는 걸 확인한 뒤 로컬 사본은 지웠는데, 이게 그 수동 검증의 실례다.
작업 막바지에 dev 브랜치와 머지하면서 infra/docs/db-access.md에서 충돌이 났는데, 다른 사람이 그 사이 같은 파일을 건드린 흔적이었다. 단일 출처로 정리해둔 문서라 충돌 해소 자체는 어렵지 않았다. PR은 생성 후 13분 만에 dev로 머지됐다.