WebSocket 통신에 대해 알아보기
2023-09-08 00:48
소켓 통신인 WebSocket 프로토콜을 스프링 프레임워크에서 어떻게 지원하는지 함께 자세히 알아봅시다. WebSocket 통신 구현을 위한 간단한 예제도 함께 제공합니다.
개요
WebSocket은 HTTP와 같은 프로토콜이며, 하나의 TCP 연결에서 양방향 통신을 제공하기 위해 만들어졌다.
- HTTP 는 단방향, 스테이스리스(stateless) 프로토콜이다
- 소켓은 양방향, 스테이드풀(stateful) 프로토콜이다.
WebSocket은 HTTP 프로토콜과 호환되며, TCP 핸드셰이크를 통해 HTTP Upgrade Header를 사용하여 WebSocket 프로토콜로 변경된다. 즉 최초 접속은 HTTP 프로토콜을 통해 이루어진다.
- WebSocket Upgrade HTTP Request
GET /spring-websocket-portfolio/portfolio HTTP/1.1
Host: localhost:8080
Upgrade: websocket // 업그레이드 헤더 To websocket
Connection: Upgrade // 업그레이드 Connection 사용
Sec-WebSocket-Key: Uc9l9TMkWGbHFD2qnFHltg==
Sec-WebSocket-Protocol: v10.stomp, v11.stomp
Sec-WebSocket-Version: 13
Origin: http://localhost:8080
- WebSocket Upgrade HTTP Response
HTTP/1.1 101 Switching Protocols // 프로토콜 변경
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 1qVdfYHU9hPOl4JYYNXF623Gzn0=
Sec-WebSocket-Protocol: v10.stomp
변경 후 통신시 URL은 ws://www.websocket.com
같은 형식을 사용한다.
많은 경우 서버와의 통신은 Ajax, HTTP Streaming, Polling 등의 기술을 통해 간단하게 구현할 수 있으나, 장시간 접속이라는 전제 하에 실시간에 가까운 쌍방향 통신이 요구될 때 WebSocket을 고려한다.
Spring Framework에서 WebSocket 프로토콜을 어떻게 지원하는지 살펴보도록 한다.
WebSocket APIs
Spring은 WebSocket 메시지를 처리하는 클라이언트측 및 서버측 응용프로그램을 개발하는 데 사용될 다양한 API를 제공한다.
WebSocketHandler
@EnableWebSocket 어노테이션을 통해 자동 설정을 올리고, WebSocketConfigurer를 구현하여 추가 설정을 적용한다.
- @EnableWebSocket
package org.springframework.web.socket.config.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Import;
/**
* Add this annotation to an {@code @Configuration} class to configure
* processing WebSocket requests. A typical configuration would look like this:
*
* @Configuration
* @EnableWebSocket
* public class MyWebSocketConfig {
*
* }
*
* Customize the imported configuration by implementing the WebSocketConfigurer interface:
*
* @Configuration
* @EnableWebSocket
* public class MyConfiguration implements WebSocketConfigurer {
*
* @Override
* public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
* registry.addHandler(echoWebSocketHandler(), "/echo").withSockJS();
* }
*
* @Override
* public WebSocketHandler echoWebSocketHandler() {
* return new EchoWebSocketHandler();
* }
* }
*
* @author Rossen Stoyanchev
* @since 4.0
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(DelegatingWebSocketConfiguration.class)
public @interface EnableWebSocket {
}
WebSocket 메시지 처리 핸들러로 org.springframework.web.socket.WebSocketHandler를 구현하거나, org.springframework.web.socket.handler.TextWebSocketHandler, org.springframework.web.socket.handler.BinaryWebSocketHandler를 상속하여 생성한다.
- Handler Example
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.TextMessage;
public class MyHandler extends TextWebSocketHandler {
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) {
// To Do Impl
}
}
핸들러를 특정 URL에 매핑하기 위한 Java configuration과 XML namespace를 지원한다.
- Configuration Example (Java Configuration)
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myHandler(), "/myHandler");
}
@Bean
public WebSocketHandler myHandler() {
return new MyHandler();
}
}
WebSocket HandShake
최초 WebSocket Upgrade용도의 HTTP HandShake 요청에서 HandShakeInterceptor를 지정하여 HandShake 이전과 이후의 동작을 정의할 수 있다.
WebSocket 연결은 HTTP Session과 별개의 Session 객체를 가지고 있으며, 소켓 접속시 생성된다.
인터셉터를 통해 HTTP 세션 데이터를 WebSocket Session에 전달할 수도 있고(HttpSessionHandshakeInterceptor), HandShakeInterceptor를 구현하거나 DefaultHandshakeHandler를 상속하여 브라우저 쿠키를 전달하는 등의 동작도 정의할 수 있다.
- HandShakeInterceptor Example
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myHandler(), "/myHandler") // Handler
.addInterceptors(myHandshakeInterceptor()); // 핸드셰이크 요청을 인터셉트할 인터셉터
}
@Bean
public WebSocketHandler myHandler() {
return new MyHandler();
}
@Bean
public MyHandshakeInterceptor myHandshakeInterceptor() {
return new MyHandshakeInterceptor();
}
}
public class MyHandshakeInterceptor implements HandshakeInterceptor {
// Before
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response
, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletServerRequest = (ServletServerHttpRequest) request;
HttpServletRequest servletRequest = servletServerRequest.getServletRequest();
// 쿠키 정보 전달
Cookie token = WebUtils.getCookie(servletRequest, "MYCOOKIE");
attributes.put("MYCOOKIE", token.getValue());
}
return true;
}
// After
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response
, WebSocketHandler wsHandler, Exception exception) {
// Todo Impl
}
}
Server Configuration
각 WebSocket 엔진은 런타임 Property를 제어하는 Configuration을 제공해야 한다. (메시지 버퍼 크기, Idle Timeout 시간 등)
Tomcat, WildFly, GlassFish의 경우 ServletServerContainerFactoryBean을 추가하여 설정을 제어할 수 있다.
ServletServerContainerFactoryBean이 제공하는 프로퍼티 Setter는 다음과 같다.
setAsyncSendTimeout(Long timeoutInMillis)
The new default timeout in milliseconds. A non-positive value means an infinite timeout.setMaxSessionIdleTimeout(Long timeoutInMillis)
The new default session idle timeout in milliseconds. Zero or negative values indicate an infinite timeout.setMaxTextMessageBufferSize(Integer bufferSize)
The new default maximum buffer size in characterssetMaxBinaryMessageBufferSize(Integer bufferSize)
The new default maximum buffer size in bytes
- ServletServerContainerFactoryBean Example
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myHandler(), "/myHandler");
}
@Bean
public WebSocketHandler myHandler() {
return new MyHandler();
}
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer(){
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
// Runtime Property : 메시지 버퍼 사이즈
container.setMaxTextMessageBufferSize(8192);
container.setMaxBinaryMessageBufferSize(8192);
return container;
}
}
Allowed Origins
Origin은 Protocol, Host, Port 3개 부분으로 구성된다. 3개 부분이 모두 동일한 경우만 origin이다.
Spring 4.1.5부터 WebSocket 및 SockJS의 기본 동작은 동일한 오리진 요청만 수락한다. 다음 세가지 옵션이 가능하다.
- 동일 오리진 요청만 허용 (Default) : 이 모드에서 Iframe HTTP Header X-Frame-Options가 SAMEORIGIN으로 설정되며, JSONP 전송은 오리진 확인이 불가능하므로 비활성화된다. 이 모드일 경우 IE6, IE7은 지원되지 않는다.
- 지정된 오리진 목록 허용 : 이 모드에서 지정된 오리진은 반드시 http:// 또는 https:// 프로토콜로 시작해야 한다. 이 모드에서 Iframe 전송이 비활성화되므로, IE6, IE7은 지원되지 않는다.
- 모든 오리진 허용 : 오리진 옵션을 '*'로 설정한다. 이 모드에서는 모든 전송이 허용된다.
- setAllowdOrigins Example
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myHandler(), "/myHandler")
// Origins
.setAllowedOrigins("http://mychat.com");
}
@Bean
public WebSocketHandler myHandler() {
return new MyHandler();
}
}
WebSocket Emulation
WebSocket 기반의 애플리케이션을 만들었을 경우, 다음과 같은 문제점들이 존재할 수 있다.
- 클라이언트의 브라우저가 WebSocket을 지원하지 않음
- 클라이언트와 서버 사이의 Proxy가 WebSocket Upgrade 헤더를 해석 못해 서버에 전달하지 못함
- 클라이언트와 서버 사이의 Proxy가 Idle 상태에서 Connection을 도중 종료함
위와 같은 상황에서의 해결책은 WebSocket Emulation이다. 우선 WebSocket Connection을 시도하고, 실패할 경우 : HTTP 기반하에서 동작하는 다른 기술로 전환하여 연결을 시도한다.
Node.js를 사용시 Socket.io를 사용하는 것이 일반적이고, Spring Framework를 사용시 SockJS를 사용하는 것이 일반적이다. Spring Framework는 Servlet Stack에서 Server/Client 용도의 SockJS 프로토콜을 모두 지원한다.
SockJS Fallback
SockJS 구성
- SockJS Protocol : https://github.com/sockjs/sockjs-protocol
- SockJS Javascript Client : 브라우저에서 사용되는 자바스크립트 Lib https://github.com/sockjs/sockjs-client
- SockJS Server : Spring WebSocket 모듈을 통해 제공
- SockJS Java Client : Spring WebSocket 모듈을 통해 제공 (Spring 4.1부터)
WebSocket Emulation Process - SockJS
- SockJS Client는 우선 서버의 정보를 얻기 위해
GET /info
를 호출한다. 응답을 통해 다음 정보를 얻는다.- 서버의 WebSocket 지원 여부
- 전송 과정의 Cookies 지원 필요 여부
- CORS를 위한 Origin 정보
- 그 후 SockJS는 어떤 전송 타입을 사용할 지 결정한다. 전송 타입은 다음 3가지로 구분되며, 가능한 경우 순서대로 시도한다.
- WebSocket
- HTTP Streaming
- HTTP Long Polling
- 전송 시 모든 전송 요청은 다음과 같은 구조를 갖게 된다 :
https://host:port/myApp/myEndpoint/{server-id}/{session-id}/{transport}
- server-id : 클러스터에서 요청을 라우팅하는데 사용
- session-id : SockJS session에 소속하는 HTTP 요청과 연관
- transport : 전송 타입 (ex : WebSocket, xhr-streaming, xhr-polling)
- WebSocket 전송은 WebSocket Handshaking을 위해 HTTP 요청을 필요로하고, 메시지 전송은 연결된 Socket을 통해 이루어진다.
Enabling SockJS
Java Configuration을 통해 Enable할 수 있다.
- Enable SockJS Example
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myHandler(), "/myHandler")
// Origins
.setAllowedOrigins("http://mychat.com")
// Enableing SockJS
.withSockJS();
}
@Bean
public WebSocketHandler myHandler() {
return new MyHandler();
}
}
Internet Explore 8,9
인터넷 익스플로러 8, 9는 계속 사용되어지고 있고, SockJS가 존재하는 주요 이유중 하나다.
SockJS는 Ajax/XHR Streaming을 Microsoft의 'XDomainRequest' (xdr)를 통해 지원하고 있다. 이는 서로 다른 도메인 간에도 동작하지만, Cookie 전송을 지원하지 않는다. Cookie는 Java 어플리케이션에서 필수적이지만 SockJS 클라이언트는 Java만을 위한 것이 아닌 많은 서버 타입을 위해 사용되도록 고안되었기 때문에 Cookie를 중요하게 다룰지 여부를 알려주어야 한다. SockJS 클라이언트는 Ajax/XHR Streaming을 선택하거나, iframe기반의 기술을 사용한다.
IE10부터 XMLHttpRequest(xhr) 사용을 권장하여 XDomainRequest를 제거했다. XDR, XHR 모두 CORS를 지원하기 위한 도구이다.
XDomainRequest는 비록 CORS 도구로서 잘 동작하지만, Cookies 전송을 지원하지 않는다.
Cookies는 종종 Java 어플리케이션에서 필수적이나, SockJS 클라이언트는 여러 유형의 서버와 함께 사용될 수 있기 때문에 그다지 문제되지 않는다.
따라서 서버 측 Cookies 필요 여부에 따라 HTTP Streaming, HTTP Long Polling에서 사용하는 기술이 달라진다.
쿠키가 필요없으면 XDomainRequest(xdr)을 사용하고, 쿠키가 필요하면 iframe 기반의 기술을 사용한다.
GET /info
를 통해 얻는 정보 중 전송 과정의 Cookies 지원 필요 여부가 있는데, Java Configuration을 통해 설정할 수 있다 : setSessionCookieNeeded(true);
자바 어플리케이션에서는 ‘JSESSIONID’ 쿠키를 많이 사용하기 때문에 기본값은 ‘true’이다.
Heartbeats
SockJS 프로토콜은 Proxy가 연결이 끊겼다는 결론을 내리는 것을 방지하기 위해 서버로부터 Heartbeat 메시지를 보내도록 요구한다. Spring SockJS 구성에는 HeartbeatTime 빈도를 설정할 수 있는 속성이 있다. 기본값은 25초이며, IETF 권고안을 따른 것이다: https://datatracker.ietf.org/doc/html/rfc6202
Client Disconnects
HTTP Streaming, HTTP Long Polling의 SockJS 전송은 커넥션을 평균보다 더 오래 Open되도록 한다: https://spring.io/blog/2012/05/08/spring-mvc-3-2-preview-techniques-for-real-time-updates/ Servlet Container에서 이것은 Servlet 3 async 지원을 통해 수행되는데, 이것은 요청을 처리중인 Servlet 스레드를 빠져나오고 다른 Servlet 스레드에서 응답을 기록하도록 한다.
Servlet API가 가지는 문제는 Client Disconnect에 대한 알림을 제공하지 않는다는 것에 있다. Spring SockJS는 Default 25초 간격의 Heartbeat를 전송하므로, 이 주기 안에 Client Disconnect가 발견되게 된다.
Servlet Container는 응답을 Write하기 위한 시도에 예외를 발생시키며, 결론적으로 Client Disconnect에 의해 Network IO failure가 빈번하게 발생될 여지가 있다. 이것은 로그를 stack trace로 채우게 될 수 있다.
Spring은 이러한 client disconnect를 식별하기 위해 최선의 노력을 하고 이에 따른 최소한의 메시지를 기록하려고 노력중에 있다.
해당 로그는 AbstractSockJsSession에 정의된 DISCONNECTED_CLIENT_LOG_CATEGORY 로그 카테고리를 사용하며, 실제 stack trace를 보고싶으면 해당 로그 카테고리를 TRACE로 설정하면 된다.
SockJS and CORS
SockJS protocol 은 XHR streaming 과 polling 전송에 cross-domain 지원을 위한 CORS 헤더를 사용한다. CORS 헤더가 응답에서 발견되지 않는다면 CORS 헤더들이 자동으로 추가된다. 만약 Servlet Filter 등을 통해서 CORS 헤더가 이미 설정된다면 Spring SockJsService 는 이를 스킵한다. SockJS는 다음 헤더와 값을 예상한다.
Access-Control-Allow-Origin
: Initialized from the value of theOrigin
request header.Access-Control-Allow-Credentials
: Always set totrue
.Access-Control-Request-Headers
: Initialized from values from the equivalent request header.Access-Control-Allow-Methods
: The HTTP methods a transport supports (seeTransportType
enum).Access-Control-Max-Age
: Set to 31536000 (1 year).
STOMP
STOMP Overview
WebSocket 프로토콜은 두 가지 유형의 메시지(Text, Binary)를 정의하지만, 그 내용은 정의하지 않는다.
STOMP(Simple Text Oriented Messaging Protocol)은 메시지 전송을 효율적으로 하기 위해 탄생하였고, 기본적으로 pub / sub 구조로 되어있어 메세지를 전송하고 메세지를 받아 처리하는 부분이 확실히 정해져 있기 때문에 개발자 입장에서 명확하게 인지하고 개발할 수 있는 이점이 있다.
STOMP는 텍스트 지향 프로토콜이지만, 메시지 페이로드는 텍스트 또는 바이너리가 될 수 있다.
STOMP 프로토콜은 WebSocket 위에서 동작하는 프로토콜로써 클라이언트와 서버가 전송할 메세지의 유형, 형식, 내용들을 정의한다.
위에서 언급한 pub / sub이란 메세지를 공급하는 주체와 소비하는 주체를 분리해 제공하는 메세징 방법이다.
기본적인 컨셉을 예로 들자면 우체통(Topic)이 있다면 집배원(Publisher)이 신문을 우체통에 배달하는 행위가 있고, 우체통에 신문이 배달되는 것을 기다렸다가 빼서 보는 구독자(Subscriber)의 행위가 있다. 이때 구독자는 다수가 될 수 있다.
클라이언트는 메세지를 전송하기 위해 SEND, SUBSCRIBE COMMAND를 사용할 수 있다. 또한, SEND, SUBSCRIBE COMMAND 요청 Frame에는 메세지가 무엇이고, 누가 받아서 처리할지에 대한 Header 정보가 포함되어 있다.
이런 명령어들은 "destination" 헤더를 요구하는데 이것이 어디에 전송할지, 혹은 어디에서 메세지를 구독할 것인지를 나타낸다.
위와 같은 과정을 통해 STOMP는 Publish-Subscribe 매커니즘을 제공한다. 즉 Broker를 통해 타 사용자들에게 메세지를 보내거나 서버가 특정 작업을 수행하도록 메세지를 보낼 수 있게 된다.
만약 Spring에서 지원하는 STOMP를 사용하면 Spring WebSocket 어플리케이션은 STOMP Broker로 동작하게 된다.
Spring에서 지원하는 STOMP는 많은 기능을 하는데 예를 들어 Simple In-Memory Broker를 이용해 SUBSCRIBE 중인 다른 클라이언트들에게 메세지를 보내준다. Simple In Memory Broker는 클라이언트의 SUBSCRIBE 정보를 자체적으로 메모리에 유지한다.
또한 RabbitMQ, ActiveMQ같은 외부 메세징 시스템을 STOMP Broker로 사용할 수 있도록 지원한다.
구조적인 면을 보자면, 스프링은 메세지를 외부 Broker에게 전달하고, Broker는 WebSocket으로 연결된 클라이언트에게 메세지를 전달하는 구조가 되겠다. 이와 같은 구조 덕분에 HTTP 기반의 보안 설정과 공통된 검증 등을 적용할 수 있게 된다.
STOMP는 HTTP에서 모델링되는 Frame 기반 프로토콜이며, Frame은 몇 개의 Text Line으로 지정된 구조인데 첫 번째 라인은 Text이고 이후 Key:Value 형태로 Header의 정보를 포함한다. 다음 빈 라인을 추가하고 Payload가 존재한다. 실제 구조는 다음과 같다.
COMMAND // SEND, SUBSCRIBE
header1:value1
header2:value2
Body^@
destination은 의도적으로 정보를 불분명하게 정의하였는데, 이는 STOMP 구현체에서 문자열 구문에 따라 직접 의미를 부여하도록 하기 위함이다(EndPoints). 그러나 일반적으로 다음의 형식을 따른다.
"topic/.." --> publish-subscribe (1:N)
"queue/" --> point-to-point (1:1)
다음은 ClientA가 1번 채팅방에 대해 SUBSCRIBE를 하는 예시이다.
SUBSCRIBE
destination: /topic/chat/room/1
id: sub-1
^@
다음은 ClientB가 1번 채팅방에 채팅 메시지를 보내는 예시이다.
SEND
destination: /pub/chat
content-type: application/json
{"chatRoomId": 1, "type": "MESSAGE", "writer": "clientB"} ^@
STOMP 서버는 모든 구독자에게 메세지를 Broadcasting하기 위해 ‘MESSAGE’ command를 사용할 수 있다. 서버는 내용을 기반(chatRoomId)으로 메세지를 전송할 broker에 전달한다.
MESSAGE
destination: /topic/chat/room/1
message-id: d4c0d7f6-1
subscription: sub-1
{"chatRoomId": 1, "type": "MESSAGE", "writer": "clientB"} ^@
서버는 불분명한 메세지를 전송할 수 없다. 그러므로 서버의 모든 메세지는 특정 클라이언트 구독에 응답하여야 하고, 서버 메세지의 "subscription-id" 헤더는 클라이언트 구독의 "id"헤더와 일치해야 한다.
Benefits
STOMP를 사용하여 더 다채로운 모델링을 할 수 있다.
- Messaging Protocol을 만들고 메세지 형식을 커스터마이징 할 필요가 없다.
- RabbitMQ, ActiveMQ 같은 Message Broker를 이용해, Subscription(구독)을 관리하고 메세지를 브로드캐스팅할 수 있다.
- WebSocket 기반으로 각 Connection(연결)마다 WebSocketHandler를 구현하는 것 보다 @Controller 된 객체를 이용해 조직적으로 관리할 수 있다.
- 즉, 메세지는 STOMP의 "destination" 헤더를 기반으로 @Controller 객체의 @MethodMapping 메서드로 라우팅 된다.
- STOMP의 "destination" 및 Message Type을 기반으로 메세지를 보호하기 위해 Spring Security를 사용할 수 있다.
Enable STOMP
STOMP over WebSocket 지원은 Spring-messaging, Spring-websocket 모듈에서 사용할 수 있다. SockJS Fallback을 이용하여 WebSocket을 통해 STOMP Endpoint를 설정해야 한다.
org.webjars:stomp-websocket:{versions}
Dependency가 필요하다.
- Enable STOMP Example
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
@Configuration
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// Endpoint 설정 : WebSocket or SockJS Client의 웹소켓 Handshake 커넥션 생성 경로
registry.addEndpoint("/portfolio").setAllowedOrigins("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// /app "destination" 헤더 경로는 @Controller 객체의 @MessageMapping 메소드로 라우팅 됨
registry.setApplicationDestinationPrefixes("/app");
// 내장 메시지 브로커를 통해 Subscriptions, Broadcasting 기능을 제공함
// /topic, /queue "destination" 헤더를 가진 메시지를 브로커를 통해 라우팅 함
registry.enableSimpleBroker("/topic", "/queue");
}
}
@EnableWebSocketMessageBroker 어노테이션을 통해 Spring Boot의 WebSocketMessagingAutoConfiguration이 올라가며, SimpMessagingTemplate 빈이 등록되고 브로커를 통해 메시지의 ConvertAndSend가 가능해진다.
- WebSocketMessagingAutoConfiguration
package org.springframework.boot.autoconfigure.websocket.servlet;
import java.util.List;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.LazyInitializationExcludeFilter;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.converter.ByteArrayMessageConverter;
import org.springframework.messaging.converter.DefaultContentTypeResolver;
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
import org.springframework.messaging.converter.MessageConverter;
import org.springframework.messaging.converter.StringMessageConverter;
import org.springframework.messaging.simp.config.AbstractMessageBrokerConfiguration;
import org.springframework.util.MimeTypeUtils;
import org.springframework.web.socket.config.annotation.DelegatingWebSocketMessageBrokerConfiguration;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* Auto-configuration for WebSocket-based messaging.
*
* @author Andy Wilkinson
* @since 1.3.0
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass(WebSocketMessageBrokerConfigurer.class)
@AutoConfigureAfter(JacksonAutoConfiguration.class)
public class WebSocketMessagingAutoConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnBean({ DelegatingWebSocketMessageBrokerConfiguration.class, ObjectMapper.class })
@ConditionalOnClass({ ObjectMapper.class, AbstractMessageBrokerConfiguration.class })
static class WebSocketMessageConverterConfiguration implements WebSocketMessageBrokerConfigurer {
private final ObjectMapper objectMapper;
WebSocketMessageConverterConfiguration(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public boolean configureMessageConverters(List<MessageConverter> messageConverters) {
MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter();
converter.setObjectMapper(this.objectMapper);
DefaultContentTypeResolver resolver = new DefaultContentTypeResolver();
resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON);
converter.setContentTypeResolver(resolver);
messageConverters.add(new StringMessageConverter());
messageConverters.add(new ByteArrayMessageConverter());
messageConverters.add(converter);
return false;
}
@Bean
static LazyInitializationExcludeFilter eagerStompWebSocketHandlerMapping() {
return (name, definition, type) -> name.equals("stompWebSocketHandlerMapping");
}
}
}
Flow of Messages
STOMP Endpoint가 설정되고 나면, Spring Application은 연결된 클라이언트들에 대해 STOMP 브로커가 된다.
다음 그림은 서버사이드에서 내장 메시지 브로커를 사용했을 경우의 메시지 Flow를 보여준다.
다음 그림은 서버사이드에서 외부 브로커(RabbitMQ, Kafka 등)를 사용했을 경우의 메시지 Flow를 나타낸다.
Flow엔 세가지 채널이 존재하며, 채널은 다음과 같다.
- clientInboundChannel : WebSocket Client로부터 들어오는 요청을 전달하며, WebSocketMessageBrokerConfigurer를 통해 intercept, taskExecutor를 설정할 수 있다. 클라이언트로부터 받은 메시지를 전달한다.
- clientOutboundChannel : WebSocket Client로 Server의 메세지를 내보내며, WebSocketMessageBrokerConfigurer를 통해 intercept, taskExecutor를 설정할 수 있다. 클라이언트에게 메시지를 전달한다.
- brokerChannel : Server 내부에서 사용하는 채널이며, 이를 통해 SimpleAnnotationMethod는 SimpleBroker의 존재를 직접 알지 못해도 메세지를 전달할 수 있다. 브로커에게 메시지를 전달한다.
다음 예제 코드에서 메시지가 어떻게 흐르는지 살펴보자.
- STOMP Messaging Example
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/portfolio");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app");
registry.enableSimpleBroker("/topic");
}
}
@Controller
public class GreetingController {
@MessageMapping("/greeting")
public String handle(String greeting) {
return "[" + getTimestamp() + ": " + greeting;
}
}
- 클라이언트는
http://localhost:8080/portfolio
URL을 통해 WebSocket 커넥션을 생성하며, STOMP 프레임이 흐르기 시작한다. - 클라이언트가 destination 헤더가
/topic/greeting
인 SUBSCRIBE 프레임을 보낸다. 수신되어 Decode된 메시지는 clientInboundChannel을 통해 메시지 브로커로 라우팅된다. - 클라이언트가 destination 헤더가
/app/greeting
인 SEND 프레임을 보낸다./app
접두사는 @Controller객체(GreetingController)로 라우팅하며, 남은/greeting
파트는 컨트롤러 내의 @MessageMapping된 메소드로 라우팅된다. - 메소드 리턴값은 payload를 가진 Spring message 치환되어, destination 헤더
/topic/greeting
을 가진다. (/app/greeting
에서 유도되어/app
이 디폴트 DestinationPrefix인/topic
으로 변환됨) 변환된 메시지는 brokerChannel을 통해 메시지 브로커에 의해 핸들링된다. - 메시지 브로커는 매칭되는 모든 Subscriber를 찾아 MESSAGE 프레임을 clientOutboundChannel을 통해 전송한다. 메시지는 STOMP 프레임으로 encode되어 WebSocket 커넥션을 통해 전송된다.
Sending Messages
애플리케이션 컴포넌트는 brokerChannel을 통해 메시지를 전송할 수 있으며, 가장 쉬운 방법은 SimpMessagingTemplate Bean을 주입받아 메시지를 전송하는 것이다.
- Sending Message Example
@Controller
public class GreetingController {
private SimpMessagingTemplate template;
@Autowired
public GreetingController(SimpMessagingTemplate template) {
this.template = template;
}
@RequestMapping(path="/greetings", method=POST)
public void greet(String greeting) {
String text = "[" + getTimestamp() + "]:" + greeting;
this.template.convertAndSend("/topic/greetings", text);
}
}
외부 브로커의 메시징 템플릿을 사용 시, 구현된 다른 타입의 템플릿 빈을 주입받아 사용하면 된다.
Interception
애플리케이션은 메시지가 흐르는 모든 Channel에 대해 간섭하는 인터셉터를 설정할 수 있다. 다음 예는 인바운드 메시지에 대한 인터셉터 처리에 대한 예시이다.
- ChannelInterceptor Example
@Configuration
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/portfolio").setAllowedOrigins("*").withSockJS();
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(myChannelInterceptor());
}
@Bean
public MyChannelInterceptor myChannelInterceptor() {
return new MyChannelInterceptor ();
}
}
public class MyChannelInterceptor implements ChannelInterceptor {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
...
// todo Impl
...
return message;
}
}
WebSocket Scope
각각의 WebSocket Session은 Attributes에 대한 Map을 가지고 있다. 인바운드된 클라이언트의 메시지의 헤더에서 접근 가능하며, 다음과 같이 접근할 수 있다.
- WebSocket Session Map Example
@Controller
public class MyController {
@MessageMapping("/action")
public void handle(SimpMessageHeaderAccessor headerAccessor) {
Map<String, Object> attrs = headerAccessor.getSessionAttributes();
// ...
}
}
Spring Bean의 라이프사이클에는 WebSocket Scope가 지원된다. 웹소켓 스코프의 빈은 컨트롤러와 clientInboundChannel에 등록된 Channel interceptor에서 주입받을 수 있다. 기본적으로 싱글톤이며, 각각의 웹소켓 세션보다 더 길게 남아있으므로 프록시 모드로 생성해야 한다. 다음은 웹소켓 스코프 빈 생성 예제다.
- WebSocket Scope Bean Example
@Component
@Scope(scopeName = "websocket", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyBean {
@PostConstruct
public void init() {
// Invoked after dependencies injected
}
// ...
@PreDestroy
public void destroy() {
// Invoked when the WebSocket session ends
}
}
@Controller
public class MyController {
private final MyBean myBean;
@Autowired
public MyController(MyBean myBean) {
this.myBean = myBean;
}
@MessageMapping("/action")
public void handle() {
// this.myBean from the current WebSocket session
}
}
STOMP - WebSocket 연결에서 @MessageMapping으로 라우팅되는 요청은 HTTP Request가 아니므로 Request Scope의 빈이 주입되지 않는다. 따라서 WebSocket Scope로 설정하여 주입받아야 한다.
그러나 WebSocket의 최초 연결시 HandShake 요청은 HTTP upgrade Request이므로, Request Scope Bean을 주입받아 사용할 수 있다.
다음 예제는 HandShake 요청 시 인터셉터에서 HttpServletRequest에 있는 쿠키를 WebSocket Session에 전달하고, WebSocket 메시지 전송 시 인터셉터에서 쿠키 정보를 WebSocket Scope Bean에 설정하는 예제다.
- WebSocket Cookie Handle Example
@Configuration
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/portfolio").setAllowedOrigins("*").withSockJS()
// HandShake 인터셉터 설정
.setInterceptors(webSocketHandShakeInterceptor());
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
// Channel 인터셉터 설정
registration.interceptors(myChannelInterceptor());
}
@Bean
public WebSocketHandShakeInterceptor webSocketHandShakeInterceptor() {
return new WebSocketHandShakeInterceptor();
}
@Bean
public MyChannelInterceptor myChannelInterceptor() {
return new MyChannelInterceptor ();
}
}
public class WebSocketHandShakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response
, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletServerRequest = (ServletServerHttpRequest) request;
HttpServletRequest servletRequest = servletServerRequest.getServletRequest();
// HandShake HTTP Request에서 쿠키를 가져옴
Cookie token = WebUtils.getCookie(servletRequest, "USER");
// 쿠키를 WebSocket Session Attribute에 저장
attributes.put("USER", token.getValue());
}
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response
, WebSocketHandler wsHandler, Exception exception) {
}
}
public class MyChannelInterceptor implements ChannelInterceptor {
@Autowired
private WebSocketUserCookieValueHandler webSocketUserCookieValueHandler;
@Autowired
private WebScopeUserBean webSocketUserBean;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
// Header 정보를 통해 Session Attribute에 접근, 쿠키 정보 파싱
Map<String,Object> headerAttribute = headerAccessor.getSessionAttributes();
String cookieValue = URLDecoder.decode((String) headerAttribute.get("USER"));
ServiceUser serviceUser = webSocketUserCookieValueHandler.toServiceUser(cookieValue);
webSocketUserBean.setEmail(serviceUser.getEmail());
webSocketUserBean.setName(serviceUser.getName());
webSocketUserBean.setUuid(serviceUser.getUuid());
webSocketUserBean.setMemberType(serviceUser.getMemberType());
webSocketUserBean.setLoginDttm(serviceUser.getLoginDttm());
return message;
}
}
맺음말
WebSocket 프로토콜이란 어떤 것이며, Spring Framework에서 이를 어떤식으로 지원하는지 다양한 설정에 대한 예제와 함께 자세히 알아보았다.
WebSocket은 실시간 채팅, SNS 등의 서비스에서 광범위하게 사용되고 있는 기술이다. WebSocket을 학습하면서 필자는 새로운 기술을 어디에 적용해볼까 하는 생각이 많이 들게 되었다.
이 포스팅을 통해 WebSocket을 이용한 서비스 구현에 아주 조금이나마 도움이 되었으면 한다.
Ref
- WebSocket Protocol
- Java API for WebSocket
- WebSocket Spring Doc, Servlet Stack