본문 바로가기
IT이야기/PROJECT

[React + SpringBoot] 1대1 랜덤채팅 프로젝트 - Spring&웹소켓&STOMP 설정 및 구현

by JI_NOH 2024. 3. 9.

목적

- 로스트 아크 게임 유저들을 위한 깐부 찾기 랜덤채팅 사이트

구성

- 프론트 : React, Shadcn, Javascript, Socket, STOMP
- 백 : SpringBoot, Java, MySQL, Redis, WebSocket, STOMP

 

 

 

목차

     

     

    웹소켓 + STOMP 설정


    웹소켓과 STOMP에 대해서는 이전 포스팅에서 설명하고 넘어갔으니 스프링에서 어떻게 설정하는지에 대해서 자세히 알아보자. 첫 포스팅에도 간단히 첨부는 하고 넘어갔던 데이터들이다.

     

    build.gradle

    	// websocket - 실시간 통신
    	implementation 'org.springframework.boot:spring-boot-starter-websocket'
    	implementation 'org.webjars:sockjs-client:1.1.2'
    	// stomp - 구독
    	implementation 'org.webjars:stomp-websocket:2.3.3-1'
     

     

    WebSocketConfig

    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;
    
    @Configuration
    @EnableWebSocketMessageBroker
    public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    
        @Override
        public void configureMessageBroker(MessageBrokerRegistry config) {
            // 메세지 구독 경로
            config.enableSimpleBroker("/sub");
            // 메시지 발행 경로 설정
            config.setApplicationDestinationPrefixes("/pub");
        }
    
        @Override
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            //Client에서 websocket 연결할 때 사용할 API 경로를 설정 - 채팅용
            registry.addEndpoint("/chat")
                    .setAllowedOriginPatterns("*")
                    .withSockJS();
            //Client에서 websocket 연결할 때 사용할 API 경로를 설정 - 매칭용
            registry.addEndpoint("/match")
                    .setAllowedOriginPatterns("*")
                    .withSockJS();
        }
    }
    
     

     

     

    1.configureMessageBroker

    웹소켓 설정을 찾아봤다면 해당 함수 내에서 enableSimpleBroker, setApplicationDestinationPrefixes

    를 설정해주는 것을 많이 보았을 것이다.

     

    우선 Spring에서 안내하는 기본 설정은 아래와 같다.

    package com.example.messagingstompwebsocket;
    
    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;
    
    @Configuration
    @EnableWebSocketMessageBroker
    public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    
      @Override
      public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
      }
    
      @Override
      public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/gs-guide-websocket");
      }
    
    }
     

    어차피 configureMessageBroker 안에 선언하는 경로는 커스텀 설정이기 때문에 똑같이 할 필요는 없으며 여기서 사용되는 경로는 차후에 '클라이언트 -> 서버 / 서버-> 클라이언트'가 서로 메세지를 보낼 때 구독인지 발행인지 판단하는데 사용되는 경로라고 생각하면 되겠다.

     

    프론트에서 아래와 같은 형태로 발행(/pub)과 구독(/sub)을 판단하며 이하 세부 주소는 MessageController에서 매핑하여 연결될 수 있게끔 작업한다.

    // 발행
    stomp.send('/pub/chat/exit', headers, data);
     
    // 구독
    stomp.subscribe('/sub/chat/room/'+roomInfo.roomKey, (message) => {
              const newMessage = JSON.parse(message.body);
              // 구독해서 받아온 메세지 구현부
    
            });
     

    2. registerStompEndpoints

    다음으로 registerStompEndpoints는 소켓 통신을 할 때 사용되는 endpoint를 설정하는 곳이다.

    소켓 연결을 할 때, 어느 주소로 소켓 연결을 시작할 것 인지를 선언하는 곳인데 api 주소와 유사하다고 생각하면 된다.

     

    이는 프론트에서 아래와 같은 형태로 sockJS연결할 때 사용된다.

    const socket = new SockJS("http://localhost:8080/chat");
     

     

     

     

     

     

    MessageController


    1. 백엔드 STOMP구현

    private final SimpMessageSendingOperations template;
    
    
    @MessageMapping("/chat/enter")
    public void enterUser(ChatMessages chat, SimpMessageHeaderAccessor headerAccessor) {
        chat.setMessage("채팅방에 참여하였습니다.");
        template.convertAndSend("/sub/chat/room/" + chat.getRoomKey(), chat);
    
        // 입장 관련 Service 구현부
    
    }
    
    @MessageMapping("/chat/message")
    public void sendMsg(ChatMessages message) {
        template.convertAndSend("/sub/chat/room/"+ message.getRoomKey(), message);
    
        // 메세지 관련 Service 구현부
    
    }
    
    @MessageMapping("/chat/exit")
    public void exitUser(ChatMessages chat) {
        chat.setMessage("채팅방에서 퇴장하였습니다.");
        template.convertAndSend("/sub/chat/room/" + chat.getRoomKey() , chat);
    
        // 퇴장 관련 Service 구현부
    
    }
    
     

    앞서 간단하게 언급했는데

    프론트에서 연결 후 , /pub/chat/~ 로 발행하게 된다면 내가 만든 컨트롤러에서 @MessageMapping("/chat/~") 어노테이션 경로로 해당 요청을 받을 수 있게 된다.

     

    그리고 발행 내용을 받았으니 서버에서는 해당 내용을 구독자들에게 뿌리는데 그 부분이 아래 코드다.

    template.convertAndSend("/sub/chat/room/" + chat.getRoomKey(), chat);
     

     

     

    2. 백엔드와 프론트엔드 간 STOMP

    그럼 쉬운 이해를 위해 프론트 <-> 백 사이의 통신이 어떻게 이루어 지는지 보자.

    const [stompClient, setStompClient] = useState(null);
    
    // socket오픈
    useEffect(() => {
      const socket = new SockJS("http://localhost:8080/chat");
      const stomp = Stomp.over(socket);
    
      setStompClient(stomp);
    }, []);
    
    // 채팅방 입장
    useEffect(() => {
        if (stompClient) {
          let headers = {Authorization: getCookie('accessToken')};
          // stomp연결 및 /pub/chat/enter경로로 메세지DTO발행
          stompClient.connect(headers, () => {
            if(props.enter == true && stompClient){
              stompClient.send('/pub/chat/enter', {}, 
              JSON.stringify({ 
                type: 'ENTER',
                roomKey: roomInfo.roomKey,
                sender: uuId,
                message: "",
                sendTime: moment().format('YYYY-MM-DDTHH:mm:sszz')}
              ));
            }
        }
      }, [stompClient, roomInfo]); 
     
        @MessageMapping("/chat/enter")
        public void enterUser(ChatMessages chat) {
    
            chat.setMessage("채팅방에 참여하였습니다.");
            template.convertAndSend("/sub/chat/room/" + chat.getRoomKey(), chat);
    
            chatRepository.saveMessages(chat, ChatMessages.MessageType.ENTER);
            chatRoomRepository.updateLastChat(chat.getRoomKey(), chat.getSendTime(), chat.getMessage());
        }
     
    // 구독
    useEffect(() => {
        if (stompClient) {
            stompClient.subscribe('/sub/chat/room/'+roomInfo.roomKey, (message) => {
              const newMessage = JSON.parse(message.body);
              setChatHistory((prevHistory) => [...prevHistory, newMessage]);
            });
        }
      }, [stompClient]); 
     

    프론트에서는 sockJS를 연 순간부터 해당 엔드포인트로 양방향 통신이 이루어 지고 있다.

        const socket = new SockJS("http://localhost:8080/chat");

     

    발행을 프론트에서 행한다면

        stompClient.send('/pub/chat/enter', {},

     

    백에서는 MessageMapping을 통해 발행 내용을 받고

        @MessageMapping("/chat/enter")

     

    메세지 가공이 필요하다면 가공을 한 후 해당 발행내용 타겟 구독자들에게 다시 발행해준다.

    여기서 chat.getRoomKey()의 경우 해당 방 구독자들만 받아볼 수 있는 키 값이라고 보면 되겠다.

        chat.setMessage("채팅방에 참여하였습니다.");

        template.convertAndSend("/sub/chat/room/" + chat.getRoomKey(), chat);

     

    프론트에서는 앞서 sockJS연결한 정보에 관해 stompClient에 갖고 있고, 해당 객체에 작업이 들어온다면 그걸 받는 useEffect처리를 해둔 상황이다.

        useEffect(() => { ), [stompClient]);

     

    그리고 내 구독함에 메세지가 들어온다면 해당 내용을 화면에다 뿌려준다.

        stompClient.subscribe('/sub/chat/room/'+roomInfo.roomKey, (message) => {

            const newMessage = JSON.parse(message.body);

            setChatHistory((prevHistory) => [...prevHistory, newMessage]);

        });

     

     

     

    다 적용하고 직접 겪어보면 쉬운데 처음 찾아 볼 때는 다들 쓰고 있는 엔드포인트도 다르고

    프론트에서 어떻게 쓰이고 -> 백에서 어떻게 받고 -> 그 반대는 다시 어떻게 하는지에 대한 설명이 없어서 상당히 헤맸었다.

     

    이 정도만 알면 소켓 통신에 대해서는 어떻게 쓰는지 쉽게 이해할 수 있으리라 생각된다.

    다음은 Redis 사용에 관해 포스팅해보겠다. 마찬가지로 처음쓰면서 삽질을 많이 했던 파트다.