raw WebSocket 위에 목적지와 구독 개념을 얹은 텍스트 메시징 규약.
왜 필요한가
-
WebSocket 은 '바이트를 나르는 통로' 일 뿐이다
- 이 메시지가 어느 방 것인지 표현할 방법이 없다
- 구독·해지 개념이 없다
- 요청·응답 상관관계가 없다
-
직접 만들면 결국 '목적지 헤더 + 명령어' 를 다시 발명하게 된다
- 그 자리를 표준으로 채운 것이 STOMP 다
프레임과 목적지 규칙
- CONNECT · SUBSCRIBE · SEND · UNSUBSCRIBE · DISCONNECT
/app 으로 SEND → 접두어를 떼고 @MessageMapping 컨트롤러로 라우팅
/topic 구독 → 브로커가 구독자 전원에게 팬아웃 (1:N)
/queue 구독 → 특정 사용자에게 (1:1)
/user 접두어 → 세션을 찾아 개인 목적지로 변환
@MessageMapping("/chat") // 클라이언트가 /app/chat 으로 SEND
@SendTo("/topic/room") // 반환값을 구독자에게 브로드캐스트
public ChatMessage handle(ChatMessage in) { return in; }
컨트롤러 바깥에서 밀어야 하면 SimpMessagingTemplate.convertAndSend("/topic/room", msg)를 쓴다. 도메인 이벤트를 받아 푸시하는 코드가 대개 이 모양이다.
내장 브로커의 한계
enableSimpleBroker("/topic", "/queue")
- 구독 정보가 그 인스턴스의 '메모리' 에 있다
- 인스턴스가 둘 이상이면 자기에게 붙은 구독자에게만 닿는다
증상: 로컬·단일 노드에서는 완벽히 동작하다가
- 파드를 2개로 늘린 순간 '메시지가 절반만 온다'
외부 브로커 릴레이
registry.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("rabbitmq").setRelayPort(61613);
모든 노드가 같은 브로커를 공유하므로 팬아웃이 전 노드에 닿는다 (RabbitMQ 는 rabbitmq_stomp 플러그인이 필요하다)
대가
- 브로커가 단일 장애점이 된다 → 클러스터로 띄운다
- 릴레이 연결이 끊기면 구독 전달만 멈추고 컨트롤러는 계속 돈다
- 어중간한 상태다. 릴레이 상태를 반드시 지표로 뽑는다
인증이 HTTP와 다르게 흐른다
핸드셰이크는 평범한 HTTP 요청 → 필터 체인을 탄다 그 뒤의 STOMP 프레임은 필터를 거치지 않는다
- 연결 시점의 Principal 을 세션에 실어 두고,
- 프레임 단위 인가는 ChannelInterceptor 로 CONNECT·SUBSCRIBE 를 가로챈다
SUBSCRIBE 검사를 빠뜨리면 남의 목적지를 구독하는 구멍이 생긴다 /topic/room.{id} 처럼 목적지에 식별자가 들어갈 때 실제로 자주 나온다
메시지 흐름을 채널로 보면 구조가 보인다
-
clientInboundChannel — 클라이언트 → 서버 (SEND · SUBSCRIBE)
-
clientOutboundChannel — 서버 → 클라이언트 (브로커가 밀어 주는 것)
-
brokerChannel — 애플리케이션 → 브로커 (convertAndSend)
-
세 채널 모두 스레드 풀을 갖고, ChannelInterceptor 를 걸 수 있다
-
인가·로깅·메트릭을 이 지점에 넣는다
기본 풀 크기는 코어 수 기반이라 메시지가 폭주하면 여기서 큐가 쌓인다 느린 핸들러가 하나 있으면 채널 전체가 밀린다 — 무거운 일은 별도 실행기로 넘긴다
세션 스코프가 아니라 메시지 스코프다
@MessageMapping 메서드는 HTTP 요청 스코프 밖에서 돈다
- RequestContextHolder 가 비어 있다
- SecurityContextHolder 도 기본적으로 비어 있다
- (Spring Security 의 메시지 보안 지원을 켜야 채워진다)
- @Transactional 은 평소처럼 동작한다 (프록시 기반이므로)
HTTP 컨트롤러의 습관을 그대로 옮기면 조용히 null 을 만나는 자리다
언제 STOMP를 쓰지 않나
- 메시지 종류가 한두 가지뿐이다 → raw WebSocket 이 단순하다
- 서버→클라이언트 단방향이다 → 애초에 SSE 가 맞다
- 이미 자체 프로토콜이 있다 → 굳이 얹을 이유가 없다
STOMP 가 값을 하는 지점은 '목적지가 여러 개이고 구독 관리가 필요할 때' 다 채팅방·협업 문서·실시간 대시보드가 전형적이다
면접 함정
- ❌ "WebSocket을 쓰면 STOMP가 따라온다" → 별개다. STOMP는 그 위에 얹는 선택지다.
- ❌ "브로커를 붙였으니 사용자별 전송도 된다" →
convertAndSendToUser는 사용자 레지스트리를 노드끼리 공유해야 동작한다.