WebSocket

raccoonback·2020년 8월 2일
45

Network

목록 보기
7/7
post-thumbnail

이 글은 Spring WebSocket 문서를 기반으로 작성했습니다.

WebSocket 소개

WebSocket 프로토콜은 표준된 방법으로 서버-클라이언트 간에 단일 TCP 커넥션을 이용해서 양방향 통신을 제공한다.

특징

기존의 다른 TCP 기반의 프로토콜과 다르게, WebSocket은 HTTP 요청 기반으로 Handshake 과정을 거쳐 커넥션을 생성한다.

덕분에, 초기 WebSocket Handshake 요청은 추가적인 방화벽 설정없이 80, 443 포트를 사용하여 양방향 통신이 가능하다.

뿐만 아니라, HTTP 규격 그대로 유지할 수 있기 때문에 HTTP 인증, CORS 등을 동일하게 적용할 수 있다는 장점이 있다.

커넥션 Flow

WebSocket은 커넥션을 맺기 위해 HTTP 요청을 보내는데, 아래와 같이 HTTP 요청 헤더에 Upgrade 헤더와 Connection 포함한다.

# Upgrade
- 이미 생성된 커넥션을 다른 프로토콜로 업그레이드/변경
- 클라이언트가 Upgrade 헤더 값에 나열한 프로토콜 리스트를 서버가 선택한다.
- 앞쪽에 배치할수록 우선순위가 높음
- 서버는 Upgrade 하기로 선택한 프로토콜을 응답 Uprade 헤더에 추가해 전달한다.
GET ws://localhost:3000/sockjs-node HTTP/1.1
Host: localhost:3000
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36
Upgrade: websocket
Origin: http://localhost:3000
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate, br
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7,ja;q=0.6
Sec-WebSocket-Key: xwGnajy+I6YJ/AW7pTKioA==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

서버는 아래와 같이 101 Switching Protocols 상태 코드로 응답을 하는데, Handshask 이후에도 TCP 커넥션은 지속적으로 유지된다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 6Ux2cxOp2HhzP9SLCuADGUKiLbU=

HTTP vs WebSocket

WebSocket이 HTTP 요청을 시작되는 호환성을 가지고 있지만, 분명하게 두 프로토콜은 다른 방식으로 동작한다.

HTTP는 여러 URL을 기반으로 서버 애플리케이션과 Request/Response 형식으로 상호 작용한다.

WebSocket은 반대로 오직 초기의 커넥션 수립을 위한 하나의 URL만 있고, 모든 애플리케이션 메시지는 동일한 TCP 커넥션에서 전달된다.

즉, WebSocket은 HTTP 프로토콜과 다른 asynchronous, event-driven, messaging 아키텍쳐 모델이다.

또한, HTTP 경우에는 서버가 URI, Method, Headers 정보로 적절한 핸들러로 라우팅해 처리할 수 있다.

하지만 WebSocket은 HTTP 와 다르게 메시지 내용에 의미를 두지 않기 때문에, 클라이언트-서버 간에 임의로 메시지에 의미를 부여하지 않으면 처리할 방법이 마땅히 없다.

이러한 문제를 STOMP 메시징 프로토콜을 통해서 해결할 수 있는데, 상위 프로토콜이 규정한 협약을 토대로 메시지를 처리할 수 있다.

언제 WebSocket을 사용할까?

Traditional Polling

고전적인 Polling 방식은 새로운 정보가 있는지 확인하기 위해 주기적으로 HTTP 요청을 보낸다. 이러한 방식은 지속적으로 요청을 보내기 때문에, 매번 커넥션을 생성하기 위한 Handshake 비용이 많아지며 서버에 부담을 주게 된다.

Long Polling

Long Polling은 Traditional Polling을 개선한 방식이다.

클라이언트는 서버에 요청을 보내고, 서버는 변경 사항이 있는 경우에만 응답하여 커넥션을 종료한다.

그리고 클라이언트는 바로 다시 서버에 요청을 보내어 변경 사항이 있을 때까지 대기하게 된다.

커넥션은 무한히 대기할 수 없으므로, 브라우저는 약 5분 정도 대기하며 중간 프록시에 따라 더 짧게 커넥션이 종료될 수도 있다.

만약 변경 사항이 불규칙적인 간격으로 일어나는 경우 효율적이나, 변경 사항의 빈도가 잦다면 기존 Traditional Polling과 차이가 없으므로 서버의 부담이 증가하게 된다.

HTTP Streaming

HTTP Streaming은 Long Polling 과 동일하게 HTTP 요청을 보내지만, 변경 사항을 클라이언트에 응답한 이후에도 커넥션을 종료하지 않고 유지한다.

따라서 매번 새로운 커넥션을 맺고 끊는 것이 아니라 하나의 커넥션을 유지하며, 변경 사항을 응답 메시지로 전달한다.

HTTP Streaming은 Long Polling 방식에 비해서 서버의 부담을 줄일 수 있지만, 여러 건의 변경 사항이 일어난 경우 동시 처리가 어려워진다.

왜냐하면, 서버가 현재 업데이트된 데이터를 모두 전달해야만, 클라이언트에서 다음 업데이트된 데이터의 시작 위치를 알 수 있기 때문이다.

뿐만 아니라, HTTP Streaming 방식은 서버가 클라이언트에게 전달하는 메시지에 대한 실시간성을 어느 정도 보장하지만, 클라이언트가 서버에게 보내는 요청은 여전히 새로운 커넥션을 생성해야 한다.

이러한 동시성과 서버 부담이라는 Trade Off 사항에서, HTTP Streaming 보다 Long Polling 방식을 많이 사용한다고 한다.

WebSocket

위와 같은 HTTP Long Polling, Streaming 방식이 가지고 있는 문제를 해결하고, 서버-클라이언트 간에 양방향 통신이 가능하도록 WebSocket 이라는 기술이 만들어지게 됐다.

WebSocket은 서비스를 동적으로 만들어 주지만, AJAX, HTTP Streaming, HTTP Long Polling 기술이 보다 효과적인 경우도 있다. 예를 들어, 변경 사항의 빈도가 자주 일어나지 않고 데이터의 크기가 작은 경우에는 AJAX, HTTP Streaming, HTTP Long Polling 기술이 효과적일 수 있다.

즉, 실시간성을 보장해야 하고 변경 사항의 빈도가 크다면 WebSocket은 좋은 해결책이 될 수 있다.

참고 사항

Spring MVC 3.2 Preview: Techniques for Real-time Updates

Spring WebSocket

Spring Framework는 WebSocket API를 제공한다.

여기서 주목할 점은 Spring에서 제공하는 WebSocket API는 Spring MVC 기술에 종속되지 않는다는 것이다.

WebSocket 서버는 WebSocketHandler 인터페이스의 구현체를 통해서, 각 경로에 대한 핸들러를 구현할 수 있다.

뿐만 아니라, Message 형식에 따라 TextWebSocketHandler or BinaryWebSocketHandler 핸들러를 확장해 구현할 수도 있다.

아래는 템플릿 메서드 패턴이 적용된 AstractWebSocketHandler 추상 클래스이다.

메시지 형식에 따라, 적합한 handleXXX 메서드를 호출한다.

Spring WebSocket 설정

문자열 메시지 기반으로 테스트를 진행하기 때문에 TextWebSocketHandler를 상속받아 메시지를 전달받는다.

public class Handler extends TextWebSocketHandler {
	@Override
	public void handleTextMessage(WebSocketSession wsSession, TextMessage message) throws Exception {
		wsSession.sendMessage(message);
	}
}

다음으로 핸들러를 Bean으로 등록하고, 클라이언트와 연결할 경로를 등록한다.

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
		webSocketHandlerRegistry
			.addHandler(webSocketHandler(), "/test")
			.setAllowedOrigins("*");
	}

	@Bean
	public WebSocketHandler webSocketHandler() {
		return new Handler();
	}
}

마지막으로, 클라이언트에서는 브라우저에 내장된 WebSocket 기능을 이용해서 서버와 커넥션을 맺고, 메시지를 양방향으로 주고 받는다.

<!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>
</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 client;
        document.addEventListener("DOMContentLoaded", function() {
            client = new WebSocket('ws://localhost:8080/test');

            client.onopen = function (event) {
                console.log("Connected!!")
            };

            client.onmessage = function (event) {
                const messages = document.querySelector("#messages");
                const message = document.createElement("li");
                message.innerText = event.data;
                messages.appendChild(message)
            }
        });

        function send() {
            const message = document.querySelector(".message");
            client.send(message.value);
            message.value = '';
        }
    </script>
</html>

WebSocket을 이용하여 클라이언트 애플리케이션 작성하기

WebSocket Session 동시성

WebSocketHandler를 사용하는 경우, 표준 WebSocket session(JSR-356)은 동시 전송을 지원하지 않는다.

따라서 STOMP 메시징 프로토콜을 이용해서 메시지 전송을 동기화하거나,

WebSocketSessionConcurrentWebSocketSessionDecorator으로 Wrapping해야 한다.

