Owner 범위 조회 캐시(resource store) 계약 — v0.18.0 A08
- 이슈: #1338 (계획 ID A08, Phase 2 Step 2-3). 직접 선행 A07 수명주기 계약. 계약 타입: G03 §10·§17. 라이브러리 결정: 계층 허용표 §5(새 조회 라이브러리를 들이지 않는다).
- 정본 코드:
src/react/resources/resourceStore.ts(store) ·useResourceSnapshot.ts(React 구독) ·sessionDetailResource.ts(첫 resource 예제) · 계약contracts/ports/runtime.tsResourceCachePort·contracts/resources/{snapshot,owner}.ts. - 검사:
tests/react/resourceStore.test.mjs(13건) ·sessionDetailResource.test.mjs(5건) ·readModelQueryCache.test.mjs(어댑터 8건) · 컴파일 fixturetests/react/contracts/fixtures/ports.fixture.ts(port ⇐ store).
1. 유저 A 가 세션 상세를 여는 한 건
- A 가 홈 카드에서 세션 상세를 연다. controller 는 키
(A, get_session_detail, 세션 id, 계약 버전 1)로 store 에fetch를 부른다. 캐시에 fresh 값이 없으므로 store 가 요청을 하나 만들고, loader 에 요청 취소 신호를 준다. loader 는 그 신호를 transport 까지 전달한다. - 같은 순간 데스크톱의 다른 화면(검색 결과)이 같은 세션을 연다. 서버 요청은 하나다 — 두 번째 소비자는 진행 중 요청에 합류한다.
- 검색 화면이 먼저 닫힌다(소비자 취소). 그 소비자의 대기만
AbortError로 끝나고, 홈 카드는 그대로 결과를 받는다. 두 화면이 모두 닫혔다면 요청 자체가 취소되고 응답은 캐시에 쓰이지 않는다. - 응답이 오기 전에 A 가 로그아웃하고 B 가 로그인한다. A07 runtime 이 owner 전환을 알리면 store 는 A 의 항목을 전부 지우고 A 의 요청을 끊는다. 늦게 온 A 의 응답은 "시작할 때 잡아 둔 owner scope 가 아직 현재인가" 검사에서 버려진다. 같은 A 가 다시 로그인해도 세대(epoch)가 달라 옛 응답은 버린다.
- A 가 세션을 수정해 저장하면 쓰기 답장이
invalidate(키)를 부른다. 옛 값은invalidated로 남아 그릴 수는 있지만, 다음 조회는 반드시 서버를 다시 읽고, 무효화 시점에 진행 중이던 응답은 채택되지 않는다. - 수정 직후의 강제 조회(force)가 앞선 조회를 대체하면, 앞선 조회를 기다리던 쪽은
null을 받는다 — 캐시가 채택한 응답만 돌려준다(fetchAdopted). 옛 개정번호가 쓰기 원천으로 새지 않는다(이슈 #1143 의 규칙을 store 가 지킨다).
2. 키
ResourceKey{owner, kind, id?, range?, asOf?, contractVersion} → resourceKeyString 은 결정적이다. kind 는 읽기 모델 이름(화면 RPC 이름), contractVersion 은 응답 모양이 호환되지 않게 바뀔 때 올린다(옛 키는 자연히 버려진다). owner·kind·id·range·asOf·contractVersion 중 하나라도 다르면 다른 요청이다 — 중복 제거는 같은 키에서만 일어난다.
3. 상태 판정 (5상태 + inflight)
| 스냅샷 | 판정 순서 | 화면 |
|---|---|---|
pending | data 가 없고 오류도 없다(요청 중이거나 아직 요청 전) | 로딩 |
error | 마지막 읽기가 실패했다. data 는 마지막 성공값(있으면) | 오류 + 마지막 값 |
invalidated | 쓰기 답장이 이 키를 무효화했다 | 옛 값을 그리되 fresh 로 오해하지 않는다 |
stale | 부팅 시드(seed)·통계 세대 뒤처짐(generation.stale)·TTL 만료(staleMs) | 그리되 다시 읽는다 |
fresh | 그 밖 | 그대로 그린다 |
inflight 는 상태와 별개로 "이 키의 요청이 진행 중인가" 다 — 로딩 표시를 스냅샷만으로 파생한다(손 동기화 state 없음). 시드와 stale 은 fetch 성공 없이는 fresh 가 되지 않고, 시드는 서버 응답이 이미 있으면 덮지 않는다. staleMs: Infinity 는 "무효화·owner 떠남 전까지 fresh"(쓰기 원천인 상세가 쓴다), 0 은 "언제나 stale".
4. 취소 정책 — 요청은 소비자가 있는 동안만 산다
fetch(key, loader, { signal })의signal은 그 소비자의 대기 취소 신호다. 취소하면 그 소비자만AbortError를 받는다.- 마지막 소비자가 떠나면 store 가 요청 자체를 취소한다(loader 가 받은 신호가 발화). 취소된 요청의 응답은 캐시에 쓰지 않는다.
- 신호 없이 부른
fetch(부팅 프리페치·워밍)는 소비자가 떠나지 않으므로 끝까지 살아 캐시를 채운다. 워밍은 이 방식으로만 한다 — 별도 옵션이 없다. - owner 가 떠나면 그 owner 의 모든 요청을 취소한다(§7).
5. 채택 규칙 — 네 울타리
요청은 시작할 때 (전체 세대, 키 버전, owner 세대, owner scope) 넷을 잡고, 응답이 왔을 때 넷이 그대로일 때만 캐시에 쓴다.
| 사건 | 무엇이 오르나 | 진행 중 응답 | 소비자가 받는 것 |
|---|---|---|---|
fetch(force) | 키 버전 | 앞선 요청의 응답은 채택 안 함 | 앞선 요청의 소비자도 자기 응답은 받는다(fetchAdopted 는 null) |
invalidate(key) | 키 버전 | 채택 안 함 | 자기 응답 |
remove(key) | 키 버전 | 채택 안 함 | 자기 응답 |
invalidateOwner(owner) | owner 세대 + 요청 취소 | 채택 안 함 | AbortError |
| owner runtime 전환(A07) | epoch (+ 위와 같음) | 채택 안 함 | AbortError |
clear() | 전체 세대 | 채택 안 함 | 자기 응답 |
오류는 마지막 성공값을 지우지 않는다 — status: error 에 data 는 그대로, error 는 {code, message}. 다음 성공이 오류를 지운다. 대체된 요청의 오류는 캐시에 남지 않는다.
6. 스냅샷 참조와 구독
관찰 가능한 값(data·status·error·generation·fetchedAt·inflight)이 같으면 get 은 같은 참조를 돌려준다(항목이 없는 키도). 바뀌면 새 참조를 만들고 그 키의 구독자(subscribe(key))와 전체 구독자(subscribeAll)를 깨운다. 깊은 복사는 없다 — data 참조는 채택 시점의 응답 객체 그대로다.
React 는 useResourceSnapshot(store, key, selector?)(키 하나)와 useResourceStoreSelector(store, select, isEqual)(여러 키에서 파생, 같은 값이면 이전 참조 유지)로 읽는다. 둘 다 useSyncExternalStore 다. mutable ref 나 dataTick 은 쓰지 않는다.
7. owner 가 떠날 때
store 는 만들 때 받은 owner port(OwnerScopePort = A07 OwnerRuntime 의 capture/isCurrent/subscribe)를 구독한다. 전환이 오면 invalidateOwner(previous.userId) — 그 owner 의 항목 전부 삭제·요청 취소·owner 세대 증가. 삭제(deleted)와 로그아웃을 구분하지 않는다: 화면 캐시는 어느 경우든 지운다(기기 대기열은 이 캐시의 것이 아니다). dispose() 는 구독을 해제한다(누수 검사: ownerRuntime.inspect().listeners).
7-1. store 의 수명 — 누가 닫는가 (이슈 #1628, 2026-09-15)
store 의 수명은 그것을 만들어 붙잡아 둔 쪽의 수명이다: 셸·화면 인스턴스가 useRef/useMemo 로 한 번 만든 store 는 그 인스턴스가 사는 동안 같은 인스턴스이고, owner 묶음(createOwnerStores) 안의 store 는 묶음이 교체될 때 owner runtime 의 전환 순서 안에서 정리된다. React effect 의 정리 함수에서 dispose() 를 부르지 않는다. React 는 effect 를 "붙였다 → 떼었다 → 다시 붙임" 할 수 있고(개발 모드 StrictMode 가 모든 부품에 한 번 시험한다, 화면 보존·복원 기능도 같은 순서다), 정리 함수가 store 를 영구히 닫으면 다시 붙은 부품이 같은 닫힌 인스턴스를 계속 써서 모든 fetch 가 거절된다 — 2026-09-15 개발 서버에서 로그인 뒤 홈·그룹·피드가 뼈대에서 멈춘 원인이다(17곳).
진행 중 요청의 취소는 정리 함수 없이도 세 경로가 맡는다: owner 가 떠날 때(§7), 화면을 닫는 소비자의 취소 신호(§4), 문서 이탈 신호(pagehide·beforeunload). 셸이 진짜 사라질 때(데스크톱↔모바일 루트 교체, 오류 경계 재시작) 남은 신호 없는 배경 요청은 아무도 참조하지 않는 store 에 들어가 그대로 버려진다. dispose() 를 부르는 곳은 owner runtime 의 전환(owner 묶음의 resetOwnerStores), 그리고 테스트뿐이다. 붙일 때와 뗄 때가 짝을 이루는 정리(운동 화면 flow 의 activate/dispose, 검색의 예약 타이머 취소)는 되돌릴 수 있으므로 이 규칙의 대상이 아니다. QA1 계약 테스트가 src/react 에서 이 패턴을 검사한다.
8. 이 캐시가 아닌 것
- 기기 대기열(outbox)·기기 저장물이 아니다. 읽거나 지우는 API 가 없으므로 갱신 실패가 미전송 쓰기를 지울 길이 없다.
- 표시용 사본(
PendingOverlay)을 합성하지 않는다. 대기열 행을 덧그리는 일은 화면 쪽(pendingWorkoutSavesStore등)이 캐시 밖에서 한다. 캐시의 data 는 서버 응답 사본뿐이다. - authoritative 로컬 DB 가 아니다. 부팅 시드(
bootReadModelCache)는seed로 들어와 언제나 stale 이다.
9. 예제 resource — 완료 세션 상세 (resources/sessionDetailResource.ts)
| 구성 | 내용 |
|---|---|
| 키 | sessionDetailResourceKey(owner, sessionId) → (owner, get_session_detail, id, v1) |
| store | createSessionDetailStore(ownerRuntime) — staleMs: Infinity(무효화 전까지 fresh), 상한 32, owner 구독 |
| loader | loadSessionDetailResource({ api, user, profile, sessionId, signal }) — store 의 요청 신호와 10초 제한을 전송 신호 하나로 묶어 loadSessionDetailData 에 전달. 세션 없음 = session_detail_missing 오류(빈 값을 채택하지 않는다) |
| 읽기 | controller loadSessionDetail = fetchAdopted(store, key, loader, { force }) — 채택된 응답만, 아니면 null. 화면 로딩 라벨(runWithScreenLoading)은 loader 를 감싼다(합류한 소비자는 라벨을 다시 켜지 않는다) |
| 무효화 | invalidateSessionDetail(id) → store.invalidate(key); 인자 없음 → store.clear() |
| 로딩 표시 | sessionDetailLoadingIds = useResourceStoreSelector(store, selectSessionDetailLoadingIds, sameStringList) |
| owner 떠남 | store 가 스스로 — resetRemoteUserData 에 세션 상세 줄이 없다 |
A09 는 이 모양대로 workout/plan/catalog resource 를 만든다: 키 함수 + store 공장 + loader + (필요하면) 선택자. Dto 는 typed 계약(G03 §7)으로 좁힌다.
10. 기존 소비자 어댑터 목록과 제거 담당
| 어댑터 | 목적 | 제거 조건·담당 |
|---|---|---|
services/readModelQueryCache.ts(createReadModelQueryCache) | 달력 월·일·기간 store 의 문자열 키 API 유지. 동작은 resource store. owner 는 키에 없고 store 세트 교체(ownerStores)로 분리 | A10 이 달력 store 를 ResourceKey(owner·range) 로 옮기면 삭제 |
remoteDataController.createPlannedSessionDetailCoordinator(PR 종목 상세·연도·기록 페이지·이력 = 4 인스턴스. 계획 상세는 A09(#1342, 2026-09-08)가 resources/plannedSessionDetailResource.ts 로 옮겼다) | 옛 coordinator(키 버전·세대·LRU·expectedRevision) | A10(PR 종목 4개) 가 resource 로 옮기면 함수째 삭제 |
remoteDataController.runMinimumVersionSingleFlight(연도 활동. 카탈로그는 A09 가 resources/exerciseCatalogResource.ts 로 옮겼다 — 요구 순번 비교는 isExerciseCatalogCurrent, 요청 합류는 store) | 최소 버전 단일 비행 | A10(연도 활동) |
remoteDataController 의 PR 대시보드·볼륨·피드·검색 ref 묶음과 dataTick 7곳 | gymData 투영 갱신 | A10·A11 이 각 화면을 resource 로 옮기며 resetRemoteUserData 목록과 함께 줄인다 |
bootReadModelCache | 부팅 시드 원문 | A10 이 store.seed 로 연결(시드 = 항상 stale 은 store 가 보장) |
11. A09 인계
- 계약:
ResourceCachePort<T>(get/fetch/subscribe/seed/invalidate/invalidateOwner)의 실제 구현은createResourceStore하나다. 새 조회 라이브러리를 들이지 않는다(ADR-5). - 패턴: 키 함수 + store 공장 + loader +
fetchAdopted(쓰기 원천이 되는 상세)·fetch(표시용) +useResourceSnapshot/useResourceStoreSelector. - 무효화: 영수증(receipt)이 도착하면 그 세션 키를
invalidate, 목록형 읽기 모델은 kind 별 store 에서invalidate.invalidateOwner는 부르지 않는다(runtime 구독이 한다). - 픽스처:
tests/support/ownerSwitch.mjs(A07) + 제어 가능한 Promise(deferred) + 주입 시계(clock: { nowMs }) 또는tests/support/clock.mjs의useFixedTimers. - 남은 것:
resetRemoteUserData의 나머지 ref(§10 표)·dataTick7곳·createPlannedSessionDetailCoordinator4 인스턴스는 각 화면 담당이 옮긴다. 이 문서의 §10 표에서 줄을 지우며 진행한다. - A09 이행(2026-09-08, 이슈 #1342): 계획 상세·종목 카탈로그 resource 신설, 완료 상세 열기가 resource 경로 하나로, 공통 신호 도구
resources/requestSignal.ts, store 변경 번호 훅useResourceStoreVersion. 사용 예·무효화 표·잔여 어댑터는 첫 수직 통합 계약.
A11 소비자 이전 현황 (2026-09-10)
A11 계약에 따라 프로필·피드·팔로우·친구 범위·댓글·차단·그룹·알림·인입 관찰이 같은 owner runtime을 소비한다. 실제 한 키 두 소비자=조회 1회, 마지막 소비자 취소, 이전 owner 응답 폐기와 전체 feature 조립의 반복 dispose를 검사한다.
피드 ref/state·prefetch 사본과 그룹 boardCache/loadedMarkMonths는 제거됐다. selector가 만든 gymData/화면 props는 호환 투영으로 남으며 A16/A15의 제거 대상으로 구분한다. §10의 과거 A10·A11 예정 목록을 모두 완료로 간주하지 않는다. 정확한 제거·잔존 목록과 검증 범위를 따른다.