Skip to content

랜딩 잠금 획득 시 로컬 pgTAP 통과 기록이 없으면 거부 — CI 8회 중 7회가 로컬에서 잡을 수 있던 실패였던 것에서, 잠금이 "이 PC에서 서버 테스트를 통과했다"는 증거를 요구하는 관문까지 (2026-09-04)

  • 기간: 2026-09-04 (세션 2개 — 분석·이슈 작성 1, 구현·랜딩 1). 오너 지시 원문: "CI 왜 이렇게 많이 실패해? 이거 CI 실패할 때마다 패널티 줄 수 있는 거 없어? 미리미리 잘 체크 좀 해."
  • 랜딩: PR #1228 (Phase 1~3, 65023773) — 마이그레이션·엣지 함수 없음, 앱 코드 없음(도구·문서·PR 템플릿만). Vercel 배포 무관.
  • 설계서: 없음 — 이슈 #1224 본문이 분석·Phase 계획·"예상 효과·개선사항"을 담는다.
  • 정본: process/migration-landing.md 0-1 단계("잠금 전 preflight")·도구 표·우회 절. 기록 파일 형식은 scripts/migrations/preflight-record.mjs(버전 1: 브랜치·HEAD·내용 해시·파일 수·assert 수·시각).
  • 도구: npm run db:preflight -- --sandbox <디렉터리>(scripts/migrations/db-preflight.mjs, 레포 안) · scripts/migrations/landing-lock.mjs acquire의 게이트. 통과 기록은 워크트리의 .git 디렉터리(landing-preflight.json)에만 존재하고 커밋되지 않는다. 샌드박스 스택은 레포 밖(세션 scratchpad sbx/).
  • 게이트: tests/react/landingPreflightGate.test.mjs 7건(내용 해시·판정 4경우·실행기 성공/실패/스택 없음/미연결 샌드박스·잠금 거부 4경우·우회 기록). 로컬 pgTAP 통과 기록: 이 트랙은 마이그레이션·pgTAP 파일을 고치지 않았지만 도구 실측으로 이 PC에서 db:preflight 100파일·1,601 assert PASS 2회(첫 실행, README 편집 뒤 재실행).
  • 버그리포트: 없음(절차 개선 트랙).
  • 계약: 랜딩 절차 계약이 "번호 순서" + "로컬 서버 테스트 통과 기록"으로 확장됐다. PR 템플릿(.github/pull_request_template.md) 신설 — 검증 절에 로컬 pgTAP 줄을 요구.

Phase 현황

Phase내용상태
Phase 1npm run db:preflight — 샌드박스에 리셋 → pgTAP 전 파일 → 통과 시에만 기록 파일(브랜치·HEAD·내용 해시·파일 수·assert 수·시각), 기록은 .git 아래✅ PR #1228 (65023773)
Phase 2landing:lock -- acquire가 기록 없음/브랜치 불일치/내용 해시 불일치/24시간 초과면 거부. LANDING_PREFLIGHT_BYPASS=1 우회는 잠금 기록·status에 표시. e2e 로컬 프로필은 안내만✅ 같은 PR
Phase 3migration-landing.md 0-1 단계·도구 표·우회 절, updates/README.md 게이트 줄, supabase/migrations/README.md, PR 템플릿✅ 같은 PR

1. 배경

이 PC에는 2026-08-20부터 레포 밖 샌드박스에 로컬 Supabase 스택을 띄워 마이그레이션 전체와 pgTAP 전 파일을 돌릴 수 있는 절차가 있었다(메모리 lift-guild-local-supabase-windows-hazards §4). 실측으로 리셋 포함 수 분, pgTAP 자체는 15초면 끝난다. 그런데 이 절차는 "권장"이었고, 랜딩 관문인 잠금(landing:lock)은 마이그레이션 번호 순서만 지켰다 — 서버 테스트를 로컬에서 돌렸는지는 아무도 확인하지 않았다.

2026-09-03~04 두 트랙이 그 틈으로 나갔다. #1200(PR #1219)과 #1202(PR #1223)는 pgTAP 파일을 새로 쓰거나 고치고도 "로컬 미실행 — CI 몫"으로 PR을 열었고, CI가 각각 4회 빨간불로 끝났다. 오너가 같은 날 전역 지시 §20(서버 테스트는 PR 전에 로컬 샌드박스에서 먼저)을 추가했지만, 지시문은 잊히거나 건너뛸 수 있다.

