Spring + WebSocket + STOMP

Yeseong31·2023년 8월 26일
0

Spring-WebSocket

목록 보기
3/5

WebSocket 프로토콜은 두 가지 유형의 메시지를 정의하고 있지만 그 메시지의 내용까지는 정의하고 있지 않다.


STOMP란?

Simple Text Oriented Messaging Protocol
텍스트 기반의 메시지 프로토콜

  • 메시지 브로커를 활용하여 Pub/Sub 방식으로 클라이언트와 서버가 쉽게 메시지를 주고 받을 수 있다.
    • 발신자가 메시지를 발생하면, 수신자가 그것을 수신하는 메시징 패러다임이다.
    • 메시지 브로커는 발신자가 보낸 메시지들을 받아서 수신자들에게 전달해 준다.

STOMP 프로토콜은 WebSocket만을 위해 만들어진 프로토콜이 아니다.

  • 중요한 점은 STOMP는 TCP, WebSocket과 같이 양방향 네트워크 프로토콜 기반으로 동작한다.
  • Spring은 WebSocket 위에 STOMP 프로토콜을 얹어서 사용하는 방법을 지원한다.


STOMP를 사용하는 이유

  • WebSocket

    • 메시지의 형식이 정해져 있지 않아서 프로젝트에서 정의된 메시지 형식대로 파싱하기 위해서는 로직을 따로 구현해야 한다.
    • 각 커넥션마다 WebSocketHandler 구현이 필요하다.
  • STOMP

    • 메시지의 유형, 형식, 내용들을 정의한 규칙(FRAME)을 사용해서 개발자가 메시지 형식에 대한 고민과 파싱 로직을 별도로 구현할 필요가 없다.
    • WebSocketHandler를 구현할 필요 없이 @Controller, @MessageMapping 애노테이션만 사용하면 된다.
      • @MessageMapping으로 메시지를 발행하면 엔드포인트를 별도로 분리하여 관리할 수 있다.
      • 메시지는 STOMP의 destination 헤더를 기반으로 @Controller 객체의 @MessageMapping 메서드로 라우팅된다.



STOMP Frame의 구조

STOMP는 커맨드, 헤더, 바디로 이루어진 Frame 단위를 정의해 두었다.

  • Frame은 몇 개의 텍스트 라인으로 지정된 구조를 가진다.
    • 첫 번째 라인Text이다.
    • 두 번째 라인key:value 형태로 header의 정보를 포함한다.
    • 다음 빈 라인을 추가하고 payload가 존재한다.

COMMAND
header1 : value1
header2 : value2

Body^@
  • 클라이언트는 메시지 전송을 위해 SEND, SUBSCRIBE COMMAND를 사용할 수 있다.
  • destination 헤더는 메시지를 어디에 전송(SEND)할지, 어디에서 메시지를 구독(SUBSCRIBE)할지를 나타낸다.

destination은 의도적으로 정보를 불분명하게 정의했다.
이는 STOMP 구현체에서 문자열 구문에 따라 직접 의미를 부여하도록 하기 위함이다.

일반적으로는 다음의 형식을 따른다.

  • topic/... -> publish-subscribe(1:N)
  • queue/... -> point-to-point(1:1)


Frame 구성 요소

  • COMMAND

    • 메시지의 타입을 나타내는 문자열
    • SEND, SUBSCRIBE, UNSUBSCRIBE
  • header

    • 추가 정보를 제공하는 헤더
    • destination 헤더로 메시지를 보내거나(SEND), 구독(SUBSCRIBE)할 수 있음
  • Body

    • 메시지의 내용
  • ^@

    • Body의 끝(NULL 문자)



스프링의 STOMP 동작

스프링에서 지원하는 STOMP는 많은 기능을 한다.
예를 들어 Simple In-Memory Broker를 이용해 SUBSCRIBE 중인 다른 클라이언트에게 메시지를 보낸다.

  • 메시지를 보내는 발신자(Publisher), 메시지를 받으려는 구독자(Subscriber)가 있다.

  • 구독자/topic이라는 경로로 구독을 하고 있다.

  • 발신자/topicdestination 헤더로 넣고, 메시지를 메시지 브로커를 통해 구독자에게 송신할 수 있다.

  • 서버에서 데이터 가공이 필요하다면 /app 경로로 메시지를 송신할 수 있다.

    • 가공이 끝난 데이터는 /topic이라는 경로에 담아 메시지 브로커에 전달한다.
    • 메시지 브로커는 전달받은 메시지를 /topic을 구독하는 구독자들에게 전달한다.



