Spring WebSocket + RabbitMQ STOMP 연동

임동혁 Ldhbenecia·2025년 8월 17일

SpringBoot

목록 보기
24/28
post-thumbnail

개요

나인멘스모리스라는 1:1 실시간 턴제 게임을 웹으로 구현하기 위해서 예전에 STOMP를 사용해서 서버를 구현한 적이 있었다.

STOMP를 사용한 이유는 순수 WebSocket으로만 구현하려면 이것저것 복잡한 세팅들을 전부 구현해주어야했으나 STOMP 자체에서 제공하는 많은 기능들로 인해서 선택해서 사용했었다. ex) Pub/Sub …

내가 직접 게임을 해보고 싶어서 만든 프로젝트였고, 상업용이나 홍보를 하지도 않아서 친구들과 즐기기에 적합한 서비스였다.

하지만 이번에 Sent를 진행하면서 기술 학습을 중심으로 프로젝트를 진행하다보니 채팅 기능을 구현하려고 했다.
이 역시도 WebSocket으로 간단하게 처리가 가능할 것이다.

하지만 좀 더 다양한 예시와 가용성이 높은 시스템을 구축하고 싶어 메세지 큐의 사용을 고려하게 되었고 RabbitMQ, Kafka 중 Kafka를 사용해보고 싶었지만 메세지 큐 사용 자체가 처음이라는 점, 러닝 커브가 높다는 점 등을 고려하여 RabbitMQ로 학습을 진행하고 설계까 잘 되었다면 추후 여유가 생겼을 때 Kafka 연습도 해볼 예정이다.

RabbitMQ

RabbitMQ는 끝에 붙어있는 말 그대로 MQ, Message Queue이다.
자료구조에서 학습했던 그 큐가 맞다.

기본적으로 Spring에서 제공하는 Simple Broker는 데이터를 JVM 메세지에 저장하는 특징을 가지고 있다.
이의 문제점은 당연스럽게도 서버가 다운될 시 메모리가 초기화 되면서 이전에 쌓였던 항목들이 전부 유실된다.

무식한 방법으로는 매 순간 마다 DB에 데이터를 적재하면 해결할 수 있겠으나, 이러면 애초에 JVM 메세지에 저장할 필요도 없고 리소스, 부하 등등 다양한 이슈가 터지기 마련이다.

또한 사용자가 증가하여 많은 사용자가 보낸 메세지들을 전부 메모리에 적재하게 될 경우 내장되어있는 Simple Broker는 서버의 메모리를 정말 많이 차지하게 될 것이고 과부하로 흘러갈 수 있다.

RabbitMQ를 사용하면 클라이언트가 메세지를 보냈을 때 중간자 역할을 하는 메세지 브로커가 해당 메세지를 큐에 적재한다.

WebSocketConfig

@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig : WebSocketMessageBrokerConfigurer {

    override fun configureMessageBroker(registry: MessageBrokerRegistry) {
        registry.enableSimpleBroker("/topic")
        registry.setApplicationDestinationPrefixes("/app")
    }

    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/ws-chat")
            .setAllowedOriginPatterns("*")
    }
}

Controller, Producer, Consumer

@Controller
class ChatController(
    private val chatMessageProducer: ChatMessageProducer,
) {
    @MessageMapping("/chat.send")
    fun sendChatMessage(
        message: ChatMessage,
    ) {
        chatMessageProducer.sendChatMessage(
            message.copy(
            ...
            ),
        )
    }
}

@Service
class ChatMessageProducer(
    private val rabbitTemplate: RabbitTemplate,
    private val rabbitProperties: RabbitProperties,
) {

    fun sendChatMessage(message: ChatMessage) {
        rabbitTemplate.convertAndSend(
            rabbitProperties.chatExchange.name,
            rabbitProperties.chatRouting.key,
            message,
        )
    }
}

@Service
class ChatMessageConsumer(
    private val messagingTemplate: SimpMessagingTemplate,
    private val rabbitProperties: RabbitProperties,
) {

    @RabbitListener(queues = ["#{rabbitProperties.chatQueue.name}"])
    fun receiveChatMessage(message: ChatMessage) {
        messagingTemplate.convertAndSend("/topic/chat/${message.roomId}", message)
    }
}
                          Broker
Producers -> [Exchange -- Binding --> Queue] -> Consumers

기본적으로 RabbitMQ는 AMQP를 구현한 메세지 브로커 시스템이다.

메시지를 발행하는 Producer 에서 Broker 의 Exchange 로 메시지를 전달하면, Binding 이라는 규칙에 의해 연결된 Queue 로 메시지가 복사된다.

