종목 정체 컬럼 정리와 id uuid 전환 — "외부 인입 종목은 커스텀인가"라는 모호함에서 한 줄 규칙·타입 보장까지 (2026-09-03)
- 기간: 2026-09-03 (세션 2개 — 분석·계획
b56663ea, 구현·랜딩5d407216; 오너 질의 원문 "외부 인입 종목은 왜 남아 있나 → 유저 커스텀과 어떻게 다르나 → origin과 source가 같은 역할인가 → BRID 3필드만 남기고 나머지 드롭 + id uuid 타입") - 랜딩: PR #1190 (Phase 0~3,
d23c9418) — 마이그레이션20260906005900_exercise_identity_columns_v1·20260906015900_exercise_id_uuid_v1, Vercel 배포 O(릴리스 PR 뒤) - 설계서: 이슈 #1175 첫 댓글(분석·Phase 계획·"예상 효과·개선사항" 포함) · 오너 결정 댓글 · 인수인계 댓글 · Phase 완료 보고 2건
- 정본:
docs/data/brid.md(행 판별 =owner_user_id·source, 라벨 공식 v2) ·docs/data/exercise-identity-hard-cutover.md(en/ko) · 마이그레이션 2개의 머리 주석(순서·치환 규칙) ·exercises_source_ref_shape_check·exercises_external_owner_source_ref_uidx - 도구: 세션 스크래치패드(레포 밖) —
probe.mjs(Production 읽기 전용 프로브 17종),build/edit-fns.mjs·edit-p2-g1~g3g.mjs(Production 원문에 "정확히 N회 일치" 치환),assemble.mjs·assemble-p2.mjs(템플릿 + 함수 본문 조립),phase2-proto.sql(타입만 바꾼 롤백 트랜잭션 + plpgsql_check 전수),p2-report.mjs(형 오류를 함수별로 묶어 원문 줄과 함께 보고), 샌드박스sbx/(config + migrations/tests junction) - 게이트: pgTAP
exercise_identity_columns_v1(18단언) + 기존 pgTAP 45파일 개정(origin 제거)·27파일 개정(uuid) · 마이그레이션 자가 검증 DO 2개(컬럼 부재·함수 본문 구 참조 0·정책/제약/인덱스·라벨 일치·FK 24·트리거 9·시그니처 text 잔존 0) · 단위 계약 테스트 재앵커(schemaRls·screenRpcContracts·exerciseIdentityHardCutover 등 20파일) - 버그리포트: 없음(정리 트랙 — 드러난 결함 2건은 4절 "작업 중 드러난 것")
- 계약:
docs/data/app-screen-rpc-contract.md(카탈로그origin은 소유자 파생값) ·docs/data/rpc-catalog.md(시그니처 uuid·은퇴 34) ·docs/contracts/identity-passing.md·desktop-screens-props.md·docs/data/import-pipeline.md점검 SQL ·docs/data/exercise-naming.md·docs/data/admin-health-report.md(placeholder 항목 삭제)
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 퇴역 함수 2개 드롭(remap_wodup_user_customs_to_canonical_v1·wodup_provider_exercise_key_for_placeholder_v1, 호출처 0 실측) + 전용 테스트 삭제 | ✅ PR #1190 |
| Phase 1 | 정체 컬럼 4개 드롭(origin·external_payload·is_external_placeholder·client_request_id), source_ref 제약·인덱스, 라벨 공식 v2, 함수 19개 재발행, 앱 발급 id(D3), 클라이언트·관리자 화면 | ✅ PR #1190 |
| Phase 2 | exercises.id와 참조 컬럼 29개 text → uuid, 함수 48개 재서명·88개 재발행, brid_uuid_v1 uuid 반환, 권한 복원 | ✅ PR #1190 |
| Phase 3 | 문서 9종 개정 + 이 작업 기록 | ✅ PR #1190 · docs PR 이 문서 PR |
1. 배경
종목(exercises) 테이블에는 "누구 것이고 어디서 왔나"를 말하는 정체 컬럼이 8개(id·source·owner_user_id·brid·origin·source_ref·external_payload·is_external_placeholder·client_request_id) 있었다. 2026-09-03 오너가 관리자 화면에서 외부 인입 종목이 남아 있는 이유를 물었고, 질의가 "유저 커스텀과 무엇이 다른가 → origin과 source가 같은 역할인가"로 이어졌다. Production 실측(종목 1,072개: 정식 793·커스텀 110·외부 인입 169)으로 origin은 source+owner_user_id로 100% 유추되고, is_external_placeholder는 전부 false, external_payload는 어느 화면도 읽지 않으며, 커스텀 110개의 source_ref에는 앱 요청 토큰이 의미 없이 복사돼 있었다. 세션·세트·유저 id는 uuid인데 종목만 text라 함수마다 ::text 변환과 정규식 CHECK가 붙어 있던 것도 같은 자리에서 드러났다.
2. 문제 제기
외부 인입 종목이 앱의 커스텀 관리에서 빠져 있었다
"내 커스텀 종목" 목록·숨기기(set_own_custom_exercise_active)·이름 중복 검사(create_custom_exercise)가 전부 origin='user'만 커스텀으로 취급했다. 박주희(145)·성근(19)·봉천곰돌이(5)의 외부 인입 종목 169개는 카탈로그에는 보이지만 관리 목록에는 0개 노출.
WodUp 중복 방지 인덱스가 아무것도 지키지 않았다
exercises_wodup_owner_source_ref_uidx가 origin='user' 조건이라(8월 중순 user로 만들던 시기의 잔재) 현행 외부 인입 164개를 0개 보호.
컬럼 8개가 4개 일을 겹쳐서 했다
정체를 읽으려면 컬럼 조합을 알아야 했고, 그 겹침이 위 결함을 만들었다. id는 text라 정규식 CHECK 19개·길이 CHECK 3개가 모양을 대신 지켰다.
3. 해결 방안
원칙 (오너 결정, 2026-09-03)
- D1 id 타입 uuid 전환은 이 트랙에 포함하되 마지막 코드 Phase로.
- D2 퇴역 함수 3개 드롭 승인(실측으로 1개는 이미 없어 2개).
- D3 앱이 종목 id를 직접 발급하고
client_request_id컬럼은 드롭. - D4 불필요 —
source_ref는 남긴다(재결정): 외부 인입만 채우고 커스텀·정식은 빈 값, 제약으로 강제. - D5 랜딩 단위 = 전 Phase 완료 후 PR 1개(§16).
- D6 관리자 "전체 종목"의 "외부 인입 종목" 세그먼트는 "유저 커스텀 종목"에 합침.
details는 범위 밖.
접근
| 대안 | 판단 |
|---|---|
채택 정식 = 소유자 없음 / 내 종목(커스텀·외부 인입) = 소유자 있음 / 외부 인입 = source <> 'barbelic', source_ref는 외부 인입만 | 컬럼 1개(origin)를 두 컬럼 조합으로 대체, 결함 2건이 조건 교체만으로 해소 |
origin 유지 + 결함만 수리 | 겹침이 남아 같은 종류의 조건 누락이 재발할 수 있어 기각 |
RPC 응답에서 origin 키 제거(계약 버전 올림) | 옛 앱 번들 파손 위험 → 기각. 파생값(소유자 없음=system, 있음=user)으로 키 유지, 카탈로그 계약 버전 8 불변 |
| uuid 전환을 별도 이슈로 | D1로 이번 트랙 마지막 Phase에 포함 |
| 함수 재발행을 손으로 | Production 원문(pg_get_functiondef)에 "정확히 N회 일치" 치환 스크립트 + diff 전수 검토로 대체 |
4. 적용한 내용
Phase 0 — 퇴역 함수 정리 (#1190)
remap_wodup_user_customs_to_canonical_v1·wodup_provider_exercise_key_for_placeholder_v1 드롭, 전용 테스트 2개 삭제(pending-changes 신고 대상 아님 — manifest 미등재), 원격 스키마 게이트 퇴역 목록 등재.
Phase 1 — 정체 컬럼 정리 (#1190, 20260906005900_exercise_identity_columns_v1)
① 컬럼 드롭 전에 의존 객체 선정리(정책 exercises_select_authenticated 재생성, 인덱스 4 드롭·1 재생성, CHECK 2 드롭) ② 커스텀 110행 source_ref := '' ③ exercises_source_ref_shape_check + exercises_external_owner_source_ref_uidx (owner_user_id, source, source_ref) where source <> 'barbelic' ④ brid_label_exercise_v2(source, owner_user_id, id) → 정규화 트리거 재발행 → v1 드롭(라벨 값 전 행 불변) ⑤ 함수 19개 재발행 ⑥ 컬럼 4개 드롭 ⑦ 자가 검증. D3는 create_custom_exercise·create_catalog_exercise(_engine)가 선택 키 id를 받도록: 같은 id 재전송이면 소유자·내용 일치 시 기존 행, 불일치면 23505 id is already assigned to a different … payload, 모양 불량은 22023 id must be a UUID. 클라이언트는 barbelicRepository가 id를 발급해 보내고(createClientUuid), 재시도는 같은 제출 키의 id를 재사용(appController.customExerciseRequestIdsRef). 직접 select(EXERCISE_SELECT_COLUMNS·관리자 카탈로그 컬럼)에서 origin 제거, 내 커스텀 목록은 소유자 필터만, 어댑터는 구 캐시의 external을 소유자 기준으로 정규화, 관리자 세그먼트 합침(D6), 건강 리포트 placeholder_exercises 항목 삭제.
Phase 2 — id uuid 전환 (#1190, 20260906015900_exercise_id_uuid_v1)
선정리(뷰 1·정책 1·트리거 9·인덱스 1·CHECK 22·FK 24) → 타입 변경 29컬럼(using ::uuid; jobs 배열은 default를 내렸다 올림) → 재생성(FK 24 원문 옵션 유지, 의미 CHECK 3 — 노트 행은 종목 없음·종목 행은 필수·배열 null 없음) → 시그니처 변경 함수 48개 drop+재생성·본문 88개 재발행·brid_uuid_v1 uuid 반환·라벨 v2 id uuid·Production ACL 복원 → 자가 검증. 클라이언트는 무변경(JSON에서 uuid는 문자열).
Phase 3 — 문서
정본 문서 9종 개정(상단 "계약" 항목).
주요 결정과 그 근거
- RPC
origin키는 파생값으로 유지:exercise_legacy_read_keys_v1에'origin'파생 키를 넣어 등록 RPC 응답(to_jsonb(행) || 키) 전부가 자동으로 유지되고, 카탈로그·PR 요약 스냅샷은 파생식으로 치환. 계약 버전 올림 없이 옛 번들이 깨지지 않는다. - 외부 인입도 "내 종목": 소유자가 있는 행은 전부 소유자 본인 것으로 취급(정체성 트리거·가시성·숨기기·중복 검사). 공급자 매핑 행만 외부 인입을 가리킬 수 있고 앱 커스텀은 못 가리킨다(
source분기). - 함수 수리의 오라클은 정적 검사기: 타입만 바꾼 롤백 트랜잭션에서 plpgsql_check 2.8(로컬 이미지 내장)로 전 함수를 검사 → 67함수 149건의 진짜 형 오류를 원문 치환으로 0건까지. SQL 언어 함수는 재생성으로 검사.
작업 중 드러난 것
- 타입 변경을 막는 객체 목록: 뷰·정책·
update of <컬럼>트리거(컬럼 목록만 있어도)·collate 인덱스·정규식 CHECK·FK 양쪽. 전부 명시 drop → 변경 → 명시 재생성. 마이그레이션 게이트는add constraint/create policy직전의drop … if exists를 요구한다. - drop 후 재생성한 함수는 실행 권한이 기본값으로 돌아간다 — Production ACL을 실측해 새 시그니처에 복원(revoke public + 역할별 grant). pgTAP
identity_passing_contract가 잡았다. - 같은 함수 다중 트랙 되덮기가 리베이스로 재발: #1182가
get_session_presentation_mains_json_v2_engine을 먼저 재발행 → 원문을 그 본문으로 교체하고 치환 재적용. 리베이스 뒤엔 upstream 마이그레이션 함수 ∩ 재발행 목록 교집합 확인. - 임시 마이그레이션 번호가 다른 트랙과 겹침(둘 다
20260905235900) → 리베이스 후migrations:renumber가20260906005900/015900으로 해결. $function$인용은$$로 바꿔야 계약 테스트(finalSqlFunction)가 다음 함수를 삼키지 않는다 · 계약 테스트 헬퍼는 시그니처가 바뀐 함수를 "오버로드"로 봐 시그니처 명시 필요 ·tableColumns/indexesFor는 베이스라인만 읽어 append 마이그레이션의 drop column은 스냅샷 본문으로 단언.- 플랫폼 검사기의 착각 1건(
get_admin_mapping_pageCTE 컬럼) — 런타임 pgTAP 정상.
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| 종목 정체 컬럼 | 9개(id·source·owner_user_id·brid·origin·source_ref·external_payload·is_external_placeholder·client_request_id) → 5개 |
| 외부 인입 종목의 "내 커스텀 종목" 노출·숨기기·중복 검사 | 0/169 → 169/169 (pgTAP·소유자 필터; 실기기 미확인) |
| WodUp 종목 중복 보호 | 인덱스 보호 0/164 → (owner, source, source_ref) 유일 164/164 |
커스텀 source_ref 잔여 토큰 | 110 → 0 (제약이 재발 차단) |
| 죽은 컬럼·퇴역 함수 | 컬럼 2·함수 2 → 0 |
| 종목 id 타입 | text + 정규식 CHECK 19·길이 CHECK 3 → uuid(참조 29컬럼 포함), CHECK 0 |
| 함수 시그니처의 text 종목 id | 48 → 0 (postcheck 단언) |
| pgTAP | 95파일 1,473건 PASS (샌드박스, 마이그레이션 2개 적용) |
npm run check | 2,449/2,450 (1건 = Windows CRLF 전용 perf_tab_switch, CI 통과) |
| e2e crud·empty·cardio | 24/24 |
| CI | verify·migration-smoke 초록(2차 — 1차는 브라우저 e2e 3건의 구 입력 키 clientRequestId·origin 단언 수정, run 33742556484) |
| Production 적용 | 릴리스 v0.15.0(PR #1177, 2026-09-03 10:56 UTC 머지)에 포함 — Production 마이그레이션 꼬리 20260906015900, exercises.id·session_exercises.exercise_id uuid, 드롭 컬럼 4종 부재를 읽기 전용 조회로 확인 |
6. 이번 개선으로 향상된 것
유저: 외부 인입 종목도 내 종목처럼 관리된다
인입 유저가 종목 관리 화면에서 인입 종목을 보고 숨길 수 있고, 같은 이름 커스텀 재등록이 막힌다. 화면 응답 모양은 그대로다.
개발: 정체 규칙이 한 줄이 됐다
"정식 = 소유자 없음, 내 종목 = 소유자 있음, 출처는 source, 외부 인입만 source_ref". 조건 누락 재발의 원인이던 origin 컬럼이 없어졌고, 인입 엔진은 (owner, source, source_ref)로 기존 행을 찾는다.
구조적으로 남는 것
exercises_source_ref_shape_check·exercises_external_owner_source_ref_uidx(제약이 규칙을 강제).- uuid 타입이 id 모양을 보장 — 정규식 CHECK와
::text변환 관습 소멸. - 앱 발급 id 계약(D3): 세션·세트와 같은 "클라이언트 발급 + 기본키 중복 흡수" 철학으로 통일.
- 마이그레이션 기법 정본: 정적 검사기(plpgsql_check) 오라클 + 원문 치환 조립(스크래치패드 도구 목록은 상단).
남은 것
- 릴리스 v0.15.0(2026-09-03)으로 Production 적용 완료 → 이슈 #1175
[반영완료]. - 시각적 확인은 사람 몫: 관리자 "유저 커스텀 종목" 세그먼트에 외부 인입 종목이 합쳐진 모습, 앱 종목 관리 목록의 인입 종목 표시 — 정보로만 남긴다(§14).
details컬럼 정리는 범위 밖(오너 별도 결정).