namalzi

← 글 목록

Spring 소스 파헤치기 25: WebSocket과 메시징 — STOMP 메시지는 어떻게 흐르나

Spring · 게시

브라우저가 보낸 STOMP 메시지 하나가 @MessageMapping 메서드를 거쳐 구독자들에게 퍼지기까지를 소스로 따라갑니다. spring-messaging의 Message·채널·핸들러, spring-websocket의 핸드셰이크, 그리고 둘을 잇는 채널 세 개와 간단한 브로커를 WebSocket을 처음 보는 사람 기준으로 확인합니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 스물다섯 번째 글입니다. 24편까지는 요청 하나에 응답 하나로 끝나는 HTTP를 봤습니다. 이번 글은 1편의 지도에서 Phase 5(웹)의 마지막 단위 U23, 연결을 열어 둔 채 양쪽이 아무 때나 말을 거는 WebSocket과 그 위에서 메시지를 나르는 메시징입니다. 채팅방에 한 사람이 쓴 글이 방에 있는 모두의 화면에 뜨는 일이 Spring 안에서 어떻게 일어나는지를 따라갑니다.

답하려는 질문은 네 가지입니다.

항목 내용
기준 소스 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;
	}

spring-messaging/src/main/java/org/springframework/messaging/support/ExecutorSubscribableChannel.java 95–114행

구독한 핸들러마다 SendTask를 하나 만들고, 채널에 Executor(스레드 풀)가 있으면 거기에 맡기고 없으면 그 자리에서 실행합니다. 구독자 목록은 CopyOnWriteArraySet입니다(AbstractSubscribableChannel 34행).

채널에 메시지를 보낼 때의 흐름. preSend에서 인터셉터가 null을 돌려주면 false를 반환하고 핸들러는 불리지 않는다. 아니면 sendInternal이 구독한 핸들러마다 SendTask를 만들고, 채널에 Executor가 없으면 보낸 스레드가, 있으면 풀의 스레드가 handleMessage를 부른다. 그 뒤 postSend와 afterSendCompletion을 거쳐 true를 반환한다. 파란색은 인터셉터가 끼어드는 자리입니다. 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() 반환값 줄보다 먼저 찍히기도 합니다.

헷갈리기 쉬운 곳

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);
		}
		// ...
	}

spring-websocket/src/main/java/org/springframework/web/socket/server/support/WebSocketHttpRequestHandler.java 159–195행

세 줄이 뼈대입니다. HandshakeInterceptor들의 beforeHandshake를 부르고(하나라도 false면 끝), HandshakeHandler.doHandshake에 맡기고, afterHandshake를 부릅니다. attributes 맵은 인터셉터가 채워 두면 나중에 WebSocketSession.getAttributes()로 꺼낼 수 있는 값들입니다.

doHandshake의 기본 구현은 AbstractHandshakeHandler에 있습니다(168–230행). 하는 일은 검사 여럿과 위임 하나입니다.

  1. HTTP 메서드가 GET(또는 HTTP/2용 CONNECT)이 아니면 405(177–185행).
  2. GET이면 Upgrade 헤더가 WebSocket인지, Connection 헤더에 Upgrade가 있는지, Sec-WebSocket-Key가 있는지 봅니다. 아니면 400(186–204행).
  3. WebSocket 버전과 Origin을 검사합니다. Origin이 허용되지 않으면 403(205–212행).
  4. 클라이언트가 요청한 서브프로토콜 가운데 핸들러가 지원하는 것을 하나 고릅니다(219행, 297–308행). STOMP가 선택되는 자리입니다.
  5. 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 GET 요청을 WebSocketHandlerMapping이 WebSocketHttpRequestHandler로 보낸다. HandshakeInterceptor의 beforeHandshake가 false면 끝나고, true면 DefaultHandshakeHandler의 doHandshake가 메서드와 헤더와 Origin을 검사한다. 통과하면 RequestUpgradeStrategy가 서버에게 연결을 WebSocket으로 바꾸게 하고, WebSocketHandler의 afterConnectionEstablished가 불린다. 초록 상자부터는 HTTP가 아닙니다. 이 뒤에 오는 메시지는 DispatcherServlet을 거치지 않습니다.

