본문 바로가기

항해 99/Spring

WebSocket - STOMP 1

STOMP ?

STOMP는 Simple Text Oriented Messaging Protocol의 약자로 TCP 또는 WebSocket 같은 양방향 네트워크 프로토콜 기반으로 동작한다

 

STOMP는 텍스트 지향 프로토콜이지만 Message Payload에는 Text 또는 Binary 데이터를 포함할 수 있음

COMMAND
header1:value1
header2:value2

Body^@

 

STOMP Protocol Specification, Version 1.2

 

클라이언트는 Message 전송을 위해 SEND, SUBSCRIBE, COMMAND를 사용할 수 있음

 

또, SEND, SUBSCRIBE, COMMAND 요청 Frame에는 메시지가 무엇기고 누가 받아서 처리할지에 대한 Header 정보를 함께 포함함

 

위 같은 과정을 통해, STOMP는 Publish-Subscribe 매커니즘을 제공, Broker를 통해서 다른 사용자들에게 메시지를 보내거나 서버가 특정 작업을 수행하도록 메시지를 보낼 수 있게 되는 것임

 

스프링에서 지원하는 STOMP 사용 시, 스프링 WebSocket 애플리케이션은 STOMP Broker로 동작함

 

스프링 STOMP의 제공 기능

  • 메시지를 @Controller의 메시지 핸들링하는 메서드로 라우팅
  • Simple InMemory Broker를 이용해서 Subscribe 중인 다른 클라이언트들에게 메시지를 브로드캐스팅 함
    • Simple InMemory Broker는 클라이언트의 Subscribe 정보를 자체적으로 메모리에 유지함
  • RabbitMQ, ActiveMQ 같은 외부 Messaging System을 STOMP Broker로 사용할 수 있도록 지원함

스프링은 외부 STOMP Broker와 TCP 커넥션을 유지하고, 외부 STOMP Broker는 서버-클라이언트 사이의 매개체로 동작함

  • 스프링은 메시지를 외부 브로커에 전달하고, 브로커는 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 장점

Spring Framework 및 Spring Security는 STOMP 프로토콜을 사용하여, WebSockets만 이용할 때보다 더 풍부한 프로그래밍 모델을 제공할 수 있음

  • 메시징 프로토콜을 만들고, 메시지 형식을 커스터마이징할 필요가 없다
  • RabbitMQ, ActiveMQ 같은 Message Broker를 이용해서, subscription을 관리하고 메시지를 브로드캐스팅할 수 있음
  • WebSocket 기반으로 각 커넥션마다 WebSocketHandler를 구현하는 것보다, @Controller된 객체를 이용해서 조직적으로 관리할 수 있음
    • 메시지들은 STOMP의 Destination 헤더를 기반으로, @Controller 객체의 @MethodMapping 메서드로 라우팅됨
  • STOMP의 Destination 및 Message Type을 기반으로 메시지를 보호하기 위해, Spring Security를 사용할 수 있음

STOMP 사용

서버

스프링은 WebSocket 또는 SockJS 기반으로 STOMP를 위해 spring-messaging and spring-websocket 모듈을 제공한다

 

STOMP는 설정을 할 수 있는데, 기본적으로 커넥션을 위한 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");
	}
}
  1. /test는 WebSocket 또는 SockJS 클라이언트가 WebSocket Handshake로 커넥션을 경로이다
  2. /simple 경로로 시작하는 STOMP 메시지의 Destination 헤더는 @Controller 객체의 @MessageMapping 메서드로 라우팅 됨
  3. 내장된 메시지 브로커를 사용하여 클라이언트에게 subscriptions, broadcasting 기능을 지원함

/topic 또는 /queue로 시작하는 Destination 헤더를 가진 메시지를 브로커로 라우팅함

 

내장된 Simple Message Broker는 /topic, /queue prefix에 대해 특별한 의미를 갖지 않는다

  • /topic, /queue prefix는 단순히 메시지가 pub-sub, poit-to-point 인지 여부를 나타내는 컨벤션일 뿐이며, 외부 브로커를 사용할 경우에는 해당 Destination 헤더 prefix가 달라질 수 있음

클라이언트

브라우저인 클라이언트 측면에서는 SockJS 프로토콜을 사용하기 위해서 sockjs-client 라이브러리를 사용함(최근에는  STOMP 프로토콜의 클라이언트 라이브러리는 webstomp-client를 많이 사용)

 

예제 - 클라이언트가 SockJS를 기반으로 한 STOMP 프로토콜을 이용한 서버와 통신

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <style>
        .message {
            margin: 5em;
            color: olive;
            border: solid 2px #D2691E;
            -webkit-transition: 0.5s; transition: 0.5s;
            width: 50%;
            padding: 10px 20px;
            box-sizing: border-box;
        }
        .messages {
            margin: 5em;
        }
        .send-btn {
            background-color: #F4FEE2;
            padding: 10px 20px;
        }
        li {
            list-style-type: none;
            margin: 1em;
            text-align: center;
            font-size: 2em;
        }
    </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>

 

SockJS 프로토콜을 사용하고 있지만, WebSocket만 사용하고 싶은 경우에는 브라우저에서 지원하는 WebSocket 객체를 이용할 수 있음

const socket = new WebSocket("http://localhost:8080/test");
const stomp = Stomp.over(socket);

