347 lines
33 KiB
Markdown
347 lines
33 KiB
Markdown
---
|
|
title: branch / feature-frontend-cache-tier-cross-tab-invalidation-contract
|
|
source_type: branch-note
|
|
status: raw
|
|
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-029
|
|
kind: project-work-item
|
|
project: ca-skeleton-frontend-operational-contract
|
|
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-029
|
|
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001@2, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SERVER-STATE-001@1]
|
|
refines: []
|
|
overrides: []
|
|
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-010, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-011]
|
|
imports: [FE-GATE-028@1, FE-OC-012@1, FE-OC-013@1, FE-OC-023@1]
|
|
delegates: [DELEG-FE-009@1]
|
|
accepts_delegations: []
|
|
contract_packet: 1
|
|
branch: feature-frontend-cache-tier-cross-tab-invalidation-contract
|
|
parent_branch:
|
|
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
|
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
|
|
tags: [branch, ca-skeleton-frontend, cache, server-state, cross-tab]
|
|
created: 2026-07-28
|
|
target_merge:
|
|
status_label: in-progress
|
|
---
|
|
|
|
# branch: feature-frontend-cache-tier-cross-tab-invalidation-contract
|
|
|
|
<!-- section-id: branch-parent -->
|
|
## 부모 (필수)
|
|
|
|
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
|
|
|
형제 branch (같은 부모, 이번 확장에서 함께 생성):
|
|
|
|
- [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]]
|
|
- [[raw/branch-notes/feature-frontend-large-object-transfer-contract]]
|
|
- [[raw/branch-notes/feature-frontend-multi-protocol-api-transport-contract]]
|
|
- [[raw/branch-notes/feature-frontend-realtime-subscription-lifecycle-contract]]
|
|
- [[raw/branch-notes/feature-frontend-background-execution-worker-contract]]
|
|
|
|
<!-- section-id: branch-contract-packet -->
|
|
## 브랜치 계약 패킷
|
|
|
|
- **생성 시 프로젝트 개정**: `2`
|
|
- **패킷 스키마**: `contract_packet: 1`
|
|
- **완료 조건**: version 파티션·탭 간 무효화·채널 부재 시 탭 내 무효화 fixture가 통과한다
|
|
|
|
<!-- section-id: inherited-project-decisions -->
|
|
### 상속한 프로젝트 결정
|
|
|
|
| Decision Ref | Project Summary | Branch Application | Source |
|
|
|---|---|---|---|
|
|
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001@1` | 신규 runtime capability 6종은 FE-REG-CAPABILITY flag로 default OFF이며 활성화는 owner·gate·runbook을 동반한다 | `CAP_FE_CACHE_PERSISTENCE` 를 이 branch 가 소유하고 default OFF 로 유지한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
|
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001@2` | 탭 간 무효화는 BroadcastChannel만 쓰고 별도 fallback 경로를 두지 않으며 leader election 없이 무효화 key만 전파한다 | `CrossTabSyncPort` adapter 의 transport 선택과 메시지 봉투에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
|
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SERVER-STATE-001@1` | application-owned QueryCachePort와 TanStack Query adapter를 사용하고 client store에 server state를 복제하지 않는다 | 영속 tier 를 추가해도 `QueryCachePort` 를 우회하지 않는다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
|
|
|
<!-- section-id: branch-local-decisions -->
|
|
### 브랜치 지역 결정
|
|
|
|
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
|
|---|---|---|---|---|
|
|
| D1 | 영속 캐시 파티션 키는 `releaseId`·`configSchemaVersion`·`apiContractVersion` 세 값을 모두 포함하고 하나라도 불일치하면 복원하지 않고 폐기한다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SERVER-STATE-001@1` | `raw/official-docs/tanstack-query-persistence-hydration-official.md#C1`, `#C2` (폐기 동작). **tuple 구성은 project-local** | `proposed` |
|
|
| D2 | 탭 간 메시지는 무효화 key 만 싣고 값을 싣지 않는다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001@2` | `raw/official-docs/mdn-broadcastchannel-storage-event.md#C5` (structured clone 이라 값 전송은 *가능* — 금지는 우리 선택) | `proposed` |
|
|
| D3 | `CachePersistencePort` 는 `BlobStorePort` 를 재사용하지 않고 자체 백엔드를 가진다 | **`UNSUPPORTED_DECISION`** — 조사 후에도 근거 없음. 오히려 `raw/official-docs/mdn-storage-quotas-eviction-persistence.md#C1`·`#C2` 는 백엔드를 나눠도 **quota·eviction 은 origin 단위로 함께** 움직인다고 말하므로 "quota 격리" 를 이 결정의 근거로 쓸 수 없다. trade-off: 그럼에도 분리를 택한 이유는 `FE-OC-002` 의 port 경계다 — 캐시 스냅샷과 전송 버퍼는 수명·폐기 규칙·소유 branch 가 전부 다르고, 하나의 port 로 묶으면 한쪽 폐기 규칙이 다른 쪽에 샌다. 비용은 같은 origin 에 IndexedDB 사용처가 둘로 늘어나는 것이며 이는 quota 경쟁으로 나타난다 | `proposed` |
|
|
| D4 | 로컬 무효화는 채널을 거치지 않고 직접 수행한다. adapter 는 컨텍스트당 `BroadcastChannel` 객체를 **정확히 1개** 유지한다 | `local` | `raw/official-docs/mdn-broadcastchannel-storage-event.md#C2` (보낸 **객체**만 제외) | `proposed` |
|
|
| D5 | 채널을 만들 수 없으면 대체 경로를 만들지 않고 탭 내 무효화만 수행한다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001@2` | `raw/official-docs/mdn-broadcastchannel-storage-event.md#C4` (2022-03 부터 모든 주요 브라우저에서 동작), `#C10` (대체 후보였던 `sessionStorage` 경로는 다른 탭에 도달하지 않음) | `proposed` |
|
|
| D6 | 복원은 bootstrap 에서 await 하여 **첫 렌더 이전에** 끝낸다. 복원 실패·불일치는 부팅을 막지 않고 메모리 캐시로 진행한다 | `local` | `raw/official-docs/tanstack-query-persistence-hydration-official.md#C5` (복원 중 렌더는 mount·fetch 와 경합) | `proposed` |
|
|
| D7 | 영속 대상은 **성공한 query** 중 `FE-REG-QUERY.persistenceTier` 가 `memory` 가 아닌 행으로 한정하고, 직렬화는 adapter 책임이다 | `local` | `raw/official-docs/tanstack-query-persistence-hydration-official.md#C6` (기본이 성공 query 만), `#C7` (직렬화는 소비자 책임) | `proposed` |
|
|
|
|
**deferred (이번 회차 조사 범위 밖)**: D8 — 영속 write 의 최소 간격과 병합 정책. `tanstack-query-persistence-hydration-official.md#C4` 는 **번들 persister** 가 1초 throttle 을 쓴다고만 말하므로 custom adapter 값의 근거가 아니다. 적정 간격은 스냅샷 크기 실측 후 정한다.
|
|
|
|
<!-- section-id: declared-overrides -->
|
|
### 선언한 예외
|
|
|
|
해당 없음.
|
|
|
|
<!-- GENERATED: artifact-imports:start -->
|
|
### 가져온 artifact 계약
|
|
|
|
| Artifact Ref | Owner | Producer | Schema Ref |
|
|
|---|---|---|---|
|
|
<!-- GENERATED: artifact-imports:end -->
|
|
|
|
<!-- GENERATED: project-contract-imports:start -->
|
|
## 가져온 프로젝트 계약
|
|
|
|
| Ref | Owner | 요약 | Branch 적용 |
|
|
|---|---|---|---|
|
|
| `FE-GATE-028@1` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | version 파티션·탭 간 무효화 fixture 가 실패하면 merge 를 MUST 차단 | 이 branch 가 owner 로서 fixture 와 report 를 산출 |
|
|
| `FE-OC-012@1` | [[raw/branch-notes/feature-server-state-caching-contract]] | query key와 invalidation은 registry factory만 MUST 사용 | 탭 간 전파도 factory key 만 사용 |
|
|
| `FE-OC-013@1` | [[raw/branch-notes/feature-frontend-storage-registry-contract]] | storage key는 namespace·version·classification을 MUST 가지며 token/secret 저장을 금지 | `QUERY_CACHE_SNAPSHOT` 행을 소비 |
|
|
| `FE-OC-023@1` | [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] | API/config/storage/release schema의 breaking change는 migration 또는 version bump 없이 배포하면 안 됨 | 파티션 키 불일치 시 폐기 규칙이 migration 대체 |
|
|
<!-- GENERATED: project-contract-imports:end -->
|
|
|
|
<!-- GENERATED: received-delegations:start -->
|
|
### 수신한 위임
|
|
|
|
없음. 이 branch 는 `DELEG-FE-009` 의 delegator 다.
|
|
|
|
<!-- GENERATED: received-delegations:end -->
|
|
|
|
<!-- GENERATED: flow:start -->
|
|
### 가져온 흐름 단계
|
|
|
|
| Stage Ref | Order | Owner | Input | Action | Output |
|
|
|---|---:|---|---|---|---|
|
|
<!-- GENERATED: flow:end -->
|
|
|
|
<!-- section-id: branch-goal -->
|
|
## 목표
|
|
|
|
`CachePersistencePort` 와 `CrossTabSyncPort` 를 정의하고, memory↔session↔local↔IndexedDB 캐시 계층과 탭 간 무효화를 고정한다. 이 계약이 없으면 탭 A 의 mutation 이 탭 B 의 캐시를 무효화하지 않아 두 탭이 서로 다른 사실을 보여주고, release 를 넘어 살아남은 영속 캐시가 새 스키마로 파싱되어 렌더 트리 깊은 곳에서 터진다.
|
|
|
|
- 이슈:
|
|
- PR:
|
|
|
|
<!-- section-id: branch-scope -->
|
|
## 범위
|
|
|
|
### 포함 범위
|
|
|
|
- `CachePersistencePort` — 캐시 스냅샷 직렬화·영속·복원과 복원 거부
|
|
- `CrossTabSyncPort` — BroadcastChannel 전송, 채널 부재 시 탭 내 무효화만
|
|
- `persistenceTier`·`crossTabScope` 규칙(`FE-REG-QUERY` 확장) 소비
|
|
- release·config·API version 파티션과 불일치 시 폐기
|
|
- `CAP_FE_CACHE_PERSISTENCE` capability 행 소유
|
|
|
|
### 제외 범위
|
|
|
|
- **use case, domain model, business rule** — port 와 adapter 계약까지만 정의한다
|
|
- `QueryCachePort` 의 **정책**(stale time·gc·refetch·invalidation 매핑) — [[raw/branch-notes/feature-server-state-caching-contract]] 소유
|
|
- physical storage key·namespace·classification·quota fallback — `DELEG-FE-009` 로 [[raw/branch-notes/feature-frontend-storage-registry-contract]] 에 위임
|
|
- offline-first 동기화 충돌 해결(CRDT·last-write-wins 등)
|
|
- leader election 기반 단일 리더 동기화 — `FE-D028` 이 명시적으로 배제
|
|
|
|
## 근거 (필수, 최소 1개+)
|
|
|
|
| Source | 정당화하는 결정 |
|
|
|---|---|
|
|
| `[[docs/superpowers/specs/2026-07-28-ca-skeleton-frontend-runtime-adapter-features-design]]` §5.1·§6.2 | port 분해와 `FE-REG-QUERY` 확장 |
|
|
| [[raw/official-docs/tanstack-query-server-state-official]] | `QueryCachePort` 정책의 상위 근거 (persistence 는 이 문서가 다루지 않음) |
|
|
| [[raw/official-docs/tanstack-query-persistence-hydration-official]] | D1 불일치 캐시 전량 폐기 · D6 복원/렌더 경합 gating · D7 영속 대상 선별과 직렬화 책임 |
|
|
| [[raw/official-docs/mdn-broadcastchannel-storage-event]] | D2 메시지 봉투 제약 · D4 발신자 자기 수신 불가 · D5 fallback 이 `localStorage` 여야 하는 이유 |
|
|
| [[raw/official-docs/mdn-storage-quotas-eviction-persistence]] | D2 fallback 크기 예산(`#C6`) · D3 반증(`#C1`·`#C2` — 백엔드를 나눠도 quota 는 분리되지 않음) |
|
|
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.7·§9.2 | query key registry 와 cache defaults |
|
|
|
|
**근거 등급 경계**: 2026-07-28 `/branch-spec` 조사로 채널의 **의미론**(전달 범위·자기 제외·직렬화)과 캐시 영속의 **폐기·복원 동작**은 공식 문서 근거를 확보했다. 아직 근거가 없는 것은 파티션 키를 **세 값으로** 구성한다는 D1 의 tuple 구성이다 — 라이브러리는 buster 가 문자열이라는 것만 말한다(`tanstack-query-persistence-hydration-official#C1`). §검증해야 할 주장에 기록했다. 조사 중 나왔던 "대체 경로의 존재 이유 부족" 은 경로 자체를 삭제해 해소했다(§Audit & Findings).
|
|
|
|
## TODO
|
|
|
|
- [ ] `CachePersistencePort` 인터페이스와 파티션 키 규칙 확정 — 등급: `planned`
|
|
- [ ] `CrossTabSyncPort` 인터페이스와 메시지 봉투 확정 — 등급: `planned`
|
|
- [ ] version 불일치 캐시 폐기 fixture — 등급: `planned`
|
|
- [ ] 탭 A mutation → 탭 B 무효화 integration fixture — 등급: `planned`
|
|
- [ ] BroadcastChannel 부재 시 `CROSS_TAB_CHANNEL_UNAVAILABLE` + 탭 내 무효화만 (D5) — 등급: `planned`
|
|
- [ ] 발신 탭 자기 수신 없음 negative fixture (D4) — 등급: `planned`
|
|
- [ ] 복원 await 가 첫 렌더보다 앞서는지 순서 fixture (D6) — 등급: `planned`
|
|
- [ ] `persistenceTier` 미지정 query 가 영속되지 않음을 확인하는 fixture (D7) — 등급: `planned`
|
|
- [ ] 파티션 폐기·fallback 진입·채널 부재 telemetry event 등록 (관심사 커버리지 should-fix) — 등급: `planned`
|
|
- [ ] `FE-GATE-028` cache tier report 산출 — 등급: `planned`
|
|
|
|
## 진행 중 메모
|
|
|
|
`crossTabScope: same-origin` 이 값이 아니라 key 만 전파하는 이유는 하나다. 값을 전파하면 수신 탭이 자기 권한으로 얻지 않은 데이터를 갖게 된다. 수신 탭은 key 를 받아 자기 `QueryCachePort` 로 refetch 한다.
|
|
|
|
이건 플랫폼 제약이 아니다. `mdn-broadcastchannel-storage-event#C5` 대로 structured clone 이라 값을 그대로 보낼 수 있다 — 안 보내는 건 우리 정책이다.
|
|
|
|
## 결정 사항
|
|
|
|
- 2026-07-28: 파티션 키에 세 version 을 모두 포함 / 이유: 하나만 쓰면 config 만 바뀐 배포에서 stale 캐시가 살아남음 / 검토한 대안: `releaseId` 단독 / 근거: 근거 raw 미수집, project-local 판단 (`FE-Q-011`)
|
|
- 2026-07-28: leader election 미도입 / 이유: 탭 간 무효화에 리더가 필요 없고 리더 선출 자체가 새 실패 모드 / 검토한 대안: Web Locks 기반 리더 / 근거: `FE-D028`
|
|
- 2026-07-28: `storage` event 대체 경로 삭제 / 이유: BroadcastChannel 이 2022-03 부터 모든 주요 브라우저에서 동작하고, 대체가 필요한 환경이 지원 대상에 있다는 근거가 없다. 평소 실행되지 않는 경로는 테스트로도 검증되지 않으면서 저장 예산·자기 수신 제외·연속 동일값 회피 장치를 계속 요구한다 / 검토한 대안: `localStorage` signal key 유지 / 근거: `mdn-broadcastchannel-storage-event#C4`·`#C10`, hub `CROSS-TAB-001` revision 2
|
|
|
|
<!-- section-id: decision-evidence -->
|
|
## 결정-근거 매핑
|
|
|
|
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
|
|---|---|---|---|---|---|
|
|
| D1 | 세 version 파티션 + 불일치 시 전량 폐기 | 항상. migration 이 폐기보다 싼 대용량 캐시가 생기면 재검토 | `tanstack-query-persistence-hydration-official.md#C1`(buster 불일치 → discarded), `#C2`(`removeClient()` 후 즉시 폐기, 부분 복원 경로 없음) | `official-reference`(폐기 동작) + `project-local default`(tuple 구성) | 매 배포마다 캐시가 비워져 첫 로드가 느려진다. 세 값 중 어느 것이 실제로 캐시를 무효화해야 하는지는 미측정 |
|
|
| D2 | key 만 전파 | 항상. 값 전파가 필요한 실시간 협업 요구가 생기면 재검토 | `mdn-broadcastchannel-storage-event.md#C5`(structured clone — 값 전송은 기술적으로 가능), `mdn-storage-quotas-eviction-persistence.md#C6`(localStorage 5 MiB) | `project-local default`(금지 자체) + `official-reference`(제약) | 수신 탭의 refetch 가 몰려 backend 부하가 튄다. dedup 경계가 `QueryCachePort` 와 얇다 |
|
|
| D3 | `CachePersistencePort` 가 자체 백엔드 보유 | 항상. 단, quota 격리를 근거로 쓰면 안 된다 | 없음 — 조사 결과 **반증**만 나왔다(`mdn-storage-quotas-eviction-persistence.md#C1`·`#C2`: origin 단위 전량 eviction) | `UNSUPPORTED_DECISION` | 같은 origin 에 IndexedDB 사용처가 둘. 한쪽이 quota 를 소진하면 **다른 쪽도 함께** 브라우저 eviction 대상이 된다 |
|
|
| D4 | 로컬 무효화는 채널 왕복 없이 직접 수행 | 항상. 발신자 echo 가 없으므로 예외 없음 | `mdn-broadcastchannel-storage-event.md#C2` | `official-reference` | 채널 객체를 실수로 2개 만들면 자기 메시지를 자기가 받는 경로가 생겨 무효화가 2회 실행된다 |
|
|
| D5 | 대체 경로 없음 — 채널 부재 시 탭 내 무효화만 | 항상. 지원 대상 브라우저에 BroadcastChannel 미동작 환경이 실제로 들어오면 재검토 | `mdn-broadcastchannel-storage-event.md#C4`, `#C10` | `official-reference` | 미동작 환경이 나중에 발견되면 그 환경의 사용자는 다중 탭에서 stale 을 본다. 감지는 `CROSS_TAB_CHANNEL_UNAVAILABLE` 계측에 의존한다 |
|
|
| D6 | 복원을 첫 렌더 이전에 await | 항상. 복원 시간이 체감 가능해지면 skeleton UI 로 보완하되 순서는 유지 | `tanstack-query-persistence-hydration-official.md#C5` | `official-reference` | 복원이 느린 저사양 기기에서 첫 페인트가 지연된다 |
|
|
| D7 | 성공 query + `persistenceTier` 로 대상 한정, 직렬화는 adapter | 항상. error/pending 캐시를 살려야 할 요구가 생기면 재검토 | `tanstack-query-persistence-hydration-official.md#C6`, `#C7` | `official-reference` | registry 에 `persistenceTier` 를 빠뜨린 신규 query 는 조용히 영속되지 않는다 — gate 가 잡아야 함 |
|
|
|
|
<!-- section-id: implementation -->
|
|
## 구현 가이드
|
|
|
|
> 2026-07-28 `/branch-spec` 조사(MDN 2건 + TanStack 공식 2페이지)로 in-scope detail 을 채웠다. 근거가 원칙만 지지하고 detail 은 지지하지 않는 cell 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄로 표시했다(CLAUDE.md §15.5 R2). 다른 branch 결정 영역(`QueryCachePort` 정책, storage key·classification)은 남기지 않았다(R3).
|
|
|
|
### 1. `CrossTabSyncPort` — 채널 하나와 발신자 처리
|
|
|
|
> **Trace**: D4(로컬 무효화 직접 수행) ← `mdn-broadcastchannel-storage-event.md#C2` / D5(대체 경로 없음) ← 같은 문서 `#C4`·`#C10` / 상속 `DEC-…-CROSS-TAB-001@2`
|
|
|
|
adapter 는 부팅 시 채널을 한 번 만들고 그 뒤로 바꾸지 않는다. 경로는 두 개뿐이다.
|
|
|
|
| 조건 | 동작 |
|
|
|---|---|
|
|
| `BroadcastChannel` 생성 성공 | 채널 객체를 **컨텍스트당 1개** 만들어 발행·수신 모두에 쓴다 |
|
|
| 생성 실패 | `CROSS_TAB_CHANNEL_UNAVAILABLE` 로 표면화하고 **탭 내 무효화만** 수행한다(§8.2). 대체 전송을 만들지 않는다 |
|
|
|
|
**발신 탭의 무효화는 채널을 거치지 않고 직접 호출**한다. `#C2` 가 보낸 객체를 수신 대상에서 제외하므로, echo 를 기다리는 구현은 발신 탭만 stale 로 남긴다.
|
|
|
|
대체 경로를 두지 않는 이유는 `#C4` 다 — BroadcastChannel 은 2022년 3월부터 모든 주요 브라우저에서 동작하며, 대체가 필요한 환경이 지원 대상에 있다는 근거는 조사에서 나오지 않았다. 유력 후보였던 `storage` event 경로는 `#C10` 대로 `sessionStorage` 에서 다른 탭에 도달하지 않아, 잘못 구현하면 **조용히 아무 일도 하지 않는** 경로가 된다. 평소 실행되지 않아 테스트로도 걸리지 않는 경로를 미리 만들어 두지 않고, 미동작 환경이 실제로 관측되면(`CROSS_TAB_CHANNEL_UNAVAILABLE` 계측) 그때 추가한다.
|
|
|
|
`UNSUPPORTED_IMPL_DECISION` — 부재 판정을 `typeof` 검사 + 생성 시도로 하는 것. `#C4` 는 Baseline 이라고만 말하고 *부재를 어떻게 감지하는지*는 말하지 않는다. trade-off: 생성까지 시도해야 차단 환경의 예외를 잡을 수 있어 객체 1개 비용을 감수했다.
|
|
|
|
### 2. 메시지 봉투
|
|
|
|
> **Trace**: D2(key 만 전파) ← `mdn-broadcastchannel-storage-event.md#C5` / D4 ← `#C2`
|
|
|
|
봉투는 두 필드만 갖는다.
|
|
|
|
- `keys` — 무효화할 query key 배열. **`FE-OC-012` 의 registry factory 가 만든 key 만** 허용한다. 값·응답 본문·사용자 식별자를 넣지 않는다
|
|
- `origin` — 발신 컨텍스트 식별자. 수신부가 자기 발신을 걸러내는 2차 방어(1차는 D4 의 채널 자체 제외)
|
|
|
|
값을 싣지 않는 것은 기술 제약이 아니다. `#C5` 대로 structured clone 이라 객체를 그대로 보낼 수 있다. 금지하는 이유는 수신 탭이 자기 권한으로 얻지 않은 데이터를 갖게 되기 때문이며, 수신 탭은 key 를 받아 자기 `QueryCachePort` 로 다시 가져온다.
|
|
|
|
수신부는 방어적으로 판독한다. 봉투 파싱 실패는 던지지 않고 계측한 뒤 무시한다 — 무효화 신호 하나를 놓치는 것이 UI 를 죽이는 것보다 낫다.
|
|
|
|
`UNSUPPORTED_IMPL_DECISION` — `origin` 필드로 2차 방어를 두는 것. `#C2` 만으로 발신자 제외가 보장되므로 원칙적으로는 불필요하다. trade-off: 채널 객체가 실수로 2개 만들어졌을 때 중복 무효화를 막는 안전망이며, 문자열 하나의 비용으로 D4 위반을 런타임에 흡수한다.
|
|
|
|
### 3. `CachePersistencePort` — 파티션 키와 복원 순서
|
|
|
|
> **Trace**: D1(3-tuple + 폐기) ← `tanstack-query-persistence-hydration-official.md#C1`·`#C2` / D6(렌더 이전 await) ← `#C5` / D7(대상 선별·직렬화) ← `#C6`·`#C7` / 상속 `DEC-…-SERVER-STATE-001@1`
|
|
|
|
파티션 키는 `<releaseId>:<configSchemaVersion>:<apiContractVersion>` 단일 문자열로 만들어 스냅샷과 함께 저장한다. 복원 시 이 문자열이 **정확히 일치하지 않으면** 스냅샷을 폐기하고 빈 캐시로 시작한다. 부분 복원·필드 단위 migration 경로를 두지 않는 것은 `#C2` 가 채택 라이브러리의 기존 동작(`removeClient()` 후 즉시 폐기)임을 확인해 준다.
|
|
|
|
복원 순서는 bootstrap §4.5 의 캐시 복원 단계에서 **await** 한다. `#C5` 가 경고하는 경합(복원 중 query mount → fetch)이 정확히 이 순서를 어겼을 때 나타난다. 복원 실패는 부팅을 막지 않는다 — `CACHE_PERSISTENCE_FAILURE` 로 계측하고 메모리 캐시로 계속한다(§8.2 와 정합).
|
|
|
|
영속 대상은 `FE-REG-QUERY.persistenceTier !== 'memory'` 인 행 **중 성공한 query** 로 한정한다. `#C6` 의 기본 동작과 같은 방향이며, registry 를 상위 필터로 두어 "라이브러리 기본값이 바뀌면 우리 계약도 바뀌는" 결합을 끊는다. 직렬화는 adapter 책임이다(`#C7`).
|
|
|
|
`UNSUPPORTED_IMPL_DECISION` — 파티션 키를 `:` 구분 단일 문자열로 만드는 것. `#C1` 은 buster 가 문자열이라는 것만 말하고 구성·구분자를 말하지 않는다. trade-off: 구조화 객체 대신 문자열을 택해 비교를 동등성 1회로 끝냈다. 비용은 세 값 중 **무엇이** 불일치했는지 폐기 시점에 알 수 없는 것이며, 이는 telemetry 에 세 값을 따로 실어 보완한다.
|
|
|
|
### 4. 이 branch 가 남기지 않는 것 (R3)
|
|
|
|
- query 의 stale time·gc·refetch·invalidation 매핑 → [[raw/branch-notes/feature-server-state-caching-contract]] 소유(`FE-OC-012`)
|
|
- `QUERY_CACHE_SNAPSHOT` 의 physical key·namespace·classification·quota fallback → `DELEG-FE-009` 로 [[raw/branch-notes/feature-frontend-storage-registry-contract]] 에 위임
|
|
- 브라우저 eviction 자체에 대한 방어(`persist()`) → [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] `D7` 소유. 이 branch 의 캐시는 폐기돼도 correctness 를 잃지 않으므로 persist 를 요청하지 않는다
|
|
|
|
<!-- section-id: edge-failure-dependency -->
|
|
## 엣지·실패·의존
|
|
|
|
- **실패·엣지 경로**
|
|
- 직렬화·영속·복원 실패 → `CACHE_PERSISTENCE_FAILURE`, 메모리 캐시만 사용하고 제품 흐름을 막지 않음 (D6)
|
|
- BroadcastChannel 생성 불가 → `CROSS_TAB_CHANNEL_UNAVAILABLE`, 탭 내 무효화만 수행. 대체 전송을 시도하지 않는다 (D5)
|
|
- 파티션 키 불일치 캐시 발견 → 복원하지 않고 폐기. 부분 복원 금지 (D1)
|
|
- 수신 탭이 무효화 key 를 받았으나 해당 query 를 구독하지 않음 → 무시 (에러 아님)
|
|
- 다중 탭이 동시에 같은 key 를 무효화 → 중복 refetch 를 `QueryCachePort` 의 dedup 이 흡수해야 함
|
|
- **발신 탭이 자기 무효화를 놓침** → 채널이 발신자에게 echo 하지 않으므로(`mdn-broadcastchannel-storage-event#C2`) 로컬 무효화는 직접 호출한다 (D4). echo 대기 구현은 이 경로에서 조용히 실패한다
|
|
- **채널 객체를 2개 이상 만든 경우** → `#C2` 의 제외 단위가 *객체*라서 같은 문서의 두 번째 객체가 자기 메시지를 수신해 무효화가 중복 실행된다. adapter 는 컨텍스트당 1개를 강제하고 `origin` 필드로 흡수한다 (D4)
|
|
- **닫힌 채널에 발행** → `InvalidStateError`(`#C6`). unmount 후 발행 경로가 남아 있다는 신호이므로 삼키지 않고 계측한다
|
|
- 봉투 JSON 파싱 실패 → 던지지 않고 계측 후 무시. 신호 1건 손실이 UI 중단보다 낫다
|
|
- **다른 계약 의존**
|
|
- [[raw/branch-notes/feature-server-state-caching-contract]] `D?` — `QueryCachePort` 의 invalidation 매핑과 dedup 책임. 매핑이 바뀌면 전파 대상이 바뀌고, dedup 이 없으면 D2 의 refetch 폭주 위험이 이 branch 로 되돌아온다. 해당 branch 의 Decision ID 는 `/branch-spec` 미실행이라 아직 부여되지 않았다 — 확정 시 이 줄을 D-ID 로 갱신한다
|
|
- [[raw/branch-notes/feature-frontend-storage-registry-contract]] — `QUERY_CACHE_SNAPSHOT` 행의 physical key·classification·TTL·`quotaFallback`. `DELEG-FE-009` 로 위임했고 D1·D7 이 그 행을 소비한다
|
|
- [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] — `releaseId`·`configSchemaVersion`·`apiContractVersion` version tuple 의 정의. D1 의 파티션 키가 이 tuple 에서 나오므로 tuple 구성이 바뀌면 D1 도 바뀐다
|
|
- [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] `D4` — `CAP_FE_CACHE_PERSISTENCE` 를 포함한 capability flag 의 해석 시점. flag 가 OFF 면 이 branch 의 adapter 는 번들에 없어야 한다(`FE-GATE-033`)
|
|
- [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] `D7` — `navigator.storage.persist()` 요청 소유권. 이 branch 는 요청하지 않으며, 캐시가 브라우저 eviction 으로 사라져도 폐기와 같은 경로로 처리한다
|
|
|
|
<!-- section-id: claims-to-verify -->
|
|
## 검증해야 할 주장
|
|
|
|
| Claim | Why uncertain | How to verify | Status |
|
|
|---|---|---|---|
|
|
| version 불일치 캐시가 복원되지 않는다 | 부분 복원이 조용히 성공하기 쉬움 | negative fixture — 이전 version 스냅샷 주입 후 복원 시도가 폐기로 끝나는지 | `planned` |
|
|
| 탭 A mutation 이 탭 B 캐시를 무효화한다 | BroadcastChannel 은 같은 origin 의 다른 탭에서만 동작 | integration test — 두 컨텍스트에서 발행/수신 확인 | `planned` |
|
|
| BroadcastChannel 부재 시 탭 내 무효화만 하고 조용히 넘어가지 않는다 | 부재를 감지 못하면 다중 탭 stale 이 무음으로 남는다 | fixture — BroadcastChannel 을 undefined 로 만든 뒤 `CROSS_TAB_CHANNEL_UNAVAILABLE` 계측과 탭 내 무효화 동작 확인 | `planned` |
|
|
| key 만 전파해도 UI 가 일관된다 | 수신 탭의 refetch 타이밍에 따라 잠깐 어긋날 수 있음 | integration test — 전파 후 두 탭의 최종 상태 일치 | `needs-confirmation` |
|
|
| 다중 탭 동시 무효화가 refetch 폭주를 만들지 않는다 | dedup 이 `QueryCachePort` 책임인지 이 branch 책임인지 경계가 얇음 | 부하 fixture — N개 탭 시뮬레이션 후 실제 요청 수 측정 | `needs-confirmation` |
|
|
| 지원 대상 브라우저에 BroadcastChannel 미동작 환경이 실제로 있는지 | `#C4` 는 2022-03 이후 모든 주요 브라우저에서 동작한다고 말하지만 지원 대상 목록(`FE-Q-007`)이 미확정이다. 있으면 `CROSS-TAB-001@2` 의 "대체 경로 없음" 을 되돌려야 한다 | `FE-Q-007` 확정 후 대조 + `CROSS_TAB_CHANNEL_UNAVAILABLE` 발생률 관측 | `needs-confirmation` |
|
|
| 발신 탭이 자기 무효화를 받지 못한다 | `#C2` 는 명시적이나 구현이 echo 를 기대하기 쉬움 | negative fixture — 탭 A 발행 후 탭 A 의 수신 handler 가 호출되지 않는지 | `planned` |
|
|
| 세 version 중 무엇이 실제로 캐시를 무효화해야 하는지 | 세 값을 모두 넣은 것은 project-local 판단이며 과잉일 수 있음 | 배포 로그 대조 — 각 값이 단독으로 바뀐 배포에서 stale 캐시가 실제 문제를 냈는지 | `needs-confirmation` |
|
|
| 복원 await 가 첫 페인트를 유의미하게 늦추지 않는다 | `#C5` 는 경합만 말하고 비용은 말하지 않음 | 저사양 기기에서 복원 유/무 FCP 비교 | `needs-confirmation` |
|
|
|
|
## Audit & Findings
|
|
|
|
> `/branch-spec` 2026-07-28 조사에서 발견한 **상위 계약과 조사 결과의 불일치**. `FE-D028` 은 hub 소유이므로 이 branch 는 정합 권고만 남기고 자동 수정하지 않는다(hub §3.3).
|
|
|
|
| Finding ID | 대상 | 현재 서술 | 조사 결과 | 권고 | 처리 |
|
|
|---|---|---|---|---|---|
|
|
| `FALLBACK_JUSTIFICATION_GAP` | hub `FE-D028` / `DEC-…-CROSS-TAB-001@1` | "BroadcastChannel 우선에 `storage` event fallback" — fallback 이 필요한 환경을 특정하지 않는다 | `mdn-broadcastchannel-storage-event#C4`: "It's been available across browsers since March 2022" (Baseline Widely available). fallback 은 `localStorage` write·5 MiB 예산·연속 동일값 회피 장치를 추가로 요구한다 | 근거 없는 대체 경로를 유지하지 말고 삭제할 것. 필요해지면 그때 추가 | `resolved` 2026-07-28 — 사용자 결정으로 fallback 삭제. `CROSS-TAB-001` revision 1→2(`behavior-change`), 이 노트의 D5·§구현 가이드·§엣지·TODO 동기화 |
|
|
| `STORAGE_EVENT_SCOPE_UNSPECIFIED` | hub `FE-D028` 및 §4.2 `adapters/cross-tab` | "`storage` event fallback" 이라고만 적어 backend 를 명시하지 않는다 | `mdn-broadcastchannel-storage-event#C10`: `sessionStorage` 의 `storage` event 는 "not other tabs" — 탭 간 신호로 동작하지 않는다 | 서술을 좁히거나, 위 finding 대로 경로 자체를 삭제할 것 | `resolved` 2026-07-28 — fallback 삭제로 해소. 고칠 대상 자체가 사라졌다 |
|
|
| `NO_GROUND_TRUTH` | `/branch-spec` §2 ca-tmpl 대조 | 명령은 `/home/donghyeon/workspace/ca-tmpl` 의 registry·코드와 대조하라고 요구 | `ca-tmpl` 은 Gradle/Java **백엔드 전용**. frontend 구현 repo 는 hub §0.3 대로 미식별 | 이 branch 의 모든 명세는 `planned`. `actually-implemented` 승격은 frontend repo 식별(`FE-Q-001`) 이후 | `open` — `FE-Q-001` 선행 |
|
|
|
|
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
|
|
|
> 2026-07-28 `/branch-spec` §8b. **agent dispatch 없이 수기 판정**했다(세션 제약). governing doc = [[raw/project-notes/ca-skeleton-frontend-operational-contract]]. 기준은 hub §2.2 의 10개 universal acceptance question.
|
|
|
|
| # | 관심사 | 판정 | 근거 |
|
|
|---:|---|---|---|
|
|
| 1 | 문제와 실패 모드가 구체적인가 | covered-here | §목표 — 탭 간 stale 과 release 를 넘은 캐시 파싱 실패. §엣지에 10개 실패 경로 |
|
|
| 2 | 상속한 결정을 실제로 적용했는가 | covered-here | `CROSS-TAB-001@2` → D2·D5, `SERVER-STATE-001` → D1·D7, `CAPABILITY-001` → `CAP_FE_CACHE_PERSISTENCE` 소유 |
|
|
| 3 | project-wide default 와 limit | covered-here | D1(3-tuple 폐기), D4(echo 없음), D5(대체 경로 없음), D6(렌더 이전 await), D7(성공 query 한정) |
|
|
| 4 | 대안을 검토했는가 | covered-here | §결정 사항 — `releaseId` 단독 / Web Locks 리더 / `storage` event 대체 경로(삭제 결정, §Audit). D3 는 반증까지 기록 |
|
|
| 5 | 금지 구현 | covered-here | §구현 가이드 2 — 값·응답 본문·사용자 식별자 전송 금지, factory 밖 key 금지. §구현 가이드 1 — 대체 전송 신설 금지 |
|
|
| 6 | 실패 경로가 error kind 로 매핑되는가 | covered-here | `CACHE_PERSISTENCE_FAILURE`·`CROSS_TAB_CHANNEL_UNAVAILABLE` (hub §8.2 와 정합) |
|
|
| 7 | 관측 가능한가 | should-fix | 파티션 폐기·fallback 진입·채널 부재를 구분할 telemetry event 를 `FE-REG-TELEMETRY` 에 등록하지 않았다. D1 의 "세 값 중 무엇이 불일치했는지" 보완도 여기에 걸린다 |
|
|
| 8 | 위임 경계가 명확한가 | covered-here | `DELEG-FE-009` (storage registry), §구현 가이드 4 의 R3 목록 |
|
|
| 9 | 검증 수단이 있는가 | covered-here | §검증해야 할 주장 11행, `FE-GATE-028` fixture + cache tier report |
|
|
| 10 | 말할 수 있는 범위 | covered-here | 전 항목 `planned` — `NO_GROUND_TRUTH` 로 구현 repo 미식별 |
|
|
|
|
**판정: Covered (missing 0)** · Should-fix 1건(관심사 7 — telemetry event 미등록). Blocking 아님.
|
|
|
|
## 마주친 문제
|
|
|
|
없음.
|
|
|
|
## 묶음 (이 branch에서 파생된 자료)
|
|
|
|
### Sub-branches (세부 작업)
|
|
|
|
아직 없음.
|
|
|
|
### 오류 기록 (이 branch 작업 중 발생)
|
|
|
|
아직 없음.
|
|
|
|
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
|
|
|
아직 없음.
|
|
|
|
### 강의 (이 작업을 위해 학습한 강의)
|
|
|
|
아직 없음.
|
|
|
|
### job-posting tie-ins (이 작업에서 파생된 글감)
|
|
|
|
아직 없음.
|
|
|
|
## 관련 일일 노트
|
|
|
|
- 아직 없음
|
|
|
|
## 완료 후 정리
|
|
|
|
- PR 링크:
|
|
- 리뷰 메모:
|
|
- 머지 결과 / 배포 환경:
|
|
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
|
- `actually-implemented` 항목: 없음
|
|
- `locally-verified` 항목: 없음
|
|
- `prod-verified` 항목: 없음
|
|
- **추출하지 않을 항목**: 현재 전 항목 `planned`
|