WebSocket - STOMP

dragonappear·2022년 2월 21일
0

SpringBoot WebSocket

목록 보기
3/4


출처

제목: "[Spring Boot] WebSocket과 채팅 (3) - STOMP"
작성자: tistory(조용한고라니)
작성자 수정일: 2021년 4월 9일 
링크: https://dev-gorany.tistory.com/235
작성일: 2022년 2월 22일

들어가기에 앞서

  • 앞선 포스팅에서 SockJS를 적용해 WebSocket을 지원하지 않는 브라우저에서 서버와 클라이언트 간 통신이 끊기지 않고 채팅 기능을 수행하는 것 까지 확인하였다. 다만 채팅방이 여전히 하나만 존재한다는 점이 아쉽게 느껴진다.

  • 그리고 메시징 방식만 잘 정의한다면 WebSocket만으로 좋은 Server/Client 소켓 서버를 완성할 수 있으나, 단순한 통신 구조로 인해 WebSocket만을 이용해 채팅을 구현하면 해당 메세지가 어떤 요청인지, 어떻게 처리해야 하는지에 따라 채팅방과 세션을 일일이 구현하고 메세지 발송 부분을 관리하는 추가 코드를 구현해주어야 한다.


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

STOMP (Simple Text Oriented Messaging Protocol) 은 메세징 전송을 효율적으로 하기 위해 탄생한 프로토콜이고, 기본적으로 pub/sub구조로 되어있어 메세지를 전송하고 메세지를 받아 처리하는 부분이 확실히 정해져 있기 때문에 개발자 입장에서 명확하게 인지하고 개발할 수 있는 이점이 있다.

한 줄로 정의하자면, STOMP 프로토콜은 WebSocket 위에서 동작하는 프로토콜로써 클라이언트와 서버가 전송할 메세지의 유형,형식,내용들을 정의하는 메커니즘이다.

또한 STOMP를 이용하면 메세지의 헤더에 값을 줄 수 있어 헤더 값을 기반으로 통신 시 인증 처리를 구현하는 것도 가능하면 STOMP 스펙에 정의한 규칙만 잘 지키면 여러 언어 및 플랫폼 간 메세지를 상호 운영할 수 있다.

STOMP란?

  • STOMP는 TCP 또는 WebSocket 같은 양방향 네트워크 프로토콜 기반으로 동작한다.

  • 이름에서 알 수 있듯, STOMP는 TEXT 지향 프로토콜이나, Message Payload에는 Text or Binary 데이터를 포함할 수 있다.

  • 위에서 언급한 pub/sub 란 메세지를 공급하는 주체와 소비하는 주체를 분리해 제공하는 메세징 방법이다.

    • 기본 적인 컨셉을 예로 들자면 우체통(Topic)이 있다면 집배원(Publisher)이 신문을 우체통에 배달하는 행위가 있고, 우체통에 신문이 배달되는 것을 기다렸다가 전달받아 보는 구독자(Subscriber)의 행위가 있다.
    • 이 때 구독자는 다수가 될 수 있다.
    • pub/sub 컨셉을 채팅방에 빗대면 다음과 같다

채팅방 생성: pub/sub 구현을 위한 Topic이 생성됨
채팅방 입장: Topic 구독
채팅방에서 메세지를 송수신: 해당 Topic으로 메세지를 송신(pub),메세지를 수신(sub)

클라이언트는 메세지를 전송하기 위해 SEND, SUBSCRIBE COMMAND를 사용할 수 있다.

또한 SEND, SUBSCRIBE COMMAND 요청 Frame에는 메세지가 무엇이고, 누가 받아서 처리할지에 대한 Header 정보가 포함되어있다.

이런 명령어들은 destination 헤더를 요구하는데 이것을 어디에 전송할지, 혹은 어디에서 메세지를 구독할 것인지를 나타낸다.

위와 같은 과정을 통해 STOMP는 Publish-Subscribe 메커니즘을 제공한다.
즉, Broker를 통해 타 사용자들에게 메세지를 보내거나 서버가 특정 작업을 수행하도록 메세지를 보낼 수 있게 된다.
- 만약 Spring에서 지원하는 STOMP를 사용하면 Spring WebSocket 어플리케이션은 Broker로 동작하게 된다.

외부 브로커

Spring에서 지원하는 STOMP는 많은 기능을 하는데 예를 들어 Simple In-Memory Broker를 이용해 Subscribe 중인 다른 클라이언트들에게 메세지를 보내준다.
- Simple In Memory Broker는 클라이언트의 SUBSCRIBE 정보를 자체적으로 메모리에 유지한다.
- 또한 RabbitMQ, ActiveMQ같은 외부 메세징 시스템을 STOMP Broker로 사용할 수 있도록 지원한다.