구현단에서 생긴 의문점

여기서 의문점은 이렇게 구현을 할 시 WebSocket은 Simple Broker를 사용하게 된다.

WebSocket 자체는 Simple Broker를 사용하는데 컨트롤러단에서 RabbitMQ를 사용하는 Producer로 요청을 보내면 어떻게 돌아가는 것일까?

브라우저 ──WS/STOMP──▶ Spring (SimpleBroker) ──AMQP──▶ RabbitMQ
                         ▲                         │
                         └──── @RabbitListener ◀───┘

이러한 Flow로 돌아갈 것이라고 생각했다.

  1. 클라이언트 → 서버: app/chat.send 로 들어온 STOMP 프레임이 ChatController 에서 잡힌다.
  2. 서버 → RabbitMQ: ProducerRabbitTemplate으로 RabbitMQ chat.exchange 에 메시지를 발행한다.
    이러면 RabbitMQ가 메세지를 durable queue에 저장한다.
  3. RabbitMQ → 서버: Consumer@RabbitListenerchat.queue 를 구독하다가 메세지를 받으면, 다시 Simple Broker가 관리하는 /topic/chat/{roomId} 경로로 브로드캐스트한다.

즉 브라우저는 RabbitMQ를 만나지 않고, Spring 서버 안에서 한번 더 돌아서 나오는 2단 브리지 구조를 띈다.

왜 /exchange + routing-key 를 택했고 /topic 은 쓰지 않았나?

나는 chat.room.*으로 모든 채팅방을 처리하지 않고 유연성을 고려하여 exchange를 사용하였다.

/exchange/chat.exchange/chat.room.* 는 AMQP topic-exchange 를 그대로 노출하므로 방이 늘어나도 코드 수정 없이 라우팅키 패턴만으로 큐를 자동 매칭할 수 있고,
TTL·DLQ·재시도·멀티바인딩 같은 RabbitMQ 정책을 큐마다 세밀하게 걸 수 있다.
물론 이러한 세부적인 기능은 당장 사용할 일은 없을 것 같으나 확장성을 위해서 사용해보기로 했다.

