[Spring] STOMP + WebSocket으로 구현한 실시간 채팅 플로우

띠용·2026년 1월 21일

우테코 7기 BE

목록 보기
13/15

1. 배경

우테코 팀 프로젝트로 진행 중인 핏토링에서는 멘토와 멘티가 실시간 채팅을 통해 운동 상담을 진행할 수 있다.

"실시간 채팅 기능에 이미지, 동영상 등의 파일 전송 기능 추가"를 담당하게 되었다.
본격적인 설계 이전에 우리 프로젝트의 기존 채팅 과정을 단단히 이해하고 가기 위해 정리해봤다.

백엔드 환경 : Java, Spring

  • 스프링에서 실시간 채팅을 구현하는 방법 중 가장 널리 알려진 WebSocket + STOMP를 이용했다.

WebSocket과 STOMP

WebSocket

클라이언트와 서버가 양방향 통신을 할 수 있도록 하는 프로토콜, HTTP와 달리 연결이 유지되어 서버가 클라이언트에 먼저 데이터를 보낼 수 있다. 채팅, 온라인 게임, 실시간 알림 등에 활용된다.

  • 키워드는 실시간 통신, 양방향 통신

  • 웹 소켓은 TCP 위에서 동작하는 응용 계층 프로토콜이다. 3-way Handshake를 통해 TCP 연결한 뒤 HTTP 프로토콜로 Upgrade 요청을 보내 WebSocket Handshake를 수행하고 프로토콜을 전환한다.

이후 해당 연결을 유지하며 실시간, 양방향 통신을 지원한다.

STOMP

웹소켓이 실시간, 양방향 통신을 위한 프로토콜이라면 STOMP는 웹소켓 통신에서 주고 받을 메시지의 양식을 규정하는 프로토콜이다.

웹소켓 위에서 마치 HTTP처럼 정해진 양식으로 통신하도록 한다.
HTTP 메서드(GET, POST 등)비슷한 역할을 하는 COMMAND가 있다.
주요 커맨드는 아래와 같다.

  • CONNECT: 서버와 STOMP 연결을 맺을 때 사용
  • SUBSCRIBE: 특정 경로(예: /topic/1)를 구독하여 메시지를 받겠다고 선언
  • SEND: 특정 경로(예: /app/1)로 메시지를 보낼 때 사용
  • MESSAGE: 서버가 구독자들에게 메시지를 전달할 때 사용

스프링은 implementation 'org.springframework.boot:spring-boot-starter-websocket' 으로 웹소켓과 STOMP를 간단하게 사용할 수 있도록 지원한다.


핏토링 채팅 플로우

