본문 바로가기

Project/기능 개발

Springboot로 Web Socket 상에서 STOMP 메시지 브로커 적용하기

개요

 지난 포스팅에서는 특정 URL로 전달되는 웹소켓 요청을 처리하는 웹소켓 핸들러를 구현했습니다. 웹소켓 핸들러 내부에 컨커런트 해시맵 자료구조를 두고, 웹소켓 연결이 체결된 클라이언트의 세션을 관리했습니다. 하지만 이 방식은 다음의 단점이 존재했습니다.

 

  1. URL(채팅방) 마다 웹소켓 요청을 처리할 웹소켓 핸들러를 구현해 매핑해야 한다.
  2. 메시지 인식에 문제가 없도록 클라이언트와 서버 간 전달할 데이터 형식을 정해야 한다.
  3. 메시지를 전달하기 위한 웹소켓 세션 관리 로직(메시지 채널링)을 개발자가 직접 작성해야 한다.

 

 이번 포스팅에서는 스프링이 제공하는 내장 심플 메시지 브로커를 사용해 1,2,3번 모두 해결해 보겠습니다. 그전에 Simple Message Broker는 STOMP 메시징 프로토콜을 사용하므로 STOMP부터 살펴보겠습니다.

 

STOMP 메시징 프로토콜

 클라이언트와 서버 간 웹소켓 연결이 체결되어 스트림을 통해 데이터가 송수신될 때, 데이터를 일정한 규칙에 따라 형식에 맞춰 읽고 쓰는 것이 효율적일 것입니다. 이처럼 데이터의 형식을 규정한 프로토콜 중 하나가 바로 STOMP입니다.

 

STOMP 메시지는 프레임이라는 단위로 송수신합니다. 프레임의 구조는 다음과 같습니다.

COMMAND
header1:value2
header1:value2

Body^@

 

COMMAND에 올 수 있는 명령어 중 SUBSCRIBE, SEND, MESSAGE 세 가지를 살펴보겠습니다. 다음은 클라이언트가 구독을 요청하는 STOMP 프레임입니다. 

SUBSCRIBE
id:sub-1
destination:/topic/price.stock.*

^@

 

위 클라이언트는 SUBSCRIBE 커맨드에 따라 /topic/price.stock.* 토픽(채널)을 구독했고, 이 정보는 메모리에 저장됩니다.

SEND
destination:/queue/trade
content-type:application/json
content-length:44

{"action":"BUY","ticker":"MMM","shares",44}^@

 

SEND 커맨드는 destination의 prefix 문자열에 따라 메시지 처리 방법이 두 갈래로 나뉩니다. 관행적으로 "/topic" prefix는 채널을 의미하며, 이 채널에 메시지가 발행되어 구독자들에게 브로드캐스팅 됩니다. "/queue" prefix는 애플리케이션 내 특정 로직을 시작할 엔드 포인트를 의미합니다.

 

위 프레임에서는 /queue/trade 데스티네이션을 사용했으므로, 애플리케이션 내 주식을 매입하는 엔드포인트를 호출할 것입니다.

MESSAGE
message-id:nxahklf6-1
subscription:sub-1
destination:/topic/price.stock.MMM

{"ticker":"MMM","price":129.45}^@

 

MESSAGE 커맨드는 서버(메시지 브로커)가 /topic/price.stock.MMM 토픽(채널)에 메시지를 발행함을 의미합니다. 메시지 브로커는 이전 프레임에서 /topic/price.stock.* 을 구독했던 클라이언트에게 위 프레임의 body 데이터를 전달합니다.

 

 스프링의 내장형 심플 메시지 브로커는 데이터 송수신에 위와 같은 STOMP 메시징 프로토콜을 사용합니다. 이제 심플 메시지 브로커의 동작 원리를 살펴보겠습니다.

 

Simple Message Broker 동작 방식

 심플 메시지 브로커는 STOMP 프레임의 커맨드에 따라 크게 세 가지로 처리합니다.

Spring 제공 built-in Simple Message Broker

  • SUBSCRIBE: 클라이언트를 destination 토픽(채널)에 구독시킵니다. (구독 정보는 메모리에 저장)
  • SEND: destination 헤더의 prefix 문자열에 따라 destination 토픽(채널)에 메시지를 발행하거나, 애플리케이션 내 특정 로직을 동작시킵니다.
  • MESSAGE: 메시지 브로커가 destination으로 메시지를 발행합니다.

 

