v0.18.0 D13 — 빈 DB 재생만 있고 "데이터가 쌓인 DB 에서 이 마이그레이션이 얼마나 잠그고·걸리고·중간에 죽으면 어떻게 되는가" 를 아무도 재지 않던 것에서, 위험 분류·populated upgrade harness·배치 백필·랜딩 게이트까지 (2026-09-07)
- 기간: 2026-09-07 ~ 2026-09-07 (클라우드 세션 1개
session_01LK3nBvkkq1CVkjzUgaKryt, 오너 지시 "1287 진행해줘" → 분석·Phase 계획·착수를 한 댓글로 게시하고 중단 없이 진행) - 랜딩: PR (Phase 1~5 한 PR, squash) — 브랜치
claude/1287-n1gacw, basebeff760b(G04 #1315 머지 직후). 마이그레이션·엣지 함수·앱 화면 변경 없음(스크립트·테스트·문서·package.json스크립트 3줄·check-supabase-migrations훅 1개·coverage-inventory.json규칙 1줄). 총괄 D13 카드 · 계획 ID D13 · Phase 1 스텝 1-4 - 설계서: 없음 — 분석·전제·Phase 계획은 이슈 #1287 댓글
- 정본:
docs/data/populated-db-upgrade.md(위험 부류표·헤더·harness·fixture·digest·게이트·예산·백필·공존 조건·중단/재실행/forward repair·R05 템플릿) ·supabase-migration-strategy.md"Populated-DB Upgrade" 절(en·ko) · 랜딩 절차 0-1-1 - 도구:
scripts/migrations/migration-risk.mjs(npm run migrations:risk) ·scripts/migrations/upgrade/{harness,digest,coexistence}.mjs(npm run db:upgrade) ·scripts/migrations/backfill-runner.mjs(npm run db:backfill) ·scripts/migrations/upgrade/platform-shim.sql+shim-extensions/(Docker 없는 PC 의 도구 검증용, 랜딩 증거 아님) - 게이트: 새 단위 테스트 4파일 21건(
migrationRisk·backfillRunner·upgradeHarness·migrationRiskGate,npm test자동 편입, Docker 불필요) + 실 Postgres 검사 2파일 6건(tests/db/{backfillRunner,upgradeHarness}— 대상 없으면 skip, 이 세션은 로컬 PG16 에서 6/6 통과).check:migrations에 위험 헤더 정적 검사,db:preflight·landing:lock acquire에 헤더·증거(env=supabase-sandbox) 게이트. 마이그레이션·pgTAP·e2e 미접촉. Supabase 샌드박스 실측은 이 세션에서 미실행(Docker 데몬 없음) — 통과로 합산하지 않는다 - 버그리포트: 없음(도구 트랙). 발견 사항은 §4 "작업 중 드러난 것" 과 HQ 갱신안으로
- 계약: 새 계약 문서 1(정본). 기존 원본 불변 계약·SQL 원천·랜딩 절차의 규칙은 그대로이고 0-1-1 단계가 더해졌다. 공유 sentinel:
package.json스크립트 3줄(의존성·lock 무변경),scripts/check-supabase-migrations.mjs훅 1개 — PR 본문에 사유 기재
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 선행 확인(G05 80572704·G02 b0e5076a·D03 16b8e996), 환경 실측(Docker 없음·PG16), 직전 릴리스 → main 의 실제 delta(#1256 2개, 그중 populated 표 전체 UPDATE 1개) 확인, 계획·착수 댓글 | ✅ 이슈 댓글 |
| Phase 1 | 위험 분류기 — 부류 50여 종(잠금·재작성·스캔·WAL·재실행·공존), 헤더(문장 해시 지문)·증거 줄 파서, 게이트 판정, 증거 템플릿. 저장소 마이그레이션 174개 전부 분류(모르는 문장 0) | ✅ |
| Phase 2 | 백필 primitive — 키셋 커서·배치 트랜잭션·lock_timeout 재시도·checkpoint 파일·재개·중단 주입·advisory lock·증거. 실 Postgres 1,000행 배치 100·중단 3배치 뒤 재개·FOR UPDATE 공존 | ✅ |
| Phase 3 | upgrade harness — from 상태 격리 DB(샌드박스/bare shim)·fixture(데이터 사본/workload)·사실 digest(원본·ID·간선·source)·파일별 적용 측정(시간·WAL·크기·잠금 샘플러·옛 앱 프로브)·중단/재실행·공존 diff·원천 대조·빈 DB replay·증거 JSON/MD/줄. v0.17.0 → main 실측(bare PG16 shim, history-1y) | ✅ |
| Phase 4 | 게이트 — check:migrations(정적 헤더·증거 형식), db:preflight(새 파일 헤더·env=supabase-sandbox 증거, 기록에 위험 요약), landing:lock acquire(어느 도구의 기록이든 잠금 직전 재판정, status 표시), package.json 스크립트 3개 | ✅ |
| Phase 5 | 정본 문서·전략 문서 절(en/ko)·랜딩 절차 0-1-1·이 기록·등록 2곳·장부 규칙·npm run check·PR·인계 | ✅ |
1. 배경
v0.18.0 은 D02·D09·D12 등 여러 트랙이 DDL·백필을 내는 릴리스다. 지금 있는 것은 빈 DB 재생(CI migration-smoke·db:preflight)과 Production 에서 되돌리는 트랜잭션으로 원본 파괴 건수만 세는 loss-audit 뿐이다. 직전 릴리스(v0.17.0 = production 478ddf12, 마이그레이션 172개)와 main(174개)의 차이 2개 중 reps_stats_projection_v1 은 populated 표 exercise_set_part 전체 UPDATE 를 한 트랜잭션 안에서 한다 — 빈 DB 에서는 0행·0ms 라 어떤 검사도 이 사실을 보지 못했다. 총괄 F24·F28·C03 이 "데이터가 있는 DB 의 migration·잠금·backfill 안전성" 을 D13 으로 배정했다.
2. 문제 제기
잠금·재작성·WAL 을 정적으로 분류하는 자리가 없었다
check:migrations 는 멱등 관용구·원본 DML 티켓·권한 선언만 본다. CREATE INDEX(쓰기 차단)·ALTER COLUMN TYPE(재작성)·ADD CONSTRAINT CHECK(전체 스캔 + ACCESS EXCLUSIVE)·전체 UPDATE(행 잠금 전체·DDL 차단)를 구분하는 규칙이 어디에도 없다.
"값이 보존되는가" 의 절반만 있었다
loss-audit 는 원본 열의 파괴·삭제 건수를 센다. canonical ID 집합·부모/자식 간선(고아)·source identity·중단 뒤 재실행은 검사하지 않는다.
옛 앱이 그동안 살아 있는지 아무도 묻지 않았다
main 머지 = staging, 릴리스는 앱·DB 를 한 몸으로 내보내지만 DB 적용은 앱 배포보다 먼저 끝나고 사용자 기기에는 옛 앱이 캐시돼 있다. door 함수 삭제·시그니처 교체·열 삭제·기본값 없는 NOT NULL 열이 옛 앱을 42883·42703·23502 로 깨뜨리는데, 그것을 두 ref 사이에서 기계적으로 찾는 도구가 없었다.
긴 백필의 "어디까지 했는가" 가 없었다
마이그레이션 파일 = 트랜잭션 하나. 죽으면 전부 되돌아가고, 다시 돌리면 처음부터 전부다. 배치·커서·checkpoint 가 없어 Production 규모의 백필을 안전하게 나눌 길이 없었다.
3. 해결 방안
원칙 (오너 결정 없음 — 이슈 댓글의 전제 5개로 진행, 2026-09-07)
- 새 마이그레이션·DB 객체 없음 — checkpoint 는 러너 PC 파일 +
pg_advisory_lock+ 멱등 술어. 운영 DB drift 0. - 낮은 위험은 기계가 쓰는 헤더 한 줄이 전부. high 만 populated 증거를 요구. 백필 프레임워크는 실측이 예산을 넘긴 백필에만.
- DDL/backfill 의 내용은 SQL object 담당이 쓴다. 도구는 분류·측정·비교·재개만.
- Supabase 샌드박스 실측은 이 세션에서 불가(Docker 없음) → 도구 검증은 순수 node + 로컬 PG16(
tests/db, bare shim). 랜딩 증거는env=supabase-sandbox만 받는다. package.json스크립트 3줄·check-supabase-migrations.mjs훅은 공유 sentinel — PR 본문 사유 기재.
접근
| 안 | 판정 |
|---|---|
| A. 정적 분류 + 실측 harness + 러너 쪽 checkpoint + 게이트 연결 | 채택 — 정적으로 아는 것(문장 부류)과 실측으로만 아는 것(시간·잠금·WAL)을 나누고, 게이트는 등급에 비례해 요구한다 |
| B. 모든 UPDATE 를 배치 백필 프레임워크로 강제 | 기각 — 이슈 완료 조건 위반. 3,120세트 fixture 에서 전체 UPDATE 는 2초 — 대부분의 백필은 한 트랜잭션이 옳다 |
| C. checkpoint 표를 DB 에 만든다 | 기각 — 운영 DB 에 도구 객체가 생겨 check:remote-schema·D03 원천과 어긋난다 |
| D. 이 세션에서 Supabase 샌드박스 실측을 흉내내 "통과" 로 적는다 | 기각 — 지원 환경 부재는 통과가 아니다. bare shim 증거는 env=bare-postgres-shim 으로 표기하고 게이트가 거부한다 |
| E. pg_cron·vault 없이 shim 없이 재생 | 불가 — 베이스라인이 CREATE EXTENSION pg_cron·supabase_vault·auth.users·storage.buckets·supabase_realtime publication 을 요구한다. 순수 SQL 가짜 확장 2개 + 플랫폼 shim 으로 172개 전부 재생됐다(14~23초) |
4. 적용한 내용
Phase 1 — 위험 분류기 (scripts/migrations/migration-risk.mjs)
문장 분리는 D03 splitStatements 를 재사용. 부류 50여 종을 여섯 축으로 정의(RISK_CLASSES), 같은 파일에서 만든 표의 인덱스·제약은 low, 행이 없는 표(--sizes)는 high → medium, 공존 부류(drop/rename/…)는 행 수와 무관하게 high. DO $$ 블록은 declare/begin 껍데기와 if … then·foreach … loop 머리를 벗겨 안의 문장을 같은 규칙으로. 헤더 -- migration-risk: v1 level=… classes=… fingerprint=… 의 지문은 실행 문장의 해시(renumber 에 불변). evaluateRiskGate 가 정적/랜딩 두 모드로 판정하고 --template 이 PR 표를 낸다. 저장소 174개 파일 전부 분류(low 56·medium 60·high 58, 모르는 문장 0 — 테스트 ⑥ 이 닫힌 목록으로 고정).
Phase 2 — 백필 primitive (scripts/migrations/backfill-runner.mjs)
spec v1(table·key·where·batch 의 /). 배치 = 트랜잭션: set local lock_timeout → 다음 키 N 개 → 범위 문장 → commit → checkpoint. 55P03 은 지수 대기 뒤 재시도(수 기록). 같은 spec 지문·같은 DB 만 재개. --stop-after·--dry-run·--reset. 실 Postgres 검사 4건 통과(1,000행/10배치·중단 3배치 뒤 7배치 재개·다른 세션 FOR UPDATE 와 2회 재시도 뒤 완료·advisory lock).
Phase 3 — upgrade harness (scripts/migrations/upgrade/)
digest.mjs: 등급 목록에서 표·원본 열·parents·source 열을 골라 표당 JSON 한 행(행 수·ID 해시·원본 해시·간선 자식/고아/해시·source 해시·source 별 수), 행 단위 표본, 종류별 비교. coexistence.mjs: 두 schema.sql 을 D03 모델로 읽어 door 삭제·EXECUTE 회수·반환형 변경·표/열/뷰 삭제·기본값 없는 NOT NULL 열을 break, 타입 변경·정책 삭제·RLS 변경·내부 함수 삭제를 경고로. harness.mjs: 준비(sandbox/bare/none) → fixture → digest → 프로브·잠금 샘플러 세션 → 파일별 비동기 적용(시간·WAL·크기·장부) → --interrupt → digest 비교(표본) → --check-definitions → --empty-replay → 증거 JSON/MD/줄. Production URL 거부.
실측(bare PG16 shim, history-1y workload 260세션·3,120세트, v0.17.0 478ddf12 → worktree beff760b)
| 항목 | 값 |
|---|---|
| from 재생 172개 / 빈 DB replay 174개 | 22.5초 / 23.1초 |
20260913000000_reps_stats_projection_v1.sql (high: backfill-dml·add-column·create-table-as·drop-table) | 1,951ms · WAL 3.98MB · 표 크기 +3.02MB · 옛 앱 프로브 최대 1세션 1,893ms 대기 · 프로브 오류 0/5 · 중단 5/11 뒤 롤백 → 재실행 |
20260913000100_reps_stats_readers_v1.sql (low: function) | 85ms · WAL 0.08MB · 잠금 대기 0 · 프로브 0/7 |
| 사실 digest(원본 표 23개, 원본 행 8,854) | 전후 동일 · 중단 직후도 전과 동일 |
| 공존 diff v0.17.0 → main | 함수 376 → 376 · 표 77 → 77 · break 0 · 경고 0 |
| 원천(definitions) 대조 | 차이 0건(참고용 — shim 덤프) |
| 증거 줄 | env=bare-postgres-shim fixture=history-1y from=478ddf12 to=beff760b migrations=2 elapsed=2036ms lock-wait=1/1893ms wal=4.06MB probes=0/12 coexistence=0 interrupt=20260913000000:5/11 facts=preserved |
실측 2 — Supabase 샌드박스(랜딩 증거, 2026-09-07 세션 08b11132, Docker 있는 Windows PC): env=supabase-sandbox · PostgreSQL 17.6(Production 이미지) · fixture history-4y(세션 1,043 적재, 거부 0 · 원본 표 행 33,127 · digest 대상 표 23) · v0.17.0 478ddf12 → worktree 950edf67.
| 항목 | 값 |
|---|---|
20260913000000_reps_stats_projection_v1.sql (high) | 5,638ms · WAL 19.99MB · 표 크기 +12.42MB · 옛 앱 프로브 최대 1세션 5,269ms 잠금 대기 · 프로브 p95 488ms/max 5,344ms · 오류 0/26 · 중단 5/11 뒤 롤백(294ms) → 재실행 |
20260913000100_reps_stats_readers_v1.sql (low) | 296ms · WAL 0.09MB · 잠금 대기 0 · 프로브 0/23 |
| 사실 digest(원본 표 23·행 33,127) | 전후 동일 · 중단 직후도 전과 동일 |
| 공존 diff v0.17.0 → 워크트리 | 함수 376 → 376 · 표 77 → 77 · break 0 · 경고 0 |
| 원천(definitions) 대조 | 원천 462 vs DB 458 · 차이 0건(플랫폼 객체 제외) |
| 빈 DB replay(별개 증거) | 174개 · 102초 · 스냅샷 --check 일치 |
| 증거 줄 | env=supabase-sandbox fixture=history-4y from=478ddf12 to=950edf67 migrations=2 elapsed=5934ms lock-wait=1/5269ms wal=20.09MB probes=0/49 coexistence=0 interrupt=20260913000000:5/11 facts=preserved |
실측 1(shim, 1년 3,120세트)과 견주면 4년 12,516세트에서 백필 시간 1.95초 → 5.6초, WAL 4MB → 20MB, 옛 앱 프로브 최대 잠금 대기 1.9초 → 5.3초로 데이터량에 비례해 늘었다. Production 세트 수(16,704)는 이 fixture 보다 많으므로, #1256 마이그레이션이 v0.17.1 릴리스에서 적용될 때 옛 앱의 홈·달력 조회가 약 5~7초 기다릴 수 있다 — 예산 §4-2 "프로브 잠금 대기 5초" 경계에 있다(오류는 0). 릴리스 PR #1303 에 참고로 전달.
Phase 4 — 게이트
check-supabase-migrations.mjs: 규칙 도입 꼬리 20260913000100 뒤 번호의 파일에 헤더·(high) 증거 형식. db-preflight.mjs assessBranchMigrations: origin/main 에 없는 새 파일(기준을 못 읽으면 꼬리 뒤 번호)에 헤더·env=supabase-sandbox 증거를 요구하고 기록에 risk 요약. landing-lock.mjs: acquire 가 기록 출처와 무관하게 재판정(LANDING_PREFLIGHT_BYPASS=1 은 GATE BYPASSED 표기), status 에 risk: 줄. main 병합 뒤(#1314 자동 랜딩) landing-request.mjs 의 랜딩 요청 직전에도 같은 판정을 넣었다.
Phase 5 — 문서·등록
정본 문서, 전략 문서 절(en·ko), 랜딩 절차 도구 표 3행 + 0-1-1 단계, 이 기록, 사이드바·README 등록(작업 기록·데이터 문서), 장부 규칙 ^tests/db/(G02 잔여 미분류 2개도 함께 분류).
주요 결정과 그 근거
- 지문은 문장 해시: 파일 이름은 랜딩 직전에 바뀐다(
migrations:renumber). 이름·주석을 지문에 넣으면 renumber 마다 헤더를 다시 써야 한다. - 증거의 환경을 게이트가 판정: shim 은 PG16·백그라운드 워커 없음·
MAINTAIN제거 — 도구를 검증하기엔 충분하고 랜딩을 보증하기엔 부족하다. 두 환경을 이름으로 구분하고 게이트는 하나만 받는다. - 투영 열은 digest 에 넣지 않는다: 계약 R4 — 시스템이 자유롭게 다시 계산한다. 넣으면 모든 투영 마이그레이션이 "사실 변경" 으로 보인다.
- 적용은 비동기(spawn):
spawnSync는 Node 이벤트 루프를 멈춰 프로브·샘플러가 한 번도 돌지 못했다(첫 실측 프로브 0/0 → 고친 뒤 0/12·잠금 대기 1,893ms 관측). - break 는 두 릴리스로 나눈다: 옛 앱이 캐시돼 있는 동안 door·열·표를 지우면 깨진다. 지우는 마이그레이션은 다음 릴리스로.
작업 중 드러난 것
ORDER BY "id"가 출력 열을 가리킨다 —select "id"::text … order by "id"는 text 로 캐스팅된 출력 열로 정렬돼'1','10','100'순이 됐다(배치 1이 189행). 출력 열에 별칭(as d13_key)을 두면 입력 열로 정렬한다.- 베이스라인은 PG17 전용
MAINTAIN권한을 10곳에서 쓴다 — PG16 에서는 문법 오류. shim 재생은 그 토큰만 빼고env.rewrites에 남긴다. Production 은 17.6 이라 실제 영향 없음. supabase_vault확장은schema = vault를 control 에 적고 스키마를 미리 만들어야CREATE EXTENSION … WITH SCHEMA "vault"가 통과한다.pg_cron도schema = pg_catalog로.get_pr_overview는 통계 worker 가 돈 적 없는 fixture 에서 "snapshot unavailable for applied generation 0" 으로 실패 — 프로브 기본 목록에서 뺐다(harness 가 upgrade 전 실패한 프로브를 자동 제외하고 로그에 남긴다).get_volume_overview의 door 시그니처는(date, int4)—(date)오버로드는 없다.tsx가node_modules에 없어npm run test가 죽는다(클라우드 세션 초기 상태) →npm ci.ci:local이 남기는 preflight 기록에는risk가 없다 — 그래서landing:lock acquire가 기록 출처와 무관하게 위험 게이트를 다시 본다.
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| 마이그레이션 문장의 잠금·재작성·스캔·WAL·재실행·공존 분류 | 없음 → 부류 50여 종, 저장소 174개 전부 분류(모르는 문장 0) |
| 위험 헤더·증거 요구 | 없음 → check:migrations(정적)·db:preflight·landing:lock acquire(env 판정). low 는 헤더 한 줄 |
| populated upgrade 실측 도구 | 없음 → db:upgrade(시간·WAL·크기·잠금 대기·옛 앱 프로브·중단/재실행·사실 digest·공존 diff·원천 대조·빈 DB replay) |
| 사실 보존 검사 범위 | 원본 파괴·삭제 건수(loss-audit) → + canonical ID 집합·부모/자식 간선(고아)·source identity·중단 직후 상태 |
| 구/신 공존 판정 | 없음 → 두 ref 의 schema.sql diff(break 5종·경고 4종) + 적용 중 프로브. v0.17.0 → main break 0(테스트가 매번 확인) |
| 긴 백필 | 파일 한 트랜잭션 → 배치·커서·checkpoint·재개·lock_timeout 재시도(DB 객체 0) |
| 직전 릴리스 → main 실측(#1256 투영 백필) | 미측정 → shim 1년 3,120세트: 1,951ms · WAL 3.98MB · 프로브 최대 1,893ms 대기 / 샌드박스 4년 12,516세트: 5,638ms · WAL 19.99MB · 프로브 최대 5,269ms 대기 · 오류 0 · 사실 보존 · 중단 5/11 뒤 재실행 동일 |
| 테스트 | — → 단위 21건(npm test) + 실 Postgres 6건(로컬 PG16 6/6 · Supabase 샌드박스 6/6 — 세션 08b11132, BARBELIC_DB_SANDBOX=<sbx> node --import tsx --test "tests/db/*.test.mjs") |
npm run check | 정적 게이트·테스트 전체 통과(PR 본문에 실제 수치) |
랜딩 증거(env=supabase-sandbox) | 없음 → v0.17.0 → 워크트리 실측 1회(위 표) · 빈 DB replay 174개·스냅샷 일치 · 원천 대조 차이 0 |
| 미실행(통과 아님) | 매일 데이터 사본(user-data-copy-<날짜>)을 fixture 로 한 실측. tests/db 의 CI 연결(HQ 통합 슬롯). R05 의 0.18.0 전체 리허설 |
6. 이번 개선으로 향상된 것
마이그레이션 PR 이 "얼마나 잠그는가" 를 제출한다
등급이 high 면 실측 없이는 잠금을 받지 못한다. low 는 한 줄이라 부담이 없다.
사실·ID·관계·source 보존이 기계적으로 증명된다
원본 불변 계약이 "무엇을 지키는가" 를 정했고, harness 가 "지켜졌는가" 를 upgrade 전후·중단 직후에 센다.
옛 앱이 깨지는 변화가 릴리스 전에 보인다
door 삭제·열 삭제·NOT NULL 추가는 diff 한 번으로 드러나고, 두 릴리스로 나누는 규칙이 문서에 있다.
구조적으로 남는 것
부류표·헤더 형식·증거 schema v1·harness·digest·공존 diff·백필 spec v1·게이트·예산·R05 템플릿. D02/D09/D12 는 같은 명령을 그대로 돌리고, R05 는 §8 템플릿으로 전체를 다시 잰다.
남은 것
- 매일 데이터 사본(
user-data-copy-<날짜>)을--fixture로 넣은 실측(원본 보존 검증의 정본 fixture). tests/db를 CI 로컬 스택 잡에 연결(.github/**= HQ 통합 슬롯, G02 부터 남은 항목).ci:local의 preflight 기록에도risk요약을 넣는 것(scripts/ci-local.mjs, 소유 경계 밖 — 잠금이 재판정하므로 게이트는 닫혀 있다).- 예산 §4-2 는 초기값 — R04/R05 실측 뒤 오너 결정으로 고정.
- 총괄 문서 §12 D13 상태 칸·G05 장부 §4-2/§7-2 의 D13 도구 행·
db-platform/script행 갱신은 HQ.