STOMP
WebSocket 프로토콜은 두 가지 유형의 메시지를 정의하고 있지만, 그 메시지의 내용까지는 정의하고 있지 않다.
STOMP은 WebSocket 위에서 동작하는 프로토콜로써, 클라이언트와 서버가 전송할 메시지 유형, 형식, 내용들을 정의하는 매커니즘이다.
STOMP를 사용해 좀 더 편리하게 WebSocket을 사용할 수 있다. 다만 STOMP의 기능이 개발중인 서비스에 적절한지에 대한 판단은 필요하다.
STOMP 란?
STOMP은 Simple Text Oriented Messaging Protocol 약자로 TCP 또는 WebSocket 같은 양방향 네트워크 프로토콜 기반으로 동작한다.
이름에서도 알 수 있듯이, STOMP는 텍스트 지향 프로토콜이지만 Message Payload에는 Text 또는 Binary 데이터를 포함할 수도 있다. STOMP는 프레임 기반 프로토콜이며, 그 프레임은 HTTP를 모델로 한다.
Frame은 아래와 같은 형식을 가지고 있다.
COMMAND
header1:value1
header2:value2
Body^@
STOMP Protocol Specification, Version 1.2
클라이언트는 SEND 또는 SUBSCRIBE 명령을 사용하여 메시지를 보내거나 구독할 수 있으며, 메시지가 무엇인지, 누가 받아야 하는지를 설명하는 destination 헤더를 포함할 수 있다.
즉 Broker를 통해 다른 연결된 클라이언트로 메시지를 보내거나 서버에 작업을 요청할 수 있는 간단한 Publish-Subscribe 메커니즘을 사용할 수 있다.
만약 스프링에서 지원하는 STOMP을 사용하게 된다면, 스프링 WebSocket 애플리케이션은 STOMP Broker 역할을 한다.
스프링에서 지원하는 STOMP은 다양한 기능을 제공한다.
메시지를 @Controller의 메시지를 핸들링하는 메서드로 라우팅하거나, Simple In-Memory Broker를 이용해서 Subscribe중인 다른 클라이언트들에게 메시지를 브로드캐스팅한다. Simple In-Memory Broker는 클라이언트의 Subscribe 정보를 자체적으로 메모리에 유지한다. 뿐만 아니라, 스프링은 RabbitMQ, ActiveMQ 같은 외부 Messaging System을 STOMP Broker로 사용할 수 있도록 지원하고 있다. 이 경우에 스프링은 외부 STOMP Broker와 TCP 연결을 유지하고, 메시지를 브로커로 전달하며, 브로커로부터 연결된 WebSocket 클라이언트로 메시지를 전달한다.
위와 같은 구조 덕분에, 스프링 웹 애플리케이션은 'HTTP 기반의 보안 설정'과 '공통된 검증' 등을 적용할 수 있게 된다.
만약 클라이언트가 특정 경로에 대해서 아래와 같이 Subscribe한다면, 서버는 원할 때마다 클라이언트에게 메시지를 전송할 수 있다.
SUBSCRIBE
id:sub-1
destination:/topic/something.*
^@
또한 클라이언트는 서버에 메시지를 전달할 수 있는데, 서버는 @MessageMapping된 메서드를 통해서 해당 메시지를 처리할 수 있다. 그리고 서버는 Subscribe한 클라이언트들에게 메시지를 브로드캐스팅할 수 있다.
SEND
destination:/queue/something
content-type:application/json
content-length:38
{"key1":"value1","key2":"value2", 38}^@
STOMP 스펙에서는 의도적으로 Destination 정보를 불분명하게 정의하였는데, 이는 STOMP 구현체에서 문자열 구문에 따라 직접 의미를 부여하도록 하기 위해서이다.
따라서, Destination 정보는 STOMP 서버 구현체마다 달라질 수 있기 때문에 각 구현체의 스펙을 살펴봐야 한다.
그러나, 일반적으로 /topic 문자열로 시작하는 구문은 일대다(one-to-many) 관계의 publish-subscribe를 의미하고, /queue 문자열로 시작하는 구문은 일대일(one-to-one) 관계의 메시지 교환을 의미한다.
STOMP 서버는 MESSAGE COMMAND를 사용해서 모든 Subscriber들에게 메시지를 브로드캐스트할 수 있다.
MESSAGE
message-id:d4c0d7f6-1
subscription:sub-1
destination:/topic/something
{"key1":"value1","key2":"value2"}^@
STOMP Broker는 반드시 애플리케이션이 전달한 메시지를 구독한 클라이언트에게 전달해야 하며, 서버 메시지의 subscription 헤더는 클라이언트가 SUBSCRIBE한 id 헤더와 일치해야만 한다.
STOMP 장점
STOMP를 서브 프로토콜로 사용하면 Spring Framework와 Spring Security가 WebSocket만을 사용하는 것보다 더 풍부한 프로그래밍 모델을 제공한다. 다음은 예시다.
사용자 정의 메시징 프로토콜 및 메시지 형식을 발명할 필요가 없다:
- 이미 정의된 STOMP 프로토콜을 사용하면 새로운 프로토콜을 설계할 필요 없이 표준화된 방식으로 메시지를 주고받을 수 있다.
다양한 STOMP 클라이언트를 사용할 수 있다
- Spring Framework에 포함된 Java 클라이언트를 비롯하여 여러 STOMP 클라이언트가 제공됩니다. 이를 통해 손쉽게 STOMP를 사용할 수 있습니다.
메시지 브로커를 (선택적으로) 사용할 수 있습니다
- RabbitMQ, ActiveMQ 등과 같은 메시지 브로커를 사용하여 구독을 관리하고 메시지를 브로드캐스트할 수 있다.
- 이는 확장성과 관리 측면에서 큰 이점을 제공한다.
애플리케이션 로직을 @Controller 인스턴스에 조직화할 수 있다
- WebSocket 기반으로 각 커넥션마다 WebSocketHandler를 구현하는 것보다, @Controller된 객체를 이용해서 조직적으로 관리할 수 있다.
- 즉 메시지들은 STOMP의 Destination 헤더를 기반으로, @Controller 객체의 @MethodMapping 메서드로 라우팅된다.
Spring Security를 사용하여 메시지를 보안할 수 있다
- STOMP 목적지와 메시지 유형에 따라 메시지의 보안을 설정할 수 있어, 보다 안전한 메시징 시스템을 구축할 수 있다
STOMP 사용
서버
스프링은 WebSocket 또는 SockJS 기반으로 STOMP를 위해 spring-messaging and spring-websocket 모듈을 제공한다.
아래 예제와 같이, STOMP 설정을 할 수 있는데 기본적으로 커넥션을 위한 STOMP Endpoint를 설정해야만 한다.
addEndpoint 메서드를 통해 STOMP Endpoint를 설정할 수 있다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/simple")
.enableSimpleBroker("/topic", "/queue");
}
}
- / test 는 WebSocket 또는 SockJS 클라이언트가 WebSocket Handshake로 커넥션을 생성할 경로이다.
- /simple 경로로 시작하는 STOMP 메시지의 Destination 헤더는 @Controller 객체의 @MessageMapping 메서드로 라우팅된다.
- 내장된 메시지 브로커를 사용하여 클라이언트에게 subscriptions, broadcasting 기능을 지원한다.
- 또한, /topic 또는 /queue로 시작하는 Destination 헤더를 가진 메시지를 브로커로 라우팅한다.
내장된 Simple Message Broker는 /topic, /queue prefix에 대해 특별한 의미를 갖지 않는다.
/topic, /queue prefix는 단순히 메시지가 pub-sub, point-to-point 인지 여부를 나타내는 컨벤션일 뿐이며, 외부 브로커를 사용할 경우에는 해당 Destination 헤더 prefix가 달라질 수 있다.
Message Flow
일단 STOMP Endpoint를 노출하면, 스프링 애플리케이션은 연결된 클라이언트를 위한 STOMP Broker가 된다.
구성 요소
spring-message 모듈은 스프링 프레임워크의 통합된 메시징 애플리케이션을 위한 근본적인 지원을 한다.
다음 목록에서는 몇 가지 사용 가능한 메시징 추상화에 대해 설명한다.
- Message는 headers와 payload를 포함하는 메시지의 단순 표현이다.
- MessageHandler는 Message 처리에 대한 계약이다.
- MessageChannel는 Producers과 Consumers의 느슨한 연결을 가능하게 하는 메시지 전송에 대한 계약이다.
- SubscribableChannel는 MessageHandler 구독자(Subscribers)를 위한 MessageChannel이다.
- 즉 Subscribers를 관리하고, 해당 채널에 전송된 메시지를 처리할 Subscribers를 호출한다.
- ExecutorSubscribableChannel는 Executor를 사용해서 메시지를 전달하는 SubscribableChannel이다.
- 즉, ExecutorSubscribableChannel은 각 구독자(Subscribers)에게 메시지를 보내는 SubscribableChannel이다.
Java 기반의 설정(@EnableWebSocketMessageBroker)과 XML 네임스페이스 기반의 설정(websocket:message-broker)은 모두 앞선 위의 구성 요소를 사용해서 message workflow를 구성한다.
아래의 그림은 내장 메시지 브로커를 사용한 경우의 컴포넌트 구성을 보여준다.
- clientInboundChannel은 WebSocket 클라이언트로 부터 받은 메시지를 전달한다.
- clientOutboundChannel은 WebSocket 클라이언트에게 메시지를 전달한다.
- brokerChannel은 서버 측 애플리케이션 코드에서 메시지 브로커로 메시지를 보낸다.
다음 그림은 외부 브로커를 사용해서 subscriptions과 broadcasting 메시지를 관리하도록 설정한 구성 요소를 보여준다.
위 두 구성 방식의 주요한 차이점은 Broker Relay 사용 여부이다.
Broker Relay의 역할은 다음과 같다.
- TCP 기반으로 외부 STOMP Broker에게 메시지를 전달
- 브로커로부터 받은 메시지를 구독한 클라이언트에게 전달
동작 흐름
이제 위 그림에 대한 전체적인 흐름을 살펴보면 다음과 같다.
- WebSocket 커넥션으로부터 메시지를 전달받는다.
- STOMP Frame으로 디코드한다.
- 스프링에서 제공하는 Message Representation으로 변환한다.
- 추가 처리를 위해, clientInboundChannel로 전송한다.
- STOMP Message의 Destination 헤더가 /app으로 시작한다면, @MessageMapping 정보와 매핑된 메서드를 호출한다.
- 반면에, Destination 헤더가 /topic 또는 /queue로 시작한다면, 메시지 브로커로 바로(직접) 라우팅된다.
Message 처리 과정
@Controller 컨트롤러는 클라이언트로부터 받은 STOMP Mesaage를 다룰 수 있을 뿐만 아니라, brokerChannel을 통해서 메시지 브로커에게 메시지를 보낼 수도 있다.
이후, 메시지 브로커는 매칭된 구독자들(subscribers)에게 clientOutboundChannel을 통해서 메시지를 브로드캐스팅한다.
또한, 동일한 컨트롤러의 HTTP 요청에 대한 응답 처리 과정에서 같은 작업을 수행할 수 있다.
예를 들어, 클라이언트가 HTTP POST 요청을 보낸다고 가정해보자.
그러면, @PostMapping 메서드는 메시지 브로커에게 메시지를 보내어 구독자들(subscribers)에게 브로드캐스팅할 수도 있다.
다음 예제를 통해서, 메시지 처리 과정을 코드로 살펴보자.
@Configuration
@ComponentScan(basePackages = "com.example.demo")
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/simple")
.enableSimpleBroker("/topic", "/queue");
}
}
@Controller
public class TestController {
@MessageMapping("/good")
public String handle(String message) {
return message + " - good";
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
...
</style>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script type="text/javascript" src="node_modules/webstomp-client/dist/webstomp.min.js"></script>
</head>
<body>
<div>
<input type="text" class="message"/>
<button onClick='send()' class="send-btn">보내기</button>
</div>
<div class="messages">
<div id="messages"></div>
</div>
</body>
<script>
let stomp;
document.addEventListener("DOMContentLoaded", function() {
// ["websocket", "xhr-streaming", "xhr-polling"]
const sock = new SockJS('http://localhost:8080/test', null, {transports: ["xhr-polling"]});
stomp = webstomp.over(sock);
stomp.connect({}, function(frame) {
console.log('Connected!!');
stomp.subscribe("/topic/good", function(frame) {
const messages = document.querySelector("#messages");
const message = document.createElement("li");
message.innerText = frame.body;
messages.appendChild(message)
});
});
});
function send() {
const message = document.querySelector(".message");
stomp.send('/simple/good', message.value);
message.value = '';
}
</script>
</html>
- 클라이언트는 http://localhost:8080/test에 연결하여 커넥션을 수립하고, STOMP 프레임들을 해당 커넥션으로 전송하기 시작한다.
- 클라이언트는 /topic/good 경로의 Destination 헤더를 가지고 SUBSCRIBE 프레임을 전송한다. 서버는 프레임을 수신하면 디코딩하여 Message로 변환하고, 메시지를 clientInboundChannel로 전송한다.
그리고 나서, 해당 clientInboundChannel 채널에서 메시지를 메시지 브로커로 바로 라우팅해주고, 메시지 브로커는 해당 클라이언트의 구독(Subscription) 정보를 저장한다. - 이후, 클라이언트는 /test/good 경로의 Destination 헤더를 가지고 메시지를 전송한다.
/test prefix는 해당 메시지가 @MessageMapping 메서드를 가진 컨트롤러로 라우팅될 수 있도록 도움을 준다.
구체적으로, /test 접두사가 벗겨진 후에는 /good 목적지 경로만 남게 되고 TestConroller의 @MessageMapping 가진 handle() 메서드로 라우팅된다. - @MessageMapping 가진 handle() 메서드가 반환한 값은 스프링의 Message로 변환된다. Message의 Payload는 handle() 메서드가 반환한 값을 기반으로 하고, 기본적으로 Destination 헤더는 /topic/good로 설정된다. Destination 헤더는 클라이언트가 보낸 기존 /test/good 경로의 목적지 헤더에서 /test를 /topic으로 변경된 값으로 설정된다. 이후, 변환된 Message는 brokerChannel로 전송되고 메시지 브로커에 의해서 처리된다.
- 마지막으로 메시지 브로커는 매칭된 모든 구독자들(subscribers)을 탐색하고, clientOutboundChannel을 통해서 각 구독자들에게 MESSAGE 프레임을 보낸다.
구체적으로, clientOutboundChannel 채널에서는 스프링의 Message를 STOMP의 Frame으로 인코딩하고, 연결된 WebSocket 커넥션으로 프레임을 전송한다.
Annotated Controllers
애플리케이션은 클라이언트로 부터 받은 메시지를 처리하기 위해 @Controller 클래스를 사용할 수 있다.
이러한, 컨트롤러는 @MessageMapping, @SubscribeMapping, @ExceptionHandler 메서드를 선언할 수 있는데, 구체적으로 어떤 역할을 하는지 하나씩 살펴보자.
@MessageMapping
@MessageMapping 메서드는 지정한 경로를 기반으로 메시지를 라우팅할 수 있다.
@MessageMapping은 메서드뿐 만 아니라 타입 레벨, 즉 클래스에도 설정할 수 있는데 이는 컨트롤러 안에서 공통된 경로를 제공하기 위해서 사용된다.
기본적으로, 매핑은 Ant-Style Path 패턴으로 구성하고, Template 변수도 지원한다.(ex, /something*, /something/{id})
Template 변수는 @DestinationVariable로 선언한 메서드 인자를 통해서 전달받을 수 있다.
또한, 애플리케이션은 dot-separated 기반의 Destination 컨벤션으로 바꿀 수도 있는데, 이는 아래서 다시 언급하겠다.
Method Arguments
그럼, @DestinationVariable과 같은 메서드에서 지원하는 인자 목록에 대해서 살펴보자.
Message | 전체 메시지에 대한 접근. |
MessageHeaders | 메시지 내의 헤더에 대한 접근. |
MessageHeaderAccessor, SimpMessageHeaderAccessor, StompHeaderAccessor | 타입 지정 접근자 메서드를 통해 헤더에 접근. |
@Payload | 메시지의 페이로드에 접근. 설정된 MessageConverter를 통해 변환됩니다(예: JSON). 해당 어노테이션은 다른 인수와 일치하지 않을 경우 기본으로 가정됩니다. |
@Header | 특정 헤더 값에 접근. 필요 시 타입 변환을 사용합니다(org.springframework.core.convert.converter.Converter). |
@Headers | 메시지의 모든 헤더에 접근. 이 인수는 java.util.Map에 할당 가능해야 합니다. |
@DestinationVariable | 메시지 목적지에서 추출한 템플릿 변수에 접근. 값은 필요에 따라 선언된 메서드 인수 타입으로 변환됩니다. |
java.security.Principal | WebSocket HTTP 핸드셰이크 시 로그인된 사용자. |
다음은 위 Method Argument를 적용한 예제이다.
@Controller
public class TestController {
@MessageMapping("/good/{id}")
public String handle(Message message, MessageHeaders messageHeaders,
MessageHeaderAccessor messageHeaderAccessor, SimpMessageHeaderAccessor simpMessageHeaderAccessor,
StompHeaderAccessor stompHeaderAccessor, @Payload String payload,
@Header("destination") String destination, @Headers Map<String, String> headers,
@DestinationVariable String id) {
System.out.println("---- Message ----");
System.out.println(message);
System.out.println("---- MessageHeaders ----");
System.out.println(messageHeaders);
System.out.println("---- MessageHeaderAccessor ----");
System.out.println(messageHeaderAccessor);
System.out.println("---- SimpMessageHeaderAccessor ----");
System.out.println(simpMessageHeaderAccessor);
System.out.println("---- StompHeaderAccessor ----");
System.out.println(stompHeaderAccessor);
System.out.println("---- @Payload ----");
System.out.println(payload);
System.out.println("---- @Header(\"destination\") ----");
System.out.println(destination);
System.out.println("---- @Headers ----");
System.out.println(headers);
System.out.println("---- @DestinationVariable ----");
System.out.println(id);
return payload;
}
}
Return Values
기본적으로 @MessageMapping 메서드가 반환한 값은 일치한 MessageConverter 통해서 Payload로 직렬화된다.
그리고나서, Message에 담겨 brokerChannel 보내지고 구독자들(subscribers)에게 브로드 캐스팅된다.
이 과정에서, Message의 Destination 헤더는 클라이언트로 부터 전달받은 Destination 헤더 값에서 접두사만 /topic 으로 변경된 값으로 설정된다.
만약 Destination 헤더를 직접 설정하고 싶다면, @SendTo 또는 @SendToUser을 사용하면 된다.
@SendTo과 @SendToUser은 동시에 같은 메서드 또는 클래스에서 사용할 수도 있다.
- @SendTo는 특정 또는 다수의 목적지(Destination 헤더)를 설정하는 경우에 사용한다.
- @SendToUser는 오직 Input Message와 관련된 사용자에게만 Output Message를 보내도록 설정한다.
@SubscribeMapping
@SubscribeMapping은 @MessageMapping와 유사하지만, 오직 Subscription 메시지만 매핑한다는 차이점이 있다.
또한 @SubscribeMapping은 @MessageMapping와 동일한 Method Arguments을 제공한다.
하지만, Return Value는 기본적으로 brokerChannel 통해서 브로커로 전달되는 것이 아니라, clientOutboundChannel 통해서 클라이언트에게 직접 보내진다는 차이점이 있다.
만약 @SendTo 또는 @SendToUser를 통해서 재정의한다면 Return Value을 브로커에게 보낼 수도 있다.
그럼, @SubscribeMapping은 언제 사용하는 것일까?
브로커는 /topic과 /queue에 매핑되어 있고, 애플리케이션 컨트롤러는 /app에 매핑되어 있다고 가정해보자.
이러한 설정에서, 브로커가 /topic, /queue에 대한 모든 구독(subscriptions) 정보를 저장하고 있으므로, 애플리케이션은 개입하지 않아도 된다. 하지만, 클라이언트가 /app 접두사를 가진 목적지로 구독 요청 보내는 상황을 생각해보자.
@SubscribeMapping을 사용한다면, 컨트롤러는 브로커 통과없이 Return Value를 구독에 대한 응답으로 보낸다.
즉, @SubscribeMapping은 브로커에 구독 정보를 저장하지 않을 뿐더러 구독 정보를 재활용하지도 않는 일회성 용도로 사용된다. 일회성 request-reply 교환인 것이다.
좀 더 단적인 예로, 시작과 동시에 UI 초기 데이터를 채우기 위한 용도로 많이 사용된다.
위와 같은 이유가 아니라면, 브로커와 컨트롤러는 동일한 Destination 접두사로 매핑하지 않도록 해야한다.
Inbound 메시지는 병렬적으로 처리되기 때문에, 브로커와 컨트롤러 중에 어느 것이 먼저 처리하는 지 보장하지 않는다.
@MessageExceptionHandler
애플리케이션은 @MessageMapping 메서드에서 발생한 Exception을 처리하기 위해서 @MessageExceptionHandler 메서드를 지원한다. 발생한 예외는 Method Argument을 통해 접근할 수 있다.
@Controller
public class TestController {
// ...
@MessageExceptionHandler
public Exception handleException(CustomeException exception) {
// ...
return exception;
}
}
@MessageExceptionHandler 메서드는 전형적으로 선언된 컨트롤러 내부의 예외를 처리한다.
만약 좀 더 글로벌하게 예외 처리 메서드를 적용하고 싶다면, @MessageExceptionHandler 메서드를 [@ControllerAdvice]컨트롤러에 선언하면 된다.
Message 전송
만약 애플리케이션에서 연결된 클라이언트에게 메시지를 보내야 할 경우에는 어떻게 해야 할까?
애플리케이션 구성 요소는 BrokerChannel로 메시지를 보낼 수 있는데, 가장 간단한 방법은 아래와 같이 SimpMessagingTemplate을 주입받아서 메시지를 전송하는 것이다.
@Controller
public class TestController {
private SimpMessagingTemplate simpMessagingTemplate;
public TestController(SimpMessagingTemplate simpMessagingTemplate) {
this.simpMessagingTemplate = simpMessagingTemplate;
}
@PostMapping(path = "/greet")
@ResponseBody
public void greet(@RequestBody String greet) {
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
simpMessagingTemplate.convertAndSend("/topic/greet", "[" + now + "]" + greet);
}
}
참고
https://docs.spring.io/spring-framework/reference/web/websocket/stomp/handle-annotations.html
https://velog.io/@koseungbin/WebSocket#stomp-%EC%9D%B4%EB%9E%80