세션 id BRID 체계 통합 — 무작위 uuid 이중 체계에서 자연키 파생 단일 정체성까지 (2026-08-29)
- 기간: 2026-08-28 ~ 08-29 (세션 1, 오너 지시 "이슈 #879 이어서 … phase 단위로 설명해주고" → 계보 설계 확정 08-28 → "중단없이 진행해줘" 08-29)
- 랜딩: PR #889(Phase 1,
37063306, 마이그레이션 20260829100000) · PR #897(Phase 2·3,00c356f8, 20260829110000) — 둘 다db pushProduction 적용·Vercel success 실측 - 설계서: 없음 — 이슈 #879 본문이 계획서(예상 효과·개선사항 절 포함), 계보 개정은 채팅 오너 설계(08-28)
- 정본:
docs/data/brid.md세션 절 ·public.brid_for_session_v1·public.enforce_session_identity_row(발급 트리거) - 도구: Production 드라이런 러너·픽스처 파생 치환 스크립트(세션 scratchpad, 레포 밖)
- 게이트: pgTAP
session_brid_issuance(plan 18) ·session_brid_cutover(plan 3) · 두 마이그레이션의 postcheck - 버그리포트: 없음(신규 트랙)
- 계약:
docs/data/brid.md에 세션 자원 절 신설(resource 확장 exercise → exercise·session)
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 발급 경로 — brid 컬럼·파생 함수·행 트리거 발급, id 기본값 제거 | ✅ PR #889 (37063306, 20260829100000) |
| Phase 2 | 기존 2,006행 컷오버 — 재발급+참조 재작성+not null 봉인 | ✅ PR #897 (00c356f8, 20260829110000) |
| Phase 3 | 게이트·실측 — 상주 pgTAP 2벌 + Production 검증 | ✅ #897 동승 + 실측(아래 5절) |
| Phase 4 | 기록 — 본 문서 + brid.md 세션 절 | ✅ 이 PR |
1. 배경
BRID(자원 정체성 문자열 → uuid 결정 파생)는 종목 카탈로그만 컷오버된 상태였다(#488~#491, 778행 예외 0). 세션은 자연키 (user_id, source, source_ref)가 유니크 인덱스·비공백 제약·저장 멱등까지 실사용 중일 만큼 정련돼 있으면서도, 정작 id는 자연키와 무관한 gen_random_uuid()로 발급됐다 — 한 테이블에 정체성 체계가 둘. 발단은 #875 화이트보드 완료 의미론 논의로, 오너가 세션 식별을 BRID로 통합하기로 결정(08-27)했고 후속 #880(보드 완료 BRID 기반)이 이 트랙에 의존한다.
2. 문제 제기
세션 행만 봐서는 출처를 알 수 없었다
id가 무작위라 "이 행이 어디서 왔나"에 즉답이 없고, 자연키 중복이 생겨도 id 충돌로 드러나지 않는다(종목 컷오버에서는 무참조 중복 68행이 실제로 드러났다).
이슈의 사전 추정이 실측과 달랐다
이슈 본문 추정 "FK 11개·2,003행(wodup 1,623·barbelic 380)" ↔ Production 카탈로그 실측 FK 13제약·12테이블, 2,006행(wodup 1,623·motra 331·barbelic 52) — "barbelic 380"은 motra 미분리 집계였다. 참조 목록 수기 나열 금지(#489 교훈)가 다시 근거를 얻었다.
3. 해결 방안
원칙 (오너 결정, 2026-08-28 채팅)
- D1 — 범위 =
sessions.id만. 하위 계층(session_exercises·exercise_sets)은 제외 — barbelic 하위 행은 자연키(source_ref='')가 없고, 편집 엔진 계약이 "위치 가변·id 불변"(클라이언트 existing_*_id 제자리 갱신)이라 위치 기반 BRID와 충돌하며, 세트 id는 통계 FK 9곳의 provenance 앵커라 유동화가 위험하다. 필요해지면 "클라이언트 발급 안정 키" 설계 선행의 별도 트랙. - D2 — 집행 시간대 무지정. 런칭 전이므로 즉시 집행("그냥 지금해줘").
- D3 — 계보는 종목과 같은 단위:
brid:session:barbelic:<owner>:<source_ref>/brid:session:external:<owner>:<앱>:<source_ref>— 외부 인입은 tail 선두에 어느 앱인지, barbelic은 앱 표기 없음. (첫 안brid:session:<source>:<owner>:<ref>를 오너가 이 단위로 개정.)
접근
| 대안 | 판정 |
|---|---|
| 발급 = 행 트리거 단일 지점(파생·게이트 겸임) | 채택 — 세션은 레시피가 하나·입력 전부 not null이라 트리거가 직접 파생 가능. insert 경로 3곳(save_workout_v4_engine·wodup·motra) 무수정 커버, 무BRID INSERT 구조적 불가 |
| 쓰기 경로별 계산 + 트리거는 거부 게이트(종목 방식, 이슈 원안) | 기각 — 종목은 계보별 레시피가 달라 필요했던 방식. 세션에선 대형 함수 3벌 재정의만 늘린다 |
| 컷오버 FK 재작성을 수기 나열 | 기각(#489 교훈) — 적용 시점 pg_constraint 카탈로그에서 생성 + 소프트 참조는 전수 조사 고정 + 드리프트 게이트 |
| jsonb 흔적 재작성 | 불필요 판정(실측) — 세션 id를 품는 jsonb 없음. raw_payload는 프로바이더 원본(불변), 달력 요약·PR 스냅샷은 uuid 컬럼으로만 보유. 로그성 기록은 역사로 보존 |
4. 적용한 내용
Phase 1 — 발급 경로 (#889, 20260829100000)
sessions.brid(부분 유니크 sessions_brid_uidx·형식 체크 — 닫힌 계보 enum barbelic|external) + brid_for_session_v1(계보 분기) + enforce_session_identity_row(BEFORE INSERT OR UPDATE): 미지정 시 파생, 명시값은 파생 일치 시만 통과(22023), id 전 행 불변·brid 불변·brid 보유 행 정체성 입력 불변. id의 gen_random_uuid() 기본값 제거. pgTAP 픽스처 14파일의 명시 무작위 id를 파생값 리터럴로 치환(자동 스크립트 + select-형 3파일 수동) — 값이 틀리면 트리거가 22023으로 즉사하는 자가 검증.
Phase 2·3 — 컷오버·게이트 (#897, 20260829110000)
매핑(old_id→brid→new_id, where brid is null) → 부모 재발급 + FK 자식(카탈로그 생성 13제약) + 소프트 참조 4열(user_exercise_pr_events.session_id·user_pr_exercise_summary_snapshots best_load/best_estimated_1rm_session_id·workout_mutation_receipts.session_id) + content_takedowns(type='session', 0행). 안전장치: 소프트 참조 드리프트 게이트·파생 충돌 단언·접촉 테이블 사용자 트리거 일시 정지(FK 내부 트리거 유지)·FK 지연 승격 후 set constraints all immediate 커밋 전 전수 검증·승격분만 원복·행수 보존 단언 → brid not null 봉인. postcheck: 무brid 0·파생 항등 2종 0·고아 0(영수증은 tombstone 고아가 정상이라 "재발급 old_id 잔존 0"으로 단언). 상주 게이트 session_brid_cutover(plan 3) 신설, 발급 테스트 레거시 구간을 컷오버 이후 의미(게이트 우회 무brid insert → 23502)로 개정.
주요 결정과 그 근거
- 랜딩 전 Production 전문 리허설: 두 마이그레이션 본문 그대로 + 포이즌 필(raise)을 Management API 한 트랜잭션으로 실행 —
DRYRUN-OK map_rows=2006 total=2006 no_brid_left=0후 전체 롤백, 롤백 실효 별도 확인. 계보 개정 후 재실행으로 재증명. FK 전수 검증(set constraints all immediate)이 롤백 전에 발화되도록 설계해 드라이런에서도 유효. - 인입 upsert 무충돌 근거: wodup·motra의
on conflict (user_id, source, source_ref)는 중재자 인덱스 선검사라, 파생 id 충돌(=자연키 충돌과 같은 사건)은 항상 중재자가 먼저 잡는다 — pkey 충돌로 새는 경로 없음.
작업 중 드러난 것
- 마이그레이션 번호 선점(규칙 6 실전): 최초 20260828100000이 #893(taxonomy_set_counts)에 선점 랜딩 → 20260829100000/110000으로 재번호. 병렬 세션 시대엔 push 직전 재확인이 필수.
- v81 풀 레인 지뢰를 밟음: CASE-004(
daySummaryExercises훅 소멸)·dock-geometry(탭바 68→55) — 본 트랙 첫 풀 레인이 발견했고 #893이 먼저 수리해 main 판 채택(제 중복 수리분 폐기). - pgTAP
throws_ok3-인자 형의 3번째 인자는 설명이 아니라 기대 메시지 — 7건이 "던졌는데 실패"로 나왔다. 4-인자(errcode, errmsg, desc)로 교정. - 픽스처 리터럴 치환은 파생값이라 자가 검증된다 — 손계산 실수가 조용히 썩지 않고 트리거 22023으로 즉사.
5. 적용 결과
| 항목 | 결과 |
|---|---|
| BRID 없는 세션 | 2,006 → 0 (Production 실측) |
id ≠ brid_uuid_v1(brid) 행 | — → 0 / brid ≠ 자연키 파생 행 0 |
| 계보 분포 | barbelic 52 · external 1,954(wodup 1,623+motra 331) — 원장부 합치 |
| 신규 발급 | 무작위 uuid → 트리거 파생 강제(무BRID INSERT 구조적 불가·23502) — Production 발급 리허설 1건 파생 일치(롤백 필) |
| 재작성 참조 고아 | FK 13제약·소프트 4열 전부 0 (postcheck + 독립 프로브) |
| 봉인 | sessions.brid NOT NULL ✓ · 발급 트리거 상주 ✓ · Vercel success ✓ |
| 오너 실기기 | 신규 저장·기존 기록 열람 정상 — 확인 완료(2026-08-29), 이슈 #879 종결([반영완료]) |
6. 이번 개선으로 향상된 것
세션 정체성이 한 체계로 접혔다
행 하나만 봐도 "누구의·어느 출처의·그 출처에서 무엇"인지 brid가 즉답하고, 자연키 중복은 id 충돌로 구조적으로 드러난다. #880(화이트보드 완료 BRID 기반)의 기반이 열렸다.
구조적으로 남는 것
brid_for_session_v1레시피 + 발급 트리거(단일 발급 지점 패턴 — 레시피 하나인 도메인의 표준형)- 참조 카탈로그 생성 + 소프트 참조 드리프트 게이트 + 전문 드라이런(포이즌 필) 절차 — 다음 도메인 컷오버 재사용
- 상주 pgTAP 2벌 +
docs/data/brid.md세션 절(정본)
남은 것
- 하위 계층(session_exercises·exercise_sets) 편입은 D1로 명시 제외 — 필요 시 "클라이언트 발급 안정 키" 설계 선행의 별도 트랙.
- 후속 이슈 #880 착수 가능 상태.