stomp.connect({}, function(frame) {
  ...
}
  • 예제에서는 login과 passcode 헤더가 필요하지 않았음, 클라이언트에서 설정했더라도 서버 측에서 무시됐을 것임

스프링 제공 샘플 코드

Message Flow

STOMP Endpoint를 노출하면, 스프링 애플리케이션은 연결된 클라이언트에 대한 STOMP Broker가 됨

 

구성 요소

spring-message 모듈은 스프링 프레임워크의 통합된 메시징 애플리케이션을 위한 근본적인 지원을 한다

 

몇 가지 사용 가능 한 메시징 추상화에 대한 설명

  • Message headers와 payload를 포함하는 메시지의 representation
  • 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를 구성함

 

내장 메시지 브로커를 사용한 경우의 컴포넌트 구성

https://docs.spring.io/spring-framework/docs/current/spring-framework-reference/web.html#websocket-stomp-message-flow

  • clientInboundChannel은 WebSocket 클라이언트로부터 받은 메시지를 전달함
  • clientOutboundChannel은 WebSocket 클라이언트에게 메시지를 전달함
  • brokerChannel은 서버의 애플리케이션 코드 내에서 브로커에서 메시지를 전달함

외부 브로커를 사용해서 subscriptions 과 broadcasting 메시지를 관리하도록 설정한 구성 요소

https://docs.spring.io/spring-framework/docs/current/spring-framework-reference/web.html#websocket-stomp-message-flow

  • 두 구성 방식의 주요한 차이점은 Broker Relay 사용 여부임
  • Broker Relay의 역할
    • TCP 기반으로 외부 STOMP Broker에게 메시지를 전달
    • 브로커로부터 받은 메시지를 구독한 클라이언트에게 전달

동작 흐름

  1. WebSocket 커넥션으로부터 메시지를 전달받는다
  2. STOMP Frame으로 디코드한다
  3. 스프링에서 제공하는 Message Representation으로 변환한다
  4. 추가 처리를 위해, clientInboundChannel로 전송한다
    1. STOMP Message의 Destination 헤더가 /app으로 시작한다면, @MessageMapping 정보와 매핑된 메서드를 호출한다
    2. 반면에, Destination 헤더가 /topic 또는 /queue로 시작한다면, 메시지 브로커로 바로(직접) 라우팅된다

Message 처리 과정

@Controller 컨트롤러는 클라이언트로부터 받은 STOMP Message를 다룰 수 있을뿐만 아니라, 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>
        .message {
            margin: 5em;
            color: olive;
            border: solid 2px #D2691E;
            -webkit-transition: 0.5s; transition: 0.5s;
            width: 50%;
            padding: 10px 20px;
            box-sizing: border-box;
        }
        .messages {
            margin: 5em;
        }
        .send-btn {
            background-color: #F4FEE2;
            padding: 10px 20px;
        }
        li {
            list-style-type: none;
            margin: 1em;
            text-align: center;
            font-size: 2em;
        }
    </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>
  1. 클라이언트는 http://localhost:8080/test 에 연결하여 커넥션을 수립하고, STOMP 프레임들을 해당 커넥션으로 전송하기 시작함
  2. 클라이언트는 /topic/good 경로의 Destination 헤더를 가지고 SUBSCRIBE 프레임을 전송한다
    • 서버는 프에림을 수신하면 디코딩하여 Message로 변환하고, 메시지를 clientInboundChannel로 전송함
    • 해당 clientInboundChannel 채널에서 메시지를 메시지 브로커로 바로 라우팅해주고, 메시지 브로커는 해당 클라이언트의 구독(Subscription) 정보를 저장한다
  3. 클라이언트는 /test/good 경로의 Destination 헤더를 가지고 메시지를 전송한다
    • /test prefix는 해당 메시지가 @MessageMapping 메서드를 가진 컨트롤러로 라우팅될 수 있도록 도움을 줌
    • /test 접두사가 벗겨진 후에는 /good 목적지 경로만 남게 되고 TestController의 @MessageMapping 가진 handle( ) 메서드로 라우팅 된다
  4. @MessageMapping을 가진 handle( ) 메서드가 반환한 값은 스프링의 Message로 변환 됨
    • Message의 Payload는 handle( ) 메서드가 반환한 값을 기반으로 하고, 기본적으로 Destination 헤더는 /topic/good로 설정된다
    • Destination 헤더는 클라이언트가 보낸 기존 /test/good 경로의 목적지 헤더에서 /test를 /topic으로 변경된 값으로 설정된다
    • 이후, 변환된 Message는 brokerChannel로 전송되고 메시지 브로커에 의해서 처리된다
  5. 마지막으로 메시지 브로커는 매칭된 모든 구독자들(subscribers)을 탐색하고, clientOutboundChannel을 통해서 각 구독자들에게 MESSAGE 프레임을 보낸다
    • clientOutboundChannel 채널에서는 스프링의 Message를 STOMP의 Frame으로 인코딩하고, 연결된 WebSocket 커넥션으로 프레임을 전송한다

 

'항해 99 > Spring' 카테고리의 다른 글

WebSocket - 실제 코드 분석  (1) 2024.04.03
WebSocket - STOMP 2  (0) 2024.03.30
WebSocket - SockJS  (0) 2024.03.28
WebSocket - 기본 websocket  (0) 2024.03.27
Redis  (1) 2024.03.25