Stomp와 SockJS

Jinjin·2024년 1월 22일
0
post-thumbnail

Q. WebSocket이 아닌 Stomp를 사용하는 이유는❓

A. WebSocket은 Text 또는 Binary 두 가지 유형의 메세지 타입은 정의하지만 메세지의 내용에 대해서는 정의하지 않습니다. 즉, WebSocket만 이용하면 해당 메세지가 어떤 요청인지? 어떤 포맷으로 오는지? 메세지 통신 과정을 어떻게 처리해야 하는지? 정해져 있지 않아서 일일이 구현해야 합니다. 따라서, Stomp라는 프로토콜을 서브 프로토콜로 사용합니다.
Stomp는 클라이언트와 서버가 서로 통신하는 데 있어 메세지의 형식, 유형, 내용 등을 정의해주는 프로토콜로 단순한 binary, text가 아닌 규격을 갖춘 메세지 입니다.

🔎Stomp


: simple/Stream Text Oriented Message Protocol의 약자로, 메세지 브로커의 역할을 한다.

  • 특징
    • WebSocket 기반으로 동작하며 pub/sub 구조로 되어있다. ⇒ pub/sub 구조는 쉽게 말해 편지를 쓰는 사람(Publisher)이 편지함에 편지를 넣어두면 그걸 기다리고 있는 편지를 받는 사람(Subscriber)가 편지를 읽는 구조이다. Ex) 채팅방 생성 : pub/sub 구현을 위한 Topic 생성 채팅방 입장 : Topic 구독 채팅방에서 메세지를 송수신 : 해당 Topic으로 메세지를 송신(pub) 혹은 수신(Sub)
  • 형식
    COMMAND
    header1:value1
    header2:value2
  • 예시
    1. 철수, 민희, 유리, 수호 라는 사용자가 2번 방에 입장합니다.

    2. 철수가 2번 방에서 “안녕? 내가 방을 만들었음” 이라고 채팅을 전송합니다.

    3. 2번 방 메세지 브로커(중재자)가 메세지를 받습니다.

    4. 2번 방 메세지 브로커가 2번 방 구독자들(철수, 민희, 유리, 수호)에게 메세지를 전송합니다.

      ⇒ 또, 다른 사용자가 방에 메세지를 보내도 메세지 브로커가 인식하고 구독하는 다른 사용자들에게 해당 메세지를 모두 전송합니다.

    • 예시에 대한 흐름 코드
      1. 사용자들은 채팅방에 입장하면서 동시에 2번 채팅방에 대해 구독을 하게 되고, 메세지 브로커는 클라이언트의 구독 정보를 자체적으로 메모리에 유지합니다.

        SUBSCRIBE
        destination:/subscribe/chat/room/2
      2. 아래와 같이 어떤 유저가 메세지를 보내면, 메세지 브로커는 SUBSCRIBE 중인 다른 유저들에게 메세지를 전달합니다.

        SEND
        content-type:application/json
        destination:/publish/chat
        
        {"chatRoomId":2, "type": "MESSAGE", "writer":"Cheolsoo"}
  • 프로젝트 기반으로 코드 설명(실시간으로 원본 그림과 client로 부터 받은 그림의 유사도를 비교하는 기능)
    • StompConfig.java
      package com.yehah.draw.global.config;
      
      import lombok.extern.slf4j.Slf4j;
      import org.springframework.beans.factory.annotation.Value;
      import org.springframework.context.annotation.Configuration;
      import org.springframework.messaging.simp.config.MessageBrokerRegistry;
      import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
      import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
      import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
      
      @Slf4j
      @EnableWebSocketMessageBroker
      @Configuration
      public class StompConfig implements WebSocketMessageBrokerConfigurer {
      
          @Override
          public void registerStompEndpoints(StompEndpointRegistry registry){
              registry.addEndpoint("/ws/draws/comm-similarity")
                      .setAllowedOrigins("*").withSockJS();
          }
          
          @Override
          public void configureMessageBroker(MessageBrokerRegistry registry){
              registry.setApplicationDestinationPrefixes("/pub"); 
              registry.enableSimpleBroker("/sub"); 
          }
      
      }
      http://domain이름/ws/draws/comm-similarity 으로 요청을 보내서 http통신에서 ws통신으로 변경한다.
    • StompController.java
      package com.yehah.draw.global.stomp;
      
      import com.yehah.draw.global.common.AnimalType;
      import com.yehah.draw.global.stomp.dto.MessageResponse;
      import lombok.RequiredArgsConstructor;
      import lombok.extern.slf4j.Slf4j;
      import org.springframework.messaging.handler.annotation.DestinationVariable;
      import org.springframework.messaging.handler.annotation.MessageMapping;
      import org.springframework.messaging.simp.SimpMessagingTemplate;
      import org.springframework.stereotype.Controller;
      
      @Slf4j
      @RequiredArgsConstructor
      @Controller
      public class StompController {
      
          private final SimpMessagingTemplate messagingTemplate;
      
          // roomId는 FE에서 랜덤으로 생성해서 알려주기
          @MessageMapping("/first-enter/{animalType}/{roomId}")
          public void firstEnter(@DestinationVariable("animalType") AnimalType animalType, @DestinationVariable("roomId") String roomId){
              log.info("{} 방의 연결을 시작합니다", roomId);
      
              messagingTemplate.convertAndSend("/sub/room/"+roomId,
                      MessageResponse.builder()
                              .roomId(roomId)
                              .animalType(animalType)
                              .responseState(ResponseState.SUCCESS)
                              .message(roomId + "방의 연결을 시작합니다.").build());
          }
      
          @MessageMapping("/similarCheck/{animalType}/{roomId}")
          public void similarcheck(@DestinationVariable("animalType")AnimalType animalType, @DestinationVariable("roomId") String roomId){
              log.info("{} 방의 유사도 검사를 시작합니다.", roomId);
      
              messagingTemplate.convertAndSend("/sub/room/"+roomId,
                      MessageResponse.builder()
                              .roomId(roomId)
                              .animalType(animalType)
                              .responseState(ResponseState.SUCCESS)
                              .message(roomId + "방의 유사도 검사를 시작합니다.").build());
          }
      }
      • firstEnter() : client 측에서 ws 통신에서 /pub/first-enter/{animalType}/{roomId} 로 어떤 유형의 방을 생성할 것인지 알려준다. (animayType은 animal, friendsAnimal, tale가 있으며 roomId는 client 측에서 랜덤으로 값을 보내준다. 따라서, animalType이 tale이면 동화 그리기에서 유사도를 진행한다는 뜻이다) 그 후, /sub/room/roomId로 해당 방을 구독하고 있는 곳에 "방의 연결을 시작합니다" 라고 보내는 방식이다.
      • similarcheck() : client 측에서 ws 통신으로 /pub/similarCheck/{animalType}/{roomId} 로 방의 유사도 검사를 진행한다. 그 후, /sub/room/roomId로 해당 방을 구독하고 있는 곳에 "방의 유사도 검사를 시작합니다" 라고 보내는 방식이다.
    • http 통신으로 유사도 검사에 사용될 데이터를 보내면서 ws으로 검사도 요청함 ⇒ similarcheck()
      @Operation(summary = "친구의 유사도를 확인한다.", description = "ALL")
          @PostMapping("/similarcheck")
          public ResponseEntity<Void> animalSimilarCheck(@ModelAttribute AnimalSimilarReqDto animalSimilarReqDto) throws IOException {
              similarCheckProcessor.similarCheck(animalSimilarReqDto.getRoomId(), animalSimilarReqDto.getOriginalFile(), animalSimilarReqDto.getNewFile()
              ,animalSimilarReqDto.getComparisonValue(), AnimalType.animal);
              return ResponseEntity.ok().build();
          }
    • SimilarCheckProcessor.java
      package com.yehah.draw.global.Processor;
      
      import com.yehah.draw.domain.animal.entity.SimilarState;
      import com.yehah.draw.global.common.AnimalType;
      import com.yehah.draw.global.stomp.ResponseState;
      import com.yehah.draw.global.stomp.StompService;
      import com.yehah.draw.global.stomp.dto.MessageResponse;
      import com.yehah.draw.global.stomp.dto.SimilarMessageResponse;
      import jakarta.annotation.PostConstruct;
      import lombok.RequiredArgsConstructor;
      import lombok.extern.slf4j.Slf4j;
      import org.springframework.beans.factory.annotation.Value;
      import org.springframework.context.annotation.Configuration;
      import org.springframework.util.LinkedMultiValueMap;
      import org.springframework.util.MultiValueMap;
      import org.springframework.web.multipart.MultipartFile;
      
      @Slf4j
      @Configuration
      @RequiredArgsConstructor
      public class SimilarCheckProcessor {
      
          @Value("${micro.path.similarityCheck}")
          private String similarityPath;
      
          private final CommunicationProcessor communicationProcessor;
      
          private final StompService stompService;
      
          public void similarCheck(String roomId, MultipartFile originalFile, MultipartFile newFile
                  ,double comparisonValue, AnimalType animalType){
              MultiValueMap<String, Object> bodyData = new LinkedMultiValueMap<>();
              bodyData.add("roomId", roomId);
              bodyData.add("originalFile", originalFile.getResource());
              bodyData.add("newFile", newFile.getResource());
      
              String stompUrl = "/sub/room/"+roomId;
      
              try{
                  double value = Double.parseDouble(String.valueOf(communicationProcessor.postMultipartMethod(bodyData, similarityPath+"/similarcheck", String.class)));
      
                  // NOTE : STOMP 연결하기
                  SimilarMessageResponse similarMessageResponse = SimilarMessageResponse.builder()
                          .roomId(roomId)
                          .animalType(animalType)
                          .similarValue(value)
                          .responseState(ResponseState.SUCCESS)
                          .message("유사도 연결에 성공하셨습니다.")
                          .build();
      
                  if(value >= comparisonValue){
                      // NOTE : STOMP 응답 전송하기
                      stompService.stompSuccessRes(stompUrl, SimilarState.END, similarMessageResponse);
                  }else{
                      // NOTE : STOMP 응답 전송하기
                      stompService.stompSuccessRes(stompUrl, SimilarState.CONTINUE, similarMessageResponse);
                  }
              }catch(Exception e){
                  e.printStackTrace();
                  // NOTE : STOMP 응답 전송하기
                  stompService.stompFailRes(stompUrl, MessageResponse.builder()
                          .roomId(roomId)
                          .animalType(animalType)
                          .message("유사도 측정에 실패했습니다.").responseState(ResponseState.FAIL).build());
              }
      
          }
      }
      ⇒ flask 서버를 호출해서 받은 유사도를 StompService라는 이름의 자바 파일에서 ws으로 응답한다.
    • StompService.java
      package com.yehah.draw.global.stomp;
      
      import com.yehah.draw.domain.animal.entity.SimilarState;
      import com.yehah.draw.global.stomp.dto.MessageResponse;
      import com.yehah.draw.global.stomp.dto.SimilarMessageResponse;
      import lombok.RequiredArgsConstructor;
      import lombok.extern.slf4j.Slf4j;
      import org.springframework.messaging.simp.SimpMessagingTemplate;
      import org.springframework.stereotype.Service;
      
      @Slf4j
      @Service
      @RequiredArgsConstructor
      public class StompService {
      
          private final SimpMessagingTemplate messagingTemplate;
      
          // NOTE : STOMP 성공 응답 전송하기
          public void stompSuccessRes(String responseUrl, SimilarState similarState, SimilarMessageResponse similarMessageResponse){
              log.info("similarState : {}", similarState);
              similarMessageResponse.setSimilarState(similarState);
              messagingTemplate.convertAndSend(responseUrl, similarMessageResponse);
          }
      
          // NOTE : STOMP 실패 응답 전송하기
          public void stompFailRes(String responseUrl, MessageResponse messageResponse){
              messagingTemplate.convertAndSend(responseUrl, messageResponse);
          }
      }
      ⇒ Stomp 유사도 검사 결과를 client 에게 반환한다.

