스프링부트

spring boot websocket (웹소켓)

알쓸개잡 2023. 9. 20.

websocket 프로토콜인 RFC 6455는 단일 TCP 연결을 통해 클라이언트와 서버 간에 양방향 통신 채널을 구축하는 표준화된 방법을 제공한다. websocket 프로토콜은 HTTP와는 다른 TCP 프로토콜이지만 포트 80과 443을 사용하고 기존 방화벽 규칙을 재사용할 수 있도록 설계되어 HTTP에서 작동하도록 설계 되었다. 가장 대표적인 사용은 웹채팅으로써 HTTP 상에서 상호 데이터를 주고 받는데 사용된다. 이번 포스팅 에서는 spring framework 공식 문서의 websocket 관련 내용을 정리하고 spring boot에서 websocket을 사용하기 위한 구성 및 방법을 기록한다.

 

Upgrade Header

websocket 상호작용은 HTTP 요청에 업그레이드 헤더를 사용하여 WebSocket 프로토콜로 전환하여 이루어진다.

GET /spring-websocket-portfolio/portfolio HTTP/1.1
Host: localhost:8080
Upgrade: websocket      ---> upgrade header
Connection: Upgrade     ---> using upgrade connection
Sec-WebSocket-Key: Uc9l9TMkWGbHFD2qnFHltg==  ---> handshake 검증을 위한 코드
Sec-WebSocket-Protocol: v10.stomp, v11.stomp
Sec-WebSocket-Version: 13
Origin: http://localhost:8080

websocket을 지원하는 서버는 200 응답 코드 대신에 다음과 같은 응답을 전송한다.

HTTP/1.1 101 Switching Protocols  ---> 프로토콜 전환을 의미한다
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 1qVdfYHU9hPOl4JYYNXF623Gzn0=
Sec-WebSocket-Protocol: v10.stomp

위 websocket 사용을 위한 요청과 응답은 handshake 과정이다.

Sec-Web-Socket-Accept 헤더는 요청 헤더의 Sec-WebSocket-Key 값에 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" GUID 값을 붙여 sha1 -> base64를 한 값으로 셋팅하여 검증 처리를 한다.

websocket에 대한 규약에 대해서는 RFC 6455를 참고하면 도움이 된다.

 

HTTP vs WebSocket

websocket은 HTTP와 호환되도록 설계되었고 HTTP 요청으로 시작하지만, 두 프로토콜은 아키텍처와 애플리케이션 프로그래밍 모델이 매우 다르다. HTTP와 REST에서 애플리케이션은 여러 URL로 모델링 된다. 클라이언트는 애플리케이션과 상호 작용하기 위해 request-response 방식으로 URL에 액세스한다. 서버는 HTTP URL, 메서드, 헤더를 기반으로 요청을 적절한 핸들러로 라우팅한다.

 

이와는 대조적으로 websocket에서는 초기 연결에 대한 URL이 하나만 존재한다. 이후에는 모든 애플리케이션 메시지가 동일한 TCP 연결로 흐른다. 이는 완전히 다른 비동기 이벤트 중심 메시징 아키텍처를 가르킨다. websocket 클라이언트와 서버는 HTTP handshake 요청의 Sec-WebSocket-Protocol 헤더를 통해 더 높은 수준의 메시징 프로토콜(e.g. STOMP)을 사용하도록 할 수 있다. 이러한 프로토콜이 없는 경우에는 자체 규약을 만들어야 한다.

 

언제 WebSocket을 사용하는 것이 좋은가?

  • 협업, 게임, 금융 앱과 같은 실시간 처리가 중요한 애플리케이션 개발
  • 짧은 지연 시간, 높은 빈도, 대용량 메시지의 조합이 필요한 경우
  • 내부 애플리케이션간의 websocket 통신을 사용하는 경우

 

WebSocket API

Spring 프레임워크는 웹소켓 메시지를 처리하는 클라이언트 및 서버 측 애플리케이션을 작성하는 데 사용할 수 있는 웹 소켓 API를 제공한다.

 

WebSocketHandler 인터페이스

 websocket 서버는 WebSocketHandler를 직접 구현하거나 제공되는 WebSocketHandler 구현체를 통해서 구현할 수 있다.

WebSocketHandler 구현체

WebSocketHandler 인터페이스는 아래와 같다.

public interface WebSocketHandler {
    void afterConnectionEstablished(WebSocketSession session) throws Exception;

    void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception;

