schema.sql을 실제 DB 상태 스냅샷으로 — 마이그레이션 163개를 이어 붙인 10.2 MB 이력 사본에서 재생한 DB의 3.3 MB 덤프와 그것을 지키는 3중 검사까지 (2026-09-05)
- 기간: 2026-09-04 ~ 2026-09-05 00:10 (세션 3개 —
6f451501분석·Phase 0·1·재앵커 시작,d3209c36Phase 2·3,0be874c3Phase 4·5. 오너 질문 원문: "schema.sql은 지금 효율적으로 짜져 있을까? 파일이 엄청 크다고 했는데 개선의 여지가 있을지 봐줄래?") - 랜딩: PR #1263(Phase 0~4,
401029e02026-09-05 00:08 KST, CI 6회 — 1차 빨간불(identity 열 비결정성)·2~6차 초록: verify·migration-smoke(--check포함)·browser-journeys 2샤드·viewport-matrix) — 마이그레이션 없음, Vercel 배포 없음(앱 코드 변경 없음) - 설계서: 없음 — 분석·Phase 계획·예상 효과는 이슈 #1252 첫 댓글
- 정본:
docs/data/supabase-migration-strategy.md"Schema Snapshot" 절(en·ko) · 참조 데이터 목록supabase/contracts/reference-data-tables.json(19표) ·supabase/migrations/README.md"schema.sql" 절 ·supabase/README.md - 도구:
scripts/migrations/build-schema-snapshot.mjs(npm run schema:snapshot,--check·--reset·--sandbox) ·scripts/migrations/renumber-to-tail.mjs(이제 지문 헤더만 갱신) - 게이트:
tests/react/schemaSnapshotContract.test.mjs(머리 지문 == migrations 폴더 지문 · 함수 시그니처 단일 정의 · CRLF 없음) · CIpolicy-contract.ymlmigration-smoke 잡db reset직후build-schema-snapshot --check·npm run ci:localdb 단계(reset →--check→ pgTAP) ·npm run db:preflight(--check실패 = 기록 없음) · 로컬 통과 기록:npm run ci:local -- --full2026-09-04 15:58~16:05 KST(HEADfdb67f67) — verify 5단계 · db reset · 스냅샷--check일치(19초) · pgTAP 104파일/1776 assert · e2e local 11/11·empty 7/7·cardio 6/6·browser 35/35·viewport 13/13(브라우저 2단계는 포트 4173 점유로 따로 실행) · 결정성 수리 뒤 관련 테스트 48건·npm run check2569/2569(06df700e) - 버그리포트: 없음(도구·테스트·문서 트랙). 덤프가 드러낸 제품 결함 의심은 이슈 #1258·#1259로 분리
- 계약:
docs/process/migration-landing.md0·2단계(충돌 해법 = 생성기 재실행) ·docs/process/ci-local.mddb 단계 표
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 생성 규격 확정·문서화(덤프 범위·정규화 규칙·입력 지문), 참조 데이터 목록 등재 | ✅ PR #1263 |
| Phase 1 | 생성기 build-schema-snapshot.mjs(재생 → 덤프 → 멱등 재작성 → 참조 데이터·덤프 밖 실재 → 지문 헤더, 2회 생성 바이트 동일 자체 검사) | ✅ PR #1263 |
| Phase 2 | 검사 3중 배치(지문 계약 · CI smoke --check · ci:local/preflight --check), renumber는 지문만 | ✅ PR #1263 |
| Phase 3 | 단위 테스트 재앵커 32파일(파일 전체 문자열 단언 18곳 → 0) + 문서 6곳 | ✅ PR #1263 |
| Phase 4 | 최신 main 기준 첫 생성 → ci:local --full → PR 1개(합류 6회·CI 6회) → 머지 | ✅ PR #1263 (401029e0) |
| Phase 5 | 작업 기록 + 사이드바·README 등록 | ✅ 이 문서 |
1. 배경
8/21 베이스라인 v2 스쿼시는 supabase/schema.sql을 "이력 대장이 아니라 현재 상태"로 만들었고 문서(supabase/migrations/README.md, 전략 문서)도 그렇게 적었다. 그런데 그 뒤의 생성 규칙이 "새 마이그레이션을 그대로 뒤에 붙인다"(renumber-to-tail.mjs)였기 때문에 파일은 다음 랜딩부터 다시 이력이 됐다. 2주·랜딩 125회 만에 2.1 MB → 8.2 MB(오너 질문 시점), 9/4 저녁 main에서는 10.2 MB·198,647줄이었다. v2 스쿼시에 쓴 생성기는 세션 scratchpad에만 있어 레포에 없었고, 다시 접는 주기도 없었다.
오너 질문(9/4): "schema.sql은 지금 효율적으로 짜져 있을까? 파일이 엄청 크다고 했는데 개선의 여지가 있을지 봐줄래?"
2. 문제 제기
"현재 상태"를 약속하고 "이력 연결"로 구현했다
실측(main d13550ec, 9/4): 함수 정의 텍스트 5.40 MB(파일의 71%) 중 뒤에 다시 정의돼 죽은 텍스트가 359개·3.06 MB(37%), drop된 함수인데 create가 앞에 남은 것 92개, group_validated_board_exercises_v1은 15번 재정의. 랜딩 PR 하나의 diff는 마이그레이션 594줄 + 같은 내용의 schema.sql 595줄(#1249 실측)로 두 배였다.
죽은 텍스트를 상대로 초록이 나는 단위 테스트가 있었다
파일 전체를 문자열로 찾는 단언 11파일 18곳은 #1215가 없앤 planned_sessions/v4 엔진, #1175 전의 text id·slug, complete_onboarding_v2(jsonb) 같은 옛 정의에 걸려 통과하고 있었다. 실제 상태 덤프로 바꾸자 29파일 약 80건이 빨간불로 드러났고(생성기 결함 0건), #1237 합류 뒤에는 v1 추정기·effort_level 쓰기 단언 2건이 같은 방식으로 더 드러났다.
다시 만드는 도구가 레포에 없었다
"상태 덤프를 다시 뽑는다"가 1커맨드가 아니라 v2 규모(테스트 54파일 213곳 재앵커)의 작업이었다.
3. 해결 방안
원칙 (오너 결정 D1~D3, 2026-09-04)
- A안 채택 — schema.sql = "마이그레이션을 로컬 샌드박스에 재생한 뒤 뽑은 실제 DB 상태 덤프". 마이그레이션 파일은 한 글자도 바꾸지 않는다(이력은
supabase/migrations/와 git이 보존). - D1 = ① v2식 멱등 재작성 유지(
create table if not exists· index/policy/constraintif exists/if not exists) — 테스트 도우미tests/support/schemaSql.mjs의 전제가 그대로 유효. - D2 = ① 참조 데이터 포함 — 코드가 시드한 표는
reference-data-tables.json에 등재하고 그 행을 담는다. 유저·운영 데이터 0행. - D3 = ③ 진행 중 마이그레이션 트랙(#1215·#1238·#1237)이 랜딩된 뒤 머지. Phase 0~3은 브랜치에 먼저 쌓고 Phase 4만 뒤로. 저녁에 #1215·#1237이 랜딩되고 #1238만 남은 시점에 충돌 비용 실측(생성기 1회, 약 2분)을 근거로 재확인을 올렸고, 오너가 "문제 없으면 그냥 머지"로 ②(지금 머지)를 택했다. #1238은 main을 받을 때 생성기 1회로 흡수한다.
접근
| 대안 | 판정 |
|---|---|
| A. 실제 DB 상태 덤프로 전환(생성기 + 3중 검사) | 채택 — 파일이 정의대로 "현재 상태"가 되고, 랜딩 수와 무관하게 크기가 유지되며, 손 편집·이력 회귀를 검사가 거부한다 |
| B. 이어 붙이되 겹치는 함수 정의만 텍스트로 접기 | 기각 — 테이블·권한 변경은 여전히 이력으로 남고, SQL을 문자열로 해석해 틀릴 수 있다 |
| C. 주기적으로 다시 스쿼시 | 기각 — v2 규모 작업을 몇 주마다 반복해야 하고 구조가 그대로다 |
4. 적용한 내용
Phase 0 — 규격 문서화
전략 문서 en·ko에 "Schema Snapshot" 절: 덤프 범위(public 스키마 DDL · auth.users 트리거 · storage 버킷·정책 · pg_cron 잡 · 기본 권한 · 참조 데이터), 정규화 규칙(멱등 재작성, 정렬 고정, now()가 값으로 들어간 시각 열 제외), 머리의 입력 지문(-- migrations-fingerprint: sha256:… / -- migrations: N files, tail V). 참조 데이터 목록 19표(exercise_synonyms 포함 — 목록 밖 표에 행이 있으면 생성기가 표 이름을 말하며 실패하는 가드가 두 번 잡아냈다: Phase 0 의 exercise_synonyms, 6차 합류의 exercise_catalog_changes — 후자는 카탈로그 시드에 행 트리거가 따라 쓰는 변경 로그로 "시드에서 파생되는 표"로 등재).
Phase 1 — 생성기
docker exec supabase_db_<project_id>로 pg_dump --schema-only --schema=public --quote-all-identifiers --no-owner + psql JSON 쿼리(덤프 밖 실재·참조 데이터) → 멱등 재작성 → 덤프 주석·\restrict 제거(함수 본문 안 --는 유지) → 정렬 → 지문 헤더. 쓰기 모드는 2회 생성 바이트 동일일 때만 파일을 쓴다. 결정성 수리 3건: 기본키가 INSERT에서 빠지는 열(exercise_synonyms.id = gen_random_uuid)이면 실리는 열 전체로 정렬, 마이그레이션이 값으로 now()를 써 넣은 시각 열(cutlines_generated_at·reviewed_at)은 "재생 시작 시각 이후 값이 있는 시각 열" 판정으로 제외, 그리고 CI가 잡은 세 번째 — 시퀀스(nextval)·identity 열(exercise_strength_standards.id)도 재생 시점에 DB가 정하는 값이라 INSERT·정렬에서 제외(규칙을 "재생 시점에 DB가 정하는 값"으로 일반화). 컷라인 생성기 generate-strength-standard-cutlines.mjs 입력은 베이스라인 v2 파일로.
Phase 2 — 검사 3중 + renumber
① schemaSnapshotContract "연결과 같다" → "머리 지문 == 현재 migrations 폴더 지문"(순수 node, Docker 없이) ② CI migration-smoke 잡 db reset 직후 --check(내용 일치는 여기서) ③ ci:local db 단계(reset → --check → pgTAP, 실패 시 고치기 명령을 요약에 출력)·db:preflight(--check 실패 = 통과 기록 없음) ④ renumber-to-tail.mjs는 본문 재생성을 멈추고 지문 헤더만 갱신(개명은 DB 상태를 바꾸지 않으므로) ⑤ npm run schema:snapshot.
Phase 3 — 재앵커·문서
42파일(29 + 합류 2차 3 + 3차 5 + 4차 2 + 6차 3) 약 95건. 파일 전체 문자열 매치는 객체 범위 도우미(functionBody·constraintsFor·indexesFor·tablePrivilegesFor·functionGrants·tableColumns)로 좁히고, 순수 이력 단언(법적 문서 v2/v3 순서, 마이그레이션 문장 형태 drop column if exists …, 자기 자신과 비교하던 항등 단언)만 삭제. 도우미 schemaSql.mjs 파생 권한 대장 확장(grant all on function, CREATE TABLE 인라인 제약 → alter table … add constraint). 문서 6곳(supabase/README.md, supabase/migrations/README.md "Append it verbatim" 절 교체, 전략 문서 en·ko, migration-landing.md 0·2단계, ci-local.md).
Phase 4 — 첫 생성·랜딩
main 합류 6회(#1236 PR #1254, #1237 PR #1260, #1244·#1245·#1246 묶음 PR #1266, #1258·#1259 PR #1268, #1235 PR #1265, #1238 PR #1271) — 매번 schema.sql 충돌은 git checkout --ours 뒤 생성기 1회 실행으로 해소(이 트랙이 약속한 절차를 이 브랜치가 첫 실전으로 겪었다). 최종 172 마이그레이션 → 3.25 MB(생성기 표기 3.10 MB)·39,578줄(#1235 데이터 수리 마이그레이션은 빈 DB 상태를 바꾸지 않아 지문 3줄만 변경; #1238 은 시드 파생 변경 로그 표 등재로 약 1,200줄 추가), 2회 생성 바이트 동일. npm run ci:local -- --full → PR #1263 · CI 1차 빨간불(아래) → 수리 06df700e → CI 2차 초록 → 머지 직전 main 이 다시 움직여(#1244~#1246) 3차 합류·재앵커 5건(76b8ab3e) → CI 3차 → squash 머지.
주요 결정과 그 근거
- 내용 일치 검사를 순수 node에서 샌드박스·CI로 옮겼다. 덤프는 Docker가 필요하다. 대신 Docker 없이도 "이 파일은 지금 마이그레이션 집합으로 만든 것"은 지문 계약이 검사한다. #1224 이후 랜딩 전 preflight가 이미 샌드박스를 요구하므로 새 요구는 아니다.
- 손 편집 금지·손 병합 금지. 지문 계약과
--check가 거부한다. 충돌 해법은 항상 "생성기 재실행". - 참조 데이터는 목록으로만. 목록 밖 표에 행이 있으면 생성기가 실패한다 — 유저 데이터가 파일에 새는 길을 막는 가드.
작업 중 드러난 것
- 실제 상태 덤프가 처음 드러낸 제품 결함 의심 2건 → #1258(drop 후 재생성 함수 6개 anon·authenticated EXECUTE 잔여 — 옛 파일엔 revoke 줄만 보여 테스트가 초록), #1259(유저 기록 표 앱 계정 TRUNCATE·REFERENCES·TRIGGER 잔여).
- CI 1차 빨간불 — 로컬로 재현 불가한 비결정성. migration-smoke의
--check가exercise_strength_standards행 순서에서 실패.id는 DB가 재생 때 순번을 매기는 identity 열인데 시드가select … from jsonb로 순서 없이 넣어 Linux CI와 Windows 로컬에서 행마다 다른 번호가 붙었다. 로컬 2회 재생은 우연히 같은 순서였다(§20 분류: 재현 불가 — OS·실행 계획 차이). 수리는 규칙 일반화(위 Phase 1 세 번째). - 머지 경쟁. 이날 main 이 시간당 1회꼴로 움직여(#1237 → #1244~46 → #1258·59 → #1235 → #1238) CI 초록을 받고 머지하러 가면 이미 뒤처져 있는 일이 4번 반복됐다. 합류·재생성·check·CI 한 바퀴가 약 20분이라, 마지막엔 "CI 전부 통과 즉시 자동 머지" 대기 스크립트로 틈을 없앴다. 다음에 이런 파일(모든 마이그레이션 랜딩이 건드리는 파생 파일)을 바꾸는 트랙은 처음부터 그렇게 한다.
- main 합류 때마다 같은 종류의 재앵커가 나온다. #1236 합류 2건, #1237 합류 3건(v1 추정기 삭제·
effort_level삭제), #1244·#1245 합류 5건(세부 세트 소속 열 이름session_exercise_part_id, 삭제된 composite_meta 합성 함수,$_$본문 태그를$$;로 자르던 도우미), #1258·#1259 합류 2건(Phase 3가 "현재 사실"로 못박아 둔 과잉 권한이 회수됨 — 덤프가 드러낸 결함을 다른 트랙이 고치자 기대값을 계약대로 바꿈) — 모두 이어 붙인 옛 파일에 남은 죽은 텍스트로 통과하던 단언이었다. 이 트랙 뒤로는 그런 단언이 처음부터 빨간불이다. - 워크트리 node_modules 정션과 샌드박스 migrations/tests 정션이 작업 중 사라져(다른 세션의 정리로 추정) 다시 만들었다. 정션이 없으면
db reset이 마이그레이션 0개를 적용해 빈 DB가 되고cron.job does not exist로 실패한다 — 생성기 실행 전 정션 확인. --check는db reset직후에만 의미가 있다. pgTAP 뒤에 돌리면 pgtap·dblink 확장 때문에 불일치가 정상.- 포트 4173을 다른 세션의 e2e 미리보기 서버가 쓰고 있어
ci:local --full을 브라우저 단계 제외로 먼저 돌리고browser,viewport는 포트가 빈 뒤 따로 돌렸다(#1241 선례). - Bash 히어독이
\s·\(를 먹는다(Edit/Write 툴로) ·cmd //c mklink /J는 Git Bash가/J를 깨뜨린다(PowerShellNew-Item -ItemType Junction) · 워크트리 CRLF 때문에 node 문자열 치환이 빗나간다.
5. 적용 결과
| 항목 | 전(main abf6289f) | 후 |
|---|---|---|
| schema.sql 크기 | 10.9 MB · 212,940줄(머지 직전 main; 오너 질문 시점 8.2 MB·158,486줄) | 3.37 MB · 39,577줄 |
| 함수 정의 문장 | 1,137(9/4 저녁 main; 9/4 오전 실측 878 중 죽음 359) | 전부 현재 정의(시그니처 단일, 지문 계약이 단언) |
| drop된 함수의 create 잔존 | 92 | 0 |
| 랜딩 1회의 schema.sql diff | 마이그레이션과 같은 내용 복사(#1237 +9,449줄) | 실제 바뀐 정의만(#1237 합류 +485/−471줄, #1258·#1259 권한 회수 합류 −238/+166줄 수준) |
| 파일 전체 문자열 단언 | 11파일 18곳 | 0곳(문자열 슬라이스 도우미 1곳도 객체 범위로) |
| 죽은 텍스트로 통과하던 단언 | 29파일 약 80건(+ 합류 15건) | 0(현재 사실로 재앵커 또는 삭제) |
| 재생성 도구 | 없음(v2 스크립트 유실) | npm run schema:snapshot 1커맨드(reset 포함 약 2분) |
| 검사 | "연결과 같다" 1중 | 지문 계약 · CI --check · 로컬 --check 3중 |
| 검증 | — | npm run ci:local -- --full 2026-09-04 15:58~16:05 KST(HEAD fdb67f67) — verify 5단계 · db reset · 스냅샷 --check 일치(19초) · pgTAP 104파일/1776 assert · e2e local 11/11·empty 7/7·cardio 6/6·browser 35/35·viewport 13/13(브라우저 2단계는 포트 4173 점유로 따로 실행) · 결정성 수리 뒤 관련 테스트 48건·npm run check 2569/2569(06df700e) · npm run check 2569/2569 · CI 1회 초록 |
미검증: 단위 테스트 파싱 시간 감소(예상 효과 표의 "프로세스당 0.25~0.3초 × 51")는 따로 재지 않았다. git gc로 낱개 사본 정리는 로컬 PC마다의 선택 사항으로 남긴다.
6. 이번 개선으로 향상된 것
파일이 이름값을 한다
supabase/schema.sql을 열면 지금 DB에 있는 것만 보인다. 사람·에이전트가 "이 함수가 지금 어떤 모양인가"를 마지막 정의를 찾아 내려가지 않고 읽는다.
테스트가 죽은 텍스트에 속지 않는다
사라진 객체·옛 시그니처를 단언하면 바로 빨간불이다. 이번 트랙만으로 그런 단언 85건과 제품 결함 의심 2건(#1258·#1259)이 드러났다.
랜딩 리뷰가 절반이 된다
마이그레이션 + 실제로 바뀐 정의만 diff에 남는다. 크기는 랜딩 수와 무관하게 실제 상태 크기만 유지된다.
구조적으로 남는 것
"schema.sql = 재생한 DB의 덤프"라는 정의와 그것을 지키는 3중 검사. 다음에 누가 마이그레이션을 어떤 형태로 쓰든, 지문 계약과 --check가 파일이 이력으로 되돌아가는 것을 거부한다. 충돌 해법은 항상 "생성기 1회 실행" 한 줄.
남은 것
- #1258·#1259 — 덤프가 드러낸 권한 잔여 2건(별도 트랙, 마이그레이션 필요).
- 머지 시점에 열려 있던 마이그레이션 브랜치(#1238 등)는 main을 받을 때 schema.sql 충돌을 "생성기 1회 실행"으로 흡수한다 — 절차는
docs/process/migration-landing.md0단계. - 단위 테스트 파싱 시간 실측(선택).