💡 플랫폼의 목표는 저희가 제공하는 원본 그림의 테두리만 있는 그림과 아이가 해당 그림에 그린 테두리의 유사도를 실시간으로 체크하여 일정 유사도를 넘기면 다음 색칠하기 페이지로 자동으로 넘어갈 수 있도록 개발하는 것이었습니다. 이때, 메세지를 응답하여 소통하는 창구는 webSocket 기반의 메세지 프로토콜인 stomp 를 사용했고 실제로 아이가 그린 그림을 보내는 창구로는 http 통신을 사용했습니다. 이렇게 구현한 이유는 http 통신으로 불필요한 데이터를 전송하거나 받지 않고 최대한의 실시간성을 보장하기 위함이었습니다.

🔎SockJS


: webSocket을 지원하지 않는 최신 브라우저에서도 해당 라이브러리의 api가 잘 작동하도록 지원하는 라이브러리이다.

  • Polling

    ⇒ 일정한 주기로 서버에 요청(Request)를 보내는 방법이다.
    (setTimeout, setInterval 등으로 일정 주기마다 서버에 요청을 보내면 된다.)
  • 특징
    1. 불필요한 Request와 Connection을 생성해서 서버에 부담을 주게 된다.
    2. 요청 주기가 짧을 수록 부하는 커진다.
    3. ‘일정 주기’ 마다 요청을 보내는 것이기 때문에 실시간 통신이라고 보기에는 애매한 부분이 있다.
    4. Http통신을 하기 때문에 Request, Response 헤더가 불필요하게 크다.

