WebSocket + STOMP +React를 활용한 프로젝트 진행 중 문제 해결 과정

짱센호랑이·2023년 8월 22일
3

이제 본격적으로 프로젝트 진행 중 발생했던 문제들과 그 문제들을 어떤식으로 해결해 나갔는지에 대해 써보려고 한다.

사실 아주 간단한 문제들이었지만 WebSocket이 뭔지 STOMP가 뭔지 또 클라이언트와 서버의 통신은 어떤식으로 이루어지는지 제대로 몰랐던 상태로 처음 시작했던 거라 개인적으로도, 또 같이 WebSocket을 담당했던 프론트엔드 조원이 많은 벽에 부딛혔었다.

몇 가지 뽑아보자면

  1. 클라이언트와 서버의 통신 오류
    • 메시지 교환이 정상적으로 이루어지지 않음.
  1. WebSocket 연결 문제
    • WebSokcet이 연결되지 않았는데 메시지를 송, 수신하려할 때 발생하는 문제. 비동기통신오류

추가적으로 우리 서비스는 N:N 미팅 서비스였는데 중간에 메기가 들어올 때 SSE 를 이용해 서버에서 클라이언트에서 메시지를 전송해주었는데 그 때 발생한 오류 해결 과정도 간단하게 적어보겠다.


첫 번째, WebSocket 연결 오류

STOMPCONNECTIONERROR

아마 WebSocket을 처음 다루다 보면 가장 자주 부딛히는 에러일 것이다.
로컬 환경과 서버에서 WebSocket에 연결되는 속도는 눈에 띄게 차이가 많이난다. 체감상 서버는 바로 연결이 되지만 로컬 환경은 길게는 20초까지 시간이 필요 한 것 같다.

이유는 여러가지가 존재하는데 여기저기 알아본 바로는 아마 로컬 환경에서 자원이 한정되어 있어서거나 (Docker나 다른 프로그램이 자원을 잡아먹어서) 방화벽 문제일 수도 있다. 원인을 명확히 파악하려면 연결되는 과정마다 log를 찍어서 알아봐야하지만 현재는 개발이 이미 끝난시기고 각기 다른 조로 이동했기때문에 이렇게 예측만 해볼 수 있을 것 같다.

아무튼 테스트를 하게 된다면 로컬에서 테스트를 먼저 하게 될 것인데 이럴 때에는 페이지가 랜더링 된 후 처음 서버에 Send 하는 시간을 여유를 좀 두게 되면 쉽게 해결되는 문제이다.

두 번째, STOMP SUB-PUB 사용 문제.


