How Spring Handle WebSocket

@MJ · 7 min read
Created Date · 2025년 08월 12일 09:08
Updated Date· 2025년 08월 20일 09:08

how_spring_injects_httpsession 이 포스트를 읽고 오면 이해가 더 잘 됩니다!

Spring에서 어떻게 WebSocket 연결을 수립할까? 그 과정을 찾아가보자.

Spring에서 연결방식을 WebSocket으로 업그레이드 하는 방법

HTTP Request (GET /ws) 
	↓ 
DispatcherServlet.doService() 
	↓ 
DispatcherServlet.doDispatch() 
	↓ 
HandlerMapping.getHandler() (여기서 WebSocketHandlerMapping 사용) 
	↓ 
HandlerAdapter.handle() (여기서 HttpRequestHandlerAdapter 사용) 
	↓ 
WebSocketHttpRequestHandler.handleRequest() 
	↓ 
DefaultHandshakeHandler.doHandshake()
	↓ 
RequestUpgradeStrategy.upgrade() (HTTP 연결이 WebSocket 연결로 업그레이드)
	↓
DispatcherServlet 완전히 빠짐
	↓ 
이후 모든 메시지는 WebSocket 핸들러가 직접처리

handshake 성공 후에는?

  • 물리적 TCP 연결은 동일함
  • 메시지들은 DispatcherServlet을 안거쳐감
  • Tomcat/Jetty가 직접 WebSocket 핸들러로 라우팅
  • Spring WebSocket 인프라가 직접 처리

흐름 비교

HTTP 요청

TCP → Tomcat → DispatcherServlet → Controller → 응답 

WebSocket 메시지 (handshake 성공 후)

TCP → Tomcat WebSocket Container → SubProtocolWebSocketHandler → ChannelInterceptor → @MessageMapping

클라이언트에서 서버로

WebSocket Frame (STOMP CONNECT)
    ↓
SubProtocolWebSocketHandler.handleMessage() (서블릿 컨테이너가 호출함)
    ↓  
StompSubProtocolHandler.handleMessageFromClient()
    ↓
clientInboundChannel로 Spring Message 전송
    ↓
ChannelInterceptor (커스텀한 인터셉터가 여기서 동작!)
    ↓
SimpleBroker 또는 외부 브로커
    ↓
@MessageMapping 컨트롤러 (전후로 presend, postsend)

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();  
    }  
    
    // 프로토콜 핸들러 찾기 (STOMP, etc, ...)
    SubProtocolHandler protocolHandler = findProtocolHandler(session);  

	// 위임하기
    protocolHandler.handleMessageFromClient(session, message, this.clientInboundChannel);  
    if (holder != null) {  
       holder.setHasHandledMessages();  
    }  
}

StompSubProtocolHandler.handleMessageFromClient() 호출

@Override  
public void handleMessageFromClient(WebSocketSession session,  
       WebSocketMessage<?> webSocketMessage, MessageChannel targetChannel) {  
  
    /* 위에서 생략된 내용들
    1. WebSocket 메시지 -> ByteBuffer로 변환
    2. STOMP 프레임 디코딩
    3. 순서 보장처리 (하나의 메시지가 프레임단위로 쪼개져서 올 수도 있음)
    */

	// 4. 각 STOMP 메시지별 처리
    for (Message<byte[]> message : messages) {  
       StompHeaderAccessor headerAccessor =  
             MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);  

       StompCommand command = headerAccessor.getCommand();  
       boolean isConnect = StompCommand.CONNECT.equals(command) || StompCommand.STOMP.equals(command);  
  
       boolean sent = false;  
       try {  
		  // 5. 세션 정보를 Spring Message 헤더에 설정
          headerAccessor.setSessionId(session.getId());  
          headerAccessor.setSessionAttributes(session.getAttributes());  
          headerAccessor.setUser(getUser(session));  
          if (isConnect) {  
             headerAccessor.setUserChangeCallback(user -> {  
                if (user != null && user != session.getPrincipal()) {  
                   this.stompAuthentications.put(session.getId(), user);  
                }  
             });  
          }  
          headerAccessor.setHeader(SimpMessageHeaderAccessor.HEART_BEAT_HEADER, headerAccessor.getHeartbeat());  
          
          try {  
            // 여기서 clientInboudChannel로 전송! 
            // → channelToUse.send()
            // → AbstractMessageChannel.send() 
            // → ChannelInterceptorChain.applyPreSend() 
            // → ChannelInterceptor.preSend() 호출됨! (Custom)
            // → 실제메시지 처리 (@MessageMapping)
            // → SimpleBrokerMessageHandler의 SubscriptionRegistry에 구독정보 저장
            // → ChannelInterceptorChain.applyPostSend()
            // → ChannelIntercaptor.postSend() 호출됨 (Custom)
	         SimpAttributesContextHolder.setAttributesFromMessage(message); 
             sent = channelToUse.send(message);  
  
             if (sent) {  
                if (this.eventPublisher != null) {  
                   Principal user = getUser(session);  
                   if (isConnect) {  
                      publishEvent(this.eventPublisher, new SessionConnectEvent(this, message, user));  
                   }  
                   else if (StompCommand.SUBSCRIBE.equals(command)) {  
                      publishEvent(this.eventPublisher, new SessionSubscribeEvent(this, message, user));  
                   }  
                   else if (StompCommand.UNSUBSCRIBE.equals(command)) {  
                      publishEvent(this.eventPublisher, new SessionUnsubscribeEvent(this, message, user));  
                   }  
                }  
             }  
          }  
          finally {  
             SimpAttributesContextHolder.resetAttributes();  
          }  
       }  
}

