SSE 로 구현한 실시간 채팅의 성능 최적화

RuLu·2024년 1월 5일
4

Etc.

목록 보기
11/13

아아 진짜 너무 귀찮습니다 아주 귀찮아요 SSE로 채팅 구현하는거? 진짜 너무나도 후회가 됩니다만 어쩔수없죠 이미 벌어진 일.. N개월간 실시간 채팅 버그 픽스했는데 글쓰는데도 N주가 걸린다니… 다들 추천하지 않는다면 그 이유가 있다는 것을 다시한번 깨닫습니다.(여러분도 명심하십쇼)

기존 팀바팀에서 실시간 채팅을 구현한 방식은

SSE 연결 → 이벤트 발생 → 채팅 조회 요청 → 렌더링

의 무한 반복이었다. 채팅처럼 잦은 이벤트가 발생할 시 다수의 인원이 데이터 조회 요청을 보내니 서버에 과부하가 걸리는건 당연지사.. 팀바팀도 팀당 3명 이상의 인원이 동시에 요청을 보내면 채팅이 어마어마하게 느려졌다. 이를 위해 백엔드도 물론 조치를 취했지만 그보다 더 중요한 것은 조회요청을 알잘딱깔센하게 보내는 것..
(이거 고친다고 이슈만 3개 PR만 12개 보냄)

안내의 말씀

구현방식을 쓰기 전에 먼저 알립니다. tanstack-query의 queryClient를 이용했습니다. 장단점이 있는 것 같으니 플젝에 맞는 방법을 찾으시면 될 듯합니다(그냥 채팅을 sse로 구현하려 하지 말자)

구현하고자 한 방식

서버 과부하의 원인이 다수의 인원이 잦은 요청이었기에 채팅이 오더라도 조회요청을 새로 보내는 것 외의 방법을 찾아야했다. 그래서 SSE의 이벤트로 data를 받을 수 있다는 것을 이용하기로 했다.

  1. 채팅을 처음 렌더링 할 때는 기존처럼 채팅 조회 요청을 보내 값을 받는다.
  2. 이후 채팅 이벤트 발생 시 기존 채팅으로 받던 명세를 JSON 문자열로 변환한 값을 이벤트의 data로 받는다.
  3. 받은 문자열을 객체로 parse 한다.
  4. 조회 요청으로 받은 채팅 배열의 가장 처음에 parse한 객체를 끼워넣는다.
  5. SSE 재연결시에만 채팅 조회 요청을 보낸다.

일명 끼워넣기 전법!!

이유

SSE는 사용자의 탭이동과 같은 행동으로 연결이 끊겼다면 연결 시 밀린 이벤트를 한번에 보내는 특성이 있어 SSE가 연결되어있다면 이벤트를 받지 못하는 경우가 드물다. 다만 주기적으로 연결이 끊길때가 있는데 이때는 가끔 이벤트가 무시되는 것 같았다. 때문에 SSE 재연결에 맞춰서 서버에 조회요청을 보낸다면 무시된 채팅까지 불러올 수 있어 데이터 무결성을 어느정도 지킬 수 있을 것이라 판단했다.

queryClient를 이용한 이유는 두가지 정도이다.

  1. 채팅을 클라이언트 상태로 변환해 가지고 있는 것이 어색하다. 어쨋든 채팅 자체는 서버 데이터인데 데이터를 끼워넣기 위해 상태로 변환해서 관리하는게 맞나? 라는 생각이 들었다.
  2. sse를 호출하는 것은 구조상 상단부에 위치해있는데 채팅데이터는 거의 하단부쪽에 있어서 어떻게 sse에서 받은 데이터를 하단까지 운반(?)할 지를 해결할 수 있기 때문이다.

기존 코드

export const useSSE = () => {
  const queryClient = useQueryClient();
  const { accessToken } = useToken();
  const { teamPlaceId } = useTeamPlace();

  const connect = useCallback(() => {
    console.log(teamPlaceId);
    if (!teamPlaceId) {
      return;
    }
    const eventSource = new EventSourcePolyfill(
      baseUrl + `/api/team-place/${teamPlaceId}/subscribe`,
      {
        headers: {
          Authorization: `Bearer ${accessToken}`,
        },
      },
    );

    eventSource.addEventListener('new_thread', (e) => {
      queryClient.invalidateQueries(['threadData', teamPlaceId]);
    });

    return () => {
      eventSource.close();
    };
  }, [queryClient, teamPlaceId, accessToken]);

  useEffect(() => {
    return connect();
  }, [connect]);
};

