2차 베이스라인 스쿼시 — DB 컷오프 (2026-08-21)
마이그레이션 125개(20260622000100 ~ 20260820250000)를 베이스라인 한 벌로 교체하고, Production 마이그레이션 장부를 그에 맞게 재기록한 작업의 기록이다. 레포 변경(Phase A~C)과 Production 장부 repair(Phase D)까지 전 단계가 완결됐고, 스쿼시가 드러낸 Production 보안 드리프트 1건(내부 엔진 4종의 service_role 실행 권한)도 함께 상환했다.
관련 PR: #494 · 태그: pre-squash-v2 · 관련 게이트 전사: #480(번호 충돌) #483·#484(권한 위생)
1. 배경
이 프로젝트의 DB는 마이그레이션 파일의 순차 재생으로 정의된다. 2026-06-22 1차 베이스라인 이후 두 달 동안 파일이 125개까지 쌓였고, 세 가지 비용이 누적됐다.
- 재생 비용. 로컬 개발 스택과 CI가 DB를 만들 때마다 125개 파일을 순서대로 실행해야 했다.
schema.sql의 부패. 스키마 스냅샷이 append-only 사본이어서, 어떤 객체의create와 그것을 없앤 나중의drop이 한 파일에 공존했다. 텍스트로 계약을 거는 테스트는 이미 사라진 객체를 상대로 초록일 수 있었다.- 드리프트의 비가시성. 신선 재생본과 Production이 같은지 확인하는 수단이
check:remote-schema하나였는데, 이 게이트는missing(레포에 있는데 원격에 없는 것)만 실패로 세고 제약 이름·on delete동작·컬럼 순서·권한은 애초에 보지 않는다. 둘이 어긋나 있어도 아무도 몰랐다.
스쿼시의 목표는 단순히 파일 수를 줄이는 것이 아니라, "신선 DB = Production"을 실측으로 증명할 수 있는 상태를 만드는 것이었다.
2. 문제 제기
무엇을 기록할 것인가 — 재생본과 Production은 같지 않았다
베이스라인을 만들려면 먼저 "무엇이 정본인가"를 정해야 했다. 마이그레이션 전량을 로컬에 재생한 결과와 Production을 전수 대조하니 차이 10건이 나왔다. 어떤 마이그레이션도 만들지 않는데 Production에 살아 있는 테이블(public.sets — RLS 정책 4개를 단 채), 개명 전 이름으로 굳은 인덱스 2개와 제약 7건, 어떤 마이그레이션도 추가하지 않는 컬럼(exercise_archetypes.note — 96행 중 64행이 내용을 담고 있으나 앱은 읽지 않음), 같은 이름인데 on delete 동작이 다른 FK, add column으로 붙어 컬럼 순서가 다른 8테이블 66컬럼, 그리고 권한 차이(아래 §4).
테스트 계약의 거짓 초록
schema.sql이 현재 상태의 덤프로 바뀌면, 옛 마이그레이션 텍스트에 계약을 걸던 테스트는 전부 죽는다. 전수 조사(정적 grep이 아니라 베이스라인을 얹은 트리에서 실제 실행으로 귀속을 확정)하니 54파일 213곳이 재앵커 대상이었다. 이 과정에서 tests/react/schemaRls.test.mjs 단언 61개가 이미 drop된 객체를 보면서 통과 중이었다는 것도 드러났다 — 스쿼시가 아니었으면 계속 초록이었을 부패다.
이력을 잃으면 안 된다
125개 파일에는 스키마만이 아니라 결정의 근거(주석·postcheck·동적 치환의 흔적)가 실려 있다. 삭제된 테스트 2건과 재작성된 계약들의 존재 근거이기도 하다.
장부 재기록은 Production을 건드린다
스쿼시를 랜딩해도 Production의 supabase_migrations.schema_migrations 장부에는 옛 버전 125행이 그대로 남는다. 이걸 재기록하지 않으면 다음 db push부터 로컬과 원격이 서로 다른 역사를 주장한다. 장부 repair는 메타데이터만 만지지만 Production 접촉이므로 별도 승인 절차가 필요했다.
3. 해결 방안
원칙
- 베이스라인은 Production을 기록한다. 재생본이 아니라 Production 덤프가 본문이다. 드리프트 10건은 "정리하고 싶은 것"과 무관하게 일단 그대로 실린다 — 베이스라인에서 빼도 Production에서 사라지지 않으며, 빼는 순간 도면이 Production에 대해 영구히 거짓말을 하게 된다. 실제 정리는 각각 별도 마이그레이션으로 갚는다.
- 유저·운영 데이터는 한 행도 싣지 않는다. 참조 데이터(정식 카탈로그·정책 버전·표준)만 시드하고, 런타임에 쌓인 행은 전부 제외한다.
- 이력은 삭제가 아니라 봉인. 머지 직전 main 팁에
pre-squash-v2태그를 박아 125개 원문 전체를 열람 가능하게 남긴다. 태그가 머지보다 먼저 존재해야 한다. - 재생성은 1커맨드. main이 움직일 때마다 손으로 다시 만드는 게 아니라, 스크립트 (
build-baseline.mjs --reset --write)가 재생→덤프→멱등 재작성→시드 추출→조립을 반복 가능하게 수행한다. 2회 연속 생성 시 바이트 동일이 확보되도록now()기본값 컬럼은 INSERT에서 제외했다. - 증명은 실측 배터리로. "됐을 것"이 아니라 신선 재생·문장집합 대조·체크섬·pgTAP·JS 스위트를 매 재생성마다 전부 다시 돈다.
구조
| Phase | 무엇 |
|---|---|
| A | 재앵커 전수조사 · 시드 인벤토리 · 게이트 영향 조사 |
| B | 베이스라인 생성 + 검증 배터리 + 테스트 재앵커 |
| C | 최신 main 기준 전면 재생성 → 태그 → PR → 머지 |
| D | Production 장부 repair (오너 직접 승인 후) |
C는 병행 트랙(import-rework)의 마이그레이션 랜딩 완결을 게이트로 걸고, 그 신호가 올 때까지 "랜딩 가능 상태로 대기"를 유지했다. 프리즈 창 안에서는 이 PR 외에 main이 움직이지 않도록 조율됐다.
4. 적용한 내용
베이스라인 구성 — 덤프가 담지 못하는 것까지
20260821000000_baseline_v2.sql (32,417줄) 한 파일이 다음을 담는다.
- 정책 출처 블록 —
check:policies가 요구하는 e1rm 정책 마커는 주석에 살고, 덤프는 주석을 버린다. 원본 마이그레이션 헤더에서 그대로 옮겨 실었다. - 스키마 본문 — Production 덤프를 멱등 재작성(index 110 · unique index 16 · policy 96 · constraint 202건을
if exists/if not exists형태로)한 것. 재작성 덕에 가드 면제 목록 없이 기존check:migrations를 그대로 통과한다. - 덤프 밖의 실재 — public 스키마 덤프에 포함되지 않는 auth.users 트리거 2건, storage 버킷 3개, storage.objects RLS 정책 6건, pg_cron 잡 4건, 기본 권한 revoke. 이 중 기본 권한 revoke는 SEC-02 하드닝이 조용히 사라질 뻔한 것을 diff 대조가 잡아낸 항목이다.
- 참조 데이터 — 15테이블(정식 카탈로그 675행, 아키타입 96행, 강도 표준, 정책 버전 등). 유저·운영 데이터 0행.
오너 결정 2건
exercise_external_mappings는 26행만. Production에는 767행이 있지만 741행은 런타임 축적물이고(그중 342행은 판결이 아니라 미처리 큐), 31행은 실제 유저 uuid를 들고 있다. 마이그레이션 생성분 26행만 시드했다 — 신선 DB의 인입 매핑 경험이 Production보다 얕아지는 대가는 수용, 뒤집는 비용은 시드 섹션 한 곳.- 유령 객체는 그대로 기록.
public.sets·구명 인덱스·구명 제약·note컬럼 전부 베이스라인에 실린다(§3 원칙 1). 후속 폐기 대상은 드리프트 문서에 명시했다.
테스트 재앵커 — 텍스트 계약에서 상태 계약으로
재작성 62 · 삭제 2 · 신규 2. SQL 계약은 이제 tests/support/schemaSql.mjs 헬퍼를 통해 상태를 읽는다 — 매칭 전에 객체 하나로 범위를 좁히고, 덤프를 이 레포의 SQL 관용구로 렌더하며, 실효 권한을 마이그레이션이 쓰던 grant/revoke 쌍으로 파생시킨다. 손으로 유지하던 꼬리잠금 계약 3종은 "schema.sql = 마이그레이션의 버전 순 연결"을 단언하는 제네릭 스냅샷 계약 1종으로 대체됐다.
Phase C 집행 — 재생성과 랜딩
병합 시점의 main(#477 측정타입 수리, #485 재인입 프리미티브, #488~#491 BRID 컷오버)을 흡수해 전면 재생성했다. 커버리지가 지시서의 116개에서 120개, 최종 125개까지 늘어난 것은 작업 기간에 main이 계속 움직였기 때문이고, 재생성이 1커맨드라서 흡수 비용은 낮았다.
랜딩 직전 main에 #495(글리프 자산 단일본화)가 오너 직접 머지로 들어왔는데, supabase/·schema.sql 접촉 0건을 실측 확인하고 배터리 재실행을 면제했다 — 베이스라인 입력이 변하지 않았기 때문이다. 태그 pre-squash-v2(= 99040977)를 push한 뒤 #494를 스쿼시 머지했다(main c358c072).
스쿼시가 잡아낸 것 — 내부 엔진 4종이 시크릿 키로 열려 있었다
재생성 후 pgTAP에서 1건이 빨개졌다. main의 internal_surface_grant_hygiene.test.sql ("어떤 재작업 엔진도 자기 표면 밖에서 호출할 수 없다")이 베이스라인 DB에서 엔진 4종을 지목했다: import_wodup_batch_to_canonical_engine · stage_wodup_import_batch_engine · update_completed_session_v4_engine · delete_completed_session_v4_engine.
원인은 스쿼시 결함이 아니라 Production 드리프트다. 호스티드 프로젝트의 기본 권한은 public 함수 실행을 service_role에 부여하는데, 엔진 생성 마이그레이션들의 revoke가 from public, anon, authenticated까지만 걷고 service_role을 빠뜨렸다. 로컬 CLI 스택에는 그 기본 권한이 없어서 로컬과 CI는 계속 초록이었고, Production만 열려 있었다. 베이스라인은 Production을 기록하므로 그 grant를 그대로 실었고, 그래서 테스트가 처음으로 진실을 본 것이다.
기존에 1건(import_…_engine)으로 기록돼 있던 이 구멍이 4종으로 확장 실측됐다. 처리는 원칙 그대로 — 베이스라인은 수정하지 않고, 상환 revoke를 후행 마이그레이션 20260821000100_engine_grant_parity.sql로 스쿼시에 동승시켰다. "별건으로 갚는다"던 원래 결정을 바꾼 것인데, main의 위생 테스트가 이미 이 상태를 단언하고 있어서 분리하면 스쿼시 CI가 영구 레드가 되기 때문이다. postcheck는 이 마이그레이션이 직접 손댄 4개 시그니처만 단언한다(전역 불변식 금지 — #483→#484 사고의 교훈).
Phase D — Production 장부 repair
오너 직접 승인 후 집행했다. 명령은 파일로 써서 실행했다(과거 셸 백틱 오실행으로 repair가 인자 없이 나간 사고의 재발 방지).
- repair 전
migration list --linked스냅샷 확보 — 원격 125행 적용, 로컬 2벌 미기재. migration repair --status reverted×125 +--status applied 20260821000000— 장부 메타데이터만, DB에 SQL 0줄.db push1회 —20260821000100(엔진 revoke)만 실적용. 이것이 repair 후 첫 push의 자연 검증을 겸했고, D-9 상환이 Production에 실제로 나간 유일한 SQL이다.
작업 중 드러난 것
- worktree
node_modules정션 소실 재발. 검증 배터리 도중 정션이 사라져Cannot find package 'typescript'로 check 체인이 죽었다. 앞선 단계들(utf8·brand·정책 게이트)은 node 표준 라이브러리만 써서 통과했기 때문에 실패 지점이 원인 지점보다 한참 뒤였다.cmd /c mklink /J재연결로 복구. - 크로스세션 신호 유실. 랜딩 게이트의 최종 해제 신호 2통이 발신 기록에는 있으나 수신 세션에 도착하지 않았다. 발신 기록만 믿지 말고 수신 확인을 요구해야 한다는 것을 절차에 남겼다.
5. 적용 결과
레포·Production 상태
| 항목 | 결과 |
|---|---|
supabase/migrations/ | 125개 → 2벌 (20260821000000 베이스라인 + 20260821000100 엔진 grant 파리티) |
| 이력 | 태그 pre-squash-v2 = 99040977 (125개 원문 전체 열람 가능) |
| Production 장부 | 2행 1:1 정합 (빈 local/remote 열 0) |
check:remote-schema | EXIT=0, release contract failures 0 |
| 스키마·유저 데이터 | repair 과정 무접촉 (실변경은 의도된 엔진 revoke뿐) |
파리티 실측 (신선 재생 vs Production)
| 검사 | 결과 |
|---|---|
| 문장집합 대조 | diff 2 / 1,968 — 둘 다 PostgreSQL deparser의 CHECK 괄호 표기, 의미 동일 |
| 구조 지표 | 테이블 61 · 함수 230 · 정책 102 · 인덱스 208 · cron 4 · 버킷 3 · auth 트리거 2 · 기본 ACL 전부 일치 |
| 정식 카탈로그 체크섬 | origin='system' 675행 md5 일치 (BRID 컬럼 포함 — #488 전량 재발급 흡수 확인) |
| 참조 데이터 | 15테이블 행수 일치, 유저 데이터 유입 0행 |
엔진 권한 실측 (적용 후 Production)
delete_completed_session_v4_engine {postgres=X/postgres}
import_wodup_batch_to_canonical_engine {postgres=X/postgres}
stage_wodup_import_batch_engine {postgres=X/postgres}
update_completed_session_v4_engine {postgres=X/postgres}앞 세우는 표면(import_wodup_batch_to_canonical, update_completed_session_v4 등)은 자기 오디언스에게 그대로 열려 있음을 같은 테스트의 6번 단언이 확인한다.
게이트
| 게이트 | 결과 |
|---|---|
| pgTAP | 33파일 520단언 전부 통과 (신선 베이스라인 재생 기준) |
npm run check (JS 스위트 포함) | EXIT=0 · 1,560 테스트 / 0 실패 |
CI (verify · migration-smoke) | 통과 |
6. 이번 개선으로 향상된 것
신선 DB가 Production과 같다는 것을 증명할 수 있다
지금까지는 "마이그레이션을 다 돌리면 Production이 될 것"이라는 가정이었고, 실제로는 10건이 어긋나 있었다. 이제 신선 재생과 Production의 차이는 의미 동일한 괄호 표기 2건뿐이며, 그 사실을 문장집합 비교기로 언제든 재실측할 수 있다.
시크릿 키로 열려 있던 내부 엔진이 닫혔다
인입 엔진 2종과 완료 세션 수정·삭제 엔진 2종이 service_role로 직접 호출 가능한 상태였다. 표면(전이 RPC·권한 검사)을 우회하는 경로였고, 로컬에서는 재현되지 않아 어떤 게이트도 잡지 못하고 있었다. 이번에 회수됐고, 위생 pgTAP이 재발을 막는다.
계약이 진실을 본다
drop된 객체를 상대로 통과하던 단언 61건이 있던 자리에서, 이제 SQL 계약은 현재 상태의 덤프를 읽는다. schema.sql을 읽으면 DB가 실제로 어떤 상태인지 알 수 있고, 어긋나면 제네릭 스냅샷 계약이 빨개진다.
드리프트가 목록이 됐다
보이지 않던 Production과 재생본의 차이 10건이 문서 (docs/ko/data/supabase-migration-strategy.md)에 실명으로 기록됐다. 베이스라인이 Production을 기록하므로 랜딩과 동시에 "드리프트"는 아니게 됐지만, 그중 폐기할 것들은 후속 대상으로 명시돼 있다.
재생과 리뷰가 가벼워졌다
로컬·CI의 DB 구축이 125개 파일 순차 실행에서 2개로 줄었다. 새 마이그레이션의 리뷰 기준도 단순해졌다 — schema.sql은 마이그레이션의 버전 순 연결과 정확히 같아야 한다는 계약 하나다.
이력은 잃지 않았다
git show pre-squash-v2:supabase/migrations/<파일명>으로 125개 원문 전체와 그 안의 결정 근거를 언제든 읽을 수 있다. 삭제된 테스트 2건의 존재 근거도 같은 태그에 있다.
남은 것
이번 범위 밖으로 남긴 항목이다. 전부 베이스라인 수정이 아니라 별도 마이그레이션으로 갚는다.
public.sets드롭 — 0행이지만 살아 있는 쓰기 표면(읽기 정책에 소유자 조건 없음). 보안 후속으로 분리.refresh_wodup_complex_interpretations_v1()→authenticated,training_effective_load(...)→anon— Production이 재생본보다 넓은 함수 2건. 판단 대상.- 개명 전 이름의 인덱스 2·제약 7 — 표기 문제. 위험 대비 이득이 낮아 미룸.
exercise_archetypes.note64행 — 아무도 읽지 않는 내용. 폐기 전에 내용 확인 선행.exercises_archetype_id_fkey의on delete동작 차이 — 유일한 의미 차이. 정리가 아니라 판단 필요.- 읽기 엔진 10종(
get_*_engine)의service_role실행 권한 — 엣지 service key 경로의 정규 오디언스로 잠정 판단(위생 테스트 스코프 밖, 현행 계약이 보증). 이견 시 재검토. - 트리거 함수 33개의 Production 권한 드리프트 — 재생으로 재현되지 않는 권한. 각각의 직접 호출자 유무 판정이 선행돼야 하는 별건.