서버에서 클라이언트로!

@Controller에서 SimpMessagingTemplate.send()
    ↓
clientOutboundChannel로 메시지 전송
	↓
SimpleBroker 또는 외부 브로커
    ↓
SubProtocolWebSocketHandler.handleMessage()
    ↓
StompSubProtocolHandler.handleMessageToClient()
    ↓
WebSocket Frame으로 변환해서 클라이언트 전송 (Tomcat/Jetty에서)

@Controller에서 SimpMessagingTemplate.send()

@MessageMapping("/room/{joinCode}/update-players")  
public void broadcastPlayers(@DestinationVariable String joinCode) {  
    final List<PlayerResponse> responses = roomService.getAllPlayers(joinCode)  
            .stream()  
            .map(PlayerResponse::from)  
            .toList();  
  
    messagingTemplate.convertAndSend("/topic/room/" + joinCode,  
            WebSocketResponse.success(responses));  
}

SimpMessagingTemplate 내부동작

@Override  
public void convertAndSend(D destination, Object payload, @Nullable Map<String, Object> headers,  
       @Nullable MessagePostProcessor postProcessor) throws MessagingException {  
  
    Message<?> message = doConvert(payload, headers, postProcessor);  
    send(destination, message);  
}

// send -> ... -> AbstractMessageChannel.send()
@Override  
public final boolean send(Message<?> message, long timeout) {  
    Assert.notNull(message, "Message must not be null");  
    Message<?> messageToUse = message;  
    ChannelInterceptorChain chain = new ChannelInterceptorChain();  
    boolean sent = false;  
    try {  

		// 커스텀한 interceptor의 presend 호출
       messageToUse = chain.applyPreSend(messageToUse, this);  
       if (messageToUse == null) {  
          return false;  
       }  
       
		// 등록된 모든 MessageHandler들에게 메시지 전달
		// SubProtocolWebSocketHandler.handleMessage 호출됨
       sent = sendInternal(messageToUse, timeout);

		// 커스텀한 interceptor의 postsend 호출
       chain.applyPostSend(messageToUse, this, sent);  
       chain.triggerAfterSendCompletion(messageToUse, this, sent, null);  
       return sent;  
    }  
    catch (Exception ex) {  
       chain.triggerAfterSendCompletion(messageToUse, this, sent, ex);  
       if (ex instanceof MessagingException messagingException) {  
          throw messagingException;  
       }  
       throw new MessageDeliveryException(messageToUse,"Failed to send message to " + this, ex);  
    }  
    catch (Throwable err) {  
       MessageDeliveryException ex2 =  
             new MessageDeliveryException(messageToUse, "Failed to send message to " + this, err);  
       chain.triggerAfterSendCompletion(messageToUse, this, sent, ex2);  
       throw ex2;  
    }  
}

SubProtocolWebSocketHandler.handleMessage() 호출