Spring + WebSocket + STOMP


메시지 스펙 변경 MessageDto

@Getter
@Setter
@NoArgsConstructor(access = PROTECTED)
public class MessageDto {
    
    private MessageType type;    // 메시지 타입
    private String sender;       // 보내는 사람
    private String channelId;    // 채널 ID
    private String message;      // 메시지
    private LocalDateTime time;  // 채팅 발송 시간
    
    @Builder
    public MessageDto(
            MessageType type, String sender, String channelId, String message, LocalDateTime time) {
        
        this.type = type;
        this.sender = sender;
        this.channelId = channelId;
        this.message = message;
        this.time = time;
    }
}
  • 기존 WebSocket을 사용할 때에는 1:1 채팅이었기 때문에 받는 사람을 DTO에 지정해 주었다.
  • 이번에는 여러 사람이 속해 있는 채널에 메시지를 전송할 것이므로 channelId를 DTO에 포함시켰다.

WebSocketMessageBrokerConfig 추가

@Configuration
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
    
    /**
     * 엔드포인트 등록
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // stomp 접속 URL: "/ws-stomp" 
        registry
                .addEndpoint("/ws-stomp")
                .setAllowedOrigins("*");
    }
    
    /**
     * 메시지 브로커 설정
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 메시지 구독 요청 URL -> SUBSCRIBE하는 클라이언트에게 메시지 전달
        registry.enableSimpleBroker("/sub");
        // 메시지 발행 요청 URL -> 클라이언트에서 SEND 요청 처리
        registry.setApplicationDestinationPrefixes("/pub");
    }
}
  • @EnableWebSocketMessageBroker 애노테이션을 달고, WebSocketMessageBrokerConfigurer 인터페이스를 구현한다.

  • registerStompEndpoints() 메서드는 WebSocket의 handler와 유사하다.

    • 웹 소켓 서버의 엔드포인트는 /ws-stomp로 정의했다.
    • 추가적으로 CORS, SockJS 설정도 이곳에서 할 수 있다.
  • configureMessageBroker() 메서드는 STOMP에서 사용하는 메시지 브로커를 설정하는 메서드이다.

    • 클라이언트 사용자의 구독 경로는 /sub/channel/{channelId}로 정의한다.
    • 메시지를 발송할 때에는 /pub/message로 메시지를 보내도록 한다.
      • 이때 메시지에는 채널 아이디인 channelId를 포함해야 한다.

WebSocket과는 달리 STOMP는 하나의 연결 주소마다 핸들러 클래스를 따로 구현할 필요 없이 Controller 방식으로 간편하게 사용할 수 있다.




외부 메시지 브로커가 필요한 이유

지금까지의 코드를 살펴봤을 때 메시지 브로커, 메시지 큐는 스프링 부트 서버의 내부 메모리에 존재한다.
다시 말하면 WebSocket 서버가 다수일 때에는 정상적으로 동작하지 않는다.

  • 여러 서버에 구독자가 나뉘어 분포되어 있다고 하면 같은 채널을 구독하더라도 해당 채널에 대한 메시지를 모든 사용자가 받을 수 없는 상황이 발생한다.

해결 방안

  • 사용자가 어떤 서버에 접속하고 있는지 기억한다.
  • 또는 외부 메시지 브로커에서 메시지 큐를 관리한다.

인메모리 기반 시스템은 메시지 유실 가능성이 있다.

서버가 down 뒤에서 메시지 전송을 하지 못했다면, 큐는 인메모리 기반으로 동작하므로 메시지는 유실될 것이다. 하지만 외부 메시지 브로커를 사용하고 있었다면, 서버 재실행 시에도 외부 브로커에 저장 중인 큐에 대기 중인 메시지를 수신할 수 있다.

다만, 무조건 외부 브로커 연동을 하는 것은 좋지 않다. 외부 메시지 브로커를 사용하면 인프라 비용이 증가하므로 여러 상황을 고려해서 시스템 아키텍처를 잘 구성해야 한다.

profile
역시 개발자는 알아야 할 게 많다.

0개의 댓글