웹소켓
WebSocket은 ws 프로토콜을 기반으로 클라이언트와 서버 사이에 지속적인 완전 양방향 연결 스트림을 만들어 주는 기술입니다. 출처 : https://developer.mozilla.org/ko/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications
연결은 클라이언트에서 연결요청을 보내면
GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
서버에서 응답을 통해 웹소켓 프로토콜로 업그레이드한다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
그러면 실시간 양방향 통신이 되는 웹소켓을 사용할 수 있게 된다.
웹소켓은 데이터가 크면 조각으로 나누어 보낼 수 있고 그것에 대한 설명은 웹소켓 프로토콜에 대한 웹소켓에서 확인할 수 있다. 또한, 웹소켓은 일반적으로 보낼 수 있는 데이터에 제한이 있을 수 있다. 해당 내용은 스프링 Doc도 확인이 가능하며, 실제로도 웹소켓 이용중 연결이 끊기는 경우를 경험할 수 있을 것이다. 데이터가 너무 크면 서버의 부하가 있을 수 있어 제한이 있다. 하지만 이러한 제한도 설정을 통해 해결할 수 있는 방법이 있다. 아니면 큰데이터를 조각화하여 보내고 서버에서 합쳐서 대응하는 방법 등이 있다. 또한, 한번 연결된 웹소켓이 연결이 끊어지는 경우도 있다. 재연결에 대한 방안도 있으니 문제가 된다면 찾아보자.
웹소켓 재연결 핸들링한 글도 있으니 참고해보면 좋을 것 같다. 이 글은 다른 환경 및 라이브러리를 사용했으므로 어떻게 대처하는지에 대한 참고만 해보자.
웹소켓에 대한 대략적인 내용을 봤다. 이후에는 웹소켓을 java에서 사용하는 간략한 예제와 함께 봐보자.
Java에서의 웹소켓 사용하기
@Configuration
@EnableWebSocketMessageBroker
public class CustomWebsocketConfig implements WebSocketMessageBrokerConfigurer{}
자바에서 웹소켓을 사용하기 위해서 위의 클래스와 어노테이션만 있어도 Stomp를 사용하는 Websocket을 사용할 수 있다. Spring이 인메모리를 사용해 메세지브로커 역할을 하는 것이다. 만약, 더 많은 기능이 필요하다면 외부 브로커(RabbitMQ 등)를 사용해서 설정을 하면된다.
자바에서는 Stomp를 사용하기를 여러 이유에서 권하고 있다. Stomp사용 이점
나는 websocket을 사용하기 위한 설정 뿐만 아니라,
HandshakeHandler와 HandshakeIntercepter, EventListener를 같이 사용했다.
웹소켓에 Principle 인터페이스를 주입할 수 있는데 스프링 시큐리티를 같이 사용하면 스프링 시큐리티에서 Security Context에서 Authentication객체를 가져와서 자동으로 주입해준다고 한다. 하지만 내가 만든 프로젝트에서는 security를 적용하지 않아서 직접 주입을 해야했고, 따라서 HandshakeHandler와 HandshakeIntercepter를 사용하게 되었다. EventListener는 웹소켓 연결이 끊겼을 때 동작을 구현해 두었다.
예시 코드
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class SimpleWebsocketConfig implements WebSocketMessageBrokerConfigurer {
private final CustomHandshakeHandler customHandshakeHandler;
private final WebsocketHandshakeInterceptor msgHandshakeInterceptor;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.addInterceptors(msgHandshakeInterceptor)
.setHandshakeHandler(customHandshakeHandler)
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}
@Component
@RequiredArgsConstructor
public class WebsocketHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
// 유저정보를 처리하여 attributes에 주입
// 웹소켓 연결시 쿼리스트링을 사용할 수 있어서 원하는 정보를 받을 수도 있다.
return true; // intercept를 사용하려면 true를 반환해야한다.
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
}
}
@Component
public class CustomHandshakeHandler extends DefaultHandshakeHandler {
@Override
public Principal determineUser(
ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
// attributes.get(유저정보);
// return Principle 구현체;
}
}
웹소켓을 사용하기 위해 위 3개의 클래스를 사용했다. 위에서 언급했듯 설정을 위한 cofigurer를 사용하고 유저정보 주입을 위한 handler와 intercepter를 사용했다.
유저정보가 주입되는 과정은
- 클라이언트에서 핸드쉐이크 요청
- 인터셉터에서 유저정보를 attribute에 추가
- 핸들러에서 attribute의 유저정보를 가져와서 websocket에 넣어준다.
유저를 넣는 과정이 이럴뿐이지 다른 정보도 넣을 수 있다.
코드 설명
가장 중요한 설정을 살펴보자.
엔드포인트 설정과 메세지브로커 설정이 있다.
먼저, 엔드포인트 설정
- 엔드포인트 : 웹소켓 클라이언트를 연결하기 위한 엔드포인트이다. 클라이언트에서 웹소켓에 연결하기 위해서 해당 엔드포인트가 필요하다. 엔드포인트는 1개만 되는 것이 아니라 다양한 엔드포인트를 설정할 수 있다.
- 핸들러, 인터셉터(선택): 각 엔드포인트에 추가할 인터셉터와 핸들러를 추가해주면 된다.
- sockJs : sockJs를 사용할 것이라면 withSockJS()를 붙여줘야 한다.
SockJs는 웹소켓뿐만 아니라, polling이나 HTTP Streaming을 선택적으로 연결해준다. 물론 주로 웹소켓을 연결해준다. Spring Doc에도 내용이 있다.
다음은 브로커 설정이다.
- 심플브로커("/topic") : 브로커를 찾기위한 접두어이다. 클라이언트에서 구독을 할 때 사용한다. 그래야 브로커가 받은 메세지를 전달해줄테니까. 1개말고 여러개를 추가할 수 있다. 스프링 예제에서는 "/queue"도 추가되어 있다.
- 어플리케이션프리픽스("/app") : 클라이언트에서 서버로 데이터를 보낼 때 사용한다. "/app" 이후의 Path를 찾아서 데이터를 처리한다. 각 목적지에 맞게 컨트롤러에서 찾아준다.
그러면 메세지를 받아야하는 서버 설정만 하면되는건가? 아니다. 아까 "/app"을 설정해주었다. 이 접두어에 보내진 메세지를 처리하는 로직이 필요하다.
컨트롤러에서 @MessageMapping을 사용하여 지정된 패스에 따라 메세지를 전달한다. Payload만 받아서 처리할 수도 있고, Path에서 변수를 받을 수도, principle을 사용할 수도 있다. 이외에도 더 있으니 필요한 파라미터를 받아서 사용하자.
@MessageMapping("패스/{id}")
public void roomSendMsg(@DestinationVariable(value = "id")Long id,
DTO클래스 Payload,
Principal user){
SimpMessagingTemplate template = new SimpMessagingTemplate();
String dest = "/topic" + destination;
template.convertAndSend(dest, message);
}
메세지를 보내는 방법은 SimpMessagingTemplate을 사용했다. 목적지로 생각하는 브로커 접두어에 목적지를 추가하고 데이터를 보내면 된다. 외부 브로커는 다를 수 있다. 참고하자.
클라이언트(브라우저)에서 연결
클라이언트에서는 어떻게 연결할까?
나는 Thymeleaf를 사용하여 html에 javascript로 직접 script를 작성했다. 따라서 javascript에서 사용할 수 있는 StompJS를 사용했다.
const sockjs = new SockJS(엔드포인트, {}, {});
// stompJs 버전 변경 후
const stompClient = new StompJs.Client({
webSocketFactory: () => sockjs
});
stompClient.onConnect = function (frame) {
stompClient.subscribe(`/topic`, function (data) {
// data를 처리하는 로직
}
}
stompClient.activate();
stompClient.publish({
destination: `/app/패스`,
body: JSON.stringify({ msg: msg })
})
구독과 메시지를 보내는 방법이다. 연결을 할 때 구독을 해주었다. 구독은 원하는
- 브로커 주소를 넣고
- 구독을 통해 데이터를 처리할 함수를 추가하면 된다.
그리고 activate는 해주도록 하자. 그래야 동작하니까.
예시에는 브로커에 "/topic"만 넣었으나, 서버측 컨트롤러에서 보면 destination이 추가되는 것을 볼 수 있다. 원하는 브로커를 잘 선택해야한다. 예를 들어 서버에서 메세지를 /topic/site로 보냈는데 구독을 /topic만 하면 데이터를 전달받을 수 없다. 구독 할 때 /topic/site를 추가해야한다.
반대로 메세지를 보낼때는 @MessageMapping 어노테이션에 맞게 입력해주어야 메세지가 제대로 전달된다. Payload(body)로 Json형태로 보냈으나, binary데이터도 보낼 수 있다. Stomp는 텍스트와 binary데이터를 지원한다.
이렇게 Websocket과 Stomp를 사용해서 아주 간단한 실시간채팅을 구현할 수 있다. 단순히 연결만 하는 것은 그다지 어려운 내용은 아니였다. 하지만 Handler와 intercepter가 어떻게 동작하는지, 웹소켓이 연결되는 과정, 데이터 크기 제한 등 다양한 문제를 다루었었고 완전히 해결한 문제, 부분적으로 해결한 문제 등 다사다난했다. 하지만 Stomp를 사용한 웹소켓은 매우 익숙하게 사용할 수 있게 되었다. 가장 힘들었던 것은 데이터 크기제한과 extension(Implementation-Specific Limits)과 관련한 것이었다. 부분적으로 밖에 해결하지 못한 것중 하나이다. 서버제한이나, 조각화 등 다양한 방법으로 접근해봐야 하는 부분이여서 당장 급하지않아서 차차 해결하면 될 것이다.
Java에서 웹소켓을 테스트하는 것도 다루어 보겠다.