BRID 정체성 체계 구축 — 종목 식별의 단일 원칙 (2026-08-20~21)
모든 종목의 UUID가 어디서 왔는지를 사람이 읽을 수 있는 문자열(BRID, Barbelic Resource ID)로 고정하고, 그 문자열에서 UUID를 결정적으로 파생하는 통일 식별 체계를 설계·구현·전량 적용한 작업의 기록이다. 체계 정본화(#460)부터 인입 경로의 BRID 전환(작업 E), Production 전 행 재발급 컷오버(#488~#491), 베이스라인 각인(#494), 옛 기계 해체(#498)까지 완결됐다.
관련 PR: #460 · #470 · #476 · #488~#491 · #498 · 정본 설계 문서: docs/data/brid.md · 계약: test/bridIdentitySqlContract.test.mjs, supabase/tests/database/brid_identity_v1.test.sql
1. 배경
이 앱의 종목(exercises)은 세 곳에서 태어난다 — 공식 카탈로그, 유저의 직접 입력(custom), 외부 앱 인입(WodUp). 세 출신이 한 테이블에 살면서 정체성 규칙이 없었고, 비용이 누적됐다.
- UUID의 출처 불명. 초기의 시드 함수들(
lift-guild:접두 4계열)은 문자열을 함수 안에서 만들어 UUID를 뽑고 그 문자열을 버렸다. 나중에 발급된 행들은 그마저 없이 랜덤 UUID였다. 한 테이블에 정체성 체계가 둘이었고, 행만 봐서는 어느 쪽이 만든 것인지 알 수 없었다. - 인입이 만든 오염. WodUp 인입은 매칭에 실패하면 custom 종목을 만들어냈고, 오너 계정 실측에서 기록의 46%가 정본이 아닌 인입산 종목에 붙어 있었다. 동명 종목이 표기 변형만으로 중복 생성됐고, 무참조 중복이 수십 행 쌓였다.
- 전량 제거가 불가능했다. "외부 인입분을 언제든 걷어낼 수 있다"는 인입 재설계의 전제인데, 인입산 종목과 수제 custom이 같은 정체성 공간에 섞여 있으면 그 경계를 그을 수 없다.
2. 문제 제기
식별자가 이력을 담지 못한다
UUID는 불투명하다. "이 행이 왜 존재하나"에 답하려면 UUID 바깥에 근거가 있어야 하는데, 그 근거(시드 문자열)가 생성 즉시 소멸했다. 디버깅·감사·중복 판정 전부가 우회 증거에 의존했다.
같은 실체에 UUID가 여럿 생긴다
외부 키가 매핑에 성공해도 실체를 만들면 같은 운동에 UUID가 둘 생긴다(카탈로그 정본 원칙 위반). 반대로 매핑 실패분을 실체화하지 않으면 기록이 붙을 곳이 없다. 성공=번역, 실패=실체화의 이원 처리가 규칙 없이 코드 곳곳에 흩어져 있었다.
수제 custom과 인입산 custom의 공간 충돌
동명이라는 이유로 두 출신을 융합하면, 유저가 손으로 만든 종목과 인입이 만들어낸 종목이 카탈로그 레벨에서 구분 불가가 된다 — 전량 제거 원칙이 무너진다. 권한·수명주기는 같게 취급하더라도 정체성 공간은 갈라야 했다.
3. 해결 방안
원칙 (오너 확정 2026-08-19~20)
- 모든 데이터의 정체성은 BRID, UUID는 파생이다. 예외 없음. — 오너 단일 원칙(08-20).
- 문법은 머리 3조각 + 불투명 tail.
brid:<resource>:<lineage>:<tail...>. 머리(brid/exercise/계보)만 위치로 해석하고 tail은 verbatim 보존한다 — 사후 정규화가 결정성을 깨는 것을 문법 차원에서 봉쇄. - lineage는 닫힌 enum 3종.
official-catalog(전역 유일) /user-custom(소유자 내, compact 이름키) /external(소유자 내, 스코프드 provider 키). 인입산은external계보로 격리 — 수제 custom과 정체성 공간을 분리해 전량 제거 경계를 보존한다. - UUID 파생은 결정적 함수 하나.
brid_uuid_v1(text)— md5 기반, legacy 시드와 동일 조립식. 같은 BRID면 언제 몇 번을 발급해도 같은 UUID(멱등). - 발급은 DB 단일 소유. builder 3종(
brid_for_official_exercise_v1/brid_for_user_custom_v1/brid_for_external_exercise_v1)만 발급하고, 클라이언트·Edge의 임의 조립은 금지. 무스코프 provider 키(128946같은 raw id)는 builder가 거부한다. - external은 매핑 실패분에만 발행한다. 매핑 성공분은 실체 없이
exercise_external_mappings로 번역만 한다.
구조
| 단계 | 무엇 | PR |
|---|---|---|
| 체계 정본화 | 문법·builder·파생 함수·exercises.brid 컬럼·계약 테스트 | #460 |
| 인입 경로 전환 | 인입 read-only·일회성화 + BRID 발급 배선 + 동명 가드 | #470 · #476 (작업 E) |
| 전량 컷오버 | Production 전 행 BRID 재발급 + 예외 없음 강제 | #488~#491 |
| 각인 | 베이스라인 스쿼시에 BRID 상태 그대로 수록 | #494 |
| 해체 | BRID 이전 시대의 replay 기계 은퇴 | #498 |
4. 적용한 내용
체계 정본화 — 문자열이 살아남게
exercises.brid 컬럼을 신설해 발급 문자열을 저장했다(legacy 시드가 생성 후 소멸해 디버깅이 불가능했던 것의 교정). 형식은 exercises_brid_format_check가 닫힌 lineage enum까지 강제한다. 파생식의 고정 벡터는 pgTAP(실 DB)과 JS 독립 구현(bridIdentitySqlContract)이 이중 검증한다 — 한쪽 구현이 조용히 어긋나면 다른 쪽이 빨개진다.
인입 경로의 BRID 전환 (작업 E 중 BRID 배선)
- 배선 1·2 (#470): 인입이 종목을 만들 때 랜덤 UUID 대신 BRID 발급으로 전환. 동시에 custom 생성 가드를 넣었다 — 신규 custom 이름이 정식 종목과 compact 동일이면 생성 거부 (에러 detail에 기존 종목 id를 실어 클라이언트가 안내 가능), 자기 custom과 동일이면 거부가 아니라 BRID 멱등 반환(같은 BRID → 같은 UUID → 기존 행). compact 정의는
brid_compact_name_key_v1단일 소유 — 신규 정의 금지. - 배선 3 (#476): 매핑 실패분의 1단계 실체화 — 실패한 스코프드 키를
external계보 BRID로 즉시 실체화해 기록이 붙을 곳을 만들고, 성공분은 매핑 행으로 번역만 한다. 이원 처리가 규칙이 됐다. - 같은 작업 묶음에서 인입 세션 완전 read-only(#470)·일회성화(#476)·엣지 오디언스 단일화(#479)가 함께 랜딩됐다 — BRID가 "전량 제거 가능"을 정체성 차원에서 보장하고, read-only·일회성이 운영 차원에서 보장하는 상보 구조다.
전량 컷오버 — 예외의 시대를 끝내다 (#488, 마이그레이션 20260820250000)
기존 행을 "새 행부터 적용"으로 남겨두는 대신, Production의 모든 종목 행을 자기 계보의 BRID로 재발급했다(오너 결정: 예외 없는 단일 원칙). 이후는 DB가 강제한다 — exercises.brid not null, 행 트리거가 id = brid_uuid_v1(brid)를 요구, BRID 없이 들어온 행은 랜덤 id를 받는 대신 거부된다.
컷오버의 Production 적용은 두 번 막혔고 두 번 다 당일 해소됐다.
- #489: 재발급이 무참조 중복 종목과 충돌 — 컷오버가 무참조 중복 68행을 먼저 정리하도록 수정.
- #490: 통계 갱신 큐가 은퇴한 id를 물고 있어 재차단 — "통계 큐는 역사가 아니다" 판정으로 큐를 정리 대상에 포함.
- #491: 사후검사가 이 마이그레이션과 무관한 옛 잔재에 걸려 죽는 문제 — 검사를 이 마이그레이션이 직접 은퇴시킨 id로 한정(전역 단언 금지 원칙, #483→#484 사고의 교훈 재적용).
각인과 해체
- 이틀 뒤의 2차 베이스라인 스쿼시(#494)가 컷오버 결과를 그대로 수록했다 — 신선 재생본과 Production의 정식 카탈로그 675행이 BRID 컬럼 포함 md5 일치로 실측됐다. BRID 체계는 이제 마이그레이션 이력이 아니라 베이스라인 자체다.
- BRID 이전 시대의 인입 재실행(replay) 기계 — 이미 호출자 0인 실체화 함수(32,908자)와
wodup-replay정책 — 를 #498(20260821010000)이 걷어냈다.
5. 적용 결과
Production 실측 (컷오버 적용 후)
| 항목 | 결과 |
|---|---|
exercises 전 행 BRID 보유 | 778행 · brid null 0 |
id = brid_uuid_v1(brid) 정합 | 불일치 0 |
| 무참조 중복 정리 | 68행 |
정식 카탈로그 (origin='system' 675행) | 신선 재생본과 md5 일치 (BRID 컬럼 포함) |
| 예외 없음 강제 | not null + 행 트리거 + format check (구조로 잠금) |
계약·게이트
| 계약 | 무엇을 잠그나 |
|---|---|
bridIdentitySqlContract.test.mjs | docs/data/brid.md 핵심 조항 ⇔ 구현 일치, 파생식 고정 벡터(JS 독립 구현) |
brid_identity_v1.test.sql (pgTAP) | 같은 고정 벡터를 실 DB에서 검증 |
exercises_brid_format_check + 행 트리거 | 무BRID·비정합 행의 진입 차단 |
| builder의 무스코프 거부 | raw provider id로는 정체성 발급 불가 |
6. 이번 개선으로 향상된 것
UUID의 출처에 즉답할 수 있다
임의의 종목 행에서 brid 컬럼을 읽으면 "누가 이 이름을 지었고 어느 범위에서 유일한가"가 문자열로 나온다. 감사·디버깅·중복 판정이 우회 증거 수집에서 컬럼 조회 하나로 바뀌었다.
재인입이 멱등해졌다
같은 외부 키·같은 custom 이름은 언제 다시 들어와도 같은 BRID → 같은 UUID다. 인입을 전량 제거하고 다시 실행해도 정체성이 보존된다 — 인입 재설계의 "일회성 인입 + 언제든 재인입" 운영 모델이 정체성 차원에서 성립하게 됐다.
전량 제거의 경계가 그어졌다
lineage external ⇔ origin='external' ⇔ 전량 제거 대상 — 세 값이 항상 일치하는 정합성 트라이앵글로, 인입산과 수제 custom이 구조적으로 분리됐다. "외부 인입분을 걷어낸다"가 쿼리 한 줄로 정의 가능한 집합이 됐다.
동명 중복이 원천에서 접힌다
표기 변형("원 암 로우(변형)" / "원암로우 (변형)")은 compact 이름키가 같은 키로 접어 같은 종목이 된다. 정식 종목과의 동명은 생성 시점에 거부된다. 오염이 쌓인 뒤 청소하는 대신 생기지 않게 됐다.
한 테이블 한 체계
legacy 시드 4계열·랜덤 UUID·BRID가 섞여 있던 자리에 체계가 하나만 남았고, 그 사실을 DB 제약이 지킨다. 이후의 어떤 작업(스쿼시·재인입·카탈로그 정비)도 정체성 전제를 다시 의심할 필요가 없다.
남은 것
체계 본체 밖의 후속이다.
- 카디오
measurement_type정비 — 무작업 종결 판정(08-21). 심폐 시드 22행 전수에서mt=reps0건 — #477(mt 108행 정정)이 이미 커버했고 베이스라인=Production md5 일치로 Production도 동일함이 실측됐다. 잔여 목록이 #477 랜딩 이전 작성이라 낡아 있던 것. - 5.1 RPC 체인 평탄화 — 3체인 단일 본문화 + 내부 층 8개 드롭(#502,
20260821010200~010400), 랜딩 진행 중. - P4 문서 최종화 —
docs/data/brid.md에 배선 3 반영 등, P3 해체 결과 기준으로 마감. - 카탈로그 판결 잔여(5.3) — incline 중복·바벨로우 병합·synthetic 은퇴 등 오너 판결 대기.
- 재인입 실행 — 파이프라인·프리미티브·런북은 준비 완료, 실행은 오너 수동(확정 정책).
- 음차 사전 구축 — 인입 실패분 표시명의 한글 음차는 현재 원문 유지 + 단일 훅 함수로 격리돼 있고, 사전이 준비되면 그 함수만 교체한다.