Skip to content

v0.18.0 D03 — SQL 정의를 3.4 MB 스냅샷 한 파일에서만 읽고 마이그레이션에 손으로 베끼던 것에서, 도메인별 원천 462파일·registry·후보 마이그레이션 생성기·drift 검사까지 (2026-09-07)

  • 기간: 2026-09-07 ~ 2026-09-07 (세션 1개 9e2872ef, 오너 지시 "1283 진행해줘" → 분석·Phase 계획 게시 → "고")
  • 랜딩: PR #1312 (Phase 0~6 한 PR, squash, CI 1회) — 마이그레이션·엣지 함수·앱 화면 변경 없음(스크립트·원천 파일·테스트·문서·package.json 스크립트 3줄). 총괄 D03 카드 · 계획 ID D03 · Phase 1 스텝 1-3
  • 설계서: 없음 — 분석·Phase 계획·예상 효과는 이슈 #1283 댓글
  • 정본: docs/data/sql-definitions.md(원천 형식·도메인 규칙·registry·층 규칙·생성기 절차·소유 분할) · supabase-migration-strategy.md "Domain Sources" 절 · supabase/definitions/rules.json(도메인 분류)·exceptions.json(층 규칙 예외 24건)
  • 도구: scripts/sql/definitionsModel.mjs(문장 분리·객체 귀속·시그니처·권한·의존) · extract-definitions.mjs(npm run sql:extract) · check-definitions.mjs(npm run sql:check) · generate-migration-candidate.mjs(npm run sql:candidate) · layerRules.mjs
  • 게이트: tests/react/sqlDefinitionsContract.test.mjs 16건(원천 == 스냅샷 문장 집합·registry 최신·파일 배치·층 규칙 == 예외 목록·drift fixture 9종 탐지·결정성) · sqlMigrationCandidate.test.mjs 10건(생성기 결정성·재선언 규칙·DDL 요구). 둘 다 npm testnpm run check → CI verify. Docker 불필요
  • 버그리포트: 없음(도구·문서 트랙). 층 규칙 위반 24건은 결함이 아니라 "현재 사실" 로 예외 목록에 등재하고 후속 이슈로 전달
  • 계약: docs/process/migration-landing.md 0단계(원천 편집 → 후보 → 스냅샷 → sql:check, 리베이스 뒤 sql:extract) · supabase/README.md Change Workflow · HQ 운영 정본 §3의 "SQL object" 행이 가리키던 supabase/definitions/<domain>/** 가 실제로 생겼다

Phase 현황

Phase내용상태
Phase 0규격 문서 + 도메인 분류 규칙 파일(도메인 18개·규칙 37줄) + 층 정의7b78917e
Phase 1추출기 — 스냅샷 1·2절 2,758 문장 → 객체 462개(함수 376·표 77·뷰 3·플랫폼 6) → 도메인 폴더 파일 462개 + registryb69ab27a
Phase 2drift 검사 sql:check + 계약 테스트 16건 + 예외 목록 + npm 스크립트 3개e8bf7a43
Phase 3예외 24건 사유·후속 계획 ID, G05 장부 등재(규칙 21줄·sql 장부 행 17개), 문서 §5 실측07fa190a
Phase 4후보 마이그레이션 생성기 sql:candidate + 테스트 10건1a40ec4b
Phase 5샌드박스 실측(원천 수정 → 후보 → 재생 → 스냅샷 → sql:extractsql:check, 2회 바이트 동일) + 절차 문서 연결e03dcbf1 + 보정 커밋
Phase 6소유 분할·D13 인계·작업 기록·ci:local·PR·CI 1회·머지✅ PR #1312

1. 배경

v0.18.0 은 62개 작업이 갈라져 SQL 을 고치는 릴리스다(D02·D04~D12·S10 이 각자 도메인의 함수·정책·표를 만진다). 그런데 9/7 시점의 "현재 SQL 정의" 는 supabase/schema.sql 한 파일(3.4 MB, 함수 376개·표 77개·정책 81개·트리거 97개·권한 문장 967개)에만 있었고, 그 파일은 #1252 가 만든 기계 생성 덤프 라 사람이 편집할 수 없다. 편집할 수 있는 것은 마이그레이션(175개)인데 그것은 적용 이력이라 불변이다. 총괄 F24 는 이 둘을 "도메인별 원천 + 생성기" 로 분리하기로 정했고(HQ 운영 정본 §2), D03 이 그 구현이다.

2. 문제 제기

유저 A 의 저장 결과를 바꾸는 서버 함수(예: save_session_v5_engine) 한 줄을 고치려면 개발자 B 는 ① 3.4 MB 파일에서 정의를 찾아 읽고 ② 새 마이그레이션에 본문 전체 를 다시 적고(부분 치환 금지) ③ 권한·주석도 잊지 않고 같이 적고 ④ 샌드박스에 재생해 스냅샷을 다시 뽑아야 했다.

  • 도메인별로 읽을 자리가 없다. "이 함수는 어느 도메인 것이고 누가 소유하는가" 가 파일 구조에 없어, 갈라진 작업자들이 같은 함수를 각자 만질 수 있었다.
  • 이름만으로는 객체를 특정할 수 없다. 인자가 다른 두 정의가 공존하는 함수 4쌍(8개)이 있다. 이름 기준 관리는 이 8개를 4개로 뭉갠다.
  • 정의와 권한·보안 설정이 수백 줄 떨어져 있다. SECURITY DEFINER(만든 사람 권한으로 실행)·search_path·GRANT/REVOKE 가 덤프의 다른 절에 있어, 8/21 스쿼시 때 드러난 "내부 엔진이 service_role 에 열려 있던 것" 같은 문제가 오래 보이지 않았다.

어떤 구조라서 가능했나: 현재 상태의 유일한 표현이 편집 불가능한 생성물이고, 편집 가능한 유일한 형식이 이력이었다. "읽고 고친다" 와 "이력을 보존한다" 가 같은 파일 형식 안에서 충돌했다.

3. 해결 방안

원칙

  • 오너 결정 없음 — 계획의 전제 4개로 진행: ① 원천 텍스트 = 덤프 형태 그대로(정규화 없음) ② 첫 추출은 현재 상태 그대로, 규칙 위반은 예외 목록 + 후속 이슈 ③ 생성기는 함수·정책·트리거·뷰·권한만 만들고 DDL·백필은 명시 입력(--ddl) ④ package.json 스크립트 3개(공유 sentinel — 의존성·lock 변경 없음).
  • §22(근본 구조): 땜질(예: "함수 고칠 때 grep 잘 하기" 안내)이 아니라, 원천·이력·상태의 세 형식을 파일로 분리하고 그 사이를 검사로 잇는 구조.

접근

대안판정
A. 도메인별 원천 파일(덤프 형태 그대로) + qualified signature registry + 원천 diff → 후보 마이그레이션 + 원천 ⟷ 스냅샷 문장 단위 동일성 검사채택 — 이력은 불변, 편집은 원천, 진실은 재생 DB. 검사 3층이 이어진다
B. schema.sql 을 도메인별 여러 파일로 쪼개 생성기각 — 여전히 생성물이라 편집 불가, "고친 것에서 마이그레이션을 만든다" 가 없다
C. 마이그레이션 폴더 안에 도메인 폴더를 두고 편집기각 — 적용된 마이그레이션 불변 규칙(ADR §5)과 충돌, 8/21 이전으로 회귀
D. DB 카탈로그(pg_proc)에서 직접 추출기각(이번엔) — Docker 없이는 검사가 못 돌아 npm test·PR 검토에서 쓸 수 없다. 스냅샷은 CI 가 재생 DB 와 바이트 일치를 증명하므로 그것을 읽는 편이 같은 진실을 더 자주 검사한다

4. 적용한 내용

Phase 0 — 규격·규칙

docs/data/sql-definitions.md: 네 층(마이그레이션 → 재생 DB → 스냅샷 → 원천)의 동일성과 각 고리의 검사, 폴더·파일 규칙(객체 하나 = 파일 하나, 오버로드는 이름__인자타입.sql), 딸린 문장의 귀속(표 파일에 제약·인덱스·정책·트리거·권한·주석; 함수 파일에 주석·권한), registry 항목, 층 정의, 생성기 절차, 소유 분할 표. rules.json: 도메인 18개(G05 장부 area 키와 같음)·규칙 37줄, 위에서부터 첫 일치, 미분류는 실패.

Phase 1 — 추출기

definitionsModel.mjs 가 스냅샷 1·2절을 달러 인용·문자열·주석을 인식해 문장으로 나누고(splitStatements), 문장마다 객체를 정하고(attributeStatement — 함수는 시그니처, 표는 이름, 인덱스 주석은 인덱스 → 표 지도, 시퀀스 권한은 이름 접두로 표), 함수 머리(언어·휘발성·보안·search_path·반환형)와 권한(GRANT/REVOKE → 역할별 EXECUTE·표 권한)과 의존(본문이 부르는 public 함수·읽는 표; 문자열·주석 제거 뒤)을 읽는다. extract-definitions.mjs 가 파일 462개 + registry.json 을 쓴다. 멱등(두 번째 실행 = 변경 0). 시그니처 정규화는 check:remote-schema 와 같은 규칙(이름·기본값·OUT 제외, timestamptz·int4 별칭).

Phase 2 — drift 검사·테스트

check-definitions.mjs: 원천 파일 전체를 이어 같은 모델을 만들어 스냅샷 모델과 객체·문장 다중집합으로 대조 → 문제 코드 6종(missing-in-snapshot 원천만 고침 / missing-in-definitions 재추출 안 함 / unclassified / registry-stale / file-mismatch / layer-rule)과 고치는 명령. 0.4초. 계약 테스트 16건: 저장소 현재 상태 통과 + fixture 9종(함수 본문 한 글자·GRANT 삭제·정책 삭제·마이그레이션 없는 새 객체·미분류·registry 손편집·잘못된 파일 자리·새 층 위반·사라진 예외) 탐지 + 결정성.

Phase 3 — 층 규칙·예외·장부

층 판정 순서 trigger(반환형) → engine(이름) → door(앱 역할 EXECUTE) → core. 규칙 4개(core/engine → door 호출 금지 · engine 앱 권한 없음 · trigger 앱 권한 없음 · DEFINER 는 search_path 필수). 첫 추출 실측: door 122 · core 174 · trigger 67 · engine 13, 위반 24건(전부 호출 방향; 규칙 2~4 는 0건) → exceptions.json 닫힌 목록에 쉬운 말 사유·후속 계획 ID. G05 규칙 파일에 원천 폴더·스크립트·테스트·문서 경로 21줄 + 영역 17개의 sql 장부 행, 장부 문서 재생성.

Phase 4 — 후보 마이그레이션 생성기

generate-migration-candidate.mjs: 원천 vs 스냅샷 객체 diff → 함수 전체 본문 create or replace + 주석 + 권한 차이(뺀 권한은 REVOKE 로 뒤집음), 정책 drop policy if exists + create policy(사라진 정책은 drop), 트리거 재선언/drop, 뷰, RLS on/off, cron(unschedule), storage 정책·auth 트리거 drop+create. 표·열·인덱스·제약·함수 삭제·시그니처 변경은 목록만 내고 멈춤 → --ddl <파일> 로 명시 SQL 을 받으면 후보 맨 앞 절에 그대로. 파일 머리: 자기 이름·원천 해시·스냅샷 지문·user-fact: reissued-functions:·loss-audit:(DDL 없을 때만). 번호 = KST 정시 임시(--now 로 고정 가능), 확정은 종전대로 migrations:renumber. 생성 파일이 check:migrations 통과.

Phase 5 — 실측·절차 연결

이 워크트리용 샌드박스(npm run ci:local -- --only db 가 만든 %TEMP%/barbelic-ci-local/2c15b806, project cil0a14b515)에서 db 단계 통과(재생 1분 22초·스냅샷 --check 18초·pgTAP 109파일/1,899 assert) 뒤, 원천 한 함수(workout/functions/save_session_v5.sql)의 본문에 주석 한 줄을 넣고 다음을 2회 반복했다: sql:candidate(후보 20260913010100_d03_phase5_probe.sql, check:migrations 통과) → schema:snapshot --reset(116초·119초, 스냅샷에 수정 본문 1건 확인) → sql:check(registry-stale 1건: 바뀐 함수의 해시) → sql:extract(registry 1파일만 다시 씀, 원천 파일 변경 0) → sql:check 초록 → 계약 테스트 16/16. 두 회의 후보(d1b219bd…)·스냅샷(6d00576b…)·registry(afbd879d…) 해시가 모두 같았다. 원천 수정을 되돌린 뒤 sql:check 초록.

절차 문서 연결: supabase/README.md Change Workflow 1~3단계, docs/process/migration-landing.md 0단계(원천 편집 → 후보 → 재생·스냅샷 → sql:extractsql:check, 리베이스 뒤 sql:extract), docs/data/supabase-migration-strategy.md(en·ko) "Domain Sources / 도메인 원천" 절.

Phase 6 — 인계·랜딩

소유 분할 표(정본 §9)·D13 인계(registry·sql:check·생성기 인터페이스)·이 기록. npm run ci:local(full) 3회 나눠 실행 — 1차 verify 빨간불 2건은 이 트랙의 생성기 테스트가 indexOf 리터럴을 써 sourceSliceAnchors 게이트(소스 슬라이스 앵커 검사)에 잡힌 것 → 생성물 순서 도우미로 교체; 2차 verify 빨간불은 이전 브라우저 실행이 남긴 playwright-report/(추적 안 되는 산출물)의 U+FFFD 를 check:utf8 이 잡은 것 → 삭제 뒤 통과. 브라우저 저니 1차의 플레이키 1(CASE-018, net::ERR_QUIC_PROTOCOL_ERROR)은 재실행 37/37. PR #1312 → CI 1회 → squash 머지. D13(#1287)에 인계 댓글.

주요 결정과 그 근거

  • 원천은 덤프 형태 그대로. 예쁘게 다시 쓰면 "원천 == 스냅샷" 이 바이트 비교로 끝나지 않고 왕복(원천 → 마이그레이션 → 재생 → 덤프)이 같은 텍스트로 돌아오지 않는다.
  • 검사는 Docker 없이. 스냅샷이 재생 DB 와 바이트까지 같다는 것은 CI migration-smoke 가 매 PR 증명하므로, 원천 ⟷ 스냅샷만 순수 node 로 검사하면 원천 == 재생 DB 다. npm test 에서 돌아 PR 검토 전에 잡힌다.
  • 첫 추출은 현재 상태 그대로, 위반은 고치지 않는다. 동작·권한 변경은 이 이슈 범위 밖이다. 예외 목록은 닫힌 목록이라 새 위반은 통과하지 못하고, 고쳐진 위반도 목록에서 빼라고 실패한다.
  • 층 판정은 이름(engine)이 권한(door)보다 먼저. 앱 역할에 열린 engine 이 door 로 승격되면 규칙 위반이 영영 보이지 않는다.
  • 생성기는 DDL 을 추측하지 않는다. 표·열·데이터 변경은 유저 원본(#1236)에 닿을 수 있는 자리라 사람이 쓴 SQL 만 받고, 재생 뒤 sql:check 가 결과를 대조한다.

작업 중 드러난 것

  • 층 규칙 위반 24건의 실체 — 셋으로 나뉜다: 앱도 부르고 내부도 부르는 공용 판정 함수가 공개(door)로만 존재(17건: is_lift_guild_admin()profile_feed_can_view_v1ensure_user_profile() 2 등), core 가 공개 오버로드를 되부르는 층 역전(2건: PR 상세 _v3_core → get_exercise_pr_detail(4인자)), 통계 잡·인입 갱신 함수가 앱에도 열려 있어 엔진·cron 이 문을 부르는 모양(5건). 고치는 자리는 B01·D12·D02·D06·D07·D01·D09 로 exceptions.json 에 적었다.
  • 트리거 함수 67개 중 61개가 service_role EXECUTE 보유 — 플랫폼 기본 권한(ALTER DEFAULT PRIVILEGES … GRANT ALL ON FUNCTIONS TO service_role)에서 온다. 규칙 3 을 anon/authenticated 로 한정(그 기준 0건). 특정 트리거의 service_role 회수는 pgTAP internal_surface_grant_hygiene 가 개별로 지킨다.
  • 커버리지 검사가 main 에서 이미 빨간 상태 — 미분류 12개(G02 의 새 테스트·픽스처 9개, .gitattributes, tests/db/** 2개, #1277 의 authSessionGuard.ts). 내 변경과 무관해 손대지 않았다 — G05/HQ 몫.
  • Bash 히어독·node -e 가 백슬래시를 먹는다(메모리 bash-heredoc-escape-mangling 재확인) — 정규식 \n·JSON \\. 이 깨져 두 번 다시 썼다. 파일 편집은 Edit 도구로.
  • 재생 뒤에는 sql:check 앞에 sql:extract 한 번이 필요하다 — 함수 본문이 바뀌면 registry 의 해시가 달라지므로 check 가 registry-stale 을 낸다. 계획에는 "재생 → sql:check" 로 적었는데 실측이 순서를 고쳐 줬다. 문서 4곳을 보정.
  • 임시 번호가 꼬리 아래로 정렬되는 함정 — 현재 꼬리 20260913000100 이 오늘(09-07)보다 미래라, "KST 현재 정시" 만 쓰면 후보가 뒤 마이그레이션에 덮여 sql:check 가 "원천만 고쳤다" 로 헛빨간불을 낸다. renumber 와 같은 "꼬리 이하면 꼬리 + 1시간" 규칙으로 실측 후보 번호가 20260913010100 이 됐다.

5. 적용 결과

항목전(main b0e5076a)
함수 정의를 읽는 곳3.4 MB 단일 파일 검색supabase/definitions/<도메인>/functions/<이름>.sql 1파일(객체 462개 전부 배치, 미분류 0)
오버로드 함수 관리이름 충돌 4쌍(8정의)registry 키 8개·파일 8개
정의·권한·주석의 거리덤프의 다른 절(수백~수천 줄)같은 파일
원천 ⟷ 실제 DB 어긋남 발견없음npm test 1회(0.4초), fixture 9종 탐지 증명
호출 방향·권한 규칙사람 리뷰테스트(예외 24건 명시, 새 위반 0 허용)
함수 수정 =마이그레이션 손 작성(본문·권한·주석 복사)원천 편집 + sql:candidate(2회 바이트 동일)
DDL·백필(사람)생성기가 만들지 않음 — --ddl 없으면 실패
SQL 소유 분할HQ 정본 §3 한 행도메인 18개 × 객체 수 + 규칙 파일 + 정본 §9 표
검증ci:local full: verify 6단계 · db reset 1분 22초 · 스냅샷 --check 17초 · pgTAP 109파일/1,899 assert · e2e local 11/11·empty 7/7·cardio 6/6·browser 37/37·viewport 14/14 · 샌드박스 왕복 2회 바이트 동일 · CI 1회

미검증: 도메인 담당이 실제 트랙(D02·D04~)에서 이 절차로 랜딩까지 가는 첫 사례는 아직 없다 — 첫 사용자가 겪는 마찰(예: 후보 파일 머리의 "왜" 를 채우는 습관, 리베이스 뒤 sql:extract 누락)은 그 PR 에서 드러난다.

6. 이번 개선으로 향상된 것

함수 하나를 파일 하나로 읽고 고친다

D02 담당이 workout/functions/save_session_v5_engine.sql 을 열면 정의·주석·권한이 한 자리에 있고, 고친 뒤 명령 하나로 마이그레이션 후보가 나온다.

어긋남이 PR 검토 전에 문구로 잡힌다

원천만 고쳤는지, 마이그레이션만 랜딩됐는지, 새 객체가 규칙에 없는지를 npm test 가 어느 층이 어긋났는지 말하며 거부한다. Docker 가 없어도.

소유가 파일 구조로 보인다

도메인 폴더 = G05 장부 영역 = 후속 계획 ID. 두 도메인에 걸치는 함수는 규칙 파일이 정한 한 곳에만 있다.

구조적으로 남는 것

"원천 == 스냅샷 == 재생 DB == 마이그레이션 적용" 을 이어 붙이는 검사 3층과, 닫힌 목록 두 개(도메인 분류 규칙·층 규칙 예외). 어느 한 층을 손으로 우회해도 다음 npm test·CI 가 거부한다. 마이그레이션 번호 배정·잠금·병합 큐는 바뀌지 않았다.

남은 것

  • 후속 이슈로 넘기는 것(예외 24건): B01(공용 판정 함수 door/core 분리 또는 앱 권한 회수 — is_lift_guild_admin·profile_feed_can_view_v1·ensure_user_profile), D12(required_inputs_valid_v1·exercise_target_muscles_valid_v1 를 내부로), D02(plan_owner_json_v1), D06(planned_session_card_stats_v1), D07(PR 상세 core/door 층 역전), D01·D09(통계 잡·인입 갱신 함수의 앱 권한 정리).
  • D13 인계: registry(supabase/definitions/registry.jsonsql:check·생성기 인터페이스(planCandidate) — D13 의 upgrade harness 가 "바뀐 객체 목록" 을 registry diff 로 정한다.
  • HQ: 총괄 §12 D03 상태 칸·PR/SHA·이 기록 링크 갱신, 커버리지 미분류 12개(G02·#1277 잔여) 규칙 등재.