Java 아키텍처·Spring 용어 사전
실시간@MessageMapping · SimpMessagingTemplate · @SendTo

STOMP

raw WebSocket 위에 목적지·구독 개념을 얹은 메시징 규약. 스프링이 컨트롤러 모델을 그대로 재사용하게 해 준다.

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는 사용자 레지스트리를 노드끼리 공유해야 동작한다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 웹 — MVC·REST·검증·예외·WebSocket/STOMP