Owner 범위 조회 캐시 primitive — 명시적 키·소비자/요청 취소 분리·owner 떠남 자동 폐기·5상태 불변 스냅샷·세션 상세 첫 적용 — v0.18.0 A08 (2026-09-08)
- 기간: 2026-09-08 (세션 1개
8ea7cc80-edf5-4324-a427-7f793c63448e. 오너 지시 "1338 작업 진행해줘 나한테 물어보지 말고 phase끝까지 완주하고, 아직 선행작업 안끝났으면 기다렸다가 진행해" — 직접 선행 A070775e6b2반영 확인 뒤 착수). 계획 ID A08 / Phase 2 Step 2-3. 총괄 A08로 돌아가기. - 랜딩: PR #1372 → main
4fb2d26f(2026-09-09 02:1x KST squash, CI verify 초록) · staging Deploy run 34144772166 성공(resolve·database·functions·frontend·smoke, release tag 는 Production 전용이라 skip) · 이 기록의 마무리는 docs PR. Phase 1~4 커밋409ac3bb·5931af0c·382d4a94·33c499b5·cc0c04d1·f3c36276(origin/mainf77032ee(S04 #1369 뒤) 위로 리베이스한 해시; 리베이스 전ce353944·34f65e0f·bacfa10a·8faaec0f·dfe0f0f3). 마이그레이션·엣지 함수 없음, 랜딩 큐 불필요(일반 절차). 앱 controller·서비스 변경이라 main 머지 = staging 배포, Production 은 v0.18.0 릴리스. - 설계서: 이슈 #1338 분석·계획 댓글(유저 이야기·원인·해결 방안 표·정책·Phase 4개·예상 효과·개선사항 표).
- 정본: Owner 범위 조회 캐시 계약 ·
src/react/resources/{resourceStore,useResourceSnapshot,sessionDetailResource,index}.ts· 계약contracts/ports/runtime.ts(ResourceCachePort)·contracts/resources/{snapshot,owner}.ts· 어댑터services/readModelQueryCache.ts(제거 담당 A10). - 도구: 없음(레포 안 스크립트 변경 없음). controller·테스트 편집 스크립트는 세션 scratchpad(레포 밖).
- 게이트: 새 테스트 2파일 18건(
tests/react/resourceStore13 ·sessionDetailResource5) + 컴파일 fixture(ports.fixture.tsport ⇐ store) · 기존readModelQueryCache8건·calendarReadModelStore무수정 통과 ·npm run check(Phase 마다) ·npm run test:persistence-browser11건(Phase 3) ·npm run ci:local --full(Phase 4, 검증 줄은 §5 표와 PR 본문) · 명부 신고 2건(pending-changes.json: backgroundTokens·workoutFlowDraftSafety 이어 적기) · 경계 검사 2곳·계층 허용표·G05 장부에src/react/resources/**등재. - 버그리포트: 없음(오너 보고 결함이 아니라 계획된 구조 개선).
- 계약: 새 문서
docs/architecture/owner-scoped-resource-cache.md§1~§11. G03 계약 §10(inflight)·§17(port 멤버subscribe·seed, 신호 두 가지)·§21(A08 완료). A07 계약 §8 에 A08 반영.contracts/resources/owner.ts에OwnerScopePort.
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 조사·분석·계획(이슈 댓글) | ✅ |
| Phase 1 | resource store primitive + 행동 테스트 13건 | ✅ 409ac3bb(구 ce353944) |
| Phase 2 | 문자열 키 캐시를 store 위 어댑터로, src/react/resources/** 경계·허용표·장부 등재 | ✅ 5931af0c(구 34f65e0f) |
| Phase 3 | 첫 적용 경로 = 완료 세션 상세(ref 5개·loading 손 동기화 제거) | ✅ 382d4a94(구 bacfa10a) |
| Phase 4 | 계약 문서·작업 기록·ci:local·PR·CI·머지·staging 확인 | ✅ 33c499b5·cc0c04d1·f3c36276 → 머지 4fb2d26f · staging run 34144772166 성공 |
1. 배경
v0.18.0 Phase 2 "안전한 저장 경로와 첫 수직 통합" 의 세 번째 스텝이다. A07 이 owner 세대(epoch)·취소 신호·자원 등록을 runtime 하나로 모아 "이 응답을 지금 화면에 적용해도 되는가" 의 기준값을 만들었고, G03 이 ResourceSnapshot(5상태)·ResourceCachePort 라는 이름을 정해 두었다. A08 은 그 이름에 실제 구현을 붙이고, feature 마다 복제하던 loaded flag·promise·abort·revision 관리를 primitive 하나로 통일하는 자리다. 후속 A09(workout/plan/catalog resource)가 이 위에서 첫 수직 통합을 만든다.
2. 문제 제기
캐시가 owner 를 몰라 호출자가 결과마다 손으로 검사했다
유저 A 가 세션 상세를 여는 중에 로그아웃하고 B 가 로그인하면 늦은 응답을 버려야 한다. 캐시는 문자열 키 저장소일 뿐이라 loadSessionDetail 등 호출 함수가 시작 시점 scope 를 잡고 결과마다 isCurrentAuthScope 로 검사했고, owner 가 바뀌면 resetRemoteUserData 가 ref 약 50개를 목록으로 비웠다(A07 §8 이 넘긴 지점). 새 화면이 목록에 줄을 빠뜨리면 B 화면에 A 데이터가 남는 구조였다.
첫 소비자의 취소가 요청 자체를 끊어 합류한 소비자가 깨졌다
readModelQueryCache.fetch(key, loader, { signal }) 은 첫 소비자의 취소 신호를 요청의 신호로 썼다. 같은 달을 두 곳이 동시에 요청하고 첫 곳이 닫히면 서버 요청이 끊기고 두 번째 소비자도 실패했다. 세션 상세는 반대로 소비자 취소가 아예 없었다(owner 신호만).
같은 규칙이 캐시 4벌에 복제돼 있었다
"키 버전 울타리·세대·비행 중 합류·LRU" 가 services/readModelQueryCache.ts(달력 월·일·기간), createPlannedSessionDetailCoordinator(계획 상세 + PR 종목 상세 4개 = 5 인스턴스), 세션 상세 손 캐시(ref 5개 + 상한 32 + 세대 번호), runMinimumVersionSingleFlight(카탈로그·연도 활동)에 각각 있었고 조금씩 달랐다(총괄 F09·F11).
캐시 변화를 React 가 모른다
구독이 없어 setDataTick(x + 1)(7곳)·setSessionDetailLoadingIds 같은 별도 state 를 손으로 올려 화면을 깨웠다. 스냅샷 참조가 안정적이지 않아 소비자가 매번 다시 계산했다.
상태 5종이 이름만 있었다
구현은 {data, stale, updatedAt} 두 상태라 "오류가 났지만 마지막 성공값은 있다"·"부팅 시드라 항상 stale"·"통계 세대가 뒤처졌다" 를 구분할 자리가 없었다.
3. 해결 방안
원칙 (오너 결정)
이번 트랙에 오너 결정은 없다. 전제: "물어보지 말고 phase 끝까지 완주" 를 착수 승인으로 봤고(이슈 범위는 총괄·HQ 승인 완료), 취소 정책·owner 떠남 처리·첫 적용 경로는 권장안으로 정해 이슈 댓글에 적었다. 제품 정책 값 변경 0.
접근
| 안 | 내용 | 판정 |
|---|---|---|
| 손 캐시마다 owner 검사·취소 분리를 덧붙임 | 세션 상세·계획 상세·달력에 각각 소비자 집합과 owner 구독 | 기각 — 복제 4벌이 규칙까지 4벌이 된다(전역 지침 §22) |
| 조회 라이브러리(TanStack Query 류) 도입 | 라이브러리가 dedup·stale·구독 제공 | 기각 — ADR-5·계층 허용표 §5 가 금지(owner 전환·통계 세대 대기를 밖에서 다시 만들고 두 구현 공존) |
| owner 범위 resource store 하나 + 얇은 어댑터 (채택) | resources/resourceStore.ts 하나가 명시적 키·owner epoch 채택 울타리·소비자/요청 취소 분리·5상태 불변 스냅샷·구독·LRU 전부. 기존 createReadModelQueryCache 는 이 위의 어댑터(테스트 8건 그대로). 첫 적용 = 세션 상세 | 채택 |
정책(테스트에 고정): ① 요청은 소비자가 있는 동안만 산다 — 한 명의 취소는 그 사람 대기만 끊고, 마지막이 떠나면 요청 취소, 워밍은 신호 없이 호출. ② owner 떠남 = 그 owner 의 항목 삭제·요청 취소·늦은 응답 미채택(같은 사용자 재로그인도 새 세대). ③ 응답은 (전체 세대·키 버전·owner 세대·owner scope) 넷이 그대로일 때만 채택. ④ pending → error → invalidated → stale → fresh 순 판정, 시드·stale 은 fetch 성공 없이 fresh 가 되지 않음. ⑤ 관찰 가능한 값이 같으면 같은 스냅샷 참조. ⑥ 캐시는 서버 응답 사본만 — outbox 를 읽거나 지우는 API 가 없다.
4. 적용한 내용
Phase 1 — resource store primitive (ce353944)
src/react/resources/resourceStore.ts:ResourceCachePort<T>의 실제 구현. 키당 요청 하나에 소비자 수·요청 AbortController·울타리 넷.fetch(key, loader, {force, signal})·get·subscribe·seed·invalidate·invalidateOwner+set/peek/remove/keys/inflightKeys/subscribeAll/clear/dispose/diagnostics. owner port 를 받으면 전환을 구독해invalidateOwner(previous.userId).useResourceSnapshot.ts:useSyncExternalStore구독 훅 둘(키 하나·store 전체 파생, 같은 값이면 이전 참조 유지).- 계약:
contracts/resources/owner.ts에 store 가 소비하는 좁은OwnerScopePort(A07OwnerRuntime이 만족),ResourceSnapshot.inflight,ResourceCachePort에subscribe·seed·ResourceFetchOptions(소비자 신호 ≠ 요청 신호 주석). tests/react/resourceStore.test.mjs13건 — 완료 조건의 여섯 시나리오(2소비자 중 1명 취소·owner 전체 폐기·force 역순·invalidate 중 응답·오류 뒤 마지막 성공값·stale 시드)에 A→A 새 epoch·키 구분·세대/TTL·스냅샷 참조·정리 상한·계측을 더해, 요청 횟수·취소 영향·채택 값·참조만 검사한다.
Phase 2 — 어댑터·등재 (34f65e0f)
services/readModelQueryCache.ts를 store 위 얇은 어댑터로 재구현(문자열 키 →ResourceKey(owner '', kind=scope, id=key, v0)). 옛 함수 이름·동작·진단 모양 그대로, 테스트 8건 무수정 통과. 제거 담당 A10 명시.frontendImportBoundaries·contractsImportBoundaries에src/react/resources/**를 controllers 와 같은 규칙으로, 계층 허용표에 행 추가, G05 장부 규칙 3줄 + shell controller 행 갱신(--render, 미분류 0), 컴파일 fixture(port ⇐createResourceStore).
Phase 3 — 첫 적용 경로: 완료 세션 상세 (bacfa10a)
resources/sessionDetailResource.ts: 키 함수·store 공장(staleMs: Infinity— 상세는 쓰기 원천이라 무효화 전까지 fresh)·loader(store 의 요청 신호 + 10초 제한을 전송 신호 하나로, 세션 없음 =session_detail_missing오류)·로딩 id 선택자.resourceStore.fetchAdopted: 캐시가 채택한 응답만 돌려주고 대체된 옛 응답은 null — 옛 개정번호가 쓰기 원천으로 새지 않는다(이슈 #1143 규칙 유지).remoteDataController: 세션 상세 손 캐시(ref 5개·세대·상한·LRU·loading 손 동기화 state·cleanup 함수·상수 2개) 제거 → store 하나 + owner 구독.resetRemoteUserData의 세션 상세 줄 5개 삭제.sessionDetailLoadingIds는useResourceStoreSelector파생.invalidateSessionDetail(id)→store.invalidate, 인자 없음 →store.clear(). 3,503 → 3,432줄.tests/react/sessionDetailResource.test.mjs5건(키·TTL 없음, 신호 전달·시간 초과(고정 타이머), 세션 없음, force 대체 → null, owner 떠남 → 요청 끊김·로딩 표시 소멸). 소스 앵커 재조준 2파일(backgroundTokens·workoutFlowDraftSafety, 명부 신고).
Phase 4 — 문서·랜딩 (8faaec0f + 기록 갱신)
- 계약 문서
docs/architecture/owner-scoped-resource-cache.md§1~§11(유저 이야기·키·상태 판정·취소 정책·채택 울타리·구독·owner 떠남·캐시가 아닌 것·예제·어댑터 목록·A09 인계). G03 §10·§17·§21, A07 §8 한 줄씩. 사이드바(아키텍처·작업 기록)·README 등록. 이 기록.npm run ci:local --full→ PR → CI 1회 → 머지 → staging 확인 → HQ 갱신안(#1279 댓글).
주요 결정과 그 근거
- 취소 정책 = "요청은 소비자가 있는 동안만 산다": 소비자가 전부 떠난 요청을 계속 살려 캐시를 채우는 것(워밍)은 날짜를 빠르게 넘기는 달력에서 서버 부하만 남긴다. 워밍이 필요하면 신호 없이 부르면 되므로 옵션을 늘리지 않았다. 옛 테스트 "취소된 읽기는 캐시에 들어가지 않는다" 도 그대로 성립한다.
- 대체된 요청의 소비자는 자기 응답을 받는다(store) / 세션 상세는 null(fetchAdopted): store 는 "무엇을 채택하는가" 만 정하고 소비자에게 거짓말하지 않는다. 쓰기 원천이 되는 상세는 채택된 응답만 써야 하므로(#1143) 그 규칙을
fetchAdopted한 함수로 두고 controller 가 쓴다 — 옛 코드의 "superseded → null" 행동이 유지된다. - owner 떠남 = 삭제(invalidated 아님): 떠난 owner 의 값을 stale 로라도 들고 있을 이유가 없다(B 가 볼 일이 없고 메모리만 든다).
invalidate(key)는 반대로 데이터를 남긴다 — 화면이 옛 값을 그리며 다시 읽을 수 있게(A10 세대 UX). - 어댑터의 owner 는 키에 싣지 않는다: 달력 store 는 owner 전환 때 store 세트 자체가 바뀌고
adoptOwner가clear()를 부른다. 어댑터에 owner 를 억지로 넣으면 두 방식이 겹친다. A10 이ResourceKey로 옮기며 어댑터를 지운다. - 첫 적용 경로 = 세션 상세: 손 캐시가 가장 완전하게 복제된 곳(ref 5개)이고, A09 의 workout resource 가 바로 이어받는 자리다. dataTick 은 이 경로에 없어 그대로 두었다(남은 7곳은 gymData 투영의 것 — A10·A11).
작업 중 드러난 것
- ESLint hook gate 가 새
eslint-disable주석을 막는다(npm run check실패로 발견). 훅에서 억제 없이useRef로 키 정체를 유지하도록 바꿨다 — 억제 목록에 줄을 더하지 않는 편이 맞다. - 백그라운드 명령 안의
git stash/pop은 쓰지 말 것(재확인): 브라우저 픽스처의 기준선을 재려고git add -A && git stash && npm run … ; git stash pop을 백그라운드로 돌렸더니 pop 뒤 인덱스가 전부 "삭제" 로 남았다(작업 트리 파일은 무사,git reset으로 복구). 게다가 그 사이 편집을 했다면 pop 이 충돌했을 것이다. 메모리git-stash-in-compound-command-hazard그대로 — 기준선은git worktree를 하나 더 만들어 잰다. - Bash 히어독은 백슬래시를 먹는다(재확인): G05 규칙의
\\.test\\.mjs가\.로 들어가 JSON 파싱이 깨졌다 → Edit 도구로. python 편집 스크립트도 파일로 쓰고 실행했다. - 소스 앵커 테스트 2파일이 세션 상세 손 캐시의 내부 이름(
sessionDetailCacheRef등)을 검사하고 있었다. 새 앵커는 store 생성·구독 파생·fetchAdopted·invalidate와 옛 ref 부재이고, 행동은 새 테스트 18건이 검사한다. - 스냅샷 참조 안정성은 "항목이 없는 키" 에서도 필요하다(
useSyncExternalStore가 같은 값을 기대) — 상한 있는 부재 스냅샷 memo 를 두었다. ci:local범위 판정은 verify-only 였다(바뀐 파일이 fullCiPaths 밖). 세션 상세 조회는 브라우저 여정이 지나는 길이라--full로 강제했다(§20 "서버·e2e 에 닿는 변경이면 --full"). CI 도 같은 정책으로 verify 범위만 돌았다(browser·migration-smoke·viewport skip) — 로컬 full 통과가 그 대체 증거다.- 리베이스 겹침: PR 대기 중 S04(#1369)가 main 에 들어와 문서·장부 5파일이 겹쳤다. 계약 §17 표의 인접 행(내
ResourceCachePort행 + S04 의OutboxPort행)과 G05 생성 절만 충돌 — 생성 절은 아무 쪽이나 받고--render(A07 교훈 그대로).
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| 합류한 소비자가 다른 소비자의 취소에 깨짐 | 실패 → 다른 소비자는 결과 수신, 요청 신호 미발화(행동 테스트) |
| owner 전환 뒤 옛 응답이 새 화면 캐시에 채택 | 호출자 손 검사에 의존 → store 가 보장: A→B·A→A 새 epoch 채택 0건, 요청 취소(행동 테스트) |
| 캐시 규칙 구현 | 4벌 → primitive 1 + 어댑터 2(달력 문자열 키·계획/PR coordinator, 제거 담당 A10·A09 명시) + 최소 버전 단일 비행 1(A09·A10) |
| 세션 상세 조회의 손 상태 | ref 5개·세대·상한·LRU·loading 손 동기화 state 3곳·cleanup 함수·상수 2개 → store 1 + 구독 파생 1 |
resetRemoteUserData 의 세션 상세 줄 | 5 → 0(owner 구독) |
| 상태 표현 | 2상태(data/stale) → 5상태 + inflight, 오류 뒤 마지막 성공값·시드 stale·세대 stale 구분(행동 테스트) |
| 스냅샷 참조 | 매 읽기 새 객체 → 값 불변 시 동일 참조, 변화 시 새 참조 + 구독자 갱신(행동 테스트) |
remoteDataController.ts | 3,503 → 3,432줄. src/react/resources/** 4파일 신설 |
| 동작 변화(의도) | 대체된 옛 요청의 소비자: (달력 어댑터) 종전과 같이 자기 응답 · (세션 상세) 종전과 같이 null. 세션 상세 오류가 대체된 요청에서 나면 종전 null → 이제 소비자에게 전파(캐시에는 남지 않음; 드문 겹침) |
| 전체 게이트 | npm run check 통과(Phase 2·3) · test:persistence-browser 11/11 · tsc·lint·unused·test-manifest 통과 · 검증: ci:local full · verify 통과 (8단계) · db reset(마이그레이션 전체 적용) 통과 · schema.sql 스냅샷 --check 통과 · pgTAP 통과 117파일/2036 assert · 동시 저장 세대·영수증 통과 · e2e-local 통과 11/11 · e2e-empty 통과 7/7 · e2e-cardio 통과 6/6 · e2e-persistence 통과 11/11 · e2e-browser 통과 38/38 · e2e-viewport 통과 14/14 · 16분 35초(리베이스 전 head dfe0f0f3; 리베이스로 바뀐 것은 문서·장부 겹침 5파일뿐) · CI 1회(verify 범위: static-checks·unit-tests·docs-build 초록; browser 여정은 CI 범위 정책상 skip, 로컬 --full 로 대체) |
| 실기기·브라우저 | ci:local --full 의 브라우저 묶음(세션 상세 열기 여정 포함)까지 자동 검증. 시각·감각적 품질은 사람 눈 몫(정보) |
| main/staging | PR #1372 → main 4fb2d26f · staging Deploy run 34144772166 성공(database·functions·frontend·smoke) · Production 은 v0.18.0 릴리스 대기 |
6. 이번 개선으로 향상된 것
같은 데이터를 두 화면이 봐도 한 화면의 이탈이 다른 화면을 깨지 않는다
서버 요청은 하나이고, 요청은 소비자가 있는 동안만 산다. 워밍은 신호 없이 부르는 것으로 정해져 옵션이 늘지 않는다.
계정을 바꾸면 캐시가 스스로 비운다
새 화면 조회가 resetRemoteUserData 목록에 줄을 더할 필요가 없다 — store 를 owner runtime 에 붙이면 떠남·재로그인·늦은 응답을 store 가 처리한다.
다음 화면이 복제할 것이 아니라 가져다 쓸 것이 생겼다
키 함수 + store 공장 + loader + fetchAdopted/fetch + 구독 훅 — A09 가 workout/plan/catalog resource 를 이 모양으로 만든다. 상태 5종·inflight 로 로딩·오류·낡음을 스냅샷만 보고 그린다.
구조적으로 남는 것
ResourceCachePort 의 단일 구현과 컴파일 fixture, 취소 정책·채택 울타리·스냅샷 참조 규칙의 행동 테스트, 어댑터 목록과 제거 담당(계약 문서 §10), A09 인계(§11).
남은 것
- A09(#1342): workout/plan/catalog resource — 계획 상세 coordinator·카탈로그 단일 비행을 resource 로, 영수증 도착 시
invalidate. - A10·A11: 달력 어댑터 제거(ResourceKey range), PR 종목 coordinator 4개·연도 활동·피드·검색 resource 화,
dataTick7곳·resetRemoteUserData나머지 ref 축소,bootReadModelCache→seed. - 릴리스 v0.18.0 뒤
[v0.18.0 반영완료]+ 이슈 닫기.