⇒ 실시간으로 보이기 위해서는 잦은 요청이 필요하다.


  • Long Polling

    ⇒ Polling 방식과 비슷하게 일정 주기마다 요청을 보내지만 server가 응답을 바로 전달하지 않는 방식이다.
  • 요청을 보냈을 때, 서버가 응답을 바로 보내지 않고 특정 이벤트나 타임아웃이 발생했을 때 응답을 전달하는 방식
    	1. client가 http 요청을 보낸다.
    	2. server에서 특정 이벤트나 타임아웃이 발생하면 client에게 응답을 보낸다.
    	3. client측에서 응답을 받으면 바로 http request를 보낸다.
    • polling과 비교했을 때의 특징
      • Polling보다는 서버의 부담이 줄어든다(왜? 의미없는 http request의 빈도가 줄어든다) → 하지만, 클라이언트에게 동시에 많은 양의 메세지가 올 경우 Polling과 별 차이가 없으며, 다수의 클라이언트에게 동시에 이벤트가 발생될 경우에는 곧바로 다수의 클라이언트가 서버로 접속을 시도하게 되면서 서버의 부담이 커진다.
    • 특징
      • Http 통신을 하기 때문에 Request, Response 헤더가 불필요하게 크다.

  • Streaming

    ⇒ 이벤트가 발생했을 때 응답을 내려주되, 응답을 완료시키지 않고 계속 연결을 유지하는 방식이다.
  • 특징
    • Long polling에 비해 응답마다 다시 요청을 하지 않아도 되므로 효율적이지만, 연결 시간이 길어질수록 연결 유효성 관리의 부담이 발생합니다.
    • Http 통신을 하기 때문에 Request, Response 헤더가 불필요하게 크다.

참고


https://velog.io/@kwj2435/Web-WebSocket-STOMP-SocketJS-차이

https://velog.io/@akskflwn/StompJs와-SockJs

profile
BE Developer

0개의 댓글