Skip to content

종목 정체성 하드 컷오버

한국어 번역본

이 문서는 원문(영어)의 한국어 번역이다. 정본은 원문이며, 계약·게이트 판단이 갈리면 원문을 따른다. 원문: docs/data/exercise-identity-hard-cutover.md

이 문서는 UUID hard cutover의 이력과 이식 계약을 기록한 ADR이다. 현재 종목의 의미·정식 결합 종목·연속 동작 세트·외부 provider 해석 정책은 lift-guild.exercise-model을 정본으로 삼는다.

결정

Barbelic은 하나의 종목에 하나의 내부 정체성을 갖는다: 데이터베이스가 발급한 UUID 형식 값인 public.exercises.id다. 운동 사실(fact), 계획, 과거 1RM 입력, 즐겨찾기, 외부 인입, 큐, 프로젝션, 화면 read model이 모두 같은 값을 사용한다.

장기 존속하는 정식 ID 리졸버는 없다. 레거시 slug와 UI 약칭은 일회성 컷오버 마이그레이션에서만 받아들인다. 컷오버 이후 그것들은 유효한 영속화 경계도 RPC 경계도 아니다.

정체성 모델

public.exercises가 엔터티 테이블이다.

  • id uuid primary key가 유일한 내부 종목 식별자다(#1175, 2026-09-03: 이 컬럼과 모든 참조 컬럼이 text에서 uuid로 바뀌어 정규식 check 대신 타입이 모양을 보장한다). 최초 컷오버 때는 SQL 운반 타입을 text로 유지해 ID 컬럼 두 개를 일시적으로 병행하지 않고도 단일 트랜잭션 하드 컷오버를 실현했다. 이는 slug나 별칭을 저장해도 된다는 허가가 아니다.
  • 시스템 카탈로그 ID는 일회성 컷오버 동안 검수를 마친 slug로부터 결정론적으로 생성되어, 서로 다른 환경이 동일한 불투명 값으로 수렴한다. 런타임 코드는 여전히 그 ID를 받아서 저장할 뿐이며, slug로부터 다시 생성하지 않는다.
  • slug text unique not null은 안정적인 URL이자 관리용 라벨이며, 외래 키가 아니다.
  • 소유와 출처는 두 컬럼이다(#1175, 2026-09-03 — 이 둘로 유추되던 origin 컬럼은 퇴역): owner_user_id는 정식 카탈로그면 null, 사용자 소유 행(앱 커스텀·외부 인입 모두)이면 채워진다. source는 출처(barbelic·wodup·motra)이고 barbelic이 아닌 행만 source_ref(공급자 원본 키, (owner_user_id, source, source_ref) 유일)를 갖는다. RPC 응답의 origin 키는 소유자에서 파생한 system/user다.
  • 이름 변경, 번역, 숨김, slug 변경 그 어느 것도 id를 바꾸지 않는다.
  • 과거 참조가 있는 종목은 삭제하지 않고 is_active = false로 은퇴시킨다. 은퇴는 그 종목을 신규 선택 표면에서 제거하지만, 기존 사실을 무효화하거나 그 재구성을 막지는 않는다.

검색 별칭과 분류 체계는 서술용 메타데이터다. row 같은 archetype이나 deadlift 같은 검색어는 종목 정체성이 아니며, exercise_id 컬럼에 절대 기록해서는 안 된다.

영속되는 모든 내부 종목 참조는 다음 둘 중 하나다:

  • public.exercises(id)를 향한 외래 키를 가진 정식 UUID 텍스트, 또는
  • 크기가 제한된 갱신/조회 필터로만 쓰이며 사용 전에 public.exercises에 대해 검증되는 UUID 텍스트 배열.

provider 식별자는 외부 경계에서만 문자열로 남는다:

text
(provider, provider_exercise_id) -- unique source identity
                  |
                  v
       exercise_id UUID text FK  -- internal identity

여러 provider 종목이 의도적으로 하나의 내부 종목에 대응할 수 있다. 그 반대 방향은 이름으로부터 추론하지 않는다.

strength standards 같은 provider 데이터셋도 해석된 exercise_id를 저장한다. 그들의 source_slug는 계보(lineage) 용도로만 남으며, 프런트엔드는 slug·이름·별칭으로 기준값과 종목을 매칭하지 않는다.

사용자 생성 종목

커스텀 종목은 로컬 카탈로그 패치가 아니라 일급 종목 엔터티다.

  1. 클라이언트가 CatalogDomain을 통해 create_custom_exercise(jsonb)를 호출한다.
  2. 데이터베이스가 소유자를 인증하고, 측정 메타데이터를 검증하고, origin = 'user' 종목을 삽입한다.
  3. RPC가 영속된 행과 그 UUID를 반환한다.
  4. 클라이언트가 반환된 그 행을 자신의 카탈로그 캐시에 추가한다.
  5. 운동, 계획, PR, 즐겨찾기는 3단계 이후에만 그 UUID를 사용할 수 있다.

클라이언트는 custom-*, slug 유래, 타임스탬프 유래, 이름 유래 종목 ID를 지어내서는 안 된다. 재시도는 DB RPC 경계에서 클라이언트가 제공한 멱등성 키를 사용하며, 두 번째 정체성을 발급하지 않는다. 커스텀 종목은 모든 원천 참조를 다시 쓰고 모든 파생 모델을 재생(replay)하는 명시적 유지보수 마이그레이션으로만 병합할 수 있다. 부분적인 런타임 병합 RPC는 없다.

두 사용자가 같은 표시 이름으로 종목을 만들 수 있다. 이들은 서로 다른 소유자 범위 UUID 엔터티로 남으며 그 이름 때문에 병합되는 일은 결코 없다. 소유자 범위 UUID는 다른 계정으로 이전할 수 없다. 계정을 삭제할 때, 같은 트랜잭션이 그 계정의 세션, 계획, 과거 RM 입력, 즐겨찾기를 연쇄 삭제한 뒤에야 커스텀 엔터티가 제거된다. 종목만 따로 삭제하는 것은 어떤 원천 사실이라도 그것을 참조하는 동안 계속 차단된다.

쓰기 경계

운동, 완료 세션 수정, 계획, 과거 1RM, 즐겨찾기, 관리자, 인입 쓰기 경로는 모두 UUID 종목 ID를 받는다. 데이터베이스 외래 키가 최종 강제 경계다. 이름 조회는 선택기(picker)의 관심사일 뿐 쓰기 폴백이 결코 아니다.

브라우저는 신뢰된 read model이 반환한 ID를 불투명 값으로 취급한다. 신뢰되지 않은 쓰기 경계에서는 요청을 보내기 전에 정식 UUID 텍스트가 아닌 값을 추가로 거부한다. 데이터베이스 check와 외래 키가 여전히 권위를 갖는다.

wodup과 앞으로의 provider는 운동 사실을 삽입하기 전에 exercise_external_mappings를 통해 (provider, provider_exercise_id)를 해석한다. 해석되지 않은 provider 종목은 영속되는 전역 provider 범위 origin = 'external' 스테이징 엔터티가 되거나 인입 검토 큐에 남는다. 그 provider 문자열은 사실 테이블의 exercise_id에 결코 들어가지 않는다. 원시 테이블 RLS가 이 스테이징 엔터티를 숨긴다. 정제된 카탈로그/상세 행은 해당 계정이 provider로 검증된 운동 연결을 가진 뒤에만 노출되며, 그 첫 연결을 만들 수 있는 것은 검증된 인입뿐이다.

외부 매핑을 바꾸면 앞으로의 인입에만 영향을 준다. 관리자 버튼 동작 중에 과거 사실의 일부를 다시 써서는 안 된다. 이미 사용된 external 엔터티를 다른 종목으로 통합하는 것은 오프라인 유지보수 마이그레이션이다: 쓰기 주체를 차단(fence)하고, 모든 원천 참조를 다시 쓰고, 프로젝션을 폐기하고, 하나의 세대(generation) 아래에서 재생한다.

일회성 컷오버

이 컷오버는 의도적으로 롤링 이중 ID 마이그레이션이 아니다.

  1. 운동 쓰기 주체, 인입 워커, 통계 워커를 중단하거나 차단한다.
  2. 구 ID → UUID 전체 대응표를 만들고 수동으로 검수한다. 이름이 비슷하다고 동등한 것은 아니다. pull-up/strict-pull-up이나 row archetype과 barbell-row 종목 같은 사례는 명시적 결정이 필요하다.
  3. system, user, external-placeholder, 고아(orphan) 레코드에 대한 UUID 카탈로그 엔터티를 만든다.
  4. 정본 참조를 먼저 다시 쓴다: 완료된 운동 종목, 계획 세트, 과거 1RM 기록, 즐겨찾기, archetype 대표 종목, 외부 매핑.
  5. 해당 컬럼과 남은 모든 내부 참조에 정식 UUID 형식 check와 정확한 외래 키를 추가한다. 갱신/조회 ID 배열은 사용 전에 UUID 텍스트로 검증한다.
  6. 구 정체성 계약 아래에서 만들어진 페이로드를 가진 대기 잡을 비운다.
  7. 파생 종목 데이터를 삭제하고 원천 사실로부터 재구축한다: 세션 롤업, 기간/월/전체 기간 통계, 실측 PR 이벤트와 상태, PR 요약과 스냅샷, 홈/볼륨 프로젝션, 달력 요약. PR 이벤트의 previous_valuedelta는 시간순으로 재계산한다. 파생 ID를 제자리에서 갱신하는 일은 결코 없다.
  8. 고아가 된 원시 행이 없고, 논리적 원천 행이 유실되지 않았고, 모든 파생 세대가 최신이며, 모든 read model이 (user_id, exercise_id, read-model key)마다 최대 1행만 갖는지 검증한다.
  9. 쓰기 주체를 다시 열기 전에 일회성 대응표, 구 카탈로그 행, 별칭 배열 RPC 파라미터, 프런트엔드 정규화 코드를 제거한다.

마이그레이션은 PostgreSQL이 허용하는 범위에서 트랜잭션으로 실행한다. 워커 차단과 재생 공표는 새 세대를 사용하므로, 독자가 구 프로젝션과 신 프로젝션이 섞인 상태를 관찰하는 일은 결코 없다.

Read model 계약

개요, 상세, 기록표, 볼륨, 홈, 달력, 즐겨찾기는 같은 UUID를 exercise_id로 노출한다. 이들은 별칭 행을 반환하지 않으며, 프런트엔드는 slug나 이름으로 행을 병합하지 않는다.

  • 하나의 종목에 속하는 PR 대상은 그 UUID의 필드이거나 자식 행이다.
  • 벤치마크 표현이 묶어서 보여주더라도 서로 다른 종목은 서로 다른 채로 남는다.
  • 달력의 종목 수는 서로 다른 UUID를 기준으로 센다.
  • 기록·볼륨 필터는 UUID를 받아 직접 비교한다.
  • 상세 요청은 UUID 하나를 사용하며, 정식 UUID에 별칭 배열을 더한 형태가 아니다.

홈 벤치마크, PR 요약, 온보딩 소속도 프런트엔드에 박아 넣은 slug 관례가 아니라 데이터다. exercise_business_role_memberships는 안정적인 표현/설정 키 각각을 정확한 exercise_id 하나 및 명시적 순서와 함께 저장한다. 런타임 쿼리는 그 UUID로 직접 조인한다. 반환된 benchmark_keyonboarding_key는 표현만 선택하며, slug로 부터 정체성을 복원하는 데 절대 써서는 안 된다.

정상 상태에서 금지되는 메커니즘

다음은 컷오버 퇴행이다:

  • canonicalExerciseIdFromUiAlias, exerciseIdVariantsForUiAlias, 또는 이에 상응하는 런타임 리다이렉트 표;
  • slug, 종목 이름, archetype ID, provider ID를 내부 exercise_id로 사용하는 것;
  • 커스텀 종목의 정본으로 localStorage를 쓰는 것;
  • 클라이언트에서 custom-${Date.now()}나 slug 유래 ID를 생성하는 것;
  • 화면 매퍼에서 마지막 행 우선(last-row-wins)이나 별칭 기준 합산 병합을 하는 것;
  • 전체 재생 없이 파생 PR/통계 행 안의 ID를 다시 쓰는 것.

검색 동의어, 구 URL 리다이렉트, provider 매핑은 남아 있어도 되지만, 영속화 바깥에서 해석되어야 하며 쓰기 전에 반드시 하나의 UUID 엔터티에서 끝나야 한다.

인수 게이트

다음이 모두 성립할 때에만 컷오버가 완료된 것이다:

  • 영속되는 모든 내부 exercise_id가 UUID로 뒷받침되고 참조 무결성이 유효하다;
  • 모든 공개 쓰기 RPC가 종목 UUID 자리에 온 이름, slug, archetype, provider 문자열을 거부한다;
  • 커스텀 종목 생성이 DB 우선이며 멱등이다;
  • 외부 매핑이 다대일이며 UUID를 대상으로 한다;
  • 마이그레이션 전후로 원천 행 수가 대조 일치한다;
  • 파생 데이터가 완전히 재생되었고 프로젝션 세대가 최신이다;
  • 개요, 기록, 볼륨, 달력에 중복/별칭 종목 행이 없다;
  • 홈 벤치마크, PR 요약, 온보딩 소속이 slug나 이름 매칭이 아니라 데이터베이스의 정확한 역할 소속을 통해 해석된다;
  • 프런트엔드에 런타임 종목 ID 정규화기나 별칭 팬아웃이 없다.