ConcurrentWebSocketSessionDecorator은 오직 하나의 스레드만 메시지를 전송하도록 보장해주기 때문이다.

WebSocket Handshake

WebSocketHandler마다 HandShake 전(before)/후(after)로 필요한 작업이 있다면, HandshakeInterceptor 인터페이스를 구현해서 등록하면 된다.

이를 통해서, HandShake를 막거나 WebSocketSession의 속성을 사용할 수 있다.

아래는 기본적으로 제공하는 HTTP Session을 WebSocket Session에 전달하는 HttpSessionHandshakeInterceptor 예제이다.

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
		webSocketHandlerRegistry
			.addHandler(webSocketHandler(), "/test")
			.addInterceptors(new HttpSessionHandshakeInterceptor())
			.setAllowedOrigins("*");
	}

	@Bean
	public WebSocketHandler webSocketHandler() {
		return new Handler();
	}
}

뿐만 아니라 만약 직접 HandShake 단계의 작업을 수행해야 한다면, AbstractHandshakeHandler 확장해 직접 구현할 수도 있다.

만약 아직 지원하지 않는 WebSocket 서버 엔진이나 버전을 적용하기 위해서, RequestUpgradeStrategy 을 직접 구현할 수도 있다. (직접 구현한 RequestUpgradeStrategy 객체는 AbstractHandshakeHandler 생성자를 통해 전달한다.)

아래는 디폴트로 제공되는 DefaultHandshakeHandler를 추가하는 과정이다. (추가하지 않아도 디폴트로 제공된다.)

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
		webSocketHandlerRegistry
			.addHandler(webSocketHandler(), "/test")
			.setHandshakeHandler(new DefaultHandshakeHandler())
			.setAllowedOrigins("*");
	}

	@Bean
	public WebSocketHandler webSocketHandler() {
		return new Handler();
	}
}

WebSocketHandlerDecorator

Spring은 WebSocketHandler가 호출되기까지 아래 그림과 같이, 여러 Decorator를 거친다.

Spring은 데코레이터 패턴을 이용해서 WebSocketHandler에 대한 추가적인 작업을 처리할 수 있도록 WebSocketHandlerDecorator 객체를 제공한다.

예를 들어 Message, Session 등의 정보에 대한 로깅 등의 작업을 추가할 수 있다.

아래 그림을 실제로 기본적으로 Spring에 등록된 WebSocketHandlerDecorator 구현체들이며, 연속적으로 전파되어 실행되는 것을 확인할 수 있다.

ExceptionWebSocketHandlerDecoratorWebSocketHandler 실행 과정에서 발생하는 예외를 모두 잡아서 처리한다.

LoggingWebSocketHandlerDecorator는 Message, Session 정보를 logging 한다.

마지막으로, AbstractWebSocketHandler는 Message 타입에 따라 handleXXX() 메서드를 호출한다.

WebSocket 속성 설정

WebSocket Engine에 대한 메시지 버퍼 크기, 유휴 제한 시간 등 같은 런타임 특성을 ServletServerContainerFactoryBean으로 등록할 수 있다.

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
	...
	@Bean
	public ServletServerContainerFactoryBean createWebSocketContainer() {
		ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
		container.setMaxTextMessageBufferSize(10000);
		container.setMaxSessionIdleTimeout(1000L);
		container.setAsyncSendTimeout(1000L);
		return container;
	}
}

Allowed Origins

WebSocket을 위해 스프링은 기본적으로 Same-Origin 요청을 지원한다. 즉, 동일한 출처의 도메인에 대해서만 커넥션을 수락하겠다는 것이다.

하지만, 각 핸들러마다 지원할 도메인을 지원할 수 있도록 설정하는 방법도 스프링에서 지원하고 있다.

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
		webSocketHandlerRegistry
			.addHandler(webSocketHandler(), "/test")
			.setAllowedOrigins("http://something.co.kr", "https://example.com");
	}

	...
}

자세한 내용은 다음 링크에서 참조하길 바란다.

SockJS

SockJS 또 뭔데?

지금까지는 클라이언트-서버 간에 WebSocket 연결과 메시지 주고 받는 방법에 대해 살펴보았다.

그런데, 클라이언트-서버 WebSocket 통신이 순탄하게만 진행될 수 있을까?

아니다.

그럼, 발생할 수 있는 예외 상황은 어떤 것이 있을지 살펴보자.

우선, 모든 클라이언트의 브라우저에서 WebSocket을 지원한다는 보장이 없다.

두 번째로, 클라이언트/서버 중간에 위치한 프록시가 Upgrade 헤더를 해석하지 못해 서버에 전달하지 못할 수 있다. 

마지막으로,  클라이언트/서버 중간에 위치한 프록시가 유휴 상태에서 도중에 커넥션 종료시킬 수도 있다.

이러한 문제는 WebSocket Emulation을 통해서 해결이 가능하다.

WebSocket Emulation 이란,

우선 WebSocket을 첫 번째로 시도하고

WebSocket 연결이 실패한 경우에는 HTTP-Streaming, HTTP Long Polling 같은 HTTP 기반의 다른 기술로 전환해 다시 연결을 시도하는 것을 말한다.

WebSocket Emulation을 통해서, 위와 같이 WebSocket 연결을 할 수 없는 경우에는 다른 HTTP 기반의 기술을 시도하는 방법이다.

이러한, WebSocket Emulation을 지원하는 것이 바로 SockJS 프로토콜다.

Spring Framework는 Servlet 스택 위에서 서버/클라이언트 용도의 SockJS 프로토콜을 모두 지원하고 있다.

SockJS 란?

SockJS의 목표는 "애플리케이션이 우선적으로 WebSocket API를 사용하도록 하지만, 사용할 수 없는 경우에는 런타임 시점에 코드 변경없이 WebSocket 이외의 대안으로 대체"하도록 하는 것이다.

특징

우선 SockJS는 브라우저에서 사용하도록 설계가 되었기 때문에, 다양한 브라우저와 버전을 지원하고 있다.

자세한 브라우저 지원 범위는 아래 링크를 참고하길 바란다.

또한 SockJSWebSocket, HTTP Streaming, HTTP Long Polling 등의 크게 세 가지 전송 방법(Transports)을 지원하고 있는데, 이외에도 아래와 같이 다양한 방식을 제공하고 있다.

SockJS가 지원하는 자세한 전송 방법(Transports) 리스트는 아래 링크에서 참고 바란다.

WebSocket Emulation 과정

SockJS는 서버로 부터 기본 정보를 획득하기 위해서 GET /info 요청을 보내며 시작한다.

클라이언트가 서버에게 GET /info 요청을 보내므로써, 서버가 WebSocket을 지원하는 지와 전송 과정에서 Cookies 지원이 필요한 지 여부, CORS 위한 Origin 정보 등의 정보를 응답으로 전달받는다.

이후, 서버가 응답한 메시지를 토대로 앞으로 통신에 사용할 프로토콜을 아래와 같은 방식으로 결정하고 요청을 보낸다.

  1. WebSocket 사용 가능하다면, WebSocket 사용
  2. WebSocket 사용 불가능하다면,
    1. OptionsTransports 항목에 HTTP streaming 설정이 존재한다면, HTTP streaming 사용
    2. OptionsTransports 항목에 HTTP streaming 설정이 없고 HTTP Long Polling 존재한다면, HTTP Long Polling 사용
const sock = new SockJS('http://localhost:8080/test', null, {transports: ["websocket", "xhr-streaming", "xhr-polling"]});

Transports Request URL 형식

모든 Transports 요청의 URL 형식은 아래와 같다.

http://host:port/myApplication/myEndpoint/{server-id}/{session-id}/{transport}

각각의 의미를 하나씩 살펴보자.

  • server-id는 클러스터 환경에서 요청을 라우팅하는데 유용하게 사용된다.
  • session-idSockJS 세션에 속하는 HTTP 요청을 연관시킨다.
  • transport는 전송 타입을 가리킨다. (ex, websocket, xhr-streaming, xhr-polling)

Transports Type

websocket 타입의 전송 방식은 WebSocket HandShake를 하기 위해서 오직 하나의 HTTP 요청만 필요하고, 이후 모든 메시지는 해당 소켓에서 교환된다.

xhr-streaming 타입의 경우에는 장기 실행 요청을 유지하여 서버에서 클라이언트로 전달하기 위한 메시지를 응답으로 전달받는다.

이후, 클라이언트에서 서버로 새로운 요청을 보내야 할 경우에는 기존의 커넥션을 종료하고 새로운 HTTP POST 요청을 보내어 커넥션을 유지한다.

xhr-polling 타입 경우에는 서버에서 클라이언트로 응답 메시지가 전달이 될 때마다 기존의 커넥션을 종료하고 새로운 요청을 보내어 커넥션을 생성한다.

메시지 형식

추가적으로, SockJSMessage Frame 크기를 최소화하기 위해 노력한다.

예를 들어, open frame 경우에는 첫 글자인 o를 전송한다.