@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
/**
 * 채팅 시스템 개발을 위핸 Config Class
 */
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {


    /**
     * 소켓 연결과 관련된 설정
     *
     * addEndpoint : 소켓 연결 URL
     * setAllowedOriginPatterns : 소켓 CORS 설정
     * withSocketJS : 소켓을 지원하지 않는 브라우저라면 socketJs를 사용하도록 설정.
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        
        registry.addEndpoint("/ws/chat").setAllowedOriginPatterns("*").withSockJS();
    }


    /**
     * STOMP 사용을 위한 MessageBroker 설정
     *
     * enableSimpleBroker : 메세지를 받을 때 경로 설정
     * /queue, /topic을 통해 1:1 , 1:N 설정
     * 두 API가 Perfix로 붙은 경우 messageBroker가 해당 경로를 가로 챔.
     *
     * setApplicationDestinationPrefixes : 메세지를 보낼 때 관련 경로 설정
     * 클라이언트가 메시지를 보낼 때 경로 앞에 /app이 붙어있으면 Broker로 보내짐.
     *
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/queue", "/topic");
        registry.setApplicationDestinationPrefixes("/app");
    }
}

내가 작성했던 WebSocketConfig Calss이다.

아래쪽에 보면 STOMP의 SUB-PUB을 활용하여 1:N 메시지 통신을 활용하기 위해 메서드를 구현해놨고

클라이언트에서는 Subscribe만 해놓고 별도 Send를 하지 않았었다.
웹소켓 통신 흐름

하지만 이전 포스팅에서 올렸던 그림처럼, 발행자가 Send를 해야지만 그 메시지가 Broker를 통해 구독하고 있는 모든 구독자에게 메시지를 전송하는 시스템이기 때문에 Subscribe와 Send는 필수이다. 이를 인지하고 클라이언트에서 Send를 해주는 부분을 추가해주었다.

아래는 그 두가지를 추가한 ChatRoom Component에 대한 간단한 코드이다.

const client = useWebSocket({
    subscribe: (client) => {
      setIsLoading(false);

      // 기존의 채팅 메시지에 대한 구독
      client.subscribe(`/topic/room/${roomId}/chat`, (res: any) => {
        const chatMessage: ChatMessage = JSON.parse(res.body);
        console.log(chatMessage);
        setMessages((messages) => [...messages, chatMessage]);
        console.log(messages);
      });
      // 입장 및 퇴장 메시지에 대한 구독 추가
      client.subscribe(`/topic/room/${roomId}`, (res: any) => {
        const chatMessage: ChatMessage = JSON.parse(res.body);
        // 메시지 내용이 입장이나 퇴장 메시지인 경우에만 처리
        if (
          chatMessage.content.includes(`입장하셨습니다.`) ||
          chatMessage.content.includes(`퇴장하셨습니다.`)
        ) {
          setMessages((messages) => [...messages, chatMessage]);
        }
      });
    },
  });

  useEffect(() => {
    if (messageRef && messageRef.current) {
      const element = messageRef.current;
      element.scroll({
        top: element.scrollHeight,
        left: 0,
        behavior: 'smooth',
      });
    }
  }, [messages]);

  const sendMessage = () => {
    if (newMessage && client?.connected) {
      console.log(client.connected);
      client.send(
        `/app/room/${roomId}/chat`,
        {},
        JSON.stringify({
          senderNickname: member.nickname,
          content: newMessage,
          timestamp: new Date(),
          isUserMessage: true,
        }),
      ); 
      setNewMessage('');
    }
  };
  const handleFormSubmit = (e: React.FormEvent) => {
    e.preventDefault(); // 새로고침을 막습니다
    sendMessage(); // 메시지를 보냅니다
  };

이처럼 Subscribe를 통해 구독 주소를 정확히 설정하고 작성한 메시지를 Send를 통해 서버에 처리 요청을 하였다.

 @MessageMapping("room/{roomId}/chat")
    public void sendMessage(@DestinationVariable Long roomId, @Payload ChatMessage chatMessage) {
        chatService.addMessage(roomId, chatMessage);
        operations.convertAndSend("/topic/room/"+ roomId +"/chat", chatMessage);
    }

그 후 서버에서 처리를 해준 뒤 다시 "/topic"으로 시작하는 주소를 구독하고 있는 사용자들에게 메시지를 ChatMessage DTO에 담아서 송신해주면 간단하게 채팅 시스템 완성이다.

처음 STOMP를 접해보았기 때문에 구독-발행 방식에 대한 이해가 적어 발생했던 아주 간단한 문제였다.

세 번째, 미팅방에 새로 들어온 멤버(메기)에 대한 처리

우리 프로젝트는 앞서 말한 것 처럼 N:N 미팅 서비스이다. 그 미팅을 더 박진감 넘치게 해주는 시스템으로 여러 소개팅 프로그램에서 사용중인 메기 시스템을 넣기로 하였다.

메기는 빠른 매칭 방에만 적용되었고 메기 역시 매칭 시스템을 통해서 진행중인 미팅에 입장할 수 있게 하였다.

그리고 이 메기 시스템을 구현하면서 몇 가지 문제가 있었는데

그 중 첫 번째 문제가 이미 진행중인 미팅방에 메기가 들어올 시 미팅방의 시간은 계속 흐르고 있지만 메기의 시간은 0분 0초부터 시작한다는 것이다.

따라서 지정된 시간마다 질문을 받고 특정 시간이 되면 미팅이 종료되어야 하는데 메기는 0분 0초부터 진행되기 때문에 미팅방의 멤버들과 진행이 맞지 않는다는 문제가 발생했다.

그래서 나와 같이 미팅방을 개발하던 프론트 엔트 조원 한명과 계속 고민을 해보았다.

그래서 내린 결론은

  1. 메기가 입장을 하게되면 WebSocket 통신을 통해 서버에 요청을 보낸다.
  2. 서버는 요청을 받은 순간의 시간과 방이 시작한 시간을 계산해 두 시간의 차이를 구한다.
  3. 구한 시간을 다시 클라이언트로 보내준다.
  4. 클라이언트는 받은 시간을 계속 갱신해준다.
  5. 이 과정을 계속 반복한다.

이렇게 해결해야겠다! 라고 생각하고 코드를 구현했었다.

하지만 WebSocket으로 주기적으로 계속 요청을 보내게되면 서버에 부하가 많이 가게 된다.

굳이 계속 요청을 보낼 필요 없이 메기가 처음 입장할 때에만 요청을 보내주고 메기의 시간만 한번 맞춰주면 된다는 결론을 내렸다.

따라서 사용자가 처음 미팅방에 입장 했을 때, 즉 페이지가 처음 랜더링 될 때 서버로 axios 요청을 보내고 나머지 계산 방식은 똑같이 적용해서 클라이언트로 보내줬다.

아래는 요청하는 부분의 간단한 코드 예이다.

  // 서버 시간으로 타이머 설정
  useEffect(() => {
    fetchElapsedTime();
  }, []);

  const fetchElapsedTime = async () => {
    try {
      const response = await axios.get(`/api/rooms/${roomId}/elapsedTime`);
      console.log('시간', response.data);
      setStartSec(response.data);
    } catch (error) {
      console.error('Failed to fetch elapsed time:', error);
    }
  };

서버에서는 별도 메소드 호출 없이 바로 Controller에서 방의 시작시간을 받아와서 흐른 시간을 MilliSec으로 계산 후 클라이언트로 보내준다.

    @GetMapping("{roomId}/elapsedTime")
    public ResponseEntity<Long> getElapsedTime(@PathVariable Long roomId){
        LocalDateTime startTime = privateRoomService.getRoomStartTime(roomId);
        long milliSec = startTime.atZone(ZoneId.of("Asia/Seoul")).toInstant().toEpochMilli();
        Duration duration = Duration.between(startTime, LocalDateTime.now());
        return ResponseEntity.ok(milliSec);
    }

그 후 받은 시간을 별도 타이머 컴포넌트에서 계산하여 시간을 맞춰주면 된다.

하지만 여기서 또 문제가 발생한다.

서버의 시간이 로컬 시간과 달라서 시간이 몇 시간 차이가 났던 것이다.

그래서 서버의 시간을 따로 설정해 주려 했지만 잘 되지 않아서 그냥 시간을 표시할 때 차이나는 시간만큼 빼주어서 display 해 주었다. 사실상 서비스는 서버단에서 제공되기 때문에 로컬시간은 고려하지 않고 서버 시간에 맞춰 계산해주는 간단한 방식으로 해결하였다.

시간을 맞춰주는것이 생각보다 간단하게 해결될 줄 알았지만 시간이 가장 오래걸렸던 것 같다.

여기서 추가적으로 메기가 입장하였을 때 모든 멤버들에게 "메기 입장!" 이라는 메시지를 전송해 주고 싶었다.

그래서 생각한 방법은

  1. 메기가 입장하면 사용자의 메기 여부를 판별한다.
  2. 사용자가 메기라면 WebSocket을 통해서 서버에 요청을 보낸다.
  3. 서버에서 요청을 받으면 특정 주소를 구독하고 있는 사용자들에게 "메기입장!" 이라는 메시지를 뿌려준다.

하지만 여기서도 연결 관련 문제가 발생하는데, 메기는 아직 WebSocket에 연결되지 않았지만, 서버는 메기 포함 모든 사용자들에게 메시지를 전송하려고 시도하는 것이다.

그 결과...

STOMPCONNECTIONERROR

이 에러가 또 발생했다.....

그래서 나중에 말할 SSE 에러 해결법과 동일한 딜레이를 살짝 주었다.

딜레이를 주는 방법은 자칫 서버에 부하를 줄 우려가 있어 다른 해결방법으로는 Messaging Queue 사용하는 방법도 있었지만 현실적으로 남은 시간이 많이 없기도 하고 우리 서비스가 사이즈가 크지 않았기 때문에 그냥 딜레이를 주는 방법으로 해결하기로 하였다.

아래는 메기를 판별하는 부분을 포함한 간단한 코드 예제이다.

매칭을 돌리게 되면

위와 같이 매칭 모달이 나온다. 메기 입장을 눌러서 이 매칭 모달이 뜨게 되면 이 유저게 메기라는 정보와 함께 미팅방으로 넘겨준다.

  //메기 매칭
  const megiMatch = async () => {
    const memberId = member.memberId;
    const queueType = 'special';
    try {
      const res = await axios.post(`api/match/members/${memberId}/${queueType}`);

      if (res.status === 200) {
        console.log('메기 매치 응답 : ', res.data);
        setMegiModalOpen(true);
      } else {
        alert('메기 매칭 중 오류가 발생했습니다.');
      }
    } catch (error) {
      console.log('메기 매칭 오류', error);
      alert('메기 매칭 중 오류가 발생했습니다.');
    }
  };

일단 메기 매칭 버튼을 누르면 queueType에 "special" 이라는 요청을 같이 보내게 된다.

    @PostMapping("/members/{memberId}/{queueType}")
    public ResponseEntity<Response> findQuickMatch(@PathVariable Long memberId, @PathVariable String queueType) {

        log.info("queuetype = " + queueType);
        Response response = matchingService.enqueueMember(memberId, queueType);
        return ResponseEntity.ok(response);
    }

그럼 이쪽 Controller에 요청이 와서 처리해주고 매칭이 완료되면 매칭 모달에서 처리해준다.


eventSource.addEventListener('match', (event: MessageEvent) => {
    const parseData = JSON.parse(event.data);

    if (parseData.status === 'OK') {
      setIsMatching(false);
      setIsMatched(true);

      setTimeout(() => {
        navigateToMeetingRoom(parseData.roomId, parseData.megi);
      }, 3000);
    }
  });

  const navigateToMeetingRoom = (roomId: string, isMegi: boolean) => {
    navigate(`/meeting/${roomId}`, {
      replace: true,
      state: { isMegi },
    });
    eventSource.close();
    onClose();
  };

위에서 보이는 것 처럼 navigate 할 때 이 유저가 메기인지 아닌지 isMegi를 state로 같이 넘겨준다.

useEffect(() => {
    if (location.state?.isMegi) {
      setIsMegiFlag(true);

      // 예) 특정 알림 표시, 데이터 요청 등의 로직
    } else {
      console.log('ismegi?', false);
    }
  }, [location.state]);

그리고 위 코드처럼 state로 넘겨준 isMegi 값이 true이면 넘어온 유저가 메기라는 소리가 되기 때문에 megiFlag를 true로 설정해준다.

 	if (isMegiFlag && !hasSentMegiMessage) {
        setTimeout(() => {
          client?.send(`/app/room/${roomId}/megiEnterMessage`);
          setHasSentMegiMessage(true);
        }, 5000); // 5초 후 실행
      }

그 후 위 코드처럼 isMegiFlage가 true이면 메기 입장 메시지를 서버로 send 해주는데, WebSocket 연결 시간을 고려해 앞서 말한것 처럼 5초정도의 딜레이 를 주게된다.

이 딜레이를 설정해 주지 않으면 앞서 말한 것 처럼 STOMP CONNECTION ERROR가 똭 하고 터진다.

hasSentMegiMessage send가 여러번 보내지는걸 방지하는 판별값이다. 한번 send를 보내주게 되면 hasSentMegiMessage가 true로 바뀌게 되어 더이상 send를 보내주지 않는다.

그 후 처리 과정을 좀 더 기술해보자면


 @MessageMapping("room/{roomId}/megiEnterMessage")
    public void megiEnterMessage(@DestinationVariable Long roomId){
        String msg = "메기입장";
        log.info(msg);
        operations.convertAndSend("/topic/room/"+roomId+"/megiEnterMessage", msg);
    }
    

서버에서는 send를 받고 주소를 구독하고 있는 유저들에게 BroadCast해주면

client.subscribe(`/topic/room/${roomId}/megiEnterMessage`, (res: any) => {
       setGuideMessage('메기 등장!!! 메기 등장!!! 메기가 등장합니다!!');
       setIsShow(true);
     });

방에 있는 모든 사람들에게 "메기 입장" 이라는 메시지를 쏴주게 되는 것이다.

연결 시간때문에 생각을 많이 했지만 돌고 돌아 딜레이를 주는 방법으로 해결했다.

사실 앞서 말한것 처럼 딜레이를 주는 방식은 그렇게 좋진 않아서 다음 프로젝트에는 Messaging Queue를 도입하는 방법을 고려해 보려 한다.


SSE 비동기 통신 에러

매칭 시스템 관련해서는 다른 조원이 RestAPI를 구현 해놨고 나는 그것을 클라이언트에 연결하는 작업을 수행했는데, 매칭을 돌린 사람 중 맨 마지막에 매칭 요청을 한 유저만 미팅방으로 연결이 되지 않는 문제가 생겼다.

몇 시간을 고민하고 코치님에게 질문해 보아 얻은 해결책은

  1. HTTP 업그레이드
  2. Messaging Queue 이용
  3. Delay

1번은 SSE는 한 브라우저당 동시 연결이 최대 6개로 제한되어 있다. 하지만 내가 테스트 할 때에는 2명, 4명이 매칭을 하더라도 마지막 사람이 넘어가질 않았다. 그래서 이 부분은 아니라고 생각했다.

남은 방법은 2번 3번인데 역시 Messaging Queue를 이용하기엔 시간이 부족하다고 생각했다.

그래서 3번을 이용하여 해결하여하는데 나는 무슨 오기가 생겼는지 Delay는 죽어도 주기 싫었다.

어찌저찌 비동기 통신 에러로 인해 6명이 매칭을 한다면 5명이 큐에서 대기하고 마지막 6번째 멤버가 큐로 들어가는 순간 6번째 멤버에게 SSE가 연결 되기 전 나머지 5명은 Redis에 하나의 큐가 완성되어 있고, SSE도 연결되어 있기 때문에 6번째 멤버가 SSE에 연결 되었든 안되었든 바로 다음 페이지에 연결되어버리는 상황이라는걸 인지했다.

따라서 SSE연결 순서라던가, axios를 통해서 RestAPI를 요청하는 순서 등등 모든 방법을 해보았지만 (내가 할 수 있는 선에서) 결국 잘 해결되지 않았다.

그래서 조원들에게 상황을 공유했고 처음 매칭 기능을 구현한 조원이 딜레이를 살짝 주는 방법을 결정해 코드를 살짝 수정해주었다.

 scheduler.schedule(() -> {
 alarmService.sendMatchCompleteMessage(matchingRoom);
 }, 1000, TimeUnit.MILLISECONDS);
return esponse.of(MatchingCode.MATCHING_SUCCESS, MatchingRoomInfo.of(matchingRoom));
                    

위 코드처럼 딜레이를 살짝 주어 SSE에 연결되는 시간을 살짝 벌어주었더니 문제가 해결되었다.

위의 메기 시스템을 해결할 때 처럼 딜레이는 서버에 부하를 줄 수 있기에 다음에 이러한 기능을 똑같이 구현하게 된다면 적극적으로 Messaging Queue를 도입해 보려고 한다.


마치며...

내가 겪었던 문제점들은 사실 다 별거 아닌 문제들이다.

결국 WebSocket, STOMP, SSE 서버와 통신하는 과정을 처음 접해보았기 때문에, 이해도가 떨어져 발생했던 문제였다고 생각한다.

하지만 이런 과정들이 있었기에 좀 더 공부를 하게 되었고 그 결과 같이 WebSocket 부분을 담당하던 프론트엔드 조원과 " 우리는 소켓마스터야! " 라고 할정도로 WebSocket 부분을 좀 더 수월하게 다룰 수 있었다 .
사실 마스터는 좀 건방지다...

앞으로 프로젝트를 진행할 때 서버와 클라이언트간의 실시간성을 보장해 주기 위해선 WebSokcet을 무조건 다시 이용할 기회가 생길텐데 그 때를 대비해 미리 공부를 많이 할 수 있어 다행이라고 생각한다. 오히려 좋아!

기회가 된다면 다음 포스팅엔 WebSocket + STOMP + React를 이용해 간단한 채팅 기능을 구현한 코드를 포스팅해보겠다.

profile
뭐라도 해야지

0개의 댓글