이제 코드 수준에서 위 동작의 구현을 살펴보도록 하겠습니다.

 

내장 메시지 브로커를 적용한 채팅 애플리케이션 개발

 지난 포스팅에서 의존성을 추가했던 org.springframework.boot:spring-boot-starter-websocket 모듈에는 내장 메시지 브로커가 포함되어 있습니다.

 

WebSocketConfig

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws") // 1. 웹소켓 연결을 체결할 엔드포인트
                .setAllowedOrigins("*");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.setApplicationDestinationPrefixes("/api/send"); // 2. 애플리케이션 엔드포인트를 호출할 destination 헤더 prefix
        config.enableSimpleBroker("/channel"); // 3. 토픽(채널)으로 동작할 destination 헤더 prefix
    }
}
  1. 클라이언트는 ws://domain.com/ws 경로로 웹소켓 연결을 체결합니다.
  2. STOMP Frame의 COMMAND가 SEND일 때, destination 헤더가 "/api/send"로 시작한다면, 애플리케이션의 특정 엔드포인트를 호출하도록 설정합니다. 여기서 엔드포인트란, 컨트롤러의 @MessageMapping 애너테이션이 달린 핸들러메서드를 의미합니다.
  3. 메시지를 발행해 구독자들에게 브로드캐스팅 할 수 있는 토픽(채널)을 지정합니다.

 

SocketController

@Controller
public class SocketController {

    private final SimpMessagingTemplate template; // 1. 메시지 전송 메서드를 제공하는 클래스

    public SocketController(SimpMessagingTemplate template) {
        this.template = template;
    }

    @MessageMapping("/{channel-id}") // 2. destination prefix + /{channel-id} 경로가 매핑 됨
    public void handle(@DestinationVariable("channel-id") Long channelId, ChatDto chatDto) {
        template.convertAndSend("/channel/" + channelId, chatDto); // 3. 채널에 메시지를 발행
    }
}
  1. SimpMessagingTemplate 클래스는 SimpMessageSeningOperations 인터페이스의 구현체로, 클라이언트에게 메시지를 전송할 수 있는 메서드들을 제공합니다.
  2. STOMP Frame의 COMMAND가 SEND일 때, destination 헤더 값이 /api/send/{channel-id} 라면 위 handle 메서드(엔드포인트)가 호출됩니다.
  3. convertAndSend 메서드는 첫 번째 인자로 destination을, 두 번째 인자로 payload를 받습니다. destination을 구독 중인 클라이언트에게 STOMP Frame의 바디 부분에 payload를 담아 브로드캐스팅 합니다.

 

ChatDto

STOM Frame의 바디로 전달할 ChatDto는 간단히 작성했습니다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ChatDto {
    private Long senderId;
    private String content;
}

 

테스트

1번 탭의 엔드포인트 호출

1번 탭에서 /api/send/1 destination(큐)로 STOMP Frame을 전송하면, @MessageMapping 엔드포인트를 호출합니다.

 

2번 탭의 결과 화면

2번 탭은 1번 탭과 동일하게 /channel/1 destination(토픽)을 SUBSCRIBE 했기 때문에, 1번이 전송한 프레임이 잘 도착한 것을 확인할 수 있습니다.

 

2번 탭의 메시지 브로드캐스팅

이번에는 엔드포인트를 호출하는 destination 헤더 prefix인 /api/send 대신, 바로 토픽(채널)으로 메시지 브로드캐스팅이 가능한 preifx인 /channel을 설정해 보았습니다.

 

1번 탭의 메시지 수신

2번 탭에서 /channel/1 토픽으로 브로드캐스팅한 STOMP Frame이 구독자 1번 탭으로 잘 수신되었습니다.

 

정리

  1. Web Socket은 클라이언트와 서버가 연결을 유지한 채 양방향 통신이 가능한 프로토콜을 말한다.
  2. STOMP 메시징 프로토콜은 클라이언트와 서버가 통신하는 방식과 주고받는 데이터의 형식을 규칙으로 정한 것이다. 데이터 형식을 STOMP Frame이라고 한다.
  3. Spring 내장 심플 메시지 브로커는 웹소켓 연결 위에서 스트림으로 STOMP Frame을 주고받는다.

 

Reference