또한, Message Frame의 경우에는 다음과 같은 형태로 전달받는다.

a["message1", "message2"]

커넥션 유지 여부를 확인하는 Heartbeat Frame 경우에는 h 로 보낸다.

마지막으로, 커넥션 종료를 의미하는 Close Framec["message"] 형식으로 보낸다.

SockJS 사용

SockJS는 아래와 같이 설정할 수 있다.

스프링에서 제공하는 WebSocket APISockJSSpring MVC에 독립적이지만, 관련된 Configuration 설정들은 DispatcherServlet에 포함되어야 한다.

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
		webSocketHandlerRegistry
			.addHandler(webSocketHandler(), "/test")
			.withSockJS();
	}

	@Bean
	public WebSocketHandler webSocketHandler() {
		return new Handler();
	}
}

클라이언트 측에서는 sockjs-client 라이브러리를 사용하는데, 서버와 통신하여 브라우저에 따른 최적의 전송 옵션(타입)을 선택한다.

<!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>
</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 sock;
        document.addEventListener("DOMContentLoaded", function() {
            // ["websocket", "xhr-streaming", "xhr-polling"]
            sock = new SockJS('http://localhost:8080/test', null, {transports: ["websocket", "xhr-streaming", "xhr-polling"]});
            sock.onopen = function() {
                console.log('Connected!!');
            };

            sock.onmessage = function(event) {
                const messages = document.querySelector("#messages");
                const message = document.createElement("li");
                message.innerText = event.data;
                messages.appendChild(message)
            };

            sock.onclose = function() {
                console.log('close');
            };
        });

        function send() {
            const message = document.querySelector(".message");
            sock.send(message.value);
            message.value = '';
        }
    </script>
</html>

IE 8, 9 호환성

여전히 많은 사용자들은 Internet Explorer 브라우저의 8, 9 버전을 여전히 사용하고 있지만, 해당 버전에서는 WebSocket을 지원하고 있지 않다.

이러한 부분에서 SockJS 진가가 발휘되는데, IE 8, 9 버전은 HTTP Streaming 또는 HTTP Long Polling Transports 타입으로 전환되어 호환이 가능하기 때문이다.

SockJS 클라이언트는 Microsoft의 XDomainRequest(xdr) 이용해서 HTTP Streaming을 지원한다.(10 버전부터는 XMLHttpRequest(xhr) 사용을 권장하여 XDomainRequest 를 제거)

XDomainRequest(xdr), XMLHttpRequest(xhr)는 모두 CORS를 지원하기 위한 도구이다.

XDomainRequest는 비록 CORS 도구로서 잘 동작하지만, Cookies 전송을 지원하지 않는다.

Cookies 는 종종 Java 애플리케이션에서 필수적이지만, SockJS 클라이언트는 여러 유형의 서버와 함께 사용할 수 있기 때문에 큰 문제는 아니다.

따라서, 서버 측의 Cookies 필요 여부에 따라 HTTP Streaming, HTTP Long Polling에서 사용하는 기술이 달라진다.

  • Cookies 사용 불가능하다면, XDomainRequest(xdr)가 사용된다.
  • Cookies 사용 가능하다면, iframe 기반의 기술이 사용된다.

SockJS 클라이언트가 첫 번째로 요청한 GET /info에 대한 응답 메시지에는 클라이언트가 Transports 타입을 선택하는데 영향을 미치는 요소들이 포함되어 있다.

위 그림과 같이, 서버가 Cookies 정보가 필요한 지 등의 정보를 클라이언트에게 응답 메시지로 전달한다.

cookie_needed 항목은 스프링에서 setSessionCookieNeeded(bolean) 메서드로 제어가 가능한데, 아래와 같이 설정할 수 있다.(Java 애플리케이션에서 JSESSIONID 쿠키를 많이 사용하기 때문에 디폴트 설정은 true 이다.)

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
		webSocketHandlerRegistry
			.addHandler(webSocketHandler(), "/test")
			.withSockJS()
			.setSessionCookieNeeded(false);
	}

	...
}

위에서 말했다시피, 만약 서버에서 쿠키가 필요하지 않다면 SockJS 클라이언트는 IE 8, 9 버전에서 XDomainRequest를 사용하게 한다.

또한, iframe 기반의 Transports를 사용하는 경우에는 브라우저가 [X-Frame-Options](https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/X-Frame-Options) 응답 헤더에 지정한 DENY, SAMEORIGIN, ALLOW-FROM <origin> 페이지들에 대해서만 iframe을 렌더링하도록 방지할 수 있다.

  • DENY는 모든 iframe에서 사용될 수 없다.
  • SAMEORIGIN은 동일한 출처, 즉 같은 도메인인 경우에만 허용
  • ALLOW-FROM <origin>는 지정한 도메인 URI에 대해서만 허용

X-Frame-Options 응답 헤더는 해당 페이지를 <frame>, <iframe>, <object>에서 렌더링할 수 있는 지 여부를 의미한다.

만약 iframe 기반의 Transports를 사용하고 X-Frame-Options 응답 헤더를 포함하려면, 반드시 SAMEORIGIN 이거나 ALLOW-FROM <origin>SockJS 클라이언트 도메인을 지정해야만 한다.

즉, iframe로 부터 로드되기 위해서는 스프링 서버의 SockJS가 클라이언트의 위치를 알고 있어야 한다는 것이다.

따라서, 스프링은 SAMEORIGIN을 지원하기 위해서 SockJS-Client 접근 경로를 설정할 수 있도록 아래와 같이 제공한다.

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
		webSocketHandlerRegistry
			.addHandler(webSocketHandler(), "/test")
			.withSockJS()
			.setClientLibraryUrl("http://localhost:8080/myApplication/js/sockjs-client.js")
	}

	@Bean
	public WebSocketHandler webSocketHandler() {
		return new Handler();
	}
}

Heartbeat

SockJS 프로토콜은 서버가 주기적으로 Heartbeat Message 전송하여, 프록시가 커넥션이 끊겼다고 판단하지 않도록 한다.

스프링 SockJS ConfigurationHeartbeatTime을 사용자가 지정할 수 있도록 setHeartbeatTime(long) 메서드를 제공하는데, HeartbeatTime의 시작은 마지막 메시지가 전송된 이후부터 카운트된다. (디폴트는 25초이다)

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
		webSocketHandlerRegistry
			.addHandler(webSocketHandler(), "/test")
			.withSockJS()
			.setHeartbeatTime(30)
	}

	@Bean
	public WebSocketHandler webSocketHandler() {
		return new Handler();
	}
}

뿐만 아니라, 스프링 SockJSHeartbeat Tasks를 스케줄링할 수 있도록 TaskScheduler를 설정할 수도 있다. TaskScheduler는 기본적으로 사용 가능한 프로세서 수만큼 생성되어 Thread Pool에 백업된다.

만약 STOMP를 사용해 Heartbeat를 주고 받을 경우에는 SockJS Heartbeat 설정은 비활성화된다.

Client Disconnects

SockJS Transports 타입인 HTTP StreamingHTTP Long Polling는 일반 요청보다 더 긴 커넥션을 요구한다.

이러한 요구 사항은 서블릿 컨테이너에서 Servlet 3 asynchronous 지원을 통해 수행된다.

구체적으로, Servlet 3 asynchronousServlet Container Thread가 종료되고도 요청을 처리하며 다른 스레드가 지속적으로 응답에 Write 할 수 있도록 지원한다.

여기서 문제점은 Servlet API가 갑자기 사라진 클라이언트에 대한 알림을 제공하지 않는다는 것이다.

그러나 다행히도, Servlet Container는 응답에 Write를 시도하는 경우 예외를 발생시킨다.

뿐만 아니라, 스프링의 SockJS는 서버 측에서 주기적으로 Heartbeat를 전송하기 때문에 클라이언트의 연결 여부를 일정 시간 안에 파악할 수 있다.

SockJS와 CORS

만약 Cross-Origin Requests(CORS)를 허용한다면, SockJS 프로토콜은 HTTP Streaming, HTTP Long Polling 과정에서 해당 CORS를 사용한다.

따라서, 스프링은 응답 헤더에서 CORS 헤더를 발견되지 않는다면 SockJS CORS에서 설정한 정보를 기반으로 헤더를 추가한다.

만약 Servlet Filter를 통해서 이미 CORS 설정한 경우에는 스프링의 SockJsService에서의 CORS 설정은 건너뛴다. 또한, 각 핸들러에서는 setSupressCors(boolean) 메서드를 이용해서 SockJsService를 통한 CORS 헤더 추가 여부를 설정할 수 있다.

SockJsService에서 CORS 헤더를 추가하도록 설정하고 싶은 경우에는 SockJS Endpoint의 Prefix에 대해서는 Servlet Filter를 제외하도록 설정한다.

