지금까지(01~03장)는 "프레임을 주고받는" 저수준 채널을 다뤘다. 하지만 실시간 애플리케이션은 보통 더 높은 수준의 의미를 원한다 — "이 메시지를 /topic/prices 구독자 전원에게 보내라", "이 사용자에게만 보내라". 이런 목적지 기반 메시징을 WebSocket 위에서 표준적으로 표현하는 것이 STOMP(Simple Text Oriented Messaging Protocol) 서브프로토콜이다.
spring-websocket의 messaging 패키지는 WebSocket 프레임을 STOMP로 해석하고, 그것을 spring-messaging의 메시지 채널/브로커 세계로 연결하는 다리다. 핵심 통찰: 이 패키지는 STOMP 라우팅을 스스로 하지 않는다. WebSocket 프레임 ↔ spring-messaging의 Message를 번역하는 경계만 담당하고, 실제 라우팅(목적지 매칭, 구독 관리, 브로커 릴레이)은 spring-messaging에 위임한다.
WebSocket 세계 spring-messaging 세계
┌──────────────────┐ ┌──────────────────────────┐
│ WebSocketSession │ │ clientInboundChannel │
│ (프레임) │ │ clientOutboundChannel │
└────────┬─────────┘ │ (Message<byte[]>) │
│ └──────────────────────────┘
▼ ▲
┌─────────────────────────────────────────────┐ │
│ SubProtocolWebSocketHandler │────┘ 양방향 브리지
│ - WebSocketHandler (프레임 ← 클라이언트) │
│ - MessageHandler (메시지 → 클라이언트) │
│ - Map<protocol, SubProtocolHandler> │
└────────────────────┬────────────────────────┘
│ 프로토콜별 위임
▼
┌─────────────────────────────────────────────┐
│ SubProtocolHandler │
│ handleMessageFromClient(...) → outChannel │
│ handleMessageToClient(session, msg) │
│ └ StompSubProtocolHandler │ STOMP 1.0/1.1/1.2
└─────────────────────────────────────────────┘
│ 인코딩/디코딩 │ 이벤트 발행
▼ ▼
StompEncoder / StompDecoder Session{Connect,Connected,
(spring-messaging) Subscribe,Unsubscribe,Disconnect}Event
SubProtocolHandler는 WebSocket 메시지와 spring-messaging의 Message를 양방향으로 번역하는 계약이다.
List<String> getSupportedProtocols();
void handleMessageFromClient(WebSocketSession session, WebSocketMessage<?> message, MessageChannel outputChannel);
void handleMessageToClient(WebSocketSession session, Message<?> message);
String resolveSessionId(Message<?> message);
void afterSessionStarted(WebSocketSession session, MessageChannel outputChannel);
void afterSessionEnded(WebSocketSession session, CloseStatus closeStatus, MessageChannel outputChannel);handleMessageFromClient는 클라이언트가 보낸 WebSocket 프레임을 해석해 Message로 만들어 출력 채널(clientInboundChannel)로 보내고, handleMessageToClient는 반대로 애플리케이션이 보낸 Message를 WebSocket 프레임으로 인코딩해 세션에 쓴다. resolveSessionId는 메시지에서 대상 세션 ID를 뽑아낸다 — 아웃바운드 라우팅의 열쇠다.
SubProtocolWebSocketHandler는 이 모듈에서 가장 중요한 클래스 중 하나다. 두 인터페이스를 동시에 구현한다.
WebSocketHandler— 클라이언트→애플리케이션 방향. WebSocket 콜백을 받아 적절한SubProtocolHandler로 위임.MessageHandler— 애플리케이션→클라이언트 방향.clientOutboundChannel을 구독해 메시지를 받아 해당 세션으로 전달.
생성자는 인바운드/아웃바운드 두 채널을 받는다.
public SubProtocolWebSocketHandler(MessageChannel clientInboundChannel,
SubscribableChannel clientOutboundChannel) { ... }SmartLifecycle.start()에서 아웃바운드 채널을 구독(clientOutboundChannel.subscribe(this))하고, stop()에서 구독 해제 후 모든 활성 세션을 GOING_AWAY로 닫는다.
인바운드(클라이언트 → 애플리케이션):
public void afterConnectionEstablished(WebSocketSession session) {
checkSessions(); // 유휴 세션 정리(아래)
session = decorateSession(session); // ConcurrentWebSocketSessionDecorator로 감쌈
this.sessions.put(session.getId(), new WebSocketSessionHolder(session));
findProtocolHandler(session).afterSessionStarted(session, this.clientInboundChannel);
}
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) {
...
SubProtocolHandler protocolHandler = findProtocolHandler(session);
protocolHandler.handleMessageFromClient(session, message, this.clientInboundChannel);
holder.setHasHandledMessages();
}세션이 열리면 ConcurrentWebSocketSessionDecorator로 감싼다(01장). STOMP 메시지는 여러 스레드에서 동시에 한 세션으로 푸시될 수 있으므로 전송 직렬화가 필수다. 그 다음 findProtocolHandler로 협상된 서브프로토콜에 맞는 핸들러를 골라 위임한다 — 협상된 프로토콜이 없고 핸들러가 하나뿐이거나 기본 핸들러가 설정돼 있으면 그것을 쓰고, 여러 개인데 협상이 안 됐으면 예외.
아웃바운드(애플리케이션 → 클라이언트):
public void handleMessage(Message<?> message) { // MessageHandler
String sessionId = resolveSessionId(message); // 어느 세션으로?
WebSocketSessionHolder holder = this.sessions.get(sessionId);
if (holder == null) return; // 세션 이미 사라짐
findProtocolHandler(session).handleMessageToClient(session, message);
// SessionLimitExceededException → 통계 기록 후 세션 강제 종료
}아웃바운드 채널에서 받은 메시지의 세션 ID를 풀어 해당 세션을 찾고, 프로토콜 핸들러로 클라이언트에 보낸다. 전송 한도 초과(SessionLimitExceededException, 01장의 동시성 데코레이터가 던짐)면 통계를 올리고 세션을 정리·종료한다.
유휴 세션 보호(checkSessions / timeToFirstMessage): WebSocket 연결은 맺어졌지만(프록시 문제 등으로) STOMP 프레임이 한 번도 흐르지 않으면, 상위 프로토콜이 협상되지 않은 채 좀비 세션이 매달린다. SubProtocolWebSocketHandler는 연결 후 timeToFirstMessage(기본 60초) 동안 메시지를 한 번도 못 받은 세션을 SESSION_NOT_RELIABLE로 닫는다. 그래서 핸들러가 WebSocketSessionHolder에 hasHandledMessages 플래그를 둔다.
통계(Stats): 내부 DefaultStats는 세션을 WebSocket / HTTP-streaming / HTTP-polling으로 분류해(03장의 StreamingSockJsSession/PollingSockJsSession을 instanceof로 판별) 카운트하고, 비정상 종료(전송 한도 초과, 무메시지 종료, 전송 오류)도 집계한다. 운영 모니터링의 기반이다.
StompSubProtocolHandler는 SubProtocolHandler의 STOMP 구현으로 STOMP 1.0/1.1/1.2를 지원한다. 인바운드에서는 BufferingStompDecoder로 WebSocket 프레임을 STOMP 프레임으로 디코딩하고, 아웃바운드에서는 StompEncoder(또는 큰 프레임 분할용 SplittingStompEncoder)로 인코딩한다.
중요한 세부:
- 메시지 분할 처리 — STOMP 클라이언트(stomp.js 등)는 큰 STOMP 메시지를 16K 경계에서 여러 WebSocket 프레임으로 쪼갠다. 그래서 서버 입력 버퍼는 최소
MINIMUM_WEBSOCKET_MESSAGE_SIZE(16K + 256)를 허용해야 하며, 핸들러는BufferingStompDecoder를 세션별(Map<String, BufferingStompDecoder>)로 두어 조각을 재조립한다. - CONNECT/CONNECTED — 클라이언트의 STOMP
CONNECT(또는STOMP) 프레임을 받으면 인증된 사용자 이름을CONNECTED프레임의user-name헤더(CONNECTED_USER_HEADER)에 실어 응답하도록 처리한다. - 이벤트 발행 —
ApplicationEventPublisherAware로 이벤트 퍼블리셔를 주입받아, STOMP 생명주기마다 애플리케이션 이벤트를 쏜다(아래). - 오류 처리 —
StompSubProtocolErrorHandler(SubProtocolErrorHandler구현)로 클라이언트에 보내는 ERROR 프레임을 커스터마이징하거나 억제할 수 있다.
messaging 패키지는 STOMP 세션의 사건을 Spring 애플리케이션 이벤트로 노출한다. 모두 AbstractSubProtocolEvent(원본 Message<byte[]>와 Principal 보유)를 상속한다.
AbstractSubProtocolEvent
├ SessionConnectEvent 클라이언트 CONNECT 수신
├ SessionConnectedEvent 서버가 CONNECTED로 응답(연결 확립)
├ SessionSubscribeEvent SUBSCRIBE 수신
├ SessionUnsubscribeEvent UNSUBSCRIBE 수신
└ SessionDisconnectEvent 세션 종료
@EventListener로 이들을 구독하면 접속 사용자 추적, 감사 로깅, 입장/퇴장 알림 같은 기능을 비침투적으로 붙일 수 있다.
DefaultSimpUserRegistry(spring-messaging의 SimpUserRegistry 구현)는 위 이벤트들을 수신해 현재 접속 사용자와 그들의 세션·구독을 추적한다. "특정 사용자에게 보내기"(user destination)나 접속자 목록 조회의 백본이다.
[클라이언트] SEND /app/greet ──WebSocket frame──▶ SubProtocolWebSocketHandler.handleMessage
│ findProtocolHandler → STOMP
▼
StompSubProtocolHandler.handleMessageFromClient
│ 디코딩 → Message<byte[]>
▼
clientInboundChannel.send(message)
│
┌───────────────────────────────────────┴──────────────────────┐
▼ (spring-messaging 영역)
SimpAnnotationMethodMessageHandler → @MessageMapping 메서드 실행
(WebSocketAnnotationMethodMessageHandler) 반환값을 brokerChannel로 발행
│
▼
SimpleBroker / StompBrokerRelay → 구독자(/topic/...) 매칭
│
▼
clientOutboundChannel.send(message)
│
▼
SubProtocolWebSocketHandler.handleMessage(Message) ← MessageHandler로 구독 중
│ resolveSessionId → 대상 세션
▼
StompSubProtocolHandler.handleMessageToClient → 인코딩 → session.sendMessage
│
▼
[클라이언트] MESSAGE 프레임 수신 ◀──WebSocket frame──
SubProtocolWebSocketHandler와 StompSubProtocolHandler가 다루는 것은 이 그림의 양 끝(WebSocket ↔ 채널)뿐이다. 가운데의 @MessageMapping 디스패치, 브로커, 구독 매칭은 전부 spring-messaging이 맡는다. 다만 이 모듈은 STOMP 환경에 특화된 WebSocketAnnotationMethodMessageHandler를 제공해, @MessageMapping 핸들러가 WebSocket 세션 속성·헤더에 접근할 수 있게 보강한다.
SubProtocolWebSocketHandler.afterConnectionEstablished— 세션을 동시성 데코레이터로 감싸 등록하고 프로토콜 핸들러에 시작을 알림 + 유휴 세션 청소.SubProtocolWebSocketHandler.handleMessage(WebSocketMessage)/handleMessage(Message)— 인바운드/아웃바운드 두 방향의 위임 진입점.findProtocolHandler— 협상된 서브프로토콜로 핸들러 선택, 데코레이터를 벗겨getAcceptedProtocol조회.StompSubProtocolHandler.handleMessageFromClient/handleMessageToClient— STOMP 프레임 ↔Message인코딩/디코딩과 이벤트 발행.
- 경계만 담당, 라우팅은 위임: 이 패키지는 WebSocket ↔
spring-messaging번역에 집중하고, 목적지 라우팅·브로커는spring-messaging에 맡겨 관심사를 깔끔히 분리한다. - 서브프로토콜 다형성:
SubProtocolWebSocketHandler는 프로토콜별SubProtocolHandler맵을 들고 있어 STOMP 외 다른 서브프로토콜도 같은 골격에 끼울 수 있다. - 운영 안전장치 내장: 동시 전송 직렬화, 전송 한도 초과 시 종료, 무메시지 좀비 세션 정리, 세분화된 통계가 기본 탑재.
- 이벤트 기반 관찰: 접속/구독/종료를 애플리케이션 이벤트로 노출해 사용자 추적·감사를 비침투적으로 구현.
STOMP over WebSocket은 두 인터페이스를 겸하는 SubProtocolWebSocketHandler가 WebSocket 세계와 spring-messaging 채널 세계를 양방향으로 잇는 다리다. 인바운드 프레임은 StompSubProtocolHandler가 디코딩해 clientInboundChannel로 보내고, 아웃바운드 메시지는 clientOutboundChannel을 구독해 세션 ID로 대상을 찾아 인코딩해 내보낸다. 실제 목적지 라우팅은 spring-messaging이 처리하며, 이 모듈은 번역·세션 관리·운영 안전장치·생명주기 이벤트를 책임진다.