Files
clean-architecture-frontend…/docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md
T
DongHyeonkaandClaude Opus 5 2f29ccbf1a fix: retain realtime work through draining
R-02: add an OPEN/DRAINING/CLOSED lifecycle orthogonal to freshness. Effect and
recovery authorities are now awaited under a deadline: on expiry the commit
capability is revoked and the work aborted, the caller gets a bounded
non-retryable IDLE_TIMEOUT, and the underlying task is retained rather than
dropped. A draining stream refuses new events and recovery, and close() returns
a Promise that succeeds only once every retained task actually settled,
reporting IDLE_TIMEOUT otherwise.

R-03: a handoff fail-close moves active, probe, quiescing and transition leases
into a retired-writer set before clearing their references, and close() waits on
current and retired writers together, so an abandoned non-cooperative writer can
no longer make teardown report a false success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:52:46 +09:00

661 lines
34 KiB
Markdown

# VD-28: Realtime events, Web Push와 bounded polling
- 상태: Accepted — reference runtime available, product implementation pending
- 결정일: 2026-07-28
- 관련 결정: VD-10, VD-13, VD-23, VD-24, VD-25, VD-26, VD-27, VD-29
- 상세 설계:
`docs/architecture/realtime-events-web-push-and-bounded-polling.md`
- 현재 product selection: `NOT_SELECTED`
- common runtime delta: `AVAILABLE_NOT_COMPOSED`
- 재검토:
첫 제품 stream/Web Push를 선택할 때, 또는 backend replay/hosting/provider
protocol이 바뀔 때
## 스트림 lifecycle은 freshness와 직교한다 (R-02, R-03)
`RealtimeStreamLifecycle = OPEN | DRAINING | CLOSED`는 freshness
(`UNKNOWN/CURRENT/STALE/RESYNCING`)와 별개다.
- effect/recovery deadline에 도달하면 commit capability를 즉시 영구 무효화하고
abort한다. caller에는 bounded `IDLE_TIMEOUT`(non-retryable, operation
`APPLY`/`RECOVER`)을 반환하되 **실제 task는 버리지 않고 retain**한다.
- retain된 task가 하나라도 있으면 stream은 `DRAINING`이고 새 event/recovery
admission을 거절한다. 실제 settlement가 일어나야 `STALE`로 돌아가
authoritative recovery를 요구하거나, close 요청이면 `CLOSED`가 된다.
- `close()``Promise<RealtimeResult<void>>`다. 모든 retain task가 실제로
settle해야 success이고, drain bound를 넘기면 `IDLE_TIMEOUT/CLOSE`를 반환하며
stream은 계속 `DRAINING`이다. teardown success가 곧 quiescence다.
- LIVE↔POLL overflow fail-close는 active/probe/quiescing/transition lease를
모두 abort한 뒤 **retired writer set**으로 옮기고 나서 reference를 지운다.
`close()`는 current와 retired를 dedupe해 함께 기다리므로, 버려진
non-cooperative writer가 아직 실행 중인데 close가 성공을 보고할 수 없다.
## 배경
현재 optional recipe catalog는 realtime capability에
`referenceRuntime.status=AVAILABLE_NOT_COMPOSED`를 기록한다. 공통 event authority,
bounded reconnect owner, single-writer live↔Poll handoff, fetch-stream SSE,
bounded Polling, closed WebSocket protocol과 Web Push window/worker adapter는
deterministic test와 함께 존재하지만 production entry에서는 제외된다. generic
mega `RealtimePort`, 제품 event schema, 실제 endpoint, backend replay/provider
contract와 composition은 선택하지 않았다.
추가 설계 범위에는 성격이 다른 네 capability가 있다.
- SSE: active document의 server-to-client event stream
- WebSocket: active document의 duplex application protocol
- Web Push: inactive browser에도 도착할 수 있는 Service Worker 기반 notification
- bounded polling: 기존 HTTP/query operation의 제한된 scheduling policy
이를 “realtime transport” 하나로 합치면 다음 문제가 생긴다.
- Web Push의 permission, push service와 worker lifecycle이 connection 상태에 숨는다.
- Polling을 무한 timer나 transport downgrade로 오해한다.
- WebSocket이 필요하지 않은 server notification까지 duplex protocol이 된다.
- connection open, event delivery, application effect와 server 최신성을 같은 성공으로
표시한다.
- auth refresh, reconnect, HTTP retry와 Query retry가 중첩된다.
- gap, cursor expiry와 browser restore 뒤 authoritative resync owner가 사라진다.
- push subscription endpoint/key나 cursor가 일반 application state와 telemetry에
노출될 수 있다.
기존 recipe의 generic `channel: string`, `sequence: number`, 고정
`resumeToken`, callback과 `heartbeat()`는 선택 시 복사해 좁힐 출발점이다.
scope/epoch, closed event type, byte/queue limit, gap/reset, 진행되는 cursor,
generation과 effect certainty가 없어 production wire authority로 사용할 수 없다.
## 현재 상태
| 항목 | 상태 | 설명 |
| --- | --- | --- |
| optional realtime catalog/recipe | `RECIPE_AVAILABLE` / product `NOT_SELECTED` | uncomposed reference runtime과 conformance script가 있음 |
| common event/recovery/reconnect runtime | `AVAILABLE_NOT_COMPOSED` | scope/gap/barrier authority, finite reconnect owner와 exact close classification test가 있음 |
| live↔Poll handoff coordinator | `AVAILABLE_NOT_COMPOSED` | monotonic generation, one effect writer와 bounded checkpoint/quiescence test가 있음 |
| SSE runtime | `AVAILABLE_NOT_COMPOSED` | fetch-stream parser/adapter/reconnect test 있음; local server/browser evidence pending |
| WebSocket runtime | `AVAILABLE_NOT_COMPOSED` | exact handshake/protocol/queue/recovery test 있음; load/browser evidence pending |
| bounded polling coordinator | `AVAILABLE_NOT_COMPOSED` | finite single-flight visible/online lease와 deterministic budget test 있음 |
| Web Push window/worker runtime | `AVAILABLE_NOT_COMPOSED` | subscription, registration/revoke, durable fence, strict inbound worker factory가 있음; provider/browser evidence pending |
| exactly-once/global ordering | `PLATFORM_LIMITED` | 공통 browser delivery 목표로 보장하지 않음 |
| always-on background connection/polling | `PLATFORM_LIMITED` | hidden/frozen/terminated document에서 보장하지 않음 |
| timely cross-browser Web Push | `PLATFORM_LIMITED` | provider/browser/OS가 즉시 delivery를 보장하지 않음 |
reference source는 `AVAILABLE_NOT_COMPOSED`까지 승격됐다. 그러나 이 ADR과
deterministic test만으로 `COMPOSED` 또는 `PRODUCTION_READY`로 올리지 않는다.
제품 endpoint/registry와 backend/provider/target-browser evidence가 생긴 뒤
선택 capability만 별도 승격한다.
## 결정
### 1. 네 capability를 분리한다
다음 의미를 고정한다.
| capability | 선택 의미 | 기본 fallback |
| --- | --- | --- |
| SSE | foreground one-way ordered hint stream | bounded polling 또는 stale UI |
| WebSocket | foreground duplex interaction protocol | 의미가 축소되지 않으면 bounded polling, 아니면 disabled/stale UI |
| Web Push | background user-visible notification hint | foreground inbox/focus refresh |
| bounded polling | finite visible HTTP scheduling | manual refresh/explicit stale UI |
Web Push는 SSE/WebSocket의 fallback이 아니라 보완 capability다. Polling은
WebSocket duplex 기능을 대신할 수 없다. SSE↔WebSocket 자동 downgrade도 하지
않는다. 같은 사용자 의미를 보존하는 fallback만 registry에 명시한다.
추가 transport를 선택하기 전 기존 TanStack Query의 focus/reconnect refetch와
manual refresh가 측정된 freshness 요구를 만족하는지 먼저 확인한다.
Connect/gRPC-Web server stream은 VD-29/VD-27의 operation-bound API protocol이고 GraphQL
subscription은 현재 `NOT_SELECTED`다. GraphQL `@defer`/`@stream`은 finite
incremental HTTP response이지 realtime subscription이 아니다. RPC adapter가
protocol-specific terminal proof와 protobuf decode/schema/mapper를 끝낸
runtime-wide notification branch에서만 공통
scope/gap/resync coordinator를 재사용한다. frame/media/trailer, reconnect와
operation deadline owner를 SSE/WebSocket adapter로 합치거나 protobuf message를
`REALTIME_EVENT_V1` JSON으로 다시 감싸지 않는다. Polling의 개별 attempt는 VD-23의
terminal·replay-safe REST `QUERY` execution contract를 재사용하되 transport/Query
retry는 끄고, 이 결정은 attempt 사이 bounded lease만 소유한다.
### 2. source of truth는 서버다
SSE/WebSocket event의 기본 효과는 registered `QueryInvalidationTopic`과 authoritative
HTTP refetch다. raw event payload를 domain entity나 Query cache의 authoritative
state로 자동 승격하지 않는다.
authoritative delta 적용은 event type별 server revision, base revision, commit
뒤 publication, idempotent reducer, gap/reset과 snapshot reconciliation이 모두
승인된 경우에만 별도 선택한다.
Web Push payload는 작은 opaque notification hint다. Poll response는 해당 HTTP
representation의 결과다. 어느 것도 authorization이나 exactly-once effect를
증명하지 않는다.
### 3. outbound connection과 inbound event adapter를 분리한다
outbound가 소유한다.
- fixed endpoint와 credential 협력
- connect/subscribe/resume/reconnect/close
- selected WebSocket typed send
- push subscription register/revoke
- bounded poll scheduling/cancel
inbound가 소유한다.
- raw byte/frame hard cap
- UTF-8/JSON/schema/version 검증
- stream/event/scope/generation 확인
- dedupe/order/gap
- feature input 또는 query invalidation mapping
- effect 뒤 cursor/ack commit
application/domain에 native browser, TanStack, URL/header나 vendor type을 노출하지
않는다. `send(unknown)`과 arbitrary `channel`/endpoint도 금지한다.
### 4. target event protocol을 versioning한다
foreground common envelope은 다음 의미를 가져야 한다.
```text
protocol = REALTIME_EVENT_V1
streamId = registry-owned ID
streamEpoch = opaque server reset epoch
eventType = closed registry ID
eventId = bounded dedupe ID
sequence = canonical unsigned decimal string
recoveryMode = CURSOR | SNAPSHOT_ONLY | SESSION_REBUILD
resumeCursor = CURSOR면 opaque replay position, 아니면 exact null
occurredAt = strict RFC 3339, ordering authority 아님
scopeBinding = session/BFF-issued opaque exact-match token
payload = event-type-specific closed codec
```
`eventId`, `sequence`, `resumeCursor`와 business revision은 별도 의미다.
sequence는 JSON safe-integer 문제를 피하도록 decimal string으로 전달하고
stream + epoch 안에서만 비교한다.
credential, readable subject/account ID, signed URL, PushSubscription material과
자유 형식 message는 envelope에 넣지 않는다.
event type registry는 payload schema, pure boundary mapper와 effect profile을
함께 bind한다. `scopeBinding`은 cache fingerprint/authorization proof가 아니고,
cursor는 protocol/stream/feed/epoch/registered subscription set/auth scope에
server-side로 bind한다. client는 opaque cursor를 해석하지 않는다.
state-bearing stream의 recovery profile은 snapshot operation/checkpoint codec과
replay/connect-buffer/server-hold barrier를 닫는다. `SESSION_REBUILD`
EPHEMERAL-only다. V1 server-side subset filter는 `NOT_SELECTED`이며 필요하면
contiguous sequence/checkpoint를 가진 별도 stream으로 등록한다.
### 5. delivery guarantee와 authoritative resync를 분리한다
apply 순서는 다음과 같다.
```text
byte cap
-> parse/schema/version
-> registry/scope/generation
-> dedupe/order/gap
-> registered boundary mapper
-> sequential application effect
-> effect commit
-> last-applied cursor
-> optional selected WebSocket protocol ACK
```
effect 뒤 cursor를 commit하므로 crash window에서 duplicate가 생길 수 있다.
effect는 idempotent하거나 query invalidation/refetch여야 한다.
- 전체 browser lifecycle에 대한 delivery guarantee는 없음
- retention 안의 `CURSOR` foreground event 처리만 duplicate-tolerant
at-least-once model
- V1 ordering은 stream-wide 하나; partition은 별도 logical stream
- exact duplicate/old sequence는 safe drop
- 같은 event ID/sequence의 conflicting content는 protocol failure
- old captured generation callback만 safe drop; current connection의
`scopeBinding` mismatch는 security protocol violation으로 close/revalidate/resync
- sequence gap, stream epoch change, cursor expiry, queue overflow는 delta 적용 중단
- authoritative snapshot과
`SnapshotCheckpoint(streamEpoch,lastAppliedSequence,resumeCursor|null,snapshotRevision)`
같은 commit point로 얻은 뒤에만 resume
- exactly-once와 global ordering은 비목표
backend는 commit 이후 publication, replay retention, cursor reset과
snapshot/checkpoint 의미를 소유한다. subscribe ACK는 accepted cursor와
`nextExpectedSequence`를 반환한다. replay가 없는 `SNAPSHOT_ONLY`
connect/bounded-buffer 또는 server hold barrier 없이는 snapshot/connect 사이
event를 잃을 수 있으므로 `CURRENT`를 보장하지 않고 finite revalidation/stale UX로
degrade한다.
### 6. lifecycle은 scope generation으로 fence한다
connection, freshness, authorization, availability와 traffic admission을 별도
상태 축으로 둔다. `connected: boolean` 하나로 표현하지 않는다.
- runtime config/release/session recovery 뒤에만 connect한다.
- route lease는 unmount에서 release한다.
- logout/account/release transition은 old generation을 먼저 fence한다.
- connect/read/backoff/poll/snapshot을 abort하고 queue/cursor/dedupe를 폐기한다.
- late event/response/worker handoff는 captured old generation이면 적용하지 않는다.
- close/dispose/unsubscribe는 terminal/idempotent다.
- React StrictMode 반복 뒤 physical listener/connection/timer가 하나만 남는다.
- admission은 canonical `DISABLED | SHADOW | CANARY | ENABLED`만 사용하고,
drain은 connection lifecycle의 `DRAINING`으로 표현한다.
- `DISABLED`는 새 data-plane side effect를 0으로 한다. 이미 소유한 fixed
resource의 idempotent close/revoke만 bounded `DRAINING` cleanup plane에서
허용하며 `CLOSED` 뒤 network side effect는 0이다.
hidden에서는 Polling을 중지하고 live connection은 configured bounded grace 뒤
close/pause한다. `pagehide`에서 document-owned SSE/WS/Poll을 모두 정리하고
`pageshow`/visible 복귀에는 snapshot freshness gate 뒤 새 runtime으로 resume한다.
`unload` 완료에 의존하지 않는다. backend는 active authorization revoke를
close/control event로 전파하거나 bounded max connection age에 재인가한다.
### 7. retry owner를 하나로 제한한다
reconnect는 capped full-jitter exponential backoff를 사용한다. base/max delay,
max attempts와 max elapsed는 immutable registry/implementation ceiling으로
제한한다.
- stable-open window 또는 valid heartbeat/event 뒤에만 attempt reset
- valid server hint는 local delay보다 이른 retry를 금지하는 not-before bound
- server hint가 implementation max/remaining elapsed budget을 넘으면 낮춰
clamp하지 않고 degraded/stale로 종료
- offline에서는 timer retry를 멈춤
- auth expiry는 session owner single-flight recovery 한 번
- forbidden/protocol/schema failure는 terminal
- 외부 rate/provider failure는 exact bounded server not-before hint가 있을 때만
retry하고, hint가 없으면 terminal
- retry budget 소진 뒤 declared Polling fallback 또는 stale UI
- reconnect는 realtime coordinator, auth는 session owner, Poll cadence는 poll
coordinator가 소유하고 Poll-bound HTTP/Query retry는 비활성
- recovery checkpoint는 exact branded object identity로 다음 attempt에 전달한다.
SSE `onOpen`/WebSocket `onSubscribed` proof와 attempt 성공 proof가 같은
object일 때만 common transport barrier를 확인하고 event admission을 연다.
clone/missing proof와 30초 readiness deadline 초과는 fail-closed다.
- aborted sleep/attempt/closed-receipt는 기본 2초 bounded drain 뒤 run을
fail-closed로 끝내되, 실제 old task가 settle할 때까지 `DRAINING`을 유지한다.
정상 active session의 `waitClosed`에는 deadline을 두지 않는다.
### 8. SSE baseline은 bounded fetch-stream이다
common reference target은 fixed same-origin BFF에 대한 fetch-stream SSE다.
native EventSource보다 다음을 명시적으로 제어하기 위해서다.
- credential integration
- status/content type/redirect
- AbortSignal과 lifecycle
- parser/event byte ceiling
- reconnect/idle/retry budget
- explicit current cursor
native EventSource는 same-origin cookie auth, native `Last-Event-ID`/reconnect,
`204` terminal contract와 lifecycle 뒤 cursor recovery를 backend가 수용한
별도 profile에서만 허용한다. UA cursor를 application effect commit과 묶을 수
없으므로 `INVALIDATION_HINT` 전용이고 reconnect/restore마다 authoritative
snapshot gate를 수행한다. gate 중 hint는 bounded `pendingInvalidation`으로
coalesce하고 checkpoint 뒤 pending refetch까지 drain한다. 이 buffer/barrier가
없으면 `CURRENT`를 금지한다. `AUTHORITATIVE_DELTA`는 fetch-stream만 허용한다.
token을 URL에 넣지 않는다.
fetch-stream parser는 표준 UTF-8 SSE format, BOM/line ending/comment/multi-line
data/id/retry/incomplete EOF를 bounded하게 구현한다. exact `200
text/event-stream`만 stream 성공이며 auth/rate/reset/provider status를 closed
failure로 mapping한다. parsed candidate ID와 effect-committed cursor를 분리하고
각 application event block의 직접 `id`와 envelope cursor를 exact match한다.
SSE baseline은 registry-owned session feed 하나와 feed-wide cursor 하나다.
route lease는 local dispatch만 바꾸며 arbitrary server multiplex와
per-subscription cursor는 `NOT_SELECTED`다.
hosting은 proxy buffering, idle/request timeout, heartbeat, cache/transform,
HTTP connection budget와 client disconnect cleanup을 실제로 검증한다.
### 9. WebSocket은 versioned duplex protocol로만 선택한다
- fixed same-origin `wss:` endpoint와 exact subprotocol
- server `Origin` 검증과 current session authorization
- URL/query/subprotocol에 credential 금지
- closed welcome/subscribe/unsubscribe-ack/event/reset/heartbeat/close frame
- baseline text JSON, binary/extension은 별도 승인
- application heartbeat/watchdog
- bounded incoming sequential queue
- bounded outgoing queue와 `bufferedAmount`
- raw close reason redaction
- same-epoch cursor resume의 `nextExpectedSequence = lastApplied + 1`; accepted
cursor silent advance 금지, mismatch는 reset/snapshot
- state-bearing initial subscribe는 snapshot/checkpoint + barrier 전 `CURRENT` 금지
- `UNSUBSCRIBE` 뒤 matching `UNSUBSCRIBED`까지 tombstone과 quota를 유지하고 late
event/control은 effect 없이 버린다. unknown ACK와 ACK deadline 초과는
connection-level failure다.
classic browser WebSocket은 incoming backpressure를 제공하지 않으므로 queue
overflow에서 임의 delta drop을 하지 않는다. baseline은 connection을 close하고
snapshot resync한다. server의 bounded pause/resume ACK protocol을 별도 증명한
profile에서만 subscription pause를 허용한다.
모든 client control frame은 하나의 FIFO outbound queue를 통과한다. negotiated
message count/queued bytes와 native `bufferedAmount` 중 하나라도 넘으면
`QUEUE_OVERFLOW`, `retryable=false`, `OVERLOADED`로 generation 전체를 닫고
snapshot recovery를 요청한다.
durable business command는 기존 HTTP path를 기본으로 유지한다. WebSocket
command를 선택하면 closed operation, command ID/idempotency, expected revision,
ack와 business commit certainty를 별도로 정의한다.
### 10. Web Push는 별도 window/worker/backend capability다
Web Push 선택에는 다음이 모두 필요하다.
- user-action 기반 permission UX
- active Service Worker registration
- `userVisibleOnly: true`인 window subscription manager
- authenticated backend register/revoke
- server subscription registry
- VAPID private-key/provider owner
- worker push/notification/click inbound adapters
PushSubscription endpoint, `p256dh`, `auth`는 capability material로 취급하고
application state, browser storage, URL, BroadcastChannel과 telemetry에서
금지한다. VAPID private key는 server-only다.
push payload는 versioned, association/release-bound, expiring opaque notification
hint로 제한한다. 개인 내용은 foreground BFF가 current authorization으로
조회한다. worker handler는 `waitUntil` 안에서 bounded validation과
`showNotification`만 수행하며 long retry/sync/migration을 하지 않는다.
decoded application hint는 3 KiB를 넘지 않으며 최상위 JSON member name 중복은
last-wins로 해석하지 않고 거절한다. `issuedAt`의 client clock 대비 future
skew는 최대 5분, `expiresAt - issuedAt` lifetime은 최대 24시간이다.
window의 native permission/subscription operation은 30초, backend
register/reconcile/revoke operation은 15초 안에 종료하며 제품 config는 이
implementation ceiling을 높일 수 없다.
`pushsubscriptionchange` window handoff도 worker lifecycle abort와 10초
deadline을 사용하고, non-cooperative `matchAll()` 또는 동기 `waitUntil()` 예외
뒤에는 늦은 `postMessage`를 허용하지 않는다.
notification copy와 click route는 closed registry를 사용한다. arbitrary backend
text나 URL을 OS notification/openWindow에 전달하지 않는다.
worker restart 뒤 click을 처리하도록 bounded non-sensitive
`NotificationClickDataV1``NotificationOptions.data`에 넣고 click 시
codec/expiry/current association/release를 다시 검증한다. logout 때 owned
notification은 bounded best-effort close하지만 OS 잔존 가능성 때문에 copy는
항상 account-neutral이어야 한다.
worker는 window in-memory session을 authority로 사용할 수 없으므로 opaque
`fenceGeneration`, `sessionBindingEpoch`, `releaseEpoch`
`UNASSOCIATED | ACTIVE | REVOKED` association discriminant를 가진
adapter-owned IndexedDB `PUSH_CONTROL_V1` record를 사용한다.
account ID, endpoint/key, credential과 notification content는 이 record에서
금지한다. missing/corrupt/mismatch는 fail-closed한다. 동일 association epoch의
`REVOKED`는 terminal tombstone이다. logout은 durable fence generation rotate와
REVOKED를 먼저 commit한다. 새 `ACTIVE`는 distinct backend epoch와 captured/current
fence generation, server session binding, prior record revision/epoch, release를
한 IDB transaction에서 CAS해 stale-tab response를 거절한다. 첫 register 전
`UNASSOCIATED` record도 같은 generation을 durable하게 보관하므로 logout과
in-flight register response의 race를 association sentinel 없이 닫는다. client
`updatedAt`은 ordering authority가 아니다.
logout은 old generation fence, durable local association `REVOKED` commit과
backend account association revoke를 정상 security commit으로 사용한다. boot에서
native subscription/local fence/server association을 reconcile하고, local commit
실패나 ambiguous revoke는 `PUSH_UNAVAILABLE`로 내려 짧은 TTL, send-time auth와
click-time 재인가에 의존한다. native unsubscribe/old notification close는
best-effort지만 captured native subscription, exact association tag와 unchanged
durable fence를 모두 다시 확인한 경우에만 수행한다. 새 association이 commit되면
old cleanup은 건너뛴다. local fence 실패 뒤 current native subscription 조회나
association wildcard cleanup은 금지한다.
control tombstone purge는 자동 revoke 단계가 아니다. 별도 maintenance owner만
captured revision/authority/association epoch가 exact한 `REVOKED` record를
repository CAS로 삭제할 수 있고, concurrent newer owner가 있으면
`STALE_REVISION`으로 끝난다.
backend register/revoke는 VD-23의 fixed `COMMAND`로 등록하고 cookie session의
exact CSRF를 검증한다. register는 keyed idempotency 또는 atomic installation
upsert/receipt, revoke는 duplicate/`ALREADY_GONE` 성공 의미를 가져야 하며
`associationEpoch`은 server commit 뒤에만 발급한다.
Service Worker를 우회해 UA가 직접 notification을 표시할 수 있는 declarative push
message는 V1에서 `NOT_SELECTED`다. outbound `web_push: 8030` shape를 거절하고
별도 ADR 전에는 encrypted `WEB_PUSH_HINT_V1`만 허용한다.
Service Worker를 선택해도 offline fetch, PWA shell cache나 background sync가
자동 승인되지 않는다. 하나의 worker composition/update owner가 선택된 handler를
조립한다.
### 11. Polling은 bounded lease다
허용 형태:
- visible query의 낮은 빈도 conditional freshness poll
- 사용자 시작 async job의 terminal-state convergence poll
각 lease는 operation owner, minimum/success/max interval, max attempts,
max elapsed, response byte cap, visible-only policy와 terminal states를 가진다.
- operation은 registered terminal·replay-safe REST `QUERY`여야 함
- Poll `maxAttempts`는 physical request 하나인 logical completion을 셈
- Poll-bound VD-23 budget은 `maxAttempts=1`, `authRecoveryCount=0`,
`maxCumulativeSleepMs=0`; TanStack Query retry도 끔
- completion-chained timeout으로 single-flight
- hidden/offline/pagehide/unmount/scope change/user cancel에서 stop
- ETag/`If-None-Match` 또는 server cursor 사용
- `304`, auth, cursor reset, `429/503 Retry-After`를 closed mapping
- common recovery coordinator가 `POLL_ACTIVE -> LIVE_PROBING`에서 poll만 effect
writer로 유지하고 live candidate는 bounded buffer만 사용. handoff mutex에서
poll fence/abort + quiescence를 먼저 완료하고 current-generation
snapshot/checkpoint와 buffered event를 적용한 뒤 live를 활성화
- active writer effect tail도 in-flight 포함 256건/4MiB로 제한하고 overflow는
전체 generation을 `QUEUE_OVERFLOW`로 fail-close
- budget 소진 뒤 manual refresh/stale UI
- page component `setInterval`과 unlimited loop 금지
### 12. resource ceiling과 privacy를 fail-closed한다
상세 설계의 target hard ceiling은 physical connection, logical subscription,
event/frame/parser/queue/dedupe/reorder/outbound buffer, reconnect, poll lease,
push hint와 worker deadline을 제한한다. 제품 config는 더 작게만 설정할 수 있다.
2026-07-28 reference-runtime amendment로, RT-01~RT-04 source 전체를
tree-shaking 없이 합성하는 optional-recipe gzip 예산을 40,000 bytes로
고정한다. 이는 production bundle 허용량이 아니며 미선택 production asset의
realtime module 허용량은 계속 0이다. SSE replay-open과 WebSocket
`SUBSCRIBED`가 exact recovery checkpoint를 증명하고 common barrier가 확인될
때까지 event admission을 막는 readiness gate는 attempt당 최대 30초다.
phase abort 뒤 비협조적인 retry sleep, connect attempt 또는 closed-receipt
cleanup을 기다리는 drain은 2초로 고정하고 구현 절대 최대는 30초다. 상한을
넘긴 task가 settle할 때까지 lifecycle은 `DRAINING`을 유지하며 정상 active
session의 `waitClosed`에는 이 cleanup deadline을 적용하지 않는다.
ceiling 초과는 limit 자동 인상이나 silent drop이 아니라 new lease rejection,
connection close, snapshot resync, typed backpressure, stale/degraded 또는
notification drop으로 처리한다.
telemetry에는 transport/registry ID, closed outcome, count/duration/lag bucket만
허용한다. raw URL/query/credential/subject/event ID/cursor/payload/close reason/
PushSubscription key와 notification private content는 금지한다.
### 13. 실제 provider/browser/operations evidence 전에는 promotion하지 않는다
evidence를 분리한다.
1. pure unit/property와 deterministic fault contract
2. 실제 local SSE/WS server integration
3. backend replay/snapshot/auth/hosting/provider conformance
4. built production asset의 target-browser lifecycle
5. Web Push provider + browser/OS 자동·수동 evidence
6. load/chaos/security negative gate
7. dashboards, kill switch와 drain/recovery/rollback drill
fake/jsdom/MSW만으로 native stream, socket, worker, notification이나 provider
readiness를 주장하지 않는다. 외부 evidence가 없으면 `PromotionEvidence`
`MISSING | PARTIAL`이고 promotion gate result는 `FAIL_UNVERIFIED`다.
## 선택하지 않은 대안
### 범용 transport enum을 가진 `RealtimePort`
전송 교체는 가능해 보이지만 direction, permission, lifecycle, delivery certainty와
fallback 의미를 잃는다. 공통 protocol coordinator만 재사용하고 native capability
port는 분리한다.
### 모든 server event에 WebSocket 사용
one-way notification에도 duplex handshake, heartbeat, queue와 server connection
운영 비용을 강제한다. one-way stream은 SSE를 우선 검토한다.
### native EventSource만 공통 baseline으로 사용
arbitrary auth header, detailed status mapping, bounded reconnect와 explicit
lifecycle cursor 제어가 부족하다. 조건부 profile로는 허용하지만 reference
baseline은 fetch-stream이다.
### token을 SSE/WS URL에 전달
history, log, proxy, analytics와 referrer에 노출될 수 있다. same-origin
BFF/cookie 또는 승인된 별도 handshake를 사용한다.
### event payload로 Query cache 직접 patch
filter/pagination/revision/gap 의미가 없으면 stale projection을 만든다. 기본은
namespace invalidation과 authoritative refetch다.
### Web Push를 silent sync로 사용
permission/browser/OS/provider가 background execution과 timely delivery를
보장하지 않는다. user-visible notification hint와 foreground refresh로 제한한다.
### 무한 `setInterval` Polling
overlap, hidden resource 사용, retry 중첩과 terminal cleanup 누락을 만든다.
finite immutable lease와 single owner를 사용한다.
### cross-tab leader를 기본 제공
leader election/crash/handoff/partition과 SharedWorker 지원이 별도 protocol을
요구한다. 기본은 tab별 bounded runtime과 focus snapshot이다.
### exactly-once delivery
cursor commit과 application effect 사이 crash window, push service와 browser
lifecycle을 공통 frontend만으로 제거할 수 없다. retention 안의 CURSOR event만
duplicate-tolerant하게 처리하고 나머지는 best-effort + authoritative resync를
사용한다.
## 결과
긍정적 결과:
- 전송 선택이 요구와 failure semantics에 연결된다.
- server state/query ownership과 clean architecture 경계를 유지한다.
- gap, late callback, logout과 page restore가 명시적 복구 경로를 가진다.
- Web Push permission/subscription material이 일반 realtime state와 분리된다.
- Polling fallback이 resource-unbounded loop가 되지 않는다.
- 미선택 capability의 bundle/worker/runtime side effect를 0으로 유지할 수 있다.
비용:
- common coordinator 외에도 transport별 adapter와 실제 provider harness가 필요하다.
- backend는 replay/snapshot/outbox/auth와 provider 운영 계약을 제공해야 한다.
- worker와 window에 별도 composition/test matrix가 필요하다.
- direct delta보다 invalidation/refetch가 추가 HTTP 비용을 만들 수 있다.
- target browser/OS에서 자동화할 수 없는 Web Push evidence를 운영해야 한다.
## 구현 순서
```text
RT-00 contract/status
-> RT-01 event authority + scope/gap/resync
-> RT-02 SSE + bounded polling
-> RT-03 WebSocket
-> RT-04 Web Push
-> RT-05 product composition/provider/browser/operations
```
SSE와 WebSocket을 모두 구현해야 skeleton이 완성되는 것은 아니다. 공통
mechanism을 구현한 뒤 실제 product requirement에 필요한 최소 transport만
선택한다.
reference source와 deterministic/native evidence가 생기면 해당 runtime만
`AVAILABLE_NOT_COMPOSED`로 올린다. 제품 endpoint/event registry/policy가
bootstrap에 연결된 transport만 `COMPOSED`다.
## Rollout
capability별 traffic admission:
```text
DISABLED -> SHADOW -> CANARY -> ENABLED
SHADOW | CANARY | ENABLED -> DISABLED
```
- transport, stream, Poll fallback과 push category kill switch를 분리한다.
- safe config default는 `DISABLED`다.
- canary 전에 backend/provider/browser/operations evidence를 만료 검증한다.
- deploy/drain과 reconnect herd를 load test한다.
- freshness/latency만 아니라 gap/resync/queue/memory/battery/push permission
지표를 함께 본다.
## Rollback과 제거
1. admission을 `DISABLED`, connection lifecycle을 `DRAINING`으로 전환한다.
2. logical subscription/send/poll/push registration을 중지한다.
3. active reader/socket/timer/handler를 bounded close한다.
4. HTTP focus/manual refresh 또는 명시된 fallback을 노출한다.
5. server publisher/replay/subscription compatibility window를 유지한다.
6. composition/registry/adapter/dependency/worker handler를 제거한다.
7. CSP/runtime config/provider key와 retained server subscription을 정리한다.
8. typecheck, architecture, tests, build, bundle/module inventory와 removal gate를
실행한다.
미선택/제거 상태에서 connection, timer, push listener/subscription request와
production bundle sentinel이 0이어야 한다.
## 완료 기준
### 이 결정의 설계 완료
- [x] 네 capability의 의미와 선택 조건을 분리했다.
- [x] current status와 target runtime 상태를 구분했다.
- [x] source of truth와 delivery/effect certainty를 정했다.
- [x] target envelope, ordering, cursor와 resync를 정했다.
- [x] lifecycle/retry/resource/security/privacy 경계를 정했다.
- [x] transport별 auth/hosting/worker/Poll contract를 정했다.
- [x] evidence, rollout, rollback과 제거 기준을 정했다.
### 구현과 promotion 상태
- [x] RT-01 공통 coordinator/reconnect/contract suite
- [x] RT-02 SSE/Poll 및 single-writer handoff reference runtime과 deterministic evidence
- [x] RT-03 WebSocket reference runtime과 deterministic evidence
- [x] RT-04 Web Push window/worker reference runtime과 deterministic evidence
- [x] static boundary/security fixture, synthetic bundle budget와 removal blocking gate
- [ ] actual SSE/WS local server, load와 target-browser evidence
- [ ] actual Web Push provider, permission UX와 target-browser evidence
- [ ] provider/browser evidence와 operations drill의 release-blocking gate 등록
- [ ] 실제 product/backend/provider selection
- [ ] operations runbook drill
common runtime status는 `AVAILABLE_NOT_COMPOSED`다. 위의 미완료 promotion
항목 전에는 product selection이 계속 `NOT_SELECTED`이고 production-ready를
주장하지 않는다.
## 관련 자료
- [상세 설계](../realtime-events-web-push-and-bounded-polling.md)
- [VD-10 optional capability recipes](./VD-10-optional-capability-recipes.md)
- [VD-13 client cache scope and persistence](./VD-13-client-cache-scope-and-persistence.md)
- [VD-23 API transport selection and REST execution](./VD-23-api-transport-selection-and-rest-execution.md)
- [VD-25 Server State Cache lifecycle](./VD-25-server-state-cache-lifecycle.md)
- [VD-27 gRPC-Web unary and server stream](./VD-27-grpc-web-unary-and-server-stream.md)
- [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md)
- [Optional adapter recipes](../optional-adapter-recipes.md)
- [Client cache and browser storage](../client-cache-and-storage.md)
- [Frontend ports, adapters, and boundaries](../frontend-ports-adapters-and-boundaries.md)
- [WHATWG Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html)
- [WHATWG WebSockets](https://websockets.spec.whatwg.org/)
- [W3C Push API](https://www.w3.org/TR/push-api/)
- [WHATWG Notifications API](https://notifications.spec.whatwg.org/)
- [W3C Service Workers](https://www.w3.org/TR/service-workers/)
- [RFC 8030](https://www.rfc-editor.org/rfc/rfc8030)
- [RFC 8291](https://www.rfc-editor.org/rfc/rfc8291)
- [RFC 8292](https://www.rfc-editor.org/rfc/rfc8292)