SockJS에서는 CORS 헤더에 아래와 같은 값이 필요하다.

  • Access-Control-Allow-OriginOrigin 요청 헤더의 값으로 초기화된다.
  • Access-Control-Allow-Credentials는 항상 True로 설정된다.
  • Access-Control-Request-Headers는 실제 요청이 만들어질 때 클라이언트가 보낼 수도 있는 HTTP headers를 서버에게 알리는 용도로, 브라우저가 preflight request를 보내는 경우에 사용된다. SockJS에서는 Request와 동일한 헤더로 설정한다.
  • Access-Control-Allow-Methods는 서버가 지원하는 Transports 타입의 HTTP METHOD를 설정한다.
  • Access-Control-Max-Age는 preflight request 결과를 얼마나 캐시할 지를 나타내고, 31536000(1년)으로 설정된다.

SockJS 설정

SockJS은 서버 측에서 "HTTP Streaming에서 전송하는 메시지의 크기", "클라이언트가 연결이 끊긴 것으로 간주하는 시간" 등의 설정을 WebSocketConfigurerSockJsServiceRegistration 통해서 할 수 있다.

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
		webSocketHandlerRegistry
			.addHandler(webSocketHandler(), "/test")
			.withSockJS()
			.setStreamBytesLimit(512 * 1024)
			.setHttpMessageCacheSize(1000)
			.setDisconnectDelay(30 * 1000)
	}

	@Bean
	public WebSocketHandler webSocketHandler() {
		return new Handler();
	}
}
  • StreamBytesLimit는 단일 HTTP 스트리밍 요청을 통해 전송될 수 있는 최소 바이트 수를 의미한다.(Default, 128 * 1024)

  • HttpMessageCacheSize는 클라이언트의 다음 HTTP 폴링 요청을 기다리는 동안에, 서버가 클라이언트로 전송하기 위해 메시지들을 세션에 캐시할 수 있는 개수이다.(Default, 100)

    즉, 다음 HTTP 폴링 요청에 대한 커넥션이 생성될 때까지 세션에 저장하고 있을 수 있는 메시지의 개수를 의미한다.

    모든 HTTP 기반의 Transports도 해당 속성을 사용한다.(HTTP Streaming도 사용)

  • DisconnectDelay는 클라이언트가 연결이 끊긴 것으로 간주되는 시간을 의미한다.(Default, 5 * 1000)

SockJSClient

스프링은 브라우저없이 SockJS Java Client 이용해서 SockJS에 연결하는 기능을 제공한다.

이러한 기능은 두 서버간에 양방향 통신이 필요한 경우에 유용하게 사용될 수 있다.

또한, 테스팅하는 경우에도 유용하게 사용될 수 있다.

SockJS Java Client는 오직 websocket, xhr-streaming, xhr-polling Transports 타입만 제공하고, 나머지는 브라우저에서만 지원된다.

아래 예제는 사용할 Transports 타입을 지정하고 SockJSClient 이용해서 http://localhost:8090/test 서버와 연결하는 작업이다.

List<Transport> transports = new ArrayList<>();
transports.add(new WebsocketTransport(new StandardWebSocketClient()));
transports.add(new RestTemplateXhrTransport());

SockJsClient sockJsClient = new SockJsClient(transports);
sockJsClient.doHandshake(webSocketHandler(), "http://localhost:8080/test");

Transports 타입 등록하는 과정을 자세히 살펴보자.

클라이언트 관점에서는 서버에 연결하는데 사용되는 URL 외에 다른 차이점이 없기 때문에, XhrTransportxhr-streamingxhr-polling 모두를 지원한다.

즉, RestTemplateXhrTransportxhr-streamingxhr-polling 모두를 지원하는 XhrTransport 구현체이다.

RestTemplateXhrTransport은 내부적으로 HTTP Request를 위해 스프링에서 제공하는 RestTemplate를 사용한다.

또한 WebSocket 설정은 WebsocketTransport 객체를 통해 이루어지는데, JSR-356 runtime에서 제공하는 StandardWebSocketClient를 사용하도록 지정한다.

HTTP Client 시뮬레이션 테스트

만약 SockJsClient 이용해서 여러 동시 사용자를 시뮬레이션하는 경우라면, HTTP Client(XHR transports)가 충분히 많은 커넥션과 스레드를 허용하도록 구성할 수 있다.

아래는 Jetty 이용한 예제이다.

HttpClient httpClient = new HttpClient();
httpClient.setMaxConnectionsPerDestination(1000);
httpClient.setExecutor(new QueuedThreadPool(500));

STOMP

WebSocket 프로토콜은 두 가지 유형의 메시지를 정의하고 있지만, 그 메시지의 내용까지는 정의하고 있지 않다.

STOMPWebSocket 위에서 동작하는 프로토콜로써, 클라이언트와 서버가 전송할 메시지 유형, 형식, 내용들을 정의하는 매커니즘이다.

STOMP 이란?

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

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

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

COMMAND
header1:value1
header2:value2

Body^@

STOMP Protocol Specification, Version 1.2

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

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

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

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

만약 스프링에서 지원하는 STOMP을 사용하게 된다면, 스프링 WebSocket 애플리케이션은 STOMP Broker로 동작한다.

스프링에서 지원하는 STOMP은 다양한 기능을 제공한다.

구체적으로 메시지를 @Controller의 메시지 핸들링하는 메서드로 라우팅하거나, Simple In-Memory Broker를 이용해서 Subscribe중인 다른 클라이언트들에게 메시지를 브로드캐스팅한다. Simple In-Memory Broker는 클라이언트의 Subscribe 정보를 자체적으로 메모리에 유지한다.

뿐만 아니라, 스프링은 RabbitMQ, ActiveMQ 같은 외부 Messaging SystemSTOMP 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 헤더는 클라이언트가 SUBSCRIBEid 헤더와 일치해야만 한다.

STOMP 장점

Spring FrameworkSpring SecuritySTOMP 프로토콜을 사용하여, WebSockets만 이용할 때 보다 더 풍부한 프로그래밍 모델을 제공할 수 있는데 하나씩 살펴보자.

  • 메시징 프로토콜을 만들고, 메시지 형식을 커스터마이징할 필요가 없다.

  • RabbitMQ, ActiveMQ 같은 Message Broker을 이용해서, subscription을 관리하고 메시지를 브로드캐스팅할 수 있다.

  • WebSocket 기반으로 각 커넥션마다 WebSocketHandler를 구현하는 것보다, @Controller된 객체를 이용해서 조직적으로 관리할 수 있다.

    즉 메시지들은 STOMPDestination 헤더를 기반으로, @Controller 객체의 @MethodMapping 메서드로 라우팅된다.

  • STOMPDestinationMessage 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, 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>

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

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

stomp.connect({}, function(frame) {
  ...
}

현재 위 예제에서는 loginpasscode 헤더가 필요하지 않았다. 심지어 만약 클라이어트에서 설정했더라도 서버측에서 무시했을 것이다.

자세한 코드를 보고 싶다면, 스프링에서 제공하는 샘플 코드를 살펴보자.

Message Flow

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

구성 요소

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

다음 목록에서는 몇 가지 사용 가능한 메시징 추상화에 대해 설명한다.

  • Messageheaderspayload를 포함하는 메시지의 representation이다.

  • MessageHandlerMessage 처리에 대한 계약이다.

  • MessageChannelProducersConsumers의 느슨한 연결을 가능하게 하는 메시지 전송에 대한 계약이다.

  • SubscribableChannelMessageHandler 구독자(Subscribers)를 위한 MessageChannel이다.

    Subscribers를 관리하고, 해당 채널에 전송된 메시지를 처리할 Subscribers를 호출한다.

  • ExecutorSubscribableChannelExecutor를 사용해서 메시지를 전달하는 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

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

다음 그림은 외부 브로커를 사용해서 subscriptionsbroadcasting 메시지를 관리하도록 설정한 구성 요소를 보여준다.

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 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 프레임을 전송한다.

    서버는 프레임을 수신하면 디코딩하여 Message로 변환하고, 메시지를 clientInboundChannel로 전송한다.

    그리고나서, 해당 clientInboundChannel 채널에서 메시지를 메시지 브로커로 바로 라우팅해주고, 메시지 브로커는 해당 클라이언트의 구독(Subscription) 정보를 저장한다.

  3. 이후, 클라이언트는 /test/good 경로의 Destination 헤더를 가지고 메시지를 전송한다.

    /test prefix는 해당 메시지가 @MessageMapping 메서드를 가진 컨트롤러로 라우팅될 수 있도록 도움을 준다.

    구체적으로, /test 접두사가 벗겨진 후에는 /good 목적지 경로만 남게 되고 TestConroller@MessageMapping 가진 handle() 메서드로 라우팅된다.

  4. @MessageMapping 가진 handle() 메서드가 반환한 값은 스프링의 Message로 변환된다.

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

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

    이후, 변환된 MessagebrokerChannel로 전송되고 메시지 브로커에 의해서 처리된다.

  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과 같은 메서드에서 지원하는 인자 목록에 대해서 살펴보자.

  • Message

    완전한 Message 정보에 접근한다.

  • MessageHeader

    Message 안에 Header 정보에 접근한다.

  • MessageHeaderAccessor, SimpMessageHeaderAccessor, StompHeaderAccessor

    타입이 지정된 접근자 메서드를 통해서 Header 정보에 접근한다.

  • @Payload

    MessageConverter 의해서 변환된 메시지의 Payload에 접근한다.

    만약 다른 인자와 일치하지 않으면 매칭되기 때문에 반드시 요구되지는 않는다.

    Payload 인자를 자동으로 검증하기 위해 @javax.validation.Valid 또는 스프링의 @Validated을 함께 사용할 수도 있다.

  • @Header

    구체적인 Header 값에 접근한다.

    필요한 경우에는 org.springframework.core.convert.converter.Converter 이용한 타입 변환과 함께 사용할 수도 있다.

  • @DestinationVariable

    메시지의 Destination 헤더의 경로를 기반으로 Template 변수의 값을 추출하여 접근한다.

    Value는 선언된 타입에 따라 필수적으로 변환된다.

  • java.security.Principal

    WebSocket HTTP Handshake시 로그인한 사용자를 반영한다.

다음은 위 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)에게 브로드 캐스팅된다.

