https://taejinii.tistory.com/73
지난 글인 웹소켓 연결에 이어서 이번 포스트에서는 이전에 채팅했던 기록을 역순 무한스크롤로 불러오는 기능에 대해 포스트 해보겠습니다.
해당 기능구현에 있어 필요한 라이브러리는 React-query 하나만 있으면 충분합니다.
yarn add @tanstack/react-query
1. 이전 채팅 기록 역순으로 바꿔주기
import { useInfiniteQuery } from '@tanstack/react-query';
import { getChatHistory } from '@/service/chatRoom';
import { queryKeys } from '@/constant/query-keys';
export default function useGetChatHistory(roomId: string) {
const { data, fetchNextPage, isFetchingNextPage, hasNextPage } =
useInfiniteQuery({
queryKey: [queryKeys.CHAT_HISTORY_LIST, roomId],
queryFn: ({ pageParam = 0 }) => getChatHistory(roomId, pageParam),
enabled: !!roomId,
refetchOnWindowFocus: false,
getNextPageParam: (history) => {
const lowestId = history.content.sort((a, b) => a.id - b.id).at(0)?.id;
return lowestId;
},
select: (chatHistory) => {
const reversedChatContent = chatHistory.pages
.slice()
.reverse()
.flatMap((chat) => chat.content);
return {
pages: reversedChatContent,
pageParams: chatHistory.pageParams,
};
},
});
return { data, fetchNextPage, isFetchingNextPage, hasNextPage };
}
queryKey의 첫 번째 인자로는 객체로 관리하고 있는 문자열값을 넣어주었으며 두 번째 인자로는 현재 유저가 입장한 방번호를 넣었습니다.
보통 fetch가 어떠한 인자에 의존하는 경우 queryKey에도 fetcher가 의존하는 인자를 두 번째로 주입해주어야 합니다.
queryFn 에는 이전채팅기록을 불러오는 함수를 호출합니다.
getNextPageParam에서 반환되는 값은 queryFn의 pageParam으로 전달됩니다.
즉 무한스크롤을 사용하려면 현재 유저가 마지막으로 어떤 콘텐츠를 봤는지 백엔드에 값을 전달해줘야 합니다. 백엔드는 이 값을 바탕으로 콘텐츠를 어디서부터 다시 전달해 주면 될지 알아야 하기 때문입니다.
select에서는 받아온 데이터를 data에 반환 전 가공해 줄 수 있습니다. reverse()를 사용하게 되면 원본배열을 변경하기 때문에 slice()를 같이 사용해주어 배열을 유지시켜 주었습니다. 추가적으로 flatMap()을 사용하여 배열을 평탄화시켜주었습니다.
2. 실시간 채팅 배열과, 이전채팅을 합쳐보자.
import { usePathname } from 'next/navigation';
import useGetChatHistory from '@/hooks/queries/useGetChatHistory';
export default function ChatRoomBox({
currentChatList,
}: {
currentChatList: ChatType[];
}){
const roomId = usePathname().split('/')[2];
const {
data: chatHistory,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useGetChatHistory(roomId);
}
const mergedChatList = [...(chatHistory?.pages ?? []), ...currentChatList];
날짜별로 섹션을 구분해 주기 위하여 실시간 채팅기록을 담은 배열과 이전채팅기록을 하나의 배열로 합쳐줍니다.
3. 채팅기록들을 날짜별로 묶어주자.
import { ChatType } from '@/types/types';
import { format, parseISO } from 'date-fns';
interface DateSection {
[key: string]: ChatType[];
}
export default function makeDateSection(chatList: ChatType[]) {
const section: DateSection = {};
chatList.forEach((chat) => {
// 채팅에 표시하고자 하는 날짜 형식 변수
const date = format(parseISO(chat.createdDate), 'yyyy년 MM월 dd일');
// 섹션에 날짜가 "이미" 존재하는지 확인.
if (section[date]) {
// 섹션에 날짜가 "이미" 존재하고 있다면 해당 섹션에 채팅추가
section[date].push(chat);
} else {
// 섹션에 존재하지 않는 날짜라면 해당 날짜 섹션 생성하고 해당 섹션에 채팅 추가
section[date] = [chat];
}
});
return section;
}
4. 최종 가공된 채팅을 화면에 뿌려보자.
const chatListWithDateSection = makeDateSection(
mergedChatList && mergedChatList,
);
return (
<div
className="flex flex-col flex-1 p-4 overflow-y-scroll "
ref={chatContainerRef}
>
<div className="flex flex-col" ref={chatListRef}>
{Object.entries(chatListWithDateSection).map(
([date, chatList], index) => (
<Fragment key={`date-${index}`}>
<div className="mb-5 text-center text-grey-500 body4">{date}</div>
{chatList.map((chat) => (
<ChatItem key={chat.id} {...chat} userId={userInfo!.userId} />
))}
</Fragment>
),
)}
</div>
<div ref={bottomRef} />
</div>
);
makeDateSection에서 반환된 가공된 데이터를 chatListWithDateSection에 할당해 주고 이를 렌더링 해주었습니다.
chatListWithDateSection는 날짜가 "키" 채팅기록이"값" 이 쌍인 객체로 저장되기 때문에 이를 배열로 만들어주기 위해 Object.entries 메서드를 사용하여 변환해 주었고 이를 map을 사용해 주었습니다.
여기서 index를 key값으로 사용한 이유는 제가 개발하고 있는 프로젝트에서는 채팅기록이 사라지지 않기 때문입니다.
만약 채팅이 삭제가 가능했다면 index가 아닌 고유한 값을 key값으로 지정해야 합니다.
5. 무한스크롤 트리거 구현
채팅방의 스크롤 관련 로직을 useChatScroll이라는 커스텀훅으로 추상화하여 분리하였습니다.
const {
data: chatHistory,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useGetChatHistory(roomId);
const chatContainerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const chatListRef = useRef<HTMLDivElement>(null);
useChatScroll({
chatContainerRef,
bottomRef,
beforeChatLoadMore: fetchNextPage,
chatListRef,
shouldLoadMore: !isFetchingNextPage && !!hasNextPage,
});
무한스크롤 기능 구현을 위하여 유저의 현재 스크롤 위치를 사용하였습니다. 유저가 만약 채팅창의 스크롤 위치가 최상단이라면 다음 데이터를 불러오도록 구 현하였습니다.
// useChatScroll.ts
const loadMore = useRef(false);
useEffect(() => {
const topDiv = chatContainerRef?.current;
const handleScroll = () => {
const scrollTop = topDiv?.scrollTop;
if (scrollTop === 0 && shouldLoadMore) {
beforeChatLoadMore();
loadMore.current = true;
}
};
topDiv?.addEventListener('scroll', handleScroll);
return () => {
topDiv?.removeEventListener('scroll', handleScroll);
};
}, [shouldLoadMore, beforeChatLoadMore, chatContainerRef]);
이전채팅 불러올시 스크롤 유지 안되는 문제
일반적인 채팅방의 무한스크롤 방식이라면 유저가 화면을 최상단으로 올렸을때 이전 채팅기록들을 불러오고 스크롤은 유저가 보고있던 채팅을 유지해야 합니다.
하지만 현재 코드에서는 이전 채팅조회를 불러올때 스크롤이 최상단으로 따라 올라가 유저가 현재 보고 있던 채팅에서 벗어나는 현상이 발생하였습니다. 이를 해결하기 위해 Web API중 하나인 ResizeObserver API를 사용하여 해결하였습니다.
useEffect(() => {
const bottom = bottomRef.current!;
const chatCount = chatNodes.current;
// 채팅방 최초 입장시 스크롤 최하단으로 위치.
bottom.scrollIntoView();
const callback: ResizeObserverCallback = (entries) => {
entries.forEach((entry) => {
// 채팅이 추가 됐다면
if (chatCount !== entry.target.childNodes.length) {
// 데이터를 불러오는게 아니라면 채팅이 새롭게 추가된것이니 스크롤 하단으로 이동
if (!loadMore.current) {
// 채팅추가가 부드럽게 보이는 효과를 추가하기위해 setTimeout 설정
setTimeout(() => {
bottom.scrollIntoView({ behavior: 'smooth' });
}, 50);
} else {
// 데이터를 불러오는거라면 원래 보고있었던 채팅구역의 스크롤을 유지
chatContainerRef.current?.scrollTo({
top: entry.contentRect.height - scrollRecod.current,
});
loadMore.current = false;
}
}
chatNodes.current = entry.target.childNodes.length;
scrollRecod.current = entry.contentRect.height;
});
};
const Observer = new ResizeObserver(callback);
Observer.observe(chatListRef.current!, { box: 'border-box' });
return () => {
Observer.disconnect();
};
}, []);
ResizeObserver 이란?
DOM 요소의 크기와 위치 변경을 감지하는 데 사용됩니다. 감시할 요소를 선택한 후 요소의 크기가 변경된다면 콜백 함수를 호출하게 됩니다.
https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
6. 결과물
'FrontEnd' 카테고리의 다른 글
Next.js 프로젝트 폴더구조 개선 (1) | 2023.11.03 |
---|---|
React Query 의 useQuery 훅 공통 에러처리 (0) | 2023.10.22 |
리액트 Stompjs,Sockjs 실시간 채팅 구현 - (1) (0) | 2023.10.16 |
프론트엔드 클린코드란?(지극히 주관적) (1) | 2023.10.11 |
FrontEnd - WebSocket 에 대하여 (0) | 2023.08.03 |