-
채팅구현3 - 채팅방 내부 구현Mate프로젝트 2025. 6. 21. 19:55728x90
chatRoomPage 서론
채팅구현을 위한 마지막 페이지, 채팅방 내부 구현을 이어서 작성하려고 한다.
컴포넌트 구조
구성 요소 및 기능
필요한 Side Effect들 (useEffect & useLayoutEffect)
- 초기 메시지 세팅: 서버 데이터를 화면 표시용으로 변환 및 Set 초기화
- 페이지 진입/복귀 시 강제 동기화: focus/visibilitychange 이벤트로 실시간 동기화
- 첫 진입 시 하단 스크롤: 최신 메시지를 보여주기 위한 스크롤 조정
- 스크롤 관리: 새 메시지 도착 시 조건부 자동 스크롤 처리
- 컴포넌트 언마운트 시 정리: 채팅방 리스트 캐시 무효화
핵심 이벤트 핸들러들
- handleLoadMore: 과거 메시지 불러오기 및 스크롤 위치 보정
- handleReceive: 웹소켓 메시지 수신 및 옵티미스틱 업데이트 처리
- handleSend: 메시지 전송 및 옵티미스틱 업데이트
- handleBack: 뒤로가기 시 리스트 동기화
- handleLeave: 채팅방 나가기 (미구현)
웹소켓 연결
useChatSocket 훅을 통한 실시간 채팅 연결 및 에러 처리
1. page.tsx
'use client'; import React from 'react'; import ChatRoomPage from '@/app/chat/page-components/ChatRoomPage'; import { useParams } from 'next/navigation'; import useChatRoomList from '@/hooks/query/useChatRoomList'; export default function ChatRoomDetailPage() { const params = useParams(); const roomToken = params?.roomId as string; const { data: roomList } = useChatRoomList(); const room = roomList?.find((r) => r.room_token === roomToken); const headerInfo = room ? { profileUrl: room.other_user_profile_url, nickname: room.other_user_nick_name, productTitle: room.product_title, } : { profileUrl: '', nickname: '', productTitle: '' }; return <ChatRoomPage roomToken={roomToken} headerInfo={headerInfo} />; }
클라이언트 페이지로서 최상단 부모컴포넌트는 채팅방 리스트 페이지에서 클릭된 아이템들을 채팅방 내부와 연결시켜주는 브릿지 역할만을 담당하였다. (현재 잘못된 값은 빈문자열로 임시 처리)
2. ChatRoomPage.tsx
더보기'use client'; import React, { useState, useEffect, useCallback, useRef, useLayoutEffect, } from 'react'; import { useRouter } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; import { useAuthStore } from '@/store/authStore'; import ChatRoomHeader from './ChatRoomHeader'; import ChatMessages, { ChatMessageData } from './ChatMessages'; import ChatInput from './ChatInput'; import useChatMessages from '@/hooks/query/useChatMessages'; import { useChatSocket } from '@/hooks/chat/useChatSocket'; import { apiClient } from '@/utils/api/api'; import { chatMessagesResponseSchema } from '@/schemas/api/chat.schema'; import { chatURL } from '@/service/endpoints/endpoints'; interface ChatRoomPageProps { roomToken: string; headerInfo: { profileUrl: string; nickname: string; productTitle: string; }; } const ChatRoomPage: React.FC<ChatRoomPageProps> = ({ roomToken, headerInfo, }) => { const router = useRouter(); const queryClient = useQueryClient(); const { user } = useAuthStore(); const myUserId = user?.user_id; // 채팅 내역 불러오기 - refetch 함수도 가져오기 const { data, refetch } = useChatMessages(roomToken); const [messages, setMessages] = useState<ChatMessageData[]>([]); const [hasNext, setHasNext] = useState(false); const [loadingMore, setLoadingMore] = useState(false); // 중복 메시지 방지용 id Set const messageIdSet = useRef<Set<string | number>>(new Set()); const optimisticMessages = useRef<Set<string | number>>(new Set()); const chatListRef = useRef<HTMLDivElement>(null); const isPrepending = useRef(false); const prevMessagesLength = useRef(0); // 초기 메시지 세팅 useEffect(() => { if (data?.messages) { const sorted = [...data.messages].sort( (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ); const mapped = sorted.map((msg) => ({ id: msg.id, message: msg.message, isMine: myUserId ? msg.sender_id === myUserId : false, time: msg.created_at ? new Date(msg.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }) : undefined, senderProfileUrl: msg.sender_profile_url, senderName: msg.sender_name, })); setMessages(mapped); setHasNext(!!data.has_next); // id Set 초기화 messageIdSet.current.clear(); optimisticMessages.current.clear(); mapped.forEach((m) => messageIdSet.current.add(m.id)); } }, [data, myUserId]); // 페이지 진입/복귀 시 강제 동기화 useEffect(() => { const handleFocus = () => { // 포커스 복귀 시 메시지 다시 불러오기 refetch(); }; const handleVisibilityChange = () => { if (!document.hidden) { // 페이지가 다시 보일 때 동기화 refetch(); } }; // 페이지 진입 시 즉시 동기화 refetch(); window.addEventListener('focus', handleFocus); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('focus', handleFocus); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [refetch, roomToken]); // 첫 진입 시 하단 스크롤 useEffect(() => { if (chatListRef.current && messages.length > 0) { chatListRef.current.scrollTop = chatListRef.current.scrollHeight; } }, [messages.length > 0]); // 더 불러오기 핸들러 const handleLoadMore = async () => { if (loadingMore || messages.length === 0) return; const container = chatListRef.current; const prevScrollHeight = container?.scrollHeight || 0; const prevScrollTop = container?.scrollTop || 0; try { setLoadingMore(true); const oldestId = messages[0].id; const url = chatURL.getMessages.replace('roomToken', roomToken) + `?cursorId=${oldestId}`; const res = await apiClient.get(url, { schema: chatMessagesResponseSchema, }); if (res?.messages) { const sorted = [...res.messages].sort( (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ); const mapped = sorted.map((msg) => ({ id: msg.id, message: msg.message, isMine: myUserId ? msg.sender_id === myUserId : false, time: msg.created_at ? new Date(msg.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }) : undefined, senderProfileUrl: msg.sender_profile_url, senderName: msg.sender_name, })); const toAdd = mapped.filter((m) => !messageIdSet.current.has(m.id)); if (toAdd.length > 0) { isPrepending.current = true; setMessages((prev) => [...toAdd, ...prev]); toAdd.forEach((m) => messageIdSet.current.add(m.id)); setHasNext(!!res.has_next); // prepend 후 스크롤 위치 보정 setTimeout(() => { if (container) { const newScrollHeight = container.scrollHeight; container.scrollTop = newScrollHeight - prevScrollHeight + prevScrollTop; } isPrepending.current = false; }, 20); } } } finally { setLoadingMore(false); } }; // 웹소켓 메시지 수신 const handleReceive = useCallback( (msg: any) => { if (!myUserId || msg.type !== 'TALK') return; const msgId = msg.id || `ws-${Date.now()}-${Math.random()}`; // 이미 처리된 메시지인지 확인 if (messageIdSet.current.has(msgId)) { return; } // 내가 보낸 메시지인 경우 - 옵티미스틱 메시지와 병합 if (msg.sender_id === myUserId) { setMessages((prev) => { const optimisticIndex = prev.findIndex( (m) => optimisticMessages.current.has(m.id) && m.message === msg.message && m.isMine ); if (optimisticIndex !== -1) { // 옵티미스틱 메시지를 실제 메시지로 교체 const newMessages = [...prev]; const oldId = newMessages[optimisticIndex].id; newMessages[optimisticIndex] = { id: msgId, message: msg.message, isMine: true, time: msg.created_at ? new Date(msg.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }) : undefined, senderProfileUrl: msg.sender_profile_url, senderName: msg.sender_name, }; // ID 관리 업데이트 optimisticMessages.current.delete(oldId); messageIdSet.current.delete(oldId); messageIdSet.current.add(msgId); return newMessages; } // 옵티미스틱 메시지가 없으면 새로 추가 if (!messageIdSet.current.has(msgId)) { messageIdSet.current.add(msgId); return [ ...prev, { id: msgId, message: msg.message, isMine: true, time: msg.created_at ? new Date(msg.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }) : undefined, senderProfileUrl: msg.sender_profile_url, senderName: msg.sender_name, }, ]; } return prev; }); } else { // 상대방이 보낸 메시지 messageIdSet.current.add(msgId); setMessages((prev) => [ ...prev, { id: msgId, message: msg.message, isMine: false, time: msg.created_at ? new Date(msg.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }) : undefined, senderProfileUrl: msg.sender_profile_url, senderName: msg.sender_name, }, ]); // 상대방 메시지 받으면 채팅방 리스트도 업데이트 queryClient.invalidateQueries({ queryKey: ['chat', 'roomList'] }); } }, [myUserId, queryClient] ); // 웹소켓 연결 const { sendMessage } = useChatSocket({ roomToken, onMessage: handleReceive, onError: (err) => { if (window.location.pathname.startsWith('/chat')) { console.error('WebSocket error:', err); alert('채팅 서버와 연결이 끊어졌습니다.'); router.push('/'); } }, enabled: !!myUserId && !!roomToken, }); // 메시지 전송 const handleSend = useCallback( (msg: string) => { if (!msg.trim() || !myUserId) return; const now = new Date(); const tempId = `optimistic-${Date.now()}-${Math.random()}`; // 옵티미스틱 메시지 추가 const optimisticMessage: ChatMessageData = { id: tempId, message: msg, isMine: true, time: now.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }), senderProfileUrl: user?.user_url, senderName: user?.user_nickname, }; setMessages((prev) => [...prev, optimisticMessage]); messageIdSet.current.add(tempId); optimisticMessages.current.add(tempId); // 웹소켓으로 메시지 전송 sendMessage({ message: msg, type: 'TALK', room_token: roomToken, }); // 메시지 전송 후 채팅방 리스트도 업데이트 setTimeout(() => { queryClient.invalidateQueries({ queryKey: ['chat', 'roomList'] }); }, 100); }, [myUserId, user, sendMessage, roomToken, queryClient] ); // 스크롤 관리 useLayoutEffect(() => { if (!isPrepending.current && chatListRef.current) { const container = chatListRef.current; const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100; if (nearBottom || messages.length > prevMessagesLength.current) { container.scrollTop = container.scrollHeight; } } prevMessagesLength.current = messages.length; }, [messages]); // 뒤로가기 시 채팅방 리스트 강제 업데이트 const handleBack = useCallback(() => { // 채팅방 리스트 캐시 무효화 queryClient.invalidateQueries({ queryKey: ['chat', 'roomList'] }); // 읽지 않은 메시지 수 초기화 (선택사항) // queryClient.invalidateQueries({ queryKey: ['unread'] }); router.back(); }, [router, queryClient]); const handleLeave = () => { alert('채팅방을 나가시겠습니까? (구현 필요)'); }; // 컴포넌트 언마운트 시 정리 useEffect(() => { return () => { // 컴포넌트 떠날 때 채팅방 리스트 업데이트 queryClient.invalidateQueries({ queryKey: ['chat', 'roomList'] }); }; }, [queryClient]); return ( <div className='fixed left-0 right-0 bottom-0 top-[65px] flex flex-col max-w-lg w-full mx-auto bg-bgDark text-textPrimary z-40'> <div className='sticky top-0 z-20 bg-bgDark flex-shrink-0'> <ChatRoomHeader profileUrl={headerInfo.profileUrl} nickname={headerInfo.nickname} productTitle={headerInfo.productTitle} onBack={handleBack} onLeave={handleLeave} /> </div> <div className='flex-1 min-h-0 flex flex-col'> <ChatMessages ref={chatListRef} messages={messages} hasNext={hasNext} onLoadMore={handleLoadMore} /> </div> <div className='sticky bottom-0 z-20 bg-bgLight flex-shrink-0'> <ChatInput onSend={handleSend} /> </div> </div> ); }; export default ChatRoomPage;
해당 페이지는 실시간 채팅방의 핵심 로직인 메시지 송수신, 실시간 동기화, 스크롤 관리, 옵티미스틱 업데이트 등 복잡한 기능들을 담당한다.
상태관리
const [messages, setMessages] = useState<ChatMessageData[]>([]); const [hasNext, setHasNext] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
현재화면에 표시할 메시지들의 배열은 messages, 채팅내역 조회 시, res 바디 응답필드에 hasNext의 값을 hasNext State로 관리하여 채팅 더 불러오기 버튼 상태관리, 그리고 이 버튼을 통해 과거 메시지를 가져올 때의 로딩을 위한 loadingMore 이 3가지로 분리하였다.
중복 방지용 Set
const messageIdSet = useRef<Set<string | number>>(new Set()); const optimisticMessages = useRef<Set<string | number>>(new Set());
messageIdSet을 통해 이미 화면에 표시된 메시지들의 ID를 저장한다. 웹소켓이나 Rest API 에서 같은 메시지 중복을 거르기 위함, 그리고 optimisticMessages에는 사용자가 보낸 메시지 중 아직 서버 확인을 받지 못한 임시 메시지들 ID를 저장한다.
메시지 중복은 옵티미스틱 업데이트와 내가진짜 서버에 보낸 후 응답으로 오는 메시지 그리고 http 메소드로 처음 데이터를 불러온 응답값들 사이에서 중복을 전부 제거하기 위함. 따라 new Set()으로 선언 후, 위 조건들을 모두 고려하기 위하여 하나의 집합 안에 순서상관없는 고유한 값들만을 넣어둔다.
++) Set 이란
더보기Set은 JavaScript/TypeScript의 내장 자료구조로, 중복을 허용하지 않는 값들의 집합
(배열에 비해 시간복잡도의 성능상 이점)
// 일반 배열 - 중복 허용 const array = [1, 2, 2, 3, 3, 3]; // [1, 2, 2, 3, 3, 3] // Set - 중복 자동 제거 const set = new Set([1, 2, 2, 3, 3, 3]); // Set(3) {1, 2, 3}
주요 메소드
const mySet = new Set(); // 추가 mySet.add("hello"); mySet.add("world"); mySet.add("hello"); // 중복이므로 추가되지 않음 // 확인 mySet.has("hello"); // true mySet.has("bye"); // false // 삭제 mySet.delete("hello"); // 초기화 mySet.clear(); // 크기 mySet.size; // 현재 Set의 크기
DOM 상태 추적
const chatListRef = useRef<HTMLDivElement>(null); const isPrepending = useRef(false); const prevMessagesLength = useRef(0);
chatListRef: 메시지 리스트 컨테이너의 DOM 요소를 직접 참조합니다. 스크롤 위치 조작을 위해 필요
// 첫 진입 시 하단 스크롤 useEffect(() => { if (chatListRef.current && messages.length > 0) { chatListRef.current.scrollTop = chatListRef.current.scrollHeight; } }, [messages.length > 0]); // 스크롤 관리 useLayoutEffect(() => { if (!isPrepending.current && chatListRef.current) { const container = chatListRef.current; const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100; if (nearBottom || messages.length > prevMessagesLength.current) { container.scrollTop = container.scrollHeight; } } prevMessagesLength.current = messages.length; }, [messages]);
isPrepending: 상단에 과거 메시지를 추가하는 중인지를 추적합니다. 이때는 자동 스크롤을 하지 않기 위해 사용
prevMessagesLength: 이전 메시지 개수를 기억해서 새 메시지가 추가되었는지 판단
초기 메시지 세팅
const { data, refetch } = useChatMessages(roomToken);
데이터를 받아와 초기 메시지를 세팅한다.
useEffect(() => { if (data?.messages) { const sorted = [...data.messages].sort( (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ); const mapped = sorted.map((msg) => ({ id: msg.id, message: msg.message, isMine: myUserId ? msg.sender_id === myUserId : false, time: msg.created_at ? new Date(msg.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }) : undefined, senderProfileUrl: msg.sender_profile_url, senderName: msg.sender_name, })); setMessages(mapped); setHasNext(!!data.has_next); // id Set 초기화 messageIdSet.current.clear(); optimisticMessages.current.clear(); mapped.forEach((m) => messageIdSet.current.add(m.id)); } }, [data, myUserId]);
이 사이드 이펙트는 먼저 sort함수를 이용해 시간순으로 메세지를 정렬한다. (여기서 워본 data배열이 아닌 sorted라는 복사본을 만들어서 사용하는 이유는 불변성을 유지해야하기 떄문. 그리고 날짜비교를 위해 Date객체로 변환 후 숫자로 비교한다. )
그리고 정렬된 데이터를 매핑을 하는데, isMine을 통해 나의 메시지인지 상대의 메시지인지 확인 하고, time필드는 보기편한 한국 시간으로 계산한다. 그 후 state를 업데이트 한다.
제일 하단에 id Set초기화는 다른 채팅방 이동 시, 중복을 방지하기위해 초기화.
서버 원본 vs 매핑 후
더보기// 서버 원본 { id: "msg-123", message: "안녕하세요", sender_id: 456, sender_name: "김철수", sender_profile_url: "https://...", created_at: "2025-01-15T14:30:25.123Z", type: "TALK", room_token: "room-789" } // 매핑 후 { id: "msg-123", message: "안녕하세요", isMine: true, // ← 계산된 값 time: "오후 11:30", // ← 포맷팅된 시간 senderProfileUrl: "https://...", senderName: "김철수" }
자동 업데이트 useEffect 및 하단 스크롤 useEffect
// 페이지 진입/복귀 시 강제 동기화 useEffect(() => { const handleFocus = () => { refetch(); }; const handleVisibilityChange = () => { if (!document.hidden) { refetch(); } }; refetch(); window.addEventListener('focus', handleFocus); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('focus', handleFocus); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [refetch, roomToken]); // 첫 진입 시 하단 스크롤 useEffect(() => { if (chatListRef.current && messages.length > 0) { chatListRef.current.scrollTop = chatListRef.current.scrollHeight; } }, [messages.length > 0]);
첫번째 useEffect는 앞서 채팅구현 2 글에서 소개한 것과 비슷한 글이라 요약하자면 실시간 동기화를 위한 브라우저 메소드 사용을 진행한다.
두번째 useEffect는 첫 진입시에만 스크롤을 하단으로 내리기 위한 사이드 이펙트이다. 여기서 첫 진입시에만 사용하는 즉, 마운트에만 동작해야할 의존성 배열이 빈배열이아닌 [messages.length > 0]인 이유는 크게 2가지인데 첫번째는 타이밍의 문제로서, 아직 messages가 데이터페칭이 동작하고있는 와중에서 배열이 세팅되지 않은 상태 중 실행될 가능성을 배제하기 위함이다. 그리고 한번 세팅된 값들은 messages의 length가 이미 0보다 크기 때문에 사실상 값이 변경될 수가 없다. 두번째는 아래에서 소개할 스크롤 관리 로직(useLayoutEffect)과 충돌을 방지하기 위함이다.
스크롤 관리를 위한 useLayoutEffect
// 스크롤 관리 useLayoutEffect(() => { if (!isPrepending.current && chatListRef.current) { const container = chatListRef.current; const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100; if (nearBottom || messages.length > prevMessagesLength.current) { container.scrollTop = container.scrollHeight; } } prevMessagesLength.current = messages.length; }, [messages]);
이 useLayoutEffect는 언제 자동 스크롤할지 결정하기 위해 사용한다.
자동 스크롤 하는 경우:
- 사용자가 하단 근처(100px 이내)에 있으면서 새 메시지가 올 때
- 새 메시지가 추가될 때 (위치 상관없이, 주로 내가 보낸 메시지)
자동 스크롤 하지 않는 경우:
- 과거 메시지를 불러오는 중일 때 (isPrepending = true)
- 사용자가 위쪽 메시지를 보고 있을 때 새 메시지가 올 때
++) scrollTop - DOM 표준 속성
더보기// 모든 스크롤 가능한 HTML 요소가 가진 속성
element.scrollTop // 현재 스크롤 위치 (읽기/쓰기)
element.scrollLeft // 가로 스크롤 위치
element.scrollHeight // 전체 스크롤 가능한 높이 (읽기 전용)
element.scrollWidth // 전체 스크롤 가능한 너비 (읽기 전용)
element.clientHeight // 실제 보이는 영역의 높이
element.clientWidth // 실제 보이는 영역의 너비++)useEffect vs useLayoutEffect
더보기차이는 실행 시점이다.
useEffect는 비동기로 실행되기 떄문에 DOM이 업데이트 된 후에 실행된다. 반대로 useLayoutEffect는 동기적으로 실행되어 DOM이 업데이트 되기 전에 실행된다.
이 스크롤의 조작은 DOM을 변경하기 떄문에 useLayoutEffect를 써야 깜박임 없이 부드러운 스크롤 조작이 가능해진다.이벤트 헨들러
handleLoadMore - 과거 메시지 불러오기.
더보기const handleLoadMore = async () => { if (loadingMore || messages.length === 0) return; const container = chatListRef.current; const prevScrollHeight = container?.scrollHeight || 0; const prevScrollTop = container?.scrollTop || 0; try { setLoadingMore(true); const oldestId = messages[0].id; const url = chatURL.getMessages.replace('roomToken', roomToken) + `?cursorId=${oldestId}`; const res = await apiClient.get(url, { schema: chatMessagesResponseSchema, }); if (res?.messages) { const sorted = [...res.messages].sort( (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ); const mapped = sorted.map((msg) => ({ id: msg.id, message: msg.message, isMine: myUserId ? msg.sender_id === myUserId : false, time: msg.created_at ? new Date(msg.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }) : undefined, senderProfileUrl: msg.sender_profile_url, senderName: msg.sender_name, })); const toAdd = mapped.filter((m) => !messageIdSet.current.has(m.id)); if (toAdd.length > 0) { isPrepending.current = true; setMessages((prev) => [...toAdd, ...prev]); toAdd.forEach((m) => messageIdSet.current.add(m.id)); setHasNext(!!res.has_next); // prepend 후 스크롤 위치 보정 setTimeout(() => { if (container) { const newScrollHeight = container.scrollHeight; container.scrollTop = newScrollHeight - prevScrollHeight + prevScrollTop; } isPrepending.current = false; }, 20); } } } finally { setLoadingMore(false); } };
동작과정은 먼저 초기 검증 및 스크롤 위치를 저장한다.
if (loadingMore || messages.length === 0) return; const container = chatListRef.current; const prevScrollHeight = container?.scrollHeight || 0; const prevScrollTop = container?.scrollTop || 0;
과거 메시지를 상단에 추가하면 전체 높이가 늘어나 사용자가 보던 위치가 아래로 밀려나기 때문에 현재 스크롤 위치를 기억하고 후 보정을 해야한다. 그다음 try catch문을 통해 데이터 페칭을 하고 현재 배열 이전에 메세지를 삽입한다.(커서 기반 페이징 및 filter메서드로 중복데이터 제거)
그다음 스크롤을 보정하기위한 setTimeout 메서드를 호출하는데, react의 상태 업데이트는 비동기적이고 실제 DOM이 업데이트 되기전까지의 시간이 필요하다 따라 20ms를 직접 선언하여 보장한다.
setTimeout(() => { if (container) { const newScrollHeight = container.scrollHeight; container.scrollTop = newScrollHeight - prevScrollHeight + prevScrollTop; } isPrepending.current = false; }, 20);
스크롤 보정 예시
더보기이전 상태:
┌─────────────────┐ ← scrollTop = 800
│ 사용자가 보던 │ ← prevScrollTop = 800
│ 메시지 위치 │ ← prevScrollHeight = 2000
└─────────────────┘
새 메시지 20개 추가 후:
┌─────────────────┐ ← 새로 추가된 메시지들 (500px)
├─────────────────┤ ← scrollTop = ? (계산해야 함)
│ 사용자가 보던 │ ← 이 위치를 유지해야 함
│ 메시지 위치 │ ← newScrollHeight = 2500
└─────────────────┘
계산: newScrollTop = 2500 - 2000 + 800 = 1300handleReceive - 웹소켓 메시지 수신
더보기const handleReceive = useCallback( (msg: any) => { if (!myUserId || msg.type !== 'TALK') return; const msgId = msg.id || `ws-${Date.now()}-${Math.random()}`; // 이미 처리된 메시지인지 확인 if (messageIdSet.current.has(msgId)) { return; } // 내가 보낸 메시지인 경우 - 옵티미스틱 메시지와 병합 if (msg.sender_id === myUserId) { setMessages((prev) => { const optimisticIndex = prev.findIndex( (m) => optimisticMessages.current.has(m.id) && m.message === msg.message && m.isMine ); if (optimisticIndex !== -1) { // 옵티미스틱 메시지를 실제 메시지로 교체 const newMessages = [...prev]; const oldId = newMessages[optimisticIndex].id; newMessages[optimisticIndex] = { id: msgId, message: msg.message, isMine: true, time: msg.created_at ? new Date(msg.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }) : undefined, senderProfileUrl: msg.sender_profile_url, senderName: msg.sender_name, }; // ID 관리 업데이트 optimisticMessages.current.delete(oldId); messageIdSet.current.delete(oldId); messageIdSet.current.add(msgId); return newMessages; } // 옵티미스틱 메시지가 없으면 새로 추가 if (!messageIdSet.current.has(msgId)) { messageIdSet.current.add(msgId); return [ ...prev, { id: msgId, message: msg.message, isMine: true, time: msg.created_at ? new Date(msg.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }) : undefined, senderProfileUrl: msg.sender_profile_url, senderName: msg.sender_name, }, ]; } return prev; }); } else { // 상대방이 보낸 메시지 messageIdSet.current.add(msgId); setMessages((prev) => [ ...prev, { id: msgId, message: msg.message, isMine: false, time: msg.created_at ? new Date(msg.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }) : undefined, senderProfileUrl: msg.sender_profile_url, senderName: msg.sender_name, }, ]); // 🔥 상대방 메시지 받으면 채팅방 리스트도 업데이트 queryClient.invalidateQueries({ queryKey: ['chat', 'roomList'] }); } }, [myUserId, queryClient] );
이 함수는 가장 크게 내가 보낸 메세지인지 내가 수신한 메세지인지 크게 두가지 틀에서 확인한다.
가정 먼저 메세지 ID를 세팅 하고 Set내부에서 이미 처리된 ID값이 있는지를 확인한다. 그후 조건문을 통해 먼저 내가 보낸 메세지인 경우에는 message배열에서 3가지의 조건을 통해 확인하는데, 먼저 optimisticMessages.current.has(m.id)를 통해 진짜 임시 메시지만을 찾고 m.message === msg.message 메세지 내용이 같은지도 확인한다. 그리고 안정성을 위해 isMine을 통해 최종적으로 판단후 해당 인덱스 넘버를 할당한다. 그 후 (optimisticIndex ! == -1)을 통해 중첩 조건문을 수행한다.
const newMessages = [...prev]; const oldId = newMessages[optimisticIndex].id;
그 다음 불변성을 위해 새로운 배열을 복사해서 newMessages에 담고, 중복제거를 위해 oldId를 선언하고 옵티미스틱 Id값을 할당한다. 그리고
newMessages[optimisticIndex] = { id: msgId, // 서버에서 받은 실제 ID message: msg.message, isMine: true, time: msg.created_at ? new Date(msg.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }) : undefined, senderProfileUrl: msg.sender_profile_url, senderName: msg.sender_name, };
서버에서 받은 실제의 ID 값으로 변환 하고
optimisticMessages.current.delete(oldId); // 임시 ID 제거 messageIdSet.current.delete(oldId); // 임시 ID 제거 messageIdSet.current.add(msgId); // 실제 ID 추가
임시의 ID를 전부 제거한다. 그리고 안정성을 위해 옵티미스틱 메시지가 없는 경우도 조건문으로 안전하게 처리한다. 이는 MATE가 모바일에서 서비스가 가능하기 때문에 안정성을 더했다.
// 옵티미스틱 메시지가 없으면 새로 추가 if (!messageIdSet.current.has(msgId)) { messageIdSet.current.add(msgId); return [ ...prev, { id: msgId, message: msg.message, isMine: true, time: msg.created_at ? new Date(msg.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }) : undefined, senderProfileUrl: msg.sender_profile_url, senderName: msg.sender_name, }, ]; }
마지막으로 상대가 보낸 메세지의 경우는 쉽게 추가한다.
웹소켓 연결
// 웹소켓 연결 const { sendMessage } = useChatSocket({ roomToken, onMessage: handleReceive, onError: (err) => { if (window.location.pathname.startsWith('/chat')) { console.error('WebSocket error:', err); alert('채팅 서버와 연결이 끊어졌습니다.'); router.push('/'); } }, enabled: !!myUserId && !!roomToken, });
handleSend - 메세지 전송
// 메시지 전송 const handleSend = useCallback( (msg: string) => { if (!msg.trim() || !myUserId) return; const now = new Date(); const tempId = `optimistic-${Date.now()}-${Math.random()}`; // 옵티미스틱 메시지 추가 const optimisticMessage: ChatMessageData = { id: tempId, message: msg, isMine: true, time: now.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', }), senderProfileUrl: user?.user_url, senderName: user?.user_nickname, }; setMessages((prev) => [...prev, optimisticMessage]); messageIdSet.current.add(tempId); optimisticMessages.current.add(tempId); // 웹소켓으로 메시지 전송 sendMessage({ message: msg, type: 'TALK', room_token: roomToken, }); setTimeout(() => { queryClient.invalidateQueries({ queryKey: ['chat', 'roomList'] }); }, 100); }, [myUserId, user, sendMessage, roomToken, queryClient] );
여기서 UX를 위해 옵티미스틱 메시지 ID를 앞서 언급한 배열들에 추가한다.
그리고 하위 컴포넌트들에게 앞서 정리한 데이터들을 prop으로 내린다.
return ( <div className='fixed left-0 right-0 bottom-0 top-[65px] flex flex-col max-w-lg w-full mx-auto bg-bgDark text-textPrimary z-40'> <div className='sticky top-0 z-20 bg-bgDark flex-shrink-0'> <ChatRoomHeader profileUrl={headerInfo.profileUrl} nickname={headerInfo.nickname} productTitle={headerInfo.productTitle} onBack={handleBack} onLeave={handleLeave} /> </div> <div className='flex-1 min-h-0 flex flex-col'> <ChatMessages ref={chatListRef} messages={messages} hasNext={hasNext} onLoadMore={handleLoadMore} /> </div> <div className='sticky bottom-0 z-20 bg-bgLight flex-shrink-0'> <ChatInput onSend={handleSend} /> </div> </div> );
이제 다음 글을 마지막으로 ChatInput과 렌더링을 위한 메시지 아이템을 설명하고 글을 마무리한다.
728x90'Mate프로젝트' 카테고리의 다른 글
#5 프로덕트 상세보기 페이지 구현 (1) 2025.01.19 #4 마크다운 에디터 구현 (1) 2025.01.09 #3 카카오 로그인 페이지 디자인 구현 (1) 2024.12.27 #2 프론트 세팅 및 홈페이지 구현 (2) 2024.12.26 #1-b 와이어 프레임 (3) 2024.12.26