헷갈리기 쉬운 곳

3. STOMP가 얹히는 자리

WebSocketHandler.handleMessage가 받는 것은 글자 덩어리일 뿐입니다. 그 덩어리를 STOMP 프레임으로 읽어 1절의 Message로 바꾸는 다리가 둘 있습니다.

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);
		}
		// ...
	}

spring-websocket/src/main/java/org/springframework/web/socket/messaging/SubProtocolWebSocketHandler.java 347–408행

위는 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);
				// ...
		}
	}

spring-websocket/src/main/java/org/springframework/web/socket/messaging/StompSubProtocolHandler.java 248–378행

  1. decoder.decode가 바이트를 STOMP 프레임 단위로 잘라 Message<byte[]>의 목록으로 만듭니다. 프레임의 명령과 헤더는 Message의 헤더 안으로, 본문은 payload로 갑니다(StompDecoder, 50행).
  2. 세션 id, 세션 속성, 사용자를 헤더에 적어 넣습니다. STOMP 프레임에는 "누가 보냈는지"가 없고 그것은 WebSocket 연결이 알고 있기 때문입니다. 이 뒤의 부품들은 WebSocketSession을 모르고 헤더의 세션 id만 봅니다.
  3. 채널에 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행).

SubProtocolWebSocketHandler 와 StompSubProtocolHandler 가 잇는 두 방향. 들어오는 방향: 클라이언트가 보낸 STOMP 프레임(글자)을 SubProtocolWebSocketHandler.handleMessage(session, WebSocketMessage) 가 WebSocketHandler 로서 받고, StompSubProtocolHandler.handleMessageFromClient 가 ① decoder.decode 로 Message<byte[]> 목록을 만들고(명령·헤더는 헤더로, 본문은 payload 로) ② 세션 id·세션 속성·사용자를 헤더에 적은 뒤 ③ clientInboundChannel.send(message) 로 보낸다. 나가는 방향: 시작할 때 스스로 구독한 clientOutboundChannel 에서 SubProtocolWebSocketHandler.handleMessage(Message) 가 MessageHandler 로서 받고, StompSubProtocolHandler.handleMessageToClient 가 헤더를 STOMP 헤더로 바꾸고 StompEncoder 로 바이트를 만들어 session.sendMessage 로 클라이언트에게 STOMP 프레임을 보낸다 같은 클래스가 WebSocketHandler로서 받고 MessageHandler로서 보냅니다. 노란 칸이 4절에서 볼 채널입니다.

헷갈리기 쉬운 곳

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가 받은 프레임을 Message로 바꿔 clientInboundChannel에 넣는다. 이 채널을 SimpAnnotationMethodMessageHandler, UserDestinationMessageHandler, SimpleBrokerMessageHandler가 구독한다. 어노테이션 핸들러의 반환값과 사용자 목적지 핸들러의 결과, 그리고 서버 코드의 SimpMessagingTemplate은 brokerChannel로 들어가고, 브로커가 그것을 받아 구독자별 메시지를 clientOutboundChannel에 넣으면 SubProtocolWebSocketHandler가 프레임으로 바꿔 내보낸다. 노란색이 채널, 파란색이 채널을 구독한 핸들러입니다. 위아래의 SubProtocolWebSocketHandler는 같은 객체입니다.

clientInboundChannel에 붙은 세 핸들러는 메시지를 전부 받습니다. 그리고 각자 목적지의 앞부분(접두사)을 보고 자기 것만 처리합니다. 흔히 쓰는 설정(/app은 우리 메서드로, /topic은 브로커로)을 기준으로 하나씩 봅니다.

SimpAnnotationMethodMessageHandler — @MessageMapping을 부르는 쪽

