본문 바로가기
Spring

스프링과 웹소켓

by hseong 2023. 5. 1.

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#websocket

본 게시글에서는 스프링 공식문서의 WebSockets 파트를 읽고 정리합니다.

 

WebSocket

웹소켓 프로토콜은 단일 TCP 연결을 이용하여 클라이언트-서버 간의 양방향 통신 채널을 수립하는 표준화된 방법을 제공합니다.

기존 HTTP와는 다른 TCP 프로토콜입니다. 그러나 HTTP 위에서 작동하도록 디자인되어 80, 443 포트를 사용하고 기존의 방화벽 규칙을 재사용할 수 있도록 설계되었습니다.

 

웹소켓은 HTTP를 웹소켓 프로토콜로 전환하기위한 HTTP 요청부터 시작합니다.

GET /spring-websocket-portfolio/portfolio HTTP/1.1
Host: localhost:8080
Upgrade: websocket (1)
Connection: Upgrade (2)
Sec-WebSocket-Key: Uc9l9TMkWGbHFD2qnFHltg==
Sec-WebSocket-Protocol: v10.stomp, v11.stomp
Sec-WebSocket-Version: 13
Origin: http://localhost:8080
HTTP/1.1 101 Switching Protocols (1)
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 1qVdfYHU9hPOl4JYYNXF623Gzn0=
Sec-WebSocket-Protocol: v10.stomp

요청에 (1) Upgrade와 (2) Connection 헤더를 통해 웹소켓을 사용할 것을 서버에 알리면 웹소켓을 지원하는 서버는 아래와 같이 (2) 프로토콜이 스위칭 되었음을 알리는 응답을 반환합니다.

Handshake를 통해 TCP 연결이 수립된 이후 양쪽 모두 메시지를 주고받을 수 있도록 연결은 종료되지 않습니다.

 

언제 웹소켓을 이용해야 하는가?

공식 문서에 따르면 동적인 웹페이지를 제공하고자 할 때, 대부분의 경우는 AJAX와 HTTP 스트리밍, 긴 폴링의 조합으로 간단한 솔루션을 제공할 수 있습니다.

웹소켓은 실시간성이 강력하게 요구되는 분야, 짧은 지연 시간, 높은 빈도, 대용량 메시지가 조합될 때 가장 사용하기 적절하다고 합니다.

 

스프링과 웹소켓

STOMP

웹소켓 프로토콜은 텍스트, 바이너리 유형의 메시지를 정의하지만, 내용에 대한 형식은 정의되지 않습니다.

STOMP 프로토콜은 클라이언트-서버가 전송할수 있는 메시지의 종류, 형식, 각 메시지의 내용 등을 정의하기 위해 하위 프로토콜(higher-level messaging protocol)을 협상할 수 있는 메커니즘을 정의합니다.

 

STOMP는 프레임 기반 프로토콜로 프레임은 HTTP를 기반으로 모델링됩니다. STOMP 프레임의 구조는 다음과 같습니다.

COMMAND
header1:value1
header2:value2

Body^@

클라이언트는 SEND 또는 SUBSCRIBE 명령을 사용하여 메시지의 내용 및 수신 대상을 설명하는 destination 헤더와 함께  메시지를 보내거나 구독할 수 있습니다.

이는 간단한 publish-subscribe 메커니즘을 가능하게 하고, 브로커를 이용해 연결된 다른 클라이언트로 메시지를 보내거나 서버에 특정 작업을 수행하도록 하는 요청을 전송할 수 있습니다.

 

Spring의 STOMP 지원을 사용하는 경우, 스프링 애플리케이션은 클라이언트에 대한 STOMP 브로커 역할을 수행합니다. 메시지는 @Controller 의 message-handling methods나, subsciptions을 추적하고 구독한 사용자에게 메시지를 브로드캐스트하는 simple in-memory broker로 라우팅됩니다.

실제 메시지를 브로드캐스트하는데 RabbitMQ와 같은 특정한 STOMP broker를 이용할 수도 있습니다. 이 경우 Spring은 broker에 대한 TCP connection을 유지하고, 메시지를 릴레이하며, broker의 메시지를 연결된 웹소켓 클라이언트들에게 전달합니다.

그러므로, 스프링 웹 애플리케이션은 HTTP 기반의 보안, 공통 validation, message handling을 위한 친숙한 프로그래밍 모델을 사용할 수 있습니다.

 

Enable STOMP

implementation 'org.springframework.boot:spring-boot-starter-websocket'