구조적인 면을 보자면,

  • 스프링은 메세지를 외부 Broker에게 전달하고
  • Broker는 WebSocket으로 연결된 클라이언트에게 메세지를 전달하는 구조가 되겠다.

이와 같은 구조 덕분에 HTTP 기반의 보안 설정과 공통된 검증 등을 적용할 수 있게 된다.

STOMP는 HTTP에서 모델링되는 Frame 기반 프로토콜이다. Frame은 몇개의 Text Line으로 지정된 구조인데 첫 번째 라인은 Text이고 이후 Key:Value 형태로 Header의 정보를 포함한다. 다음 빈 라인을 추가하고 Payload가 존재 한다. 이 구조를 보면 HTTP 요청과 왜 유사한지 알 수 있다.

Frame 기반 프로토콜 참고
https://brunch.co.kr/@sangjinkang/3

COMMAND
header1:value1
header2:value2

Body^@
  • COMMAND: SEND,SUBSCRIBE를 지시할 수 있다.

  • header: 기존의 WebSocket으로는 표현이 불가능한 header를 작성할 수 있다.

    • destination : 이 헤더로 메세지를 보내거나(SEND), 구독(SUBSCRIBE)할 수 있다.
      • destination은 의도적으로 정보를 불분명하게 정의하였는데, 이는 STOMP 구현체에서 문자열 구문에 따라 직접 의미를 부여하도록 하기 위함이다.
      • 따라서 destination 정보는 STOMP 서버 구현체마다 달라질 수 있기 때문에 각 구현체의 스펙을 살펴보아야 한다.
      • 일반적으로 다음의 형식을 따른다.
"topic/.." --> publish-subscribe (1:N)
"queue/" --> point-to-point (1:1)
  1. ClientA가 5번 채팅방에 대해 구독을 하는 예시이다.
SUBSCRIBE
destination: /topic/chat/room/5
id: sub-1

^@
  1. ClientB에서 채팅 메세지를 보내는 예시이다
SEND
destination: /pub/chat
content-type: application/json

{"chatRoomId": 5, "type": "MESSAGE", "writer": "clientB"} ^@
  1. STOMP 서버는 모든 구독자에게 메세지를 Broadcasting 하기 위해 message command를 사용할 수 있다.
  • 서버는 내용을 기반(chatRoomId)으로 메세지를 전송할 broker에 전달한다.(topic을 sub로 보아도 될 것 같다.)
MESSAGE
destination: /topic/chat/room/5
message-id: d4c0d7f6-1
subscription: sub-1

{"chatRoomId": 5, "type": "MESSAGE", "writer": "clientB"} ^@


출처: https://supawer0728.github.io/2018/03/30/spring-websocket/

서버는 불분명한 메세지를 전송할 수 없다. 그러므로 서버의 모든 메세지는 특정 클라이언트 구독에 응답하여야 하고, 서버 메세지의 **subscription-id 헤더는 클라이언트 구독의 id** 헤더와 일치해야 한다.


Client Frames

SEND

  • SEND frame은 destination의 메세징 시스템으로 메세지를 보낸다.
  • 필수 헤더는 어디로 보낼지에 대한 destination 하나이다.
SEND
destination: /queue/a
content-type: text/plain

hello queue a
^@
  • SEND Frame은 body가 있는 경우 content-lengthcontent-type 헤더를 반드시 가져야만 한다.

SUBSCRIBE

  • SUBSCRIBE frame은 주어진 destination에 등록하기 위해 사용된다.
  • SEND frame과 마찬가지로 Subscribe는 client가 구독하기 원하는 목적지를 가리키는 destination 헤더를 필요로 한다.
  • 가입된 대상에서 수신된 모든 메세지는 이 후 Message frame로서 서버에서 클라이언트에게 전달된다.
SUBSCRIBE
id: 0
destination: /queue/foo
ack: client

^@
  • 단일 연결은 여러 개의 구독을 할 수 있으므로 구독 ID를 고유하게 식별하기 위해 id 헤더가 프레임에 포함되어야 한다.

  • 이외에도 아래 내용이 있지만 필요하면 찾아보자


출처: https://stomp.github.io/stomp-specification-1.2.html#Frames_and_Headers


STOMP Benefits

