관리자 종목 정보 최신화 — 부위 2필드에서 target_muscles 단일 정본, 유저 스코프 RPC에서 전량 직접 읽기까지 (2026-08-25)
- 기간: 2026-08-24 ~ 2026-08-25 (설계 1 + 구현 1 세션. 오너: "관리자 전체 종목 페이지에 구 종목들이 표시되는 것 같다, brid도 category도 기록 필드도 안 보인다" → "region_shares랑 primary_region을 합쳐서 target_muscle 같은 하나의 필드로")
- 랜딩: PR #720(Phase 0,
0b289449, 마이그레이션20260821730000Production) · PR #729(Phase 1~3,d4bd6adb, 클라 전용, barbelic-docs /admin Vercel 배포 success·번들 신규 마커 실측) · 리포트 PR #730 - 설계서: 이슈 #661 본문(설계서 전문 — "예상 효과·개선사항" 절 포함, artifact 아님)
- 정본:
exercises.target_muscles(순서 배열[{region, share}], 선두=주동, 정수 % 내림차순 합 100) +exercise_target_muscles_valid_v1·adminExerciseCatalog.ts(AdminExercise행 계약) ·adminMappingPageRepository.tsloadAdminExerciseCatalogRows· 원칙 "관리자 읽기 = 전량·전 컬럼, 앱 스코프 RPC를 타지 않는다" - 도구:
simulate_admin_catalog.mjs(Production 전 행 어댑터 시뮬레이션, 일회성·미커밋) · Management API raise-롤백 dry-run - 게이트:
report_taxonomy_target_muscles.test.sql(pgTAP 18 — 구 region_shares 12 개정) ·adminStandaloneShell.test.mjs13(테이블 행 wire shape 픽스처 + D4 화이트리스트) ·exercises_target_muscles_check제약(NOT NULL·DEFAULT 없음) - 버그리포트:
bug-report/bug-023-20260825.md— 인입·관리자 신설 종목 미분류 생성, 리포트 볼륨 조용한 누락 - 계약: 앱 RPC 페이로드 불변(구 컬럼이 원래 안 실렸다) — 클라 타입
ExerciseRow.target_muscles(ExerciseTargetMuscle[]) 갱신
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | target_muscles 스키마 통합 — 백필 1,070행·소비자 5함수 재배선·구 컬럼 드롭 | ✅ PR #720 (0b289449, 730000 Production) |
| Phase 1 | 관리자 읽기 경로 교체 — 전량·전 컬럼 직접 읽기 + AdminExercise 어댑터 | ✅ PR #729 (d4bd6adb) |
| Phase 2 | 상세 패널 — BRID(복사)·출처 인입원/소유자·타겟 부위 % 칩·기능 분류 | ✅ PR #729 |
| Phase 3 | 목록 강화 — 배지·검색 확장·필터(상태/출처/분류, 기본 활성만) | ✅ PR #729 |
| Phase 4 | 검증·기록 — pgTAP·계약 테스트·BUG-023·이 문서 | ✅ (#730 + 이 문서, 오너 실화면 확인 대기) |
| Phase 5 | 출처 분리 뷰 — 정식/커스텀/외부 일급 세그먼트(기본 정식)·소유자 표시 (오너 지시 08-25) | ✅ PR #743 |
1. 배경
독립 관리자 셸(barbelic-docs /admin)의 "전체 종목" 탭은 앱과 같은 유저 스코프 RPC(get_exercise_catalog) 위에서 돌았다. 이 RPC는 앱 화면용이라 ① 활성 AND (system ∪ 본인 커스텀 ∪ 본인이 만진 external)만 반환하고 ② brid·부위·기능 분류를 페이로드에 아예 싣지 않는다. 관리자가 보는 것은 최신 데이터의 유저 스코프 부분집합을 구 필드셋으로 렌더한 화면이었다(오너 인식은 "구 종목이 표시된다"였지만 실측 원인은 스코프+필드 누락). 한편 부위 정보는 primary_region(주동 단일) + region_shares(둘 이상 주동일 때만 % 지분)의 2필드 결합 표현이라, 소비자마다 "지분이 있으면 지분, 없으면 주동 100%" 분기를 반복해야 했다.
2. 문제 제기
관리자가 전 카탈로그의 ~73%만, 구 필드셋으로 보고 있었다
Production 실측(2026-08-24): 전체 1,047(당시) 중 관리자 화면에 오는 것은 ~780+α, 비활성 90은 0건. brid·primary_region·movement_pattern·region_shares는 RPC 페이로드에 없어 화면을 고쳐도 값이 올 수 없는 구조.
부위 정보가 2필드에 걸쳐 있어 모든 소비자가 같은 분기를 반복했다
exercises_region_shares_check가 두 필드를 결합 제약으로 묶고, 집계(get_volume_overview_annual_v3)·파생(create_custom_exercise)이 각각 "지분 유무" 분기를 들고 있었다. 소비자가 함수 2개뿐인 지금이 가장 싼 통합 시점(전수 실측).
분류 없는 INSERT 경로 3곳이 열려 있었고, 실제로 재발했다 (BUG-023)
20260821580000이 "다음 인입 때 스윕 규칙 필요"를 기록으로 남겼지만, 이틀 뒤(08-24) 오너 모트라 인입이 미분류 5행을 만들었고 그 볼륨(기간당 최대 1,104kg)이 리포트 두 차트에서 조용히 빠지고 있었다. import_motra_workouts_v1·import_wodup_batch_to_canonical_engine·create_catalog_exercise_engine 셋 다 분류를 싣지 않았다 — nullable 스키마에서 운영 규칙은 문서일 뿐이었다.
3. 해결 방안
원칙 (오너 지시 2026-08-24·25)
- "region_shares랑 primary_region을 합쳐서 target_muscle 같은 하나의 필드로" — 통합이 트랙의 전제.
- 착수 지시(08-25): "오너 승인 없어도 끝까지 완주, 땜질 대신 본질적으로 더 좋은 구조" — D1~D6은 이슈의 권장안(구조 우선안)으로 확정: D1 RLS 직접 읽기 / D2 2-pane 유지+배지 / D3 기본 활성만 / D4 새 필드 표시 전용 / D5 순서 배열
target_muscles/ D6 구 컬럼 동일 트랙 드롭.
접근
- 표현: 맵
{region: share}은 Postgres jsonb 키 정렬로 50/50 동률(평행봉 딥스 등)의 주동이 소실 → 순서 배열 채택, "선두=주동"은 검증기의 내림차순 검사로 선언이 아니라 계약. DEFAULT 없는 NOT NULL — 분류 없는 쓰기는 조용한 전신 분류 대신 시끄러운 실패(BUG-023 재발 차단). DEFAULT 안·인입 후 스윕 절차 안은 기각(리포트 3절). - 읽기 경로: 관리자 RPC 신설 대신 RLS 직접 읽기(
exercises_select_authenticated의is_lift_guild_admin()분기, archetype 인덱스와 같은 기존 패턴) — 마이그레이션 0, 새 컬럼 추적 비용이 "select 컬럼 추가"로 줄어든다. 서버 게이트 부재는 어댑터의 필수 계약 단언 + 행 상한(4,000/12,000) + PostgREST 1,000행 절단 회피 페이지 워크로 보강. - 정규화: 앱
NormalizedExercise재사용은 기각(활성·앱 스코프 전제 —isActive: true하드코딩, slug 필수, 관리자 컬럼 부재) — 같은 정규화 관용구 위에 관리자 진실을 얹은AdminExercise별도 계약. 독립 셸의 공유 카탈로그 인덱스(setRemoteExercises) 의존은 소비자 0 실측 후 제거.
4. 적용한 내용
Phase 0 — target_muscles 스키마 통합 (#720, 20260821730000)
- 검증기
exercise_target_muscles_valid_v1(배열 1~7 ·{region, share}정확 두 키 · 7부위 enum · 양의 정수 · 중복 금지 · 합 100 · 내림차순) +target_muscles jsonb NOT NULL+ CHECK. - 백필: 미분류 5행 실명 분류(시스템 유사 종목 계열) → 잔여 스윕(replay 안전망) → 변환(지분 없는 행
[{주동, 100}], 지분 행 주동 선두·내림차순) → postcheck A: 드롭 전 전 행을 구 컬럼과 대조(선두=구 주동, 지분 멀티셋=구 지분, 전 행 검증기 통과) → 구 컬럼·제약·검증기 드롭 → postcheck B(대표 행·라이브 함수 잔여 참조 0). - 소비자 5함수 SCR-3 전체 재선언:
get_volume_overview_annual_v3(가중 수치 동일 유지) ·create_custom_exercise(파생 동일, 산출만 이관) ·create_catalog_exercise_engine(분류 파생 신설) ·import_motra_workouts_v1·import_wodup_batch_to_canonical_engine(전신 축 폴백 신설). - pgTAP 전면 개정 + 직접 시드 9파일
target_muscles추가(throws_ok 테스트는 NOT NULL 선행 실패로 단언이 바뀌지 않게).
Phase 1 — 읽기 경로 교체 (#729)
loadAdminExerciseCatalogRows(exercises 전 컬럼 24개 + exercise_synonyms, 안정 전체 정렬 위 페이지 워크, 상한 초과 시끄러운 실패) → adminDomain → lazyFeatureDomain → BarbelicApi.AdminExercise어댑터: 필수 계약(id·brid·name_ko·target_muscles 검증기 동형·movement_pattern) 파손 행은 부분 렌더 대신 화면 에러. synonym 없는 행은 이름으로 default 표기 합성(앱 규칙 동일).adminCatalogStore재배선: catalogVersion = 로컬 단조 카운터(직접 읽기는 항상 신선), 쓰기 후 재조회(reloadRemoteData)는 기존 호출부 그대로 새 경로.
Phase 2 — 상세 패널 (#729)
- 종목 식별자: BRID 행(복사 버튼,
DkIc.copy신설) · 출처에 인입원(WodUp/모트라)·소유자(단축 UUID) 병기. - 종목 특성: 타겟 부위 % 칩(선두=주동 강조, 단일 100%) · 기능 분류 — 영문 키는 리포트와 같은 한글 라벨, 미지 키는 원문 표기. 비활성 종목 열람(상태 배지). 수정 폼은 기존 필드셋 유지(D4).
Phase 3 — 목록 강화 (#729)
- 행 배지(분류 칩·비활성) + 메타 라인(주동 부위·기록 필드·출처). 검색이 brid·분류·부위/기능(한글+영문 키) 매칭. 필터: 상태(기본 활성만)·출처·분류 8종, 파셋 카운트(매핑 탭 칩 패턴 재사용).
Phase 5 — 출처 분리 뷰 (#743, 오너 지시 2026-08-25)
"유저 커스텀이랑 오피셜 카탈로그는 분리해서 볼 수 있도록" — Phase 3의 출처 보조 칩을 목록 상단의 일급 세그먼트(정식 카탈로그 | 유저 커스텀 | 외부 인입 | 전체, 파셋 카운트 상시)로 승격. 기본값 = 정식 카탈로그: 분리가 기본 경험이고 커스텀·인입은 명시적으로 골라 본다. 커스텀·인입 행 메타에 소유자 단축 UUID 표시. design contract hook adminAllExercisesOriginView 등록, 고아 CSS 제거.
주요 결정과 그 근거
- 내림차순을 검증기에 넣은 것: "선두=주동"이 컨벤션이면 언젠가 깨진다 — 제약이면 깨지는 순간 23514.
- DEFAULT 없는 NOT NULL: DEFAULT는 새 정식 종목까지 조용히 전신으로 오분류한다. 각 쓰기 경로가 명시적으로 분류를 실어야 한다.
- catalogVersion 로컬 카운터: 직접 읽기에는 서버 버전 게이트가 무의미 — 기능의 "내 카탈로그 최신인가" 검사(자기 발행 버전 비교)만 만족시키면 된다. ensure가 채운 요청을 다시 미충족으로 보지 않게
max(현재+1, 요청 버전).
작업 중 드러난 것
- 백필 UPDATE 후 같은 트랜잭션의 ALTER TABLE이 55006("pending trigger events") — deferrable 제약의 트리거 이벤트 잔존.
set constraints all immediate;를 ALTER 앞에 넣어 해소(Production dry-run에서 실측 후 반영). - 조사 3일 사이 데이터가 움직였다: 08-24 조사(1,047·미분류 0) → 착수 시점 1,070·미분류 5. 마이그레이션 백필은 조사 스냅샷이 아니라 착수 시점 재실측 위에서 작성해야 한다.
- PostgREST 기본 1,000행 절단: 1,070행 직접 읽기는 페이지 워크 없이는 조용히 잘린다 — 안정 전체 정렬(sort_order·name_ko·id) 필수.
- 깨진 로컬 설치에서도 tsc·전체 스위트 실행 가능:
.bin심이 없어도node node_modules/typescript/bin/tsc·node --import tsx --test로 직접 실행된다(check:lint만 CI 판정).
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| 부위 정본 | 2필드(결합 제약·소비자 분기 반복) → target_muscles 1필드(전 행 NOT NULL, 정보 손실 0 — postcheck A로 전 행 증명) |
| 관리자 종목 범위 | ~780+α·비활성 0 → 전 카탈로그 1,070(활성 980·비활성 90) — Production 전 행 어댑터 시뮬레이션 통과 |
| 관리자 표시 필드 | brid·부위·기능 0 → BRID(복사)·타겟 부위 %·기능 분류·인입원·소유자·비활성 상태 |
| 미분류 종목 | 5행(08-24 인입분, 리포트에서 볼륨 누락 중) → 0행 + 신규 발생 구조적 차단(NOT NULL) |
| 볼륨 집계 | dry-run 실측: 무관 유저 payload 완전 동일 · 모트라 유저만 두 축 동일량(+280/+240/+1,104) 인입(수리 효과) — 부위 합==기능 합 불변식 유지 |
| 게이트 | pgTAP 12(region_shares) → 18(target_muscles, 새 제약 7종) · adminStandaloneShell 8 → 13 · 전체 노드 스위트 1,916 통과 · check:remote-schema 0 실패 |
| 미검증 | 오너 실화면 확인 대기 — barbelic-docs /admin 재배포 후 전체 종목 1,070·페이스풀 상세(어깨 60%·등 40%)·커스텀+비활성 필터 90건 |
6. 이번 개선으로 향상된 것
부위 질문의 답이 한 필드에서 나온다
"주동이 어디고 몇 %인가"가 target_muscles 하나로 답이 되고, 이후 소비자는 결합 제약·지분 유무 분기 없이 이 필드만 읽는다. 50/50 동률의 주동까지 배열 순서가 보존한다.
미분류 종목은 이제 존재할 수 없다
NOT NULL·DEFAULT 없음 + 쓰기 4경로 명시 분류. 리포트 볼륨의 조용한 누락(BUG-023)이 구조적으로 재발 불가.
관리자 화면이 카탈로그의 진실을 그대로 본다
"관리자 읽기 = 전량·전 컬럼" 원칙 + AdminExercise 계약 + 실 스키마 행 픽스처 테스트 — 새 컬럼이 생기면 select 목록과 어댑터에 한 줄씩 추가하면 끝(RPC 개정 불요).
구조적으로 남는 것
exercise_target_muscles_valid_v1(내림차순 포함) · AdminExercise 어댑터와 계약 테스트 · D4 화이트리스트 계약(수정 저장이 표시 전용 필드를 실으면 테스트가 먼저 깨진다) · 관리자 직접 읽기 페이지 워크 패턴.
남은 것
- 오너 실화면 확인(barbelic-docs
/admin): 전체 1,070 표시·필터·BRID 복사·타겟 부위 칩. - 타겟 부위·기능 편집은 별도 트랙(D4) — 쓰기 RPC 확장 + 지분 합 검증 필요.
- 상세의 "메인/보조 타겟 부위"(자유 텍스트
primary_part/secondary_parts)와 새 "타겟 부위"(분류 정본)가 나란히 보인다 — 표기 정리는 오너 판단 대기. mobileReportViewMappers.ts의 축 폴백 후보 키primaryRegion/primary_region은 RPC 페이로드 호환 체인이라 남겨 둠(죽은 키지만 무해).