website: V6 마이그레이션 CD 실패, 이미 세운 규칙이 왜 뚫렸는지 기록하다
07-27 dev CD가 `V6__expand_member_roles.sql`의 CHECK 위반으로 실패했다. 같은 날 #253 hotfix로 바로 고쳤지만, 그냥 넘어가지 않고 원인을 파서 `pm/docs/learnings.md`에 후속 기록을 남겼다.
이 마이그레이션은 MemberRole을 PM/FE/BE/DESIGN/AI/INFRA 6개에서 14개로 확장하는 작업이었다. SQLite는 기존 CHECK 제약을 ALTER TABLE로 수정할 수 없어서, 이번에도 테이블을 재생성하고 insert ... select member_id, role from member_roles로 기존 값을 옮기는 방식을 썼다. 문제는 stage DB에 남아있던 실제 값이 BE/FE/PM/INFRA였고, 특히 PM과 INFRA는 새 14개 목록에 아예 없는 값이었다는 것이다. INSERT가 새 CHECK에 걸려 Flyway가 즉시 실패했고, 앱이 기동을 못 하니 헬스체크 타임아웃으로 자동 롤백까지 이어졌다.
당황스러운 건 이게 완전히 새로운 유형의 실패가 아니었다는 점이다. 얼마 전 MemberRole에 AI를 추가할 때 이미 같은 계열 사고를 겪었고, 그때 "공유 enum에 값 하나 추가할 때는 그 enum을 참조하는 모든 CHECK 제약까지 찾아서 Flyway 마이그레이션으로 함께 넓혀야 한다"는 규칙을 세워뒀다. AiMemberRoleUpgradeTest처럼 실데이터 위 업그레이드 테스트를 만들어서 검증 체계도 갖춰뒀다고 생각했다. 그런데 그 방어막이 이번에 그대로 뚫렸다.
로그를 보고 테스트 코드를 다시 열어봤다. AiMemberRoleUpgradeTest가 심어둔 시드는 'DESIGN' 하나뿐이었다. DESIGN은 신구 CHECK 목록의 교집합이라 "호환되는 값이 마이그레이션 이후에도 살아남는지"는 검증이 됐지만, 정작 stage에 실제로 쌓여 있던 BE/FE/PM/INFRA는 시드에 하나도 없었다. CI에서 도는 임시 SQLite는 매번 빈 파일에서 시작하니, 이 시드-실데이터 사이의 간극이 로컬/CI에서는 절대 드러나지 않는 구조였다.
정리하면 마이그레이션 SQL 자체는 문제가 없었다. 문제는 업그레이드 테스트의 시드 데이터가 실제 운영 데이터의 부분집합조차 아니었다는 것이다. "CHECK 값을 바꾸는 마이그레이션은 그 컬럼이 과거에 허용했던 모든 값을 시드에 포함해야 한다"는 규칙은 이미 있었는데, 실제로 지켜지지 않았다. 이걸 그냥 "다음엔 더 조심하자"로 끝내고 싶지 않았다. 사람이 기억해서 지키는 체크리스트가 두 번째 사고에서도 통하지 않았다는 건, 그 방식 자체의 신뢰도에 대한 실증적인 근거이기 때문이다.
대안으로 떠오른 건 합성 시드 대신 실제 stage DB 스냅샷 위에서 마이그레이션을 검증하는 CI 단계를 두는 것이다. 이 방식이면 사람이 무엇을 기억하든 상관없이 실데이터가 자동으로 검증을 걸어준다. 다만 이건 CI가 회원 PII 데이터에 접근할 권한을 갖는다는 뜻이라 보안 트레이드오프가 만만치 않다. 이번 기록에는 원인 분석과 이 대안, 그리고 그 트레이드오프까지만 남기고 실제 도입 여부는 별도 인프라 판단으로 넘겼다.
hotfix 자체는 단순했다 — BE→BACKEND, FE→FRONTEND는 CASE로 명시 매핑하고, 새 목록에 아예 없는 PM/INFRA는 행을 삭제했다. 코드 변경은 이 한 줄짜리 기록이 전부지만, 같은 유형의 실수가 규칙을 세운 뒤에도 재발할 수 있다는 걸 문서로 남겨두는 게 이번 작업의 핵심이었다.