Spring framework 및 Spring Security는 STOMP 를 사용하여, WebSocket만 사용할 때보다 더 다채로운 모델링을 할 수 있다.

  • Messaging Protocol을 만들고 메세지 형식을 커스터마이징 할 필요가 없다.

  • RabbitMQ,ActiveMQ 같은 Message Broker를 이용해, Subscription(구독)을 관리하고 메세지를 브로드캐스팅할 수 있다.

  • WebSocket 기반으로 각 Connect(연결)마다 WebSocketHandler를 구현하는 것보다 @Controller 된 객체를 이용해 조직적으로 관리할 수 있다.

    • 즉, 메세지는 STOMP의 destination 헤더를 기반으로 @Controller 객체의 @MethodMapping 메서드로 라우팅 된다.
  • STOMP의 destination 및 message type을 기반으로 메세지를 보호하기 위해 스프링 시큐리티를 사용할 수 있다.


Enable STOMP

  • Spring은 WebSocket / SockJS를 기반으로 STOMP를 위해 spring-messagingspring-websocket 모듈을 제공한다.

  • 아래 예시와 같이 STOMP 설정을 할 수 있는데 기본적으로 커넥션을 위한 STOMP Endpoint를 설정해야만 한다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

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

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

    • WebSocket 또는 SockJs Client가 웹소켓 핸드셰이크 커넥션을 생성할 경로
  • /test

    • /test 경로로 시작하는 STOMP 메시지의 destination 헤더는 Controller 객체의 @MessageMapping 메서드로 라우팅된다.
  • 내장된 메세지 브로커를 사용해 Client에게 Subscriptions,Broadscasting 기능을 제공한다.

  • /topic, /queue로 시작하는 destination 헤더를 가진 메세지를 브로커로 라우팅 한다.

    • 내장된 Simple Message Broker/topic, /queue prefix에 대해 특별한 의미를 부여하지 않는다.

Using STOMP

  • SockJS로 브라우저에 연결하기 위해 sockjs-client를 이용할 수 있다.

  • STOMP에 있어 많은 어플리케이션들은 jmesnil/stomp-websocket( stomp.js로 알려진) 라이브러리를 사용해왔지만, 더이상 유지되지 않는다.

  • 최근에는 JSteunou/webstomp-client를 많이 사용한다.

var sock = new SockJS("/ws/chat");
var stomp = webstomp.over(sock);

stomp.connect({}, function(frame) {
}

/* WebSocket만 이용할 경우 */

var websocket = new WebSocket("/ws/chat");
var stomp = webstomp.over(websocket);

stomp.connect({}, function(frame) {
}

참고 링크:


Flow of Messages

STOMP Endpoint가 노출되고 나면, Spring 어플리케이션은 연결되어있는 Client들에 대해 STOMP 브로커가 된다.

(/app == /pub, /topic == /sub) 아래 그림은 내장 메세지 브로커를 사용한 경우 컴포넌트 구성을 보여준다.


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

spring-message 모듈은 Spring framework의 통합된 Messaging 어플리케이션을 위한 지원을 한다.

  • Message

    • headers와 payload를 포함하는 메세지의 표현
  • MessageHandler

    • Message 처리에 대한 계약
  • SimpleAnnotationMethod

    • @MessageMapping 등 Client의 Send를 받아서 처리한다.
  • SimpleBroker

    • Client의 정보를 메모리 상에 들고 있으며, Client로 메세지를 보낸다.
  • Channel

    • clientInBoundChannel

      • WebSocket Client로부터 들어오는 요청을 전달하며, WebSocketMessageBrokerConfigurer를 통해 intercept,taskExecutor를 설정할 수 있다.
      • 클라이언트로 받은 메세지를 전달
    • clientOutboundChannel

      • WebSocket Client로 Server의 메세지를 내보내며, WebSocketMessageBrokerConfigurer를 통해 intercept,taskExecutor를 설정할 수 있다.
      • 클라이언트에게 메세지를 전달
    • brokerChannel

      • 서버 내부에서 사용하는 채널이며, 이를 통해 SimpleAnnotationMethodSimpleBroker의 존재를 직접 알지 못해도 메세지를 전달할 수 있다.
      • 서버의 어플리케이션 코드 내에서 브로커에게 메세지를 전달

이것들 말고도 더 많은 내용이 있지만, 지금 다 보기에는 힘들 것 같다. 추후 Spring docs를 더 참고하도록 하고, 실제 코드를 짜서 구현해보도록 하자.


STOMP 적용

의존성 추가

// https://mvnrepository.com/artifact/org.webjars/stomp-websocket
implementation 'org.webjars:stomp-websocket:2.3.4'

WebSocketConfig -> StompWebSocketConfig

기존에 WebSocketConfigurer를 구현한 WebSocketConfig 설정파일을 WebSocketMessageBrokerConfigurer를 구현한 StompWebSocketConfig로 변경한다.

@Configuration
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {

    /*
    endpoint 를 /stomp 로 하고, allowedOrigins 를 "*"로 하면 페이지에서
    Get /info 4040 Error 가 발생한다.
    그래서 아래와 같이 2개의 계층으로 분리하고 origins 를 개발 도메인으로 변경하지 잘 동작한다.
     */

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/stomp/chat")
                .setAllowedOriginPatterns("http://localhost:8080")
                .withSockJS();
    }

    // 어플리케이션 내부에서 사용할 path 를 지정할 수 있음
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/pub");
        registry.enableSimpleBroker("/sub");
    }
}
  • @EnableWebSocketMessageBroker

    • STOMP 를 사용하기 위해 선언하는 어노테이션
  • setApplicationDestinationPrefixes

    • 클라이언트에서 SEND 요청을 처리
    • Spring docs에서는 /topic, /queue로 나오나 편의상 /pub,/sub로 변경
  • enableSimpleBroker

    • 해당 경로로 SimpleBorker를 등록
    • SimpleBroker는 해당하는 경로를 Subscribe하는 클라이언트에게 메시지를 전달하는 간단한 작업을 수행
  • enableStompBrokerRelay

    • SimpleBroker의 기능과 외부 Message Broker(RabbitMQ,ActiveMQ 등) 메세지를 전달하는 기능을 가진다.

