ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • WebSocket을 이용한 실시간 채팅 구현하기 3: STOMP
    Today I Learned 2023. 1. 21. 20:45

    WebSocket을 이용한 실시간 채팅 구현 기록은 다음의 네 단계에 걸쳐 작성할 예정이다.

     

    기존에 작성했던 WebSocket을 이용해 구현한 간단한 실시간 채팅 애플리케이션을 STOMP 프로토콜을 적용해 통신하는 방식으로 리팩터링을 진행했다.

     

    작업 코드는 다음의 링크에서 확인할 수 있다. 추후 프로젝트에 적용할 것을 염두에 두어 클라이언트에서는 전역 상태 관리 Layer를 추가했고, 서버에서는 동작을 처리하는 Layer 계층을 분리했다.

     

     

    GitHub - hsjkdss228/CodingLife

    Contribute to hsjkdss228/CodingLife development by creating an account on GitHub.

    github.com

     

    시연 영상

     

    클라이언트

    아래에는 서버 연결 및 구독, 메시지 발행 및 수신과 관련된 로직의 소스코드만을 포함했다. 채팅방 입장 및 퇴장, 채팅 메시지 출력과 관련된 소스코드는 전체 소스코드의 UI 컴포넌트에서 확인할 수 있다.

     

    서버 연결 및 구독

    클라이언트 화면에서 입장할 채팅방 번호를 클릭하면, 서버를 구독할 SockJS 객체의 인스턴스를 생성하고 STOMP에게 해당 인스턴스를 전달해 STOMP 프로토콜을 따르는 WebSocket을 생성한다.

    cf. SockJS는 WebSocket 프로토콜을 지원하지 않는 브라우저에서도 WebSocket과 유사한 방식으로 실시간 통신을 수행할 수 있도록 한다.

    // src/stores/MessageStore.js
    
    this.socket = new SockJS(`${baseUrl}/chat`);
    this.client = Stomp.over(this.socket);

     

    이후 생성한 WebSocket 인스턴스의 함수인 connect를 호출한다. connect 내부에서는 서버와 연결된 직후 수행할 메서드를 정의할 수 있다. 여기서는 클라이언트와 서버가 핸드셰이크로 연결된 직후, 입장한 채팅방의 id에 해당하는 URL을 구독하는 subscribe 함수를 실행해 해당 URL로 발행되는 메시지들을 클라이언트가 수신하고, 수신하는 경우 수행할 함수를 정의했다. connect와 subscribe는 필요 시 Header를 정의해 추가로 데이터를 전송할 수 있다.

    // src/stores/MessageStore.js
    
    subscribeMessageBroker(roomIndex) {
      this.client.connect(
        {},
        () => {
          this.client.subscribe(
            `/subscription/chat/room/${roomIndex}`,
            (messageReceived) => this.receiveMessage(messageReceived),
            {},
          );
    
          this.sendMessage({ type: 'enter' });
        },
      );
    }
    
    receiveMessage(messageReceived) {
      const message = JSON.parse(messageReceived.body);
      this.messageLogs = [...this.messageLogs, this.formatMessage(message)];
      this.publish();
    }

     

    여기에서 connect와 subscribe 함수의 상세 내용을 확인할 수 있다.

     

     

    메시지 발행

    클라이언트는 연결된 서버의 특정 URL에 메시지를 발행할 수 있다. send 함수를 호출해 보내고자 하는 메시지를 서버에 발행한다. 서버에서 수신하는 형태의 메시지 객체를 인자로 전달한다. 이때 메시지 객체는 JSON 형태로 변환시켜 전송한다.

    // src/stores/MessageStore.js
    
    const messageToSend: {
      type,
      roomId: this.currentRoomIndex,
      userId: this.userId,
      message,
    }
    
    // src/services/MessageService.js
    
    client.send(
      `/publication/chat/${messageToSend.type}`,
      {},
      JSON.stringify(messageToSend),
    );

     

    구독 해제 및 연결 종료

    접속한 채팅방의 연결을 해제하거나 브라우저를 종료할 경우, WebSocket 인스턴스의 메서드인 unsubscribe를 호출해 구독을 해제한다. 이후 disconnect를 호출해 일단은 서버와의 연결까지도 해제하도록 했다.

    // src/stores/MessageStore.js
    
    this.client.unsubscribe();
    this.client.disconnect();

     

    서버

    WebSocketConfig

    기존의 TextWebSocketHandler를 상속받아 연결 수립, 메시지 전달, 연결 종료를 처리하는 로직을 구현한 Bean을 Handler로 추가하는 기존의 WebSocketConfig의 내용이 다음과 같이 수정되었다.

     

    먼저 STOMP 프로토콜과 MessageBroker에 대한 설정을 해줄 수 있도록 @EnableWebSocket 어노테이션을 @EnableWebSocketMessageBroker 어노테이션으로 수정했다. 그리고 더 이상 WebSocketHandler를 사용하지 않고, 메시지 브로커에 대한 설정과 SockJS 구현체에서 접근하도록 정의한 경로를 수신하는 STOMP Endpoint에 대한 설정을 추가했다.

    // config/WebSocketConfig.java
    
    @Configuration
    @EnableWebSocketMessageBroker
    public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
        @Override
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/chat")
                .setAllowedOrigins("http://localhost:9991")
                .withSockJS();
        }
    
        @Override
        public void configureMessageBroker(MessageBrokerRegistry registry) {
            registry.enableSimpleBroker("/subscription");
            registry.setApplicationDestinationPrefixes("/publication");
        }
    }
    
    // utils/SocketTextHandler.java는 삭제

     

    위의 내용으로부터 서버에 설정되는 내용들은 다음과 같다.

    • setAllowedOrigins에 명시된 URL로부터 전달되는 핸드셰이크 요청 및 메시지 수신이 도달할 endpoint를 지정한다.
    • 수신받은 메시지를 '/subscription'을 포함하는 URL을 구독하고 있는 세션에 발행하는 Broker에 Simple Broker를 사용하도록 설정한다.
    • 클라이언트에서 메시지를 발행할 때, '/publication'으로 시작하는 URL로 발행한 메시지에서 '/publication'을 제거한 뒤, Controller의 @MessageMapping(URL)의 URL과 일치하는 메서드로 메시지를 라우팅한다.
    • enableSimpleBroker, setApplicationDestinationPerfixes 메서드의 인자로는 하나 이상의 URL을 전달할 수 있다.

     

    Controller

    클라이언트에서 (해당 예시 프로젝트에서는 '/publication'으로 시작하는 URL로) 발행한 메시지를 수신해 URL이 일치하는 메서드를 실행한다. 클라이언트로부터 전달되는 메시지 객체를 받을 객체를 정의했다.

     

    Controller의 메서드가 실행되면, Message Broker는 '/subscription'으로 시작하는 특정 URL을 구독하고 있는 모든 클라이언트에게 별도로 정의된 응답 메시지 객체를 발행한다.

    // controllers/MessageController.java
    
    @RestController
    public class MessageController {
        private final EnterRoomService enterRoomService;
        private final QuitRoomService quitRoomService;
        private final ConvertAndSendMessageService convertAndSendMessageService;
    
        public MessageController(EnterRoomService enterRoomService,
                                 QuitRoomService quitRoomService,
                                 ConvertAndSendMessageService convertAndSendMessageService) {
            this.enterRoomService = enterRoomService;
            this.quitRoomService = quitRoomService;
            this.convertAndSendMessageService = convertAndSendMessageService;
        }
    
        @MessageMapping("/chat/enter")
        // ...
    
        @MessageMapping("/chat/quit")
        // ...
        
        @MessageMapping("/chat/message")
        public void message(MessageRequestDto messageRequestDto) {
            convertAndSendMessageService.convertAndSendMessage(
                messageRequestDto.getType(),
                messageRequestDto.getRoomId(),
                messageRequestDto.getUserId(),
                messageRequestDto.getMessage()
            );
        }
    
        @MessageExceptionHandler
        public String exception() {
            return "Error has occurred.";
        }
    }
    // dtos/MessageRequestDto.java
    
    public class MessageRequestDto {
        private String type;
        private Long roomId;
        private Long userId;
        private String message;
        
        // constructors, getters
    }
    // dtos/MessageResponseDto.java
    
    public class MessageResponseDto {
        private final Long id;
        private final String type;
        private final String value;
        
        // constructor, getters
    }
    // services/ConvertAndSendMessageService.java
    
    @Service
    public class ConvertAndSendMessageService {
        @Autowired
        private SimpMessagingTemplate template;
    
        public void convertAndSendMessage(String type,
                                          Long roomId,
                                          Long userId,
                                          String message) {
            template.convertAndSend(
                "/subscription/chat/room/" + roomId,
                new MessageResponseDto(
                    MessageIdGenerator.generateId(),
                    type,
                    "사용자 " + userId + ": " + message
                )
            );
        }
    }

     

    cf. 클라이언트에서 특정 path를 subscribe할 때나 메시지를 send할 때, 혹은 서버에서 message를 송신받거나 구독하고 있는 경로에 발행할 때 Endpoint의 주소는 꼭 들어가지 않아도 된다. 소스코드에서는 해당 경로들이 채팅과 관련된 동작이라는 의미를 부여하기 위해 각 path마다 endpoint로 지정했던 경로명인 'chat'을 추가했다.

     

     

    References

    개념

    - https://docs.spring.io/spring-framework/docs/4.2.x/spring-framework-reference/html/websocket.html#websocket-fallback

    - https://www.youtube.com/watch?v=rvss-_t6gzg 

    - https://brunch.co.kr/@springboot/695

    SockJS

    - https://github.com/sockjs/sockjs-client

    - https://fgh0296.tistory.com/24

    클라이언트

    - http://jmesnil.net/stomp-websocket/doc/

    WebSocketConfig

      - https://rmcodestar.github.io/websocket/2019/02/11/spring-websocket/

      - org/springframework/messaging/simp/config/MessageBrokerRegistry

    구현 예시

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

    - https://nobase2dev.tistory.com/25

    - https://github.com/seungjjun/kick-off-frontend/blob/main/src/pages/ChattingRoomPage.jsx

    - https://github.com/seungjjun/kick-off-backend/blob/main/src/main/java/com/junstudio/kickoff/config/WebSocketConfig.java

    - https://github.com/seungjjun/kick-off-backend/blob/main/src/main/java/com/junstudio/kickoff/controllers/MessageController.java

     

     

     

     

     

    댓글

Designed by Tistory.