build.gradle에 websocket starter 종속성을 추가해줍니다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app");
        registry.enableSimpleBroker("/topic", "/queue");
    }
}
  • addEndpoint("/ws")
    • /ws 는 웹소켓 핸드셰이크를 위해 클라이언트가 연결해야 하는 HTTP URL입니다.
  • setApplicationDestinationPrefixe("/app")
    • destination 헤더가 /app 으로 시작하는 STOMP 메시지는 @Controller 클래스의 @MessageMapping 메서드로 라우팅됩니다.
  • enableSimpleBroker("/topic", "/queue")
    • 메서드를 통해 스프링이 기본 제공하는 메시지 브로커를 사용하고 destination 헤더가 /topic, /queue 로 시작하는 메시지를 브로커로 라우팅합니다.
    • 스프링이 제공하는 메시지 브로커의 경우 /topic, /queue 라는 prefix가 별도의 의미를 가지지는 않습니다. 이는 단순히 topic(pub-sub), queue(point-to-point) 라는 일반적인 규칙을 구분하기 위해서 사용한다고 합니다.
    • 외부 브로커를 사용하는 경우 해당 브로커의 STOMP 페이지를 확인해서 어떤 종류의 STOMP destination과 prefix를 지원하는지 확인할 필요가 있습니다.

 

메시지의 흐름

STOMP 엔드포인트가 노출되면 스프링 애플리케이션은 연결된 클라이언트를 위한 STOMP 브로커가 됩니다.

위 다이어그램에는 3가지의 메시지 채널이 존재합니다.

  • clientInboundChannel: 웹소켓 클라이언트로부터 받은 메시지를 전달하기 위한 채널
  • clientOutboundChannel: 서버 메시지를 웹소켓 클라이언트로 보내기 위한 채널
  • brokerChannel: 서버 측 애플리케이션 코드 내에서 메시지 브로커로 메시지를 보내기 위한 채널

다음 다이어그램은 메시지의 구독과 브로드캐스팅에 외부 브로커를 구성할 때의 구성 요소입니다.

주요한 차이점으로는 TCP를 통해 외부 STOMP 브로커로 메시지를 전달하고 구독한 클라이언트들에게 메시지를 전달할 때, 브로커 릴레이를 사용한다는 점입니다. 

웹소켓 연걸에서 메시지를 수신하면, 메시지는 STOMP 프레임으로 디코딩되고, Spirng Message 표현으로 변환된 후, clientInboundChannel로 전송됩니다.

예를 들어, destination 헤더가 /app 으로 시작하는 STOMP 메시지는 @MessageMapping 메서드로 라우팅될 수 있고, /topic 또는 /queue 메시지는 메시지 브로커로 직접 라우팅될 수 있습니다.

 

클라이언트의 STOMP 메시지를 처리하는 컨트롤러는 brokerCahnnel 을 통해 메시지 브로커로 메시지를 보낼 수 있습니다. 그리고 브로커는 clientOutboundChannel을 통해 매칭되는 구독자들에게 메시지를 브로드캐스트합니다.

 

공식 문서의 예제에 따라 메시지의 흐름을 다시 한 번 정리해보겠습니다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app");
        registry.enableSimpleBroker("/topic", "/queue");
    }
}

@Controller
public class GreetingController {

    @MessageMapping("/greeting")
    public String handle(String greeting) {
        return "[" + LocalDateTime.now() + ": " + greeting;
    }
}
  1. 클라이언트는 http://localhost:8080/ws 에 연결하고 웹소켓 연결이 수립되면, STOMP 프레임이 이 연결로 흐리기 시작합니다.
  2. 클라이언트는 destination 헤더가 /topic/greeting 인 SUBSCRIBE 프레임을 전송합니다. 수신 및 디코딩이 완료되면, 메시지는 clientInboundChannel 로 전송되고, 클라이언트 구독을 저장하는 메시지 브로커로 라우팅됩니다.
  3. 클라이언트는 /app/greeting 으로 SEND 프레임을 전송합니다. /app prefix는 컨트롤러로 라우팅되는데 도움을 줍니다. /app prefix가 제거된 후, /greeting 부분은 GreetingController의 @MessageMapping 메서드에 매핑됩니다.
  4. GreetingController에서 반환된 값은 반환값에 기반한 payload 와 기본 destination 헤더인 /topic/greeting을 포함하는 Spring Message로 변환됩니다. 결과 메시지는 brokerChannel 로 전송되고, 메시지 브로커에 의해 처리됩니다.
  5. 메시지 브로커는 매칭하는 모든 구독자를 찾고 clientOutboundChannel을 통해 각 구독자에게 MESSAGE 프레임을 전송합니다. 메시지는 STOMP 프레임으로 인코딩되고 웹소켓 연결로 전송됩니다.

 

@MessageMapping

메시지 라우팅을 위해 @MessageMappng 어노테이션을 메서드에 사용할 수 있습니다.