    void handleTransportError(WebSocketSession session, Throwable exception) throws Exception;

    void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception;

    boolean supportsPartialMessages();
}

 

WebSocket Configuration

websocket 핸들러를 특정 URL에 매핑하기 위한 전용 websocket 구성이 지원된다.

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

HTTP websocket handshake 요청을 사용자 정의하는 가장 쉬운 방법은 handshake 전과 후에 대한 메서드를 노출하는 HandshakeInterceptor를 사용하는 것이다. 이를 통해 handshake를 방지하거나 websocket 세션에서 사용할 수 있는 속성을 설정할 수 있다.

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
		registry.addHandler(new MyHandler(), "/myHandler")
			.addInterceptors(new HttpSessionHandshakeInterceptor());
	}

}
Spring은 WebSocketHandler를 wrapping하는 WebSocketHandlerDecorator 베이스 클래스를 제공한다. 로깅 및 예외 처리 구현은 WebSocket Configuration을 사용할 때 기본적으로 제공된다. ExceptionWebSocketHandlerDecorator는 WebSocketHandler 메서드에서 발생하는 모든 잡히지 않는 예외를 catch하고 서버 오류를 나타내는 1011로 WebSocket 세션을 닫는다.

 

Server Configuration

기본 websocket 엔진은 메시지 버퍼 크기, 유휴 시간 초과 등과 같은 런타임 특성을 제어하는 configuration 속성을 노출한다. Tomcat, WildFly, GlassFish의 경우 websocket configuration에 ServletServerContainerFactoryBean을 추가할 수 있다.

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

	@Bean
	public ServletServerContainerFactoryBean createWebSocketContainer() {
		ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
		container.setMaxTextMessageBufferSize(8192);
		container.setMaxBinaryMessageBufferSize(8192);
		return container;
	}

}
클라이언트 websocket 구성의 경우 ContainerProvider.getWebSocketContainer()를 사용해야 한다.

Jetty의 경우 사전 구성된 Jetty WebSocketServerFactory를 제공하고 WebSocket 구성을 통해 이를 Spring의 DefaultHandshakeHandler에 연결해야 한다.

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
		registry.addHandler(echoWebSocketHandler(),
			"/echo").setHandshakeHandler(handshakeHandler());
	}

	@Bean
	public DefaultHandshakeHandler handshakeHandler() {

		WebSocketPolicy policy = new WebSocketPolicy(WebSocketBehavior.SERVER);
		policy.setInputBufferSize(8192);
		policy.setIdleTimeout(600000);

		return new DefaultHandshakeHandler(
				new JettyRequestUpgradeStrategy(new WebSocketServerFactory(policy)));
	}

}

 

Allowed Origins

Spring 프레임워크 4.1.5부터 websocket 및 SockJS의 기본 동작은 동일한 출처의 요청만 허용하는 것이다. 모든 origin 또는 지정된 origin 목록을 허용할 수도 있다. 이 검사는 대부분 브라우저 클라이언트를 위해서 설계되었다. 다른 유형의 클라이언트가 origin 헤더 값을 수정하는 것을 막는 것은 없다.

 

세 가지 동작

  • 동일 출처 요청만 허용(기본값): 이 모드에서 SockJS가 활성화되면 Iframe HTTP 응답 헤더 X-Frame-Options가 SAMEORIGIN으로 설정되고 요청의 출처를 확인할 수 없으므로 JSONP전송이 비활성화 된다. 이 모드가 활성화 된 경우 IE6, IE7은 지원되지 않는다.
  • 지정된 origin 목록을 허용: 허용된 origin은 http:// 또는 https:// 로 시작해야 한다. 이 모드에서 SockJS가 활성화되면 IFrame 전송이 비활성화 된다. 이 모드가 활성화 된 경우 IE6 ~ IE9까지 지원되지 않는다.
  • 모든 origin 허용: 이 모드를 사용하려면 허용된 origin 값으로 *를 입력해야 한다. 이 모드에서는 모든 전송을 사용할 수 있다.

다음과 같이 WebSocket Configuration에서 허용 origin을 구성할 수 있다.

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")
			.setAllowedOrigins("https://mydomain.com");
	}

	@Bean
	public WebSocketHandler myHandler() {
		return new MyHandler();
	}

}

 

Text기반 simple websocket 서버 구현

지금까지의 내용을 기반으로 간단한 웹 소켓 서버를 구현해 본다.

 

Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

 

WebSocket Configuration

package com.example.websocket.config;

