본문 바로가기
Spring/WebSocket

[Spring WebSocket] STOMP

by 진꿈청 2024. 11. 4.

STOMP의 사용 이유?

`WebSocket` 프로토콜은 두 가지 유형의 메시지를 정의하고 있지만,

그 메시지의 내용까지는 정의하고 있지 않다.

 

`STOMP`는 `WebSocket` 위에서 동작하는 프로토콜로써, 클라이언트와 서버가 전송할

  • 메시지 유형
  • 형식
  • 내용

들을 정의하는 매커니즘이다.

 

 

STOMP 란?

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

 

이름에서도 알 수 있듯이, `STOMP`는 텍스트 지향 프로토콜이지만, `Message Payload`에는 `Text` 또는
`Binary` 데이터를 포함할 수도 있다.

 

`STOMP`는 HTTP 위에서 동작하는 `Frame` 기반의 프로토콜이며, `Frame`은 아래와 같은 형식을 가지고 있다.

 

COMMAND
header1:value1
header2:value2

Body^@

 

클라이언트는 `Message`를 전송하기 위해 `SEND`, `SUBSCRIBE`, `COMMAND`를 사용할 수 있다.

 

또한, `SEND`, `SUBSCRIBE`, `COMMAND` 요청 `Frame`에는 메시지가 무엇이고 누가 받아서

처리할 지에 관한 `Header` 정보를 함께 포함한다.

 

위와 같은 과정을 통해서, `STOMP`은 `Publish-Subscribe` 매커니즘을 제공한다.

즉, `Broker`를 통해서 다른 사용자들에게 메시지를 보내거나 서버가
특정 작업을 수행하도록 메시지를 보낼 수 있게 되는 것이다.

만약 스프링에서 지원하는 `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 커넥션을 유지하는데,

외부 `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` 프로토콜을 사용하여,

`WebSocket`만 이용할 때 보다 더 풍부한 프로그래밍 모델을 제공할 수 있는데 하나씩 살펴보자.

  • 메시징 프로토콜을 만들고, 메시지 형식을 커스터마이징할 필요가 없다.
  • 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 `기능을 지원한다.
    1. 또한, `/topic` 또는 `/queue`로 시작하는 `Destination` 헤더를 가진 메시지를 브로커로 라우팅한다.

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

`/topic`, `/queue` prefix는 단순히 메시지가 pub-sub, point-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>

 

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

 

Redirecting...

 

docs.spring.io

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

 

다음 그림은 외부 브로커를 사용해서 `subscriptions`과 `broadcasting` 메시지를

관리하도록 설정한 구성 요소를 보여준다.

 

 

위 두 구성 방식의 주요한 차이점은 `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 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>
        .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` 프레임을 전송한다.
    1. 서버는 프레임을 수신하면 디코딩하여 `Message`로 변환하고, 메시지를 `clientInboundChannel`로 전송한다.
    2. 그리고나서, 해당 `clientInboundChannel` 채널에서 메시지를 `메시지 브로커`로 바로 라우팅해주고, `메시지 브로커`는 해당 클라이언트의 구독(`Subscription`) 정보를 저장한다.
  3. 이후, 클라이언트는 `/simple/good` 경로의 `Destination` 헤더를 가지고 메시지를 전송한다.
    1. `/simple` prefix는 해당 메시지가 `@MessageMapping` 메서드를 가진 컨트롤러로 라우팅될 수 있도록 도움을 준다.
    2. 구체적으로, `/simple` 접두사가 벗겨진 후에는 `/good` 목적지 경로만 남게 되고 `TestConroller`의 `@MessageMapping` 가진 `handle()` 메서드로 라우팅된다.
  4. `@MessageMapping` 가진 `handle()` 메서드가 반환한 값은 스프링의 `Message`로 변환된다.

    `Message`의 `Payload`는 `handle()` 메서드가 반환한 값을 기반으로 하고, 기본적으로 `Destination` 헤더는 `/topic/good`로 설정된다.

    `Destination` 헤더는 클라이언트가 보낸 기존 `/simple/good` 경로의 목적지 헤더에서 `/simple`를 `/topic`으로 변경된 값으로 설정된다.

    이후, 변환된 `Message`는 `brokerChannel`로 전송되고 `메시지 브로커`에 의해서 처리된다.
  5. 마지막으로 `메시지 브로커`는 매칭된 모든 구독자들(`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`과 같은 메서드에서 지원하는 인자 목록에 대해서 살펴보자.

 

 

다음은 위 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를 보내도록 설정한다.

 

A Quick Example of Spring Websockets' @SendToUser Annotation | Baeldung

 

또한, 만약 `@MessageMapping` 메서드에서 메시지를 비동기적으로 처리하고 싶은 경우에는
`ListenableFuture`, `CompletableFuture` 또는 `CompletionStage` 객체를 반환하면 된다.

`@SendTo`과 `@SendToUser`은 단순히 `SimpMessagingTemplate`을
사용해서 메시지를 보내는 편의에 불과하다는 것을 명심하자.

따라서, 요구 상황에 따라 `@MessageMapping` 메서드에서 `SimpMessagingTemplate`을 직접 사용해야 할 경우도 있다.

`SimpMessagingTemplate`을 이용하면 반환 값없이 메서드 내부에서 처리를 끝마칠 수 있다.

 

 

@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](https://docs.spring.io/spring-framework/docs/current/spring-framework-reference/web.html#mvc-ann-controller-advice)` 컨트롤러에 선언하면 된다.

 

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);
	}
}

 

 

<!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/greet", 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");
            fetch("http://localhost:8080/greet", {
                method: "POST",
                body: message.value
            });
            message.value = '';
        }
    </script>
</html>

 

 

'Spring > WebSocket' 카테고리의 다른 글

[Spring WebSocket] SockJS  (2) 2024.11.04
[Spring WebSocket] WebSocket  (0) 2024.11.04
[Spring WebSocket] SSE vs WebSocket  (0) 2024.11.04