1770 lines
62 KiB
Markdown
1770 lines
62 KiB
Markdown
# WebSocket 실시간 양방향 연결 실행 플랫폼 심층 리서치
|
||
|
||
## 결론과 기술 기준선
|
||
|
||
이번 조사에서 가장 중요한 결론은 **WebSocket 모듈의 안정성을 “연결이 살아 있고 `sendMessage()`가 성공했는가”로 정의해서는 안 된다**는 것입니다. RFC 6455 WebSocket은 HTTP Upgrade 이후 양방향 메시지를 운반하는 저수준 프로토콜이고, 애플리케이션 메시지의 라우팅·업무 처리 완료·구독·ACK·재전송·Resume 의미를 정의하지 않습니다. Spring Framework 역시 WebSocket 자체는 메시지 내용의 의미를 정의하지 않으므로 STOMP 같은 subprotocol을 협상하거나 애플리케이션 규약을 별도로 만들어야 한다고 설명합니다. citeturn17search0turn7view2
|
||
|
||
따라서 권장 모델은 제시하신 **접근 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 구현에 따라 달라진다고 명시합니다. citeturn21search0
|
||
|
||
### 현재 기술 기준
|
||
|
||
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를 지원합니다. citeturn18search1
|
||
|
||
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가 적절합니다. citeturn18search0turn18search3
|
||
|
||
| 영역 | 조사 결론 | 플랫폼 등급 |
|
||
|---|---|---|
|
||
| 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에서 Client–Nginx/Ingress–Runtime 전체 경로 검증이 끝나지 않았으므로 플랫폼 기능 등급을 Experimental로 둔다**고 표현하는 것이 정확합니다. RFC 9220은 HTTP/3의 Extended CONNECT를 WebSocket에 적용합니다. citeturn22search1turn7view3
|
||
|
||
### 핵심 질문에 대한 답
|
||
|
||
한 메시지의 실행 상태는 다음 한 줄로 표현해서는 안 됩니다.
|
||
|
||
```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`을 업무 트랜잭션 커밋 증거로 바꾸어 해석하면 안 됩니다. citeturn11view1turn21search0
|
||
|
||
이 판단이 전체 플랫폼 설계의 중심축이어야 합니다.
|
||
|
||
## 책임 경계와 공개 계층·모듈 구조
|
||
|
||
### 인접 플랫폼과의 경계
|
||
|
||
제시하신 경계는 전반적으로 타당합니다. 특히 **Messaging과 WebSocket 사이의 경계가 가장 중요**합니다. Spring도 WebSocket을 HTTP와 다른 비동기 메시징 구조라고 설명하지만, 그것이 곧 durable messaging을 의미하지는 않습니다. STOMP 역시 Reliability와 Destination의 실제 의미를 서버별 구현에 맡깁니다. citeturn17search0turn21search0
|
||
|
||
| 인접 모듈 | 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별 설정에 달려 있습니다. citeturn21search0
|
||
|
||
### 공개 기능 계층
|
||
|
||
권장 공개 계층은 다음과 같습니다.
|
||
|
||
| 공개층 | 공개 대상 | 포함 기능 |
|
||
|---|---|---|
|
||
| **WS1 Standard Typed WebSocket** | 일반 애플리케이션 | Endpoint, JSON Typed Message, Request–Response, 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` 사용을 안내합니다. citeturn0search1turn12search0
|
||
|
||
### 권장 의존성 구조
|
||
|
||
제시한 모듈 분리는 그대로 채택할 가치가 높습니다.
|
||
|
||
```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 선택을 피하는 정책이 필요합니다. citeturn18search3
|
||
|
||
기본 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 이후에는 한 연결을 통해 애플리케이션 메시지가 계속 흐릅니다. citeturn17search0turn7view2
|
||
|
||
권장 파이프라인은 다음과 같습니다.
|
||
|
||
```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으로 통신합니다. citeturn8view2turn17search0
|
||
|
||
### Origin 검증은 필수 보안 경계
|
||
|
||
Spring Security 공식 문서는 브라우저의 WebSocket 연결에는 일반적인 Same Origin Policy가 자동 적용되지 않으므로 서버가 이를 명시적으로 보호해야 한다고 강조합니다. Cookie 인증 상태에서 Origin을 무제한으로 허용하면 다른 사이트가 사용자의 인증 상태를 이용하는 Cross-Site WebSocket Hijacking 문제가 생길 수 있습니다. Spring Security는 STOMP 구성에서 `CONNECT`에 CSRF token을 요구하는 방식을 제공합니다. citeturn17search1
|
||
|
||
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하는 형태를 막아야 한다고 설명합니다. citeturn17search1
|
||
|
||
### 브라우저 인증 Profile
|
||
|
||
브라우저 표준 `WebSocket` 생성 인터페이스는 URL과 subprotocol을 중심으로 제공되며 애플리케이션이 일반 HTTP client처럼 임의의 `Authorization` 헤더를 자유롭게 추가하는 인터페이스는 제공하지 않습니다. 반면 Handshake는 브라우저 credential 정책에 따라 Cookie 등의 인증정보를 사용할 수 있습니다. citeturn7view0turn5search1
|
||
|
||
따라서 다음과 같이 분리하는 것이 좋습니다.
|
||
|
||
| 인증 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`에서 처리할 수 있습니다. citeturn17search1turn5search1
|
||
|
||
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를 통해 선택하는 것을 지원합니다. citeturn8view2turn17search0
|
||
|
||
권장 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` | Request–Response 연결 |
|
||
| `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는 서버 구현에 의존합니다. citeturn11view1turn11view2turn21search0
|
||
|
||
따라서 플랫폼 용어를 다음과 같이 분리해야 합니다.
|
||
|
||
```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)`를 제공하지만, 순서 보장에는 성능 비용이 있다고 명시합니다. citeturn20search1
|
||
|
||
따라서 다음 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을 엄격히 보호하는 방식을 설명합니다. citeturn17search1
|
||
|
||
민감한 스트림에서 권한 회수가 즉시 반영되어야 한다면 다음 중 하나가 필요합니다.
|
||
|
||
```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이 복구를 정의해야 합니다. citeturn7view2turn17search0
|
||
|
||
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 처리 전략도 제공됩니다. citeturn12search0turn0search1
|
||
|
||
따라서 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 수명도 고려해야 합니다. citeturn15view0turn4view3turn4view4
|
||
|
||
다만 **Reactive Streams를 쓴다는 이유만으로 브라우저까지 end-to-end backpressure가 자동 제공된다고 선언하면 안 됩니다.** 브라우저 WebSocket API에는 reactive demand protocol이 없으므로 서버의 bounded buffering과 message-level 정책은 여전히 필요합니다. 브라우저가 송신할 때는 `bufferedAmount`로 아직 network에 전달되지 않은 application data byte 수를 관찰할 수 있습니다. citeturn7view0
|
||
|
||
### 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 설정 또한 성능에 직접 영향을 준다고 설명합니다. citeturn20search0
|
||
|
||
### 초기 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 citeturn19search0 |
|
||
| 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을 제공합니다. citeturn20search0turn19search0
|
||
|
||
### 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 확인에 사용할 수 있습니다. citeturn8view3
|
||
|
||
STOMP heartbeat는 `CONNECT`와 `CONNECTED`의 `heart-beat` 값으로 양측 송신 능력과 수신 희망 간격을 교환하고 각 방향의 실제 최소 간격을 계산합니다. citeturn21search0
|
||
|
||
Nginx 공식 WebSocket proxy 문서는 upstream server가 아무 데이터도 보내지 않으면 기본적으로 60초 후 연결을 종료한다고 설명하며, `proxy_read_timeout`을 늘리거나 WebSocket Ping을 주기적으로 보내 연결 활동과 생존 확인을 수행할 수 있다고 명시합니다. citeturn17search2turn16view1
|
||
|
||
따라서 설정 관계를 다음처럼 계약화해야 합니다.
|
||
|
||
```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가 아닙니다. citeturn7view0
|
||
|
||
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를 제어할 수 있습니다. citeturn22search0
|
||
|
||
따라서 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를 별도 옵션으로 제공합니다. citeturn19search1turn20search0
|
||
|
||
| 기능 | 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을 별도로 부하 시험해야 합니다. citeturn19search1
|
||
|
||
Multi-server User Destination은 Spring의 user-destination/registry broadcast 기능을 이용해 다른 application server에 연결된 사용자를 찾는 구성이 가능하지만, 이 역시 Broker의 temporary queue 정리 및 destination semantics와 함께 검증해야 합니다. citeturn3search7turn3search3
|
||
|
||
## 오류·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 기준에서 주요 코드는 다음과 같습니다. citeturn9view2turn9view3turn8view0turn9view4turn21search1
|
||
|
||
| 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 | 일시 과부하 |
|
||
| `4000–4999` | Private Use | application close catalog |
|
||
|
||
IANA registry는 `1012`를 Service Restart, `1013`을 Try Again Later로 등록하고 `4000–4999`를 Private Use 범위로 둡니다. citeturn21search1turn21search3
|
||
|
||
권장 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` 또는 `3000–4999` 범위이며, reason은 UTF-8 기준 123 bytes 이하여야 합니다. 따라서 Client-visible close reason에는 stack trace·SQL·token·PII를 넣으면 안 됩니다. citeturn7view0
|
||
|
||
### Nginx와 TLS
|
||
|
||
Nginx reverse proxy에서 `Upgrade`와 `Connection`은 hop-by-hop header이므로 upstream으로 자동 전달되지 않으며 WebSocket proxying을 위해 명시적으로 처리해야 합니다. Nginx 공식 구성도 `Upgrade`와 `Connection`을 별도로 설정합니다. citeturn17search2
|
||
|
||
기본 계약은 다음처럼 두는 것이 좋습니다.
|
||
|
||
```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과 정렬해야 합니다. citeturn16view0turn16view1
|
||
|
||
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을 가져야 합니다. citeturn0search7
|
||
|
||
권장 흐름:
|
||
|
||
```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 구현을 지원합니다. citeturn15view0
|
||
|
||
권장 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 |
|
||
| Request–Response | **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가 개입합니다. citeturn17search0turn20search0turn15view0
|
||
|
||
**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에 포함해야 합니다. citeturn17search1
|
||
|
||
**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에서 비교해야 합니다. citeturn20search1
|
||
|
||
**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이 별도로 제공됩니다. citeturn20search0
|
||
|
||
**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 사용 가능성을 실제 배포 설정에 맞춰 검증해야 합니다. citeturn17search2
|
||
|
||
**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를 함께 검증해야 합니다. citeturn21search0turn19search1
|
||
|
||
### 장애·보안·성능 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이 필요하다고 설명합니다. citeturn20search0
|
||
|
||
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를 지원합니다. citeturn18search1turn18search3
|
||
|
||
### 단계별 구현 순서와 완료 조건
|
||
|
||
**기준선·경계 확정**
|
||
|
||
먼저 `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
|
||
Request–Response
|
||
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 시험으로 판단해야 합니다. citeturn19search1turn20search0
|
||
|
||
**고급·호환 기능**
|
||
|
||
마지막에 다음을 추가합니다.
|
||
|
||
```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만 활성화해야 합니다. citeturn22search0
|
||
|
||
### 최종 플랫폼 계약
|
||
|
||
이번 조사 결과를 가장 압축해서 표현하면 다음과 같습니다.
|
||
|
||
```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에 한계가 있습니다. citeturn17search0turn21search0turn19search1
|
||
|
||
따라서 최종 권고 구조는 다음입니다.
|
||
|
||
```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`를 서로 혼동하지 않고 질문하신 핵심 문제—“서버에 도착했는가, 커밋됐는가, 프레임이 나갔는가, 클라이언트가 적용했는가, 어디부터 재개할 수 있는가”—에 각각 독립적인 증거로 답할 수 있습니다. |