반대로 /topic/** 는 STOMP가 만든 내부 fan-out 채널이라 라우팅키 제어나 정책 적용이 제한되기 때문이다.

목적달성 여부
비동기 버퍼링: 컨트롤러가 느려도 메시지를 잃지 않기✅ RabbitMQ 큐에 잠시 쌓임
다른 백엔드 서비스와 이벤트 공유✅ 동일 큐/익스체인지로 구독 가능
WebSocket 세션 복잡도 최소화✅ SimpleBroker가 알아서 세션 관리
다중 인스턴스 간 채팅 동기화❌ JVM 메모리 브로커끼리는 서로 모름
메시지 내구성: 서버 내려가도 보존❌ SimpleBroker 쪽은 날아감
구조 단순화❌ Producer·Consumer·템플릿 코드가 추가됨

결국 Simple Broker를 쓰는한 위에서 말했던 발행되지 못한 메세지들은 서버가 다운되면 유실된다.

그러면 여기서 RabbitMQ가 하는 일은 컨트롤러-쪽에서 비동기 버퍼 역할을 하고 다른 백엔드가 같은 큐를 구독할 수 있게 이벤트 허브 역할을 한다.

하지만 채팅 내역을 최종적으로 보존하고, 여러 인스턴스가 같은 방을 공유하는 책임은 Simple Broker가 못 지므로, 그 부분은 그대로 취약하다.

왜 이런 반쪽짜리 상황이 생기는가?

단계일어나는 일어디에 저장되나
1클라이언트가 ‎⁠/app/chat.send⁠ 로 전송아직 아무 데도 저장 안 됨
2Controller → ‎⁠RabbitTemplate⁠ → RabbitMQRabbitMQ 큐 (durable 옵션이면 디스크에도 기록)
3‎⁠@RabbitListener⁠ 가 큐에서 꺼냄이 순간 큐에서 빠져나감
4‎⁠SimpMessagingTemplate⁠ 이 ‎⁠/topic/chat/...⁠ 으로 publishJVM 메모리(Simple Broker 버퍼)
5버퍼에 있는 동안 WebSocket 세션들로 push클라이언트 브라우저

의문점이 이렇게 하나 또 파생되었다.

RabbitMQ를 거치는데 어째서 데이터가 유실되는가?

  1. 큐에 남아있다가 사라지는 순간
    1. 컨트롤러가 프로듀서로 메세지 내역을 전송하고 Producer에서 rabbitTemplate.convertAndSend() 를 호출하면 메세지는 RabbitMQ 큐에 적재된다.
    2. @RabbitListener 가 Consumer에서 큐를 구독(consume)하면 ACK(확인) 과 동시에 메시지는 큐에서 삭제된다.
    3. 그런데 ACK가 끝난 후 브로드캐스트를 하기 직전에 서버가 다운될 경우?
      1. 큐에서 이미 메세지는 빠져나간 이후이다.
      2. Simple broker 메모리에서 메모리가 사라진다.
      3. 유실

@RabbitListener 의 경우 기본 모드가 auto-ack이기 때문에 리스너 메서드가 정상적으로 종료되면 RabbitMQ가 자동으로 ACK → 큐에서 삭제를 진행한다.

이후 단계는 Simple Broker 레이어단에서 처리하기 때문에 RabbitMQ는 이제 알 방도가 없기 때문에 아무것도 처리해줄 수 없다.

요약 – 현재 구조로 발생하는 유실의 본질 

큐에 있는 동안은 안전하지만,
큐 → 브로드캐스트 사이가 다른 계층(Simple Broker)이어서 끊어진다.

해결은 브로커를 하나로 통일(STOMP Relay)하거나 ACK 시점을 늦추고 실패 시 재주입하는 방법뿐이다.
실시간과 보관은 책임이 다르므로, NoSQL 로그 저장을 별도로 두는 것이 업계 표준이다.

물론 사용자도 거의 없고 연습용으로 인스턴스 한 대로 돌릴거면 Simple Broker는 나인멘스모리스에서 말했듯이 충분하다.
하지만 목표는 가용성과 데이터 유실 같은 이러한 문제를 해결하기 위해 RabbitMQ를 도입한만큼 추가해보자.

@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig(
    private val authHandshakeInterceptor: AuthHandshakeInterceptor,
    private val stompProps: RabbitStompProperties,
) : WebSocketMessageBrokerConfigurer {

    override fun configureMessageBroker(registry: MessageBrokerRegistry) {
        registry.enableStompBrokerRelay("/topic", "/queue", "/exchange", "/amq/queue")
            .setRelayHost(stompProps.host)
            .setRelayPort(stompProps.port)
            .setClientLogin(stompProps.username)
            .setClientPasscode(stompProps.password)
            .setSystemLogin(stompProps.username)
            .setSystemPasscode(stompProps.password)
        registry.setApplicationDestinationPrefixes("/pub")
    }

    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/ws-chat")
            .setAllowedOriginPatterns("*")
            .addInterceptors(authHandshakeInterceptor)
    }
}

이렇게 WebSocket 세팅을 Spring의 STOMP Broker Relay를 사용한다.

클라이언트(WebSocket/STOMP)는 /topic/queue, /exchange/chat.exchange.routingkey 경로로 메시지를 publish/subscribe하며,
Spring 서버가 중간에서 메시지를 처리하는 대신, 외부 RabbitMQ 브로커로 직접 전달된다.

  • Client 세션: 클라이언트 메시지를 브로커로 전달할 때 사용되는 계정
  • System 세션: 서버가 내부에서 메시지를 publish/subscribe할 때 사용되는 계정
    • 처음에 이 값을 넣지 않아서 오류가 발생했었다. 넣으면 해결된다.
      o.s.m.s.s.StompBrokerRelayMessageHandler|Received ERROR {message=[Bad CONNECT], content-type=[text/plain], version=[1.0,1.1,1.2], content-length=[31]} session=_system_ text/plain payload=Access refused for user 'guest'

SimpleBroker와 달리, Spring 서버가 모든 메시지를 직접 브로드캐스트하지 않고, 브로커가 메시지 라우팅과 큐잉을 담당한다.

한 줄 요약하면:

  • SimpleBroker → 서버가 직접 메시지 중계
  • STOMP Broker Relay → 외부 브로커(RabbitMQ)가 메시지 중계

또 이점이 하나 있는데 이렇게 구성할 시 위에서 구현했던 Producer, Consumer 같은 걸 전부 구현할 필요가 없어진다.

STOMP Broker Relay를 쓰면 Spring 서버가 큐에 직접 publish/subscribe 할 필요가 없다는 의미이다.

STOMP Broker Relay 사용 시

  • Controller에서 SimpMessagingTemplate.convertAndSend("/topic/room1", message) 만 호출
  • Spring이 STOMP Broker Relay로 바로 메시지를 보내고, RabbitMQ가 클라이언트에 라우팅
  • Producer/Consumer를 별도로 만들 필요 없음
  • 서버는 메시지를 검증하거나 전처리만 하고, 큐에 직접 publish/subscribe할 필요가 없음

메세지 발행(Publish)

위의 코드에서 /topic, /app 을 직관적이지 않아서 개인적인 취향대로 수정했다.
클라이언트가 서버에 메세지를 보낼 때는 prefix로 /pub 을 붙여서 전송하기로 정했다.

역할경로 예시설명
채팅 메시지 발행/pub/chat.message.{roomId}클라이언트 → 서버 → STOMP Broker Relay → RabbitMQ

Client서버로 메시지 전송 시, 항상 prefix를 붙여야 한다.

STOMP Broker Relay라고 지칭하는 이유는 이제 Simple Broker가 아니기 때문이다.

  • Simple Broker
    • Spring 내장 브로커

    • 서버가 메세지를 직접 브로드 캐스트

      클라이언트 → Spring 서버 → Spring SimpleBroker → 구독 클라이언트
  • STOMP Broker Relay
    • 외부 STOMP Broker(RabbitMQ)등 바로 메세지를 전달

    • 서버는 중계 역할을 최소화

      클라이언트 → Spring 서버 → RabbitMQ STOMP Broker → 구독 클라이언트

      특징:

    • .enableStompBrokerRelay(...)로 지정한 destination (/topic, /queue 등)으로 메시지 relay

    • 서버는 메시지를 가공하거나 검증만 하고, 브로드캐스트는 브로커가 처리

    • 다중 서버 환경에서 메시지 중복 문제 없음

즉, STOMP Broker Relay라고 부르는 이유는 “메시지가 Simple Broker를 거치지 않고 바로 RabbitMQ로 relay 된다”는 의미이다.

메시지 구독 (Subscribe)

서버에서 STOMP Broker Relay를 사용할 때 destination 경로는
registry.enableStompBrokerRelay("/topic", "/queue", "/exchange", "/amq/queue") 을 사용해야 한다.

클라이언트는 이 중 하나로 구독(subscribe) 하면 된다.

  • /topic/... → 브로드캐스트 메시지 (여러 클라이언트에게 전달)
  • /queue/... → 1:1 메시지 (큐잉)
  • /exchange/... → 특정 exchange를 직접 구독할 때

이 규칙은 공식 문서를 따랐다.

참조: https://www.rabbitmq.com/docs/stomp#d

역할경로 예시설명
채팅 구독/exchange/chat.exchange/chat.{roomId}클라이언트가 해당 채팅방 메시지를 실시간 수신

여기서 또 의문점을 가질 수 있다.

  1. /exchange는 구독 전용인데 topic을 통한 브로드캐스트를 날리면 구독은 뭐 어떻게 처리가 되는 걸까?
  • RabbitMQ STOMP 동작 방식을 알아보자.
    1. 클라이언트가 /topic/chat/room1 구독 → 브로커가 임시 큐를 생성하고 fanout exchange에 바인딩

    2. 클라이언트가 /topic/chat/room1 로 메세지 발행 → 브로커는 이 메세지를 fanout exchange로 전달

    3. fanout exchange는 바인딩 된 모든 임시 큐(구독자)로 메시지 브로드캐스트

      즉, /exchange를 따로 지정하지 않아도 /topic만으로 자동적으로 exchange+queue 매핑이 만들어져 메시지가 전달된다.

      요약: /exchange는 선택적 고급 설정이고, 일반 브로드캐스트는 /topic만으로 충분히 처리된다는 것이다.

  1. /exchange를 먼저 하고 /topic을 하거나 그렇게 해야하는게 아닐까?
  • 순서나 선후관계는 필요 없음
    • 구독하는 클라이언트가 /topic으로 구독하면, 브로커가 내부적으로 exchange와 queue를 생성
    • 메시지 발행 시 /topic으로 보내면, 이미 바인딩된 임시 큐가 메시지를 받음
  • /exchange를 먼저 구독할 필요는 없고, 단지 특정 exchange를 직접 제어하고 싶을 때만 사용
  1. /exchange의 역할
  • STOMP Broker Relay에서 /exchange/...특정 exchange를 직접 구독하고 연결할 때 사용
  • 구독(Client → Broker) 시 브로커가 해당 exchange에 연결된 큐를 생성/바인딩
  • 즉, /exchange는 이 exchange를 통해 메시지를 받을 구독자를 브로커가 관리하는 용도
  • Spring 서버 입장에서는 구독자 정보를 직접 관리할 필요 없이, 브로커가 알아서 세션 단위로 큐를 만들고 메시지를 전달

나는 Topic이 아닌 exchange를 사용해서 라우팅키를 가지고 구독과 발행을 따랐다.

  1. fanout exchange는?

RabbitMQ UI를 들어가보면 이렇게 여러가지지가 있는 것을 살펴볼 수 있다.
여기서 가장 아래 chat.exchange는 내가 지정한 설정 값이다.

이것들에 대한 각각의 특징은 정리하지는 않겠다,,

fanout: exchange에 바인딩된 모든 큐에 같은 메시지 브로드캐스트

이것만 알면 될 것 같다.

  • 채팅방 구독자마다 별도의 임시 큐 존재
  • 큐는 fanout exchange에 바인딩되어 메시지를 브로드캐스트
  • 서버는 메시지를 relay만 하고, 구독자 관리는 브로커가 담당

요약

클라이언트                서버(WebSocket/STOMP)          RabbitMQ
 /pub/chat.message.room1  ->  ChatController             -> STOMP Broker Relay
                                                 -> /topic/chat/room1 -> 구독 클라이언트
  • 발행 경로: /pub/... (ApplicationDestinationPrefix + 컨트롤러 MessageMapping)
  • 구독 경로: /topic/... (브로커 relay 경로)
  • 필요 시 /exchange/.../queue/...로 직접 매핑 가능

발행 경로 (Publish)

  • Spring WebSocket 설정에서 지정한 ApplicationDestinationPrefix
registry.setApplicationDestinationPrefixes("/pub")
  • 의미: 클라이언트가 백엔드 서버로 메시지를 보내는 경로
  • 예시: 채팅 메시지 발행
클라이언트 → /pub/chat.message.{roomId} → Spring 서버 → STOMP Broker Relay → RabbitMQ
  • 즉, 프론트엔드가 서버로 요청(request) 보내는 경로라고 보면 된다.
@Controller
class ChatController(
    private val messagingTemplate: SimpMessagingTemplate
) {

    @MessageMapping("chat.message.{roomId}")  // 실제 클라이언트 경로: /pub/chat.message.123
    fun sendChatMessage(
        @DestinationVariable roomId: String,
        message: ChatMessage
    ) {
        // 메시지에 필요한 추가 정보 세팅
        val processedMessage = message.copy(
            timestamp = System.currentTimeMillis(),
            id = message.id
        )

        // 서버에서 바로 브로커로 전달 (구독자에게 브로드캐스트)
        messagingTemplate.convertAndSend("/topic/chat/$roomId", processedMessage)
    }
}

구독 경로 (Subscribe)

  • enableStompBrokerRelay("/topic", "/queue", "/exchange")로 지정한 브로커 relay 경로
  • 의미: 클라이언트가 메시지를 받기 위해 구독(subscribe)하는 경로
  • 예시: 채팅방 메시지 수신
클라이언트 → SUBSCRIBE /topic/chat/{roomId} → RabbitMQ exchange/queue → 클라이언트
  • 서버는 발행 메시지를 브로커로 relay할 뿐, 구독자 정보는 브로커가 관리
// 1) 구독 예시
stompClient.subscribe('/exchange/chat.exchange/chat.room.' + roomId, function(messageOutput) {
    const message = JSON.parse(messageOutput.body);
    console.log("받은 메시지:", message);
    // 여기서 브라우저 UI에 메시지 표시
});

// 2) 메시지 발행
function sendMessage(msg) {
    stompClient.send("/pub/chat.message.123", {}, JSON.stringify(msg));
}
  • 메시지를 보내는 건 /pub 경로로 하고
  • 받은 메시지는 자동으로 /topic 구독 콜백에서 처리됨
  • 즉, 클라이언트가 /topic으로 다시 요청을 보낼 필요는 없음

결론

나인멘스모리스 때는 메세지 큐를 사용하지않고 그다지 복잡하지 않다고 여겼는데 메시지 큐의 개념과 섞이면서 머릿 속이 여러번 혼잡해졌었다.
그리고 테스트를 할 때도 뜻대로 동작하지 않고 오류가 계속 발생해서 상당히 많은 시간을 쏟았다.

지금도 위의 내용대로 이해한 바를 적었으나, 정말 정확하게 내가 이해하고 있는 것이 맞을까? 라는 의구심이 든다.
계속해서 구현해나가고 있으니 정리하고 다시 읽어보면서 오류가 있는 곳은 수정해나갈 것이다.

profile
지극히 평범한 공대생

0개의 댓글