아아 진짜 너무 귀찮습니다 아주 귀찮아요 SSE로 채팅 구현하는거? 진짜 너무나도 후회가 됩니다만 어쩔수없죠 이미 벌어진 일.. N개월간 실시간 채팅 버그 픽스했는데 글쓰는데도 N주가 걸린다니… 다들 추천하지 않는다면 그 이유가 있다는 것을 다시한번 깨닫습니다.(여러분도 명심하십쇼)
기존 팀바팀에서 실시간 채팅을 구현한 방식은
SSE 연결 → 이벤트 발생 → 채팅 조회 요청 → 렌더링
의 무한 반복이었다. 채팅처럼 잦은 이벤트가 발생할 시 다수의 인원이 데이터 조회 요청을 보내니 서버에 과부하가 걸리는건 당연지사.. 팀바팀도 팀당 3명 이상의 인원이 동시에 요청을 보내면 채팅이 어마어마하게 느려졌다. 이를 위해 백엔드도 물론 조치를 취했지만 그보다 더 중요한 것은 조회요청을 알잘딱깔센하게 보내는 것.. (이거 고친다고 이슈만 3개 PR만 12개 보냄)
안내의 말씀
구현방식을 쓰기 전에 먼저 알립니다. tanstack-query의 queryClient를 이용했습니다. 장단점이 있는 것 같으니 플젝에 맞는 방법을 찾으시면 될 듯합니다(그냥 채팅을 sse로 구현하려 하지 말자)
구현하고자 한 방식
서버 과부하의 원인이 다수의 인원이 잦은 요청이었기에 채팅이 오더라도 조회요청을 새로 보내는 것 외의 방법을 찾아야했다. 그래서 SSE의 이벤트로 data를 받을 수 있다는 것을 이용하기로 했다.
- 채팅을 처음 렌더링 할 때는 기존처럼 채팅 조회 요청을 보내 값을 받는다.
- 이후 채팅 이벤트 발생 시 기존 채팅으로 받던 명세를 JSON 문자열로 변환한 값을 이벤트의 data로 받는다.
- 받은 문자열을 객체로 parse 한다.
- 조회 요청으로 받은 채팅 배열의 가장 처음에 parse한 객체를 끼워넣는다.
- SSE 재연결시에만 채팅 조회 요청을 보낸다.
일명 끼워넣기 전법!!
이유
SSE는 사용자의 탭이동과 같은 행동으로 연결이 끊겼다면 연결 시 밀린 이벤트를 한번에 보내는 특성이 있어 SSE가 연결되어있다면 이벤트를 받지 못하는 경우가 드물다. 다만 주기적으로 연결이 끊길때가 있는데 이때는 가끔 이벤트가 무시되는 것 같았다. 때문에 SSE 재연결에 맞춰서 서버에 조회요청을 보낸다면 무시된 채팅까지 불러올 수 있어 데이터 무결성을 어느정도 지킬 수 있을 것이라 판단했다.
queryClient를 이용한 이유는 두가지 정도이다.
- 채팅을 클라이언트 상태로 변환해 가지고 있는 것이 어색하다. 어쨋든 채팅 자체는 서버 데이터인데 데이터를 끼워넣기 위해 상태로 변환해서 관리하는게 맞나? 라는 생각이 들었다.
- 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<T>이기 때문에 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
24.04.03 내용 추가
끝난줄 알았는데 끝나지 않았던 SSE의 저주.. 사실 고치고 나서 새로운 버그를 발견했다고 하면 믿으시겠나요? ㅎ 그냥그냥 sse쓰지마십셔
틀린그림찾기 ON
요토가 떠먹여준 버그 픽스 + 블로그 입니다. 아주 이슈와 PR에 야무지게 작성해줘서 이렇게 날먹해도 되나 양심이 찔리진 않아요ㅎ.. 암튼 요수리마수리토깽에게 치얼스✨
고치기 전에는 빈번하게 채팅이 늦게 올라오는 경우가 발생했다. 정확히는, 메시지를 수신받은 후 사용자가 스레드 리스트 컴포넌트의 state를 변경시키는 행동(채팅창에 무언가를 입력, 공지 여부 체크박스 선택/해제)을 할 때에서야 비로소 반영되지 않았던 최신 메시지가 반영되는 문제이다.
문제 발생 원인
SSE에서 채팅창을 갱신하기 위해 사용하는 setQueryData 의 경우, 함수 형태로 사용할 때 기존 데이터(oldData)와 새롭게 갱신할 데이터(newData)의 참조가 서로 달라야 한다. 더 어렵게 이야기하면 immutable하게 변수를 관리해야만 한다. 그렇지 않을 경우, tanstack-query에서 새로운 값으로 갱신되었음을 감지하지 못해 업데이트가 일어나지 않을 수 있다고 한다.
기존 방식의 경우, old에 직접 변경사항을 내고 old를 반환했기 때문에 참조가 변하지 않는다.
<고치기 전>
queryClient.setQueryData<InfiniteData<ThreadsResponse>>(
['threadData', teamPlaceId],
(old) => {
if (old) {
old.pages[0].threads = [newThread, ...old.pages[0].threads];
return old;
}
},
);
<고친 후>
oldData 에서 아예 새로운 변수인 newData 를 만들어 이를 반환해 참조가 변하도록 변경한다.
queryClient.setQueryData<InfiniteData<ThreadsResponse>>(
['threadData', teamPlaceId],
(oldData) => {
if (oldData) {
const newFirstPageThreads: ThreadsResponse = {
threads: [newThread, ...oldData.pages[0].threads],
};
const newData = {
pageParams: oldData.pageParams,
pages:
oldData.pages.length === 1
? [newFirstPageThreads]
: [newFirstPageThreads, ...oldData.pages.slice(1)],
};
return newData;
}
},
);
이러면 버벅거리는 문제는 해결된다 ^-^ 새로운 버그는 재연결시 채팅데이터 조회요청을 보내는 이 부분에서 발생하는 것으로 유추되는데, 재연결과 채팅 송신이 겹쳐지면 보낸쪽에서 채팅을 볼 수 없는 문제…
eventSource.addEventListener('connect', () => {
queryClient.invalidateQueries([['threadData', teamPlaceId]]);
});
다시 틀린그림찾기 ON
그냥 못쓰는 거여~ 이거 보낼 때 딱 SSE 재연결 타이밍이었는데 나한테 왼쪽처럼 보여서 새로고침하니까 이미 보내져 있었음. 아마 받는쪽에서는 문제 안되는 듯? 나중에 고치면 추가할게요,,ㅋ
'FE 기술 > React' 카테고리의 다른 글
webpack 환경에서 @emotion JSX Pragma 자동으로 적용하기 (+Storybook) (2) | 2024.11.18 |
---|---|
React 모바일 브라우저 화면 잘리는 문제 해결(100vh) + 유저 디바이스 정보 확인하기 (0) | 2024.11.18 |
React와 SSE(Server Sent Events)로 실시간 채팅 구현하기 (0) | 2024.11.18 |
[React] 무한스크롤 구현하기 (useInfiniteQuery, Cursor-based Pagination) (0) | 2024.11.18 |
Webpack 기반의 React & TypeScript 환경 세팅 (0) | 2023.07.06 |