이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 스물다섯 번째 글입니다. 24편까지는 요청 하나에 응답 하나로 끝나는 HTTP를 봤습니다. 이번 글은 1편의 지도에서 Phase 5(웹)의 마지막 단위 U23, 연결을 열어 둔 채 양쪽이 아무 때나 말을 거는 WebSocket과 그 위에서 메시지를 나르는 메시징입니다. 채팅방에 한 사람이 쓴 글이 방에 있는 모두의 화면에 뜨는 일이 Spring 안에서 어떻게 일어나는지를 따라갑니다.
답하려는 질문은 네 가지입니다.
- HTTP 요청은 어디에서 WebSocket 연결로 바뀌는가
- 클라이언트가 보낸 글자 덩어리(STOMP 프레임)는 누가 Spring의
Message객체로 바꾸고, 어디로 보내는가 @MessageMapping메서드가 돌려준 값은 어떤 길을 거쳐/topic/..을 구독한 사람들에게 가는가- 서버 코드에서
SimpMessagingTemplate.convertAndSend를 부르면 그 메시지는 어느 채널로 들어가는가
| 항목 | 내용 |
|---|---|
| 기준 소스 | Spring Framework 7.1.0-SNAPSHOT |
| 커밋 | f447f3c310 (2026-10-01, main 브랜치) |
| 주로 읽는 모듈 | spring-messaging, spring-websocket |
| 주로 읽는 클래스 | ExecutorSubscribableChannel, SubProtocolWebSocketHandler, StompSubProtocolHandler, SimpAnnotationMethodMessageHandler, SimpleBrokerMessageHandler, AbstractMessageBrokerConfiguration |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.
이 글은 WebSocket과 STOMP를 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
WebSocket은 브라우저와 서버가 연결 하나를 열어 두고 양쪽 모두 아무 때나 데이터를 보낼 수 있게 하는 통신 방식입니다. HTTP는 클라이언트가 물어야 서버가 답하지만, WebSocket에서는 서버가 먼저 말을 걸 수 있습니다. 채팅이나 실시간 알림에 쓰입니다.
핸드셰이크(handshake)는 그 연결을 여는 절차입니다. WebSocket 연결은 평범한 HTTP 요청으로 시작합니다. 클라이언트가 "이 연결을 WebSocket으로 바꾸자"는 헤더(Upgrade: websocket)를 실어 보내고 서버가 받아들이면, 같은 연결이 그때부터 WebSocket으로 쓰입니다.
STOMP는 WebSocket 위에서 주고받는 글자의 형식을 정한 약속입니다. WebSocket 자체는 "글자 덩어리를 보낸다"까지만 정하고 그 내용이 무슨 뜻인지는 정하지 않습니다. STOMP는 그 덩어리를 프레임(frame)이라고 부르고, 첫 줄에 명령(CONNECT, SUBSCRIBE, SEND, MESSAGE 등), 그 아래에 이름:값 헤더들, 빈 줄, 본문 순서로 적게 합니다. 이렇게 WebSocket 위에 얹는 약속을 서브프로토콜(sub-protocol)이라고 합니다.
목적지(destination)는 STOMP 프레임의 destination 헤더에 적는 주소입니다. /topic/greetings처럼 경로 모양이지만 URL은 아니고, "이 메시지를 어디로 보낼지" 또는 "어디 것을 받아 볼지"를 가리키는 이름입니다.
발행/구독(pub/sub)은 보내는 쪽이 받는 사람을 지목하지 않고 목적지에 메시지를 내놓으면(발행), 그 목적지를 받아 보겠다고 미리 말해 둔(구독) 모두에게 전달되는 방식입니다. 누가 무엇을 구독했는지 기억하고 나눠 주는 역할을 브로커(broker)라고 합니다.
핸들러 메서드는 요청이 오면 Spring이 대신 불러 주는 우리 메서드입니다. HTTP에서는 @RequestMapping이 붙은 메서드였고, Spring이 그 메서드를 찾고 인자를 채워 부르는 과정은 22편에서 봤습니다. 이 글의 @MessageMapping은 같은 구조를 메시지에 쓴 것입니다.
1. 바닥 하나: Message, 채널, 핸들러
spring-messaging 모듈은 WebSocket을 모릅니다. 이 모듈의 바닥에는 "메시지를 어딘가에 넣으면 누군가 받아 처리한다"는 아주 일반적인 인터페이스 세 개가 있습니다.
public interface Message<T> {
/**
* Return the message payload.
*/
T getPayload();
/**
* Return message headers for the message (never {@code null} but may be empty).
*/
MessageHeaders getHeaders();
}
spring-messaging/src/main/java/org/springframework/messaging/Message.java 28–40행
Message는 본문(payload)과 헤더(MessageHeaders) 두 가지뿐입니다. MessageHeaders는 Map<String, Object>인데 고칠 수 없게 막혀 있습니다. put을 부르면 예외가 납니다(MessageHeaders 265–268행). 만들 때 id와 timestamp 헤더가 자동으로 들어갑니다(136–158행).
나머지 둘은 구현할 메서드가 하나씩입니다(MessageChannel에는 제한 시간을 받는 send(message, timeout)이 하나 있고, 아래 표의 send(message)는 그것을 부르는 default 메서드입니다).
| 인터페이스 | 메서드 | 뜻 |
|---|---|---|
MessageChannel |
send(message) |
메시지를 넣는 구멍 |
MessageHandler |
handleMessage(message) |
메시지를 받아 처리하는 쪽 |
SubscribableChannel |
subscribe(handler), unsubscribe(handler) |
핸들러를 붙였다 뗄 수 있는 채널 |
보내는 쪽은 채널만 알고, 받는 쪽이 누구인지 몇 명인지 모릅니다. 이 글에 나오는 큰 부품들은 전부 MessageHandler이고, 서로를 직접 부르지 않고 채널에 메시지를 넣는 것으로만 이어집니다.
12편에서 본 ApplicationEvent와 닮았지만 다릅니다. 이벤트는 컨테이너 하나에 길이 하나(멀티캐스터)이고 리스너를 이벤트 타입으로 고릅니다. 채널은 여러 개를 만들어 길을 나눌 수 있고, 채널에 붙은 핸들러는 모두 메시지를 받은 뒤 헤더(주로 목적지)를 보고 자기 일인지 스스로 정합니다.
send 한 번에 일어나는 일
이 글에 나오는 채널은 전부 ExecutorSubscribableChannel 한 클래스입니다. send는 부모인 AbstractMessageChannel에 있고(128–157행), preSend → sendInternal → postSend → afterSendCompletion 순서로 부릅니다. 앞뒤로 끼어드는 것이 **ChannelInterceptor**입니다(35–95행). preSend가 null을 돌려주면 그 자리에서 false로 끝나고 핸들러는 불리지 않습니다(177–193행). 인증처럼 "이 메시지를 들여보낼지"를 정하는 일을 여기에 겁니다.
실제로 핸들러를 부르는 sendInternal은 짧습니다.
@Override
public boolean sendInternal(Message<?> message, long timeout) {
for (MessageHandler handler : getSubscribers()) {
SendTask sendTask = new SendTask(message, handler);
if (this.executor != null) {
try {
this.executor.execute(sendTask);
}
catch (RejectedExecutionException ex) {
// Probably on shutdown -> run send task locally instead
sendTask.run();
}
}
else {
// No executor configured -> always run send tasks locally
sendTask.run();
}
}
return true;
}
구독한 핸들러마다 SendTask를 하나 만들고, 채널에 Executor(스레드 풀)가 있으면 거기에 맡기고 없으면 그 자리에서 실행합니다. 구독자 목록은 CopyOnWriteArraySet입니다(AbstractSubscribableChannel 34행).
파란색은 인터셉터가 끼어드는 자리입니다. Executor가 있는 채널에서는 send가 돌아온 뒤에 핸들러가 불릴 수 있습니다.
예제 1: 채널 하나에 인터셉터와 핸들러를 붙여 보기
웹 서버 없이 채널만 만들어 돌려 봤습니다. 이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 아래 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다). PrintingInterceptor는 훅이 불릴 때마다 스레드 이름과 함께 한 줄을 찍고, BlockingInterceptor는 본문이 "차단"이면 preSend에서 null을 돌려줍니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
ExecutorSubscribableChannel direct = new ExecutorSubscribableChannel();
direct.addInterceptor(new PrintingInterceptor());
direct.subscribe(message -> log("핸들러 A " + message.getPayload()));
direct.subscribe(message -> log("핸들러 B " + message.getPayload()));
boolean sent = direct.send(hello);
// ...
ExecutorSubscribableChannel async = new ExecutorSubscribableChannel(pool);
async.addInterceptor(new PrintingInterceptor());
async.subscribe(message -> {
log("핸들러 A " + message.getPayload());
handled.countDown();
});
// ...
sent = async.send(hello);
// ...
ExecutorSubscribableChannel guarded = new ExecutorSubscribableChannel();
guarded.addInterceptor(new BlockingInterceptor());
guarded.subscribe(message -> log("핸들러 A " + message.getPayload()));
System.out.println(" send(\"통과\") 반환값 = " + guarded.send(MessageBuilder.withPayload("통과").build()));
System.out.println(" send(\"차단\") 반환값 = " + guarded.send(MessageBuilder.withPayload("차단").build()));
메시지의 헤더 키: [destination, id, timestamp]
헤더를 고치려 하면: MessageHeaders is immutable
== 1) Executor 없는 채널: 보낸 스레드가 핸들러까지 부른다 ==
[main ] preSend payload=안녕
[main ] beforeHandle
[main ] 핸들러 A 안녕
[main ] afterMessageHandled
[main ] beforeHandle
[main ] 핸들러 B 안녕
[main ] afterMessageHandled
[main ] postSend sent=true
[main ] afterSendCompletion
send() 반환값 = true
== 2) Executor 있는 채널: 핸들러는 다른 스레드에서 돈다 ==
[main ] preSend payload=안녕
[main ] postSend sent=true
[main ] afterSendCompletion
send() 반환값 = true (핸들러는 아직 불리지 않았다)
[pool-1] beforeHandle
[pool-1] 핸들러 A 안녕
[pool-1] afterMessageHandled
== 3) preSend 가 null 을 돌려주면 메시지는 채널에 들어가지 못한다 ==
[main ] 핸들러 A 통과
send("통과") 반환값 = true
send("차단") 반환값 = false
2)에서는 출력 순서가 매번 같도록 send가 돌아올 때까지 풀의 스레드를 붙잡아 두었습니다. 붙잡지 않으면 [pool-1] 줄이 send() 반환값 줄보다 먼저 찍히기도 합니다.
헷갈리기 쉬운 곳
send가true를 돌려줬다고 처리가 끝난 것이 아닙니다.ExecutorSubscribableChannel.sendInternal은 핸들러가 있든 없든 항상true를 돌려줍니다(위 코드의 마지막 줄). Executor가 있으면 그 시점에 핸들러는 아직 시작도 안 했을 수 있습니다.- 인터셉터의 훅은 두 묶음입니다.
preSend·postSend·afterSendCompletion은 보내는 스레드에서,beforeHandle·afterMessageHandled는 핸들러를 부르는 스레드에서 핸들러마다 불립니다. 뒤의 둘은ChannelInterceptor가 아니라 그것을 상속한ExecutorChannelInterceptor에 있습니다(38–69행, 부르는 곳은SendTask.run143–168행). ChannelInterceptor에는preReceive·postReceive도 있지만 이 글의 채널에서는 불리지 않습니다. 설명 주석에 적힌 대로 메시지를 꺼내 가는 방식의 채널(PollableChannel)에만 해당하는 훅이고(65–82행),ExecutorSubscribableChannel은 핸들러에게 밀어 주기만 합니다.
2. 바닥 둘: WebSocketHandler와 핸드셰이크
spring-websocket 모듈의 바닥은 WebSocketHandler 인터페이스입니다(35–80행). 연결 하나의 일생에 맞춘 메서드들입니다.
| 메서드 | 불리는 때 |
|---|---|
afterConnectionEstablished(session) |
핸드셰이크가 끝나 연결이 열렸을 때 |
handleMessage(session, message) |
상대가 보낸 WebSocket 메시지가 도착했을 때 |
handleTransportError(session, exception) |
전송 중 오류가 났을 때 |
afterConnectionClosed(session, closeStatus) |
연결이 닫힌 뒤 |
WebSocketSession은 열려 있는 연결 하나입니다(38행). getId()로 구분하고 sendMessage(message)로 상대에게 보냅니다(130행). 서버가 먼저 말을 걸 수 있는 것은 이 세션 객체를 들고 있기 때문입니다.
HTTP 요청이 WebSocket으로 올라가는 자리
연결은 HTTP 요청으로 시작하므로 입구는 21편의 DispatcherServlet입니다. WebSocket 엔드포인트 경로(예: /ws)는 WebSocketHandlerMapping에 등록돼 있고(40행, SimpleUrlHandlerMapping의 하위 클래스입니다), 그 경로에 묶인 핸들러가 WebSocketHttpRequestHandler입니다.
@Override
public void handleRequest(HttpServletRequest servletRequest, HttpServletResponse servletResponse)
throws ServletException, IOException {
ServerHttpRequest request = new ServletServerHttpRequest(servletRequest);
ServerHttpResponse response = new ServletServerHttpResponse(servletResponse);
HandshakeInterceptorChain chain = new HandshakeInterceptorChain(this.interceptors, this.wsHandler);
// ...
try {
// ...
Map<String, Object> attributes = new HashMap<>();
if (!chain.applyBeforeHandshake(request, response, attributes)) {
return;
}
this.handshakeHandler.doHandshake(request, response, this.wsHandler, attributes);
chain.applyAfterHandshake(request, response, null);
}
// ...
}
세 줄이 뼈대입니다. HandshakeInterceptor들의 beforeHandshake를 부르고(하나라도 false면 끝), HandshakeHandler.doHandshake에 맡기고, afterHandshake를 부릅니다. attributes 맵은 인터셉터가 채워 두면 나중에 WebSocketSession.getAttributes()로 꺼낼 수 있는 값들입니다.
doHandshake의 기본 구현은 AbstractHandshakeHandler에 있습니다(168–230행). 하는 일은 검사 여럿과 위임 하나입니다.
- HTTP 메서드가
GET(또는 HTTP/2용CONNECT)이 아니면 405(177–185행). GET이면Upgrade헤더가WebSocket인지,Connection헤더에Upgrade가 있는지,Sec-WebSocket-Key가 있는지 봅니다. 아니면 400(186–204행).- WebSocket 버전과
Origin을 검사합니다. Origin이 허용되지 않으면 403(205–212행). - 클라이언트가 요청한 서브프로토콜 가운데 핸들러가 지원하는 것을 하나 고릅니다(219행, 297–308행). STOMP가 선택되는 자리입니다.
RequestUpgradeStrategy.upgrade(...)에 넘깁니다(228행).
실제로 연결을 WebSocket으로 바꾸는 일은 Spring이 아니라 서버(Tomcat 등)가 합니다. DefaultHandshakeHandler가 고르는 전략은 클래스패스에 Jetty의 WebSocket 서버가 있으면 JettyRequestUpgradeStrategy, 없으면 StandardWebSocketUpgradeStrategy입니다(45행). 뒤쪽을 기준으로 보면, 이 전략은 서블릿 컨테이너의 ServerContainer.upgradeHttpToWebSocket(...)을 부르고(138행), 그때 우리 WebSocketHandler를 표준 API(jakarta.websocket.Endpoint)로 감싼 StandardWebSocketHandlerAdapter를 건넵니다(119행). 연결이 열리면 서버가 그 어댑터의 onOpen을 부르고, 어댑터가 afterConnectionEstablished를 부릅니다(StandardWebSocketHandlerAdapter 60–103행).
이 절의 흐름은 6절의 예제 4에서 내장 Tomcat에 실제로 접속해 확인합니다. 핸드셰이크 요청이 DispatcherServlet을 지나 인터셉터의 beforeHandshake와 afterHandshake에 걸리는 것, 응답 상태가 101이 되는 것, 서브프로토콜이 골라지는 것, 고른 전략이 StandardWebSocketUpgradeStrategy인 것이 출력에 찍힙니다.
초록 상자부터는 HTTP가 아닙니다. 이 뒤에 오는 메시지는 DispatcherServlet을 거치지 않습니다.
헷갈리기 쉬운 곳
HandshakeInterceptor는 핸드셰이크 요청 한 번에만 걸립니다. 연결이 올라간 뒤 오는 메시지는 HTTP 요청이 아닙니다. 서버가 어댑터의onMessage를 부르고 어댑터가 곧바로WebSocketHandler.handleMessage를 부르므로(110–113행)DispatcherServlet을 지나지 않습니다(6절 예제 4의 출력에서DispatcherServlet을 지나는 줄은 핸드셰이크 때 한 번뿐입니다). 메시지마다 무언가 검사하려면 1절의ChannelInterceptor를 써야 합니다.- SockJS는 WebSocket이 아닙니다. WebSocket을 쓸 수 없는 환경에서 HTTP 요청 여러 개로 같은 효과를 흉내 내는 대체 수단이고, 엔드포인트에
withSockJS()를 붙이면WebSocketHttpRequestHandler대신SockJsHttpRequestHandler가 등록됩니다(WebMvcStompWebSocketEndpointRegistration142–169행). 그 뒤의WebSocketHandler는 같습니다. 이 글에서는 여기까지만 봅니다.
3. STOMP가 얹히는 자리
WebSocketHandler.handleMessage가 받는 것은 글자 덩어리일 뿐입니다. 그 덩어리를 STOMP 프레임으로 읽어 1절의 Message로 바꾸는 다리가 둘 있습니다.
SubProtocolWebSocketHandler—WebSocketHandler이면서 동시에MessageHandler입니다(70–71행). 두 세계의 경계에 서 있는 클래스입니다.StompSubProtocolHandler— 실제로 STOMP를 읽고 쓰는 쪽입니다. 지원한다고 밝히는 서브프로토콜 이름은v10.stomp,v11.stomp,v12.stomp입니다(217–220행).
SubProtocolWebSocketHandler에는 이름이 같은 handleMessage가 둘 있습니다.
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
WebSocketSessionHolder holder = this.sessions.get(session.getId());
if (holder != null) {
session = holder.getSession();
}
SubProtocolHandler protocolHandler = findProtocolHandler(session);
protocolHandler.handleMessageFromClient(session, message, this.clientInboundChannel);
if (holder != null) {
holder.setHasHandledMessages();
}
}
// ...
@Override
public void handleMessage(Message<?> message) throws MessagingException {
String sessionId = resolveSessionId(message);
// ...
WebSocketSession session = holder.getSession();
try {
findProtocolHandler(session).handleMessageToClient(session, message);
}
// ...
}
위는 WebSocketHandler로서 클라이언트가 보낸 것을 받는 쪽이고, 아래는 MessageHandler로서 클라이언트에게 보낼 것을 받는 쪽입니다. 어느 쪽이든 세션이 핸드셰이크 때 고른 서브프로토콜에 맞는 SubProtocolHandler를 찾아(443–475행) 일을 넘깁니다. 아래쪽이 불리려면 누군가 이 객체를 채널에 구독시켜야 하는데, 시작할 때 스스로 clientOutboundChannel을 구독합니다(285–293행).
들어오는 방향의 StompSubProtocolHandler는 이렇게 생겼습니다.
public void handleMessageFromClient(WebSocketSession session,
WebSocketMessage<?> webSocketMessage, MessageChannel channel) {
// ...
messages = decoder.decode(byteBuffer);
// ...
for (Message<byte[]> message : messages) {
StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
// ...
headerAccessor.setSessionId(sessionId);
headerAccessor.setSessionAttributes(session.getAttributes());
headerAccessor.setUser(getUser(session));
// ...
sent = channelToUse.send(message);
// ...
}
}
decoder.decode가 바이트를 STOMP 프레임 단위로 잘라Message<byte[]>의 목록으로 만듭니다. 프레임의 명령과 헤더는Message의 헤더 안으로, 본문은 payload로 갑니다(StompDecoder, 50행).- 세션 id, 세션 속성, 사용자를 헤더에 적어 넣습니다. STOMP 프레임에는 "누가 보냈는지"가 없고 그것은 WebSocket 연결이 알고 있기 때문입니다. 이 뒤의 부품들은
WebSocketSession을 모르고 헤더의 세션 id만 봅니다. - 채널에
send합니다. 여기서channel은 위 코드에서 건넨clientInboundChannel입니다.
보낸 뒤에는 CONNECT·SUBSCRIBE·UNSUBSCRIBE에 대해 SessionConnectEvent 같은 ApplicationEvent도 발행합니다(340–353행). 12편의 @EventListener로 받을 수 있습니다.
STOMP 명령은 헤더에 두 가지 모양으로 남습니다. 원래 명령(StompCommand)과, STOMP를 모르는 부품도 알아볼 수 있게 뭉뚱그린 종류(SimpMessageType)입니다. 대응은 StompCommand 열거형에 적혀 있습니다(31–47행).
| STOMP 명령 | SimpMessageType |
|---|---|
CONNECT, STOMP |
CONNECT |
SUBSCRIBE |
SUBSCRIBE |
UNSUBSCRIBE |
UNSUBSCRIBE |
SEND (클라이언트 → 서버), MESSAGE (서버 → 클라이언트) |
MESSAGE |
DISCONNECT |
DISCONNECT |
나가는 방향은 반대입니다. handleMessageToClient가 Message의 헤더를 STOMP 헤더로 바꾸고(458–515행), StompEncoder로 바이트를 만들어 session.sendMessage(...)로 내보냅니다(517–529행).
같은 클래스가 WebSocketHandler로서 받고 MessageHandler로서 보냅니다. 노란 칸이 4절에서 볼 채널입니다.
헷갈리기 쉬운 곳
SEND와MESSAGE는 같은 종류입니다. 클라이언트의SEND도 서버가 내보내는MESSAGE도SimpMessageType.MESSAGE입니다. 나갈 때 명령이 없거나SEND이면MESSAGE로 바꿔 적습니다(578–580행). 6절의 예제 출력에서SEND로 들어온 메시지의 type이MESSAGE로 찍히는 이유입니다.CONNECTED프레임은 브로커가 만든CONNECT_ACK에서 나옵니다.StompSubProtocolHandler가CONNECT를 받고 스스로 답하는 것이 아닙니다.CONNECT메시지가 채널을 타고 브로커까지 갔다가CONNECT_ACK로 돌아오면 그것을CONNECTED로 바꿉니다(561–563행).
4. 채널 셋과 거기 붙는 핸들러들
이제 이 글의 중심입니다. 3절의 다리와 우리 코드 사이에는 ExecutorSubscribableChannel이 세 개 놓여 있습니다.
| 채널(빈 이름) | 무엇이 지나가나 | 구독하는 핸들러 |
|---|---|---|
clientInboundChannel |
클라이언트가 보낸 모든 메시지 | SimpAnnotationMethodMessageHandler, SimpleBrokerMessageHandler, UserDestinationMessageHandler |
brokerChannel |
서버 쪽 코드가 브로커에게 보내는 메시지 | SimpleBrokerMessageHandler, UserDestinationMessageHandler |
clientOutboundChannel |
클라이언트에게 나갈 모든 메시지 | SubProtocolWebSocketHandler |
구독은 각 핸들러의 start()에서 합니다(SimpAnnotationMethodMessageHandler 306–312행, AbstractBrokerMessageHandler 215–228행, UserDestinationMessageHandler 171–178행). 표의 구독자 목록은 6절의 예제 3에서 실제로 찍어 확인했습니다.
노란색이 채널, 파란색이 채널을 구독한 핸들러입니다. 위아래의 SubProtocolWebSocketHandler는 같은 객체입니다.
clientInboundChannel에 붙은 세 핸들러는 메시지를 전부 받습니다. 그리고 각자 목적지의 앞부분(접두사)을 보고 자기 것만 처리합니다. 흔히 쓰는 설정(/app은 우리 메서드로, /topic은 브로커로)을 기준으로 하나씩 봅니다.
SimpAnnotationMethodMessageHandler — @MessageMapping을 부르는 쪽
22편의 RequestMappingHandlerMapping과 RequestMappingHandlerAdapter가 하던 일을 한 클래스가 합니다. 부모는 AbstractMethodMessageHandler입니다.
- 찾아 두기. 초기화 때 컨테이너의 빈을 훑어
@Controller가 붙은 클래스에서(415–418행)@MessageMapping·@SubscribeMapping메서드를 찾아 "목적지 패턴 → 메서드" 표를 만듭니다(AbstractMethodMessageHandler231–268행, 420–451행). - 고르기. 메시지가 오면 목적지가 설정된 접두사(
/app)로 시작하는지 보고, 아니면 아무 일도 하지 않고 돌아갑니다. 맞으면 접두사를 뗀 나머지(/hello)로 메서드를 찾습니다(429–453행, 497–516행). - 부르기. 인자 리졸버들이
Message에서 메서드 인자를 만들어 내고(payload를 원하는 타입으로 바꾸는PayloadMethodArgumentResolver가 맨 끝, 336–357행), 메서드를 부른 뒤 반환값을 반환값 핸들러에 넘깁니다(549–583행).
HTTP에서는 반환값이 응답 본문이 됐습니다. 메시징에는 "응답"이 따로 없으므로 반환값은 새 메시지가 되어 어딘가로 보내집니다. 그 일을 하는 것이 SendToMethodReturnValueHandler입니다.
@SendTo("/topic/greetings")가 있으면 그 목적지로, 없으면 /topic + 들어온 목적지에서 접두사를 뗀 부분으로 보냅니다(기본 접두사 /topic은 71행, 조합은 245–260행). 보낼 때는 messagingTemplate.convertAndSend(destination, returnValue, ...)를 부르는데(209–216행), 이 템플릿은 brokerChannel로 보내는 템플릿입니다(372–373행).
SimpleBrokerMessageHandler — 구독을 기억하고 나눠 주는 쪽
Spring에 들어 있는 간단한 브로커입니다. clientInboundChannel과 brokerChannel 양쪽을 구독하고, 자기 접두사(/topic)로 시작하는 목적지만 다룹니다. 메시지를 받는 handleMessageInternal은 종류별 분기입니다(292–350행).
- 맨 먼저 접두사를 검사합니다.
/app/hello는 여기서 걸러집니다(300–302행,checkDestinationPrefix314–327행). SUBSCRIBE면 구독 레지스트리에 적어 두고,UNSUBSCRIBE면 지웁니다(342–349행).MESSAGE면 그 목적지의 구독자를 찾아 보냅니다(305–308행).CONNECT면 세션을 기억하고CONNECT_ACK를 나가는 채널로 보냅니다(309–334행).
레지스트리의 기본 구현은 DefaultSubscriptionRegistry이고 저장소가 둘입니다(91–93행).
| 저장소 | 모양 | 쓰임 |
|---|---|---|
SessionRegistry |
세션 id → 그 세션의 구독들(구독 id, 목적지 또는 패턴) | 원본. SUBSCRIBE·UNSUBSCRIBE·연결 끊김 때 고친다 (393–414행) |
DestinationCache |
목적지 → (세션 id → 구독 id들) | 메시지가 올 때마다 전체를 훑지 않으려고 계산해 둔 결과. 기본 1024개까지 (258–296행, 73행) |
구독 목적지에는 /topic/* 같은 패턴도 쓸 수 있고, 맞는지는 AntPathMatcher로 봅니다(283–296행).
구독자에게 나눠 주는 코드는 이렇습니다.
protected void sendMessageToSubscribers(@Nullable String destination, Message<?> message) {
MultiValueMap<String,String> subscriptions = this.subscriptionRegistry.findSubscriptions(message);
// ...
subscriptions.forEach((sessionId, subscriptionIds) -> {
for (String subscriptionId : subscriptionIds) {
SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
initHeaders(headerAccessor);
headerAccessor.setSessionId(sessionId);
headerAccessor.setSubscriptionId(subscriptionId);
headerAccessor.copyHeadersIfAbsent(message.getHeaders());
// ...
Object payload = message.getPayload();
Message<?> reply = MessageBuilder.createMessage(payload, headerAccessor.getMessageHeaders());
SessionInfo info = this.sessions.get(sessionId);
if (info != null) {
try {
info.getClientOutboundChannel().send(reply);
}
// ...
}
}
});
}
구독 하나마다 새 Message를 만듭니다. payload는 그대로 두고 헤더에 받을 세션 id와 구독 id를 적어 clientOutboundChannel에 넣습니다. 구독자가 100명이면 메시지 100개가 나가는 채널을 지나가고, 3절의 SubProtocolWebSocketHandler가 헤더의 세션 id로 WebSocketSession을 찾아 한 명씩 보냅니다.
예제 2: 브로커만 세워 놓고 메시지를 넣어 보기
SimpleBrokerMessageHandler를 직접 만들어 채널 셋에 붙이고, STOMP 프레임 대신 같은 모양의 Message를 손으로 만들어 넣었습니다(실행 환경은 예제 1과 같습니다). 나가는 채널에는 출력만 하는 핸들러를 붙였습니다. 채널에 Executor를 주지 않아 전부 main 스레드에서 순서대로 돕니다. 아래 출력의 단계마다 clientInboundChannel.send(...)를 한두 번 부른 것이고, 5)만 new SimpMessagingTemplate(brokerChannel).convertAndSend(...)입니다.
clientInboundChannel 구독자 수 = 1, brokerChannel 구독자 수 = 1
== 1) CONNECT 없이 SUBSCRIBE 만 하고 보내 본다 (세션 s0) ==
(나가는 채널에 아무것도 실리지 않았다)
== 2) 두 세션이 CONNECT 한다 ==
나감 → type=CONNECT_ACK session=s1 subscription=null destination=null payload=
나감 → type=CONNECT_ACK session=s2 subscription=null destination=null payload=
== 3) s1 은 /topic/news, s2 는 패턴 /topic/* 을 구독한다 ==
(구독은 레지스트리에 적힐 뿐, 나가는 메시지는 없다)
== 4) 클라이언트가 /topic/news 로 보낸다 (clientInboundChannel) ==
나감 → type=MESSAGE session=s1 subscription=sub-1 destination=/topic/news payload=둘째 소식
나감 → type=MESSAGE session=s2 subscription=sub-2 destination=/topic/news payload=둘째 소식
== 5) 서버 코드가 SimpMessagingTemplate 으로 보낸다 (brokerChannel) ==
나감 → type=MESSAGE session=s2 subscription=sub-2 destination=/topic/weather payload=맑음
== 6) 브로커 접두사가 아닌 /app/hello 로 보낸다 ==
(나가는 채널에 아무것도 실리지 않았다)
== 7) s1 이 DISCONNECT 한 뒤 다시 /topic/news 로 보낸다 ==
나감 → type=DISCONNECT_ACK session=s1 subscription=null destination=null payload=
나감 → type=MESSAGE session=s2 subscription=sub-2 destination=/topic/news payload=셋째 소식
4)에서 메시지 하나가 둘이 되어 나갑니다. 5)의 /topic/weather는 패턴으로 구독한 s2만 받습니다. 7)에서 s1의 구독은 DISCONNECT와 함께 지워졌습니다(375–389행).
헷갈리기 쉬운 곳
CONNECT를 거치지 않은 세션에게는 아무것도 가지 않습니다. 1)에서 s0은 구독은 등록됐지만 메시지를 받지 못했습니다. 브로커는CONNECT때 세션 정보를 만들어 두고(321–322행), 보낼 때 그 정보가 없으면 조용히 건너뜁니다(위 코드의if (info != null)). 예제의 1)은 소스에서 이 조건을 읽고 정말 그런지 보려고 넣은 것입니다. 손으로Message를 만들어 브로커를 시험할 때 빠지기 쉬운 자리입니다.- 클라이언트가
/topic/..으로 직접 보내면 우리 메서드를 거치지 않습니다. 4)가 그 경우입니다. 브로커도clientInboundChannel을 구독하고 있어서, 브로커 접두사로 온SEND는 곧바로 구독자에게 퍼집니다. 검사나 가공을 하려면/app/..으로 받게 하거나 채널 인터셉터를 겁니다. - 브로커를 따로 켜지 않아도 간단한 브로커가 켜집니다. 설정에서
enableSimpleBroker도enableStompBrokerRelay도 부르지 않으면 접두사 없는 간단한 브로커가 등록됩니다(MessageBrokerRegistry227–239행).
나머지 둘: 사용자 목적지와 외부 브로커
UserDestinationMessageHandler는 /user/..로 시작하는 목적지를 다룹니다(203–240행). "이 사용자에게만"이라는 뜻의 목적지를 그 사용자의 세션마다 고유한 실제 목적지(기본 구현은 끝에 -user + 세션 id를 붙입니다, DefaultUserDestinationResolver 243–247행)로 바꿔 brokerChannel에 다시 넣습니다. 그 뒤는 보통의 구독·발행과 같습니다.
StompBrokerRelayMessageHandler는 간단한 브로커 대신 쓰는 것으로(82행), 구독을 직접 기억하지 않고 바깥의 STOMP 브로커 서버에 메시지를 전달(relay)합니다. 같은 부모(AbstractBrokerMessageHandler)를 상속하고 같은 두 채널을 구독하므로 나머지 부품은 바뀌지 않습니다. 이 글에서는 이름만 적어 둡니다.
5. 이 빈들은 누가 만드나 — @EnableWebSocketMessageBroker
지금까지 나온 채널과 핸들러를 우리가 직접 만든 적은 없습니다. 설정 클래스에 어노테이션 하나를 붙이면 생깁니다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(DelegatingWebSocketMessageBrokerConfiguration.class)
public @interface EnableWebSocketMessageBroker {
}
9편에서 본 대로 @EnableXxx는 @Import입니다. 들여오는 설정 클래스는 세 층입니다.
아래로 갈수록 WebSocket을 모르는 층입니다. 채널과 브로커는 spring-messaging에, WebSocket과 닿는 빈은 spring-websocket에 있습니다.
| 층 | 하는 일 |
|---|---|
DelegatingWebSocketMessageBrokerConfiguration |
컨테이너의 WebSocketMessageBrokerConfigurer 빈을 모두 주입받아, 설정 훅이 불릴 때마다 그들에게 차례로 묻는다 (43–54행) |
WebSocketMessageBrokerConfigurationSupport |
subProtocolWebSocketHandler와, 엔드포인트 경로를 그 핸들러에 묶는 stompWebSocketHandlerMapping (93–123행) |
AbstractMessageBrokerConfiguration |
채널 셋, 4절의 핸들러들, 메시지 변환기, SimpMessagingTemplate |
우리가 구현하는 WebSocketMessageBrokerConfigurer의 registerStompEndpoints에서 addEndpoint("/ws")를 부르면, 그 순간 StompSubProtocolHandler가 SubProtocolWebSocketHandler에 등록되고(103–110행) 2절의 WebSocketHttpRequestHandler가 그 경로에 묶입니다(170–187행).
채널 빈을 만드는 메서드를 보면 1절의 클래스가 그대로 나옵니다. new ExecutorSubscribableChannel(executor)를 만들고 등록된 인터셉터를 붙여 돌려주는 것이 전부입니다(157–168행).
헷갈리기 쉬운 곳
- 들어오는 채널과 나가는 채널에는 스레드 풀이 있고,
brokerChannel에는 기본으로 없습니다. 앞의 둘은 항상 Executor를 받아 만들고(기본 풀 크기는 CPU 수의 두 배,TaskExecutorRegistration53행),brokerChannel은 따로 설정했을 때만 Executor를 씁니다(255–256행). 그래서@MessageMapping메서드가 값을 돌려주면 같은 스레드가 브로커까지 가서 구독자를 찾습니다. 6절의 출력에서 볼 수 있습니다. - 세 채널 모두 헤더를 고칠 수 없게 만드는 인터셉터가 맨 뒤에 붙습니다.
ImmutableMessageChannelInterceptor입니다(184행, 235행, 257행).preSend에서 헤더 접근자를 잠급니다(ImmutableMessageChannelInterceptor33–44행). 4절에서 봤듯이 같은 메시지를 핸들러 여럿이 받으므로, 채널에 들어간 뒤에는 헤더가 바뀌지 않게 해 두는 것으로 읽힙니다. 우리가 등록한 인터셉터는 이것보다 앞에 놓입니다.
6. 메시지 하나의 여정, 그리고 SimpMessagingTemplate
부품을 다 봤으니 처음부터 끝까지 이어 봅니다. 클라이언트가 /app/hello로 SEND를 보내고, @MessageMapping("/hello") 메서드의 반환값이 /topic/greetings 구독자에게 MESSAGE로 가는 길입니다.
노란 상자 셋이 채널입니다. 메시지는 채널을 세 번 지나고, 그 사이에서 내용과 헤더가 바뀝니다.
SimpMessagingTemplate은 brokerChannel로 보냅니다
컨트롤러 메서드 바깥에서, 예를 들어 예약 작업이나 HTTP 컨트롤러에서 구독자에게 메시지를 보내고 싶을 때 쓰는 것이 SimpMessagingTemplate입니다. 설정이 만들어 주는 빈은 이렇습니다.
@Bean
public SimpMessagingTemplate brokerMessagingTemplate(
AbstractSubscribableChannel brokerChannel, AbstractSubscribableChannel clientInboundChannel,
AbstractSubscribableChannel clientOutboundChannel, CompositeMessageConverter brokerMessageConverter) {
SimpMessagingTemplate template = new SimpMessagingTemplate(brokerChannel);
MessageBrokerRegistry registry = getBrokerRegistry(clientInboundChannel, clientOutboundChannel);
String prefix = registry.getUserDestinationPrefix();
if (prefix != null) {
template.setUserDestinationPrefix(prefix);
}
template.setMessageConverter(brokerMessageConverter);
return template;
}
생성자에 넘긴 채널이 brokerChannel입니다. convertAndSend(destination, payload)는 변환기로 payload를 Message로 만들고(AbstractMessageSendingTemplate 154–160행), 헤더에 목적지와 종류 MESSAGE를 적어 그 채널에 send합니다(148–191행). 위 그림의 ⑤로 곧장 들어가는 셈입니다. 4절에서 @SendTo의 반환값을 보내던 템플릿도 바로 이 빈입니다.
예제 3: 프레임을 넣고 프레임이 나오는지 보기
끝에서 끝까지 실제로 돌려 봤습니다(실행 환경은 예제 1과 같습니다). 두 번에 나눠 봅니다. 먼저 이 예제는 웹 서버 없이 돕니다. 서버가 없으니 2절의 핸드셰이크는 일어나지 않습니다(진짜 서버에 접속하는 것은 바로 뒤의 예제 4입니다). 대신 WebSocketSession을 구현한 가짜 세션을 만들어, 서버가 핸드셰이크 뒤에 부르는 afterConnectionEstablished와 handleMessage를 직접 불렀습니다. 이렇게 하면 넣는 프레임과 나오는 프레임의 글자를 그대로 볼 수 있습니다. 설정도 @EnableWebSocketMessageBroker 대신, 그것이 들여오는 설정 클래스의 맨 위 조상인 AbstractMessageBrokerConfiguration(5절의 셋째 층)을 직접 상속하고 SubProtocolWebSocketHandler만 손으로 등록했습니다. 어노테이션이 들여오는 위의 두 층은 엔드포인트 경로를 핸들러 매핑에 묶는 HTTP 쪽 빈까지 만드는데, 서버 없이 도는 이 예제에서는 그 빈을 쓸 일이 없기 때문입니다. 3절부터 뒤는 진짜 클래스들이 그대로 돕니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
// 우리가 쓰는 코드는 이것뿐이다: /app/hello 로 온 메시지를 받아 /topic/greetings 로 돌려보낸다
@Controller
static class GreetingController {
@MessageMapping("/hello")
@SendTo("/topic/greetings")
String greet(String name) {
log("GreetingController.greet(\"" + name + "\")");
return "안녕하세요, " + name + "!";
}
}
설정 클래스에서는 enableSimpleBroker("/topic")과 setApplicationDestinationPrefixes("/app")만 불렀습니다. main은 이렇게 프레임을 넣습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
SubProtocolWebSocketHandler handler = context.getBean(SubProtocolWebSocketHandler.class);
FakeSession session = new FakeSession();
handler.afterConnectionEstablished(session); // 핸드셰이크가 끝난 뒤 서버가 부르는 자리
handler.handleMessage(session, new TextMessage("CONNECT\naccept-version:1.2\nhost:localhost\n\n\0"));
printFrame(session);
// ...
handler.handleMessage(session, new TextMessage("SUBSCRIBE\nid:sub-0\ndestination:/topic/greetings\n\n\0"));
// ...
handler.handleMessage(session, new TextMessage(
"SEND\ndestination:/app/hello\ncontent-type:text/plain;charset=UTF-8\n\n남알지\0"));
printFrame(session);
// ...
SimpMessagingTemplate template = context.getBean(SimpMessagingTemplate.class);
System.out.println(" 템플릿이 쓰는 채널 == brokerChannel ? " + (template.getMessageChannel() == broker));
template.convertAndSend("/topic/greetings", "공지입니다");
printFrame(session);
채널마다 preSend에서 한 줄을 찍는 인터셉터를 달았고, 출력 순서가 흔들리지 않도록 들어오는·나가는 채널의 스레드를 하나로 줄였습니다.
clientInboundChannel 구독자: [SimpAnnotationMethodMessageHandler, SimpleBrokerMessageHandler, UserDestinationMessageHandler]
brokerChannel 구독자: [SimpleBrokerMessageHandler, UserDestinationMessageHandler]
clientOutboundChannel 구독자: [SubProtocolWebSocketHandler]
== 1) 클라이언트 → 서버: CONNECT ==
[main ] → clientInboundChannel type=CONNECT destination=null
[clientInboundChannel-1 ] → clientOutboundChannel type=CONNECT_ACK destination=null
[main ] 이벤트 SessionConnectEvent
[clientOutboundChannel-1] 이벤트 SessionConnectedEvent
[clientOutboundChannel-1] WebSocketSession.sendMessage(TextMessage)
서버 → 클라이언트 프레임:
CONNECTED
version:1.2
heart-beat:0,0
^@
== 2) 클라이언트 → 서버: SUBSCRIBE /topic/greetings ==
[main ] → clientInboundChannel type=SUBSCRIBE destination=/topic/greetings
[main ] 이벤트 SessionSubscribeEvent
== 3) 클라이언트 → 서버: SEND /app/hello ==
[main ] → clientInboundChannel type=MESSAGE destination=/app/hello
[clientInboundChannel-1 ] GreetingController.greet("남알지")
[clientInboundChannel-1 ] → brokerChannel type=MESSAGE destination=/topic/greetings
[clientInboundChannel-1 ] → clientOutboundChannel type=MESSAGE destination=/topic/greetings
[clientOutboundChannel-1] WebSocketSession.sendMessage(TextMessage)
서버 → 클라이언트 프레임:
MESSAGE
destination:/topic/greetings
content-type:text/plain;charset=UTF-8
subscription:sub-0
message-id:s1-0
content-length:27
안녕하세요, 남알지!^@
== 4) 서버 코드가 SimpMessagingTemplate.convertAndSend 로 보낸다 ==
템플릿이 쓰는 채널 == brokerChannel ? true
[main ] → brokerChannel type=MESSAGE destination=/topic/greetings
[main ] → clientOutboundChannel type=MESSAGE destination=/topic/greetings
[clientOutboundChannel-1] WebSocketSession.sendMessage(TextMessage)
서버 → 클라이언트 프레임:
MESSAGE
destination:/topic/greetings
content-type:text/plain;charset=UTF-8
subscription:sub-0
message-id:s1-1
content-length:15
공지입니다^@
컨텍스트를 닫았습니다.
^@는 STOMP 프레임의 끝을 알리는 0번 문자를 보이게 바꿔 찍은 것입니다. 1)에서 main 스레드의 줄과 다른 스레드의 줄은 서로 기다리지 않으므로 실행할 때마다 앞뒤가 바뀔 수 있습니다.
3)의 스레드 이름을 따라가면 위 그림이 그대로 보입니다.
main(진짜 서버에서는 서버의 스레드가 서는 자리입니다. 예제 4에서 봅니다)은 프레임을Message로 바꿔clientInboundChannel에 넣고 돌아갑니다.clientInboundChannel-1이 우리 메서드를 부르고, 같은 스레드로brokerChannel을 지나 브로커가 구독자를 찾아clientOutboundChannel에 넣는 데까지 갑니다. 5절에서 본 대로brokerChannel에 Executor가 없기 때문입니다.clientOutboundChannel-1이 프레임을 만들어 세션에 씁니다.
4)에서는 SimpMessagingTemplate을 부른 main 스레드가 brokerChannel부터 시작합니다. clientInboundChannel은 지나지 않습니다.
대괄호 안이 그 일을 한 스레드입니다. 템플릿으로 보낸 4)는 clientInboundChannel을 건너뜁니다. 모두 위 예제 3의 실제 출력입니다.
예제 4: 진짜 서버에 진짜 클라이언트로 왕복해 보기
이번에는 가짜 세션 없이 돌립니다. 내장 Tomcat 11.0.15를 임의의 빈 포트에 띄워 DispatcherServlet을 올리고, 같은 프로세스에서 Spring의 자바 STOMP 클라이언트 WebSocketStompClient로 접속해 구독하고 보내고 받았습니다. 이 예제도 릴리스 버전인 Spring Framework 7.0.9로 실행해 아래 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다). 컨트롤러는 예제 3과 같고 돌려주는 글자만 영어("Hello, " + name + "!")입니다.
설정은 5절에서 본 그대로 어노테이션 하나와 WebSocketMessageBrokerConfigurer입니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
// 이번에는 어노테이션 하나로 채널 셋, 핸들러들, 엔드포인트 매핑까지 전부 만들게 한다
@Configuration
@EnableWebSocketMessageBroker
static class Config implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").addInterceptors(new HandshakePrinter());
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
registry.configureBrokerChannel().interceptors(new Trace("brokerChannel"));
}
// ...
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
// 연결이 열리고 닫히는 순간을 보려고 WebSocketHandler 를 한 겹 감싼다
registry.addDecoratorFactory(ConnectionPrinter::new);
}
}
무엇이 어디를 지나는지 보려고 네 군데에 출력을 걸었습니다. HandshakePrinter는 2절의 HandshakeInterceptor로, 요청의 Sec-WebSocket-Protocol 헤더와 응답의 상태 코드를 찍습니다. ConnectionPrinter는 SubProtocolWebSocketHandler를 감싼 WebSocketHandler로, afterConnectionEstablished가 불릴 때 한 줄을 찍습니다. Trace는 예제 3처럼 세 채널의 preSend에서 한 줄을 찍고, 나가는 채널에서는 핸들러가 불리기 직전(beforeHandle)에도 한 줄을 찍습니다. 마지막으로 DispatcherServlet의 doService를 재정의해 요청이 지날 때마다 한 줄을 찍게 했습니다. main은 이렇습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.register(Config.class, GreetingController.class);
// 요청이 DispatcherServlet 을 지날 때마다 한 줄 찍는다
DispatcherServlet dispatcherServlet = new DispatcherServlet(context) {
@Override
protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 서블릿에도 log(String) 이 있어서 클래스 이름을 붙여 부른다
StompRoundTripExample.log("DispatcherServlet.doService " + request.getMethod() + " " + request.getRequestURI());
super.doService(request, response);
}
};
Tomcat tomcat = new Tomcat();
WebSocketStompClient stompClient = new WebSocketStompClient(new StandardWebSocketClient());
stompClient.setMessageConverter(new StringMessageConverter());
// ...
tomcat.setPort(0); // 빈 포트를 아무거나
// 출력 순서가 실행할 때마다 달라지지 않도록 요청을 처리하는 스레드도 하나로 줄인다
tomcat.getConnector().setProperty("maxThreads", "1");
tomcat.getConnector().setProperty("minSpareThreads", "1");
Context tomcatContext = tomcat.addContext("", Files.createTempDirectory("u23-docbase").toString());
// 보통은 컨테이너가 jar 를 뒤져 찾아 주는 Tomcat 의 WebSocket 초기화를 여기서는 손으로 한다
tomcatContext.addServletContainerInitializer(new WsSci(), null);
Tomcat.addServlet(tomcatContext, "dispatcher", dispatcherServlet).setLoadOnStartup(1);
tomcatContext.addServletMappingDecoded("/", "dispatcher");
tomcat.start();
int port = tomcat.getConnector().getLocalPort();
// ...
// 쓰고 싶은 서브프로토콜을 핸드셰이크 요청의 Sec-WebSocket-Protocol 헤더에 적어 보낸다
WebSocketHttpHeaders handshakeHeaders = new WebSocketHttpHeaders();
handshakeHeaders.setSecWebSocketProtocol(List.of("v12.stomp", "v11.stomp"));
session = stompClient.connectAsync("ws://localhost:" + port + "/ws", handshakeHeaders, new ClientSessionHandler())
.get(10, TimeUnit.SECONDS);
// ...
session.subscribe("/topic/greetings", new GreetingFrameHandler());
// ...
session.send("/app/hello", "namalzi");
printReceived();
// ...
template.convertAndSend("/topic/greetings", "A notice from the server");
printReceived();
WsSci는 Tomcat의 클래스입니다(Spring의 것이 아니라서 이 글에서 소스를 읽지는 않습니다). 2절의 StandardWebSocketUpgradeStrategy는 서블릿 컨텍스트의 jakarta.websocket.server.ServerContainer 속성에서 서버의 WebSocket 컨테이너를 꺼내 쓰는데(147–154행), 그 준비를 Tomcat 쪽에 시키려고 등록했습니다. 예제 3과 마찬가지로 들어오는·나가는 채널의 스레드를 하나씩으로 줄였고, 여기서는 Tomcat이 요청을 처리하는 스레드도 하나로 줄였습니다. 클라이언트가 받은 것은 클라이언트 쪽 콜백이 큐에 담아 두었다가 main이 꺼내 찍습니다(client:로 시작하는 줄).
Tomcat started on a random port
stompWebSocketHandlerMapping: WebSocketHandlerMapping /ws -> WebSocketHttpRequestHandler
handshake handler: DefaultHandshakeHandler, upgrade strategy: StandardWebSocketUpgradeStrategy
== 1) client connects: HTTP handshake, then STOMP CONNECT ==
[http-nio-auto-1-exec-1 ] DispatcherServlet.doService GET /ws
[http-nio-auto-1-exec-1 ] beforeHandshake GET /ws Upgrade=websocket Sec-WebSocket-Protocol=[v12.stomp,v11.stomp]
[http-nio-auto-1-exec-1 ] afterHandshake status=101 Upgrade=websocket Sec-WebSocket-Protocol=v12.stomp
[http-nio-auto-1-exec-1 ] afterConnectionEstablished acceptedProtocol=v12.stomp delegate=SubProtocolWebSocketHandler
[http-nio-auto-1-exec-1 ] -> clientInboundChannel type=CONNECT destination=null
[clientInboundChannel-1 ] -> clientOutboundChannel type=CONNECT_ACK destination=null
[clientOutboundChannel-1] clientOutboundChannel hands it to SubProtocolWebSocketHandler
client: connected = true
== 2) client -> server: SUBSCRIBE /topic/greetings ==
[http-nio-auto-1-exec-1 ] -> clientInboundChannel type=SUBSCRIBE destination=/topic/greetings
== 3) client -> server: SEND /app/hello ==
[http-nio-auto-1-exec-1 ] -> clientInboundChannel type=MESSAGE destination=/app/hello
[clientInboundChannel-1 ] GreetingController.greet("namalzi")
[clientInboundChannel-1 ] -> brokerChannel type=MESSAGE destination=/topic/greetings
[clientInboundChannel-1 ] -> clientOutboundChannel type=MESSAGE destination=/topic/greetings
[clientOutboundChannel-1] clientOutboundChannel hands it to SubProtocolWebSocketHandler
client: received MESSAGE destination=/topic/greetings subscription=0 payload=Hello, namalzi!
== 4) server code sends with SimpMessagingTemplate.convertAndSend ==
template channel == brokerChannel ? true
[main ] -> brokerChannel type=MESSAGE destination=/topic/greetings
[main ] -> clientOutboundChannel type=MESSAGE destination=/topic/greetings
[clientOutboundChannel-1] clientOutboundChannel hands it to SubProtocolWebSocketHandler
client: received MESSAGE destination=/topic/greetings subscription=0 payload=A notice from the server
== 5) client disconnects ==
server: afterConnectionClosed code=1000
Tomcat stopped. context active = false
일곱 번 돌려 일곱 번 모두 글자까지 같은 출력이 나왔습니다. 5)에서는 추적 출력을 꺼 두었습니다. 닫히는 동안의 줄은 순서를 고정해 두지 않았기 때문입니다. http-nio-auto-1-exec-1은 Tomcat이 요청을 처리하는 스레드의 이름입니다.
출력을 본문의 설명과 맞춰 보면 이렇습니다.
- 맨 위의 둘째·셋째 줄 — 5절과 2절의 빈.
addEndpoint("/ws")한 줄로stompWebSocketHandlerMapping빈(WebSocketHandlerMapping)에/ws→WebSocketHttpRequestHandler가 묶였습니다. 핸드셰이크 핸들러는DefaultHandshakeHandler이고, 고른 전략은StandardWebSocketUpgradeStrategy입니다. - 1)의 앞 네 줄 — 2절의 핸드셰이크.
GET /ws요청이DispatcherServlet을 지나beforeHandshake에 걸립니다. 요청에는Upgrade: websocket과, 클라이언트가 적어 보낸 서브프로토콜 두 개가 들어 있습니다(setSecWebSocketProtocol이 목록을 쉼표로 이어 헤더 값 하나로 만들기 때문에[v12.stomp,v11.stomp]로 찍혔습니다,WebSocketHttpHeaders153–155행).afterHandshake에서 본 응답은 상태 101에Sec-WebSocket-Protocol: v12.stomp입니다. 요청한 것을 앞에서부터 보며 핸들러가 지원하는 첫 번째를 고르는 2절의 4번(297–308행)대로입니다. 이 실행에서는afterHandshake가 먼저 찍히고 그 다음에 같은 스레드에서afterConnectionEstablished가 불렸고, 세션이 알려 준 서브프로토콜도v12.stomp였습니다. DispatcherServlet.doService줄은 한 번뿐입니다. 2)·3)의SUBSCRIBE와SEND는 같은 Tomcat 스레드로 들어왔지만DispatcherServlet을 지나지 않고clientInboundChannel에 실렸습니다. 2절의 "헷갈리기 쉬운 곳" 첫 항목이 출력으로 보입니다.- 1)의 나머지와 3) — 3절의 다리와 4절의 채널 셋. 예제 3에서
main이 서 있던 자리에 Tomcat의 스레드가 서 있을 뿐, 그 뒤는 예제 3과 같습니다. Tomcat 스레드가clientInboundChannel에 넣고,clientInboundChannel-1이GreetingController.greet를 부른 뒤 같은 스레드로brokerChannel과clientOutboundChannel에 차례로 넣고,clientOutboundChannel-1이 그것을SubProtocolWebSocketHandler에게 건넵니다.SEND가 typeMESSAGE로 찍히는 것,CONNECT가CONNECT_ACK로 돌아오는 것(3절)도 같습니다. client:줄 — 왕복의 끝. 클라이언트는/topic/greetings로 온MESSAGE에서Hello, namalzi!를 받았습니다. 4)에서는main이 부른SimpMessagingTemplate의 메시지가clientInboundChannel을 거치지 않고brokerChannel부터 시작해 같은 구독(subscription=0)으로 도착합니다.- 5) 클라이언트가 끊자 서버 쪽
afterConnectionClosed가 코드 1000으로 불렸습니다. 1000은CloseStatus.NORMAL입니다(46행).
예제 3에서 볼 수 있던 프레임의 글자와 SessionConnectEvent 같은 이벤트는 이 예제에서는 찍지 않았습니다. 반대로 핸드셰이크와 서버의 스레드는 이 예제에서만 보입니다.
헷갈리기 쉬운 곳
- 보낸 사람도 구독하고 있으면 자기 메시지를 받습니다. 브로커는 보낸 세션을 따로 빼지 않습니다. 예제 3의 3)에서
SEND를 보낸 s1이MESSAGE를 받은 것은 s1이/topic/greetings를 구독했기 때문입니다. 예제 4의 클라이언트도 자기가 보낸 것의 답을 자기 구독으로 받았습니다. WebSocketStompClient는 따로 적어 주지 않으면 서브프로토콜을 요청하지 않았습니다. 예제 4를 처음 돌렸을 때는 핸드셰이크 헤더를 주지 않고 접속했는데,beforeHandshake에Sec-WebSocket-Protocol=null이,afterConnectionEstablished에 빈acceptedProtocol이 찍혔습니다. 그런데도 나머지 왕복은 그대로 됐습니다.SubProtocolWebSocketHandler는 협상된 서브프로토콜이 없으면 기본 핸들러를, 그것도 없고 등록된 핸들러가 하나뿐이면 그 하나를 쓰기 때문입니다(462–473행). 서브프로토콜이 골라지는 것을 보려고WebSocketHttpHeaders에 두 개를 적어 보내도록 고친 것이 위의 코드입니다.SimpMessagingTemplate으로/app/..에 보내도@MessageMapping은 불리지 않습니다. 템플릿은brokerChannel로 보내고,SimpAnnotationMethodMessageHandler는clientInboundChannel만 구독합니다(4절의 표). 브로커는 접두사가 맞지 않아 버립니다.@SubscribeMapping의 반환값은 브로커를 거치지 않습니다. 구독 요청을 한 그 클라이언트에게만clientOutboundChannel로 곧장 답합니다(SubscriptionMethodReturnValueHandler126–160행, 이 핸들러가 쓰는 템플릿은 146행). 구독하는 순간 초기 데이터를 한 번 내려 줄 때 씁니다.
이 글에서 뺀 것
큰 줄기를 위해 많은 것을 뺐습니다. 이름과 위치만 적어 둡니다. SockJS의 전송 방식들과 세션 관리(spring-websocket의 sockjs 패키지). 외부 브로커 릴레이의 TCP 연결과 "시스템 세션"(StompBrokerRelayMessageHandler). 하트비트(간단한 브로커의 HeartbeatTask 508행부터). 사용자 목록 SimpUserRegistry와 서버 여러 대 사이의 사용자 목적지 방송. 메시지 순서를 지키는 OrderedMessageChannelDecorator와 느린 클라이언트를 끊는 ConcurrentWebSocketSessionDecorator(48행). @MessageExceptionHandler로 예외를 처리하는 길(585–609행). websocket 스코프 빈(145–150행). 구독에 SpEL 조건을 다는 selector 헤더(DefaultSubscriptionRegistry 168행부터). 예제 4에서 클라이언트로 쓰기만 한 자바 STOMP 클라이언트 WebSocketStompClient의 속(75행). 그리고 STOMP 없이 WebSocketHandler만 직접 등록하는 @EnableWebSocket과, WebFlux 쪽의 리액티브 WebSocket은 다루지 않았습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
Message |
payload와 고칠 수 없는 헤더 맵. STOMP 프레임 하나가 Message 하나가 된다 |
MessageChannel / MessageHandler |
넣는 구멍과 받는 쪽. 부품들은 서로를 부르지 않고 채널로만 이어진다 |
ExecutorSubscribableChannel |
구독한 핸들러 모두에게 준다. Executor가 있으면 다른 스레드에서 |
ChannelInterceptor |
preSend에서 메시지를 바꾸거나 null로 막는다. 핸드셰이크 인터셉터와 달리 메시지마다 걸린다 |
| 핸드셰이크 | HTTP GET 한 번. WebSocketHttpRequestHandler → HandshakeHandler → 서버의 업그레이드 |
SubProtocolWebSocketHandler |
WebSocketHandler이자 MessageHandler. WebSocket과 채널 사이의 경계 |
StompSubProtocolHandler |
프레임 ↔ Message. 들어올 때 세션 id와 사용자를 헤더에 적는다 |
clientInboundChannel |
클라이언트가 보낸 모든 것. 세 핸들러가 받고 접두사로 가른다 |
SimpAnnotationMethodMessageHandler |
/app/..을 @MessageMapping 메서드로. 반환값은 brokerChannel로 |
brokerChannel |
서버 쪽에서 브로커로 가는 길. 기본은 Executor 없음 |
SimpleBrokerMessageHandler |
구독을 기억하고, 메시지를 구독마다 하나씩 만들어 내보낸다 |
clientOutboundChannel |
클라이언트로 나가는 모든 것. SubProtocolWebSocketHandler가 구독한다 |
SimpMessagingTemplate |
서버 코드가 직접 보낼 때. brokerChannel로 들어간다 |
@EnableWebSocketMessageBroker |
@Import 하나로 설정 클래스 세 층을 들여와 위의 빈을 전부 만든다 |
다음에 볼 것
이번 글로 1편 지도의 Phase 5(웹)가 끝났습니다. 20편의 바닥에서 시작해 서블릿 MVC, WebFlux, 그리고 메시징까지 왔습니다.
이번 글의 예제 3에서는 서버를 띄우지 않고 가짜 세션으로 흐름을 돌려 봤습니다. 서버 없이 Spring의 조각을 돌려 보는 일을 제대로 도와주는 모듈이 spring-test입니다. 테스트마다 컨테이너를 새로 띄우지 않고 재사용하는 방법, JUnit과 Spring이 만나는 자리, 서버 없이 MVC를 돌리는 MockMvc가 거기에 있습니다. 26편: spring-test — SpringExtension, TestContext, MockMvc에서 이어집니다.