
나인멘스모리스라는 1:1 실시간 턴제 게임을 웹으로 구현하기 위해서 예전에 STOMP를 사용해서 서버를 구현한 적이 있었다.
STOMP를 사용한 이유는 순수 WebSocket으로만 구현하려면 이것저것 복잡한 세팅들을 전부 구현해주어야했으나 STOMP 자체에서 제공하는 많은 기능들로 인해서 선택해서 사용했었다. ex) Pub/Sub …
내가 직접 게임을 해보고 싶어서 만든 프로젝트였고, 상업용이나 홍보를 하지도 않아서 친구들과 즐기기에 적합한 서비스였다.
하지만 이번에 Sent를 진행하면서 기술 학습을 중심으로 프로젝트를 진행하다보니 채팅 기능을 구현하려고 했다.
이 역시도 WebSocket으로 간단하게 처리가 가능할 것이다.
하지만 좀 더 다양한 예시와 가용성이 높은 시스템을 구축하고 싶어 메세지 큐의 사용을 고려하게 되었고 RabbitMQ, Kafka 중 Kafka를 사용해보고 싶었지만 메세지 큐 사용 자체가 처음이라는 점, 러닝 커브가 높다는 점 등을 고려하여 RabbitMQ로 학습을 진행하고 설계까 잘 되었다면 추후 여유가 생겼을 때 Kafka 연습도 해볼 예정이다.
RabbitMQ는 끝에 붙어있는 말 그대로 MQ, Message Queue이다.
자료구조에서 학습했던 그 큐가 맞다.
기본적으로 Spring에서 제공하는 Simple Broker는 데이터를 JVM 메세지에 저장하는 특징을 가지고 있다.
이의 문제점은 당연스럽게도 서버가 다운될 시 메모리가 초기화 되면서 이전에 쌓였던 항목들이 전부 유실된다.
무식한 방법으로는 매 순간 마다 DB에 데이터를 적재하면 해결할 수 있겠으나, 이러면 애초에 JVM 메세지에 저장할 필요도 없고 리소스, 부하 등등 다양한 이슈가 터지기 마련이다.
또한 사용자가 증가하여 많은 사용자가 보낸 메세지들을 전부 메모리에 적재하게 될 경우 내장되어있는 Simple Broker는 서버의 메모리를 정말 많이 차지하게 될 것이고 과부하로 흘러갈 수 있다.
RabbitMQ를 사용하면 클라이언트가 메세지를 보냈을 때 중간자 역할을 하는 메세지 브로커가 해당 메세지를 큐에 적재한다.
@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
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로 돌아갈 것이라고 생각했다.
app/chat.send 로 들어온 STOMP 프레임이 ChatController 에서 잡힌다.Producer 가 RabbitTemplate으로 RabbitMQ chat.exchange 에 메시지를 발행한다.Consumer 가 @RabbitListener 로 chat.queue 를 구독하다가 메세지를 받으면, 다시 Simple Broker가 관리하는 /topic/chat/{roomId} 경로로 브로드캐스트한다.즉 브라우저는 RabbitMQ를 만나지 않고, Spring 서버 안에서 한번 더 돌아서 나오는 2단 브리지 구조를 띈다.
나는 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 로 전송 | 아직 아무 데도 저장 안 됨 |
| 2 | Controller → RabbitTemplate → RabbitMQ | RabbitMQ 큐 (durable 옵션이면 디스크에도 기록) |
| 3 | @RabbitListener 가 큐에서 꺼냄 | 이 순간 큐에서 빠져나감 |
| 4 | SimpMessagingTemplate 이 /topic/chat/... 으로 publish | JVM 메모리(Simple Broker 버퍼) |
| 5 | 버퍼에 있는 동안 WebSocket 세션들로 push | 클라이언트 브라우저 |
의문점이 이렇게 하나 또 파생되었다.
rabbitTemplate.convertAndSend() 를 호출하면 메세지는 RabbitMQ 큐에 적재된다.@RabbitListener 가 Consumer에서 큐를 구독(consume)하면 ACK(확인) 과 동시에 메시지는 큐에서 삭제된다.@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 브로커로 직접 전달된다.
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 서버가 모든 메시지를 직접 브로드캐스트하지 않고, 브로커가 메시지 라우팅과 큐잉을 담당한다.
한 줄 요약하면:
또 이점이 하나 있는데 이렇게 구성할 시 위에서 구현했던 Producer, Consumer 같은 걸 전부 구현할 필요가 없어진다.
STOMP Broker Relay를 쓰면 Spring 서버가 큐에 직접 publish/subscribe 할 필요가 없다는 의미이다.
STOMP Broker Relay 사용 시
SimpMessagingTemplate.convertAndSend("/topic/room1", message) 만 호출위의 코드에서 /topic, /app 을 직관적이지 않아서 개인적인 취향대로 수정했다.
클라이언트가 서버에 메세지를 보낼 때는 prefix로 /pub 을 붙여서 전송하기로 정했다.
| 역할 | 경로 예시 | 설명 |
|---|---|---|
| 채팅 메시지 발행 | /pub/chat.message.{roomId} | 클라이언트 → 서버 → STOMP Broker Relay → RabbitMQ |
Client → 서버로 메시지 전송 시, 항상 prefix를 붙여야 한다.
STOMP Broker Relay라고 지칭하는 이유는 이제 Simple Broker가 아니기 때문이다.
Spring 내장 브로커
서버가 메세지를 직접 브로드 캐스트
클라이언트 → Spring 서버 → Spring SimpleBroker → 구독 클라이언트
외부 STOMP Broker(RabbitMQ)등 바로 메세지를 전달
서버는 중계 역할을 최소화
클라이언트 → Spring 서버 → RabbitMQ STOMP Broker → 구독 클라이언트
특징:
.enableStompBrokerRelay(...)로 지정한 destination (/topic, /queue 등)으로 메시지 relay
서버는 메시지를 가공하거나 검증만 하고, 브로드캐스트는 브로커가 처리
다중 서버 환경에서 메시지 중복 문제 없음
즉, STOMP Broker Relay라고 부르는 이유는 “메시지가 Simple Broker를 거치지 않고 바로 RabbitMQ로 relay 된다”는 의미이다.
서버에서 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} | 클라이언트가 해당 채팅방 메시지를 실시간 수신 |
여기서 또 의문점을 가질 수 있다.
클라이언트가 /topic/chat/room1 구독 → 브로커가 임시 큐를 생성하고 fanout exchange에 바인딩
클라이언트가 /topic/chat/room1 로 메세지 발행 → 브로커는 이 메세지를 fanout exchange로 전달
fanout exchange는 바인딩 된 모든 임시 큐(구독자)로 메시지 브로드캐스트
즉, /exchange를 따로 지정하지 않아도 /topic만으로 자동적으로 exchange+queue 매핑이 만들어져 메시지가 전달된다.
요약: /exchange는 선택적 고급 설정이고, 일반 브로드캐스트는 /topic만으로 충분히 처리된다는 것이다.
/topic으로 구독하면, 브로커가 내부적으로 exchange와 queue를 생성/topic으로 보내면, 이미 바인딩된 임시 큐가 메시지를 받음/exchange를 먼저 구독할 필요는 없고, 단지 특정 exchange를 직접 제어하고 싶을 때만 사용/exchange의 역할/exchange/...는 특정 exchange를 직접 구독하고 연결할 때 사용/exchange는 이 exchange를 통해 메시지를 받을 구독자를 브로커가 관리하는 용도나는 Topic이 아닌 exchange를 사용해서 라우팅키를 가지고 구독과 발행을 따랐다.