이 과정에서, MessageDestination 헤더는 클라이언트로 부터 전달받은 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>

Simple Broker

내장된 Simple Message Broker는 클라이언트에게 받은 구독 요청을 메모리에 저장하고, Destination 헤더와 일치하는 클라이언트 커넥션에 메시지를 브로드캐스팅한다.

만약 TaskScheduler와 함께 설정한다면, Simple Message BrokerSTOMP Heartbeat를 지원한다.

자신만의 TaskScheduler를 구현해서 사용하거나, 기본적으로 등록되는 스케줄러를 사용할 수도 있다.

아래 예제는 자신이 구현한 TaskScheduler를 등록해서 사용하는 예제이다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	private TaskScheduler taskScheduler;
	
	public WebSocketConfig(TaskScheduler taskScheduler) {
		this.taskScheduler = taskScheduler;
	}

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setApplicationDestinationPrefixes("/simple")
			.enableSimpleBroker("/topic", "/queue")
			.setHeartbeatValue(new long[]{10000, 20000})
			.setTaskScheduler(taskScheduler);
	}
}

External Broker

Simple Message Broker는 처음 시작하기에 좋지만, 일부 STOMP COMMAND만 지원한다는 단점이 있다.

구체적으로, Simple Message Brokeracks, receipts 등의 다른 기능을 지원하지 않는다.

뿐만 아니라, 연결된 구독자들에게 메시지 전송하는 경우에 Simple Message Broker는 단순한 반복문(Loop)에 의존하기 때문에 클러스터링에 적합하지 않다는 단점이 있다.

위와 같은 단점들은 완전한 기능을 갖춘 Message Broker를 사용하므로써, 애플리케이션을 업그레이드할 수 있다.

RabbitMQ, ActiveMQ 같은 외부 메시지 브로커를 선택해 설치하고 실행하면, 애플리이케션에서 STOMP broker relay를 아래와 같이 설정할 수 있다.

@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")
			.enableStompBrokerRelay("/topic", "/queue");
	}
}

STOMP broker relay는 메시지를 외부 메시지 브로커로 포워딩하는 스프링의 MessageHandler(StompBrokerRelayMessageHandler)이다.

구체적으로, StompBrokerRelayMessageHandler는 아래 순서로 동작한다.

  1. CONNECT 메시지마다 브로커와 TCP 연결을 수립하고,

    (각 클라이언트마다 독립된 TCP 커넥션을 사용하는데, 이는 session-id 메시지 헤더로 식별한다.)

  2. 모든 메시지를 브로커에 전달한 다음,

  3. 브로커로부터 수신한 모든 메시지는 각각의 session-id를 메시지 헤더에 더하고, WebSocket 세션을 통해 클라이언트에게 전달된다.

위 흐름을 통해서 알 수 있듯이, StompBrokerRelayMessageHandler는 메시지를 양방향으로 전달하는 릴레이 역할을 한다.

또한, StompBrokerRelayMessageHandler는 자동으로(기본적으로) 메시지 브로커와 단 하나의 System TCP Connection을 수립한다.

System TCP Connection은 서버 애플리케이션이 메시지 브로커에게 메시지를 전달하기 위한 용도이다.

구체적으로, 메시지는 어떠한 클라이언트와도 관련이 없기 때문에 session-id 헤더도 가지고 있지 않다.

System TCP Connection은 효율적으로 공유가 가능하지만, 메시지 브로커로부터 메시지를 받는 용도로 사용하지 않는다.

이러한, StompBrokerRelayMessageHandlerSystem TCP Connection의 몇 가지 설정할 수 있도록 아래와 같은 메서드를 제공한다.

스프링 STOMP Configuration에서도 아래와 같이 설정할 수 있도록 메서드를 제공한다.

@Configuration

@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		ExecutorSubscribableChannel
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setApplicationDestinationPrefixes("/simple")
			.enableStompBrokerRelay("/topic", "/queue")
			.setSystemLogin(String)
			.setSystemPasscode(String)
			.setSystemHeartbeatSendInterval(long)
			.setSystemHeartbeatReceiveInterval(long)
	}
}

뿐만 아니라, 애플리케이션의 컨트롤러, 서비스 등의 컴포넌트에서도 broker relay에 메시지를 보내어, 구독중인 WebSocket 클라이언트들에게 메시지를 브로드캐스팅할 수 있다.

실제로 broker relay는 강력하고 확장 가능한 메시지 브로드캐스팅을 지원한다.

TCP 커넥션을 관리하기 위해서는 io.projectreactor.netty:reactor-nettyio.netty:netty-all 의존성을 추가해야 한다.

Connecting to a Broker

위에서 말했던, StompBrokerRelayMessageHandler은 기본적으로 메시지 브로커와 단 하나의 System TCP Connection을 수립한다.

System TCP Connection을 위해, 스프링은 STOMP credentials(STOMP Frame login, passcode headers)을 설정할 수 있도록 메서드를 제공한다.

해당 메서드들은 systemLogin, systemPasscode 속성을 설정하는데, 디폴트 값은 모두 guest이다.

STOMP broker relay는 연결된 모든 WebSocket 클라이언트에 대해 메시지 브로커와 별도의 TCP 연결을 생성한다.

스프링은 클라이언트 대신 생성된 모든 TCP 커넥션에 STOMP credentials 설정 가능하도록 메서드를 제공한다.

해당 메서드들은 clientLogin, clientPasscode 속성을 설정하는데, 디폴트 값은 모두 guest이다.

STOMP broker relay는 클라이언트를 대신해서 브로커로 전달하는 모든 CONNECT Frame에 항상 loginpasscode 헤더를 설정하기 때문에, WebSocket 클라이언트는 해당 헤더를 설정할 필요가 없다.(설정했더라도 무시된다.)

대신, WebSocket 클라이언트HTTP 인증을 사용해 WebSocket Endpoint를 보호하고 클라이언트 식별자를 설정해야 한다.

STOMP broker relay메시지 브로커System TCP Connection을 통해서 Heartbeat를 주고 받는다.

스프링은 StompBrokerRelayMessageHandler에 대한 설정을 할 수 있도록 메서드를 제공하는데, 메시지 브로커와 주고 받는 Heartbeat 시간 간격도 설정할 수 있다.(default, 10초)

만약 브로커와 연결이 끊어진다면, STOMP broker relay는 성공할 때까지 5초마다 재연결을 시도한다.

ApplicationListener<BrokerAvailabilityEvent>을 구현한 스프링 BeanSystem TCP Connection 연결이 메시지 브로커와 끊기거나 재연결될 때마다 알림을 받는다.

이를 통해서, System TCP Connection이 비활성화된 경우에는 메시지 전송을 중지할 수 있다.

public class BrokerAvailabilityEventListener implements ApplicationListener<BrokerAvailabilityEvent> {
	@Override
	public void onApplicationEvent(BrokerAvailabilityEvent brokerAvailabilityEvent) {
		// ...
	}
}

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		ExecutorSubscribableChannel
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setApplicationDestinationPrefixes("/simple")
			.enableStompBrokerRelay("/topic", "/queue");
	}

	@Bean
	public ApplicationListener<BrokerAvailabilityEvent> brokerAvailabilityEventApplicationListener() {
		return new BrokerAvailabilityEventListener();
	}
}

기본적으로 STOMP broker relay는 항상 동일한 Host, Port로 재연결한다.

만약 여러 URL을 가지고 매번 다르게 연결 시도를 하고 싶다면, 고정된 Host, Port 대신하여 주소 공급자를 설정할 수 있다.

