개요
마감 기한에 쫓겨 '일단 돌아가게 만든 로직'이 다들 하나쯤은 있을것이라 생각한다.
내 경우엔 이 프로젝트에서 실시간 채팅 로직이 그러했다.
당시 구현하면서도 "나중에 꼭 리팩터링을 해야겠다." 라고 생각했는데,
취준, 다른 프로젝트 등에 겹쳐서 미루고 미루다가 정리한 후 포스팅을 남기게 되었다.
채팅은 처음엔 단순한 기능처럼 보인다.
채팅방을 선택하고 이전 대화를 불러온 뒤, 소켓으로 받은 새 메시지를 한 곳에 렌더링하면 된다.
하지만 기능이 늘어나면서 'useChat' 커스텀 훅 하나가 방 생성과 선택, 소켓 연결, 메시지 병합, 읽음 처리, 퇴장까지 모두 맡게 됐다.
이 커스텀 훅 파일은 288줄까지 커졌고 메시지가 중복 렌더링되거나, 채팅방을 나간 뒤에도 이전 상태가 남는 문제도 생겼다.
이번 refactoring에서는 'useChat'의 책임을 나누고 메시지 상태를 Tanstack Query 캐시 하나로 통합했다.
그 과정에서 어떤 코드가 문제였고, 왜 이 방식이 필요했는지 살펴보겠다.
message를 두 곳에서 관리한 문제
기존에는 API로 받은 채팅 히스토리와 소켓으로 받은 실시간 메시지를 따로 관리했다.
히스토리가 바뀌면 로컬 messages 상태를 덮어쓰고, 새 메시지가 오면 같은 상태에 추가했다.
// REST API로 받아온 채팅 히스토리와 실시간 메시지를 모두 담던 로컬 상태
const [messages, setMessages] = useState<ChatSubscribeMessageType[]>([]);
client.subscribe(selectedChat.topicId, (message) => {
const res = JSON.parse(message.body);
// 소켓으로 받은 메시지를 로컬 상태 끝에 추가한다.
setMessages((prev) => [...prev, res]);
});
useEffect(() => {
if (!chatHistory) return;
// 히스토리가 바뀔 때마다 로컬 상태를 다시 덮어쓴다.
setMessages([...chatHistory]);
}, [chatHistory]);

