Skip to content

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.ts ResourceCachePort · contracts/resources/{snapshot,owner}.ts.
  • 검사: tests/react/resourceStore.test.mjs(13건) · sessionDetailResource.test.mjs(5건) · readModelQueryCache.test.mjs(어댑터 8건) · 컴파일 fixture tests/react/contracts/fixtures/ports.fixture.ts(port ⇐ store).

1. 유저 A 가 세션 상세를 여는 한 건

  1. A 가 홈 카드에서 세션 상세를 연다. controller 는 키 (A, get_session_detail, 세션 id, 계약 버전 1) 로 store 에 fetch 를 부른다. 캐시에 fresh 값이 없으므로 store 가 요청을 하나 만들고, loader 에 요청 취소 신호를 준다. loader 는 그 신호를 transport 까지 전달한다.
  2. 같은 순간 데스크톱의 다른 화면(검색 결과)이 같은 세션을 연다. 서버 요청은 하나다 — 두 번째 소비자는 진행 중 요청에 합류한다.
  3. 검색 화면이 먼저 닫힌다(소비자 취소). 그 소비자의 대기만 AbortError 로 끝나고, 홈 카드는 그대로 결과를 받는다. 두 화면이 모두 닫혔다면 요청 자체가 취소되고 응답은 캐시에 쓰이지 않는다.
  4. 응답이 오기 전에 A 가 로그아웃하고 B 가 로그인한다. A07 runtime 이 owner 전환을 알리면 store 는 A 의 항목을 전부 지우고 A 의 요청을 끊는다. 늦게 온 A 의 응답은 "시작할 때 잡아 둔 owner scope 가 아직 현재인가" 검사에서 버려진다. 같은 A 가 다시 로그인해도 세대(epoch)가 달라 옛 응답은 버린다.
  5. A 가 세션을 수정해 저장하면 쓰기 답장이 invalidate(키) 를 부른다. 옛 값은 invalidated 로 남아 그릴 수는 있지만, 다음 조회는 반드시 서버를 다시 읽고, 무효화 시점에 진행 중이던 응답은 채택되지 않는다.
  6. 수정 직후의 강제 조회(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)

스냅샷판정 순서화면
pendingdata 가 없고 오류도 없다(요청 중이거나 아직 요청 전)로딩
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: errordata 는 그대로, 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 OwnerRuntimecapture/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)
storecreateSessionDetailStore(ownerRuntime)staleMs: Infinity(무효화 전까지 fresh), 상한 32, owner 구독
loaderloadSessionDetailResource({ 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.mjsuseFixedTimers.
  • 남은 것: resetRemoteUserData 의 나머지 ref(§10 표)·dataTick 7곳·createPlannedSessionDetailCoordinator 4 인스턴스는 각 화면 담당이 옮긴다. 이 문서의 §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 예정 목록을 모두 완료로 간주하지 않는다. 정확한 제거·잔존 목록과 검증 범위를 따른다.