@Override  
public void handleMessage(Message<?> message) throws MessagingException {  

	// 1. 메시지에서 세션 id 추출
    String sessionId = resolveSessionId(message);  
    if (sessionId == null) {  
       if (logger.isErrorEnabled()) {  
          logger.error("Could not find session id in " + message);  
       }  
       return;  
    }  

	// 2. 해당 세션 찾기
    WebSocketSessionHolder holder = this.sessions.get(sessionId);  
    if (holder == null) {  
       if (logger.isDebugEnabled()) {  
          // The broker may not have removed the session yet  
          logger.debug("No session for " + message);  
       }  
       return;  
    }  
  
    WebSocketSession session = holder.getSession();  
    try {  
      // 3. 프로토콜 핸들러로 클라이언트에게 전송
     findProtocolHandler(session).handleMessageToClient(session, message);  
    }  
    catch (SessionLimitExceededException ex) {  
       try {  
          if (logger.isDebugEnabled()) {  
             logger.debug("Terminating '" + session + "'", ex);  
          }  
          else if (logger.isWarnEnabled()) {  
             logger.warn("Terminating '" + session + "': " + ex.getMessage());  
          }  
          this.stats.incrementLimitExceededCount();  
          clearSession(session, ex.getStatus()); // clear first, session may be unresponsive  
          session.close(ex.getStatus());  
       }  
       catch (Exception secondException) {  
          logger.debug("Failure while closing session " + sessionId + ".", secondException);  
       }  
    }  
    catch (Exception ex) {  
       // Could be part of normal workflow (for example, browser tab closed)  
       if (logger.isDebugEnabled()) {  
          logger.debug("Failed to send message to client in " + session + ": " + message, ex);  
       }  
    }  
}

StompSubProtocolHandler.handleMessageToClient() (실제 전송)

@Override  
@SuppressWarnings("unchecked")  
public void handleMessageToClient(WebSocketSession session, Message<?> message) {  
    // ... 생략
    
    // 순서 보장
    Runnable task = OrderedMessageChannelDecorator.getNextMessageTask(message);  
    if (task != null) {  
       Assert.isInstanceOf(ConcurrentWebSocketSessionDecorator.class, session);  
       ((ConcurrentWebSocketSessionDecorator) session).setMessageCallback(m -> task.run());  
    }  

	// 실제 클라이언트에게 전송
    sendToClient(session, accessor, payload);  
}
private void sendToClient(WebSocketSession session, StompHeaderAccessor stompAccessor, byte[] payload) {  
    StompCommand command = stompAccessor.getCommand();  
    try {  
       byte[] bytes = this.stompEncoder.encode(stompAccessor.getMessageHeaders(), payload);  
       boolean useBinary = (payload.length > 0 && !(session instanceof SockJsSession) &&  
             MimeTypeUtils.APPLICATION_OCTET_STREAM.isCompatibleWith(stompAccessor.getContentType()));  
	    // Tomcat/Jetty 컨테이너로 전달
       if (useBinary) {  
          session.sendMessage(new BinaryMessage(bytes));  
       }  
       else {  
          session.sendMessage(new TextMessage(bytes));  
       }  
    }  
    catch (SessionLimitExceededException ex) {  
       // Bad session, just get out  
       throw ex;  
    }  
    catch (Throwable ex) {  
       // Could be part of normal workflow (for example, browser tab closed)  
       if (logger.isDebugEnabled()) {  
          logger.debug("Failed to send WebSocket message to client in session " + session.getId(), ex);  
       }  
       command = StompCommand.ERROR;  
    }  
    finally {  
       if (StompCommand.ERROR.equals(command)) {  
          try {  
             session.close(CloseStatus.PROTOCOL_ERROR);  
          }  
          catch (IOException ex) {  
             // Ignore  
          }  
       }  
    }  
}

핵심 포인트

SubProtocolWebSocketHandler

  • SubProtocolWebSocketHandlerWebSocket ↔ Spring Messaging 브릿지 역할을 하는 핵심 컴포넌트임
  • WebSocketHandler 구현
    • WebSocket 컨테이너(Tomcat/Jetty)에서 호출
    • 클라이언트 → 서버 메시지 처리
    • WebSocketMessage → Spring Message 변환
  • MessageHandler 구현
    • clientOutboundChannel에 구독자로 등록됨
    • 서버 → 클라이언트 메시지 처리
    • Spring Message → WebSocket 프레임 변환하도록 Tomcat/Jetty 호출

ChannelInterceptor의 Presend/PostSend

  • Input 메시지 (clientInboundChannel):
    • preSend → @MessageMapping 실행 → postSend
  • Output 메시지 (clientOutboundChannel, 컨트롤러에서 전송):
    • preSend → 클라이언트 전송 → postSend
  • 그래서 채팅 하나 보내면 inbound 채널에서 preSend/postSend 한 번, outbound 채널에서 preSend/postSend 또 한 번 실행됨