화면 하나를 두 경로가 갱신하니 순서가 조금만 어긋나도 문제가 생겼다.
예를 들어 히스토리 요청이 소켓 메시지 수신보다 늦게 끝나면, 'setMessages([...chatHistory])'가 바금 추가한 메시지를 덮어쓸 수 있다.
반대로 히스토리에 같은 메시지가 포함되면 동일한 id의 메시지가 두 번 렌더링돼 React의 key 속성도 겹칠 수 있다.
화면이 API 응답만 반영한 배열을 보고 있는지, 소켓 수신분까지 합쳐진 배열을 보고 있는지 보장할 기준이 없었다.
상태를 하나 더 만들기보다, 이미 히스토리를 보관하던 Tanstack Query 캐시를 메시지의 유일한 저장소로 삼았다.
이후 소켓 메시지가 도착하면 로컬 상태가 아닌 해당 채팅방의 캐시를 직접 갱신한다.
// 소켓 메시지를 채팅 히스토리 캐시에 바로 반영
queryClient.setQueryData<ChatSubscribeMessageType[]>(
[QUERY_KEY.auth, QUERY_KEY.chatHistory, res.roomId],
(old) => {
// 히스토리를 아직 받지 못한 경우 새 배열로 시작
if (!old) return [res];
// 같은 메시지를 다시 받으면 캐시를 바꾸지 않음
if (old.some((msg) => msg.id === res.id)) return old;
return [...old, res];
},
);
// 화면은 Query가 관리하는 캐시만 읽음
const messages = chatHistory ?? [];
채팅 히스토리와 실시간 메시지가 이제 같은 배열을 본다.
수신한 메시지의 id가 이미 있으면 추가하지 않으니 중복 렌더링도 막을 수 있다.
'useChat'안에 있던 messages 상태와 히스토리를 복사하던 effect도 자연스럽게 사라졌다.
역할별 훅으로 분리한 'useChat'
메시지 상태를 정리한 뒤에도 useChat 훅은 책임이 너무 많았다.
외부 API는 유지한 채 내부를 두 개의 훅으로 나눴다.
'useChatRoomManager'는 모집글에서 진입했을 때의 방 생성, URL의 roomId에 맞는 방 선택, 최근 채팅 자동 선택을 맡는다.
'useChatSocket'은 STOMP/SockJS 연결과 해제, topic 구독, 메시지 발행과 수신을 담당한다.
'useChat'은 두 훅과 조회/퇴장 훅을 조합해 화면에 필요한 값만 돌려준다.
// 방 생성·선택·URL 동기화 담당
const { roomId, setRoomId, selectedChat, setSelectedChat } =
useChatRoomManager({ postId, selectedRoomId, chatList });
// 연결·구독·수신·발행 담당
const { connectWebSocket, disconnect, sendMessage } = useChatSocket({
postId,
selectedChat,
chatList,
setSelectedChat,
setRoomId,
});
'useChat'을 없애지는 않았다.
페이지는 계속 'useChat' 하나만 호출한다.
대신 방 관리와 소켓 통신은 서로의 세부 구현을 알 필요가 없다.
'useChat'은 복잡한 하위 로직을 감추는 Facade 역할만 맡는다.
첫 메시지가 채팅방을 확정하는 흐름
모집글에서 채팅하기 버튼을 눌렀을 때는 아직 'roomId'가 없을 수 있다.
'useChatRoomManager'는 'createChatRoom' 응답으로 임시 채팅 객체를 만들고,
이미 생성된 방이 있으면 채팅 목록에서 해당 방을 찾아 선택한다.
'creatingPostIdRef'는 같은 'postId'로 채팅방 생성 요청이 겹치지 않게 막는다.
새 대화의 첫 메시지는 'postId'와 'receiverId'를 함께 보낸다.
기존 방이라면 'roomId'만 보내고, 새 방이라면 'roomId'는 'null'이다.
// roomId가 없으면 새 대화의 첫 메시지
const isFirst = !selectedChat?.roomId;
const payload = {
body: message,
messageType: 'TALK',
roomId: selectedChat?.roomId ?? null,
// 첫 메시지일 때만 서버가 방을 만들 수 있는 정보를 함께 전송
postId: isFirst ? postId : null,
receiverId: isFirst ? selectedChat?.leaderId : null,
};
서버가 첫 메시지를 처리하고 보내는 응답에는 실제 'roomId'가 들어온다.
소켓 혹은 먼저 채팅 목록에서 그 방을 찾고,
아직 목록에 없다면 임시 채팅 객체에 'roomId'를 채운다.
이어서 내부 'roomId' 상태도 갱신한다.
첫 메시지 전의 임시 상태와 첫 메시지 뒤의 실제 방 상태를 이어 주는 처리다.
if (!currentChat.roomId) {
// 새로고침된 목록에 이미 같은 방이 있으면 그 항목을 우선 사용
const matched = chatListRef.current?.find(
(chat) => chat.roomId === res.roomId,
);
setSelectedChat((prev) => {
if (matched) return matched;
if (!prev) return prev;
// 목록 갱신 전이라면 임시 채팅 객체에 실제 roomId만 채움
return { ...prev, roomId: res.roomId };
});
// 이후 히스토리 조회와 메시지 전송이 실제 방을 바라보도록 갱신
setRoomId(res.roomId);
}
이 흐름이 없으면 첫 메시지 이후에도 화면은 임시 채팅방을 바라보고,
이후 요청은 실제 방을 기준으로 움직이는 식의 불일치가 생길 수 있다.
실제 'roomId'가 확정되는 시점에 선택 상태와 목록 상태를 맞춘 이유다.
소켓 콜백에서 최신 채팅방을 읽는 방법
소켓의 'onConnect'와 'subscribe' 콜백은 등록한 시점보다 나중에 실행된다.
콜백이 생성될 때의 'selectedChat'을 그대로 닫아 두면, 사용자가 다른 방으로 이동한 뒤에도 이전 방 정보를 읽는 stale clousure 문제가 생길 수 있다.
반대로 'selectedChat'을 의존성에 넣어 콜백을 계속 새로 만들면 소켓 연결과 구독이 불필요하게 반복될 수 있다.
'useChatSocket'은 최신 'selectedChat'과 'chatList'를 ref에 동기화하고, 연결 콜백에서는 그 ref를 읽는다.
// 비동기 소켓 콜백에서 최신 선택 방을 읽기 위한 ref
const selectedChatRef = useRef<ChatListItemType | null>(null);
useEffect(() => {
// 렌더링으로 선택 방이 바뀔 때마다 ref도 갱신
selectedChatRef.current = selectedChat;
}, [selectedChat]);
// 연결 시점이 아니라 콜백 실행 시점의 값을 사용
const currentChat = selectedChatRef.current;
연결을 다시 시작하는 effect의 의존성도 'selectedChat' 객체 전체가 아니라 'selectedChat?.topicId'다.
채팅 목록 캐시가 갱신돼 객체 참조만 바뀌는 상황까지 재연결하지 않고, 구독 대상이 바뀔 때만 연결을 새로 만든다.
현재 채팅방을 URL에도 남기기
처음에는 모집글의 'postId'를 동적 경로로 넘겼다.
문제는 채팅 목록에서 다른 방을 선택해도 URL에는 이전 postId가 그대로 남는다는 점이었다.
화면의 선택 상태와 URL이 서로 다른 값을 가리키면 새로고침이나 뒤로 가기에서도 일관된 동작을 기대하기 어렵다.
채팅 페이지는 '/chat?postId=...' 또는 '/chat?roomId=...' 형태로 바꿨다.
'postId'는 새 대화를 시작하는 정보이고, 'roomId'는 현재 선택한 기존 방을 뜻한다.
목록에서 방을 고르면 'postId'를 지우고 'roomId'를 기록한다.
// 기존 쿼리 파라미터를 보존한 채 현재 방만 교체
const params = new URLSearchParams(searchParams.toString());
// 모집글에서 새 대화를 시작했다는 정보는 제거
params.delete('postId');
// 현재 선택한 기존 채팅방을 URL에 기록
params.set('roomId', chat.roomId);
// 브라우저 히스토리를 쌓지 않고 URL만 동기화
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
이제 URL만 봐도 어떤 방을 열고 있는지 알 수 있다.
'useChatRoomManager'는 'selectedRoomId'와 채팅 목록을 비교해 해당 방을 다시 선택하므로,
새로고침하거나 URL로 직접 들어와도 같은 방을 열 수 있다.
채팅방을 퇴장할 때 함께 정리할 상태
퇴장 처리는 상태 하나만 비운다고 끝나지 않았다.
기존 코드는 소켓을 먼저 끊고 퇴장 API를 호출했다.
요청이 실패하면 사용자는 채팅방에 남아 있는데 소켓만 끊긴 상태가 될 수 있다.
이제는 서버 요청이 성공한 뒤에만 소켓을 해제한다.
이어서 선택 상태와 URL을 비우고, 채팅 목록과 히스토리 캐시도 정리한다.
// use-chat.ts
// 서버 퇴장 요청이 성공한 경우에만 소켓과 선택 상태를 정리
await leaveChatRoom(selectedChat.roomId);
disconnect();
setSelectedChat(null);
setRoomId(null);
// use-leave-chat-room.ts
onSuccess: (_, roomId) => {
// 모든 채팅 목록 캐시에서 나간 방을 즉시 제거
queryClient.setQueriesData<ChatListItemType[]>(
{ queryKey: [QUERY_KEY.auth, QUERY_KEY.chatList] },
(chatList) => chatList?.filter((chat) => chat.roomId !== roomId),
);
// 해당 방의 이전 메시지 캐시까지 제거
queryClient.removeQueries({
queryKey: [QUERY_KEY.auth, QUERY_KEY.chatHistory, roomId],
});
}
이 순서로 바꾸면서 퇴장 뒤에 이전 채팅방의 닉네임이나 메시지가 남는 문제를 줄였다.
읽음 처리에서는 반대로 채팅 히스토리를 다시 불러오지 않고, 읽지 않은 수에 영향받는 채팅 목록만 갱신하도록 범위를 좁혔다.
캐시를 갱신할 때와 다시 요청할 때
Tanstack Query를 쓴다고 해서 모든 변경 뒤에 'invalidQueries'를 호출할 필요는 없다.
소켓으로 받은 메시지는 화면에 표시할 데이터 자체를 담고 있다.
그래서 히스토리에서는 'setQueryData'를 사용했다.
'setQueryData'는 캐시를 동기적으로 갱신하므로, 수신 시점에 메시지를 화면에 반영하기 적합하다.
채팅 목록은 성격이 다르다.
첫 메시지가 전송된 뒤의 마지막 메시지, 정렬 순서, 읽지 않은 수는 서버가 계산한 결과를 다시 받아야 한다.
이때는 목록 쿼리를 'invalidateQueries'로 무효화한다.
활성 상태인 쿼리는 기본적으로 백그라운드에서 다시 요청된다.
// 서버가 계산하는 목록 요약 정보를 다시 가져오도록 무효화
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.auth, QUERY_KEY.chatList],
exact: false,
});
퇴장 후에는 이전 방의 히스토리를 더 이상 사용할 이유가 없다.
이 경우에는 오래된 상태로 남겨 두는 'invalidateQueries' 대신 'removeQueries'로 캐시 자체를 지웠다.
다음 화면 전환에서 이전 메시지가 잠깐 보일 경우를 줄이기 위해서다.
읽음 처리도 같은 기준으로 정리했다.
'useMarkAsRead'는 기존에 채팅 목록과 채팅 히스토리를 모두 무효화했지만, 읽음 처리는 메시지 본문을 바꾸지 않는다.
최종 코드에서는 'unReadCount'에 영향을 받는 채팅 목록만 갱신한다.
메시지가 바뀔 때만 하는 그룹화
채팅방은 메시지를 날짜와 시간 기준으로 묶어 렌더링한다.
(이 로직을 구현하며 UI에 연결하는 부분이 개인적으로 상당히 복잡한 부분 중 하나였다.)
메시지가 많아질수록 이 작업의 비용도 커진다.
이전에는 입력창에 글자를 쓰는 것처럼 메시지와 관계없는 렌더링이 일어나도 매번 그룹화가 실행됐다.
// 메시지나 작성자 판별 함수가 바뀔 때만 날짜·시간 기준으로 그룹화
const groupedMessages = useMemo(
() => groupByDateAndTime(messages, isMyMessage),
[messages, isMyMessage],
);
'isMyMessage'와 내부의 'getMyId'도 'useCallback'으로 고정했다.
함수 참조가 매번 바뀌지 않아야 'useMemo'도 의도대로 동작한다.
작은 변경처럼 보여도 입력과 렌더링이 잦은 채팅 화면에서는 데이터 가공을 언제 실행할지 분명히 정하는 일이 중요하다.
refactoring 뒤 확인할 흐름
이런 변경은 코드만 읽고 끝낼 수 없다.
모집글에서 처음 채팅을 시작했을 때 첫 메시지 뒤에 실제 'roomId'가 반영되는지,
기존 방을 선택했을 때 URL의 'roomId'가 바뀌는지,
새로고침 뒤에도 같은 방이 열리는지 확인해야 한다.
메시지 수신에서는 같은 id의 메시지가 한 번만 렌더링되는지 살펴봐야 한다.
퇴장에서는 API가 실패했을 때 소켓이 유지되는지, 성공했을 때 목록, 히스토리 캐시와 URL이 함께 정리되는지도 확인 대상이다.
상대방이 먼저 방을 나간 뒤 내가 퇴장하는 흐름에서도 이전 메시지가 남지 않는지 점검할 필요가 있다.
마치며
이번 작업에서 얻은 기준은 단순하다.
같은 데이터를 두 곳에서 관리하지 않고, 화면 상태와 URL, 캐시, 소켓이 같은 사실을 가리키게(SSOT) 만드는 것이다.
[ SSOT(Single Source Of Truth) ]
하나의 데이터를 여러 상태에 복제해 각각 갱신하는 대신, 애플리케이션이 신뢰할 기준점을 한 곳에 두는 원칙이다.
여기서 구현한 채팅에서는 메시지의 기준점을 'chatHistory' 쿼리 캐시로 정했다.
REST로 받은 히스토리와 소켓으로 받은 실시간 메시지는 모두 같은 캐시에 반영하고, UI는 그 결과만 읽는다.
어느 상태가 최신인지 다시 판단하거나 두 배열을 합치는 일을 줄이는 방식이다.
파일을 나눈 것만으로 refactoring이 끝난 것은 아니다.
메시지의 기준점을 캐시로 정하고, 채팅방을 선택하는 기준을 URL로 드러냈다.
퇴장처럼 여러 상태가 얽힌 동작은 완료 순서까지 설계했다.
이런 기준을 먼저 세운 뒤에야 훅을 나눈 구조도 안정적으로 동작했다.
출처
이 글은 dodream-fe PR #184를 바탕으로 작성했다.
refactor: `use-chat` 커스텀 훅 로직 분리 및 최적화 by mj0107 · Pull Request #184 · swyp-dodream/dodream-fe
📋 변경 사항 기존 use-chat.ts 커스텀 훅에 집중된 역할을 분산시키고, 채팅방 렌더링 시 발생하는 버그 및 성능 향상을 위한 리팩터링 및 렌더링 최적화를 진행했습니다. 관심사 분리를 통한 하
github.com
캐시 API의 동작은 Tanstack Query QueryClient 문서를 참고했다.
QueryClient | TanStack Query Docs
QueryClient The QueryClient can be used to interact with a cache: tsx import { QueryClient } from '@tanstack/react-query' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime:...
tanstack.com
'Project > 두드림' 카테고리의 다른 글
| [스위프 웹 11기 후기] - 팀 빌딩부터 네트워킹까지 (2) | 2025.12.13 |
|---|---|
| 프로젝트에 Ncloud를 사용해보자 (1) | 2025.11.25 |