아아 진짜 너무 귀찮습니다 아주 귀찮아요 SSE로 채팅 구현하는거? 진짜 너무나도 후회가 됩니다만 어쩔수없죠 이미 벌어진 일.. N개월간 실시간 채팅 버그 픽스했는데 글쓰는데도 N주가 걸린다니… 다들 추천하지 않는다면 그 이유가 있다는 것을 다시한번 깨닫습니다.(여러분도 명심하십쇼)
기존 팀바팀에서 실시간 채팅을 구현한 방식은
SSE 연결 → 이벤트 발생 → 채팅 조회 요청 → 렌더링
의 무한 반복이었다. 채팅처럼 잦은 이벤트가 발생할 시 다수의 인원이 데이터 조회 요청을 보내니 서버에 과부하가 걸리는건 당연지사.. 팀바팀도 팀당 3명 이상의 인원이 동시에 요청을 보내면 채팅이 어마어마하게 느려졌다. 이를 위해 백엔드도 물론 조치를 취했지만 그보다 더 중요한 것은 조회요청을 알잘딱깔센하게 보내는 것..
(이거 고친다고 이슈만 3개 PR만 12개 보냄)
구현방식을 쓰기 전에 먼저 알립니다. tanstack-query의 queryClient를 이용했습니다. 장단점이 있는 것 같으니 플젝에 맞는 방법을 찾으시면 될 듯합니다(그냥 채팅을 sse로 구현하려 하지 말자)
서버 과부하의 원인이 다수의 인원이 잦은 요청이었기에 채팅이 오더라도 조회요청을 새로 보내는 것 외의 방법을 찾아야했다. 그래서 SSE의 이벤트로 data를 받을 수 있다는 것을 이용하기로 했다.
일명 끼워넣기 전법!!
SSE는 사용자의 탭이동과 같은 행동으로 연결이 끊겼다면 연결 시 밀린 이벤트를 한번에 보내는 특성이 있어 SSE가 연결되어있다면 이벤트를 받지 못하는 경우가 드물다. 다만 주기적으로 연결이 끊길때가 있는데 이때는 가끔 이벤트가 무시되는 것 같았다. 때문에 SSE 재연결에 맞춰서 서버에 조회요청을 보낸다면 무시된 채팅까지 불러올 수 있어 데이터 무결성을 어느정도 지킬 수 있을 것이라 판단했다.
queryClient를 이용한 이유는 두가지 정도이다.
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