1. In HTTP

  1. 클라이언트가 채팅방에 입장하면 채팅방 화면 구성에 필요한 기본 정보와, 이전에 나눈 채팅 메시지 이력을 요청한다.
  2. 이후 웹소켓 연결을 시도한다. 우리 서버로의 웹소켓 연결 설정은 WebSocketConfig 클래스에서 정의해 두었다. 이 설정 클래스는 앞으로도 자주 등장한다.
    @RequiredArgsConstructor
      @Configuration
      @EnableWebSocketMessageBroker
      public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
      ...
          private final WebSocketAuthHandshakeInterceptor webSocketAuthHandshakeInterceptor;
      ...
          @Override
          public void registerStompEndpoints(StompEndpointRegistry registry) {
              registry.addEndpoint("/ws-chat")
                      .setAllowedOriginPatterns(
                              PROD,
                              DEV,
                              LOCAL
                      )
                      .addInterceptors(webSocketAuthHandshakeInterceptor)
                      .withSockJS();
          }
    registerStompEndpoints()에서 하는 일은 아래와 같다.
    1. 우리 서버에 STOMP 형식의 웹소켓 연결을 요청하기 위한 엔드포인트를 설정.

      • 우리는 로컬, 개발, 운영 서버의 엔드포인트를 환경변수로 분리했다
    2. 엔드포인트에 HandshakeInterceptor를 설정. 웹소켓 핸드셰이크 시 인터셉터의 동작을 정의할 수 있다.
      우리는 여기서 JWT 토큰을 검사하고, 회원정보를 attributes에 저장한다.

      public class WebSocketAuthHandshakeInterceptor implements HandshakeInterceptor {
      			...
        @Override
        public boolean beforeHandshake(
                ServerHttpRequest request,
                ServerHttpResponse response,
                WebSocketHandler wsHandler,
                Map<String, Object> attributes
        ) {
            if (request instanceof ServletServerHttpRequest servletRequest) {
                HttpServletRequest httpServletRequest = servletRequest.getServletRequest();
                Cookie[] cookies = httpServletRequest.getCookies();
                validateCookie(cookies);
                String token = jwtExtractor.extractTokenFromCookie(TOKEN_NAME, cookies);
                jwtProvider.validateToken(token);
      		Long memberId = jwtProvider.getSubjectFromPayloadBy(token);
                attributes.put(LOGIN_INFO_KEY, new LoginInfo(memberId));
            return true;
        }
    3. SockJS를 활성화한다.

      • SockJS : 웹소켓 연결을 지원하지 않는 브라우저나 네트워크 장애 시에도 자동으로 대안(HTTP polling 등)을 찾아 연결을 돕는 라이브러리
      • STOMP와 호환이 좋아 Spring 환경에서 자주 사용된다.
      • 연결 확인을 위해 주기적으로 하트비트 메시지를 보내는 녀석. (“h” 메시지의 주범)

2. In WebSocket

  1. 웹소켓으로 프로토콜이 전환되면, 클라이언트는 메시지 브로커에 SUBSCRIBE 메시지를 날려 토픽 구독을 요청한다. 메시지 브로커는 해당 토픽 구독자 명단에 클라이언트를 추가한다.

    • 메시지 브로커 : 토픽과 Pub/Sub을 관리하는 주체, Spring은 내장된 SimpleMessageBroker를 사용한다. 위에서 봤던 WebSocketConfig 클래스에서 경로 등록과 함께 활성화 할 수 있다.
      @Override
       public void configureMessageBroker(MessageBrokerRegistry config) {
           config.enableSimpleBroker("/topic");
           config.setApplicationDestinationPrefixes("/app");
       }
      • enableSimpleBrokerSimpleMessageBroker에게 "/topic" 하위의 엔드포인트를 토픽으로 관리하라는 설정이다.
      • setApplicationDestinationPrefixes 애플리케이션 경로를 설정하는 것이다. 기본적으로 STOMP에서는 HTTP의 GET 메서드가 @GetMapping 을 찾는 것처럼 요청이 컨트롤러 메서드를 찾지 않고 바로 메시지브로커에게 전달된다. 위와 같이 애플리케이션 경로를 설정해두면 "/app" 하위의 url은 앱 내부의 @MessageMapping 컨트롤러 메서드를 찾는다.
  2. 클라이언트가 채팅을 하면 SEND /app/chatroom/{chatRoomId} 로 메시지를 전송한다.

    • WebSocketConfig 클래스의 configureClientInboundChannel()에 인터셉터를 추가해두면 아까 WebSocket Handshake 때 attributes 에 넣어둔 회원 정보를 꺼내 @MessageMapping컨트롤러 메서드에 파라미터로 주입받을 수 있다.
      요런 식으로

      public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
      	...
          @Override
          public void configureClientInboundChannel(ChannelRegistration registration) {
              registration.interceptors(stompAuthChannelInterceptor);
          }
      }
      
      @RequiredArgsConstructor
      @Component
      public class StompAuthChannelInterceptor implements ChannelInterceptor {
      
        @Override
        public Message<?> preSend(Message<?> message, MessageChannel channel) {
            StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
      
            if (StompCommand.SEND.equals(accessor.getCommand())) {
                LoginInfo loginInfo = (LoginInfo) Objects.requireNonNull(accessor.getSessionAttributes())
                        .get(WebSocketAuthHandshakeInterceptor.LOGIN_INFO_KEY);
                accessor.setHeader(WebSocketAuthHandshakeInterceptor.LOGIN_INFO_KEY, loginInfo);
            }
            return message;
        }
      }
      @MessageMapping("/chatroom/{chatRoomId}")
      public void chat(
              @DestinationVariable("chatRoomId") Long chatRoomId,
              @Valid ChatMessageRequest request,
              @Header(WebSocketAuthHandshakeInterceptor.LOGIN_INFO_KEY) LoginInfo loginInfo
      ) {
      
  3. @MessageMapping 메서드가 메시지를 받는다.

    이후 DB에 메시지를 저장하고, 브로커의 토픽에 메시지를 보낸다.
    메시지 전송에는 SimpMessageTemplate.convertAndSend()을 활용했다. 응답 DTO를 JSON으로 직렬화 하고 STOMP의 MESSAGE 커맨드를 씌워 보내준다.
    메시지 전송에 @SendTo를 활용하는 방법도 있으니 궁금하시면 찾아보시길.

    messagingTemplate.convertAndSend("/topic/chatroom/" + chatRoomId, response);
  4. SimpleMessageBroker 가 토픽을 구독하고 있는 구독자들에게 MESSAGE를 전달한다.

0개의 댓글