SockJS
지금까지는 클라이언트-서버 간에 WebSocket 연결과 메시지 주고받는 방법에 대해 살펴보았다.
그런데, 클라이언트-서버 WebSocket 통신이 순탄하게만 진행될 수 있을까?
아니다. 그럼, 발생할 수 있는 예외 상황은 어떤 것이 있을지 살펴보자.
우선, 모든 클라이언트의 브라우저에서 WebSocket을 지원한다는 보장이 없다.
두 번째로, 클라이언트/서버 중간에 위치한 프록시가 Upgrade 헤더를 해석하지 못해 서버에 전달하지 못할 수 있다.
마지막으로, 클라이언트/서버 중간에 위치한 프록시가 유휴 상태에서 도중에 커넥션 종료시킬 수도 있다.
이러한 문제는 WebSocket Emulation을 통해서 해결이 가능하다.
WebSocket Emulation 이란, 우선 WebSocket을 첫 번째로 시도하고
WebSocket 연결이 실패한 경우에는 HTTP-Streaming, HTTP Long Polling 같은 HTTP 기반의 다른 기술로 전환해 다시 연결을 시도하는 것을 말한다.
즉 WebSocket Emulation을 통해서, 위와 같이 WebSocket 연결을 할 수 없는 경우에는 다른 HTTP 기반의 기술을 시도하는 방법이다. 이러한, WebSocket Emulation을 지원하는 것이 바로 SockJS 프로토콜이다.
Spring Framework는 Servlet 스택 위에서 서버/클라이언트 용도의 SockJS 프로토콜을 모두 지원하고 있다.
SockJS 목표
SockJS의 목표는 "애플리케이션이 우선적으로 WebSocket API를 사용하도록 하지만, 사용할 수 없는 경우에는 런타임 시점에 코드 변경 없이WebSocket 이외의 대안으로 대체"하도록 하는 것이다.
특징
우선 SockJS는 브라우저에서 사용하도록 설계가 되었기 때문에, 다양한 브라우저와 버전을 지원하고 있다.
자세한 브라우저 지원 범위는 아래 링크를 참고하길 바란다.
또한 SockJS는 WebSocket, HTTP Streaming, HTTP Long Polling 등의 크게 세 가지 전송 방법(Transports)을 지원하고 있는데, 이외에도 아래와 같이 다양한 방식을 제공하고 있다.
SockJS가 지원하는 자세한 전송 방법(Transports) 리스트는 아래 링크에서 참고 바란다.
WebSocket Emulation 과정
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 Request URL 형식
모든 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)
Transports Type
websocket 타입의 전송 방식은 WebSocket HandShake를 하기 위해서 오직 하나의 HTTP 요청만 필요하고, 이후 모든 메시지는 해당 소켓에서 교환된다.
xhr-polling 타입 경우에는 서버에서 클라이언트로 응답 메시지가 전달이 될 때마다 기존의 커넥션을 종료하고 새로운 요청을 보내어 커넥션을 생성한다.
메시지 형식
추가적으로, SockJS는 Message Frame 크기를 최소화하기 위해 노력한다.
예를 들어, open frame 경우에는 첫 글자인 o를 전송한다.
또한, Message Frame의 경우에는 다음과 같은 형태로 전달받는다.
a["message1", "message2"]
커넥션 유지 여부를 확인하는 Heartbeat Frame 경우에는 h 로 보낸다.
마지막으로, 커넥션 종료를 의미하는 Close Frame은 c["message"] 형식으로 보낸다.
SockJS 사용
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>
...
</style>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
</head>
<body>
<div>
<input type="text" class="message"/>
<button onClick='send()' class="send-btn">보내기</button>
</div>
<div class="messages">
<div id="messages"></div>
</div>
</body>
<script>
let sock;
document.addEventListener("DOMContentLoaded", function() {
// ["websocket", "xhr-streaming", "xhr-polling"]
sock = new SockJS('http://localhost:8080/test', null, {transports: ["websocket", "xhr-streaming", "xhr-polling"]});
sock.onopen = function() {
console.log('Connected!!');
};
sock.onmessage = function(event) {
const messages = document.querySelector("#messages");
const message = document.createElement("li");
message.innerText = event.data;
messages.appendChild(message)
};
sock.onclose = function() {
console.log('close');
};
});
function send() {
const message = document.querySelector(".message");
sock.send(message.value);
message.value = '';
}
</script>
</html>
IE 8, 9 호환성
여전히 많은 사용자들은 Internet Explorer 브라우저의 8, 9 버전을 여전히 사용하고 있지만, 해당 버전에서는 WebSocket을 지원하고 있지 않다.
이러한 부분에서 SockJS 진가가 발휘되는데, IE 8, 9 버전은 HTTP Streaming 또는 HTTP Long Polling Transports 타입으로 전환되어 호환이 가능하기 때문이다.
SockJS 클라이언트는 Microsoft의 XDomainRequest(xdr) 이용해서 HTTP Streaming을 지원한다.(10 버전부터는 XMLHttpRequest(xhr) 사용을 권장하여 XDomainRequest 를 제거)
XDomainRequest(xdr), XMLHttpRequest(xhr)는 모두 CORS를 지원하기 위한 도구이다.
XDomainRequest는 비록 CORS 도구로서 잘 동작하지만, Cookies 전송을 지원하지 않는다.
Cookies는 종종 Java 애플리케이션에서 필수적이지만, SockJS 클라이언트는 여러 유형의 서버와 함께 사용할 수 있기 때문에 큰 문제는 아니다.
따라서, 서버 측의 Cookies 필요 여부에 따라 HTTP Streaming, HTTP Long Polling에서 사용하는 기술이 달라진다.
- Cookies 사용 불가능하다면, XDomainRequest(xdr)가 사용된다.
- Cookies 사용 가능하다면, iframe 기반의 기술이 사용된다.
SockJS 클라이언트가 첫 번째로 요청한 GET /info에 대한 응답 메시지에는 클라이언트가 Transports 타입을 선택하는데 영향을 미치는 요소들이 포함되어 있다.
위 그림과 같이, 서버가 Cookies 정보가 필요한 지 등의 정보를 클라이언트에게 응답 메시지로 전달한다.
cookie_needed 항목은 스프링에서 setSessionCookieNeeded(bolean) 메서드로 제어가 가능한데, 아래와 같이 설정할 수 있다.(Java 애플리케이션에서 JSESSIONID 쿠키를 많이 사용하기 때문에 디폴트 설정은 true 이다.)
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry
.addHandler(webSocketHandler(), "/test")
.withSockJS()
.setSessionCookieNeeded(false);
}
...
}
위에서 말했다시피, 만약 서버에서 쿠키가 필요하지 않다면 SockJS 클라이언트는 IE 8, 9 버전에서 XDomainRequest를 사용하게 한다.
또한, iframe 기반의 Transports를 사용하는 경우에는 브라우저가 [X-Frame-Options](https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/X-Frame-Options) 응답 헤더에 지정한 DENY, SAMEORIGIN, ALLOW-FROM <origin> 페이지들에 대해서만 iframe을 렌더링 하도록 방지할 수 있다.
- DENY는 모든 iframe에서 사용될 수 없다.
- SAMEORIGIN은 동일한 출처, 즉 같은 도메인인 경우에만 허용
- ALLOW-FROM <origin>는 지정한 도메인 URI에 대해서만 허용
X-Frame-Options 응답 헤더는 해당 페이지를 <frame>, <iframe>, <object>에서 렌더링 할 수 있는지 여부를 의미한다.
만약 iframe 기반의 Transports를 사용하고 X-Frame-Options 응답 헤더를 포함하려면, 반드시 SAMEORIGIN 이거나 ALLOW-FROM <origin>에 SockJS 클라이언트 도메인을 지정해야만 한다.
즉, iframe로 부터 로드되기 위해서는 스프링 서버의 SockJS가 클라이언트의 위치를 알고 있어야 한다는 것이다.
따라서, 스프링은 SAMEORIGIN을 지원하기 위해서 SockJS-Client 접근 경로를 설정할 수 있도록 아래와 같이 제공한다.
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry
.addHandler(webSocketHandler(), "/test")
.withSockJS()
.setClientLibraryUrl("http://localhost:8080/myApplication/js/sockjs-client.js")
}
@Bean
public WebSocketHandler webSocketHandler() {
return new Handler();
}
}
Heartbeat
SockJS 프로토콜은 서버가 주기적으로 Heartbeat Message 전송하여, 프록시가 커넥션이 끊겼다고 판단하지 않도록 한다.
스프링 SockJS 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 설정은 비활성화된다.
Client Disconnects
SockJS Transports 타입인 HTTP Streaming과 HTTP Long Polling는 일반 요청보다 더 긴 커넥션을 요구한다.
이러한 요구 사항은 서블릿 컨테이너에서 Servlet 3 asynchronous 지원을 통해 수행된다.
구체적으로, Servlet 3 asynchronous는 Servlet Container Thread가 종료되고도 요청을 처리하며 다른 스레드가 지속적으로 응답에 Write 할 수 있도록 지원한다.
여기서 문제점은 Servlet API가 갑자기 사라진 클라이언트에 대한 알림을 제공하지 않는다는 것이다.
그러나 다행히도, Servlet Container는 응답에 Write를 시도하는 경우 예외를 발생시킨다.
뿐만 아니라, 스프링의 SockJS는 서버 측에서 주기적으로 Heartbeat를 전송하기 때문에 클라이언트의 연결 여부를 일정 시간 안에 파악할 수 있다.
SockJS와 CORS
만약 Cross-Origin Requests(CORS)를 허용한다면, SockJS 프로토콜은 HTTP Streaming, HTTP Long Polling 과정에서 해당 CORS를 사용한다.
따라서, 스프링은 응답 헤더에서 CORS 헤더를 발견되지 않는다면 SockJS CORS에서 설정한 정보를 기반으로 헤더를 추가한다.
만약 Servlet Filter를 통해서 이미 CORS 설정한 경우에는 스프링의 SockJsService에서의 CORS 설정은 건너뛴다. 또한, 각 핸들러에서는 setSupressCors(boolean) 메서드를 이용해서 SockJsService를 통한 CORS 헤더 추가 여부를 설정할 수 있다.
SockJsService에서 CORS 헤더를 추가하도록 설정하고 싶은 경우에는 SockJS Endpoint의 Prefix에 대해서는 Servlet Filter를 제외하도록 설정한다.
SockJS에서는 CORS 헤더에 아래와 같은 값이 필요하다.
- Access-Control-Allow-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 설정
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 기반의 Transports도 해당 속성을 사용한다.(HTTP Streaming도 사용)
- 즉, 다음 HTTP 폴링 요청에 대한 커넥션이 생성될 때까지 세션에 저장하고 있을 수 있는 메시지의 개수를 의미한다.
- DisconnectDelay는 클라이언트가 연결이 끊긴 것으로 간주되는 시간을 의미한다.(Default, 5 * 1000)
참고
https://docs.spring.io/spring-framework/reference/web/websocket/stomp/handle-annotations.html
https://velog.io/@koseungbin/WebSocket#stomp-%EC%9D%B4%EB%9E%80