ChatMessageDTO

@Data
public class ChatMessageDto {
    private String roomId;
    private String writer;
    private String message;
}

ChatRoomDTO

더 이상 하나의 채팅방에 옹기종기 모이는 것이 아닌 private한 공간에서 채팅할 수 있도록 채팅방을 만들어주자.

@Data
public class ChatRoomDto {
    private String roomId;
    private String name;
    private Set<WebSocketSession> socketSessions = new HashSet<>();
    // WebSocketSession 은 Spring 에서 Websocket Connection 이 맺어진 세션

    public static ChatRoomDto create(String name) {
        ChatRoomDto room = new ChatRoomDto();
        room.setRoomId(UUID.randomUUID().toString());
        room.setName(name);
        return room;
    }
}

ChatRoomRepository

  • 채팅방을 생성하고 정보를 조회하는 Repository를 생성한다. 지금은 간단히 구현해보고자 하는 것이 목표이므로 채팅방 정보를 Map Collection을 사용하여 관리하나, 실제 서비스 시 DB를 사용해 저장해야한다.
@Repository
public class ChatRoomRepository {
    private Map<String, ChatRoomDto> charRoomDtoMap;

    @PostConstruct
    private void init() {
        charRoomDtoMap = new LinkedHashMap<>();
    }

    public List<ChatRoomDto> findAllRooms() {
        // 채팅방 생성 순서 최근 순으로 반환
        ArrayList<ChatRoomDto> list = new ArrayList<>(charRoomDtoMap.values());
        Collections.reverse(list);

        return list;
    }

    public ChatRoomDto findRoomById(String id) {
        return charRoomDtoMap.get(id);
    }

    public ChatRoomDto createChatRoomDto(String name) {
        ChatRoomDto room = ChatRoomDto.create(name);
        charRoomDtoMap.put(room.getRoomId(), room);

        return room;
    }
}

StompChatController

@Controller
@RequiredArgsConstructor
public class StompChatController {

    // 특정 Broker 로 메세지를 전달
    private final SimpMessagingTemplate template;

    // Client 가 Send 할 수 있는 경로
    // stompConfig 에서 설정한 applicationDestinationPrefixes 와 @MessageMapping 경로가 병합됨
    // /pub/chat/enter
    @MessageMapping(value = "/chat/enter")
    public void enter(ChatMessageDto dto) {
        dto.setMessage(dto.getWriter() + "님이 채팅방에 참여하였습니다.");
        template.convertAndSend("/sub/chat/room/" + dto.getRoomId(), dto);
    }


