Skip to content

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)

  1. 모든 데이터의 정체성은 BRID, UUID는 파생이다. 예외 없음. — 오너 단일 원칙(08-20).
  2. 문법은 머리 3조각 + 불투명 tail. brid:<resource>:<lineage>:<tail...>. 머리(brid/exercise/계보)만 위치로 해석하고 tail은 verbatim 보존한다 — 사후 정규화가 결정성을 깨는 것을 문법 차원에서 봉쇄.
  3. lineage는 닫힌 enum 3종. official-catalog(전역 유일) / user-custom(소유자 내, compact 이름키) / external(소유자 내, 스코프드 provider 키). 인입산은 external 계보로 격리 — 수제 custom과 정체성 공간을 분리해 전량 제거 경계를 보존한다.
  4. UUID 파생은 결정적 함수 하나. brid_uuid_v1(text) — md5 기반, legacy 시드와 동일 조립식. 같은 BRID면 언제 몇 번을 발급해도 같은 UUID(멱등).
  5. 발급은 DB 단일 소유. builder 3종(brid_for_official_exercise_v1 / brid_for_user_custom_v1 / brid_for_external_exercise_v1)만 발급하고, 클라이언트·Edge의 임의 조립은 금지. 무스코프 provider 키(128946 같은 raw id)는 builder가 거부한다.
  6. 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.mjsdocs/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 externalorigin='external' ⇔ 전량 제거 대상 — 세 값이 항상 일치하는 정합성 트라이앵글로, 인입산과 수제 custom이 구조적으로 분리됐다. "외부 인입분을 걷어낸다"가 쿼리 한 줄로 정의 가능한 집합이 됐다.

동명 중복이 원천에서 접힌다

표기 변형("원 암 로우(변형)" / "원암로우 (변형)")은 compact 이름키가 같은 키로 접어 같은 종목이 된다. 정식 종목과의 동명은 생성 시점에 거부된다. 오염이 쌓인 뒤 청소하는 대신 생기지 않게 됐다.

한 테이블 한 체계

legacy 시드 4계열·랜덤 UUID·BRID가 섞여 있던 자리에 체계가 하나만 남았고, 그 사실을 DB 제약이 지킨다. 이후의 어떤 작업(스쿼시·재인입·카탈로그 정비)도 정체성 전제를 다시 의심할 필요가 없다.


남은 것

체계 본체 밖의 후속이다.

  • 카디오 measurement_type 정비 — 무작업 종결 판정(08-21). 심폐 시드 22행 전수에서 mt=reps 0건 — #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 은퇴 등 오너 판결 대기.
  • 재인입 실행 — 파이프라인·프리미티브·런북은 준비 완료, 실행은 오너 수동(확정 정책).
  • 음차 사전 구축 — 인입 실패분 표시명의 한글 음차는 현재 원문 유지 + 단일 훅 함수로 격리돼 있고, 사전이 준비되면 그 함수만 교체한다.