RabbitMQ UI를 들어가보면 이렇게 여러가지지가 있는 것을 살펴볼 수 있다.
여기서 가장 아래 chat.exchange는 내가 지정한 설정 값이다.
이것들에 대한 각각의 특징은 정리하지는 않겠다,,
fanout: exchange에 바인딩된 모든 큐에 같은 메시지 브로드캐스트
이것만 알면 될 것 같다.
클라이언트 서버(WebSocket/STOMP) RabbitMQ
/pub/chat.message.room1 -> ChatController -> STOMP Broker Relay
-> /topic/chat/room1 -> 구독 클라이언트
/pub/... (ApplicationDestinationPrefix + 컨트롤러 MessageMapping)/topic/... (브로커 relay 경로)/exchange/...나 /queue/...로 직접 매핑 가능registry.setApplicationDestinationPrefixes("/pub")
클라이언트 → /pub/chat.message.{roomId} → Spring 서버 → STOMP Broker Relay → RabbitMQ
@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)
}
}
enableStompBrokerRelay("/topic", "/queue", "/exchange")로 지정한 브로커 relay 경로클라이언트 → SUBSCRIBE /topic/chat/{roomId} → RabbitMQ exchange/queue → 클라이언트
// 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으로 다시 요청을 보낼 필요는 없음나인멘스모리스 때는 메세지 큐를 사용하지않고 그다지 복잡하지 않다고 여겼는데 메시지 큐의 개념과 섞이면서 머릿 속이 여러번 혼잡해졌었다.
그리고 테스트를 할 때도 뜻대로 동작하지 않고 오류가 계속 발생해서 상당히 많은 시간을 쏟았다.
지금도 위의 내용대로 이해한 바를 적었으나, 정말 정확하게 내가 이해하고 있는 것이 맞을까? 라는 의구심이 든다.
계속해서 구현해나가고 있으니 정리하고 다시 읽어보면서 오류가 있는 곳은 수정해나갈 것이다.