2. 문제 제기

CI 실패 8회 중 7회는 이 PC에서 2분 30초 안에 미리 잡을 수 있던 것이었다

PRCI 실패 원인로컬 재현 가능
#1219 (#1200)pgTAP 기대값 3파일 / main의 남의 회귀 CASE-011 / e2e 체중 삽입 권한 / e2e 체중 열 미조회예 / 아니오 / 예 / 예
#1223 (#1202)무결성 검사 함수 옛 프레임 / 맨몸 세트 이중 채점 / pgTAP 픽스처 오류 2회예 / 예 / 예 / 예

앱 결함은 0건. 전부 테스트·검사 쪽 문제였고, CI 1회 약 11분 × 반복으로 #1223 한 트랙만 55분 이상을 CI 대기에 썼다.

절차상 "CI 몫"으로 넘기는 것을 막는 장치가 없었다

잠금 획득(landing:lock -- acquire)은 모든 마이그레이션 랜딩이 반드시 지나는 관문인데, 여기서 보는 것은 잠금이 비었는지뿐이었다. 사전 검증을 했는지는 PR 본문의 문장("pgTAP N건 작성(로컬 미실행 — CI 몫)")에만 남았고, 그 문장을 쓴 세션도 읽는 세션도 그냥 지나갔다.

Production 드라이런이 무결성 검사를 꺼 둔 채 돌아 통과처럼 보였다

#1202의 재계산 드라이런은 validate_integrity=false로 돌아 무결성 검사를 건너뛰었다. 검사를 켠 CI에서 바로 실패했다. "로컬에서 돌렸다"가 아니라 "CI와 같은 검사를 로컬에서 통과했다"가 기준이어야 한다.

3. 해결 방안

원칙 (오너 결정, 2026-09-04)

  • D1 "미리미리 잘 체크" — 서버 테스트(pgTAP·로컬 프로필 e2e)는 PR 전에 로컬에서 먼저 돌린다(전역 지시 §20). 채택.
  • D2 "패널티" — 사람에게 벌점을 주는 대신 관문이 증거를 요구하도록 한다. 증거가 없으면 잠금을 주지 않는다. 채택(이슈 본문 원칙, 오너 "이어서 해줘"로 go).
  • D3 우회는 남기되 흔적이 남게 — §15의 LANDING_LOCK_BYPASS처럼 환경변수로 넘길 수 있지만 잠금 기록에 "사전 검증 우회"가 찍혀 작업 기록에 적히게 한다. 채택.

접근

대안내용판단
잠금 획득 시 통과 기록 파일 검사pgTAP 실행 스크립트가 통과 시에만 기록을 남기고, acquire가 그 기록을 브랜치·내용 해시·시각으로 대조채택 — 모든 마이그레이션 랜딩이 지나는 단일 관문, 사람이 쓰는 문장이 아닌 기계 기록
PR 본문 문구 검사(CI에서 "로컬 pgTAP 통과" 줄 grep)문장만 있으면 통과기각 — 문장은 거짓일 수 있고, CI에서 잡으면 이미 늦다
pre-push 훅에서 pgTAP 실행푸시마다 스택 리셋+pgTAP기각 — 개발 중 누적 푸시마다 수 분 걸리고, 랜딩 아닌 푸시까지 막는다
CI 실패 횟수 집계·패널티 표실패를 사후 집계기각 — 실패를 세는 것으로는 다음 실패를 막지 못한다

기록을 워크트리의 .git 디렉터리에 두는 이유: 커밋되지 않고, 워크트리마다 따로 존재하며(잠금 토큰과 같은 자리), 브랜치를 갈아타도 섞이지 않는다. 기록을 내용 해시와 묶는 이유: 통과 뒤 마이그레이션이나 pgTAP 파일을 한 글자라도 고치면 그 기록은 그 파일들을 설명하지 않는다 — 다시 돌려야 한다. 24시간 만료는 로컬 이미지 드리프트·오래된 기록의 재사용을 막는 안전선이다.

