Files
tech-log-frontend/docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed
on it through 43 template commits, so it was missing all three rounds of
adapter remediation — including files it never had, such as the shared
`abortable-operation` primitive and the `exact-snapshot` decoder that
later fixes are written against. Taking only the newest round was not
possible for that reason: the delta is coherent only as a whole.

The product had not touched `src/adapters` at all since materialization,
so the 140-file delta applied with a three-way merge and no conflicts.
`package.json` was the single overlap and merged cleanly: the product owns
`name`, the template contributed `check:adapter-inventory`,
`check:remediation-ledger` and the image-resolve-signal type fixture.
All 24 product-owned files — README, index.html, CI workflow, i18n
catalog, home page, generated schemas, evidence scripts, component and
visual snapshots — are byte-identical to `main`.

`template.lock.json` now pins the synced revision and tree.

Verified in this repository, not inherited from the template: six type
projects, lint, nine gates (adapter inventory, remediation ledger,
registries, diagnostics, realtime boundaries, architecture, browser
file/storage boundaries, optional recipes, documentation), the production
build, and 2,054 of 2,073 tests. The 19 failures are all in
`tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing
sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template
records; four suites that failed once under parallel load pass in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:04:58 +09:00

34 KiB

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은 다음 의미를 가져야 한다.

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 순서는 다음과 같다.

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 NotificationClickDataV1NotificationOptions.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, releaseEpochUNASSOCIATED | 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가 없으면 PromotionEvidenceMISSING | 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를 운영해야 한다.

구현 순서

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:

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이어야 한다.

완료 기준

이 결정의 설계 완료

  • 네 capability의 의미와 선택 조건을 분리했다.
  • current status와 target runtime 상태를 구분했다.
  • source of truth와 delivery/effect certainty를 정했다.
  • target envelope, ordering, cursor와 resync를 정했다.
  • lifecycle/retry/resource/security/privacy 경계를 정했다.
  • transport별 auth/hosting/worker/Poll contract를 정했다.
  • evidence, rollout, rollback과 제거 기준을 정했다.

구현과 promotion 상태

  • RT-01 공통 coordinator/reconnect/contract suite
  • RT-02 SSE/Poll 및 single-writer handoff reference runtime과 deterministic evidence
  • RT-03 WebSocket reference runtime과 deterministic evidence
  • RT-04 Web Push window/worker reference runtime과 deterministic evidence
  • 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를 주장하지 않는다.

관련 자료