/thing/{id} 과 같이 템플릿 변수를 참조하기 위해서는 @DestinationVariable 메서드 인수를 이용하여 참조할 수 있습니다. 또한, 매핑을 위해 점으로 구분된 destination convention 으로 변경할 수도 있습니다.

지원하는 메서드 인수는 아래 링크에서 확인 가능합니다.

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#websocket-stomp-message-mapping

 

Return Values

기본적으로 @MessageMapping 메서드의 반환 값은 매칭되는 MessageConverter르 통해 페이로드로 직렬화되고, Message 로써 brokerChannel에 전송됩니다. 이후 구독자들에게 브로드캐스트됩니다. outbound 메시지의 목적지는 inbound 메시지의 목적지와 동일하지만 접두사 앞에 /topic 이 붙습니다.

 

메시지의 목적지를 지정하는데 @SendTo, @SendToUser 어노테이션을 사용할 수 있습니다. 이런 어노테이션은 SimpMessaingTemplate 을 사용하여 메시지를 전송하는데 편의를 제공하기 위한 것입니다. 따라서 SimpMessagingTemplate 을 직접 사용하여 메시지를 전송할 수 있습니다.

 

@MessageExceptionHandler

해당 어노테이션을 사용하여 @MessageMapping 메서드의 예외를 처리할 수 있습니다.

SpringMVC 와 유사하게 @ControllerAdvice 클래스에서 이용하여 처리할 수도 있습니다.

 

메시지 전송하기

SimpMessagingTemplate 을 주입받아 사용하면 간단하게 목적지를 지정하고, 메시지를 전송할 수 있습니다.

@Controller
@RequiredArgsConstructor
public class GreetingController {

    private final SimpMessagingTemplate template;

    @MessageMapping("/greeting")
    public void handle(String greeting) {
        template.convertAndSend("/topic/greetings", "["+greeting+"]");
    }
}

 

Simple Broker

스프링이 기본 제공하는 메시지 브로커는 메모리에서 클라이언트의 구독 요청을 처리합니다. 그리고 매칭하는 연결된 클라이언트들에게 메시지를 브로드캐스트합니다.

 

외부 브로커

기본 제공 브로커는 단순한 메시지 전송 루프에 의존하고 클러스터링에는 적합하지 않습니다.

선택한 메시지 브로커의 STOMP 문서를 참조하여 STOMP 지원을 활성화하여 실행합니다. 그리고 Spring configuration 에서 STOMP 브로커 릴레이를 활성화할 수 있습니다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app");
        registry.enableStompBrokerRelay("/topic", "/queue");
    }
}

위 configuration 의 STOMP 브로커 릴레이는 외부 메시지 브로커로 메시지를 전달하여 메시지를 처리하는 Spring MessageHandler 입니다.

브로커에 대한 TCP 연결을 설정하고, 모든 메시지를 브로커로 전달하고, 브로커로부터 받은 모든 메시지를 웹소켓 세션을 통해 클라이언트로 전달합니다. 

TCP 연결 관리를 위해서는 아래의 종속성을 추가해야 한다고 합니다.

io.projectreactor.netty:reactor-netty
io.netty:netty-all

 

점 구분자

@MessageMapping 메서드를 이용할 때, 기적으로 " / " 를 구분자로 사용합니다. 메시징 규칙에 익숙하다면 " . " 을 구분자로 이용하도록 설정할 수 있습니다.

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.setPathMatcher(new AntPathMatcher("."));
    registry.setApplicationDestinationPrefixes("/app");
    registry.enableStompBrokerRelay("/topic", "/queue");
}

 

성능

clientOutboundChannel 쪽에서는 웹소켓 클라이언트에 메시지를 보내는 것이 전부입니다.

클라이언트가 빠른 네트워크에 있는 경우, 쓰레드의 수는 가용한 프로세서의 수와 비슷하게 유지되어야 합니다. 속도가 느리거나 대역폭이 낮으면 메시지를 consume 하는데 시간이 오래 걸리고 쓰레드 풀에 부담을 줍니다. 따라서 쓰레드 풀 사이즈를 늘려야 합니다.

 

clientInboundChannel 의 워크로드는 애플리케이션이 수행하는 작업에 따라 예측이 가능하지만, clientOutboundChannel 을 구성하는 방법은 애플리케이션이 제어 할 수 없는 요소에 따라 달라지기 때문에 더 어렵습니다.

때문에 메시지 전송과 관련된 두 가지 추가 요소인 sendTimeLimit 와 sendBufferSizeLimit 가 있습니다. 이러한 메서드를 사용하여 클라이언트에 메시지를 보낼 때 전송이 허용되는 시간과 버퍼링할 수 있는 데이터의 양을 구성할 수 있습니다.