4. 적용한 내용

Phase 1 — 로컬 pgTAP 실행 스크립트 + 통과 기록 (PR #1228)

  • scripts/migrations/preflight-record.mjs(새 파일): 기록 경로(<git-dir>/landing-preflight.json), 내용 해시(supabase/migrations/**+supabase/tests/**의 경로+내용, CRLF/LF 무시), 판정 함수 evaluateRecord(없음/브랜치/해시/만료), 요약 문장.
  • scripts/migrations/db-preflight.mjs(새 파일): --sandbox <디렉터리>(또는 BARBELIC_DB_SANDBOX)의 supabase/config.toml에서 db 포트를 읽어 접속 문자열을 만들고, 샌드박스의 migrations·tests junction이 이 워크트리를 가리키는지 실경로로 확인한 뒤(다르면 거부), 이전 기록 삭제 → supabase --workdir <샌드박스> db reset --no-seed --db-url … --yessupabase test db --db-url … → pg_prove 출력의 Files=N, Tests=MResult: PASS를 읽어 통과일 때만 기록을 쓴다. 마지막 줄에 PR 본문에 붙일 "로컬 pgTAP N파일·M assert 통과 (시각)" 문장을 출력한다.
  • package.json: db:preflight 스크립트.

Phase 2 — 랜딩 잠금 게이트 (같은 PR)

  • scripts/migrations/landing-lock.mjs: acquire가 잠금 파일을 만들기 전에 기록을 판정한다. 거부 시 사유(missing/branch/hash/expired)·설명·재실행 명령을 출력하고 잠금은 생기지 않는다. 통과 시 잠금 기록(current.json)에 preflight: {files, tests, createdAt, head, hash}를 넣고 획득 메시지와 status에 "preflight: 100 files · 1601 asserts @ …"로 보인다.
  • LANDING_PREFLIGHT_BYPASS=1: 경고를 내고 잠금을 주되 preflight: {bypassed: true, reason}note(및 progress.note) 앞에 preflight-bypassed를 붙인다. status가 "preflight: BYPASSED (missing) — record this in the work record"로 표시한다.
  • e2e 로컬 프로필(local-auth-admin-full-stack)은 이번 게이트 대상이 아니다(앱 서버까지 필요). 절차 문서에 "직접 돌리고 PR 본문 검증에 적는다"로 안내만 남겼다.

Phase 3 — 문서·절차 (같은 PR)

  • process/migration-landing.md: 도구 표에 db:preflight 행, 0-1 단계 "잠금 전 preflight"(샌드박스 만들기·기동·실행·재실행 규칙·e2e 안내), 1단계에 거부 조건, "우회와 한계"에 preflight 우회 규칙.
  • updates/README.md 게이트 줄: 서버 파일을 만졋으면 로컬 pgTAP 통과 기록(파일 수·assert 수·시각)과 로컬 e2e 케이스 이름을 적고, 우회했으면 §4에 적는다.
  • supabase/migrations/README.md 규칙 6 끝에 한 문단.
  • .github/pull_request_template.md(새 파일): 요약 / 검증(로컬 pgTAP 줄·로컬 e2e) / CI 판단(§19 한 줄).

주요 결정과 그 근거

  • 판정은 획득 시 1회, 재번호 뒤에는 다시 보지 않는다. 2단계 migrations:renumber가 파일명과 자기 주석을 바꿔 내용 해시가 변하지만 SQL 의미는 같다. 잠금을 쥔 채 다시 preflight를 요구하면 잠금 보유 시간만 늘어난다.
  • supabase/migrations/README.md도 해시에 들어간다. 이번 트랙이 그 README를 고친 뒤 acquirehash로 거부된 것이 실측 예다. 문서 편집으로도 재실행이 필요해지는 보수적 판정이지만, 마이그레이션 폴더 안 파일을 종류별로 골라내는 규칙을 두는 것보다 단순하다.
  • 샌드박스 junction의 실경로 대조. 기록은 "이 워크트리의 파일이 통과했다"는 뜻이어야 한다. 샌드박스가 다른 워크트리를 가리킨 채 돌리면 다른 브랜치의 결과로 내 브랜치의 기록이 생기므로 실행 자체를 거부한다.
  • 실패하면 이전 기록을 지운다. 해시가 같아도(같은 내용을 다시 돌려 실패) 옛 통과가 살아 있으면 안 된다.

작업 중 드러난 것

  • bash 히어독으로 넘긴 앵커 문자열에 em-dash(—)가 섞이면 매칭이 깨진다(메모리 bash-heredoc-escape-mangling 재현). 편집 스크립트를 파일로 써서 node patch.mjs로 돌리면 문제없다. 또 landing-lock.mjs는 CRLF 파일이라 앵커 대조 전 LF로 정규화하고 저장 시 되돌려야 한다.
  • 워크트리에는 node_modules가 없어 node --import tsx가 죽는다 — 기본 체크아웃의 node_modules로 junction(메모리 worktree-junction-node-modules-hazard).
  • supabase CLI에 --workdir 전역 플래그가 있어 cd 없이 샌드박스를 지목할 수 있다. scoop shim은 .exe라 Node spawnSync("supabase", …)가 셸 없이 찾는다.
  • 이 PC 실측: pgTAP 100파일·1,601 assert는 15초, db reset(마이그레이션 147개 재적용)이 대부분의 시간을 쓴다. 이슈 본문의 "102파일 1,629건 2분 30초"는 다른 브랜치·다른 날의 값이다.
  • CI 판단: 이 PR은 도구 스크립트·그 스크립트를 로컬 npm run test에서 똑같이 돌리는 테스트·문서·PR 템플릿만 바꿨다. CI 잡이 실행 경로로 검증하는 앱·서버 코드가 없어 §19대로 CI를 기다리지 않고 머지했다.

5. 적용 결과

항목전 → 후
랜딩 잠금이 요구하는 것잠금이 비어 있을 것 → + 이 브랜치·이 서버 파일 내용에 대한 24시간 안의 로컬 pgTAP 통과 기록
사전 검증 누락의 가시성PR 본문 문장("로컬 미실행 — CI 몫") → 잠금 거부 메시지(사유·재실행 명령) / 우회 시 잠금 기록·statuspreflight-bypassed
게이트 테스트0건 → landingPreflightGate.test.mjs 7건, npm run check 2,510건 통과
실제 워크트리 실측— → db:preflight 100파일·1,601 assert PASS → supabase/migrations/README.md 편집 → acquire 거부(hash) → 재실행 PASS → acquire 획득(기록에 100·1601 표시) → release. git status에 기록 파일 없음
CI 빨간불(서버 테스트 원인)최근 두 트랙 8회 중 7회
랜딩 소요CI 11분 × 실패 반복

미검증: 실제 병렬 세션이 이 게이트를 지나는 랜딩은 아직 없다. e2e 로컬 프로필은 게이트 대상이 아니다(안내만).

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

  • CI가 첫 실행이 아니게 됐다. 마이그레이션 묶음은 잠금을 받기 전에 이 PC에서 pgTAP 전 파일을 통과해야 하므로, 로컬에서 잡을 수 있는 실패는 CI에 가기 전에 잡힌다.
  • "돌렸다"가 아니라 "이 파일들이 통과했다"가 기록된다. 기록이 브랜치와 내용 해시에 묶여 있어 다른 브랜치 기록·수정 전 기록은 통하지 않는다.
  • 우회가 숨지 않는다. 우회 플래그는 남겼지만 잠금 기록·상태 출력·작업 기록 형식이 우회를 드러낸다.
  • 절차가 문서·템플릿에 박혔다. 랜딩 절차 0-1 단계, 작업 기록 게이트 줄, PR 템플릿 검증 절이 같은 문장을 요구한다.

남은 것

  • e2e 로컬 프로필(local-auth-admin-full-stack)의 기계 검사 — 앱 서버까지 스택에 붙이는 방법을 정한 뒤 별도 이슈.
  • 효과 실측 — 다음 마이그레이션 랜딩들의 CI 실패 원인 분류(로컬 재현 가능/불가)를 작업 기록 §4에 남기고, 몇 트랙 뒤 전 → 후를 이 문서 5절에 갱신.