22편의 RequestMappingHandlerMapping과 RequestMappingHandlerAdapter가 하던 일을 한 클래스가 합니다. 부모는 AbstractMethodMessageHandler입니다.

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행).

레지스트리의 기본 구현은 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);
					}
					// ...
				}
			}
		});
	}

spring-messaging/src/main/java/org/springframework/messaging/simp/broker/SimpleBrokerMessageHandler.java 391–423행

구독 하나마다 새 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행).

헷갈리기 쉬운 곳

나머지 둘: 사용자 목적지와 외부 브로커

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 {

}

spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/EnableWebSocketMessageBroker.java 63–69행

9편에서 본 대로 @EnableXxx는 @Import입니다. 들여오는 설정 클래스는 세 층입니다.

설정 클래스의 세 층. EnableWebSocketMessageBroker가 DelegatingWebSocketMessageBrokerConfiguration을 Import하고, 이 클래스는 WebSocketMessageBrokerConfigurationSupport를, 그것은 다시 AbstractMessageBrokerConfiguration을 상속한다. spring-websocket 쪽 층은 subProtocolWebSocketHandler와 stompWebSocketHandlerMapping 빈을, spring-messaging 쪽 층은 채널 셋과 핸들러들과 brokerMessagingTemplate을 만든다. 아래로 갈수록 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행).

헷갈리기 쉬운 곳

6. 메시지 하나의 여정, 그리고 SimpMessagingTemplate

부품을 다 봤으니 처음부터 끝까지 이어 봅니다. 클라이언트가 /app/hello로 SEND를 보내고, @MessageMapping("/hello") 메서드의 반환값이 /topic/greetings 구독자에게 MESSAGE로 가는 길입니다.

메시지 하나의 여정. 클라이언트의 SEND 프레임을 StompSubProtocolHandler가 Message로 바꿔 clientInboundChannel에 넣고, SimpAnnotationMethodMessageHandler가 접두사 /app을 떼고 메서드를 부른다. SendToMethodReturnValueHandler가 반환값을 brokerChannel로 보내면 SimpleBrokerMessageHandler가 구독자를 찾아 구독마다 메시지를 만들어 clientOutboundChannel에 넣고, StompSubProtocolHandler가 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;
	}

spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java 483–496행

생성자에 넘긴 채널이 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)의 스레드 이름을 따라가면 위 그림이 그대로 보입니다.

4)에서는 SimpMessagingTemplate을 부른 main 스레드가 brokerChannel부터 시작합니다. clientInboundChannel은 지나지 않습니다.

예제 3의 3)과 4)를 스레드 이름으로 따라간 그림. 3) 클라이언트의 SEND /app/hello: main 스레드가 type=MESSAGE, 목적지 /app/hello 인 메시지를 clientInboundChannel 에 넣고, clientInboundChannel-1 스레드가 GreetingController.greet("남알지") 를 부른 뒤 같은 스레드로 brokerChannel(/topic/greetings)을 지나 clientOutboundChannel 에 넣으며, clientOutboundChannel-1 스레드가 sendMessage 로 MESSAGE 프레임 '안녕하세요, 남알지!' 를 쓴다. 4) 서버 코드의 SimpMessagingTemplate.convertAndSend: main 스레드가 brokerChannel(/topic/greetings)에서 시작해 clientOutboundChannel 에 넣고, clientOutboundChannel-1 스레드가 sendMessage 로 MESSAGE 프레임 '공지입니다' 를 쓴다. 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이 요청을 처리하는 스레드의 이름입니다.

출력을 본문의 설명과 맞춰 보면 이렇습니다.

예제 3에서 볼 수 있던 프레임의 글자와 SessionConnectEvent 같은 이벤트는 이 예제에서는 찍지 않았습니다. 반대로 핸드셰이크와 서버의 스레드는 이 예제에서만 보입니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

큰 줄기를 위해 많은 것을 뺐습니다. 이름과 위치만 적어 둡니다. 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에서 이어집니다.

다른 글