@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")
			.enableStompBrokerRelay("/topic", "/queue");
			.setTcpClient(createTcpClient());
	}

	private ReactorNettyTcpClient<byte[]> createTcpClient() {
		// 주소 목록
		String[] addresses = new String[] {"something.co.kr:8080", "test.com:9090", "something.co.kr:9080",
			"test.com:8070", "something.co.kr:8070"};
		Queue<String> queue = new LinkedList<>();
		Collections.addAll(queue, addresses);

		return new ReactorNettyTcpClient<>(
			// System TCP Connection 설정
			(TcpClient tcpClient) -> {
				// System TCP Connection의 원격 주소 설정
				return tcpClient.remoteAddress(() -> {
					// 주소 공급자
					// 첫 번째 위치에 있는 주소 dequeue
					String address = queue.poll();

					String[] components = address.split(":");
					String host = components[0];
					Integer port = Integer.parseInt(components[1]);

					SocketAddress socketAddress = new InetSocketAddress(host, port);
					// 마지막 위치에 있는 주소 enqueue
					queue.add(address);
					return socketAddress;
				});
			},
			new StompReactorNettyCodec()
		);
	}
}

만약 STOMP broker relayVirtualHost 속성을 설정한다면, VirtualHost 속성 값은 모든 CONNECT 프레인의 host 헤더로 세팅되어 유용하게 사용된다.

예를 들어, "클라우드 기반으로 STOMP 메시지 브로커를 서비스하는 호스트"와 "TCP 커넥션이 수립되는 실제 호스트"가 다른 클라우드 환경에서 유용하게 사용된다.

@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")
			.enableStompBrokerRelay("/topic", "/queue");
			.setVirtualHost("http://virtual-host:8080");
	}
}

Dots as Separators

메시지는 AntPathMatcher와 일치하는 @MessageMapping 메서드로 라우팅된다.

AntPathMatcher는 기본적으로 /를 구분자로 사용하는데, 이는 HTTP 기반의 Web 애플리케이션에서 매우 좋은 컨벤션이다.

하지만, 만약 보다 메시징 시스템에 적합한 컨벤션을 적용하고 싶다면, . 구분자를 사용하면 된다.

아래는 . 구분자를 가진 AntPathMatcher를 설정하는 예제이다.

@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.setPathMatcher(new AntPathMatcher("."))
			.setApplicationDestinationPrefixes("/simple")
			.enableStompBrokerRelay("/topic", "/queue");
	}
}

위와 같이 설정했다면, 컨트롤러는 @MessageMapping 메서드에서 . 구분자를 사용할 수 있다.

@Controller
public class TestController {

	@MessageMapping("good.{id}")
	public String handle(@DestinationVariable String id, @Payload String payload) {
		return "[" + id + "]" + payload;
	}

}

클라이언트에서는 /simple/good.ds4j2pkz 경로로 메시지를 보낼 수 있다.

앞의 예제를 보면, broker relay에 대한 접두사는 전적으로 외부 메시지 브로커 스펙을 따르기 때문에, 스프링에서는 직접적으로 변경할 수 없다는 것을 알 수 있다.

반면에, 스프링 소켓 모듈에 내장된 Simple Broker는 설정한 PathMatcher에 의존한다.

따라서 구분자를 변경하면 브로커에 적용될 뿐만 아니라, 브로커가 subscription 패턴으로 메시지와 구독자를 매칭하는 방법도 변경된다.

인증

WebSocket을 통한 모든 STOMP 메시징 세션은 HTTP 요청으로 시작하고, WebSocket HandShake 과정으로 HTTP 요청은 WebScoket으로 Upgrade한다.(SockJS 경우에는 일련의 SockJS HTTP transport 요청을 보낸다.)

많은 웹 애플리케이션은 이미 HTTP 요청을 보호하기 위해 인증 및 권한을 제공하고 있다.

전형적으로, Spring Security는 사용자가 login 페이지, HTTP basic 인증 등을 제공하여 사용자를 인증한다.

인증된 사용자에 대한 보안 컨텍스트는 HTTP 세션에 저장되고, 그 다음 요청부터는 쿠키 기반으로 동일하게 연결된다.

따라서 WebSocket handshake 또는 SockJS HTTP transport 요청의 경우, 일반적으로 HttpServletRequest#getUserPrincipal()으로 접근 가능한 인증된 사용자가 이미 존재한다.

결국, 스프링은 자동적으로 WebSocket 또는 SockJS 세션을 갖는 사용자와 해당 세션으로 전달되는 모든 STOMP 메시지를 연관짖는다.

요약해보면, 일반적인 웹 애플리케이션은 이미 보안을 위해 이미 많은 작업을 수행하고 있기 때문에 추가적으로 무엇을 할 필요는 없다.

즉, 사용자는 쿠키 기반의 HTTP 세션으로 유지되는 보안 컨텍스트를 사용해서 HTTP 요청 수준에서 인증된다.

이후 애플리케이션을 통과하는 모든 메시지는 사용자 헤더를 가지고 인증 확인 과정을 거친다.

물론 STOMP 프로토콜도 CONNECT 프레임에 login, passcode 헤더를 가지고 있다.

이러한 헤더들은 STOMP이 TCP 기반으로 동작하는 경우에 필요하지만, WebSocket 기반인 경우에는 스프링에서 STOMP 프로토콜 수준의 인증 헤더를 무시한다.

그 이유는 사용자가 HTTP 전송 수준에서 이미 인증되었다고 가정하기 때문이다.

따라서, WebSocket, SockJS 세션은 서버에 메시지를 보내기 위해 반드시 이미 인증된 사용자를 포함하고 있어야만 한다.

Token 인증

Spring Security OAuth는 JWT 같은 Token 기반의 보안을 제공한다.

따라서, WebSocket 기반 STOMP 프로토콜을 비롯한 웹 애플리케이션에서 Token 기반 보안을 사용할 수 있다.

Token 기반 보안을 사용하는 이유는 항상 쿠키 기반의 세션이 모든 상황에서 적합할 수 없기 때문이다.

예를 들어, 서버 애플리케이션에서 세션을 지원하지 않을 수도 있고 모바일 애플리케이션에서는 일반적으로 인증 헤더를 선호한다.

The WebSocket protocol, RFC 6455은 WebSocket Handshake 과정에서 서버가 클라이언트를 인증하는 방법을 규정하고 있지 않다.

또한 브라우저 클라이언트는 오직 표준 인증 헤더 또는 쿠키만 사용할 수 있으며, 커스텀한 사용자 지정 헤더를 사용할 수 없다.

뿐만 아니라, SockJS JavaScript clientSockJS transport 요청과 함께 HTTP 헤더를 전달할 방법을 제공하고 있지 않다.

대신에, 클라이언트는 Token을 Query Parameter로 전송할 수 있으나 이 또한 몇 가지 단점을 가지고 있다.

예를 들어, 토큰이 서버 로그에 URL과 함께 실수로 기록 될 수 있다.

위에서 말했다시피, 서버 애플리케이션은 HTTP 수준에서 쿠키 사용없이 인증할 마땅한 대안이 없다.

따라서 쿠키 사용 대신에, STOMP Messaging 프로토콜 수준의 헤더를 이용해서 인증하는 것을 생각해 볼 수 있다. 그렇게 하려면, 아래와 같은 두 단계가 필요하다.

  1. STOMP 클라이언트는 CONNECT 프레임에 pass 인증 헤더를 추가해야 한다.
  2. ChannelInterceptor 사용해서 인증 헤더를 처리한다.

다음 예제는 서버측에서 커스텀하게 인증 처리하는 인터셉터를 등록하는 과정이다.

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

	@Override
	public void configureClientInboundChannel(ChannelRegistration registration) {
		registration.interceptors(new ChannelInterceptor() {
			@Override
			public Message<?> preSend(Message<?> message, MessageChannel channel) {
				StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
				
				// CONNECT 메시지인 경우에만 인증 처리
				if (StompCommand.CONNECT.equals(accessor.getCommand())) {
					Authentication user = ...; // access authentication headers
					accessor.setUser(user); // 사용자 헤더 추가
				}

				return message;
			}
		});
	}
}
@Controller
public class TestController {

	@MessageMapping("/good")
	public String handle(@Payload String payload, Principal user) {
		return "[" + user.getName() + "]" +payload;
	}

}

interceptor는 오직 CONNECT 메시지를 기반으로 인증하고, 사용자 헤더를 추가하는 기능을 제공한다.

이러한 interceptor를 통해서, 스프링은 해당 세션에 대한 인증된 사용자를 저장한다.

이후, 스프링은 동일한 세션으로 전달된 STOMP 메시지에 인증된 사용자 정보를 추가해준다.

위 사진에서 simpUser 헤더가 추가된 것을 확인할 수 있는데, 동일한 세션으로 받은 메시지 헤더에는 인증된 사용자에 대한 헤더가 추가되는 것이다.

다만, 메시지 기반으로 Spring Security 인증을 사용하는 경우에는 반드시 ChannelInterceptorSpring Security보다 앞쪽 순서에 설정해야 한다.

이는 @Order(Ordered.HIGHEST_PRECEDENCE + 99)을 추가하여 순서를 보장할 수 있다.

public class AuthenticationChannelInterceptor implements ChannelInterceptor {

