이 글은 Spring WebSocket 문서를 기반으로 작성했습니다.
WebSocket 프로토콜은 표준된 방법으로 서버-클라이언트 간에 단일 TCP 커넥션을 이용해서 양방향 통신을 제공한다.
기존의 다른 TCP 기반의 프로토콜과 다르게, WebSocket은 HTTP 요청 기반으로 Handshake 과정을 거쳐 커넥션을 생성한다.
덕분에, 초기 WebSocket Handshake 요청은 추가적인 방화벽 설정없이 80, 443 포트를 사용하여 양방향 통신이 가능하다.
뿐만 아니라, HTTP 규격 그대로 유지할 수 있기 때문에 HTTP 인증, CORS 등을 동일하게 적용할 수 있다는 장점이 있다.
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=
WebSocket이 HTTP 요청을 시작되는 호환성을 가지고 있지만, 분명하게 두 프로토콜은 다른 방식으로 동작한다.
HTTP는 여러 URL을 기반으로 서버 애플리케이션과 Request/Response 형식으로 상호 작용한다.
WebSocket은 반대로 오직 초기의 커넥션 수립을 위한 하나의 URL만 있고, 모든 애플리케이션 메시지는 동일한 TCP 커넥션에서 전달된다.
즉, WebSocket은 HTTP 프로토콜과 다른 asynchronous
, event-driven
, messaging
아키텍쳐 모델이다.
또한, HTTP 경우에는 서버가 URI, Method, Headers 정보로 적절한 핸들러로 라우팅해 처리할 수 있다.
하지만 WebSocket은 HTTP 와 다르게 메시지 내용에 의미를 두지 않기 때문에, 클라이언트-서버 간에 임의로 메시지에 의미를 부여하지 않으면 처리할 방법이 마땅히 없다.
이러한 문제를 STOMP 메시징 프로토콜을 통해서 해결할 수 있는데, 상위 프로토콜이 규정한 협약을 토대로 메시지를 처리할 수 있다.
고전적인 Polling 방식은 새로운 정보가 있는지 확인하기 위해 주기적으로 HTTP 요청을 보낸다. 이러한 방식은 지속적으로 요청을 보내기 때문에, 매번 커넥션을 생성하기 위한 Handshake 비용이 많아지며 서버에 부담을 주게 된다.
Long Polling은 Traditional Polling을 개선한 방식이다.
클라이언트는 서버에 요청을 보내고, 서버는 변경 사항이 있는 경우에만 응답하여 커넥션을 종료한다.
그리고 클라이언트는 바로 다시 서버에 요청을 보내어 변경 사항이 있을 때까지 대기하게 된다.
커넥션은 무한히 대기할 수 없으므로, 브라우저는 약 5분 정도 대기하며 중간 프록시에 따라 더 짧게 커넥션이 종료될 수도 있다.
만약 변경 사항이 불규칙적인 간격으로 일어나는 경우 효율적이나, 변경 사항의 빈도가 잦다면 기존 Traditional Polling과 차이가 없으므로 서버의 부담이 증가하게 된다.
HTTP Streaming은 Long Polling 과 동일하게 HTTP 요청을 보내지만, 변경 사항을 클라이언트에 응답한 이후에도 커넥션을 종료하지 않고 유지한다.
따라서 매번 새로운 커넥션을 맺고 끊는 것이 아니라 하나의 커넥션을 유지하며, 변경 사항을 응답 메시지로 전달한다.
HTTP Streaming은 Long Polling 방식에 비해서 서버의 부담을 줄일 수 있지만, 여러 건의 변경 사항이 일어난 경우 동시 처리가 어려워진다.
왜냐하면, 서버가 현재 업데이트된 데이터를 모두 전달해야만, 클라이언트에서 다음 업데이트된 데이터의 시작 위치를 알 수 있기 때문이다.
뿐만 아니라, HTTP Streaming 방식은 서버가 클라이언트에게 전달하는 메시지에 대한 실시간성을 어느 정도 보장하지만, 클라이언트가 서버에게 보내는 요청은 여전히 새로운 커넥션을 생성해야 한다.
이러한 동시성과 서버 부담이라는 Trade Off 사항에서, HTTP Streaming 보다 Long Polling 방식을 많이 사용한다고 한다.
위와 같은 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 Framework는 WebSocket API를 제공한다.
여기서 주목할 점은 Spring에서 제공하는 WebSocket API는 Spring MVC 기술에 종속되지 않는다는 것이다.
WebSocket 서버는 WebSocketHandler
인터페이스의 구현체를 통해서, 각 경로에 대한 핸들러를 구현할 수 있다.
뿐만 아니라, Message 형식에 따라 TextWebSocketHandler
or BinaryWebSocketHandler
핸들러를 확장해 구현할 수도 있다.
아래는 템플릿 메서드 패턴이 적용된 AstractWebSocketHandler
추상 클래스이다.
메시지 형식에 따라, 적합한 handleXXX
메서드를 호출한다.
문자열 메시지 기반으로 테스트를 진행하기 때문에 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을 이용하여 클라이언트 애플리케이션 작성하기
WebSocketHandler
를 사용하는 경우, 표준 WebSocket session(JSR-356)은 동시 전송을 지원하지 않는다.
따라서 STOMP 메시징 프로토콜을 이용해서 메시지 전송을 동기화하거나,
WebSocketSession
을 ConcurrentWebSocketSessionDecorator
으로 Wrapping해야 한다.
ConcurrentWebSocketSessionDecorator
은 오직 하나의 스레드만 메시지를 전송하도록 보장해주기 때문이다.
각 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();
}
}
Spring은 WebSocketHandler
가 호출되기까지 아래 그림과 같이, 여러 Decorator
를 거친다.
Spring은 데코레이터 패턴
을 이용해서 WebSocketHandler
에 대한 추가적인 작업을 처리할 수 있도록 WebSocketHandlerDecorator
객체를 제공한다.
예를 들어 Message, Session 등의 정보에 대한 로깅 등의 작업을 추가할 수 있다.
아래 그림을 실제로 기본적으로 Spring에 등록된 WebSocketHandlerDecorator
구현체들이며, 연속적으로 전파되어 실행되는 것을 확인할 수 있다.
ExceptionWebSocketHandlerDecorator
로 WebSocketHandler
실행 과정에서 발생하는 예외를 모두 잡아서 처리한다.
LoggingWebSocketHandlerDecorator
는 Message, Session 정보를 logging 한다.
마지막으로, AbstractWebSocketHandler
는 Message 타입에 따라 handleXXX()
메서드를 호출한다.
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;
}
}
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");
}
...
}
자세한 내용은 다음 링크에서 참조하길 바란다.
지금까지는 클라이언트-서버 간에 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
의 목표는 "애플리케이션이 우선적으로 WebSocket API
를 사용하도록 하지만, 사용할 수 없는 경우에는 런타임 시점에 코드 변경없이 WebSocket
이외의 대안으로 대체"하도록 하는 것이다.
우선 SockJS
는 브라우저에서 사용하도록 설계가 되었기 때문에, 다양한 브라우저와 버전을 지원하고 있다.
자세한 브라우저 지원 범위는 아래 링크를 참고하길 바란다.
또한 SockJS
는 WebSocket
, HTTP Streaming
, HTTP Long Polling
등의 크게 세 가지 전송 방법(Transports
)을 지원하고 있는데, 이외에도 아래와 같이 다양한 방식을 제공하고 있다.
SockJS
가 지원하는 자세한 전송 방법(Transports
) 리스트는 아래 링크에서 참고 바란다.
SockJS
는 서버로 부터 기본 정보를 획득하기 위해서 GET /info
요청을 보내며 시작한다.
클라이언트가 서버에게 GET /info
요청을 보내므로써, 서버가 WebSocket
을 지원하는 지와 전송 과정에서 Cookies
지원이 필요한 지 여부, CORS
위한 Origin 정보 등의 정보를 응답으로 전달받는다.
이후, 서버가 응답한 메시지를 토대로 앞으로 통신에 사용할 프로토콜을 아래와 같은 방식으로 결정하고 요청을 보낸다.
WebSocket
사용 가능하다면, WebSocket
사용WebSocket
사용 불가능하다면,Options
의 Transports
항목에 HTTP streaming
설정이 존재한다면, HTTP streaming
사용Options
의 Transports
항목에 HTTP streaming
설정이 없고 HTTP Long Polling
존재한다면, HTTP Long Polling
사용const sock = new SockJS('http://localhost:8080/test', null, {transports: ["websocket", "xhr-streaming", "xhr-polling"]});
모든 Transports
요청의 URL 형식은 아래와 같다.
http://host:port/myApplication/myEndpoint/{server-id}/{session-id}/{transport}
각각의 의미를 하나씩 살펴보자.
server-id
는 클러스터 환경에서 요청을 라우팅하는데 유용하게 사용된다.session-id
는 SockJS
세션에 속하는 HTTP 요청을 연관시킨다.transport
는 전송 타입을 가리킨다. (ex, websocket
, xhr-streaming
, xhr-polling
)websocket
타입의 전송 방식은 WebSocket HandShake
를 하기 위해서 오직 하나의 HTTP 요청만 필요하고, 이후 모든 메시지는 해당 소켓에서 교환된다.
xhr-streaming
타입의 경우에는 장기 실행 요청을 유지하여 서버에서 클라이언트로 전달하기 위한 메시지를 응답으로 전달받는다.
이후, 클라이언트에서 서버로 새로운 요청을 보내야 할 경우에는 기존의 커넥션을 종료하고 새로운 HTTP POST
요청을 보내어 커넥션을 유지한다.
xhr-polling
타입 경우에는 서버에서 클라이언트로 응답 메시지가 전달이 될 때마다 기존의 커넥션을 종료하고 새로운 요청을 보내어 커넥션을 생성한다.
추가적으로, SockJS
는 Message Frame
크기를 최소화하기 위해 노력한다.
예를 들어, open frame
경우에는 첫 글자인 o
를 전송한다.
또한, Message Frame
의 경우에는 다음과 같은 형태로 전달받는다.
a["message1", "message2"]
커넥션 유지 여부를 확인하는 Heartbeat Frame
경우에는 h
로 보낸다.
마지막으로, 커넥션 종료를 의미하는 Close Frame
은 c["message"]
형식으로 보낸다.
SockJS
는 아래와 같이 설정할 수 있다.
스프링에서 제공하는 WebSocket API
와 SockJS
는 Spring 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>
여전히 많은 사용자들은 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();
}
}
SockJS
프로토콜은 서버가 주기적으로 Heartbeat Message
전송하여, 프록시가 커넥션이 끊겼다고 판단하지 않도록 한다.
스프링 SockJS Configuration
은 HeartbeatTime
을 사용자가 지정할 수 있도록 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();
}
}
뿐만 아니라, 스프링 SockJS
은 Heartbeat Tasks
를 스케줄링할 수 있도록 TaskScheduler
를 설정할 수도 있다. TaskScheduler
는 기본적으로 사용 가능한 프로세서 수만큼 생성되어 Thread Pool에 백업된다.
만약 STOMP
를 사용해 Heartbeat
를 주고 받을 경우에는 SockJS
Heartbeat
설정은 비활성화된다.
SockJS
Transports 타입인 HTTP Streaming
과 HTTP Long Polling
는 일반 요청보다 더 긴 커넥션을 요구한다.
이러한 요구 사항은 서블릿 컨테이너에서 Servlet 3 asynchronous
지원을 통해 수행된다.
구체적으로, Servlet 3 asynchronous
는 Servlet Container Thread
가 종료되고도 요청을 처리하며 다른 스레드가 지속적으로 응답에 Write 할 수 있도록 지원한다.
여기서 문제점은 Servlet API
가 갑자기 사라진 클라이언트에 대한 알림을 제공하지 않는다는 것이다.
그러나 다행히도, Servlet Container
는 응답에 Write를 시도하는 경우 예외를 발생시킨다.
뿐만 아니라, 스프링의 SockJS
는 서버 측에서 주기적으로 Heartbeat
를 전송하기 때문에 클라이언트의 연결 여부를 일정 시간 안에 파악할 수 있다.
만약 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-Origin
는 Origin
요청 헤더의 값으로 초기화된다.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
은 서버 측에서 "HTTP Streaming
에서 전송하는 메시지의 크기", "클라이언트가 연결이 끊긴 것으로 간주하는 시간" 등의 설정을 WebSocketConfigurer
의 SockJsServiceRegistration
통해서 할 수 있다.
@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)
스프링은 브라우저없이 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 외에 다른 차이점이 없기 때문에, XhrTransport
는 xhr-streaming
와 xhr-polling
모두를 지원한다.
즉, RestTemplateXhrTransport
는 xhr-streaming
와 xhr-polling
모두를 지원하는 XhrTransport
구현체이다.
RestTemplateXhrTransport
은 내부적으로 HTTP Request를 위해 스프링에서 제공하는 RestTemplate
를 사용한다.
또한 WebSocket
설정은 WebsocketTransport
객체를 통해 이루어지는데, JSR-356 runtime
에서 제공하는 StandardWebSocketClient
를 사용하도록 지정한다.
만약 SockJsClient
이용해서 여러 동시 사용자를 시뮬레이션하는 경우라면, HTTP Client(XHR transports
)가 충분히 많은 커넥션과 스레드를 허용하도록 구성할 수 있다.
아래는 Jetty 이용한 예제이다.
HttpClient httpClient = new HttpClient();
httpClient.setMaxConnectionsPerDestination(1000);
httpClient.setExecutor(new QueuedThreadPool(500));
WebSocket
프로토콜은 두 가지 유형의 메시지를 정의하고 있지만, 그 메시지의 내용까지는 정의하고 있지 않다.
STOMP
은 WebSocket
위에서 동작하는 프로토콜로써, 클라이언트와 서버가 전송할 메시지 유형, 형식, 내용들을 정의하는 매커니즘이다.
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
정보를 함께 포함한다.
위와 같은 과정을 통해서, 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
헤더와 일치해야만 한다.
Spring Framework
및 Spring Security
는 STOMP
프로토콜을 사용하여, WebSockets
만 이용할 때 보다 더 풍부한 프로그래밍 모델을 제공할 수 있는데 하나씩 살펴보자.
메시징 프로토콜을 만들고, 메시지 형식을 커스터마이징할 필요가 없다.
RabbitMQ, ActiveMQ 같은 Message Broker
을 이용해서, subscription
을 관리하고 메시지를 브로드캐스팅할 수 있다.
WebSocket
기반으로 각 커넥션마다 WebSocketHandler
를 구현하는 것보다, @Controller
된 객체를 이용해서 조직적으로 관리할 수 있다.
즉 메시지들은 STOMP
의 Destination
헤더를 기반으로, @Controller
객체의 @MethodMapping
메서드로 라우팅된다.
STOMP
의 Destination
및 Message Type
을 기반으로 메시지를 보호하기 위해, Spring Security를 사용할 수 있다.
스프링은 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");
}
}
/test
는 WebSocket 또는 SockJS 클라이언트가 WebSocket Handshake로 커넥션을 생성할 경로이다.
/simple
경로로 시작하는 STOMP
메시지의 Destination
헤더는 @Controller
객체의 @MessageMapping
메서드로 라우팅된다.
내장된 메시지 브로커를 사용하여 클라이언트에게 subscriptions
, broadcasting
기능을 지원한다.
또한, /topic
또는 /queue
로 시작하는 Destination
헤더를 가진 메시지를 브로커로 라우팅한다.
내장된 Simple Message Broker
는 /topic
, /queue
prefix에 대해 특별한 의미를 갖지 않는다.
/topic
, /queue
prefix는 단순히 메시지가 pub-sub
, point-to-point
인지 여부를 나타내는 컨벤션일 뿐이며, 외부 브로커를 사용할 경우에는 해당 Destination
헤더 prefix가 달라질 수 있다.
브라우저인 클라이언트 측면에서는 SockJS
프로토콜을 사용하기 위해서 sockjs-client
라이브러리를 사용한다.
또한, 최근에 STOMP
프로토콜의 클라이언트 라이브러리는 webstomp-client
을 많이 사용한다.
아래는 클라이언트가 SockJS
를 기반으로 한 STOMP
프로토콜을 이용해서 서버와 통신하는 예제이다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
.message {
margin: 5em;
color: olive;
border: solid 2px #D2691E;
-webkit-transition: 0.5s; transition: 0.5s;
width: 50%;
padding: 10px 20px;
box-sizing: border-box;
}
.messages {
margin: 5em;
}
.send-btn {
background-color: #F4FEE2;
padding: 10px 20px;
}
li {
list-style-type: none;
margin: 1em;
text-align: center;
font-size: 2em;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script type="text/javascript" src="node_modules/webstomp-client/dist/webstomp.min.js"></script>
</head>
<body>
<div>
<input type="text" class="message"/>
<button onClick='send()' class="send-btn">보내기</button>
</div>
<div class="messages">
<div id="messages"></div>
</div>
</body>
<script>
let stomp;
document.addEventListener("DOMContentLoaded", function() {
// ["websocket", "xhr-streaming", "xhr-polling"]
const sock = new SockJS('http://localhost:8080/test', null, {transports: ["xhr-polling"]});
stomp = webstomp.over(sock);
stomp.connect({}, function(frame) {
console.log('Connected!!');
stomp.subscribe("/topic/good", function(frame) {
const messages = document.querySelector("#messages");
const message = document.createElement("li");
message.innerText = frame.body;
messages.appendChild(message)
});
});
});
function send() {
const message = document.querySelector(".message");
stomp.send('/simple/good', message.value);
message.value = '';
}
</script>
</html>
위 예제에서는 SockJS
프로토콜을 사용하고 있지만, WebSocket
만 사용하고 싶은 경우에는 브라우저에서 지원하는 WebSocket
객체를 이용할 수 있다.
const socket = new WebSocket("http://localhost:8080/test");
const stomp = Stomp.over(socket);
stomp.connect({}, function(frame) {
...
}
현재 위 예제에서는 login
과 passcode
헤더가 필요하지 않았다. 심지어 만약 클라이어트에서 설정했더라도 서버측에서 무시했을 것이다.
자세한 코드를 보고 싶다면, 스프링에서 제공하는 샘플 코드를 살펴보자.
일단 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
를 구성한다.
아래의 그림은 내장 메시지 브로커를 사용한 경우의 컴포넌트 구성을 보여준다.
clientInboundChannel
은 WebSocket
클라이언트로 부터 받은 메시지를 전달한다.clientOutboundChannel
은 WebSocket
클라이언트에게 메시지를 전달한다.brokerChannel
은 서버의 애플리케이션 코드 내에서 브로커에게 메시지를 전달한다.다음 그림은 외부 브로커를 사용해서 subscriptions
과 broadcasting
메시지를 관리하도록 설정한 구성 요소를 보여준다.
위 두 구성 방식의 주요한 차이점은 Broker Relay
를 사용 여부이다.
Broker Relay
의 역할은 다음과 같다.
STOMP Broker
에게 메시지를 전달이제 위 그림에 대한 전체적인 흐름을 살펴보면 다음과 같다.
Message
Representation으로 변환한다.clientInboundChannel
로 전송한다./app
으로 시작한다면, @MessageMapping
정보와 매핑된 메서드를 호출한다. /topic
또는 /queue
로 시작한다면, 메시지 브로커로 바로(직접) 라우팅된다.@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>
클라이언트는 http://localhost:8080/test
]에 연결하여 커넥션을 수립하고, STOMP 프레임들을 해당 커넥션으로 전송하기 시작한다.
클라이언트는 /topic/good
경로의 Destination
헤더를 가지고 SUBSCRIBE
프레임을 전송한다.
서버는 프레임을 수신하면 디코딩하여 Message
로 변환하고, 메시지를 clientInboundChannel
로 전송한다.
그리고나서, 해당 clientInboundChannel
채널에서 메시지를 메시지 브로커
로 바로 라우팅해주고, 메시지 브로커
는 해당 클라이언트의 구독(Subscription
) 정보를 저장한다.
이후, 클라이언트는 /test/good
경로의 Destination
헤더를 가지고 메시지를 전송한다.
/test
prefix는 해당 메시지가 @MessageMapping
메서드를 가진 컨트롤러로 라우팅될 수 있도록 도움을 준다.
구체적으로, /test
접두사가 벗겨진 후에는 /good
목적지 경로만 남게 되고 TestConroller
의 @MessageMapping
가진 handle()
메서드로 라우팅된다.
@MessageMapping
가진 handle()
메서드가 반환한 값은 스프링의 Message
로 변환된다.
Message
의 Payload
는 handle()
메서드가 반환한 값을 기반으로 하고, 기본적으로 Destination
헤더는 /topic/good
로 설정된다.
Destination
헤더는 클라이언트가 보낸 기존 /test/good
경로의 목적지 헤더에서 /test
를 /topic
으로 변경된 값으로 설정된다.
이후, 변환된 Message
는 brokerChannel
로 전송되고 메시지 브로커
에 의해서 처리된다.
마지막으로 메시지 브로커
는 매칭된 모든 구독자들(subscribers
)을 탐색하고, clientOutboundChannel
을 통해서 각 구독자들에게 MESSAGE
프레임을 보낸다.
구체적으로, clientOutboundChannel
채널에서는 스프링의 Message
를 STOMP의 Frame
으로 인코딩하고, 연결된 WebSocket
커넥션으로 프레임을 전송한다.
애플리케이션은 클라이언트로 부터 받은 메시지를 처리하기 위해 @Controller
클래스를 사용할 수 있다.
이러한, 컨트롤러는 @MessageMapping
, @SubscribeMapping
, @ExceptionHandler
메서드를 선언할 수 있는데, 구체적으로 어떤 역할을 하는지 하나씩 살펴보자.
@MessageMapping
@MessageMapping
메서드는 지정한 경로를 기반으로 메시지를 라우팅할 수 있다.
@MessageMapping
은 메서드뿐 만 아니라 타입 레벨, 즉 클래스에도 설정할 수 있는데 이는 컨트롤러 안에서 공통된 경로를 제공하기 위해서 사용된다.
기본적으로, 매핑은 Ant-Style
Path 패턴으로 구성하고, Template 변수도 지원한다.(ex, /something*
, /something/{id}
)
Template 변수는 @DestinationVariable
로 선언한 메서드 인자를 통해서 전달받을 수 있다.
또한, 애플리케이션은 dot-separated
기반의 Destination
컨벤션으로 바꿀 수 도 있는데, 이는 아래서 다시 언급하겠다.
그럼, @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;
}
}
기본적으로 @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)
컨트롤러에 선언하면 된다.
만약 애플리케이션에서 연결된 클라이언트에게 메시지를 보내야 할 경우에는 어떻게 해야 할까?
애플리케이션 구성 요소는 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 Message Broker
는 클라이언트에게 받은 구독 요청을 메모리에 저장하고, Destination
헤더와 일치하는 클라이언트 커넥션에 메시지를 브로드캐스팅한다.
만약 TaskScheduler
와 함께 설정한다면, Simple Message Broker
는 STOMP 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);
}
}
Simple Message Broker
는 처음 시작하기에 좋지만, 일부 STOMP COMMAND
만 지원한다는 단점이 있다.
구체적으로, Simple Message Broker
는 acks
, 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
는 아래 순서로 동작한다.
매 CONNECT
메시지마다 브로커와 TCP 연결을 수립하고,
(각 클라이언트마다 독립된 TCP 커넥션을 사용하는데, 이는 session-id 메시지 헤더로 식별한다.)
모든 메시지를 브로커에 전달한 다음,
브로커로부터 수신한 모든 메시지는 각각의 session-id를 메시지 헤더에 더하고, WebSocket 세션을 통해 클라이언트에게 전달된다.
위 흐름을 통해서 알 수 있듯이, StompBrokerRelayMessageHandler
는 메시지를 양방향으로 전달하는 릴레이
역할을 한다.
또한, StompBrokerRelayMessageHandler
는 자동으로(기본적으로) 메시지 브로커
와 단 하나의 System TCP Connection
을 수립한다.
System TCP Connection
은 서버 애플리케이션이 메시지 브로커
에게 메시지를 전달하기 위한 용도이다.
구체적으로, 메시지는 어떠한 클라이언트와도 관련이 없기 때문에 session-id
헤더도 가지고 있지 않다.
System TCP Connection
은 효율적으로 공유가 가능하지만, 메시지 브로커
로부터 메시지를 받는 용도로 사용하지 않는다.
이러한, StompBrokerRelayMessageHandler
은 System TCP Connection
의 몇 가지 설정할 수 있도록 아래와 같은 메서드를 제공한다.
setSystemLogin(String)
setSystemPasscode(String)
setSystemHeartbeatSendInterval(long)
setSystemHeartbeatReceiveInterval(long)
스프링 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-netty
과io.netty:netty-all
의존성을 추가해야 한다.
위에서 말했던, 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
에 항상 login
및 passcode
헤더를 설정하기 때문에, WebSocket 클라이언트
는 해당 헤더를 설정할 필요가 없다.(설정했더라도 무시된다.)
대신, WebSocket 클라이언트
는 HTTP 인증
을 사용해 WebSocket Endpoint
를 보호하고 클라이언트 식별자
를 설정해야 한다.
STOMP broker relay
와 메시지 브로커
는 System TCP Connection
을 통해서 Heartbeat
를 주고 받는다.
스프링은 StompBrokerRelayMessageHandler
에 대한 설정을 할 수 있도록 메서드를 제공하는데, 메시지 브로커
와 주고 받는 Heartbeat
시간 간격도 설정할 수 있다.(default, 10초)
만약 브로커와 연결이 끊어진다면, STOMP broker relay
는 성공할 때까지 5초마다 재연결을 시도한다.
ApplicationListener<BrokerAvailabilityEvent>
을 구현한 스프링 Bean
은 System 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 relay
에 VirtualHost
속성을 설정한다면, 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");
}
}
메시지는 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
세션은 서버에 메시지를 보내기 위해 반드시 이미 인증된 사용자를 포함하고 있어야만 한다.
Spring Security OAuth는 JWT 같은 Token 기반의 보안을 제공한다.
따라서, WebSocket 기반 STOMP 프로토콜을 비롯한 웹 애플리케이션에서 Token 기반 보안을 사용할 수 있다.
Token 기반 보안을 사용하는 이유는 항상 쿠키 기반의 세션이 모든 상황에서 적합할 수 없기 때문이다.
예를 들어, 서버 애플리케이션에서 세션을 지원하지 않을 수도 있고 모바일 애플리케이션에서는 일반적으로 인증 헤더를 선호한다.
The WebSocket protocol, RFC 6455은 WebSocket Handshake
과정에서 서버가 클라이언트를 인증하는 방법을 규정하고 있지 않다.
또한 브라우저 클라이언트
는 오직 표준 인증 헤더
또는 쿠키
만 사용할 수 있으며, 커스텀한 사용자 지정 헤더
를 사용할 수 없다.
뿐만 아니라, SockJS JavaScript client
는 SockJS transport
요청과 함께 HTTP 헤더를 전달할 방법을 제공하고 있지 않다.
대신에, 클라이언트는 Token을 Query Parameter
로 전송할 수 있으나 이 또한 몇 가지 단점을 가지고 있다.
예를 들어, 토큰이 서버 로그에 URL과 함께 실수로 기록 될 수 있다.
위에서 말했다시피, 서버 애플리케이션은 HTTP 수준에서 쿠키 사용없이 인증할 마땅한 대안이 없다.
따라서 쿠키 사용 대신에, STOMP Messaging 프로토콜 수준의 헤더를 이용해서 인증하는 것을 생각해 볼 수 있다. 그렇게 하려면, 아래와 같은 두 단계가 필요하다.
CONNECT
프레임에 pass 인증 헤더를 추가해야 한다.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 인증을 사용하는 경우에는 반드시 ChannelInterceptor
을 Spring 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();
}
}
스프링의 STOMP
모듈은 /user
접두사를 가진 destination
헤더를 인지해서, 애플리케이션이 특정한 사용자에게 메시지를 보낼 수 있도록 지원한다.
예를 들어, 사용자가 /user/queue/something
경로의 Destination
을 구독한다고 가정해보자.
이 Destination
은 UserDestinationMessageHandler
에 의해서 처리되는데, 각 사용자 세션마다 고유한 Destination
으로 변환된다.(/queue/something-user234
)
이를 통해, 동일한 Destination
에 동시로 구독하는 다른 사용자와 충돌이 발생하지 않도록 보장한다.
아래 링크를 보면, UserDestinationMessageHandler
가 UserDestinationResolver
로 경로를 변형하는 과정을 볼 수 있다.
UserDestinationResolver (Spring Framework 5.2.8.RELEASE API)
DefaultUserDestinationResolver (Spring Framework 5.2.8.RELEASE API)
송신측은 /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
으로 변경된 것을 알 수 있다.
<!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>
<!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
세션의 경우에는 @SendToUser
의 broadcast
속성이 false
로 설정된다.
Messaging Template
또한, 애플리케이션 컴포넌트에서는 Messaging Template
을 사용해서 /user
Destination에 메시지를 보낼 수 있다.
Messaging Template
은 SimpMessagingTemplate
객체이다.
@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을 설정할 수 있다.
아래와 같이, MessageBrokerRegistry
에 userDestinationBroadcast
속성을 설정해주면 된다.
@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");
}
}
브로커로 부터 받은 메시지는 clientOutboundChannel
에 publish
된다.
채널은 ThreadPoolExecutor
에 보관되며 메시지는 서로 다른 Thread에서 처리되기 때문에, 클라이언가 수신한 메시지 순서는 clientOutboundChannel
에 publication
(게시)된 순서와 정확하게 일치한다고 보장할 수 없다.
만약 해당 이슈가 있다면, 아래와 같이 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
된 순서와 클라이언트가 수신한 메시지 순서를 정확하게 일치시키도록 보장할 수 있다.
다만, 약간의 성능 오버헤드가 발생할 수 있기 때문에 오직 필요한 경우에만 사용해야 한다.
ApplicationContext
가 발생시키는 여러 Events
는 스프링의 ApplicationListener
를 통해서 전달받을 수 있다.
BrokerAvailabilityEvent
BrokerAvailabilityEvent
는 브로커가 이용 가능하거나 불가능할 때 발생하는 이벤트이다.
Simple Broker
는 애플리케이션 시작과 동시에 이용 가능하고 지속적으로 유지되지만, STOMP Broker Relay
는 외부 브로커와 연결이 끊길 수 있다.
따라서, STOMP Broker Relay
는 System TCP Connection
을 다시 연결해야 한다.
결과적으로, 이 이벤트는 브로커와의 연결 상태가 변할 때마다 발생한다.
SimpMessagingTemplate
을 사용하는 컴포넌트는 해당 이벤트를 구독하여 브로커를 사용할 수 없는 경우에는 메시지를 보내지 않도록 해야만 한다.
따라서, SimpMessagingTemplate
사용하는 경우에는 항상 MessageDeliveryException
대한 예외 처리를 준비해야 한다.
SessionConnectEvent
SessionConnectEvent
는 STOMP 프로토콜이 새로운 (클라이언트 세션의 시작을 알리는) CONNECT
메시를 받은 경우에 발생한다.
이 이벤트는 session ID
, 사용자 정보
, 사용자가 보낸 커스텀 헤더
등을 가지고 있는 CONNECT
메시지를 포함한다.(클라이언트 세션 추적에 유용)
따라서, 해당 이벤트를 구독한 컴포넌트는 메시지를 SimpMessageHeaderAccessor
또는 StompMessageHeaderAccessor
으로 Wrapping 할 수 있다.
SessionConnectedEvent
SessionConnectedEvent
는 브로커가 CONNECT
메시지에 대한 응답으로 STOMP
의 CONNECTED
프레임을 보낸 경우, 즉 SessionConnectEvent
이후에 발생한다.
해당 이벤트가 발생한 시점에는 STOMP
세션이 완전하게 수립된 것으로 간주할 수있다.
SessionSubscribeEvent
SessionSubscribeEvent
는 새로운 STOMP SUBSCRIBE
메시지를 받은 경우에 발생한다.
SessionUnsubscribeEvent
SessionUnsubscribeEvent
은 새로운 STOMP UNSUBSCRIBE
메시지를 받은 경우에 발생한다.
SessionDisconnectEvent
SessionDisconnectEvent
는 STOMP 세션이 끝난 경우에 발생한다.
구체적으로, 클라이언트로 부터 DISCONNECT
메시지를 받거나 WebSokcet
세션이 닫히는 경우에 자동으로 이벤트가 발생한다.
경우에 따라서는 해당 이벤트가 두 번 이상 발생하기 때문에, 컴포넌트는 다양한 disconnect 이벤트와 멱등성을 가져야 한다.
Events
는 STOMP
연결의 라이프사이클(과정)에 대한 알림을 제공하지만, 클라이언트가 보낸 모든 메시지에 대해서 이벤트를 제공하지는 않는다.
따라서, 모든 메시지에 대한 처리를 위해 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
을 제공한다.
즉, MessageHandler
의 handleMessage()
메서드가 호출되기 이전과 이후에 ExecutorChannelInterceptor
의 beforeHandle()
와 afterMessageHandled()
메서드가 호출되는데, 실제로 각 메서드는 MessageHandler
가 동작중인 Thread에서 실행된다.
각 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
메서드를 가질 수 있다.
좋은 글 정말 잘 보고 갑니다! 감사합니다.