마이그레이션 랜딩 직렬화 — 번호 충돌 반복에서 기계 확정·잠금 절차까지 (2026-08-31)
- 기간: 2026-08-31 (세션 1개, 오너 보고 원문: "마이그레이션 번호끼리 충돌해서 계속 그거만 고치면서 actions 한도 소진하고 오래걸리는 경우들이 많단말야")
- 랜딩: PR #971(Phase 1~4,
e12170d2) — 마이그레이션 0건, Vercel 앱 영향 없음(스크립트·CI·문서만) - 설계서: 없음(수리·절차 트랙) — 분석·Phase 계획·예상 효과 표는 이슈 #966 본문
- 정본:
docs/process/migration-landing.md(랜딩 절차) ·supabase/migrations/README.md규칙 6(갱신) ·scripts/migrations/* - 도구:
scripts/migrations/renumber-to-tail.mjs(재번호+동기화,--check) ·scripts/migrations/landing-lock.mjs(PC 전역 잠금) ·scripts/migrations/install-landing-hooks.mjs(훅 설치, PC당 1회) — 전부 레포 안, node 내장 모듈만. 픽스처 검증 드라이버는 세션 스크래치(레포 밖) - 게이트:
check:migrations에 오름차순 단언 추가(CIMIGRATION_BASE_REF주입 시) · pre-push 훅(꼬리 이하 번호 푸시 거부) - 버그리포트: 없음
- 계약: 신규 없음 — schema.sql 재생성은 기존
schemaSnapshotContract(버전순 이어붙임) 규칙을 그대로 따른다
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 재번호 자동화 스크립트(3곳+α 한 명령) | ✅ PR #971 |
| Phase 2 | 검사 조기화(로컬 --check + CI 오름차순 단언) | ✅ PR #971 |
| Phase 3 | 랜딩 잠금(15분 판정·넘겨받기·인계) + pre-push 훅 | ✅ PR #971 |
| Phase 4 | 절차 문서 정본 + README 규칙 6 갱신 + 등재 | ✅ PR #971 + 이 문서 |
1. 배경
병렬 세션 여러 개가 동시에 마이그레이션을 만든다. 번호는 랜딩 순서대로 커져야 하는데(db push가 꼬리 이하 번호를 조용히 건너뛰고 "적용됨"으로 기록 — 규칙 6), 랜딩 순서는 예측이 안 된다. 8/20 #477이 두 번 연속 번호를 선점당했고, 8/21~22 HQ가 번호를 미리 배정하는 "사다리"를 운영했지만 트랙별 준비 속도가 예측을 계속 깨서 재배정이 대여섯 번 반복됐으며, 8/31에도 4트랙 경합으로 하루 2회 재번호가 났다.
2. 문제 제기
충돌 발견이 CI(약 7분) 뒤에야 난다
로컬 게이트(check:migrations)는 같은 트리 안의 중복만 보므로, 상대가 main에 랜딩하기 전 갈라진 브랜치에서는 발화하지 않는다. CI가 돌고 나서(또는 머지 직전 원격 대조에서) 드러난다.
고치는 작업이 수작업 여러 곳이다
파일명·schema.sql·EXPECTED_LATEST_MIGRATION + 브랜치가 만진 파일 속 번호. README 규칙 6에는 스쿼시 이전의 "6곳 수작업" 안내가 그대로 남아 있었다.
고치면 CI가 처음부터 다시 돈다
재번호 푸시 → CI 전체 재실행(마이그레이션 경로는 migration-smoke 포함 full 레인) → 그 사이 다른 트랙이 랜딩하면 반복. 이것이 Actions 한도 소진의 주범.
3. 해결 방안
원칙 (오너 go 2026-08-31)
"번호를 미리 잘 정하는 방법은 없다 — 랜딩 직전에 기계가 정하고, 정하는 순간부터 Production 적용까지 다른 세션이 끼어들지 못하게 잠근다." 설계 문답에서 오너가 확정한 것: 죽음 판정 침묵 기준 15분(D1), 살아있는 세션의 잠금을 시간만으로 뺏는 규칙은 넣지 않음(D2, 침묵 기준만), 규칙 모르는 세션 대비 기계 강제(D3, 훅+CI 단언).
접근
| 대안 | 판정 |
|---|---|
| 번호 사전 배정(사다리 부활) | 기각 — 8/21~22 실측 실패(랜딩 순서 예측이 깨질 때마다 재배정) |
db push --include-all(순서 무시 적용) | 기각 — Production 실제 실행 순서 ≠ 새 DB 재생 순서가 되어 같은 함수를 고친 두 트랙의 결과가 조용히 어긋남 |
| GitHub merge queue | 기각 — 큐에서 CI를 한 번 더 돌려 Actions 소모 증가 |
| 랜딩 직전 기계 확정 + PC 전역 파일 잠금 | 채택 — 세션 간 메시지(불안정, 전역 지침 §6)에 의존하지 않고 파일 생성·이동의 원자성만 사용 |
4. 적용한 내용
Phase 1 — 재번호 자동화 (#971)
scripts/migrations/renumber-to-tail.mjs: origin/main을 받아 꼬리 계산 → 묶음 전체를 꼬리 다음 시간 슬롯으로 개명(묶음 내 상대 순서 유지) → 파일 자기 주석·브랜치 접촉 파일의 번호 토큰 치환 → schema.sql 전체 재생성(schemaSnapshotContract와 동일 규칙: 버전순 이어붙임) → EXPECTED_LATEST_MIGRATION 동기화 → 잔여 옛 토큰 검사(남으면 실패). 리베이스에서 schema.sql·manifest 충돌은 아무 쪽이나 받고 이 스크립트를 재실행하면 재계산된다.
Phase 2 — 검사 조기화 (#971)
--check 모드(수 초, 로컬) + check:migrations 확장: MIGRATION_BASE_REF가 있으면 "새 마이그레이션 번호 > 기존 꼬리"를 단언 — 중복 없이 더 작은 번호라 조용히 건너뛰는 경우(기존 중복 검사의 사각)를 잡는다. policy-contract.yml verify가 이미 계산하던 base_ref를 env로 주입. 새 job 없음(Actions 추가 비용 0).
Phase 3 — 랜딩 잠금 (#971)
scripts/migrations/landing-lock.mjs, 잠금 위치 ~/.barbelic/landing-lock/. current.json은 생성 후 불변(생존 신호 = mtime, heartbeat가 갱신), 변하는 정보(단계·PR)는 progress.json, 넘겨받기는 current.json을 history/로 원자적 이동(한 세션만 성공). 15분 침묵 → 죽음 판정. 머지 직전 verify 재확인이 오판 안전망(뺏긴 세션은 머지 전에 스스로 멈춤 — 최악이 재번호 1회). pre-push 훅: 꼬리 이하 번호 푸시 거부, LANDING_LOCK_BYPASS=1은 누적 푸시 전용.
Phase 4 — 문서 (#971 + 이 문서)
docs/process/migration-landing.md 절차 정본(0~7단계, 15분 규칙, 홀더 규칙 2건, 우회·한계) + sidebar. README 규칙 6에서 낡은 "수작업 6곳·three tail-lock tests" 안내 제거(스쿼시 때 이미 사라진 파일들을 가리키고 있었다).
주요 결정과 그 근거
- 잠금이 CI 구간을 통째로 덮는다 — CI 후 재번호는 CI 재실행을 낳으므로, "번호 확정 → 적용 완료"가 한 잠금 안이어야 재실행이 0회가 된다.
- 생존 신호를 잠금 파일 mtime으로 — 죽은 세션의 협조가 필요 없는 신호. current.json을 불변으로 두고 mtime만 쓰면 갱신 경합(넘겨받기 rename과의 충돌)도 원천 차단된다.
- 훅은 "번호 검사"만, "잠금 보유 검사"는 안 한다 — 브랜치 누적 푸시(CI 없이 커밋 축적, 오너 지시 8/31)가 잠금 없이 이뤄지는 정상 흐름이라, 푸시 시점엔 번호만 검문하고 잠금 강제는 랜딩 절차(verify)에 둔다.
작업 중 드러난 것
- schema.sql이 "마이그레이션 버전순 이어붙임"과 정확히 일치해야 한다는 계약(
schemaSnapshotContract) 덕분에 재번호 시 schema.sql은 통째로 재생성하면 된다 — 주석 치환 같은 부분 수정보다 단순하고 안전. - 이 PC의 bash 쪽 git은 2.24라
git init -b가 없다(PowerShell 쪽과 다름). 픽스처 스크립트는git init && git checkout -b로 우회. - PowerShell 5.1에서 한글·따옴표 섞인 커밋 메시지는 인자 전달이 깨진다 —
git commit -F <파일>로 우회(기존 heredoc 함정 메모리와 같은 계열).
5. 적용 결과
| 항목 | 결과 |
|---|---|
| 충돌 발견 시점 | CI 완주 후 ~7분 → 로컬 수 초(migrations:check) |
| 충돌 1건당 수리 | 수작업 3곳+α → 명령 1개(픽스처 36단언으로 검증) |
| 충돌로 인한 CI 재실행 | 경합일 1~2회+ → 0회 설계(잠금이 창 자체를 제거) — 실전 검증은 다음 병렬 랜딩에서(미검증) |
| 죽은 세션의 잠금 점유 | 무한대기 없음 — 15분 침묵 후 자동 인계(픽스처로 검증) |
| 규칙 모르는 세션 | 푸시 거부(훅) + CI 빨간불(오름차순 단언)로 이중 안내 |
| 검증 상태 | 픽스처 3종 36단언 + 실레포 node 게이트 12종 + CI 1회(full 레인) 통과. 실제 병렬 랜딩 시나리오는 미검증 |
6. 이번 개선으로 향상된 것
번호를 "예측"하는 일이 사라졌다
사다리·신고제·번호 풀이 전부 폐지되고, 개발 중 번호는 임시·랜딩 직전 기계 확정이 원칙이 됐다. 새 세션이 규칙을 몰라도 훅과 CI가 막고 가르친다.
구조적으로 남는 것
docs/process/migration-landing.md(절차 정본) · check:migrations 오름차순 단언(CI 상주) · pre-push 훅(PC 상주) · 잠금 파일 = 랜딩 인계 메모(죽은 세션이 어디까지 갔는지 기록).
남은 것
- 훅 설치는 PC당 1회 수동(
node scripts/migrations/install-landing-hooks.mjs) — 이 PC에는 랜딩 직후 설치 완료. 새 PC/재클론 시 재실행 필요. - 실전 효과(충돌 CI 재실행 0회)는 다음 병렬 랜딩 기간에 실측으로 닫는다.
- 오너 습관 1건: 마이그레이션 포함 PR은 GitHub 웹에서 직접 머지하지 않기(잠금 밖이라 5단계 원격 꼬리 확인만이 백스톱).