기존에는 new_thread라는 이벤트가 발생하면 채팅 쿼리를 무효화 시켜 다시 불러오는 방식이다.

변경된 코드

queryClient.setQueryData 를 이용해서 서버 데이터를 임의로 조작했다. 사실 setQueryData는 낙관적 업데이트에 주로 사용되는데 어떻게 보면 우리도 이벤트가 틀릴일이 거의 없다! + 채팅을 바로 보여주는 것이 사용자 경험에 이점이 크다 라는 생각으로 쓴거니 반쯤 낙관적 업데이트 아닐까..?ㅎ

...
		eventSource.addEventListener('connect', () => {
      queryClient.invalidateQueries([['threadData', teamPlaceId]]);
    });

    eventSource.addEventListener('new_thread', (e) => {
      const newThread = JSON.parse(e.data);

      queryClient.setQueryData<InfiniteData<ThreadsResponse>>(
        ['threadData', teamPlaceId],
        (old) => {
          if (old) {
            old.pages[0].threads = [newThread, ...old.pages[0].threads];
            return old;
          }
        },
      );
    });
...

가장 먼저 추가된 것은 connect에 대한 이벤트가 추가되었다. 팀바팀의 SSE명세에 따르면 이벤트 초기연결, 재연결 상관없이 연결될때마다 connect라는 이름의 이벤트가 수신된다. 이때는 서버데이터와 우리가 가지고 있는 데이터가 달라질 가능성이 크기때문에 query자체를 초기화하여 데이터가 최신일 수 있도록 한다.

이후 new_thread 이벤트가 발생할경우 이벤트의 data를 객체로 파싱해주고 setQueryData를 통해 끼워넣기를 진행하는데 데이터 타입을 신경써야한다. 채팅은 tanstack-query의 useInfiniteQuery를 이용하여 구현했는데 이 타입이 InfiniteData이기 때문에 setQueryData의 타입또한 맞춰줘야 정상적으로 작동한다.

또한 우리는 가장 최신일 수록 가장앞에 배치되기 때문에 이를 생각해서 Data를 넣어줬다.

이에 대한 자세한 PR과 팀바팀의 토론은 아래 PR 을 참고하자.(막상 PR제목인 스크롤 하단 안보는 버그는 여기서 못고친게 웃음벨ㅋㅋ)
https://github.com/woowacourse-teams/2023-team-by-team/pull/888

채팅 발생시 스크롤 로직 변경

여기까지 보면 채팅은 잘 끼워넣어짐 채팅도 잘됨 근데 새로운 문제가 발생한다. 바로 새로운 채팅이 오더라도 스크롤이 안내려가는 것… 버그 넘어 버그라니

기존 스크롤 로직

useEffect(() => {
    if (threadPages?.pages.length !== threadPagesRef.current) {
      threadPagesRef.current = threadPages?.pages.length ?? 0;
    } else {
      if (!threadEndRef.current) {
        return;
      }
      if (isShowScrollBottomButton) {
        return;
      }
      threadEndRef.current.scrollIntoView();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [threadPages]);

변경된 스크롤 로직

  useEffect(() => {
    if (!threadEndRef.current) {
      return;
    }
    if (isShowScrollBottomButton) {
      return;
    }
    threadEndRef.current.scrollIntoView();

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [threadPages?.pages[0].threads.length]);

쉽다. 기존에는 매번 요청을 했기때문에 thradPages자체를 확인했는데 이번에는 초기에만 요청을하고 나머지는 맨처음부터 때려넣기 때문에 첫페이지의 length만 확인하면 되는거..

프로젝트가 커지니까 단점 → 문제가 발생한 코드 찾기 힘들다…ㅠㅠ 그래서 이것도 시간 많이 잡아먹음


드디어 미루고 미루던 글 쓰니까 후련하네요

이래도 아직 쓸거 백만개는 남은 것 같아요 공부는 왜이렇게 할게 많을까요???ㅠㅠ 맨 아래에 이슈 달아두겠읍니다.. 제 삽질 구경하세요ㅋㅎㅋㅎ

더 좋은 방법있으면 언제든 댓글로 알려주십셔 그럼 ㅃ2
https://github.com/woowacourse-teams/2023-team-by-team/issues/836

https://github.com/woowacourse-teams/2023-team-by-team/issues/883

https://github.com/woowacourse-teams/2023-team-by-team/issues/892

profile
프론트엔드 개발자 루루

0개의 댓글