import com.example.websocket.handler.MVCWebSocketHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;

@Configuration
//websocket을 활성화 하기 위해서 지정하는 어노테이션
@EnableWebSocket
public class MVCWebSocketConfig implements WebSocketConfigurer {
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
		//웹 소켓 endpoint 등록
		//origin은 편의상 모두 허용
		registry.addHandler(mvcWebSocketHandler(), "/websocket")
			.addInterceptors(new HttpSessionHandshakeInterceptor())
			.setAllowedOrigins("*");
	}

	@Bean
	public WebSocketHandler mvcWebSocketHandler() {
		//websocket handler를 빈으로 생성하여 등록한다.
		return new MVCWebSocketHandler();
	}

	@Bean
	public ServletServerContainerFactoryBean createWebSocketContainer() {
		ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
		//Text Message의 최대 버퍼 크기 설정
		container.setMaxTextMessageBufferSize(8192);
		//Binary Message의 최대 버퍼 크기 설정
		container.setMaxBinaryMessageBufferSize(8192);
		return container;
	}
}

 

WebSocket Handler

package com.example.websocket.handler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
public class MVCWebSocketHandler extends TextWebSocketHandler {
	private final Map<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>();

	//websocket handshake가 완료되어 연결이 수립될 때 호출
	@Override
	public void afterConnectionEstablished(WebSocketSession session) throws Exception {
		log.info("connection established, session id={}", session.getId());
		sessionMap.putIfAbsent(session.getId(), session);
	}

	//websocket 오류가 발생했을 때 호출
	@Override
	public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
		log.error("session transport exception, session id={}, error={}", session.getId(), exception.getMessage());
		sessionMap.remove(session.getId());
	}

	//websocket 세션 연결이 종료되었을 때 호출
	@Override
	public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
		log.info("connection closed, sesson id={}, close status={}", session.getId(), status);
		sessionMap.remove(session.getId());
	}

	//websocket sessoin 으로 메시지가 수신되었을 때 호출
	@Override
	protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    	//getPayload를 통해 websocket 메시지 payload를 가져온다.
		String payload = message.getPayload();
		log.info("received message, session id={}, message={}", session.getId(), payload);
		//broadcasting message to all session
		sessionMap.forEach((sessionId, session1) -> {
			try {
				session1.sendMessage(message);
			} catch (IOException e) {
				log.error("fail to send message to session id={}, error={}",
					sessionId, e.getMessage());
			}
		});
	}
}

 

아주 심플한 websocket 서버이다. Postman을 통해서 websocket 테스트가 가능하다. new 버튼을 통해서 WebSocket 테스트 항목을 선택할 수 있다.

websocket 3개의 연결을 맺어 테스트를 해볼 것이다.

websocket 1
websocket 2
websocket 3

연결이 수립되면 Handshake Details 내용을 확인할 수 있다.

handshake details

서버 로그는 아래와 같다.

INFO 1480 --- [nio-8080-exec-1] c.e.w.handler.MVCWebSocketHandler        : connection established, session id=8cc551cb-5197-67c3-237a-b26bb4bfaa12
INFO 1480 --- [nio-8080-exec-2] c.e.w.handler.MVCWebSocketHandler        : connection established, session id=a27da8e2-968e-438a-b124-677f2cfd8a44
INFO 1480 --- [nio-8080-exec-3] c.e.w.handler.MVCWebSocketHandler        : connection established, session id=95f5bf15-aad7-c0e2-7257-d2bf46c5a4bf

각각의 세션에서 메시지를 전송하면 다른 모든 websocket session으로 메시지가 broadcasting 된다.

모든 websocket session으로 메시지 전송

 

Spring 에서 제공하는 spring-boot-start-websocket 을 통해서 간단히 websocket 서버 구현을 알아 보았다. 

예시 코드의 경우에는 websocket session을 thread safe 하도록 ConcurrentHashMap 으로 관리하였다.

물론 websocket session을 서버의 로컬 메모리로 관리하기 때문에 다중 서버 환경의 경우에는 사용할 수 없다.

다중 서버 환경의 경우 websocket STOMP를 이용하여 websocket 메시지를 message broker 를 통해서 각 websocket session 간에 메시지를 주고 받을 수 있는 방법이 있는데 아래 포스팅을 참고하기 바란다.

 

2023.10.11 - [스프링부트] - STOMP와 ACTIVEMQ를 이용한 메시지 broadcasting

댓글

💲 추천 글