저장→기기 보관→서버 영수증→무효화→재조회가 새 경계만으로 도는 첫 완성 경로 — Workout·Plan·Catalog resource 이전과 첫 수직 통합 — v0.18.0 A09 (2026-09-08)
- 기간: 2026-09-08 ~ 2026-09-08 (세션 1개 —
9c9c6170-14cf-46e3-a02e-d320167f8024, Phase 1~6). 오너 지시 "1342 진행해줘" → 분석·Phase 계획 게시 → "겹치는 작업 머지됐으니까 확인하고 후속 진행해줘". 계획 ID A09 / Phase 2 스텝 2-5. 직접 선행 A02(af2a828b)·A03(530bb4cf)·A08(4fb2d26f)·S06(1ebfda64)·D02(7db92fb5)와 합류 대상 A12(1514fc52)·A13(d5e12e9d)는 착수 시점에 전부 main 에 머지돼 있었다. 겹치던 PR #1386(계획 대기열 적재 통일·상세 갱신)은 머지(4e50e4af)를 확인한 뒤 그 위에서 시작했다. - 랜딩: PR #1394 → squash 머지
ba3b77bf(2026-09-08, staging Deploy run 34208080762 성공 — resolve·database·functions·frontend·smoke). CI 1회(첫 실행은 crudRoundtrip 정리 훅의 일회용 계정 삭제 실패 → 실패 잡 재실행 초록). (Phase 1b2791984· Phase 2bec2a954· Phase 3abb33c46· Phase 47fd46636· Phase 552a33988·e6040eae· Phase 6 문서·정리) — 마이그레이션·엣지 함수·Vercel 설정 변경 없음. 앱 코드(resource 3종·feature command/query·컨트롤러 연결·답장 처리 표)와 테스트·문서만. - 설계서: 없음 — 착수 분석·해결 방안 비교표·Phase 계획·"예상 효과·개선사항" 표는 #1342 착수 댓글.
- 정본:
docs/contracts/feature-resource-integration.md(신설 — 폴더 규약·resource 3종·쓰기 한 갈래·무효화 표·잔여 어댑터·인계) ·src/react/resources/{plannedSessionDetailResource,exerciseCatalogResource,requestSignal}.ts·src/react/features/workout/queries/{sessionDetailView,receiptInvalidation}.ts·features/workout/editor/adapters/controllerSave.ts·features/plan/{commands/planWriteCommand,queries/planDetailSelectors}.ts·features/catalog/queries/catalogSelectors.ts. - 도구: 없음(기존
check-coverage-inventory --render만). - 게이트: 새 행동 테스트 6파일 43건(
a09VerticalIntegration5 ·plannedSessionDetailResource7 ·exerciseCatalogResource5 ·a09EditorSaveParity5 ·a09ReceiptInvalidation7 ·planRoundTripHarness는 실제 dispatcher 사슬로 재배선 12), 명부 등재 앵커 테스트 8파일 재조준(controllerBoundaries·backgroundTokens·homeFragmentBoundaries·workoutFlowDraftSafety·plannedSessionRevisionContract·appContainer·calendarReadModelMapper,pending-changes.json신고), 비명부 8파일 갱신. 마이그레이션·pgTAP 변경 없음.npm run ci:local -- --full결과는 §5. - 버그리포트: 없음(구조 개선). 부수 수리 1건: 계획 저장 영수증(세대 0)이 통계 세대 폴링에 걸려 매 저장마다 경고를 남기던 것이 표에서 빠져 사라졌다.
- 계약: 신설 feature-resource-integration.md. 갱신 owner-scoped-resource-cache.md §10·§11 · workout-editor.md §9·§13 · plan-editor-model.md §8·§12 · outbox-dispatch-reconcile.md §7 · completed-workout-write-pipeline.md §10 · 층 의존 표(features → resources).
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 겹치는 PR 머지 확인·워크트리 재기준, 착수 검사 5개(현재 코드 red), 진입점·호출자 조사표 | ✅ b2791984 |
| Phase 2 | 읽기 resource 3종(계획 상세·카탈로그 신설, 완료 상세 열기 전환) + feature 선택자 3종, 옛 조정자·단일 비행 제거 | ✅ bec2a954 |
| Phase 3 | 계획 수정·삭제를 준비 → 대기열 → dispatcher 한 갈래로(온라인 우선·catch 폴백·legacyEditorIntentOf 제거), 계획 왕복 하네스를 실제 dispatcher 사슬로 | ✅ abb33c46 |
| Phase 4 | 완료 기록 저장 조립을 편집기 규칙 한 벌(exercisesForSave)로, 동치 검사 | ✅ 7fd46636 |
| Phase 5 | 영수증 → "무엇을 다시 읽을지" 순수 표, binding 은 실행만 | ✅ 52a33988·e6040eae |
| Phase 6 | 죽은 코드 제거·계약 문서·작업 기록·ci:local --full·PR | ✅ PR #1394 |
1. 배경
v0.18.0 Phase 2 는 "안전한 저장 경로와 첫 수직 통합" 이다. 앞선 트랙들이 조각을 만들었다 — A02/A03 이 저장소·codec 을, A08 이 owner 범위 조회 캐시를, S03 이 순수 조립기를, S04/S05/S06 이 대기열 claim·dispatcher·충돌 후속 행을, D02 가 영수증 세대를, A12/A13 이 headless editor 를. A09 는 그 조각을 기존 화면의 얇은 연결로 한 경로에 꿰어 "저장 → 기기 보관 → 서버 영수증 → 무효화 → 재조회" 가 새 경계만으로 도는 첫 완성 경로를 만드는 트랙이다. 예제 모듈만 두고 활성 화면이 옛 저장 경로를 계속 쓰면 완료가 아니다(이슈 본문).
2. 문제 제기
계획 저장·삭제만 "온라인 RPC 먼저, 실패하면 대기열" 두 갈래였다
완료 기록(운동 끝내기·수정·삭제)은 #1199 이후 "기기 대기열에 먼저 적고 뒤에서 보내는" 한 갈래였는데, 계획 저장(persistPlan)·삭제(deleteSession 계획 분기)는 workoutPlanCommands.save/delete 를 먼저 부르고 catch 에서 classifyDirectWorkoutWriteError 로 오프라인·일시 실패를 골라 대기열에 넣었다. G03 §11 이 save_plan·delete_planned_session 을 durable 로 정해 두었지만 코드 갈래가 그 정책을 다시 판정했고, 편집기(A13)가 만든 PlanSaveIntent 를 legacyEditorIntentOf 로 옛 모양으로 되돌려 보냈다.
상세·계획 상세·카탈로그가 세 벌의 캐시 규칙을 갖고 있었다
완료 상세는 A08 store 를 쓰기 원천 확인에만 쓰고, 화면이 여는 주 경로는 completedWorkoutCommands.openDetail 을 거쳐 앱 셸이 덮어쓴 loader 로 3단 간접 호출을 했다. 계획 상세는 createPlannedSessionDetailCoordinator(owner 경계 없음, 저장소 호출에 취소 신호 없음)가, 카탈로그는 runMinimumVersionSingleFlight + exerciseCatalogVersionRef 가 각자 캐시였다.
저장 규칙이 두 벌이었다
컨트롤러 buildLocalWorkoutSaveInput 이 lgCanonicalizeWorkoutUiAliases → lgNormalizeWorkoutExercises → ensureWorkoutChildIds → completedSessionWriteProjection 4단계를 갖고 있었고, A12 편집기의 prepareEditorSave·exercisesForSave 는 호출처 0 이었다.
답장 뒤 재조회가 결과 종류와 무관하게 일괄이었다
pendingSavesFeatureBinding 이 committed 마다 홈 셸 전체·달력·프로필·피드·통계 세대 폴링을 불렀다 — 계획 저장 영수증(세대 0)도 통계 폴링 함수에 넘겨 매번 경고를 남겼고, 생성과 수정이 한 전송에 섞이면 셸을 두 번 다시 읽었다.
3. 해결 방안
원칙 (오너 결정)
이번 트랙에 새 오너 결정은 없다. 전제: ① 새 계획 생성은 온라인 전용 그대로(G03 §11, ADR P1) ② 저장 대기/즉시 정책표·확정 문구 불변 ③ 달력·PR·피드·홈 읽기 모델은 A10 범위라 옮기지 않는다 ④ 겹치는 PR #1386 머지 뒤 리베이스.
접근
| 안 | 내용 | 채택 |
|---|---|---|
| A. 구조 개선 | 읽기 3종을 A08 store 한 패턴으로, 쓰기는 계획까지 "준비 → 대기열 → dispatcher" 한 갈래로, 저장 규칙은 편집기 한 벌, 답장은 순수 표 | 채택 — 같은 종류 문제가 다른 화면에서 재발하지 않고 U02/U03·A10 이 패턴을 복제한다 |
| B. 땜질 | 계획 저장 catch 분기만 정리, 상세 열기는 그대로 | 기각 — 정책 갈래가 남고 A08 캐시가 예제로만 남는다 |
4. 적용한 내용
Phase 1 — 조사표·착수 검사 (b2791984)
tests/react/a09VerticalIntegration.test.mjs 5개(계획 수정·삭제가 온라인 RPC 를 먼저 부른다 / 상세 열기가 resource 를 지나지 않는다 / 계획 상세·카탈로그 resource 가 없다 / 컨트롤러에 4단계가 남아 있다)가 현재 코드에서 전부 red 임을 확인. 진입점·남은 호출자 표는 이슈 Phase 1 댓글.
Phase 2 — 읽기 resource 3종 (bec2a954)
resources/plannedSessionDetailResource.ts: 키(owner, get_planned_session_detail, id, v1),staleMs: Infinity, loader 는 취소 신호·10초 제한을 저장소까지 전달(planRepository.loadPlannedSessionDetailRows에signal추가), 못 찾음·다른 id 는 채택 안 함. "카드의updatedAt과 캐시가 다르면 다시 읽는다" 는isPlannedSessionDetailCurrent하나.resources/exerciseCatalogResource.ts: owner 당 한 항목, 기기 사본은seed(언제나 stale), 요구 순번 판정isExerciseCatalogCurrent. 컨트롤러syncExerciseCatalog는 store 를 보고 부족할 때만 서버에(진행 중이면 합류, 홈 힌트에 못 미치면 한 번 더).- 완료 상세 열기
sessionDetailStore.openCompletedWorkoutDetail→loadSessionDetail(resource) 하나.completedWorkoutCommandsenv 주입 제거. - feature 선택자:
features/plan/queries/planDetailSelectors.planViewModelsOf(카드+채택된 상세 합치기) ·features/catalog/queries/catalogSelectors.exerciseCatalogSyncNeeded·features/workout/queries/sessionDetailView.completedSessionDetailViewOf(친구 세션 읽기 전용). remoteDataController: 계획 상세 조정자 인스턴스·plannedSessionDetailRevision손 동기화 tick(→useResourceStoreVersion구독)·exerciseCatalogVersionRef·카탈로그 단일 비행 제거. owner 리셋에서 상세·카탈로그 줄 삭제(store 가 owner 구독으로 스스로 폐기).- 공통 신호 도구
resources/requestSignal.ts(A08 예제에서 뽑음). 커버리지 장부에features/catalog/**·plan/queries·workout/queries규칙과 catalog binding 행.
Phase 3 — 계획 쓰기 한 갈래 (abb33c46)
features/plan/commands/planWriteCommand.ts:enqueuePlanEdit·enqueuePlanDelete(S03preparePlanEdit/Delete→enqueueport,announced: true). 갈래는intent.capability.workoutWriteController.persistPlan: durable(수정)이면 조립 →queueWorkoutMutation(스토어 적재 → 즉시 전송) → 기기 반영(primePlannedSessionDetail·카드 갱신)·확정 문구. 생성(online)만workoutPlanCommands.save직접(오프라인이면 즉시 실패). 계획 삭제 분기도enqueuePlanDelete.workoutPlanCommands.save가 저장 뜻 세 모양(편집기 뜻·대기열 행의 뜻·새 계획 생성 intent)을 받고,legacyEditorIntentOf삭제.DirectWorkoutOfflineMutationError에create허용.planRoundTripHarness: 컨트롤러 → 조립 → 메모리 대기열 → 실제dispatchPendingWorkoutSaves→ 계획 명령 → 저장소 → RPC 사슬로 RPC 도달·payload·identity 검증(사용자 id 는 uuid).
Phase 4 — 편집기 합류 (7fd46636)
features/workout/editor/adapters/controllerSave.ts:prepareEditorExercises(payload → 편집기 상태 →sessionIssues→exercisesForSave→ 세션 직렬화),mergeDraftSetAliases(모바일 payload 에 없는 실패 세트 별칭을 최신 초안 스냅샷의 같은 세트에서 병합).- 컨트롤러
buildLocalWorkoutSaveInput: 4단계 →prepareEditorExercises.editor_invalid는WorkoutEditorSaveRejected(LG_EDITOR_INPUT_INCOMPLETE) 로 그 자리에서, 종목 id 가 canonical 이 아니면 원문 그대로 조립기(codec)가 거부. 앱 셸이 편집기 ports(카탈로그·1RM 표)를 주입, 없으면 전역 카탈로그. a09EditorSaveParity: 진행 중(실패 세트 별칭·조기 종료 종목)·작성(맨몸·유산소·자유 기록)에서 옛 4단계와 편집기 한 벌의 S03 requestHash 동일. 알려진 차이 2건 명시(§4 "작업 중 드러난 것").
Phase 5 — 답장 → 무효화 표 (52a33988·e6040eae)
features/workout/queries/receiptInvalidation.ts:receiptInvalidationPlanOf(DispatchResult)→ 확정 사본·상세 무효화·목록에서 떼기·서버 최신본 수렴·목록 무효화·셸·달력·피드·통계 세대·안내 수·텔레메트리 대상. 계획(세대 0)은 통계 없음,already_gone은 목록·상세만,unknown은 성공으로 그리지 않고 상세 강제 조회.pendingSavesFeatureBinding:executePlan이 표를 순서대로 실행. 한 전송에 셸·달력 재조회 각 1회.
Phase 6 — 정리·문서·랜딩
- 죽은 코드: 컨트롤러의 항상 null 이던
completedMutationReceipt와 그 재검증 블록,WorkoutWriteEnv.completedWorkoutCommands(미사용) 제거.completedWorkoutCommands.save/delete(제품 호출처 0, 전송 계약 검사만)는 검사가 붙어 있어 남기고 제거 조건을 계약 §7 에 적었다(A15/R01). - 계약 신설 + 5개 문서 갱신(위 "계약"), 작업 기록.
주요 결정과 그 근거
- 갈래는 코드가 아니라 값이 정한다:
intent.capability(A13 union)가 online/durable 을 말하고 컨트롤러는 그 값만 본다 — 정책 재판정이 사라진다. - 판단을 env 밖으로: binding env 함수 13개는 실행 도구로 남기고 "무엇을 언제 부를지" 를 순수 표로 뺐다. 개수를 줄이는 것은 A10/A11 이 읽기 모델을 resource 로 옮길 때 자연히 따라온다.
- 화면 관문은 남긴다:
guard*SavePayload는 화면 UX 관문(즉시 반응), 컨트롤러 판정은 fail-closed 뒷받침. 관문이 곧 편집기가 되는 것은 화면이 편집기 상태를 직접 드는 U02/U03. - 홈 셸 재조회는 표의 열로 남긴다: 홈 읽기 모델은 A10 몫이라 이번에 없애지 않고 표의
shell열로 격리했다.
작업 중 드러난 것
- Phase 1 보고의 "완료 상세 열기가 캐시를 우회한다" 는 절반만 맞았다 — 제품에서는 앱 셸이
openDetail어댑터를 덮어써 resource 를 지났고 직접 호출은 기본 어댑터(테스트 경로)뿐이었다. 3단 간접 호출을 1단으로 줄였다. - 이중 키 게이트가 새 파일의
updatedAt ?? updated_at·catalog_version ?? catalogVersion폴백을 잡아 한 이름으로 확정했다. - 다른 세션의 검사 파일(
legalDocumentBrandCorrection.test.mjs, #1367)이 커버리지 장부 미분류라 게이트가 빨간불이어서 규칙 한 줄(auth/test)을 같이 넣었다. - 저장 조립의 알려진 차이 2건(동치 검사가 명시): ① 자유 기록(note) 행 체중 계수 옛 0 → 편집기 null(A12: 체중 계수는 종목에만, 서버는 둘 다 받음) ② 진행 중 운동에서 종목 완료 + 체크 안 한 값 있는 세트 — 옛 normalize 는 기록으로 쳤고 편집기는 "사용자가 완료한 세트만"(A12 계약). 화면 규칙상 거의 나오지 않는 조합이라 A12 것을 따랐다.
- 직렬화된 모바일 payload 는 입력 중 원문("12.")을 이미 숫자로 바꾼다 — 컨트롤러 쪽
editor_invalid는 직렬화 전 초안(데스크톱 작성기·스냅샷)에만 실효가 있고 모바일은 화면 관문이 먼저 막는다. - 계획 수정의 오프라인 전용 안내("기기에 보관했어요…")는 사라지고 완료 기록과 같은 즉시 확정 문구만 뜬다(정책표 §4 의
immediate규칙 적용, 표 자체 불변). 한 번이라도 못 보낸 행이 올라가면 종전대로 "동기화했어요" 1회. - Phase 별 전체
npm run check에서 소스 문자열 앵커 검사가 매번 빨간불이었다(Phase 2: 6파일, Phase 5: 5파일) — 전부 재조준·신고. 사전 검증 누락은 아니고 앵커 검사의 성격(자리 이동 감지). - 백그라운드 Bash 에서
git stash를 다시 쓰지 않았다(메모리 함정) — 기준선 비교는 브랜치 커밋 상태로.
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| 상세·계획 상세·카탈로그 캐시 구현 | 3벌(store·조정자·단일 비행) → 1벌(store). createPlannedSessionDetailCoordinator 인스턴스 5 → 4(A10 몫) |
| 완료 상세 열기 경로 | 3단 간접 호출(명령 어댑터 → 셸 덮어쓰기 loader → store) → 1단(loadSessionDetail). 열기와 쓰기 원천 확인이 같은 캐시 |
| 계획 저장·삭제 코드 갈래 | 2(온라인 우선·catch 폴백) → 1(준비 → 대기열 → dispatcher). 오프라인 계획 수정 시 오류 팝업 경로 1 → 0 |
| 저장 규칙 구현 | 2벌(편집기·컨트롤러 4단계) → 1벌. 동치 fixture(live·backfill) requestHash 일치 |
| 답장 뒤 재조회 | 결과 종류 무관 일괄(계획도 통계 폴링 + 경고) → 결과 6종 × 행 5종 표. 계획 영수증 통계 폴링 2회 → 0회(검사 pendingPlanReceiptFreshness), 한 전송에 셸 재조회 최대 2회 → 1회 |
| 착수 검사 | 5/5 red → 5/5 green |
npm run check(Phase 5 재실행) | 3,302 중 3,275 통과·27 DB 환경 skip·실패 0(e6040eae) |
npm run ci:local -- --full | 검증: ci:local full(2회) · ① e6040eae: verify 3,275/0 · db reset(마이그레이션 전체 적용) 통과 · schema.sql 스냅샷 --check 통과 · pgTAP 통과 119파일/2059 assert · 동시 저장 세대·영수증 통과 · e2e-local 12/12 · e2e-empty 8/8 · e2e-cardio 6/6 · e2e-persistence 38/38 · e2e-viewport 14/14 · e2e-browser 56/57(CASE-029 실패 — 계획 수정이 대기열 한 갈래가 되며 안내 문구·관측 순서가 바뀐 것, 매니페스트·기대값 갱신) · ② 9e902072: e2e-browser 57/57 · 12분 29초 (첫 실행의 supabase start 는 다른 세션의 오래된 샌드박스 131 컨테이너가 Docker 주소 풀을 다 써 실패 → 12시간 넘은 스택 제거 뒤 재실행) |
| 미검증 | 시각·감각 품질(화면 JSX·CSS 는 바꾸지 않았다). 실기기 확인 없음(전역 §14) |
6. 이번 개선으로 향상된 것
오프라인에서 계획을 고쳐도 오류 없이 즉시 저장된다
유저 A 가 지하 헬스장에서 내일 계획을 고치면 종전에는 서버 오류 경로를 한 번 거친 뒤 대기열로 갔다. 이제 완료 기록과 똑같이 기기에 먼저 적히고 화면은 즉시 바뀌며, 연결되면 dispatcher 가 보낸다(충돌은 S06 후속 행).
다른 계정·늦은 응답이 화면을 바꾸지 못하는 읽기 3종
계획 상세·카탈로그도 owner 범위 캐시에 들어 owner 가 떠나면 요청이 끊기고 항목이 사라진다. 상세를 열어 본 뒤의 수정·삭제가 같은 상세를 다시 읽지 않는다.
저장 규칙이 한 곳에
실패 세트·기록 프로필·완료 판정·저장용 uuid 규칙이 편집기(A12) 하나다. 화면을 옮기는 U02/U03 이 컨트롤러와 다른 결과를 만들 길이 없다.
답장 처리가 표로 읽힌다
어떤 영수증에 무엇을 다시 읽는지가 receiptInvalidation.ts 의 표 하나이고 검사(결과 6종 × 행 5종)가 잠근다. 계획 저장마다 남던 경고가 사라졌다.
구조적으로 남는 것
features/<영역>/{queries,commands} 폴더 규약 · "갈래는 capability 값" 원칙 · 무효화 표 · 잔여 어댑터 표(담당·제거 조건, 계약 §7) · Phase 3 인계(계약 §9).
남은 것
- Phase 2 종료 조건 가운데 "공통 headless editor 합류" 는 저장 경로 쪽만 이행됐다 — 화면이 편집기 상태를 직접 드는 것은 U02/U03(Phase 3). 관문(
guard*SavePayload)은 그때 사라진다. completedWorkoutCommands.save/delete(온라인 직접 쓰기 명령, 제품 호출처 0) 제거는 검사를 dispatcher 포트 기준으로 옮긴 뒤(A15/R01).- 홈 셸 재조회(
reloadRemoteData)·프로필·피드 함수는 표의 열로 남았다 — A10/A11 이 각 읽기 모델을 resource 로 옮기며 지운다. PR 조정자 4개·연도 활동 단일 비행·달력 문자열 키 캐시도 A10. WorkoutWritePort결과 타입 좁히기(A12 인계 문구)는 하지 않았다 — A15.- 새 브라우저 CASE 는 만들지 않았다: UI → outbox → RPC → 영수증 → resource 추적은 기존 CASE-029(IndexedDB
pendingSaves→ RPC → PostgreSQL → 재진입)와 계획 왕복 하네스(실제 dispatcher)가 이미 한 경로씩 덮는다.