종목 카탈로그·프로필의 검증과 서버 호출을 5천 줄 파일에서 꺼내 codec·repository 로 나누고 port 결과를 타입으로 고정 — v0.18.0 A03 (2026-09-07)
- 기간: 2026-09-07 (세션 2개 — 첫 세션이 계획 게시·Phase 1~2·Phase 3 초안, 이어받은 세션이 origin/main
2de59d69(v0.17.1) →bf16e910(B01 #1346·#1344 반영) 위로 두 번 리베이스·G05 장부·검증·랜딩). 계획 ID A03 / Phase 2 Step 1([리팩터링 2-1]). - 랜딩: PR #1351 (Phase 1~3 한 PR, 머지
530bb4cf, 자동 랜딩 큐 run 34117522838 → 스테이징 deploy run 34117555461 초록) — 마이그레이션·엣지 함수 없음, DB·서버 함수·UI 표현 변경 없음. main(=staging) 반영 뒤[v0.18.0 스테이징]; Production 은 v0.18.0 릴리스에서. - 설계서: 없음 — 분석·Phase 계획·"예상 효과·개선사항" 표는 이슈 #1330 댓글.
- 정본:
src/react/services/domains/catalogCodec.ts·profileCodec.ts·profileAvatarSigning.ts,domains/repositories/catalogRepository.ts·profileRepository.ts,services/currentInputContract.ts, 계약src/react/contracts/ports/catalogDto.ts·profileDto.ts; 문서 G03 §16 정정, A01 추출 지도 §3·§4. - 도구: 옛 파일에서 함수 블록을 표식(marker) 줄 기준으로 잘라내는 일회성 Node 스크립트 2개 — 작업 폴더 밖 scratch 에 두고 제품에 넣지 않았다. 장부는
node scripts/architecture/check-coverage-inventory.mjs --render로 재생성. - 게이트:
tests/react/domainRepositoryDestinations.test.mjs(catalog 8·profile 7 행동 검사),domainPortWiring.test.mjs(factory 런타임 import 허용 목록 + codec 순수성 검사 신설), 기존 catalog/profile 테스트 12파일 무수정 통과,npm run check(전체), TypeScript unused·boundary 게이트, 계약 컴파일 fixture(ports.fixture.ts),npm run ci:local(전체, 아래 §5). - 버그리포트: 없음(수리 건 아님).
- 계약: G03 §16 담당 표기 정정 + 정정 문단,
contracts/ports/queries.ts·writes.ts의 카탈로그·프로필·온보딩 port 결과 타입.
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | origin/main 48f249c2 기준 worktree, 착수 댓글, 소유 목록 표 | ✅ |
| Phase 1 | 카탈로그 codec 신설, factory 에 23개 이식, 옛 본문 삭제, 행동 검사 8건 | ✅ 7d091a8a |
| Phase 2 | 프로필·온보딩·신체 지표·사진·InBody codec·서명 helper·중립 검사기 신설, factory 에 8개 이식, 행동 검사 7건 | ✅ 51be3a34 |
| Phase 3 | port 결과 타입 좁히기(DTO 파일 2), 문서·장부 갱신, 작업 기록, ci:local → PR → CI → 랜딩 | ✅ 이 문서의 PR |
1. 배경
유저 A 가 커스텀 종목 "내 스쿼트"를 만들거나 프로필에서 체중을 저장하거나 온보딩을 끝내면, 그 입력을 검사하고 서버 형식으로 바꾸고 서버 응답을 앱 모양으로 되돌리는 코드가 전부 barbelicRepository.ts(5,325줄) 한 파일에 있었다. A01(#1288)이 도메인별 목적지 파일 9개와 전송(transport) 경계를 만들었지만, 카탈로그·프로필 목적지에는 대표 함수 하나씩만 들어 있었고 그 하나도 옛 파일의 검증 함수를 주입받아 썼다.
Phase 2 의 첫 수직 통합(A09)과 편집기(A12·A13)는 "종목 ID 로 조회·저장"과 "프로필 신체값 해석"을 쓴다. 그 전에 이 두 도메인의 소유 위치를 바꿔야 후속 담당이 5천 줄 파일을 통째로 가져오지 않고, 같은 검증을 화면마다 다시 쓰지 않는다.
2. 문제 제기
검증·변환·해석이 도메인 밖 한 파일에 있어 후속 담당이 전체 파일을 import 해야 했다
카탈로그 함수 23개와 helper 20개, 프로필·온보딩 함수 9개와 helper 9개가 운동 기록·인입·소셜·통계 코드와 섞여 있었다. 도메인 서비스(catalogDomain·authProfileDomain)는 임시 어댑터 repositoryPorts.ts 를 거쳐 옛 파일을 그대로 불렀고 반환은 전부 any 였다.
계약 port 4개의 결과가 unknown 이라 컴파일이 아무것도 잡지 못했다
G03 의 CatalogQueryPort·CatalogWritePort·ProfilePort·OnboardingPort 는 결과 타입이 unknown 이었다. 온보딩 상태처럼 codec 이 camelCase 로 바꿔 주는 모양도 소비자가 필드를 추측해 읽었다.
문서의 담당 표기가 실제와 어긋났다
G03 문서 §16 표는 카탈로그 port 담당을 A04, 프로필 port 담당을 A05·A09 로 적고 있었다. 총괄 카드와 A01 추출 지도는 A03 이다.
3. 해결 방안
원칙
오너 결정 없음(계획 댓글의 "오너 결정 필요: 없음"). 전제 세 줄 — 랜딩은 자동 큐(landing:request), 새 파일은 A03 소유 경계(services/domains/catalog*·profile*) 안, 화면이 보는 반환 모양은 바꾸지 않는다.
접근
| 안 | 내용 | 판정 |
|---|---|---|
| A. codec 층 + repository factory 이식 + port 타입 좁히기 | 순수 검증·변환은 *Codec.ts 로, 서버 호출은 factory 로 옮기고 옛 본문을 지운다. 도메인 반환과 G03 port 결과를 타입으로 고정 | 채택 — 같은 종류 문제(검증 중복·any 해석)가 다른 화면에서 재발하지 않는다 |
| B. factory 에서 옛 함수를 다시 내보내기만 | 파일만 옮긴 모양 | 총괄 A01 카드가 명시로 금지("재수출만으로 완료하지 않는다"). 미채택 |
| C. 옛 파일 안에서 구역만 정리 | 5천 줄 파일 유지 | 후속 담당이 여전히 전체 파일 import. 미채택 |
세 층으로 나눈다. codec(순수 함수, 네트워크 없음): 허용 키 밖 필드 거부 → snake_case 변환 → 값 규칙(서버 CHECK 와 같은 범위) → 응답의 canonical id 확인. repository factory: 어느 RPC/테이블/버킷을 어떤 순서로 부르는가만 알고, 의존은 전송 함수와 최소 helper 만. domain: 로그인 세션을 얻어 factory 를 부르는 기존 정책 층. 옛 파일은 composition root 로 남아 factory 결과를 같은 이름으로 재수출하므로 화면·컨트롤러·기존 테스트는 변경 0 이다.
4. 적용한 내용
Phase 1 — 카탈로그 (7d091a8a)
domains/catalogCodec.ts신설:catalogWriteInput(허용 키),exercisePayload·newCatalogExercisePayload·customExercisePayload·exerciseArchetypePayload·exerciseExternalMappingPayload·exerciseWriteInputFromRow(변환), 체중 반영 계수 0~2·무게 배수 1·2·인기 등급 1~5·기록 필드 1개 이상(값 규칙, 코드LG_ADMIN_FORM_INVALID),createdExerciseRow·exerciseSynonymRow(응답 해석, id 는 서버 발급 uuid 만).any0.domains/repositories/catalogRepository.ts: 조회 2(loadExerciseCatalogRows·loadExerciseCatalogChangesRows), 커스텀 3(createCustomExercise·listOwnCustomExercises·setOwnCustomExerciseActive), 관리자 카탈로그 5(createCatalogExercise·createCatalogExerciseWithExternalMapping·saveExerciseArchetype·updateExercise·setExerciseActive), 표기 5, 별칭/세부 6, 외부 매핑 2 = 23개. 의존은callScreenRpc·callMutationRpc둘. 관리자 직접 테이블 쓰기의 권한은 서버 RLS(B01) — 여기서 판정하지 않는다.- 옛 파일에서 해당 본문 삭제(5,325 → 4,653줄). 기록 필드 계약 오류 함수
workoutMutationContractError는completedWorkoutWriteContract.ts가 export 하게 해 두 곳이 복사본 없이 공유. - 커스텀 종목 목록·수정 결과는 행마다 서버 발급 id 를 확인한 뒤 돌려준다(잘못된 응답이면 거부).
Phase 2 — 프로필·온보딩·신체 지표·사진·InBody (51be3a34)
services/currentInputContract.ts신설(중립):currentInputRecord(허용 키 밖 필드 거부),requireCurrentIsoDate(실제 달력 날짜만),requireCurrentString,optionalCurrentNumber(null·undefined 는 "없음", 유한한 숫자(0 포함)는 값, 그 밖은 거부). 인입·운동 기록 쓰기(옛 파일)와 프로필이 같은 한 벌을 쓴다.domains/profileCodec.ts신설: 온보딩 상태 해석(onboardingStateFromRpc, camelCase Dto 브랜드), 온보딩 입력 변환(onboardingPayload), 신체 지표 범위 검사와 체지방률 유도(체중 20~400LG_BODY_WEIGHT_LIMIT· 골격근 5~120LG_BODY_MUSCLE_LIMIT· 체지방률 1~80LG_BODY_FAT_LIMIT), 이름 1~24자(LG_PROFILE_NAME_INVALID), 사진 JPEG/PNG/WebP/GIF·5MB(LG_PROFILE_PHOTO_TYPE/_SIZE), 아이디 오류 번역(profileHandleError— 소셜 port 의setProfileHandle도 같은 함수를 import), InBody 행 변환.domains/profileAvatarSigning.ts신설:profileWithSignedAvatar·signProfileImagePaths. 소셜·그룹·통계 port 가 프로필 repository 전체를 가져오지 않는 좁은 접점. 6일 재사용 캐시는 factory 인스턴스 안(composition root 가 하나만 만든다).domains/repositories/profileRepository.ts:loadCurrentProfile(없으면ensure_user_profile),loadProfileWorkspace,loadOnboardingState,completeOnboarding,saveProfileBodyMetric,updateProfileDisplayName,uploadProfilePhoto(행 갱신 실패 시 올린 파일 삭제, 성공 시 옛 사진 삭제),importInbodyBodyMetrics(같은 파일 해시가 완료 상태면 재인입 없이 workspace 반환, 실패 시 배치failed) 8개. 의존은callRpc·callMutationRpc·서명 helper·uuid·파일명·시계(nowIso).- 옛 파일 4,653 → 4,118줄. 반환 모양·공개 이름 불변.
Phase 3 — 타입·문서·장부·랜딩 (이 PR)
contracts/ports/catalogDto.ts(CatalogExerciseRecord·ExerciseCatalogSyncResult)·profileDto.ts(ProfileRecord·SignedProfileRecord·ProfileWorkspaceRecord·ProfileBodyMetricSaveResult·OnboardingState·OnboardingStateDto) 신설.CatalogQueryPort(sync·listOwnCustomExercises)·CatalogWritePort2·ProfilePort4(workspace·신체 지표·이름·사진)·OnboardingPort2 의 결과를unknown에서 이 타입으로. 다른 영역이 채우는 멤버(검색 신호 A06, 아이디·성별 A04)는unknown유지.servicePorts.ts의satisfies와 계약 컴파일 fixture 가 실제 파사드 함수의 반환이 맞음을 컴파일로 증명한다.- G03 §16 표 정정(카탈로그 A04→A03, 프로필 A05·A09→A03) + 정정 문단, A01 추출 지도 §3 A03 행 "완료"·§4 어댑터 행 갱신,
domain-service-boundaries.md다음 단계 1번 완료 표시, G05 규칙 파일에 새 파일 6개 등재 + catalog/profile 장부 행 갱신(profile 에 service·types 행 추가) +--render.
주요 결정과 그 근거
- codec 은
any없이unknown입력에서 시작한다. boundary gate 가 새 파일의any를 거부하기도 하지만, 그보다 "검증 없는 값은 unknown" 이 이 층의 뜻이다. 옛 파일에 남은 운동 기록·인입 코드(A02/A05 범위)는 느슨한 레코드 타입을 기대하므로, 공통 검사기를 그 경계에서만 넓혀 주는 한 줄 wrapper 를 두었다(검사 로직은 한 곳). - 프로필 행·workspace 는 브랜드 없는 열린 레코드, 온보딩 상태만
Dto<…>브랜드. 프로필 행은 아직 서버 컬럼 이름(snake_case) 그대로 화면 소비자에게 가므로 "camelCase 내부 표현"(계약 §7) 이 아니다. 거짓으로 브랜드를 붙이지 않고, camelCase 전환은 화면 소비자를 함께 바꾸는 A09/A12/A13 이 그 파일에서 좁힌다. - 서명 helper 인자는
unknown. 소셜·그룹 도메인이client를unknown으로 넘기므로 helper 가 안에서 구조(storage.from)를 확인한다. storage 가 없는 client 는 서명 없이 그대로 돌려준다(종전과 같음). - 종목 id 발급은
createExerciseRequestId(exerciseIdentity). 옛createClientUuid는Math.random폴백이 있었다. 새 카탈로그 payload 는 보안 난수만 쓰는 기존 함수를 쓴다(uuid v4, 형식 동일). - 공통 입력 검사기는 도메인 밖 중립 모듈.
currentInputRecord는 인입(A05)·운동 기록 쓰기(A02)·프로필이 함께 쓴다. 어느 도메인 파일에 두면 다른 도메인이 그 도메인을 import 하게 되므로services/currentInputContract.ts에 두었다.
작업 중 드러난 것
- 옛 파일의 본문을 정규식·문자열 슬라이스로 보던 소스 위치 검사 9건이 이동 때문에 깨졌다(
exerciseBodyweightMetadata·exerciseIdentityHardCutover·homeFragmentBoundaries·appScreenRpcContract·wodupPlaceholderResolution·leanModelHardCut·runtimeExerciseIdentity·providerNeutralOnboardingContract·limitsRegistry). 단언은 그대로 두고 읽는 파일만 새 위치로(둘을 합쳐 읽는 곳 포함) 옮겼다. manifest 등재분 7건은tests/audit/pending-changes.json에 신고했다. - A01 이 catalog factory 에 주입하던
catalogWriteInput·createdExerciseRow는 codec 으로 들어가 의존에서 사라졐다. A01 의 destination 테스트 2건은 새 factory 서명으로 다시 썼다. admin_delete_exercise_synonym_v1·admin_set_default_exercise_synonym_v1는types/supabase.ts의 RPC 인자 map 에 없다(옛 파일이string이름으로 불렀다). factory 의 인자 타입에 직접 적었다 — map 등재는 G03 타입 경계(HQ) 몫으로 남긴다.sanitizeStorageFileName은 Wodup JSONL 용 함수라 프로필 사진 경로에도.jsonl을 붙인다(종전과 같은 동작). 바꾸지 않고 주입만 했다 — A05 가 인입 helper 를 옮길 때 프로필 전용 정리를 정한다.
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
catalogRepository.ts 실제 함수 | 1 (setOwnCustomExerciseActive, 검증은 주입) | 23 (의존 2: callScreenRpc·callMutationRpc) |
profileRepository.ts 실제 함수 | 1 (loadProfileWorkspace) | 8 |
| 카탈로그·프로필 codec 단독 테스트 | 0 | 행동 검사 15건(domainRepositoryDestinations) + codec 순수성 import 검사 |
barbelicRepository.ts | 5,325줄 | 4,122줄 (−1,203) |
| 임시 어댑터 catalog/authProfile 멤버 31개 | 옛 파일 본문 참조 | factory 결과 재수출(본문 0) |
G03 port 결과 unknown (카탈로그 조회·쓰기, 프로필, 온보딩) | 4 port 전부 | 다른 영역 멤버(검색 신호 1·아이디/성별 3)만 unknown |
새 파일 any 토큰 | — | 0 (boundary gate 396→398 파일 통과) |
| 기존 catalog/profile 테스트 12파일 | — | 무수정 통과 |
npm run check | — | 테스트 2,932 중 2,909 통과·0 실패·23 skip(Phase 3, main 2de59d69 리베이스 뒤; bf16e910 재리베이스 뒤 재실행 결과는 PR #1351 본문), 타입·경계·lint·manifest 초록 |
npm run ci:local | — | full, 세 번에 나눠 실행(포트 4173 을 다른 세션의 미리보기 서버가 쓰고 있어 브라우저 묶음을 뒤로): ① verify(check:policy-snapshots → legal-documents → static → unused → build → test) 통과 ② db reset(마이그레이션 전체 적용)·schema.sql 스냅샷 --check·pgTAP 109파일/1900 assert·동시 저장 세대·영수증·e2e-local 11/11·e2e-empty 7/7·e2e-cardio 6/6 통과(7분 10초) ③ e2e-browser 38/38·e2e-viewport 14/14 통과(10분 22초) |
| 원격 CI | — | run 34117180292 성공(범위 판정 verify 레인: scope·static-checks·unit-tests×2·verify 성공, migration-smoke·browser-journeys·viewport-matrix 는 판정상 생략 — 로컬 ci:local full 이 같은 묶음을 실측). 자동 랜딩 큐 run 34117522838 "Merge and confirm staging" 성공, 머지 530bb4cf, 스테이징 deploy run 34117555461 성공 |
| DB·서버 함수·마이그레이션·UI 표현 | — | 변경 없음 |
미검증: 실기기·브라우저에서 사람 눈으로 보는 표현은 이 트랙이 바꾸지 않았다(표현 변경 0). Production 반영은 v0.18.0 릴리스 게이트에서.
6. 이번 개선으로 향상된 것
후속 담당(A09·A12·A13)이 종목 ID·프로필 값을 타입 있는 경계로 받는다
BarbelicApi.listOwnCustomExercises() 는 readonly CatalogExerciseRecord[], loadOnboardingState() 는 camelCase OnboardingStateDto 를 돌려준다. 파사드 함수가 반환 모양을 어기면 servicePorts.ts 의 satisfies 와 계약 fixture 가 컴파일에서 잡는다.
같은 검증이 한 곳에만 있다
체중 20~400·골격근 5~120·체지방률 1~80, 무게 배수 1·2, 체중 반영 계수 0~2 같은 값 규칙과 그 원인 코드가 codec 파일 한 곳에 있고, 네트워크 없이 단독 테스트된다. 화면·컨트롤러에 검증 복사본이 없다.
null 과 누락과 0 을 구분한다
optionalCurrentNumber 는 null·undefined 만 "없음" 으로 보고 0 은 값으로 넘긴다. 체중 0 은 없음이 되지 않고 범위 검사(20~400)에서 거부된다 — 행동 검사가 이 구분을 고정한다.
옛 파일이 1,200줄 줄고 세 층의 import 경계가 검사로 지켜진다
domainPortWiring 이 factory 의 런타임 import 허용 목록과 codec 순수성(전송·옛 Repository·다른 도메인 구현 금지)을 검사한다.
남은 것
- 옛 파일에 남은 운동 기록·인입·소셜·그룹·통계·관리자 구현(A02·A04·A05·A06)과 임시 어댑터 파일 자체의 제거(A01/A16),
callLegacyReadRpc·callMutationRpc주입 제거(typed transport 로 전환). - 프로필 행·workspace 의 camelCase DTO 전환과 화면 소비자 교체(A09/A12/A13) —
profileDto.ts의 열린 레코드 타입을 그 자리에서 좁힌다. barbelicShared.exerciseByName은 정의만 있고 src 어디서도 호출하지 않는다 — A14(검색·별칭)가 정리할 때 제거 후보.types/supabase.tsRPC 인자 map 에 없는 관리자 표기 RPC 2개 등재(G03).- 총괄 A03 카드 갱신(HQ): 실행 이슈 상태
Planned→ PR 번호·SHA·이 기록 링크. 갱신안은 이슈 댓글로.