Files
clean-architecture-backend-…/docs/websocket-superpowers-package/research/source-websocket-deep-research.md
T

1770 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WebSocket 실시간 양방향 연결 실행 플랫폼 심층 리서치
## 결론과 기술 기준선
이번 조사에서 가장 중요한 결론은 **WebSocket 모듈의 안정성을 “연결이 살아 있고 `sendMessage()`가 성공했는가”로 정의해서는 안 된다**는 것입니다. RFC 6455 WebSocket은 HTTP Upgrade 이후 양방향 메시지를 운반하는 저수준 프로토콜이고, 애플리케이션 메시지의 라우팅·업무 처리 완료·구독·ACK·재전송·Resume 의미를 정의하지 않습니다. Spring Framework 역시 WebSocket 자체는 메시지 내용의 의미를 정의하지 않으므로 STOMP 같은 subprotocol을 협상하거나 애플리케이션 규약을 별도로 만들어야 한다고 설명합니다. citeturn17search0turn7view2
따라서 권장 모델은 제시하신 **접근 C, `공통 Connection Runtime + Protocol Adapter`**입니다.
```text
HTTP Handshake / Upgrade
WebSocket Connection Runtime
├─ Origin / Authentication / Admission
├─ Connection Context
├─ Local Session Registry
├─ Inbound Assembly / Budget
├─ Outbound Serialized Writer
├─ Bounded Queue / Backpressure
├─ Heartbeat / Idle / Max Age
├─ Security / Observability
├─ Drain / Disconnect
└─ Reconnect Coordination
Protocol Adapter
├─ Raw Typed Protocol
├─ STOMP 1.2
├─ GraphQL Subscription Bridge
└─ Provider-specific Protocol
Protocol Command / Query / Event
Application Use Case
JPA / MongoDB / Messaging / Redis / HTTP Client
```
이 구조의 핵심은 **Connection Runtime의 운영 의미와 Protocol Adapter의 메시지 의미를 분리하는 것**입니다. STOMP의 `ACK`, `RECEIPT`, Destination 의미를 Raw Typed Protocol에 억지로 투영해서도 안 되고, 반대로 자체 Raw Protocol의 `messageId`, `sequence`, `resumeToken`을 STOMP 표준 기능인 것처럼 선언해서도 안 됩니다. STOMP 자체도 Destination 문자열을 opaque한 값으로 취급하며, 실제 전달·신뢰성 의미는 서버와 Destination 구현에 따라 달라진다고 명시합니다. citeturn21search0
### 현재 기술 기준
2026년 8월 14일 기준 Spring Boot 문서의 Stable은 `4.1.0`이며, Boot 4.1.0은 Spring Framework `7.0.8+`를 요구합니다. Java 최소 요구는 17이고 Java 26까지 호환되므로, Backend Skeleton이 Java 21을 자체 기준선으로 고정하는 것은 충분히 합리적인 플랫폼 정책입니다. Embedded Servlet Container 기준으로 Boot 4.1.0은 Tomcat 11.0.x와 Jetty 12.1.x를 지원합니다. citeturn18search1
Spring Boot 4.1은 embedded Tomcat과 Jetty의 WebSocket 자동 구성을 제공하고 MVC 애플리케이션에서는 `spring-boot-starter-websocket`을 제공하며, reactive 애플리케이션은 WebSocket API와 `spring-boot-starter-webflux` 조합을 사용합니다. Spring Boot의 reactive server 지원 범위에는 Reactor Netty, Tomcat, Jetty가 있으며, WebFlux 쪽 기본 운영 후보는 Reactor Netty가 적절합니다. citeturn18search0turn18search3
| 영역 | 조사 결론 | 플랫폼 등급 |
|---|---|---|
| Java | **21 기준선**. Spring 최소값보다 플랫폼 기준을 높게 고정 | Stable |
| Spring Boot | **4.1 BOM** | Stable |
| Spring Framework | Boot 관리 `7.0.x`, 현재 Boot 최소 `7.0.8` | Stable |
| Servlet Raw WebSocket | Tomcat 기본, Jetty 호환 Lane | Stable |
| Reactive WebSocket | WebFlux + Reactor Netty 기본 | Stable 선택 |
| Raw Text Protocol | UTF-8 JSON Typed Envelope | **Stable 기본** |
| Raw Binary | Protobuf 우선 검토, CBOR 선택 | Advanced |
| STOMP | STOMP 1.2 Adapter | Advanced Stable |
| Simple Broker | Local/Test·단일 인스턴스 제한 | 제한 지원 |
| External Broker Relay | Broker capability 검증 후 | Advanced |
| SockJS | 신규 서비스 기본 제외 | Legacy Compatibility |
| `permessage-deflate` | Endpoint별 명시적 Opt-in | Advanced |
| HTTP/2 WebSocket | RFC 8441 경로별 E2E 검증 | Compatibility |
| HTTP/3 WebSocket | RFC 9220은 표준이 존재하나 플랫폼 채택은 별도 | Experimental |
| 대형 파일 전송 | Fileserver/Object Storage 사용 | 비지원 |
| Durable ACK·DLQ·Replay | Messaging 소유 | WebSocket 비지원 |
HTTP/2와 HTTP/3에서 WebSocket을 구성하는 표준 자체는 각각 RFC 8441과 RFC 9220으로 이미 존재합니다. 따라서 “HTTP/3 WebSocket 프로토콜이 실험적”이라고 표현하기보다는, **표준은 존재하지만 Backend Skeleton에서 ClientNginx/IngressRuntime 전체 경로 검증이 끝나지 않았으므로 플랫폼 기능 등급을 Experimental로 둔다**고 표현하는 것이 정확합니다. RFC 9220은 HTTP/3의 Extended CONNECT를 WebSocket에 적용합니다. citeturn22search1turn7view3
### 핵심 질문에 대한 답
한 메시지의 실행 상태는 다음 한 줄로 표현해서는 안 됩니다.
```text
DELIVERED = true / false
```
대신 적어도 세 증거 축이 필요합니다.
```text
Inbound Evidence
FRAME_RECEIVED
→ MESSAGE_ASSEMBLED
→ MESSAGE_VALIDATED
→ MESSAGE_AUTHORIZED
→ APPLICATION_STARTED
→ APPLICATION_COMMITTED | APPLICATION_FAILED
Outbound Evidence
MESSAGE_CREATED
→ QUEUED
→ WRITE_STARTED
→ WRITTEN_TO_LOCAL_TRANSPORT
→ CLIENT_RECEIVED_ACK? // 별도 Protocol이 있을 때만
→ CLIENT_APPLIED_ACK? // 별도 Application ACK가 있을 때만
Connection Evidence
OPEN
→ HEARTBEAT_ALIVE
→ SUSPECTED_HALF_OPEN
→ DRAINING
→ CLOSE_SENT / CLOSE_RECEIVED
→ CLOSED | ABNORMAL
```
즉,
```text
sendMessage() 성공
≠ Client 수신
Client 수신
≠ Client Application 적용
STOMP RECEIPT
≠ Application Transaction Commit
TCP/WebSocket 연결 유지
≠ Client Application 정상
Application Commit
≠ Response가 Client에게 관측됨
```
이어야 합니다. STOMP 1.2의 `RECEIPT`은 해당 Client Frame을 서버가 처리했다는 증거이며 이전 Frame들이 서버에 수신됐다는 누적 증거로 쓸 수 있지만, 규격은 이전 Frame들이 완전히 처리되었다는 뜻은 아니라고 명시합니다. 따라서 `RECEIPT`을 업무 트랜잭션 커밋 증거로 바꾸어 해석하면 안 됩니다. citeturn11view1turn21search0
이 판단이 전체 플랫폼 설계의 중심축이어야 합니다.
## 책임 경계와 공개 계층·모듈 구조
### 인접 플랫폼과의 경계
제시하신 경계는 전반적으로 타당합니다. 특히 **Messaging과 WebSocket 사이의 경계가 가장 중요**합니다. Spring도 WebSocket을 HTTP와 다른 비동기 메시징 구조라고 설명하지만, 그것이 곧 durable messaging을 의미하지는 않습니다. STOMP 역시 Reliability와 Destination의 실제 의미를 서버별 구현에 맡깁니다. citeturn17search0turn21search0
| 인접 모듈 | WebSocket이 소유 | 인접 모듈이 소유 |
|---|---|---|
| `web` | Upgrade 성공 이후 Connection Runtime | HTTP Route, Forwarded Header 정규화, HTTP 인증 진입, Upgrade 이전 오류 |
| `security` | 인증 결과를 Connection Context에 유지, 메시지 권한 적용 | Token·Session 검증, Actor·Tenant·Permission 원천 |
| `messaging` | 현재 연결 Session으로 Live Push | **Durable Event, ACK, Retry, Replay, DLQ, Offset** |
| `redis` | Presence·Session Index·Ephemeral fan-out 사용 | TTL·원자 연산·Pub/Sub 자체 의미론 |
| `graphql` | WebSocket transport adapter | GraphQL operation, subscription, GraphQL error semantics |
| `grpc` | 브라우저·Client 중심 장기 양방향 연결 | 내부 서비스 typed RPC와 gRPC streaming |
| `fileserver` | 파일 상태·reference event | 대용량 byte 업·다운로드, Range, 검사 |
| `notification` | “새 알림 있음” live signal | Inbox·읽음 상태·채널 전달 상태 |
| `jpa`·`mongodb` | Application Use Case 호출 | Transaction, Query, Repository |
| `httpclient` | WebSocket 외 일반 outbound HTTP와 분리 | HTTP 호출·retry 정책 |
특히 다음 연결은 금지하는 것이 좋습니다.
```text
WebSocketHandler
→ JpaRepository 직접 호출
@MessageMapping
→ MongoTemplate 직접 상태 전이
WebSocket Session
→ Kafka ACK 의미를 직접 흉내냄
Redis Pub/Sub
→ durable replay라고 선언
WebSocket Binary Frame
→ 대형 파일 업로드
STOMP /queue/**
→ 이름만 보고 durable queue라고 선언
```
STOMP 규격상 `/queue/foo`라는 문자열 자체에는 Queue durability 같은 의미가 없습니다. Destination은 서버 구현이 해석하는 opaque string이고, 전달 신뢰성도 Destination과 Broker별 설정에 달려 있습니다. citeturn21search0
### 공개 기능 계층
권장 공개 계층은 다음과 같습니다.
| 공개층 | 공개 대상 | 포함 기능 |
|---|---|---|
| **WS1 Standard Typed WebSocket** | 일반 애플리케이션 | Endpoint, JSON Typed Message, RequestResponse, Event, Context, Auth, Heartbeat, Bounded Queue |
| **WS2 Advanced Messaging** | 고급 실시간 서비스 | Subscription, Application ACK, Resume, Sequence, Binary Codec, STOMP |
| **WS3 Infrastructure Extension** | 플랫폼·인프라 | Broker Relay, Multi-node Fan-out, Compression, SockJS, H2/H3 Profile |
| **WS4 Admin Plane** | 운영자 | Session Drain, Disconnect, Protocol disable, Connection snapshot, maintenance broadcast |
일반 도메인 개발자에게는 `WebSocketSession`, Reactor Netty native channel, 임의 `SimpMessagingTemplate`, raw Destination 생성, `ConcurrentWebSocketSessionDecorator` 구성 등을 직접 노출하기보다 등록된 Endpoint/Profile/Message Catalog를 제공하는 편이 좋습니다. Servlet WebSocket의 underlying standard session은 concurrent send를 직접 안전하게 제공하지 않기 때문에 Spring도 동기화 또는 `ConcurrentWebSocketSessionDecorator` 사용을 안내합니다. citeturn0search1turn12search0
### 권장 의존성 구조
제시한 모듈 분리는 그대로 채택할 가치가 높습니다.
```text
modules/websocket/
├── websocket-core-api
├── websocket-protocol
├── websocket-session
├── websocket-security
├── websocket-resilience
├── websocket-observability
├── websocket-servlet
├── websocket-webflux
├── websocket-stomp
├── websocket-broker-relay
├── websocket-cluster
├── websocket-resume
├── websocket-client
├── websocket-admin
├── websocket-spring-boot-starter-mvc
├── websocket-spring-boot-starter-webflux
├── websocket-testkit-core
├── websocket-testkit-servlet
├── websocket-testkit-webflux
├── websocket-testkit-browser
└── websocket-testkit-proxy
```
의존 방향은 다음처럼 단방향으로 고정하는 것이 좋습니다.
```text
core-api
protocol / session / security / resilience / observability
servlet webflux
↑ ↑
starter-mvc starter-webflux
stomp
└─ broker-relay
resume
└─ messaging bridge abstraction
cluster
└─ redis / messaging capability adapter
admin
└─ session abstraction
```
`core-api`에는 Jakarta WebSocket, Servlet, Reactor, Netty, STOMP 타입을 넣지 않는 편이 좋습니다. 같은 논리로 MVC와 WebFlux Starter도 상호 배타적으로 두는 것이 안전합니다. Spring Boot는 servlet과 reactive 스택을 별도로 구성하며, `spring-boot-starter-web``spring-boot-starter-webflux`가 함께 있으면 기본적으로 MVC를 선택하므로 우연한 Stack 선택을 피하는 정책이 필요합니다. citeturn18search3
기본 Starter에서는 다음을 제외하는 것이 적절합니다.
```text
websocket-stomp
websocket-broker-relay
websocket-cluster
websocket-resume
websocket-client
SockJS
permessage-deflate
HTTP/2 WebSocket Profile
HTTP/3 WebSocket Profile
```
이는 “지원하지 않는다”가 아니라 **WS1의 기본 런타임을 가볍고 예측 가능하게 유지하면서 WS2·WS3 기능은 명시적으로 선택하게 한다**는 의미입니다.
## Handshake·인증·보안·Typed Protocol 계약
### Handshake는 HTTP와 WebSocket의 경계선이다
Classic WebSocket은 HTTP request로 시작하여 성공 시 `101 Switching Protocols`로 전환됩니다. Spring Framework도 Upgrade 전과 후가 전혀 다른 프로그래밍 모델임을 강조하며, Upgrade 이후에는 한 연결을 통해 애플리케이션 메시지가 계속 흐릅니다. citeturn17search0turn7view2
권장 파이프라인은 다음과 같습니다.
```text
HTTP Request
→ Trusted Proxy / Forwarded Header 정규화
→ WebSocket Endpoint Profile 선택
→ Host / Origin 검증
→ HTTP Authentication 또는 Connection Ticket 검증
→ Actor / Tenant 후보 Context 생성
→ Subprotocol 협상
→ Extension 협상
→ Connection / Tenant / IP Admission
→ 101 Switching Protocols
→ Protocol-level Authentication 완료
→ Session OPEN
```
Upgrade 이전에는 기존 `web` 플랫폼의 HTTP 오류 계약을 그대로 사용할 수 있습니다.
| Handshake 상황 | 권장 HTTP 결과 |
|---|---|
| Handshake 구조 오류 | `400` |
| 필수 HTTP 인증 실패 | `401` |
| Origin·Endpoint 권한 거부 | `403` |
| 존재 은닉이 필요한 Endpoint | `404` |
| 중복 Connection 정책 충돌 | `409` |
| 연결 Rate Limit | `429` |
| Drain·과부하 Admission 거부 | `503` |
반대로 `101` 이후에는 HTTP `ProblemDetail`을 보낼 수 없으므로 Typed `ERROR` message 또는 WebSocket Close code로 전환해야 합니다. RFC 6455에서도 `101` 이외의 Handshake response는 HTTP semantics를 유지하지만 성공적으로 protocol switch가 완료된 뒤에는 WebSocket framing으로 통신합니다. citeturn8view2turn17search0
### Origin 검증은 필수 보안 경계
Spring Security 공식 문서는 브라우저의 WebSocket 연결에는 일반적인 Same Origin Policy가 자동 적용되지 않으므로 서버가 이를 명시적으로 보호해야 한다고 강조합니다. Cookie 인증 상태에서 Origin을 무제한으로 허용하면 다른 사이트가 사용자의 인증 상태를 이용하는 Cross-Site WebSocket Hijacking 문제가 생길 수 있습니다. Spring Security는 STOMP 구성에서 `CONNECT`에 CSRF token을 요구하는 방식을 제공합니다. citeturn17search1
Stable 기본 정책은 다음이 적절합니다.
```text
Origin
→ Exact Allowlist
Wildcard Subdomain
→ 기본 금지, 등록 Profile만 허용
null Origin
→ 기본 거부
Cookie Authentication
→ Origin 검증 필수
STOMP + Cookie Session
→ CONNECT CSRF 사용
Cross-origin Token Profile
→ Endpoint별 명시 Opt-in
```
다음은 기본 금지로 두는 것이 좋습니다.
```text
allowedOrigins = *
Cookie Authentication + Origin 미검증
요청 Origin을 그대로 Allow
장기 Access Token을 query parameter에 사용
모든 STOMP MESSAGE / SUBSCRIBE permitAll
Client가 보낸 actorId / tenantId 신뢰
```
Spring Security는 STOMP에서 inbound `MESSAGE``SUBSCRIBE`를 Destination별로 통제할 수 있으며, 특히 broker prefix로 직접 MESSAGE를 보내 시스템 발신자를 가장하거나 다른 사용자용 Destination을 SUBSCRIBE하는 형태를 막아야 한다고 설명합니다. citeturn17search1
### 브라우저 인증 Profile
브라우저 표준 `WebSocket` 생성 인터페이스는 URL과 subprotocol을 중심으로 제공되며 애플리케이션이 일반 HTTP client처럼 임의의 `Authorization` 헤더를 자유롭게 추가하는 인터페이스는 제공하지 않습니다. 반면 Handshake는 브라우저 credential 정책에 따라 Cookie 등의 인증정보를 사용할 수 있습니다. citeturn7view0turn5search1
따라서 다음과 같이 분리하는 것이 좋습니다.
| 인증 Profile | 지원 등급 | 권장 의미 |
|---|---|---|
| Cookie / HTTP Session | Stable | HTTP 인증 Principal 승계 + Exact Origin |
| One-time Connection Ticket | **Stable 권장** | Bearer를 URL에 장기 노출하지 않는 브라우저 연결 |
| STOMP `CONNECT` Bearer | Advanced | `ChannelInterceptor`에서 인증 |
| Query Long-lived Access Token | 비지원 | Proxy·Access Log·History 노출 위험 |
| Protocol 중간 Re-auth | Experimental | 복잡성이 크므로 초기 Stable 제외 |
Spring Security는 HTTP Handshake에서 인증된 `Principal`을 WebSocket으로 넘겨주는 모델을 지원합니다. STOMP에서 별도 token 인증을 원할 경우 `CONNECT` frame의 header를 `ChannelInterceptor`에서 처리할 수 있습니다. citeturn17search1turn5search1
One-time Ticket은 표준 기능이 아니라 **플랫폼 자체 Profile**로 설계해야 합니다.
```text
POST /websocket-tickets
→ ticket 발급
Ticket:
- random high-entropy identifier
- 매우 짧은 TTL
- one-time atomic consumption
- actor binding
- tenant binding
- endpoint binding
- origin binding
- clientInstanceId 선택 binding
```
그리고 Access Log에는 Ticket 전체 값을 남기지 않는 것이 적절합니다.
장기 Connection의 Credential 만료 정책은 Stable에서 **“만료·권한회수 시 현재 Connection 종료 → 새 Credential로 재연결”**을 기본으로 잡는 편이 낫습니다. Connection 내부 reauthentication은 state machine·race condition·권한 회수 처리 비용이 커지므로 Advanced/Experimental로 남기는 편이 안전합니다.
### Subprotocol은 Production Endpoint에서 명시적으로 협상
WebSocket 표준은 `Sec-WebSocket-Protocol`로 상위 protocol을 협상할 수 있습니다. Spring도 STOMP 등의 고수준 protocol을 이 header를 통해 선택하는 것을 지원합니다. citeturn8view2turn17search0
권장 Raw protocol 이름은 다음처럼 **Major version + codec**을 포함하는 형태입니다.
```text
hyeonworks.realtime.v1.json
hyeonworks.realtime.v1.protobuf
v12.stomp
graphql-transport-ws
```
Production Typed Endpoint는:
```text
지원되는 Subprotocol 하나 선택
→ 성공
지원되는 공통 Protocol 없음
→ Handshake 거부
```
로 처리하고, subprotocol 없이 “아무 JSON이나 받아들이는” 모드는 Local 또는 Compatibility profile로 제한하는 것을 권장합니다.
### Typed Message Envelope
Stable Raw JSON protocol에는 무조건 모든 필드를 넣는 것이 아니라 **공통 식별 필드 + 메시지 종류별 선택 필드**를 두는 편이 좋습니다.
```json
{
"type": "document.updated",
"version": 1,
"messageId": "01K...",
"correlationId": "01K...",
"streamId": "document:abc",
"sequence": 42,
"occurredAt": "2026-08-14T06:00:00Z",
"expiresAt": "2026-08-14T06:01:00Z",
"payload": {}
}
```
권장 의미는 다음과 같습니다.
| 필드 | 계약 |
|---|---|
| `type` | 등록된 stable wire name |
| `version` | 해당 message schema major/version |
| `messageId` | 메시지 인스턴스 식별 |
| `correlationId` | RequestResponse 연결 |
| `causationId` | 필요 시 원인 message |
| `streamId` | Ordering·Resume 대상 logical stream에서만 |
| `sequence` | 해당 stream 내부 monotonically increasing sequence |
| `occurredAt` | 서버 기준 이벤트 시각 |
| `expiresAt` | 오래된 Command 재실행 방지용 선택 필드 |
| `payload` | Message type별 DTO |
모든 메시지에 `sequence`, `idempotencyKey`, `subscriptionId`를 강제하기보다 메시지 family별 schema를 만드는 것이 좋습니다.
```text
RequestMessage<T>
ResponseMessage<T>
CommandMessage<T>
EventMessage<T>
SubscribeMessage<T>
AckMessage
ErrorMessage
ResumeMessage
SnapshotMessage<T>
```
다음은 금지하는 것이 적절합니다.
```text
messageType = Java FQCN
payload = Map<String, Object>
Entity 자체 직렬화
Java Serialization
무제한 polymorphic deserialization
한 Envelope 안에 모든 command payload union 수작업
```
### Codec 정책
| Codec | 등급 | 정책 |
|---|---|---|
| UTF-8 JSON | Stable 기본 | Browser 친화적, Contract Test 필수 |
| Protobuf Binary | Advanced 권장 | 강한 schema가 필요한 고성능 client |
| CBOR | Advanced 선택 | 실제 요구·SDK 지원이 있을 때 |
| Raw Binary | 제한 | 사전 등록된 Message Profile만 |
| Java Serialization | 비지원 | Wire contract로 사용하지 않음 |
대용량 byte는 Typed Event에서 object/file reference를 전달하고 fileserver/object-storage가 byte transport를 담당하도록 유지하는 것이 좋습니다.
## 실행 증거·Idempotency·Ordering·ACK·Resume 계약
### 증거 모델은 플랫폼의 핵심 API여야 한다
질문의 핵심인 “어디까지 갔는가”를 정확히 답하려면 다음 단계가 필요합니다.
| 단계 | 서버가 증명할 수 있는 것 | 재호출 판단 |
|---|---|---|
| `FRAME_RECEIVED` | WebSocket frame이 runtime에 도착 | 업무 실행 여부는 모름 |
| `MESSAGE_ASSEMBLED` | Fragment 조립 완료 | 아직 재실행 안전 |
| `MESSAGE_VALIDATED` | Protocol/schema 검증 완료 | 아직 업무 미실행 |
| `MESSAGE_AUTHORIZED` | Transport/message 권한 통과 | 아직 업무 미실행 |
| `APPLICATION_STARTED` | Use Case 진입 | **Commit 여부 불명확 가능** |
| `APPLICATION_COMMITTED` | 업무 결과의 durable evidence 존재 | 다시 실행하지 않고 결과 Replay |
| `APPLICATION_FAILED` | 정의된 실패로 종료 | 실패 유형에 따라 재시도 |
| `RESPONSE_QUEUED` | Outbound queue에 등록 | Client 수신 증거 아님 |
| `WRITE_STARTED` | local transport 쓰기 시작 | Client 수신 증거 아님 |
| `WRITTEN_LOCALLY` | framework/local transport 단계 완료 | Client 적용 증거 아님 |
| `CLIENT_ACKED` | protocol ACK를 Client가 보냄 | ACK 정의 범위만 증명 |
| `CLIENT_APPLIED` | app-level 적용 ACK가 존재 | Client application 반영 증거 |
여기서 `APPLICATION_COMMITTED`는 WebSocket runtime 자체가 추측하면 안 됩니다. **Application transaction과 Idempotency/Result ledger가 durable evidence를 제공해야 합니다.**
예:
```text
Command(commandId, idempotencyKey)
Application Use Case
DB Transaction
├─ Domain State 변경
└─ Command Result / Idempotency Record 저장
↓ COMMIT
APPLICATION_COMMITTED
```
그 뒤 소켓이 끊겨도 결과는 다음 연결에서 재조회할 수 있어야 합니다.
### Commit 후 Response 유실
가장 위험한 케이스는 다음입니다.
```text
Client
→ COMMAND C42
Server
→ APPLICATION_STARTED
→ DB COMMIT
→ Response 생성
→ Socket write 시작
Network
→ 연결 단절
Client
→ Response 미관측
```
이때 Client의 올바른 상태는 `FAILED`가 아니라:
```text
COMPLETION_UNKNOWN_TO_CLIENT
```
입니다.
그리고 재연결 후:
```text
COMMAND C42 재전송
Idempotency Ledger 조회
├─ COMPLETED
│ → 저장된 Result 반환
├─ PROCESSING
│ → 진행 상태 반환
├─ ABSENT
│ → 새 실행
└─ 동일 key + 다른 fingerprint
→ conflict
```
으로 처리해야 합니다.
따라서 상태 변경용 WebSocket Command에는 다음 중 하나가 필수입니다.
```text
idempotencyKey
client-generated commandId
client-generated resourceId
reconciliation query
```
**Connection sequence만으로 mutation idempotency를 보장하면 안 됩니다.** Connection은 끊기고 다시 만들어질 수 있고, Resume window 역시 업무 idempotency TTL과 동일한 개념이 아니기 때문입니다.
중요한 mutation이 이미 HTTP나 gRPC에서 명확하게 표현되고 있다면 다음 구조도 우선 검토할 가치가 있습니다.
```text
HTTP/gRPC
→ Command 수행 + Idempotency
WebSocket
→ 결과·상태 변경 Live Event 전달
```
이렇게 하면 WebSocket이 mutation transport와 durable command ledger까지 모두 소유하는 복잡성을 크게 줄일 수 있습니다.
### STOMP `RECEIPT`과 ACK의 정확한 의미
STOMP 1.2에서:
- `RECEIPT`은 요청한 Frame에 대해 서버가 처리했다는 protocol-level 응답입니다.
- 이전 Frame들이 서버에 수신됐다는 누적 증거로는 사용할 수 있지만, 이전 모든 Frame의 최종 업무 처리를 보장하지 않습니다.
- `ack:auto`는 별도 Client ACK가 필요 없습니다.
- `ack:client`는 cumulative acknowledgment입니다.
- `ack:client-individual`은 개별 메시지 acknowledgment입니다.
- Connection이 ACK 전에 실패하면 서버가 메시지를 재전달할 수 있으나 구체적인 redelivery semantics는 서버 구현에 의존합니다. citeturn11view1turn11view2turn21search0
따라서 플랫폼 용어를 다음과 같이 분리해야 합니다.
```text
TRANSPORT_WRITE
PROTOCOL_RECEIPT
BROKER_DELIVERY
BROKER_ACK
APPLICATION_COMMIT
CLIENT_RECEIVED
CLIENT_APPLIED
```
이 단어들을 서로 alias하지 않는 것이 중요합니다.
### Ordering은 WebSocket Frame 순서와 Application 순서가 다르다
하나의 연결에서 네트워크 바이트의 순서가 유지되더라도, Spring STOMP의 `clientInboundChannel``clientOutboundChannel`은 thread pool 기반으로 처리되므로 application handling과 publish 결과가 서로 다른 thread에서 수행되어 원래 순서와 달라질 수 있습니다. Spring은 `setPreserveReceiveOrder(true)``setPreservePublishOrder(true)`를 제공하지만, 순서 보장에는 성능 비용이 있다고 명시합니다. citeturn20search1
따라서 다음 profile이 적절합니다.
| Ordering Profile | 사용 예 | 계약 |
|---|---|---|
| `UNORDERED_LOW_LATENCY` | presence, typing | 순서 의존 금지 |
| `SESSION_ORDERED` | 한 connection command chain | session별 serialize |
| `SUBSCRIPTION_ORDERED` | subscription update | subscription별 sequence |
| `STREAM_KEY_ORDERED` | document/room/entity stream | logical stream별 sequence |
특히 Multi-node와 Resume를 고려한다면 `sessionSequence`보다:
```text
streamId + streamSequence
```
가 더 중요한 복구 기준입니다.
예:
```text
document:123
seq=100
seq=101
seq=102
```
Client는:
```text
lastReceivedSequence
lastAppliedSequence
```
를 구분해야 합니다.
`lastReceivedSequence=102`지만 UI/State Store에는 `101`까지만 반영하다 브라우저가 죽을 수도 있기 때문입니다. Resume 기준은 일반적으로 **`lastAppliedSequence`**가 더 안전합니다.
### Subscription 계약
권장 Subscription request는 다음 정도면 충분합니다.
```json
{
"type": "subscribe",
"version": 1,
"messageId": "...",
"payload": {
"subscriptionId": "sub-01",
"topic": "document.changes",
"resourceId": "doc-123",
"resumeFrom": 101
}
}
```
다음은 서버 등록 Catalog에 의해 통제합니다.
```text
Topic Allowlist
Filter Allowlist
Projection Profile
Maximum subscriptions / connection
Maximum subscriptions / actor
Event rate
Outbound queue budget
Authorization profile
Resume capability
```
권한은 “Connection할 때 로그인했는가”와 “이 Event를 지금 받을 권리가 있는가”를 구분해야 합니다. Spring Security 역시 MESSAGE와 SUBSCRIBE의 Destination 권한을 구별하며, outbound 자체를 모두 검사하는 대신 subscription을 엄격히 보호하는 방식을 설명합니다. citeturn17search1
민감한 스트림에서 권한 회수가 즉시 반영되어야 한다면 다음 중 하나가 필요합니다.
```text
Event delivery 시 Authorization 재검증
또는
Permission Revocation Event → subscription revoke / session close
또는
짧은 Connection Max Age
```
모든 Event마다 DB Authorization Query를 수행하는 것은 비용이 크므로 Endpoint별 정책으로 두는 것이 좋습니다.
### Reconnect와 Resume
RFC 6455에는 끊어진 Application Stream의 replay cursor나 resume semantics가 정의돼 있지 않습니다. 끊어진 뒤에는 새 WebSocket Connection을 만들고 애플리케이션 protocol이 복구를 정의해야 합니다. citeturn7view2turn17search0
Stable Resume protocol은 다음 형태가 적절합니다.
```text
Client disconnect
Exponential Backoff + Jitter
새 Handshake + 새 Authentication
RESUME
{
streamId,
resumeToken,
lastAppliedSequence,
snapshotVersion
}
Server
├─ history available
│ → replay sequence+1 ...
├─ history compacted / gap too old
│ → SNAPSHOT_REQUIRED
├─ permission changed
│ → RESUME_DENIED
└─ token expired
→ RESUME_EXPIRED
```
Resume 성공 뒤에도 Client는 duplicate detection을 수행해야 합니다.
```text
seq <= lastAppliedSequence
→ duplicate, ignore
seq == lastAppliedSequence + 1
→ apply
seq > lastAppliedSequence + 1
→ GAP, stop incremental apply
→ resume/snapshot request
```
Replay source는 WebSocket Node의 메모리가 아니라 **Messaging/Event Log 등 durable capability**여야 합니다. WebSocket Resume module은 cursor와 snapshot orchestration만 담당하는 것이 모듈 경계에 맞습니다.
### Presence는 사실이 아니라 관측 결과다
WebSocket `OPEN`만 보고:
```text
user.online = true
```
라고 업무 사실을 선언하면 안 됩니다. 네트워크 partition, background browser, delayed heartbeat, proxy timeout 때문에 실제 사용 상태와 Socket 관측 상태가 일치하지 않을 수 있기 때문입니다.
권장 모델은:
```text
lastObservedAt
activeConnectionCount
lastHeartbeatAt
presenceState = ONLINE | IDLE | STALE | OFFLINE
```
처럼 **관측 시각이 포함된 상태**입니다.
Redis에는 Session 객체 자체가 아니라:
```text
actor fingerprint
connection count
node id
lastObservedAt
TTL
```
정도의 summary만 저장하는 것이 적절합니다.
## 런타임·Backpressure·Heartbeat·멀티인스턴스·STOMP 운영
### Servlet과 WebFlux 실행 모델
Servlet WebSocket에서는 동일 Session에 여러 thread가 동시에 write하는 구조를 피해야 합니다. Spring의 `ConcurrentWebSocketSessionDecorator`는 하나의 thread가 실제 send를 수행하도록 하고 send-time limit 및 buffer-size limit을 적용할 수 있습니다. Buffer overflow 처리 전략도 제공됩니다. citeturn12search0turn0search1
따라서 Servlet runtime은 다음 구조로 고정하는 것을 권장합니다.
```text
Application Event
SessionOutboundQueue
Serialized Writer
ConcurrentWebSocketSessionDecorator
Container Session
```
```text
Application thread
→ WebSocketSession.sendMessage 직접 호출
```
을 일반 API로 노출하지 않는 것이 좋습니다.
WebFlux에서는 `WebSocketSession.receive()`가 inbound `Flux<WebSocketMessage>`를, `send(Publisher<WebSocketMessage>)`가 outbound 완료를 나타내는 reactive API를 제공합니다. Reactor Netty 등의 pooled buffer를 async boundary 뒤까지 보관할 경우 DataBuffer retain/release 수명도 고려해야 합니다. citeturn15view0turn4view3turn4view4
다만 **Reactive Streams를 쓴다는 이유만으로 브라우저까지 end-to-end backpressure가 자동 제공된다고 선언하면 안 됩니다.** 브라우저 WebSocket API에는 reactive demand protocol이 없으므로 서버의 bounded buffering과 message-level 정책은 여전히 필요합니다. 브라우저가 송신할 때는 `bufferedAmount`로 아직 network에 전달되지 않은 application data byte 수를 관찰할 수 있습니다. citeturn7view0
### Slow Consumer 정책
모든 message type에 같은 overflow policy를 적용하면 안 됩니다.
| Message 성격 | Queue 초과 시 |
|---|---|
| 업무 Command 결과 | **Drop 금지**, disconnect + reconciliation/resume |
| 업무 상태 전이 Event | **Drop 금지**, disconnect + durable resume |
| Presence / Typing | `DROP` 또는 `COALESCE` 허용 |
| 최신 가격·상태 Snapshot | `COALESCE_BY_KEY` 가능 |
| Durable Event | local buffer 무한 확대 금지, connection close 후 cursor replay |
| Admin/security notice | 우선순위 Queue 또는 즉시 close |
권장 overflow policy catalog:
```text
DISCONNECT
DROP_LATEST
DROP_OLDEST
COALESCE_BY_KEY
SNAPSHOT_REQUIRED
```
`DROP_*`는 메시지 schema가 explicitly lossy라고 선언한 경우에만 허용해야 합니다.
Spring STOMP도 client outbound가 느릴 때 한 thread가 실제 send를 하고 추가 메시지가 buffer에 쌓이는 구조이며 `sendTimeLimit``sendBufferSizeLimit`을 제공하므로, 무제한 buffering을 피해야 합니다. Spring 문서는 `clientInboundChannel``clientOutboundChannel`의 thread pool 및 queue 설정 또한 성능에 직접 영향을 준다고 설명합니다. citeturn20search0
### 초기 Resource Budget
아래 값은 프로토콜 표준값이 아니라 **성능 시험을 시작하기 위한 Backend Skeleton 초기 profile 권고값**입니다. 서비스별 부하 시험 후 올리는 방식이 안전합니다.
| 항목 | Stable 시작값 후보 | 비고 |
|---|---:|---|
| Raw JSON assembled message | 64 KiB | 더 큰 payload는 별도 Profile |
| Binary message | 256 KiB | Advanced |
| STOMP inbound message | 64 KiB | Spring STOMP client 기본 inbound limit도 64 KiB citeturn19search0 |
| JSON nesting depth | 32 | Codec guard |
| Array elements | 1,000 | Message schema가 더 낮게 설정 가능 |
| String bytes | 32 KiB | field별 더 낮은 제한 권장 |
| Subscriptions / connection | 32 | Profile별 조정 |
| In-flight requests | 32 | 무제한 correlation 금지 |
| Outbound queue | 512 KiB + message count limit | byte와 count 모두 제한 |
| Send stall limit | 10 s 시작값 | 실제 proxy/network 시험 필요 |
| Global buffered bytes | 반드시 상한 | Heap 보호 |
| Connection / actor | endpoint별 명시 | browser multi-tab 고려 |
| Reconnect rate | actor·IP·tenant별 제한 | reconnect storm 방지 |
여기서 **Frame size, assembled WebSocket Message size, STOMP Message size, decoded JSON size, outbound queue size는 각각 별도 제한**이어야 합니다. Spring STOMP 자체도 WebSocket message를 조립해 더 큰 STOMP message를 구성할 수 있으며 이를 위해 별도의 message size limit을 제공합니다. citeturn20search0turn19search0
### Heartbeat 계층을 분리해야 한다
다음은 서로 같은 기능이 아닙니다.
```text
TCP Keepalive
WebSocket Ping/Pong
STOMP Heartbeat
Application Heartbeat
Nginx proxy_read_timeout
Presence TTL
```
RFC 6455에서 Ping을 받은 endpoint는 closing 상태가 아니라면 Pong으로 응답해야 하며 Ping/Pong은 keepalive와 peer responsiveness 확인에 사용할 수 있습니다. citeturn8view3
STOMP heartbeat는 `CONNECT``CONNECTED``heart-beat` 값으로 양측 송신 능력과 수신 희망 간격을 교환하고 각 방향의 실제 최소 간격을 계산합니다. citeturn21search0
Nginx 공식 WebSocket proxy 문서는 upstream server가 아무 데이터도 보내지 않으면 기본적으로 60초 후 연결을 종료한다고 설명하며, `proxy_read_timeout`을 늘리거나 WebSocket Ping을 주기적으로 보내 연결 활동과 생존 확인을 수행할 수 있다고 명시합니다. citeturn17search2turn16view1
따라서 설정 관계를 다음처럼 계약화해야 합니다.
```text
heartbeatInterval
< heartbeatTimeout
< proxyReadTimeout
```
예를 들어 실제 Profile을 `25s heartbeat / 55s failure / 75s proxy read`처럼 잡을 수 있지만, 구체 숫자는 Nginx·Ingress·LB·모바일 네트워크 시험 결과로 결정하는 편이 좋습니다.
Application heartbeat는 transport heartbeat와 별도로 정말 필요한 경우만 사용합니다. 예를 들어 “Client app event loop가 정상적으로 state를 적용하고 있는지”가 중요하면 app-level `PING/PONG` 또는 state watermark를 별도 정의할 수 있습니다.
### Browser outbound budget
브라우저의 `WebSocket.bufferedAmount``send()`한 데이터 중 아직 network로 전달되지 않은 byte 수를 표시하므로 Client SDK의 로컬 backpressure 신호로 유용합니다. 다만 이것은 상대 서버가 메시지를 받았다는 ACK가 아닙니다. citeturn7view0
Client SDK는 다음을 가져야 합니다.
```text
maxBufferedAmount
bounded command queue
message priority
expiresAt
retryability
idempotency requirement
offline queue policy
```
특히 offline 상태에서 mutation을 무제한 저장한 뒤 재접속 시 전부 보내면 오래된 Command가 뒤늦게 실행될 수 있으므로:
```text
expiresAt
+
idempotencyKey
+
explicit offline-capable flag
```
가 필요합니다.
### Compression
RFC 7692 `permessage-deflate`는 Opening Handshake에서 협상하는 per-message compression extension입니다. `server_no_context_takeover`, `client_no_context_takeover`, `server_max_window_bits`, `client_max_window_bits` 등의 파라미터로 양 방향 압축 context와 memory footprint를 제어할 수 있습니다. citeturn22search0
따라서 Stable 기본은:
```text
permessage-deflate = OFF
```
가 적절하고 다음을 측정한 Endpoint만 Opt-in하는 것이 좋습니다.
```text
bandwidth 절감률
CPU / connection
memory / connection
p95/p99 send latency
decompressed size
compression context memory
sensitive data + attacker-controlled input 위험
```
Binary Protobuf처럼 이미 compact한 payload는 compression 효율이 낮을 수 있으므로 Codec별 benchmark가 필요합니다.
### Multi-instance Session 구조
실제 socket은 연결을 받은 application instance가 소유하므로 구조를 다음처럼 나누는 것이 좋습니다.
```text
Node A
├─ actual WebSocketSession
├─ local subscription handlers
├─ local outbound queue
└─ local writer
External Registry
├─ connectionId → nodeId
├─ actor → active node summaries
├─ subscription summary
└─ TTL / lastObservedAt
Fan-out Capability
├─ ephemeral: Redis capability
└─ durable: Messaging capability
```
**Native Session 객체를 Redis에 직렬화하여 다른 Node로 이동시키는 구조는 금지**해야 합니다.
Sticky session도 다음 문제를 해결하지 못합니다.
```text
Pod restart
Node crash
Deployment
Reconnect
Resume history
Lost event
```
따라서 sticky routing은 최적화일 수 있어도 recovery contract가 되어서는 안 됩니다.
### STOMP 지원 범위
Spring의 Simple Broker는 시작하기 쉬우나 STOMP 명령의 subset만 지원하고 ACK·RECEIPT 등을 지원하지 않으며 clustering에 적합하지 않습니다. Spring은 production-scale broadcast를 위해 external broker relay를 별도 옵션으로 제공합니다. citeturn19search1turn20search0
| 기능 | Simple Broker | Broker Relay |
|---|---|---|
| Local Pub/Sub | 지원 | 지원 |
| `SEND`·`SUBSCRIBE` | 기본 지원 | Broker 지원 범위 |
| ACK | 제한/비지원 | Broker capability |
| RECEIPT | 제한/비지원 | Broker capability |
| Cluster | 부적합 | 가능 |
| Durable queue | 보장하지 않음 | Broker·Destination 설정에 따라 |
| Redelivery | 보장하지 않음 | Broker 설정에 따라 |
| DLQ | 없음 | Broker capability |
| Transaction | 제한 | Broker capability |
| User Destination | Spring 변환 가능 | Broker와 결합 검증 |
| 운영 권장 | Local/Test·단일 node | Advanced production |
Spring Broker Relay는 애플리케이션과 외부 Broker 사이에서 TCP 연결을 사용해 메시지를 양 방향 relay합니다. 따라서 Broker Relay를 채택할 때는 WebSocket socket 수뿐 아니라 Broker connection footprint, broker failover, heartbeat, reconnection, broker-side destination lifecycle을 별도로 부하 시험해야 합니다. citeturn19search1
Multi-server User Destination은 Spring의 user-destination/registry broadcast 기능을 이용해 다른 application server에 연결된 사용자를 찾는 구성이 가능하지만, 이 역시 Broker의 temporary queue 정리 및 destination semantics와 함께 검증해야 합니다. citeturn3search7turn3search3
## 오류·Close·Proxy·Shutdown·관측성 계약
### Error Message와 Close는 분리
Message 단위 오류가 발생했다고 항상 Connection을 끊는 것은 좋지 않습니다.
```text
Recoverable message error
→ ERROR message
→ Connection 유지
Connection-scoped fatal error
→ Close
```
가 기본 원칙이어야 합니다.
RFC/IANA WebSocket Close code 기준에서 주요 코드는 다음과 같습니다. citeturn9view2turn9view3turn8view0turn9view4turn21search1
| Close Code | 의미 | 플랫폼 사용 |
|---:|---|---|
| `1000` | Normal Closure | 정상 종료 |
| `1002` | Protocol Error | frame/subprotocol 위반 |
| `1003` | Unsupported Data | 지원하지 않는 data type |
| `1007` | Invalid Payload Data | invalid UTF-8 등 |
| `1008` | Policy Violation | 일반 protocol/security policy |
| `1009` | Message Too Big | size limit |
| `1011` | Internal Error | 예기치 못한 server failure |
| `1012` | Service Restart | rolling restart/drain |
| `1013` | Try Again Later | 일시 과부하 |
| `40004999` | Private Use | application close catalog |
IANA registry는 `1012`를 Service Restart, `1013`을 Try Again Later로 등록하고 `40004999`를 Private Use 범위로 둡니다. citeturn21search1turn21search3
권장 private catalog는 다음과 같습니다.
```text
4400 INVALID_MESSAGE
4401 AUTHENTICATION_REQUIRED
4403 ACCESS_DENIED
4408 HEARTBEAT_TIMEOUT
4409 DUPLICATE_CONNECTION
4422 VALIDATION_FAILED
4429 RATE_LIMITED
4503 OVERLOADED
```
다만 `VALIDATION_FAILED` 같은 Message 단위 오류는 일반적으로 Close보다 Typed `ERROR`가 우선입니다. Private Close는 “이 Connection을 더 이상 유지할 수 없는 이유”에 사용해야 합니다.
Browser WebSocket API에서 script가 직접 `close()`에 지정할 수 있는 code는 `1000` 또는 `30004999` 범위이며, reason은 UTF-8 기준 123 bytes 이하여야 합니다. 따라서 Client-visible close reason에는 stack trace·SQL·token·PII를 넣으면 안 됩니다. citeturn7view0
### Nginx와 TLS
Nginx reverse proxy에서 `Upgrade``Connection`은 hop-by-hop header이므로 upstream으로 자동 전달되지 않으며 WebSocket proxying을 위해 명시적으로 처리해야 합니다. Nginx 공식 구성도 `Upgrade``Connection`을 별도로 설정합니다. citeturn17search2
기본 계약은 다음처럼 두는 것이 좋습니다.
```nginx
location /ws/ {
proxy_pass http://backend;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# deployment profile에 맞춰 명시
proxy_read_timeout ...;
proxy_send_timeout ...;
}
```
Nginx 최신 문서 기준으로 `proxy_read_timeout` 기본값은 60초이며 “전체 응답 시간”이 아니라 두 successive read 사이 timeout입니다. WebSocket처럼 idle할 수 있는 장기 연결에서는 반드시 heartbeat profile과 정렬해야 합니다. citeturn16view0turn16view1
Forwarded header와 외부 URL 신뢰 모델은 `web` 모듈에서 이미 정한 정책을 재사용해야 합니다.
```text
Client
→ Host Nginx
→ untrusted Forwarded 제거
→ trusted X-Forwarded-* 재작성
→ Backend
→ web platform normalization
→ WebSocket handshake context
```
WebSocket이 독자적인 `X-Forwarded-For` parsing 로직을 만들면 `web` 플랫폼과 client IP·scheme·host 결과가 달라질 수 있습니다.
TLS 정책은 다음이 적절합니다.
```text
Local
→ ws 허용
Dev / Staging / Prod
→ wss 필수
TLS termination
→ Nginx 또는 trusted ingress
Backend plaintext
→ trusted internal network profile에서만
Origin / External Host
→ normalized web context 사용
```
### Graceful Shutdown
Spring Boot는 servlet/reactive server의 graceful shutdown을 제공하지만, **“Client가 어떤 sequence부터 다시 받아야 하는지”나 “어떤 WebSocket Command를 이제 받지 말아야 하는지”는 application protocol의 책임**입니다. 따라서 WebSocket 플랫폼은 별도의 drain state machine을 가져야 합니다. citeturn0search7
권장 흐름:
```text
Readiness OFF
→ 신규 Handshake Admission 차단
→ 기존 Connection 상태 DRAINING
→ SERVER_DRAINING Control Message
{
reconnectAfter,
resumeSupported,
deadline
}
→ 신규 Subscription 거부
→ 신규 mutation Command 거부
→ 이미 commit 중인 Command 제한 시간 처리
→ Outbound Queue 제한 시간 drain
→ Close 1012 Service Restart
→ shutdown deadline 초과 시 강제 close
```
Connection이 무기한 지속되는 것을 허용하기보다:
```text
maxConnectionAge
credentialExpiresAt
serverDrainDeadline
resumeWindow
```
를 두는 편이 Rolling Update 운영에 유리합니다.
### Outbound WebSocket Client
Backend가 외부 WebSocket provider와 연결하는 기능은 일반 `httpclient` retry profile을 그대로 사용하지 않는 것이 맞습니다. WebSocket client는 “RPC 재시도”가 아니라 **Connection 재수립 + Protocol 재협상 + Subscription 재등록 + Resume** 문제이기 때문입니다.
Spring WebFlux WebSocket client는 Reactor Netty, Tomcat, Jetty, 표준 Java WebSocket client 구현을 지원합니다. citeturn15view0
권장 Named profile:
```yaml
websocket:
clients:
market-feed:
uri: wss://provider.example/stream
protocol: provider.feed.v2
tls: provider-ca
connect-timeout: 5s
heartbeat: 20s
idle-timeout: 60s
max-message-size: 64KiB
max-buffered-bytes: 512KiB
reconnect:
min-backoff: 500ms
max-backoff: 30s
jitter: true
resume:
supported: true
```
Profile에는 최소:
```text
URI
TLS
Proxy
Subprotocol
Authentication
Handshake timeout
Heartbeat
Idle timeout
Max age
Message limits
Reconnect strategy
Resume strategy
Observability
```
를 포함해야 합니다.
### 관측성 모델
장기 연결 하나에 거대한 tracing span 하나를 유지하기보다 **Handshake span + message operation span + connection metric** 구조가 운영상 더 적합합니다.
권장 Metric:
| 영역 | Metric |
|---|---|
| Connection | active, opened, rejected, closed, duration, abnormal close |
| Heartbeat | ping, pong, timeout, suspected half-open |
| Reconnect | attempt, success, resume success/failure |
| Message | inbound/outbound count·bytes |
| Validation | schema failure, unknown type/version |
| Security | authentication/authorization reject |
| Application | started, committed, failed |
| Ordering | duplicate, sequence gap |
| Backpressure | queue bytes/messages, slow consumer, drop, coalesce |
| Subscription | active, rejected, event lag |
| STOMP | broker availability, receipt timeout, relay disconnect |
허용 tag:
```text
endpointProfile
protocol
protocolVersion
messageTypeCatalog
operationCatalog
closeCode
outcome
node
resumeOutcome
```
금지 tag:
```text
sessionId
connectionId
userId
tenantId raw
resourceId
messageId
subscriptionId
destination의 동적 부분
token
payload
```
Connection ID 같은 값은 로그 필드나 trace correlation에 제한적으로 사용할 수 있어도 Metric tag로 사용하면 cardinality가 폭증하므로 금지하는 것이 좋습니다.
Access log/event log 예:
```text
timestamp
connectionFingerprint
actorFingerprint
endpointProfile
protocol
node
event = OPEN | CLOSE | RESUME | DRAIN
closeCode
duration
bytesIn
bytesOut
```
Admin plane은:
```text
Connection Summary
Protocol Version Usage
Node별 active count
Slow Consumer count
Close Code distribution
Resume failure
Broker status
```
를 제공하되 payload/token/raw filters는 노출하지 않는 것이 좋습니다.
다음 관리 작업은 모두 Audit 대상입니다.
```text
Session Disconnect
Actor Session Disconnect
Tenant Drain
Endpoint Drain
Protocol Version Disable
Maintenance Broadcast
Resume State 수동 무효화
```
## 지원 등급·테스트 전략·구현 로드맵
### 최종 기능 지원 매트릭스
| Capability | 최종 권고 등급 | 완료 조건 |
|---|---|---|
| Raw JSON Typed WebSocket | **Stable** | Servlet·WebFlux contract + browser/proxy test |
| RequestResponse | **Stable** | correlation, timeout, cancellation, late response 정의 |
| Mutation Command | Stable 조건부 | Idempotency ledger 필수 |
| Typed Event | **Stable** | bounded queue + schema catalog |
| Server heartbeat | **Stable** | proxy timeout E2E 검증 |
| Exact Origin policy | **Stable 필수** | browser CSWSH test |
| Cookie/session auth | **Stable** | Origin + security integration |
| One-time ticket auth | **Stable 권장** | atomic single-use + TTL |
| Connection max age | **Stable** | reconnect/drain test |
| Session serialized writer | **Stable 필수** | concurrency stress test |
| Bounded outbound queue | **Stable 필수** | slow-consumer test |
| Sequence / Gap detection | **Stable for ordered streams** | duplicate/gap contract |
| Resume | **Advanced** | durable replay source 필요 |
| Snapshot fallback | **Advanced** | history-lost test |
| Subscription | **Advanced** | auth·limits·ordering 정의 |
| Application ACK | Advanced | 의미 명시 + ledger 필요 여부 결정 |
| STOMP 1.2 | **Advanced Stable** | protocol matrix |
| Simple Broker | Local/Test | cluster 사용 금지 |
| Broker Relay | Advanced | real broker fault test |
| Multi-node fan-out | Advanced | cross-node integration test |
| Presence | Advanced | TTL·stale semantics |
| Protobuf Binary | Advanced | generated client compatibility |
| CBOR | Advanced | 실제 client 수요가 있을 때 |
| `permessage-deflate` | Advanced Opt-in | CPU/memory/security benchmark |
| Outbound WS client | Advanced | reconnect/resume profile |
| SockJS | Legacy | 명시적 legacy requirement |
| HTTP/2 WS | Compatibility | end-to-end matrix |
| HTTP/3 WS | Experimental | end-to-end support 검증 |
| GraphQL WS semantics | **WebSocket에서 비소유** | GraphQL adapter만 |
| Durable Replay/DLQ | **비지원** | Messaging 사용 |
| Large file | **비지원** | Fileserver 사용 |
| Java serialization | **비지원** | — |
| WebSocket exactly-once | **비지원 선언** | idempotent business operation으로 대체 |
### 핵심 Contract Test
Mock WebSocketSession만으로 Stable을 선언해서는 안 됩니다. Spring의 실제 Servlet/WebFlux runtime, Nginx, TLS, browser를 모두 거쳐야 low-level connection semantics를 검증할 수 있습니다. Spring 자체도 Servlet WebSocket과 Reactive WebSocket의 실행 API가 다르고, STOMP에서는 별도 thread pools·buffers·broker relay가 개입합니다. citeturn17search0turn20search0turn15view0
**Handshake·Security**
```text
101 정상 연결
malformed handshake
unsupported subprotocol
missing subprotocol
Origin allowed / rejected
null Origin
Cookie session
expired session
one-time ticket success
ticket replay
ticket expiration
STOMP CONNECT token
CSRF CONNECT
Forwarded header spoof
Host spoof
connection rate limit
draining endpoint
```
Spring Security가 WebSocket에서 Same-Origin 방어와 STOMP CONNECT CSRF를 별도로 강조하므로 이 테스트는 Release Gate에 포함해야 합니다. citeturn17search1
**Message·Schema**
```text
normal JSON
binary
fragmentation
invalid UTF-8
malformed JSON
unknown message type
unknown schema version
unknown enum
oversized string
oversized array
deep JSON
assembled size overflow
compression
decompressed oversize
```
**Execution Evidence·Idempotency**
```text
FRAME_RECEIVED 전 disconnect
MESSAGE_VALIDATED 후 reject
APPLICATION_STARTED 후 failure
APPLICATION_COMMITTED 직후 socket reset
response queue 전 disconnect
write 시작 후 disconnect
동일 commandId 재전송
동일 idempotencyKey + 동일 fingerprint
동일 idempotencyKey + 다른 fingerprint
ledger PROCESSING 상태 crash
commit 후 reconnect + reconciliation
```
이 테스트가 플랫폼의 핵심 질문에 가장 직접적으로 답합니다.
**Ordering**
```text
동일 Session 병렬 inbound
동일 Subscription 병렬 event
preserveReceiveOrder off/on
preservePublishOrder off/on
cross-node event
duplicate sequence
missing sequence
out-of-order sequence
reconnect 경계 sequence
```
Spring STOMP의 ordering option이 기본 thread-pool reorder를 보완하는 기능이므로 해당 설정의 비용과 효과를 실제 throughput test에서 비교해야 합니다. citeturn20search1
**Backpressure**
```text
slow browser
blocked network
queue bytes cap
queue message cap
send time cap
DROP_LATEST
DROP_OLDEST
COALESCE_BY_KEY
critical message overflow
global buffer exhaustion
browser bufferedAmount 증가
WebFlux slow subscriber
```
Spring STOMP의 outbound send도 slow client에서 buffer가 증가할 수 있어 send-time과 buffer-size limit이 별도로 제공됩니다. citeturn20search0
**Heartbeat·Network**
```text
Ping/Pong 정상
Pong 손실
Server Ping 정지
half-open
Nginx proxy_read_timeout
TCP reset
mobile network switch
browser sleep
background tab
temporary packet loss
TLS termination restart
```
Nginx 기본 60초 idle timeout과 WebSocket Ping 사용 가능성을 실제 배포 설정에 맞춰 검증해야 합니다. citeturn17search2
**Resume**
```text
normal resume
new node resume
lastAppliedSequence 정상
duplicate event
sequence gap
history compacted
snapshot fallback
resume token expired
resume token replay
permission revoked
schema version changed
stream deleted
```
**Multi-instance**
```text
Node A client connection
Node B business event
A로 cross-node fan-out
Node A kill -9
Node C reconnect
registry TTL cleanup
stale registry entry
duplicate session registration
network partition between app and fan-out
```
**STOMP**
```text
CONNECT / CONNECTED
heartbeat negotiation
SEND
SUBSCRIBE / UNSUBSCRIBE
ACK auto
ACK client
ACK client-individual
NACK
RECEIPT
ERROR
broker disconnect
broker reconnect
simple broker limitation
external relay
user destination multi-node
ordered publication
```
STOMP ACK mode와 redelivery 의미는 규격 및 Broker별 capability를 함께 검증해야 합니다. citeturn21search0turn19search1
### 장애·보안·성능 Gate
성능 시험에서는 단순 messages/sec 하나만 보지 말고 다음을 함께 측정해야 합니다.
```text
Concurrent connections / node
Handshake RPS
Reconnect RPS
Idle connection heap
Idle connection direct memory
Thread count
Event-loop utilization
Inbound messages/sec
Outbound messages/sec
p50 / p95 / p99 message latency
queue bytes / connection
global buffered bytes
slow consumer ratio별 처리량
serialization CPU
compression CPU
broker relay latency
resume replay throughput
snapshot latency
GC pause
connection drain duration
```
특히 다음 부하 시나리오가 중요합니다.
```text
정상 Client 100%
slow Client 1%
slow Client 10%
slow Client 50%
동시에:
Node restart
Broker latency
Redis latency
Reconnect storm
```
Slow client 몇 개 때문에 전체 outbound thread pool이나 direct memory가 고갈되지 않는지 검증해야 합니다. Spring도 outbound 성능이 client network speed에 크게 영향을 받고 별도의 send/buffer limit이 필요하다고 설명합니다. citeturn20search0
Browser matrix는 최소:
```text
Chromium
Firefox
WebKit
Foreground
Background tab
Sleep / wake
Offline / online
Wi-Fi ↔ Mobile network
Browser close
Page navigation
```
까지 포함하는 것이 좋습니다.
Runtime matrix:
```text
Tomcat + Nginx + TLS
Jetty + Nginx + TLS
Reactor Netty + Nginx + TLS
```
를 Stable gate로 잡고, HTTP/2·HTTP/3는 별도 compatibility lane에서 검증합니다. Boot 4.1의 공식 servlet container 기준은 Tomcat 11.0.x와 Jetty 12.1.x이며 reactive server는 Reactor Netty·Tomcat·Jetty를 지원합니다. citeturn18search1turn18search3
### 단계별 구현 순서와 완료 조건
**기준선·경계 확정**
먼저 `websocket-core-api`, MVC/WebFlux starter의 상호 배타성, `web`·`security`·`messaging`·`redis`와의 의존 방향을 확정합니다.
완료 조건:
```text
Boot 4.1 BOM
Java 21
Tomcat / Reactor Netty 기본 profile
인접 모듈 dependency rule
금지 API architecture test
```
**Raw Typed Stable Runtime**
다음으로 STOMP 없이 Raw JSON부터 완성하는 것이 좋습니다.
```text
Handshake
Origin
Subprotocol
Connection Context
JSON Envelope
Message Catalog
RequestResponse
Error
Serialized Writer
Size Limit
Heartbeat
Observability
```
완료 조건은 실제 Chromium + Nginx + Tomcat/Reactor Netty에서 기본 시나리오가 통과하는 것입니다.
**실행 증거와 상태 변경 안전성**
플랫폼의 가장 중요한 단계입니다.
```text
Application Started evidence
Commit evidence abstraction
Idempotency capability bridge
Result ledger
Completion Unknown
Reconciliation
```
완료 조건:
```text
DB Commit 직후 Network reset
→ Client retry
→ business mutation은 1회
→ 이전 Result 회수 가능
```
이 시나리오가 자동화 테스트로 증명되어야 합니다.
**Backpressure·Ordering·Resource Budget**
```text
Bounded queue
Serialized writer
Slow consumer classification
Sequence
Gap detection
Ordering profile
Global buffer admission
Browser bufferedAmount policy
```
완료 조건은 slow-client stress 중에도 fast-client p99와 server memory가 설정된 범위에서 유지되고, critical event drop이 발생하지 않는 것입니다.
**Reconnect·Resume**
```text
resumeToken
lastAppliedSequence
durable event cursor
deduplication
gap detection
snapshot fallback
```
완료 조건은 Node A 강제 종료 후 Node B/C에 reconnect해도 중복 없이 최신 state로 수렴하는 것입니다.
**Multi-node·STOMP**
Raw protocol의 cluster path를 먼저 검증한 뒤 STOMP adapter를 붙이는 것이 좋습니다.
```text
External Session Index
Fan-out adapter
STOMP 1.2
Simple Broker local profile
External Broker Relay
User Destination
Broker outage
```
Spring Simple Broker는 clustering에 적합하지 않으므로 multi-node Stable 여부는 external fan-out 또는 Broker Relay 시험으로 판단해야 합니다. citeturn19search1turn20search0
**고급·호환 기능**
마지막에 다음을 추가합니다.
```text
Protobuf
CBOR
permessage-deflate
Outbound WebSocket Client
SockJS
HTTP/2 WebSocket
HTTP/3 WebSocket
Admin Plane
```
각 기능은 기본 Starter를 비대하게 만들지 않고 별도 module/profile로 승격합니다. Compression은 RFC 7692 협상·memory control과 실제 CPU/heap benchmark가 완료된 Endpoint만 활성화해야 합니다. citeturn22search0
### 최종 플랫폼 계약
이번 조사 결과를 가장 압축해서 표현하면 다음과 같습니다.
```text
WebSocket Runtime이 보장하는 것
=
연결 수명
+ 인증된 Connection Context
+ Typed Protocol 진입
+ bounded resource usage
+ serialized outbound write
+ heartbeat / disconnect
+ execution evidence 관측
+ reconnect / resume orchestration
```
그러나 다음은 보장하지 않습니다.
```text
WebSocket Frame 전송
≠ Business Commit
Business Commit
≠ Response Delivery
Response Delivery
≠ Client Applied
Connection Sequence
≠ Business Idempotency
STOMP RECEIPT
≠ Transaction Commit
STOMP ACK
≠ 보편적인 Durable Exactly-once
WebSocket Reconnect
≠ Stream Resume
Simple Broker
≠ Clustered Durable Broker
Presence OPEN
≠ 사용자가 실제 Online이라는 절대 사실
```
WebSocket 자체와 Spring의 low-level API가 제공하지 않는 이 의미들을 플랫폼이 명시적으로 분리해야 합니다. WebSocket은 content semantics를 규정하지 않는 transport이고, STOMP 역시 Destination과 reliability의 실제 의미를 server implementation에 맡기며, Spring Simple Broker 또한 ACK·Receipt와 clustering에 한계가 있습니다. citeturn17search0turn21search0turn19search1
따라서 최종 권고 구조는 다음입니다.
```text
┌───────────────────────┐
│ HTTP / Web │
│ Handshake, Proxy, Auth │
└───────────┬───────────┘
│ 101
┌─────────────────────────────────────────────────────┐
│ WebSocket Platform │
│ │
│ Connection Context │ Session │ Budget │ Heartbeat │
│ Security │ Ordering │ Backpressure │ Evidence │
│ Observability │ Drain │ Reconnect Coordination │
└───────────────┬───────────────────┬─────────────────┘
│ │
┌────────▼────────┐ ┌──────▼─────────┐
│ Raw Typed JSON │ │ STOMP Adapter │
│ / Protobuf │ │ Broker Relay │
└────────┬────────┘ └──────┬─────────┘
│ │
└────────┬──────────┘
Application Use Case
┌──────────────┼──────────────┐
▼ ▼ ▼
JPA/Mongo Messaging Redis
Commit Replay/DLQ Registry/TTL
│ │
└───────┬──────┘
Durable Execution Evidence
WebSocket Live Delivery
```
이 모델에서 **WebSocket은 “실시간 전달”을 소유하고, Application은 “상태 변경의 진실”을 소유하며, Messaging은 “내구성 있는 이벤트 이력”을 소유합니다.** 그 경계가 지켜져야 `APPLICATION_COMMITTED`, `RESPONSE_NOT_OBSERVED`, `CLIENT_APPLIED`, `RESUME_FROM_SEQUENCE`를 서로 혼동하지 않고 질문하신 핵심 문제—“서버에 도착했는가, 커밋됐는가, 프레임이 나갔는가, 클라이언트가 적용했는가, 어디부터 재개할 수 있는가”—에 각각 독립적인 증거로 답할 수 있습니다.