website: V6 마이그레이션 테스트 구멍 메우고 재발 방지 CI 가드 붙이기
V6 마이그레이션이 이틀 전(07-27) stage 배포 중 CHECK 위반으로 죽었다. 원인을 파고들다 보니 테스트가 있긴 있었는데 실제로는 아무것도 검증하지 못하고 있었다는 걸 확인했고, 그걸 고치는 김에 같은 패턴이 재발하지 않도록 CI 가드까지 붙였다.
V6는 MemberRole을 6종에서 14종으로 확장하면서 member_roles.role과 project_participants.part의 값을 BE→BACKEND, FE→FRONTEND로 개명하고 PM·INFRA는 아예 삭제하는 마이그레이션이었다. 기존 CHECK를 좁히는 변경이라 재생성 패턴을 쓸 수밖에 없었고, 그러면 MigrationUpgradeHarness로 업그레이드 테스트를 반드시 짜야 한다는 게 db-man 스킬의 원칙이었다. 테스트는 실제로 있었다. AiMemberRoleUpgradeTest에 v6MapsOldRolesToNewRolesAndDropsObsoleteRoles라는 테스트가 있었고 그린으로 통과했다. 그런데 배포는 죽었다.
로그를 보니 문제는 명확했다. 그 테스트는 이전 CHECK가 허용하던 6개 값(PM/FE/BE/DESIGN/AI/INFRA) 중 실제로는 DESIGN 하나만 심어놓고 나머지는 손대지 않았다. 나머지 5개 값이 실제로 마이그레이션을 통과하는지는 테스트가 한 번도 실행해본 적이 없었던 셈이다. stage에는 저 6개 값이 전부 실재했고, 그중 PM·INFRA는 새 CHECK에 없는 값이라 배포 순간 CHECK 위반으로 터졌다. 그린 테스트가 실제로는 아무 것도 보장하지 못하는 상태였던 것이다 — 부분집합만 시드하면 "죽지 않는다"는 증거가 되지 않는다는 걸 몸으로 확인한 셈이다.
고친 방향은 단순했다. 사람이 임의로 값을 고르는 대신, 이전 버전 CHECK가 허용했던 값 전체를 상수로 박아놓고 그걸 통째로 시드하게 만들었다.
private static final Set<String> V3_LEGACY_ROLES =
Set.of("PM", "FE", "BE", "DESIGN", "AI", "INFRA");
MigrationUpgradeHarness.seedEach(dbUrl,
"insert into member_roles (member_id, role) values (%1s')", V3_LEGACY_ROLES);
seedEach는 MigrationUpgradeHarness에 새로 추가한 헬퍼로, 컬렉션을 받아 값마다 한 행씩 순번을 매겨 삽입한다. 이렇게 해두면 naive하게 insert ... select로 그대로 복사하는 마이그레이션이었을 경우 migrateToLatest 호출 시점에 즉시 CHECK 위반 예외로 실패한다 — 매핑을 빠뜨렸는지 여부가 테스트 그린/레드로 바로 드러난다. 그리고 매핑표(V6_ROLE_MAPPING)와 삭제 목록(V6_DELETED_ROLES)을 각각 상수로 선언해서 값 하나하나가 선언된 대로 처리됐는지 개별 어서션으로 확인하게 했다.
이걸 고치면서 하나 더 걸리는 게 있었다. member_roles/project_participants는 CHECK 제약이 있어서 값을 빠뜨리면 배포 중에 죽지만, posts.author_part는 애초에 CHECK가 없는 자유 텍스트 컬럼이었다. V6가 이 컬럼을 author_part_json으로 바꾸는 로직도 같이 들고 있었는데, 여기는 값 매핑을 빠뜨려도 배포가 안 죽는다 — 그냥 조용히 잘못된 값이 들어갈 뿐이다. "안 죽는다"와 "맞게 변환된다"는 서로 다른 질문이라, member_roles 쪽 안전망이 여기엔 안 통한다는 걸 확인하고 별도 테스트(v6ConvertsPostAuthorPartToJsonForEveryLegacyValueAndEdgeCase)를 새로 짰다. 여기엔 legacy 값 전체에 더해 null, 빈 문자열, 매핑표에 없는 임의 값(GUEST)까지 심었다. 다만 이 테스트를 짜면서 한 가지 가정을 깔았다 — author_part가 콤마로 여러 값을 이어붙인 케이스는 없었다는 것. V6 이전 PostService가 findFirst()로 항상 값을 하나만 뽑아 저장했고, 배포 직전 실제 stage 백업을 받아 확인해도 콤마가 섞인 값은 없었다. 이 가정이 나중에 깨지면 그 케이스를 추가하겠다고 주석으로 남겨뒀다.
테스트를 고치는 것만으로는 같은 종류의 사고가 또 안 난다는 보장이 안 됐다. 사람이 "이번엔 값 전체를 시드했나" 매번 스스로 확인해야 하는 구조라, 리뷰에서 놓치면 그대로 재현될 수 있다. 그래서 CI에 migration-guard 잡을 추가했다. 로직은 이렇다 — PR에서 새로 추가되거나 수정된 마이그레이션 SQL 파일 중 DROP TABLE이나 DELETE FROM이 들어있으면(재생성 패턴이거나 행 삭제 패턴), 같은 PR에 backend/src/test/.../migration/ 아래 테스트 변경이 있는지를 기계적으로 확인하고, 없으면 그 자리에서 실패시킨다.
이 가드를 짜면서 두 가지를 놓칠 뻔했다. 하나는 --diff-filter=AM을 안 쓰고 A만 봤으면 놓쳤을 케이스다. db-man 규칙상 머지된 마이그레이션 파일은 수정 금지가 원칙인데, 실제 V6는 배포가 실패해 flyway_schema_history에 성공 기록이 한 번도 안 남은 상태라 같은 PR 사이클 안에서 예외적으로 수정됐다. Added만 봤으면 이 시나리오가 CI 검증 대상에서 빠질 뻔했다. 다른 하나는 --no-renames다. V6의 첫 시도(9d7fe39)는 V5와 파일명이 충돌하면서 rename으로 처리된 커밋이었는데, git이 파일명 유사도로 이걸 "이전 버전 파일 → V6로 rename"으로 인식하면 Added/Modified 어느 쪽에도 안 잡힌다. 로컬에서 재현해보고서야 실제로 놓친다는 걸 확인하고 옵션을 추가했다.
가드는 "테스트가 아예 없는 것"만 잡는다는 한계가 있다. 있는데 내용이 이번 V6처럼 불완전한 경우(부분집합만 시드)는 기계적으로 못 걸러낸다 — 그건 여전히 리뷰어의 몫이다. 그래서 db-man SKILL.md에 변경 시나리오별로 SQLite ALTER 가능 여부, 필요한 검증, 배포 전 백업 필요 여부, CI 강제 여부를 표로 정리해뒀다. 그리고 행 삭제가 들어간 마이그레이션은 되돌릴 SQL을 짜둬도 지워진 데이터 자체는 복구가 안 된다는 점 때문에, 배포 직전 infra/backup-db.sh를 수동으로 한 번 더 돌리는 절차를 infra/docs/db-access.md에 남겨뒀다. 정기 백업이 하루 1회뿐이라 배포 시각에 따라 최대 24시간치 데이터가 무방비 상태일 수 있다는 게 이번에 다시 확인된 부분이다.