37 KiB
VD-13: Client cache scope, persistence와 탭 간 일관성 경계
- 상태: Accepted — staged implementation required
- 결정일: 2026-07-28
- 관련 결정: VD-10, VD-11
- 상세 설계:
docs/architecture/client-cache-and-storage.md - current status ledger:
docs/architecture/browser-data-capability-completion-ledger.md - 재검토: account/tenant switching, query persistence, SSR 또는 offline mutation을 제품 capability로 선택할 때
1. 배경
TanStack Query memory cache, Web Storage, IndexedDB와 BroadcastChannel은 모두 client state에 관여하지만 같은 authority, 수명과 commit point를 갖지 않는다.
- TanStack Query memory cache는 현재 JavaScript runtime의 server-state projection다.
localStorage와sessionStorage는 작은 preference/control record를 위한 동기식 browser storage다.- IndexedDB는 transaction, index와 durable structured record를 제공한다.
- BroadcastChannel과
storageevent는 같은 storage partition 안의 best-effort notification이다. - SSR dehydration과 browser persistence hydration은 서로 다른 source에서 생성된 cache projection을 합치는 별도 protocol이다.
현재 skeleton은 memory QueryClient, 두 개의 등록 Web Storage key와 invalidate-only cross-tab runtime을 production bootstrap에 조립한다. domain-neutral IndexedDB runtime은 source와 native contract test가 있지만 product dataset 없이 bootstrap에서 제외돼 있다. IndexedDB query persister, durable namespace epoch, session/account-scoped QueryClient lifecycle과 SSR hydration은 아직 구현되지 않았다.
이 차이를 숨긴 채 “client cache가 구현됐다”고 표현하면 다음 문제가 생긴다.
- logout 뒤 old account의 cache나 늦은 async result가 새 account 화면에 나타남
- best-effort invalidation event를 authorization 또는 server commit으로 오인함
- 여러 tab의 full cache snapshot이 서로 오래된 record를 다시 살림
- browser persistence가 최신 SSR payload를 덮음
- Web Storage memory fallback 성공과 durable write 성공을 구분하지 못함
- query cache를 offline command repository처럼 사용해 unsynced user data를 eviction으로 잃음
2. 표준 capability 상태
이 결정과 상세 설계는 다음 상태만 사용한다.
| 상태 | 의미 |
|---|---|
COMPOSED |
구현·계약·test가 있고 production bootstrap이 실제 생성·소비한다. |
AVAILABLE_NOT_COMPOSED |
reusable runtime과 test가 있지만 production bootstrap에서 생성하지 않는다. |
DESIGNED_NOT_IMPLEMENTED |
경계와 invariant는 승인됐지만 실행 코드가 없다. |
NOT_SELECTED |
제품 요구·owner·policy가 승인되지 않아 설치 대상이 아니다. |
PLATFORM_LIMITED |
browser/platform이 요구 의미를 cross-browser로 보장하지 못한다. |
AVAILABLE_NOT_COMPOSED와 NOT_SELECTED는 같은 말이 아니다. 전자는 reusable
runtime의 구현 상태고, 후자는 제품 capability 선택 상태다. 하나의 capability에
두 축이 필요하면 “reference runtime”과 “product selection”을 별도 행으로 쓴다.
2.1 현재 상태
| capability | 현재 상태 | 현재 보장 |
|---|---|---|
| TanStack Query memory runtime | COMPOSED |
runtime별 QueryClient, finite inactive GC, retry owner, query AbortSignal |
| registered Web Storage | COMPOSED |
COLOR_SCHEME, CHUNK_RELOAD_GUARD만 strict codec/envelope로 사용 |
| invalidate-only cross-tab runtime | COMPOSED |
versioned topic, BroadcastChannel 우선, localStorage pulse fallback |
| generic IndexedDB repository/maintenance runtime | AVAILABLE_NOT_COMPOSED |
CAS, idempotency, transaction complete, policy binding, bounded lifecycle/migration |
| session/account-scoped QueryClient lifecycle | DESIGNED_NOT_IMPLEMENTED |
없음; 현재 cache epoch는 release ID만 포함 |
| strict query policy/key codec | DESIGNED_NOT_IMPLEMENTED |
현재 object key order canonicalization만 존재 |
| IndexedDB query persistence facade | DESIGNED_NOT_IMPLEMENTED |
persistence는 registry에서 강제로 disabled |
| durable namespace invalidation ledger | DESIGNED_NOT_IMPLEMENTED |
없음 |
| product query persistence | NOT_SELECTED |
persist 대상 query/owner가 없음 |
| SSR dehydration/hydration | NOT_SELECTED |
현재 runtime은 client SPA composition |
| exactly-once cross-tab delivery | PLATFORM_LIMITED |
BroadcastChannel/storage event는 acknowledgement를 제공하지 않음 |
| browser storage non-eviction guarantee | PLATFORM_LIMITED |
persist 요청도 user-agent eviction을 절대 금지하지 않음 |
3. 결정
3.1 하나의 cache/storage abstraction으로 합치지 않는다
다음 경계를 유지한다.
server response
-> feature application result
-> query inbound adapter
-> scope-owned TanStack QueryClient
small approved preference/control value
-> registered Web Storage facade
-> exact localStorage/sessionStorage key
optional reconstructable query projection
-> query persistence facade
-> query-specific stable wire codec
-> governance-bound IndexedDB runtime
committed mutation
-> local namespace invalidation
-> optional durable namespace epoch commit
-> best-effort cross-tab hint
QueryClient, native Storage, IDBDatabase, BroadcastChannel, dehydrated TanStack
types와 physical key/store/index 이름을 application/domain에 노출하지 않는다.
3.2 source of truth와 authority
- 일반 server state와 authorization의 source of truth는 서버다.
- memory cache와 persisted query record는 재구성 가능한 projection이다.
- cache hit, persisted restore와 invalidation event는 authorization proof가 아니다.
- 모든 protected network request는 현재 session credential과 server authorization을 다시 통과한다.
- remote invalidation event는
invalidate만 요청할 수 있다.remove,clear, logout, account deletion과 credential revocation authority를 갖지 않는다. - unsynced command, local-first draft와 user-authored offline data는 query persistence에 저장하지 않는다. feature-specific IndexedDB repository와 sync use case가 소유한다.
4. session/account/release scope
4.1 immutable scope snapshot
composition의 session authority는 다음 의미를 갖는 immutable snapshot을 발급한다. 구현 type과 field name은 이 의미를 보존해야 한다.
type CacheScopeSnapshot = Readonly<{
protocolVersion: 1;
authorityToken: string;
partitionToken: string;
sessionEpoch: string;
accountEpoch: string;
releaseEpoch: string;
generation: number;
}>;
- 모든 token은 registry/session authority가 발급한 충분한 entropy의 opaque identifier다.
- email, account/tenant/user ID, domain ID, access token과 낮은 entropy identifier의 단순 hash를 사용하지 않는다.
generation은 현재 page runtime에서 단조 증가하는 local lifecycle fence다. backend entity revision이나 wire ordering으로 사용하지 않는다.sessionEpoch는 sign-in, re-auth, credential owner 교체 때 바뀐다.accountEpoch는 account/tenant switch, logout, account deletion 때 바뀐다.releaseEpoch는 query-key, mapper, codec 또는 persistence wire compatibility가 깨질 때 바뀐다.- scope object와 nested policy는 construction 때 copy/freeze한다. async operation은 시작 시 exact snapshot과 generation을 캡처한다.
4.2 profile별 scope projection
모든 query가 account token을 key에 넣지는 않는다. registry가 분류에 따라 다음을 고정한다.
| scope | binding |
|---|---|
ORIGIN_SHARED |
release epoch와 origin-shared token |
ACCOUNT_BOUND |
partition token, account epoch, release epoch |
SESSION_BOUND |
partition token, account epoch, session epoch, release epoch |
PUBLIC만ORIGIN_SHARED를 사용할 수 있다.INTERNAL은 제품 authority가 origin-shared public semantics를 증명하지 않는 한ACCOUNT_BOUND이상이다.PERSONAL은ACCOUNT_BOUND이상이고 persistence에는 explicit approval, bounded retention과 logout purge가 필요하다.CONFIDENTIAL은 query persistence가 금지되고 필요한 순간의 memorySESSION_BOUND만 허용한다.- credential은 memory query data, persistence, query key와 invalidation wire 모두에서 금지한다.
4.3 composite cache epoch
cross-tab cacheEpoch는 raw token을 연결한 문자열이 아니라 선택된 scope projection과
protocol major의 opaque compatibility fingerprint다. receiver는 exact equality만
검사하고 원래 account/session/release 의미 값을 wire에서 복원하지 않는다.
현재 release.<releaseId>만 사용하는 값은 transitional implementation이다.
account-dependent query를 production에 설치하기 전에 composite scope fingerprint로
교체한다.
5. QueryClient lifecycle와 late-result fence
5.1 runtime state
scope-owned query runtime은 다음 terminal lifecycle을 갖는다.
CREATING
-> ACTIVE
-> FENCING
-> DISPOSING
-> DISPOSED
ACTIVE만 신규 query/mutation/cache update를 admission한다.- scope transition이 시작되면 먼저
FENCING으로 바꾸고 generation을 올린다. DISPOSING에서 old query를 cancel하고 provider/controller를 detach한 뒤 QueryClient를 clear한다.- old cross-tab channel, persistence writer/connection, timer와 listener를 닫는다.
- exact old Web Storage key/IndexedDB partition purge는 policy와 authority를 통과한 bounded lifecycle operation으로 수행한다.
- 새 scope는 새 QueryClient와 새 coordinator를 만든다. old client를 재사용해 key prefix만 바꾸지 않는다.
- dispose와 scope transition은 idempotent하다.
5.2 query fence
query execution은 TanStack의 AbortSignal과 scope generation을 모두 캡처한다.
- 시작 전 runtime이
ACTIVE인지 확인한다. - application request에 AbortSignal을 전달한다.
- 완료 시 captured generation과 current generation을 비교한다.
- mismatch면 성공/실패 모두 새 cache/UI에 적용하지 않고
STALE_RESULT로 폐기한다. - query cancellation 실패가 scope clear를 막지 않게 하되 safe diagnostic을 남긴다.
5.3 mutation fence
frontend abort는 이미 서버에 도달한 mutation을 되돌리지 않는다.
- 시작 전 admission과 generation을 확인한다.
- server commit 전 cancellation은 transport의 idempotency/cancellation 계약을 따른다.
- server 결과가 old generation에서 돌아오면 새 cache에 optimistic result, invalidation 또는 success UI를 적용하지 않는다.
- server side effect의 authoritative 결과는 새 scope에서 정상 revalidation한다.
- mutation success 후 local invalidation 실패나 hint publish 실패가 이미 committed server mutation을 실패로 바꾸지 않는다.
- conflict resolution은 backend revision/ETag/idempotency 계약과 feature policy가 소유한다. query cache는 business merge authority가 아니다.
5.4 session owner 연결
production bootstrap은 auth/session owner subscription을 query lifecycle에
연결한다. 단순 authenticated boolean만으로 account identity를 추론하지 않는다.
owner는 opaque scope snapshot 또는 이를 발급할 authority를 제공해야 한다.
다른 tab의 logout은 cache invalidation event에 의존하지 않는다. 각 tab의 auth owner가 credential/session 변화를 독립적으로 감지하고 local lifecycle을 실행해야 한다.
6. strict query scope/persistence registry와 key codec
6.1 registry
모든 installed query namespace는 immutable scope/persistence profile을 갖는다.
freshness, GC, refetch, retry, result budget, pagination과 conditional policy의
유일한 source of truth는 VD-25 ServerStateProfile이다.
type QueryScopePersistencePolicy = Readonly<{
policyId: string;
namespace: readonly [string, number];
keySchemaVersion: number;
classification: "PUBLIC" | "INTERNAL" | "PERSONAL" | "CONFIDENTIAL";
scope: "ORIGIN_SHARED" | "ACCOUNT_BOUND" | "SESSION_BOUND";
persistence:
| Readonly<{ kind: "MEMORY_ONLY" }>
| Readonly<{
kind: "INDEXEDDB";
profileId: string;
maxAgeMs: number;
maxEntryBytes: number;
}>;
crossTab: "NONE" | "INVALIDATE";
invalidationTopics: readonly Readonly<{
topicId: string;
topicVersion: number;
}>[];
}>;
construction은 최소 다음을 검증한다.
- namespace/topic/policy ID가 closed syntax와 unique version을 가짐
- persistence가 classification, scope, max age와 맞음
NONE은 topic 0개,INVALIDATE는 namespace당 unique topic 1..8개(topicId, topicVersion)하나는 최대 32개 namespace에 fan-out하며 global topic→namespace set과 namespace→topic set이 서로 exact inverse- profile과 nested allowlist를 deep snapshot/freeze함
- VD-25 query definition/
ServerStateProfile과 join했을 때 owner, classification, scope, namespace, persistence와 invalidation topic set/version이 일치함
composition은 QueryDefinition -> QueryScopePersistencePolicy -> ServerStateProfile을 exact ID로 join한 뒤에만 TanStack option을 만든다. global
QueryClient default는 안전 baseline일 뿐이고 installed query의 정책 증거가
아니다.
6.2 query key wire subset
query key factory의 canonical input은 다음만 허용한다.
null, boolean, finite number, bounded string- 위 값의 dense array
- own enumerable data property만 가진 plain/null-prototype object
다음을 fail-closed로 거절한다.
- cycle/shared exotic graph
undefined,BigInt, symbol, function, accessorNaN, infinity, negative zero를 구분하지 않는 암묵 변환- Date, RegExp, Map, Set, class/DOM/native object
- File, Blob, ArrayBuffer와 typed array
- sparse array
__proto__,prototype,constructorkey- 허용 depth/node/part/string/serialized-byte ceiling 초과
구현 절대 상한:
| 항목 | 상한 |
|---|---|
| installed query profile | 256 |
| query key top-level part | 16 |
| canonical value depth | 8 |
| canonical value node | 256 |
| 단일 string UTF-8 | 1,024 bytes |
| 전체 canonical key UTF-8 | 4,096 bytes |
제품 profile은 더 낮출 수 있지만 이 상한을 높이려면 ADR amendment와 memory/telemetry cardinality evidence가 필요하다.
normative key layout:
[
"query",
keySchemaVersion,
scopeFingerprint,
namespaceName,
namespaceVersion,
queryDefinitionVersion,
canonicalSemanticInput
]
VD-25는 이 배열을 재정의하지 않고 마지막 두 field의 의미와 pagination projection만 소유한다. query function이 의존하는 모든 non-secret input을 포함하되 URL 전체, bearer token, email, filename, human-readable personal label을 넣지 않는다. domain entity identity가 필요하면 backend/product contract가 발급한 opaque ID와 bounded codec을 사용한다.
6.3 memory pressure
gcTime은 inactive retention이지 active cache hard cap이 아니다.
- gateway/mapper가 response count/byte ceiling을 검증한다.
- binary, native object와 unbounded collection을 query cache에 넣지 않는다.
- cache entry/active/inactive와 estimated payload를 safe bucket으로 관측한다.
- hard eviction controller는 joined VD-25 profile별 정책으로만 설치한다.
- memory pressure를 이유로 active personal data를 arbitrary global timer로 삭제하지 않는다. scope lifecycle의 remove/clear와 일반 eviction을 구분한다.
7. Web Storage contract
7.1 registered key policy
Web Storage는 registered small value 전용이다.
type WebStorageDefinition<Value> = Readonly<{
logicalName: string;
backend: "localStorage" | "sessionStorage";
scope: "ORIGIN_SHARED" | "OPAQUE_PARTITION" | "TAB";
classification: "PUBLIC_PREFERENCE" | "OPAQUE_CONTROL";
schemaVersion: number;
maxSerializedBytes: number;
retention:
| Readonly<{ kind: "SESSION" }>
| Readonly<{ kind: "TTL"; maxAgeMs: number }>
| Readonly<{ kind: "EXPLICIT_DELETE" }>;
valueCodec: string;
migration:
| Readonly<{ kind: "DISCARD" }>
| Readonly<{ kind: "ADJACENT"; migrationId: string }>;
quotaFallback: "MEMORY" | "NO_PERSIST" | "FEATURE_DISABLE";
logoutAction: "KEEP" | "PURGE_PARTITION";
}>;
구현 절대 상한:
| 항목 | 상한 |
|---|---|
| registered persistent key | 64 |
| key별 serialized value | 16,384 bytes |
| 한 sweep에서 검사할 key | 16 |
| 한 read에서 migration step | 2 |
현재 두 key는 각각 더 좁은 codec을 유지한다. COLOR_SCHEME은 public
origin-shared preference이고 CHUNK_RELOAD_GUARD는 tab session control이다.
server response, credential, signed URL, File/Blob, large draft와 queue를 Web
Storage에 넣지 않는다.
7.2 physical identity와 envelope
physical key는 application/environment, scope kind, opaque partition 또는 tab instance, logical key, schema version에서 결정적으로 파생한다. account/user ID를 포함하거나 origin 전체 key를 열거하지 않는다.
partition-aware 새 envelope는 기존 v1 세 필드의 의미를 변경하지 않고 새 envelope version으로 도입한다.
type BrowserStorageEnvelopeV2 = Readonly<{
envelopeVersion: 2;
schemaVersion: number;
scopeFingerprint: string;
writtenAtEpochMs: number;
expiresAtEpochMs: number | null;
value: unknown;
}>;
- exact field set, schema, scope, codec, written/expiry time 순으로 검증한다.
- TTL expiry는 write time과 registry max age에서 계산하며 caller가 직접 주지 않는다.
- 비정상적으로 먼 expiry, future write time과 clock skew는 fail-closed miss다.
- corrupt/expired/future/wrong-scope record는 exact key만 best-effort 제거한다.
- cleanup 실패는 validated miss를 raw exception으로 바꾸지 않는다.
- memory overlay도 exact envelope와 TTL/scope validation을 공유한다.
7.3 read/write outcome
stored undefined와 miss를 암묵적으로 합치지 않는다.
type WebStorageReadResult<Value> =
| Readonly<{ ok: true; state: "HIT"; value: Value; durability: "PERSISTED" | "MEMORY_ONLY" }>
| Readonly<{ ok: true; state: "MISS" }>
| Readonly<{ ok: false; error: ClientStorageFailure }>;
type WebStorageWriteResult =
| Readonly<{ ok: true; durability: "PERSISTED" }>
| Readonly<{ ok: true; durability: "MEMORY_ONLY"; degraded: true }>
| Readonly<{ ok: false; error: ClientStorageFailure }>;
memory fallback이 current runtime에서 승인된 성공이면 ok: true와
MEMORY_ONLY를 반환한다. durable write가 필수인 key는 fallback을 성공으로
가장하지 않는다.
7.4 migration, quota와 sweep
- migration은 registry에 등록된 deterministic adjacent version만 실행한다.
- migration callback은 network/native storage/telemetry side effect 없이 bounded pure codec으로 동작한다.
- future version과 unsupported old version은
DISCARDpolicy에서 miss다. QuotaExceededError이면 reconstructable exact key cleanup 뒤 동일 idempotent write를 최대 한 번 재시도한다.- origin 전체
clear()와 arbitrary LRU key enumeration을 금지한다. - TTL은 visibility rule이므로 boot/idle/focus 중 registry-owned bounded sweep을 별도로 수행한다.
- logout/account switch는 exact partition key만 purge한다. public origin-shared preference를 지우지 않는다.
sessionStorageopener snapshot을 authority로 사용하지 않는다. tab-local control에는 새 tab instance와noopenerpolicy를 적용한다.
8. optional IndexedDB query persistence
8.1 selection
query persistence reference facade의 현재 상태는
DESIGNED_NOT_IMPLEMENTED, product selection은 NOT_SELECTED다. 단순 warm-start
기대만으로 자동 설치하지 않는다.
다음 조건을 모두 충족한 query만 등록한다.
- server-authoritative이며 재구성 가능함
- stable query-key와 payload codec이 있음
- classification/scope/retention owner 승인
- entry/dataset/restore byte와 count budget이 있음
- logout/account deletion/release busting이 정의됨
- measured offline/warm-start 가치가 있음
- three-engine native contract와 rollback evidence가 있음
8.2 stable record, raw TanStack snapshot 금지
full QueryClient snapshot이나 library-private object를 그대로 저장하지 않는다.
type PersistedQueryRecord = Readonly<{
recordVersion: 1;
queryHash: string;
encodedQueryKey: unknown;
policyId: string;
scopeFingerprint: string;
releaseEpoch: string;
namespaceEpoch: number;
dataUpdatedAtEpochMs: number;
persistedAtEpochMs: number;
expiresAtEpochMs: number;
payloadCodecVersion: number;
payload: unknown;
measuredBytes: number;
revision: number;
}>;
- approved successful query data만 저장한다.
- error, pending state, mutation, function, Promise, AbortSignal, native/binary object, credential와 capability를 저장하지 않는다.
- query key와 payload를 각각 strict codec으로 검증한다.
- generic IndexedDB runtime의 opaque scope/policy binding, transaction complete, CAS, byte budget, migration, lifecycle와 failure mapping을 재사용한다.
8.3 구현 상한
reference facade의 기본 절대 상한:
| 항목 | 상한 |
|---|---|
| persisted query record | 1,024 |
| 단일 encoded entry | 512 KiB |
| query persistence dataset | 32 MiB |
| 한 restore record | 256 |
| 한 restore decoded bytes | 8 MiB |
| boot restore deadline | 2,000 ms |
| max age | 7 days |
| write debounce | 250–2,000 ms |
제품 policy는 더 낮출 수 있다. 상한 확대는 memory/quota/startup-latency evidence와 ADR amendment가 필요하다.
8.4 durable namespace epoch
full snapshot last-write-wins를 금지한다. 기본 writer model은 shared per-query record + monotonic namespace epoch다.
type DurableCacheLedger = Readonly<{
ledgerVersion: 1;
scopeFingerprint: string;
releaseEpoch: string;
namespaces: Readonly<Record<string, number>>;
revision: number;
}>;
- mutation invalidation은 namespace epoch를 같은 IndexedDB transaction에서 증가시킨 뒤 cross-tab hint를 publish한다.
- persisted record의 namespace epoch가 ledger보다 작으면 hydrate하지 않는다.
- record write는 current ledger epoch와 revision을 CAS 검증한다.
- BroadcastChannel sequence나 wall clock을 global durable ordering으로 사용하지 않는다.
- localStorage read-modify-write counter와 best-effort leader election을 correctness fence로 쓰지 않는다.
- ledger commit 뒤 hint를 publish한다. hint가 먼저 나가면 receiver가 commit 전 record를 읽을 수 있다.
8.5 restore와 hydration order
- bounded deadline으로 IndexedDB를 연다.
- immutable dataset/scope/release binding을 검증한다.
- ledger와 record schema/codec/TTL/byte cap을 검증한다.
- approved profile과 current namespace epoch만 decode한다.
- current memory/SSR state와 precedence를 적용한다.
- hydrate 뒤 normal stale/refetch policy를 실행한다.
wrong scope, expired, busted와 corrupt reconstructable record는 cache miss로
격하하고 exact bounded cleanup한다. persistence unavailable/blocked/timeout은
제품이 optional로 선택했다면 memory+network ONLINE_ONLY로 fail open한다.
offline-required workflow를 query persistence로 가장하지 않는다.
8.6 writer lifecycle
- cache events는 bounded debounce/coalescing한다.
- writer 하나에서 concurrent save를 serialize하고 superseded write를 버린다.
pagehide/beforeunloadtransaction 완료를 보장으로 간주하지 않는다.- 정상 runtime 중 주기적으로 commit하고 unload flush는 보조 수단이다.
- dispose는 timer를 취소하고 connection/listener를 닫는다.
- 아직 transaction complete가 아닌 write를 persisted success로 기록하지 않는다.
9. cross-tab invalidation
9.1 authority
cross-tab wire는 payload/query-key-free invalidate hint만 전달한다.
- query state/data replication 금지
- authorization/logout/server commit 증명 금지
- distributed lock/leader election 금지
- exactly-once/ordered delivery 주장 금지
- offline command 전송 금지
remote hint는 registry topic을 local namespace로 해석해 active query를
invalidate/refetch한다. inactive query는 다음 mount/focus/freshness 정책에서
revalidate한다. remote hint는 removeQueries, clear() 또는 session transition을
직접 실행하지 않는다.
9.2 transport와 source validation
BroadcastChannel
-> construction/post failure
-> registered localStorage pulse + storage event
-> failure/unavailable
-> DEGRADED_LOCAL_ONLY + normal stale/focus/reconnect
- current 2,048-byte exact wire envelope와 bounded TTL/dedupe/source tracking을 유지한다.
- topic registry 수에도 query profile과 같은 256개 절대 상한을 적용한다.
- localStorage fallback key를 Web Storage control registry에 등록한다.
- receiver는 exact key, exact
storageArea === localStorage, exact composite cache epoch와 event codec을 검증한다. sessionStorage를 cross-tab fallback으로 사용하지 않는다.- publisher는 local invalidation을 직접 수행한다.
- publish success는 receiver acknowledgement가 아니다.
- BroadcastChannel과 storage 양쪽 delivery는 event ID로 dedupe한다.
- per-source sequence gap은 global order 증명이 아니라 “hint를 잃었을 수 있음”을 나타낸다.
9.3 lost hint
query persistence가 꺼져 있으면 finite stale time, focus/reconnect와 manual refresh가 eventual revalidation을 제공한다. persistence가 켜져 있으면 visibility/focus와 sequence gap에서 durable namespace ledger를 bounded refresh한다.
즉시 global consistency가 업무 invariant라면 browser bus만으로 충족하지 않는다. backend revision/ETag, server push stream 또는 feature sync protocol을 추가한다.
10. SSR 선택 경계
현재 SSR product capability는 NOT_SELECTED다. browser-only code가 있다는 이유로
SSR support가 구현됐다고 주장하지 않는다.
SSR을 선택하면 별도 implementation gate에서 다음을 모두 구현한다.
- HTTP request마다 새 QueryClient를 생성하고 response 뒤 폐기한다.
- server process에서 Web Storage, IndexedDB와 BroadcastChannel에 접근하지 않는다.
- approved successful query만 dehydrate한다.
- serialized state를 HTML context에 안전하게 escape하고 byte/count cap을 적용한다.
- browser의 최신 SSR payload가 old persisted projection보다 우선한다.
- persisted state merge는 missing approved query만 복원하거나 explicit server revision을 비교한다.
- browser storage read 때문에 initial server/client markup이 달라지지 않게 hydration-safe bootstrap 단계에서 restore한다.
- request A의 QueryClient/data/scope가 request B에 공유되지 않는 test를 둔다.
SSR support와 IndexedDB query persistence는 서로 독립 선택이다.
11. privacy와 encryption
- credential, token, signed URL, authorization header, password와 crypto key는 memory query key/data, Web Storage, query persistence와 invalidation wire에 넣지 않는다.
- logical/physical key, query key/hash input, payload, account/user ID, URL과 native exception message/stack을 telemetry에 보내지 않는다.
- 같은 origin JavaScript가 ciphertext와 key를 모두 읽을 수 있는 client-side encryption은 XSS authorization boundary가 아니다.
- external/non-extractable key lifecycle과 compliance requirement가 있는 제품은 encryption을 defense-in-depth로 별도 선택할 수 있지만, 금지 classification을 허용하는 근거가 되지 않는다.
- logout purge는 confidentiality의 유일한 방어가 아니다. wrong-scope binding은 crash로 old bytes가 남아도 새 runtime이 읽지 못하게 해야 한다.
12. failure와 observability
failure는 최소 operation, closed code, retry owner, effect certainty와 fallback을 표현한다.
ABORTED와DEADLINE_EXCEEDED를 구분한다.- IndexedDB transaction
complete만APPLIED다. - Broadcast publish success의 remote effect는
UNKNOWN이다. - memory fallback과 persisted success를 구분한다.
- optional persistence failure는
ONLINE_ONLY로 degrade할 수 있다. - scope mismatch/corruption/future version은 raw record를 반환하지 않는다.
- diagnostics failure가 query, storage, lifecycle와 cleanup을 실패시키지 않는다.
safe metric:
- memory active/inactive/estimated-byte bucket
- Web Storage hit/miss/degraded/quota bucket
- persistence restore success/miss/busted/corrupt/deadline bucket
- scope reset duration/cleanup-incomplete
- invalidation publish/receive/drop/duplicate/gap/coalesced bucket
- listener/channel/connection leak count
13. implementation gate
Gate 0 — 상태와 문서
- 이 ADR과 상세 설계가 current/target 상태를 분리한다.
- capability catalog, runbook과 test evidence의 상태가 같은 taxonomy를 사용한다.
- 구현되지 않은 target type을 current API처럼 문서화하지 않는다.
Gate 1 — strict registry와 codec
- query policy registry와 query key closed codec 구현
- profile/key absolute ceiling 구현
- Web Storage per-key cap, HIT/MISS/durability result 구현
- current v1 key의 discard/upgrade 전략 확정
- hostile/cyclic/oversize/property-accessor test 통과
이 gate는 scope lifecycle을 자동 활성화하지 않는다.
Gate 2 — scope-owned QueryClient lifecycle
- session authority scope snapshot contract 구현
- auth owner subscription과 local generation fence 구현
- old query cancel/provider detach/client clear/dispose 구현
- late query/mutation result 폐기 구현
- account switch/logout exact partition cleanup 구현
- two-account and lost-event tests 통과
account-dependent query promotion은 이 gate 전 금지한다.
Gate 3 — cross-tab scope hardening
- composite cache epoch 구현
- registered localStorage pulse와 storageArea 검증 구현
- browser production coordinator E2E와 bfcache/StrictMode leak test
- Chromium/Firefox/WebKit 동일 case evidence
Gate 4 — optional query persistence reference runtime
- stable query record codec와 IndexedDB facade 구현
- durable namespace ledger/CAS/commit-before-hint 구현
- bounded restore/write/dispose 구현
- wrong-scope/TTL/release/migration/quota/blocked test
- production bootstrap import와 DB open이 없는 module-inventory/removal gate
완료 뒤에도 product selection은 NOT_SELECTED이고 reference 상태만
AVAILABLE_NOT_COMPOSED로 바뀐다.
Gate 5 — product composition
- measured requirement와 owner 승인
- exact query profile/persistence allowlist/retention/budget 등록
- account/logout/backend conflict contract 승인
- disabled → canary → enabled traffic admission
- rollback, cleanup-only release와 operational drill
Gate 6 — SSR 또는 offline workflow
각 capability를 별도 선택하고 별도 gate를 통과한다.
- SSR: request isolation, safe dehydration, precedence와 hydration test
- offline mutation: feature repository, server idempotency/revision/sync protocol, conflict/export/recovery UX
query persistence gate 통과가 SSR/offline workflow 통과를 의미하지 않는다.
14. test와 promotion evidence
14.1 deterministic
- independent QueryClient per runtime/scope
- session/account/release transition과 late result
- query key hostile value/ceiling/canonical equality
- Web Storage HIT/MISS/durability, TTL, migration, quota, cleanup, partition
- IndexedDB transaction complete, CAS, ledger epoch와 concurrent writer
- hint commit ordering, duplicate/self/stale/gap/coalescing
- diagnostics redaction와 dispose leak 0
14.2 real browser
Chromium, Firefox와 WebKit에서 같은 case set을 실행한다.
- native BroadcastChannel two-page delivery
- localStorage fallback과 exact storageArea
- account switch 중 in-flight query
- event loss 뒤 local auth lifecycle
- IndexedDB concurrent writer/blocked/versionchange/restore deadline
- bfcache/pagehide/StrictMode listener·connection cleanup
- sessionStorage tab/opener semantics
- N-1 release reader와 incompatible buster
현재 native transport spec이 존재해도 production QueryClient lifecycle 전체와 세 engine promotion artifact가 없으면 Gate 3 완료로 보지 않는다.
14.3 promotion artifact
artifact는 engine/browser version/OS image/build/release ID/contract suite version/pass/fail/skip/실행 시각을 보존한다. fake/jsdom 통과를 native provider 통과로 보고하지 않는다. WebKit system dependency 부족은 capability skip이 아니라 promotion evidence 미충족이다.
15. rollout과 rollback
15.1 rollout
- strict registry/codec을 기존 behavior 뒤 shadow validation으로 배포한다.
- scope lifecycle을 single-account environment에서 먼저 관측한다.
- account switch/logout fault test 뒤 account-dependent query를 허용한다.
- optional persistence는 source와 test만 추가하고 production composition은 계속 끈다.
- product selection 뒤 read-only restore/shadow write를 먼저 검증한다.
- 작은 cohort에서 write/restore/quota/blocked/rollback drill을 수행한다.
- error budget과 N-1 compatibility를 확인한 뒤 확대한다.
15.2 kill switch
서로 독립적으로 끌 수 있어야 한다.
- persistence restore off
- persistence write off
- cross-tab publish off
- cross-tab receive off
- offline mutation admission off
memory QueryClient와 정상 server fetch는 유지한다. account/session local lifecycle은 security boundary이므로 best-effort invalidation kill switch와 함께 끄지 않는다.
15.3 rollback
- 신규 persistence write/restore admission을 중지한다.
- writer/timer/channel/listener/DB connection을 dispose한다.
- scope fence와 memory QueryClient clear는 유지한다.
- rollback bundle이 future record/schema를 miss/online-only로 처리하게 한다.
- cleanup-only compatible release에서 exact owned partition을 bounded purge한다.
- retention/rollback window 뒤 registry/adapter/dependency를 제거한다.
schema version을 내리거나 origin 전체 localStorage.clear()/
indexedDB.deleteDatabase()를 자동 실행하지 않는다. unsynced user-authored data는
export/sync 확인 없이 query-cache cleanup으로 삭제하지 않는다.
16. 완료 기준
- session/account/release scope가 QueryClient, key와 event에 binding된다.
- scope transition은 admission fence, cancel, detach, clear, dispose와 새 QueryClient 생성으로 완료된다.
- old generation query/mutation result가 새 scope UI/cache를 변경하지 않는다.
- strict query scope/persistence registry와 closed key codec이 모든 absolute ceiling을 강제하고 VD-25 profile과 exact join된다.
- Web Storage가 per-key cap, HIT/MISS, durability, partition, logout, migration과 bounded sweep을 구현한다.
- localStorage invalidation fallback이 registered key와 exact storageArea를 검증한다.
- Chromium/Firefox/WebKit production coordinator/account lifecycle evidence가 있다.
- optional query persistence reference facade는 stable record와 durable namespace ledger를 사용하고 production 미선택 시 zero side effect다.
- persisted mutation/error/native object/credential이 없음을 negative test가 증명한다.
- SSR을 선택한 경우 request isolation과 hydration precedence가 증명된다.
- offline mutation을 선택한 경우 backend idempotency/revision/sync와 conflict/recovery UX가 별도 계약으로 증명된다.
- rollout/kill-switch/rollback/removal artifact가 보존된다.
현재 이 체크리스트는 완료 선언이 아니라 implementation gate다. 각 행을 실제 source, deterministic test, native evidence와 composition inventory로 증명하기 전에는 완료로 바꾸지 않는다.
17. 선택하지 않은 대안
- module singleton QueryClient
- account switch에서 query key prefix만 교체
- BroadcastChannel logout event를 lifecycle authority로 사용
- arbitrary query key와 raw TanStack cache snapshot persistence
- localStorage full-cache snapshot 또는 monotonic counter
- browser persistence를 offline command queue로 사용
- same-origin client encryption을 credential authorization boundary로 사용
- origin 전체 storage clear를 quota/logout/rollback 복구로 사용
- fake browser test만으로 production promotion
18. 결과
장점:
- account/session boundary와 best-effort invalidation의 권한 차이가 명확하다.
- persistence를 선택하지 않은 제품에는 DB open/listener/bundle side effect가 없다.
- query key, Web Storage와 IndexedDB의 migration/retention을 독립적으로 검증한다.
- old tab/snapshot이 invalidated data를 되살리는 경로를 durable epoch로 닫는다.
- SSR, offline workflow와 query warm-start를 서로 독립 선택할 수 있다.
비용:
- scope authority와 QueryClient remount lifecycle이 필요하다.
- registry/codec/historical fixture와 multi-page browser test가 늘어난다.
- optional persistence를 설치하는 제품은 IndexedDB migration, quota와 cleanup runbook을 운영해야 한다.
이 비용은 cache hit, durable restore, cross-tab hint와 server truth를 하나의 “cached” 상태로 잘못 합치지 않기 위한 의도적인 비용이다.