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`는 브라우저에서 사용하도록 설계가 되었기 때문에, 다양한 브라우저와 버전을 지원하고 있다.
자세한 브라우저 지원 범위는 아래 링크를 참고하길 바란다.
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"]});
Transport 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`)
Transport Type
websocket
`websocket` 타입의 전송 방식은 `WebSocket HandShake`를 하기 위해서 오직 하나의 HTTP 요청만 필요하고,
이후 모든 메시지는 해당 소켓에서 교환된다.
xhr-streaming
`xhr-streaming` 타입의 경우에는 장기 실행 요청을 유지하여
서버에서 클라이언트로 전달하기 위한 메시지를 응답으로 전달받는다.
이후, 클라이언트에서 서버로 새로운 요청을 보내야 할 경우에는 기존의 커넥션을 종료하고
새로운 `HTTP POST` 요청을 보내어 커넥션을 유지한다.
xhr-polling
`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>
.message {
margin: 5em;
color: olive;
border: solid 2px #D2691E;
-webkit-transition: 0.5s; transition: 0.5s;
width: 50%;
padding: 10px 20px;
box-sizing: border-box;
}
.messages {
margin: 5em;
}
.send-btn {
background-color: #F4FEE2;
padding: 10px 20px;
}
li {
list-style-type: none;
margin: 1em;
text-align: center;
font-size: 2em;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
</head>
<body>
<div>
<input type="text" class="message"/>
<button onClick='send()' class="send-btn">보내기</button>
</div>
<div class="messages">
<div id="messages"></div>
</div>
</body>
<script>
let sock;
document.addEventListener("DOMContentLoaded", function() {
// ["websocket", "xhr-streaming", "xhr-polling"]
sock = new SockJS('http://localhost:8080/test', null, {transports: ["websocket", "xhr-streaming", "xhr-polling"]});
sock.onopen = function() {
console.log('Connected!!');
};
sock.onmessage = function(event) {
const messages = document.querySelector("#messages");
const message = document.createElement("li");
message.innerText = event.data;
messages.appendChild(message)
};
sock.onclose = function() {
console.log('close');
};
});
function send() {
const message = document.querySelector(".message");
sock.send(message.value);
message.value = '';
}
</script>
</html>
IE 8, 9 호환성(참고)
여전히 많은 사용자들은 Internet Explorer 브라우저의 8, 9 버전을 여전히 사용하고 있지만,
해당 버전에서는 `WebSocket`을 지원하고 있지 않다.
이러한 부분에서 `SockJS` 진가가 발휘되는데,
IE 8, 9 버전은 `HTTP Streaming` 또는 `HTTP Long Polling` Transports 타입으로 전환되어 호환이 가능하기 때문이다.
`SockJS` 클라이언트는 Microsoft의 `XDomainRequest(xdr)` 이용해서 `HTTP Streaming`을 지원한다.
(10 버전부터는 `XMLHttpRequest(xhr)` 사용을 권장하여 `XDomainRequest` 를 제거)
XDomainRequest(xdr), XMLHttpRequest(xhr)는 모두 CORS를 지원하기 위한 도구이다.
`XDomainRequest`는 비록 `CORS 도구`로서 잘 동작하지만, `Cookies` 전송을 지원하지 않는다.
`Cookies` 는 종종 Java 애플리케이션에서 필수적이지만,
`SockJS` 클라이언트는 여러 유형의 서버와 함께 사용할 수 있기 때문에 큰 문제는 아니다.
따라서, 서버 측의 `Cookies` 필요 여부에 따라 `HTTP Streaming, HTTP Long Polling`에서 사용하는 기술이 달라진다.
- `Cookies` 사용 불가능하다면, `XDomainRequest(xdr)`가 사용된다.
- `Cookies` 사용 가능하다면, `iframe` 기반의 기술이 사용된다.
`SockJS` 클라이언트가 첫 번째로 요청한 `GET /info`에 대한 응답 메시지에는
클라이언트가 `Transports` 타입을 선택하는데 영향을 미치는 요소들이 포함되어 있다.
위 그림과 같이, 서버가 `Cookies` 정보가 필요한 지 등의 정보를 클라이언트에게 응답 메시지로 전달한다.
`cookie_needed` 항목은 스프링에서 `setSessionCookieNeeded(bolean)` 메서드로 제어가 가능한데,
아래와 같이 설정할 수 있다.
(Java 애플리케이션에서 `JSESSIONID` 쿠키를 많이 사용하기 때문에 디폴트 설정은 `true` 이다.)
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry
.addHandler(webSocketHandler(), "/test")
.withSockJS()
.setSessionCookieNeeded(false);
}
...
}
위에서 말했다시피, 만약 서버에서 쿠키가 필요하지 않다면
`SockJS` 클라이언트는 `IE 8, 9` 버전에서 `XDomainRequest`를 사용하게 한다.
또한, `iframe` 기반의 `Transports`를 사용하는 경우에는 브라우저가 [X-Frame-Options](https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/X-Frame-Options) 응답 헤더에
지정한 `DENY`, `SAMEORIGIN`, `ALLOW-FROM <origin>` 페이지들에 대해서만 `iframe`을 렌더링하도록 방지할 수 있다.
- DENY는 모든 iframe에서 사용될 수 없다.
- SAMEORIGIN은 동일한 출처, 즉 같은 도메인인 경우에만 허용
- ALLOW-FROM <origin>는 지정한 도메인 URI에 대해서만 허용
X-Frame-Options 응답 헤더는 해당 페이지를 <frame>, <iframe>, <object> 에서 렌더링할 수 있는 지 여부를 의미한다.
만약 `iframe` 기반의 `Transports`를 사용하고 `X-Frame-Options` 응답 헤더를 포함하려면,
반드시 `SAMEORIGIN` 이거나 `ALLOW-FROM <origin>`에 `SockJS` 클라이언트 도메인을 지정해야만 한다.
즉, `iframe`로 부터 로드되기 위해서는 스프링 서버의 `SockJS`가 클라이언트의 위치를 알고 있어야 한다는 것이다.
따라서, 스프링은 `SAMEORIGIN`을 지원하기 위해서 `SockJS-Client` 접근 경로를 설정할 수 있도록 아래와 같이 제공한다.
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry
.addHandler(webSocketHandler(), "/test")
.withSockJS()
.setClientLibraryUrl("http://localhost:8080/myApplication/js/sockjs-client.js")
}
@Bean
public WebSocketHandler webSocketHandler() {
return new Handler();
}
}
HeartBeat
`SockJS` 프로토콜은 서버가 주기적으로 `Heartbeat Message` 전송하여,
프록시가 커넥션이 끊겼다고 판단하지 않도록 한다.
스프링 `SockJS 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` 설정은 비활성화된다.
Clients Disconnect
`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 폴링 요청에 대한 커넥션이 생성될 때까지 세션에 저장하고 있을 수 있는 메시지의 개수를 의미한다.
- 모든 HTTP 기반의 Transports도 해당 속성을 사용한다.(HTTP Streaming도 사용)
- `DisconnectDelay`는 클라이언트가 연결이 끊긴 것으로 간주되는 시간을 의미한다.(Default, 5 * 1000)
SockJSClient
스프링은 브라우저없이 `SockJS Java Client` 이용해서 `SockJS`에 연결하는 기능을 제공한다.
이러한 기능은 두 서버간에 양방향 통신이 필요한 경우에 유용하게 사용될 수 있다.
또한, 테스팅하는 경우에도 유용하게 사용될 수 있다.
`SockJS Java Client`는 오직 `websocket, xhr-streaming, xhr-polling` Transports 타입만 제공하고, 나머지는 브라우저에서만 지원된다.
아래 예제는 사용할 `Transports 타입`을 지정하고 `SockJSClient` 이용해서
`http://localhost:8090/test` 서버와 연결하는 작업이다.`
List<Transport> transports = new ArrayList<>();
transports.add(new WebsocketTransport(new StandardWebSocketClient()));
transports.add(new RestTemplateXhrTransport());
SockJsClient sockJsClient = new SockJsClient(transports);
sockJsClient.doHandshake(webSocketHandler(), "http://localhost:8080/test");
Transports 타입 등록하는 과정을 자세히 살펴보자.
클라이언트 관점에서는 서버에 연결하는데 사용되는 URL 외에 다른 차이점이 없기 때문에,
`XhrTransport`는 `xhr-streaming`와 `xhr-polling` 모두를 지원한다.
즉, `RestTemplateXhrTransport`는 `xhr-streaming`와 `xhr-polling` 모두를 지원하는 `XhrTransport` 구현체이다.
`RestTemplateXhrTransport`은 내부적으로 `HTTP Request`를 위해 스프링에서 제공하는 `RestTemplate`를 사용한다.
또한 `WebSocket` 설정은 `WebsocketTransport` 객체를 통해 이루어지는데, `JSR-356 runtime`에서 제공하는 `StandardWebSocketClient`를 사용하도록 지정한다.
HTTP Client 시뮬레이션 테스트
만약 `SockJsClient` 이용해서 여러 동시 사용자를 시뮬레이션하는 경우라면,
`HTTP Client(XHR transports)`가 충분히 많은 커넥션과 스레드를 허용하도록 구성할 수 있다.
아래는 `Jetty`를 사용한 예시이다.
HttpClient httpClient = new HttpClient();
httpClient.setMaxConnectionsPerDestination(1000);
httpClient.setExecutor(new QueuedThreadPool(500));
정리
이번 포스팅에서는
- SockJS 탄생 이유
- SockJS 란?
- SockJS의 사용
- SockJS CORS
- SockJS Disconnect
- SockJS 설정
등에 관해 알아보았다.
여기서 헷갈리면 안되는것은 `HTTP Streaming`, `HTTP Polling` 방식은 HTTP 통신을 기반으로 커넥션을 길게 유지하는 것이다.
(WebSocket에서 TCP 커넥션을 계속 맺고 있는 것과는 조금 다르다.)
(앞서 말한 `Servlet 3 asynchronous` 지원으로 가능하다)
`WebSocket`과는 달리, `SockJS`에서 `HTTP Streaming`이나 `HTTP Polling`을 사용할 때는
실제로 TCP 연결을 통한 지속적인 소켓 연결이 아닌, HTTP 기반의 요청/응답 형태로 동작한다.
1. WebSocket의 TCP 연결
- WebSocket은 초기에 HTTP 요청을 사용해 handshake를 거치지만, handshake가 완료되면 지속적인 TCP 연결을 통해 쌍방향 통신을 유지한다.
- 이 방식은 항상 열린 하나의 TCP 소켓 연결을 통해 데이터를 양방향으로 주고받기 때문에 네트워크 효율성이 좋다.
2. `SockJS`에서의 `HTTP Streaming`과 `HTTP Polling`
- `SockJS`는 `WebSocket`을 지원하지 않는 환경(예: 일부 방화벽, 프록시 환경)에서
`WebSocket`을 흉내 내기 위해 다양한 폴백 방식을 지원한다.
대표적인 방식으로 `HTTP Streaming과 HTTP Polling`이 있다.- HTTP Streaming:
- 서버는 클라이언트의 HTTP 요청을 열어 두고 지속적으로 데이터를 전송한다.
- 클라이언트는 서버로부터 지속적으로 전송되는 데이터를 수신하지만, 이 역시 HTTP 요청 기반이기 때문에 연속적인 HTTP 연결이 열렸다 닫히는 방식으로 동작한다.
- 전송 방식이 실시간에 가깝지만, 엄밀히 말하면 하나의 TCP 연결이 계속 유지되는 것은 아니다.
- HTTP Polling:
- 클라이언트가 일정한 주기로 서버에 HTTP 요청을 보내고 서버는 응답하는 방식이다.
- Polling은 각 요청에 대해 새로운 HTTP 연결을 생성하여 데이터를 주고받고, 연결을 종료한다.
- 이 방식은 요청-응답이 끝나면 TCP 연결이 끊어지므로, `WebSocket`처럼 지속적인 TCP 연결을 유지하지 않는다.
- HTTP Streaming:
요약
- `WebSocket`: `WebSocket handshake` 후 단일 TCP 연결을 통해 쌍방향 통신을 유지.
- `SockJS`의 `HTTP Streaming과 HTTP Polling`: 각각 HTTP 요청/응답을 여러 번 사용하는 방식으로,
지속적인 TCP 연결이 아닌 HTTP 연결을 반복적으로 사용하는 방식.
'Spring > WebSocket' 카테고리의 다른 글
[Spring WebSocket] 채팅 서비스 프로젝트에 Kafka 적용 (0) | 2024.11.10 |
---|---|
[Spring WebSocket] STOMP를 활용한 채팅 서비스 토이 프로젝트 (0) | 2024.11.10 |
[Spring WebSocket] STOMP (0) | 2024.11.04 |
[Spring WebSocket] WebSocket (0) | 2024.11.04 |
[Spring WebSocket] SSE vs WebSocket (0) | 2024.11.04 |