	@Override
	public Message<?> preSend(Message<?> message, MessageChannel channel) {
		StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
		if (StompCommand.CONNECT.equals(accessor.getCommand())) {
			Authentication user = ...;
			accessor.setUser(user);
		}

		return message;
	}

}
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	
	// ...

	@Override
	public void configureClientInboundChannel(ChannelRegistration registration) {
		registration.interceptors(authenticationChannelInterceptor());
	}

	@Bean
	@Order(Ordered.HIGHEST_PRECEDENCE + 99)
	public ChannelInterceptor authenticationChannelInterceptor() {
		return new AuthenticationChannelInterceptor();
	}

}

User Destinations

스프링의 STOMP 모듈은 /user 접두사를 가진 destination 헤더를 인지해서, 애플리케이션이 특정한 사용자에게 메시지를 보낼 수 있도록 지원한다.

예를 들어, 사용자가 /user/queue/something 경로의 Destination을 구독한다고 가정해보자.

DestinationUserDestinationMessageHandler에 의해서 처리되는데, 각 사용자 세션마다 고유한 Destination으로 변환된다.(/queue/something-user234)

이를 통해, 동일한 Destination에 동시로 구독하는 다른 사용자와 충돌이 발생하지 않도록 보장한다.

아래 링크를 보면, UserDestinationMessageHandlerUserDestinationResolver로 경로를 변형하는 과정을 볼 수 있다.

송신측은 /user/{username}/queue/something같은 형식의 Destination에 메시지를 보낸다.

이후, 메시지는 UserDestinationMessageHandler 의해서 /queue/something-user{session-id} 형태로 변환되어 하나 이상의 Destination으로 전달된다.

이러한 지원을 통해서, 애플리케이션 내의 모든 컴포넌트는 '사용자 이름' 및 'Destination'에 대한 정보없이도 특정한 사용자들에게 메시지를 보낼 수 있다.

구체적으로, 이는 애노테이션과 메시징 템플릿을 이용해서 지원된다.

@SendToUser

메시지 핸들링하는 메서드는 @SendToUser을 이용해서 특정 사용자에게 메시지를 보낼 수 있다.

@Controller
public class TestController {

	@MessageMapping("/good")
	@SendToUser("/queue/something")
	public String handle(@Payload String payload) {
		return payload;
	}

}

@SendToUser("/queue/something")/user/{username}/queue/something Destination의 사용자들에게 메시지를 보내는 것이다.

UserDestinationMessageHandler/user/{username}/queue/something/queue/something-user{session-id} 형태로 변환한다.

위 링크에서 UserDestinationMessageHandler 객체의 handleMessage() 메서드를 볼 수 있는데, 변경된 경로를 로그로 보면 아래와 같다.

<!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("/user/queue/something", 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>

클라이언트에서는 /user/queue/something 경로에 대해서 구독하고 있는 것을 볼 수 있다.

만약 클라이언트에서 다른 사용자에게 메시지를 전송하고 싶다면, /user/{상대방 ID}를 접두사로 하여 메시지를 보내면 된다.

서버 설정

public class AuthenticationChannelInterceptor implements ChannelInterceptor {
	private int num = 1;
	@Override
	public Message<?> preSend(Message<?> message, MessageChannel channel) {
		StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
		if (StompCommand.CONNECT.equals(accessor.getCommand())) {
			accessor.setUser(new User(accessor.getLogin()));
		}

		return message;
	}
}

CONNECT 메시지에서 login 헤더를 추출해 사용자 식별자로 설정한다.

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

	@Override
	public void configureClientInboundChannel(ChannelRegistration registration) {
		registration.interceptors(authenticationChannelInterceptor());
	}
}

SockJS 기반의 STOMP Configuration에서 AuthenticationChannelIntercept를 등록한다.

@Controller
public class TestController {

	@MessageMapping("/good")
	@SendToUser("/queue/something")
	public String handle(@Payload String payload) {
		return payload;
	}

}

@SendToUser("/queue/something")/user/{username}/queue/something Destination의 사용자들에게 메시지를 보낸다.

UserDestinationMessageHandler/user/{username}/queue/something/queue/something-user{session-id} 형태로 변환한다.

아래 사진은 foo 사용자가 bar 사용자에게 메시지를 전달하기 위해, /user/bar/queue/something Destination으로 요청을 보낸 경우이다.

/user/bar/queue/something/queue/something-userizyex34p으로 변경된 것을 알 수 있다.

프론트 설정

  • foo 사용자
<!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>
        <button onClick='sendOther()' 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({
                login: "foo"
            }, function(frame) {
                console.log('Connected!!');

                stomp.subscribe("/user/queue/something", 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 = '';
        }
				
				// bar 사용자에게 메시지 전송
        function sendOther() {
            const message = document.querySelector(".message");
            stomp.send("/user/bar/queue/something", message.value);
            message.value = '';
        }
    </script>
</html>
  • bar 사용자
<!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>
        <button onClick='sendOther()' 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({
                login: "bar"
            }, function(frame) {
                console.log('Connected!!');

                stomp.subscribe("/user/queue/something", 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 = '';
        }

				// foo 사용자에게 메시지 전송
        function sendOther() {
            const message = document.querySelector(".message");
            stomp.send("/user/foo/queue/something", message.value);
            message.value = '';
        }
    </script>
</html>

만약 사용자가 하나 이상의 세션을 가지고 있다면, 기본적으로 Destination에 구독한 모든 세션으로 메시지가 보내진다.

그러나, 때때로 오직 메시지를 받은 하나의 세션에만 전송하고 싶은 경우에는 broadcast 속성을 false로 설정하면 된다.

@Controller
public class TestController {
	@MessageMapping("/good")
	public String handle(@Payload String payload, Principal user) {
		if(payload.equals("error")) {
			throw new IllegalArgumentException("error 문자열은 취급할 수 없습니다.");
		}

		return payload;
	}

	@MessageExceptionHandler
	@SendToUser(destinations="/queue/errors", broadcast=false)
	public String handleException(IllegalArgumentException exception) {
		return exception.getMessage();
	}
}
<!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>
        <button onClick='sendOther()' class="send-btn">상대방에게 보내기</button>
    </div>
    <div class="messages">
        <div id="messages"></div>
    </div>
</body>
    <script>
        let stomp1, stomp2;
        document.addEventListener("DOMContentLoaded", function() {
            // ["websocket", "xhr-streaming", "xhr-polling"]
            const sock1 = new SockJS('http://localhost:8080/test', null, {transports: ["xhr-polling"]});
            stomp1 = webstomp.over(sock1);
            stomp1.connect({
                login: "foo"
            }, function(frame) {
                console.log('First Connected!!');

                stomp1.subscribe("/user/queue/errors", function(frame) {
                    const messages = document.querySelector("#messages");
                    const message = document.createElement("li");
                    message.innerText = frame.body + " - first";
                    messages.appendChild(message)
                });
            });

            // ["websocket", "xhr-streaming", "xhr-polling"]
            const sock2 = new SockJS('http://localhost:8080/test', null, {transports: ["xhr-polling"]});
            stomp2 = webstomp.over(sock2);
            stomp2.connect({
                login: "foo"
            }, function(frame) {
                console.log('Second Connected!!');

                stomp2.subscribe("/user/queue/errors", function(frame) {
                    console.log("11");
                    const messages = document.querySelector("#messages");
                    const message = document.createElement("li");
                    message.innerText = frame.body + " - second";
                    messages.appendChild(message)
                });
            });
        });

        function send() {
            const message = document.querySelector(".message");
            stomp1.send("/simple/good", message.value);
            message.value = '';
        }

        function sendOther() {
            const message = document.querySelector(".message");
            stomp1.send("/user/bar/queue/something", message.value);
            message.value = '';
        }
    </script>
</html>

메시지를 보낸 stomp1의 세션을 통해서만 에러 메시지가 전달된다.

일반적으로 /user destination은 인증된 사용자를 대상으로 의미하지만, 사용자 인증을 하지 않은 WebSocket 세션도 /user destination을 구독할 수 있다.

단, 사용자 인증을 하지 않은 WebSocket 세션의 경우에는 @SendToUserbroadcast 속성이 false로 설정된다.

Messaging Template

또한, 애플리케이션 컴포넌트에서는 Messaging Template을 사용해서 /user Destination에 메시지를 보낼 수 있다.

Messaging TemplateSimpMessagingTemplate 객체이다.

@Controller
public class TestController {

	private SimpMessagingTemplate simpMessagingTemplate;

	public TestController(SimpMessagingTemplate simpMessagingTemplate) {
		this.simpMessagingTemplate = simpMessagingTemplate;
	}

	@MessageMapping("/good")
	public void handle(@Payload String payload, Principal user) {
		System.out.println(payload);
		System.out.println(user);

		simpMessagingTemplate.convertAndSendToUser(user.getName(), "/queue/something", payload);
	}

}

만약 다중 애플리케이션 서버 시나리오에서 사용자가 한 서버에 연결되어 있기 때문에, 다른 서버에서 /user Destination은 확인되지 않은 상태로 남아 있을 수 있다.

이런 경우, 다른 서버가 확인되지 않은 메시지를 브로드캐스트 시도하도록 Destination을 설정할 수 있다.

아래와 같이, MessageBrokerRegistryuserDestinationBroadcast 속성을 설정해주면 된다.

@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")
			.enableStompBrokerRelay("/topic", "/queue")
			.setUserDestinationBroadcast("/topic/unresolved-user-destination");
	}
}

Message 순서

브로커로 부터 받은 메시지는 clientOutboundChannelpublish된다.

채널은 ThreadPoolExecutor에 보관되며 메시지는 서로 다른 Thread에서 처리되기 때문에, 클라이언가 수신한 메시지 순서는 clientOutboundChannelpublication(게시)된 순서와 정확하게 일치한다고 보장할 수 없다.

만약 해당 이슈가 있다면, 아래와 같이 setPreservePublishOrder 속성을 설정하면 된다.

@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
			.setPreservePublishOrder(true)
			.setApplicationDestinationPrefixes("/simple")
			.enableSimpleBroker("/topic", "/queue");
	}
}