    @MessageMapping(value = "/chat/message")
    public void message(ChatMessageDto dto) {
        template.convertAndSend("/sub/chat/room/" + dto.getRoomId(), dto);
    }
}
  • @MessageMapping을 통해 WebSocket으로 들어오는 메시지 발행을 처리한다.

    • 클라이언트에서는 prefix를 붙여 /pub/chat/enter로 발행 요청을 하면 컨트롤러가 해당 메세지를 받아 처리하는데, 메세지가 발행되면 "/sub/chat/room/{roomId}"로 메세지가 전송되는 것을 볼 수 있다.
  • 클라이언트에서는 해당 주소를 SUBSCRIBE하고 있다가 메세지가 전달되면 화면에 출력한다.

  • 이 때 /sub/chat/room/{roomId}는 채팅방을 구분하는 값이다.

  • 기존의 핸들러 ChatHandler의 역할을 StompChatController가 대신 해주므로 핸들러는 없어도 된다.

RoomController

@Controller
@RequiredArgsConstructor
@RequestMapping(value = "/chat")
@Slf4j
public class RoomController {

    private final ChatRoomRepository chatRoomRepository;

    // 채팅방 목록 조회
    @GetMapping("/rooms")
    public String rooms(Model model) {
        log.info("# All Chat Rooms");

        model.addAttribute("list", chatRoomRepository.findAllRooms());

        return "chat/rooms";
    }

    // 채팅방 개설
    @PostMapping(value = "/room")
    public String create(@RequestParam String name, RedirectAttributes redirectAttributes) {
        log.info("# Create Chat Room, name: [{}]", name);
        redirectAttributes.addFlashAttribute("roomName", chatRoomRepository.createChatRoomDto(name));
        return "redirect:/chat/rooms";
    }

    // 채팅방 조회
    @GetMapping("/room")
    public String getRoom(String roomId, Model model) {
        log.info("# get Char Room, roomId = [{}]", roomId);

        model.addAttribute("room", chatRoomRepository.findRoomById(roomId));
        model.addAttribute("username", UUID.randomUUID().toString());
        return "chat/room";
    }
}```

**View**
```html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">

<head>
    <title>hello</title>
</head>


<body>
<div class="container">

    <div class="col-6">
        <h1>[[${room.name}]]</h1>
    </div>

    <div>
        <div id="msgArea" class="col"></div>
        <div class="col-6">

            <div class="input-group mb-3">
                <input type="text" id="msg" class="form-control">
                <div class="input-group-append">
                    <button class="btn btn-outline-secondary" type="button" id="button-send">전송</button>
                </div>
            </div>

        </div>
    </div>




</div>

<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script th:inline="javascript">
            $(document).ready(function(){

                var roomName = [[${room.name}]];
                var roomId = [[${room.roomId}]];
                var username = [[${username}]];

                console.log(roomName + ", " + roomId + ", " + username);

                //1. SockJS를 내부에 들고있는 stomp를 내어줌
                var sockJs = new SockJS("/stomp/chat");
                var stomp = Stomp.over(sockJs);

                //2. connection이 맺어지면 실행
                stomp.connect({}, function (){
                   console.log("STOMP Connection")

                   //4. subscribe(path, callback)으로 메세지를 받을 수 있음
                   stomp.subscribe("/sub/chat/room/" + roomId, function (chat) {
                       var content = JSON.parse(chat.body);

                       var writer = content.writer;
                       var message = content.message
                       var str = '';

                       console.log("writer === username")

                       str = "<div class='col-6'>";
                       str += "<div class='alert alert-secondary'>";
                       str += "<b>" + writer + " : " + message + "</b>";
                       str += "</div></div>";

                       $("#msgArea").append(str);
                   });

                   //3. send(path, header, message)로 메세지를 보낼 수 있음
                   stomp.send('/pub/chat/enter', {}, JSON.stringify({roomId: roomId, writer: username}))
                });

                $("#button-send").on("click", function(e){
                    var msg = document.getElementById("msg");

                    console.log(username + ":" + msg.value);
                    stomp.send('/pub/chat/message', {}, JSON.stringify({roomId: roomId, message: msg.value, writer: username}));
                    msg.value = '';
                });
            });
</script>

</body>

</html>

TEST 결과

  • UI는 개나 줘버렸다..


정리

WebSocket -> SockJS -> STOMP 순으로 진행해보았다

  • WebSocket을 사용하면 Streaming,Polling 보다 실시간에 가깝게 처리할 수 있고, 트래픽이 줄어든다.
  • SockJS를 사용하면 WebSocket을 지원하지 않는 브라우저에서도 WebSocket Emulation을 이용해 웹소켓을 이용하는 것 처럼 동작하게 해준다.
  • STOMP를 사용하면 Session을 직접 관리하지 않아도 되고, 메세지의 처리 방식이 간편해진다.

0개의 댓글