WebSocket 이란?
`WebSocket` 프로토콜은 표준 된 방법으로 `서버-클라이언트` 간에 단일 TCP 커넥션을 이용해서
양방향 통신을 제공한다.
특징
기존의 다른 TCP 기반의 프로토콜과 다르게,
`WebSocket`은 HTTP 요청 기반으로 WebSocket HandShake 과정을 거쳐 커넥션을 생성한다.
(기존의 TCP 3-way Handshake와는 달리 HTTP 요청을 활용해 연결을 시작한 후, 연결이 확립되면 TCP로 전환)
덕분에, 초기 WebSocket HandShake 요청은 추가적인 방화벽 설정 없이 80, 443 포트를 사용하여
양방향 통신이 가능하다. 또한 HTTP 규격 그대로 유지할 수 있기 때문에
HTTP 인증, CORS 등을 동일하게 적용할 수 있다는 장점이 있다.
커넥션 Flow
`WebSocket`은 커넥션을 맺기 위해 HTTP 요청을 보내는데, 아래와 같이 HTTP 요청 헤더에
Upgrade 헤더와 Connection을 포함한다.
# Upgrade
- 이미 생성된 커넥션을 다른 프로토콜로 업그레이드/변경
- 클라이언트가 Upgrade 헤더 값에 나열한 프로토콜 리스트를 서버가 선택한다
- 앞쪽에 배치할수록 우선순위가 높음
- 서버는 Upgrade 하기로 선택한 프로토콜은 응답 Upgrade 헤더에 추가해 전달
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` 상태 코드로 응답을 하는데,
HandShake 이후에도 TCP 커넥션은 지속적으로 유지된다.
101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: ...
HTTP vs WebSocket
`WebSocket`이 HTTP 요청을 시작되는 호환성을 가지고 있지만, 분명하게 두 프로토콜은 다른 방식으로 동작한다.
HTTP는 여러 URL을 기반으로 서버 애플리케이션과 `Request/Response` 형식으로 상호 작용한다.
`WebSocket`은 반대로 오직 초기의 커넥션 수립을 위한 하나의 URL만 있고,
모든 애플리케이션 메시지는 동일한 TCP 커넥션에서 전달된다.
즉, `WebSocket`은 HTTP 프로토콜과 다른 `asynchronous`, `event-driven`, `messaging` 아키텍쳐 모델이다.
또한, HTTP 경우에는 서버가 URI, Method, Headers 정보로 적절한 핸들러로 라우팅해 처리할 수 있다.
하지만, `WebSocket`은 HTTP 와 다르게 메시지 내용에 의미를 두지 않기 때문에, 클라이언트-서버 간에 임의로 메시지에 의미를 부여하지 않으면 처리할 방법이 마땅히 없다.
이러한 문제를 `STOMP` 메시징 프로토콜을 통해서 해결할 수 있는데, 상위 프로토콜이 규정한 협약을 토대로 메시지를 처리할 수 있다.
그렇다면 언제 WebSocket을 사용할까?
Tranditional Polling
고전적인 Polling 방식은 새로운 정보가 있는지 확인하기 위해 주기적으로 HTTP 요청을 보낸다.
이러한 방식은 지속적으로 요청을 보내기 때문에, 매번 커넥션을 생성하기 위한
`Handshake` 비용이 많아지며 서버에 부담을 주게 된다.
Long Polling
`Long Polling`은 `Traditional Polling`을 개선한 방식이다.
클라이언트는 서버에 요청을 보내고, 서버는 변경 사항이 있는 경우에만 응답하여 커넥션을 종료한다.
그리고 클라이언트는 바로 다시 서버에 요청을 보내어 변경 사항이 있을 때까지 대기하게 된다.
커넥션은 무한히 대기할 수 없으므로, 브라우저는 약 5분 정도 대기하며 중간 프록시에 따라 더 짧게 커넥션이 종료될 수도 있다.
만약 변경 사항이 불규칙적인 간격으로 일어나는 경우 효율적이나,
변경 사항의 빈도가 잦다면 기존 Traditional Polling과 차이가 없으므로 서버의 부담이 증가하게 된다.
HTTP Streaming
`HTTP Streaming`은 `Long Polling` 과 동일하게 HTTP 요청을 보내지만,
변경 사항을 클라이언트에 응답한 이후에도 커넥션을 종료하지 않고 유지한다.
따라서, 매번 새로운 커넥션을 맺고 끊는 것이 아니라 하나의 커넥션을 유지하며, 변경 사항을 응답 메시지로 전달한다.
`HTTP Streaming`은 `Long Polling` 방식에 비해서 서버의 부담을 줄일 수 있지만,
여러 건의 변경 사항이 일어난 경우 동시 처리가 어려워진다.
왜냐하면, 서버가 현재 업데이트된 데이터를 모두 전달해야만,
클라이언트에서 다음 업데이트된 데이터의 시작 위치를 알 수 있기 때문이다.
뿐만 아니라, `HTTP Streaming` 방식은 서버가 클라이언트에게 전달하는 메시지에 대한 실시간성을 어느 정도 보장하지만,
클라이언트가 서버에게 보내는 요청은 여전히 새로운 커넥션을 생성해야 한다.
이러한 동시성과 서버 부담이라는 `Trade Off` 사항에서, `HTTP Streaming` 보다 `Long Polling` 방식을 많이 사용한다고 한다.
WebSocket
위와 같은 `HTTP Long Polling`, `HTTP Streaming` 방식이 가지고 있는 문제를 해결하고,
서버-클라이언트 간에 양방향 통신이 가능하도록 `WebSocket` 이라는 기술이 만들어지게 됐다.
`WebSocket`은 서비스를 동적으로 만들어 주지만,
AJAX, HTTP Streaming, HTTP Long Polling 기술이 보다 효과적인 경우도 있다.
예를 들어, 변경 사항의 빈도가 자주 일어나지 않고 데이터의 크기가 작은 경우에는
AJAX, HTTP Streaming, HTTP Long Polling 기술이 효과적일 수 있다.
즉, 실시간성을 보장해야 하고 변경 사항의 빈도가 크다면 `WebSocket`은 좋은 해결책이 될 수 있다.
Spring WebSocket
`Spring Framework`는 `WebSocket API`를 제공한다.
여기서 주목할 점은 Spring에서 제공하는 `WebSocket API`는 `Spring MVC` 기술에 종속되지 않는다는 것이다.
`WebSocket` 서버는 `WebSocketHandler` 인터페이스의 구현체를 통해서, 각 경로에 대한 핸들러를 구현할 수 있다.
뿐만 아니라, `Message` 형식에 따라 `TextWebSocketHandler or BinaryWebSocketHandler` 핸들러를 확장해 구현할 수도 있다.
아래는 `탬플릿 메서드 패턴`이 적용된 `AbstractWebSocketHandler` 추상 클래스이다.
메시지 형식에 따라, 적합한 `handleXXX` 메서드를 호출한다.
Spring WebSocket 설정
문자열 메시지를 기반으로 실습을 진행하기 때문에 `TextWebSocketHandler`를 상속받아
메시지를 전달받는다.
public class Handler extends TextWebSocketHandler {
@Override
public void handleTextMessage(WebSocketSession wsSession, TextMessage message) throws Exception {
wsSession.sendMessage(message);
}
}
다음으로, Handler를 `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 Session 동시성
`WebSocketHandler`를 사용하는 경우, `표준 WebSocket session(JSR-356)`은 동시 전송을 지원하지 않는다.
따라서 `STOMP 메시징 프로토콜`을 이용해서 메시지 전송을 동기화하거나,
`WebSocketSession`을 `ConcurrentWebSocketSessionDecorator`으로 `Wrapping`해야 한다.
`ConcurrentWebSocketSessionDecorator`은 오직 하나의 스레드만 메시지를 전송하도록 보장해주기 때문이다.
WebSocket Handshake
각 `WebSocketHandler`마다 HandShake 전(before)/후(after)로 필요한 작업이 있다면,
`HandshakeInterceptor` 인터페이스를 구현해서 등록하면 된다.
이를 통해서, `HandShake`를 막거나 `WebSocketSession`의 속성을 사용할 수 있다.
아래는 기본적으로 제공하는 HTTP Session을 WebSocket Session에 전달하는
`HttpSessionHandshakeInterceptor` 예제이다.
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry
.addHandler(webSocketHandler(), "/test")
.addInterceptors(new HttpSessionHandshakeInterceptor())
.setAllowedOrigins("*");
}
@Bean
public WebSocketHandler webSocketHandler() {
return new Handler();
}
}
뿐만 아니라 만약 직접 `HandShake` 단계의 작업을 수행해야 한다면,
`AbstractHandshakeHandler`를 확장해 직접 구현할 수도 있다.
만약 아직 지원하지 않는 `WebSocket` 서버 엔진이나 버전을 적용하기 위해서,
`RequestUpgradeStrategy` 을 직접 구현할 수도 있다.
(직접 구현한 `RequestUpgradeStrategy` 객체는 `AbstractHandshakeHandler` 생성자를 통해 전달한다.)
아래는 디폴트로 제공되는 `DefaultHandshakeHandler`를 추가하는 과정이다. (추가하지 않아도 디폴트로 제공된다.)
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry
.addHandler(webSocketHandler(), "/test")
.setHandshakeHandler(new DefaultHandshakeHandler())
.setAllowedOrigins("*");
}
@Bean
public WebSocketHandler webSocketHandler() {
return new Handler();
}
}
WebSocketHandlerDecorator
Spring은 `WebSocketHandler`가 호출되기까지 아래 그림과 같이, 여러 `Decorator`를 거친다.
Spring은 데코레이터 패턴을 이용해서 `WebSocketHandler`에 대한 추가적인 작업을
처리할 수 있도록 `WebSocketHandlerDecorator` 객체를 제공한다.
예를 들어 Message, Session 등의 정보에 대한 로깅 등의 작업을 추가할 수 있다.
아래 그림을 실제로 기본적으로 Spring에 등록된 `WebSocketHandlerDecorator` 구현체들이며,
연속적으로 전파되어 실행되는 것을 확인할 수 있다.
`ExceptionWebSocketHandlerDecorator`로 `WebSocketHandler` 실행 과정에서
발생하는 예외를 모두 잡아서 처리한다.
`LoggingWebSocketHandlerDecorator`는 Message, Session 정보를 logging 한다.
마지막으로, `AbstractWebSocketHandler`는 Message 타입에 따라 `handleXXX()` 메서드를 호출한다.
WebSocket 속성 설정
`WebSocket Engine`에 대한 메시지 버퍼 크기, 유휴 제한 시간 등 같은 런타임 특성을
`ServletServerContainerFactoryBean`으로 등록할 수 있다.
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
...
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(10000);
container.setMaxSessionIdleTimeout(1000L);
container.setAsyncSendTimeout(1000L);
return container;
}
}
CORS
`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의 동작 방식
- WebSocket가 생긴 과정
- HTTP vs WebSocket
- WebSocket의 활용 상황
- Spring에서의 WebSocket 활용
관해 알아보았다.
다음 포스팅에서는 `SockJS`에 관해 알아보려 한다.
참고
https://velog.io/@koseungbin/WebSocket#stomp-%EC%9D%B4%EB%9E%80
'Spring > WebSocket' 카테고리의 다른 글
[Spring WebSocket] STOMP (0) | 2024.11.04 |
---|---|
[Spring WebSocket] SockJS (2) | 2024.11.04 |
[Spring WebSocket] SSE vs WebSocket (0) | 2024.11.04 |