PreservePublishOrder 속성을 설정했다면, 동일한 클라이언트 세션을 통해서 보낼 메시지들은 한번에 하나씩clientOutboundChannel 채널에 publish된다.

따라서, clientOutboundChannel 채널에 publication된 순서와 클라이언트가 수신한 메시지 순서를 정확하게 일치시키도록 보장할 수 있다.

다만, 약간의 성능 오버헤드가 발생할 수 있기 때문에 오직 필요한 경우에만 사용해야 한다.

Events

ApplicationContext가 발생시키는 여러 Events 는 스프링의 ApplicationListener를 통해서 전달받을 수 있다.

  • BrokerAvailabilityEvent

    BrokerAvailabilityEvent는 브로커가 이용 가능하거나 불가능할 때 발생하는 이벤트이다.

    Simple Broker는 애플리케이션 시작과 동시에 이용 가능하고 지속적으로 유지되지만, STOMP Broker Relay는 외부 브로커와 연결이 끊길 수 있다.

    따라서, STOMP Broker RelaySystem TCP Connection을 다시 연결해야 한다.

    결과적으로, 이 이벤트는 브로커와의 연결 상태가 변할 때마다 발생한다.

    SimpMessagingTemplate을 사용하는 컴포넌트는 해당 이벤트를 구독하여 브로커를 사용할 수 없는 경우에는 메시지를 보내지 않도록 해야만 한다.

    따라서, SimpMessagingTemplate 사용하는 경우에는 항상 MessageDeliveryException 대한 예외 처리를 준비해야 한다.

  • SessionConnectEvent

    SessionConnectEvent는 STOMP 프로토콜이 새로운 (클라이언트 세션의 시작을 알리는) CONNECT 메시를 받은 경우에 발생한다.

    이 이벤트는 session ID, 사용자 정보, 사용자가 보낸 커스텀 헤더 등을 가지고 있는 CONNECT 메시지를 포함한다.(클라이언트 세션 추적에 유용)

    따라서, 해당 이벤트를 구독한 컴포넌트는 메시지를 SimpMessageHeaderAccessor 또는 StompMessageHeaderAccessor으로 Wrapping 할 수 있다.

  • SessionConnectedEvent

    SessionConnectedEvent는 브로커가 CONNECT 메시지에 대한 응답으로 STOMPCONNECTED 프레임을 보낸 경우, 즉 SessionConnectEvent 이후에 발생한다.

    해당 이벤트가 발생한 시점에는 STOMP 세션이 완전하게 수립된 것으로 간주할 수있다.

  • SessionSubscribeEvent

    SessionSubscribeEvent는 새로운 STOMP SUBSCRIBE 메시지를 받은 경우에 발생한다.

  • SessionUnsubscribeEvent

    SessionUnsubscribeEvent은 새로운 STOMP UNSUBSCRIBE 메시지를 받은 경우에 발생한다.

  • SessionDisconnectEvent

    SessionDisconnectEvent는 STOMP 세션이 끝난 경우에 발생한다.

    구체적으로, 클라이언트로 부터 DISCONNECT 메시지를 받거나 WebSokcet 세션이 닫히는 경우에 자동으로 이벤트가 발생한다.

    경우에 따라서는 해당 이벤트가 두 번 이상 발생하기 때문에, 컴포넌트는 다양한 disconnect 이벤트와 멱등성을 가져야 한다.

Interception

EventsSTOMP 연결의 라이프사이클(과정)에 대한 알림을 제공하지만, 클라이언트가 보낸 모든 메시지에 대해서 이벤트를 제공하지는 않는다.

따라서, 모든 메시지에 대한 처리를 위해 ChannelInterceptor을 등록해 사용할 수 있다.

아래는 클라이언트로 부터 전달된 inbound message를 가로채서 처리하는 예제이다.

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

	@Override
	public void configureClientInboundChannel(ChannelRegistration registration) {
		registration.interceptors(new MyChannelInterceptor());
	}
}
public class MyChannelInterceptor implements ChannelInterceptor {
	@Override
	public Message<?> preSend(Message<?> message, MessageChannel channel) {
		StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
		StompCommand command = accessor.getCommand();

		// ..

		return message;
	}
}

또한, 애플리케이션은 메시지를 별도의 Thread에서 처리하는 ExecutorChannelInterceptor(ChannelInterceptor 하위 인터페이스)를 사용할 수도 있다.

ChannelInterceptor는 채널로 메시지가 보내질 때마다 한 번씩 호출되지만, ExecutorChannelInterceptor는 각 MessageHandler의 Thread에서 Hook을 제공한다.

즉, MessageHandlerhandleMessage() 메서드가 호출되기 이전과 이후에 ExecutorChannelInterceptorbeforeHandle()afterMessageHandled() 메서드가 호출되는데, 실제로 각 메서드는 MessageHandler가 동작중인 Thread에서 실행된다.

WebSocket Scope

WebSocket 세션은 Attributes(속성)에 대한 Map을 가지고 있다.

해당 Map은 클라이언트로 부터 받은 Inbound 메시지 헤더에 추가되기 때문에, 아래와 같이 여러 컨트롤러에서 접근이 가능하다.

@Controller
public class TestController {

	@MessageMapping("/good")
	public void handle(SimpMessageHeaderAccessor messageHeaderAccessor) {
		Map<String, Object> attributes = messageHeaderAccessor.getSessionAttributes();
		// ...
	}

}

스프링은 websocket scope를 가진 Bean 선언을 지원하는데, 해당 websocket 스코프를 가진 Bean은 컨트롤러와 clientInboundChannel에 등록된 여러 ChannelInterceptor 구현체에서 주입받아 사용할 수 있다.

websocket scope를 가진 Bean은 일반적으로 싱글톤이지만, 개별 WebSocket 세션보다 오래 유지된다.

따라서, websocket scope를 가진 Bean은 아래와 같이 Proxy Mode로 사용해야 한다.

@Component
@Scope(scopeName = "websocket", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyBean {

	private int data = 1;

	@PostConstruct
	public void init() {
		System.out.println("my bean init");
	}

	@PreDestroy
	public void destroy() {
		System.out.println("my bean destroy");
	}

	public MyBean() {
		System.out.println("my bean construct");
	}

	public void count() {
		System.out.println("my bean count = " + data++);
	}
}

WebSocket 세션이 지속적되는 동안 Bean이 유지되는 지 확인하기 위해 data 변수를 사용한다.

@Controller
public class TestController {

	private final MyBean myBean;

	public TestController(MyBean myBean) {
		this.myBean = myBean;
	}

	@MessageMapping("/good")
	public String handle(SimpMessageHeaderAccessor messageHeaderAccessor, String payload) {
		myBean.count();

		Map<String, Object> attributes = messageHeaderAccessor.getSessionAttributes();
		System.out.println(attributes);

		return payload;
	}

}

어떤 커스텀 스코프와 마찬가지로, 스프링은 새로운 MyBean을 컨트롤러가 첫 번째로 접근했을 때 생성 및 초기화하고 인스턴스를 WebSocket 세션 Attributes에 저장한다.

이후, 세션이 종료될 때까지 동일한 인스턴스를 반환한다.

또한 앞선 예제에서 보시다시피, websocket scope를 가진 Bean은 스프링이 라이프 사이클 단계마다 호출해주는 PostConstruct, PreDestroy 메서드를 가질 수 있다.

profile
한번도 실수하지 않은 사람은, 한번도 새로운 것을 시도하지 않은 사람이다.

3개의 댓글

comment-user-thumbnail
2022년 4월 28일

좋은 글 정말 잘 보고 갑니다! 감사합니다.

답글 달기
comment-user-thumbnail
2022년 8월 14일

정말 잘 정리된 글이네요 감사합니다 많은 도움이 됬습니다.

답글 달기

좋은 글 감사합니다.

답글 달기