상향식 무한 스크롤 구현해서 채팅 내역 조회 개선하기 (React Query)

설탕·2024년 7월 22일
1
post-thumbnail

토이 프로젝트에서 FAQ 챗봇을 만들었다.
FAQ 기능을 목표로 하고서는 흔히 사용되는 패턴인 목록-상세 UI와 채팅 UI를 비교해 보았다.

FAQ 기능을 채팅 UI로 구현하기

목록-상세 UI채팅 UI
FAQ 목록-상세FAQ 챗봇

전통적(?)으로 많이 접해온 목록-상세 UI로 만든다면 간단하겠지만, 챌린징 요소가 필요하기도 했고 이후 채팅 기능까지 덧붙일 가능성도 있었기 때문에 채팅 UI로 FAQ 챗봇을 구현했다.

채팅 UI에서 중요한 점은 채팅 내역이 남아야 한다는 것이다.
목록-상세 UI에서는 사용자가 FAQ를 한 번 조회하든 여러 번 조회하든 차이가 없지만, 채팅 UI에서는 챗봇 버튼을 클릭하여 조회한 FAQ 내역을 서버에 저장해서 다시 채팅방에 접속했을 때 이전 조회 기록이 남아있어야 한다.

💣 채팅 내역 UI 기존 구현 방식의 문제점: 불필요하게 많은 데이터 조회

= 그냥 무지성으로 refetch 때려버렸다...

처음에는 새로운 FAQ를 조회할 때마다 UI를 업데이트하기 위해 채팅 내역 전체를 계속해서 refetch하는 방식으로 구현했다. 그러나 변경되는 데이터뿐만 아니라 변경되지 않는 데이터도 계속해서 중복으로 조회하기 때문에 API 요청이 불필요하게 많아진다는 문제점이 있었다. 채팅 내역이 더 많이 쌓일수록 이 문제가 더 커질 것이기에 개선 방안을 생각해 보았다.

채팅 내역을 필요한 만큼만 조회하기 위해서 다음 2가지 방안으로 개선했다.

  1. 무한 스크롤을 적용하여 채팅 내역 전체를 한꺼번에 불러오지 않고 조금씩 나누어서 불러온다.
  2. FAQ 조회할 때마다 전체 채팅 내역을 다시 불러오지 않고 새로 조회한 FAQ 데이터만 내역에 추가하여 UI를 업데이트한다.

🔧 개선 1. 무한 스크롤

채팅 내역을 한꺼번에 모두 불러오기에는 너무 데이터 양이 많을 수 있으니 잘게 쪼개어서 가져오자!가 목적이었고 이를 위해 채팅 내역의 페이지를 나누기로 했다.

먼저 백엔드 팀원에게 요청해서 API request에 불러올 페이지 번호를 추가했다.

페이지네이션 vs 무한 스크롤

페이지를 나누어서 불러온 목록을 프론트엔드에서 보여줄 때 가장 많이 쓰이는 UX 패턴으로 페이지네이션, 더보기 버튼, 무한 스크롤이 있다. 각각의 특징에 대해서는 구글 Search Central 문서에서 자세히 설명하고 있다.

채팅 내역에 흔히 사용되는 패턴은 Load More(더보기 버튼) 또는 무한 스크롤 패턴이다. 스크롤할 때 뚝뚝 끊기는 느낌보다는 자연스러운 UX를 원했기 때문에 무한 스크롤 패턴으로 결정했다.

무한 스크롤을 구현해 보자!

상향식 무한 스크롤: 마지막 페이지부터 조회하기

흔히 첫 페이지부터 조회하는 것과는 달리 채팅 내역은 가장 마지막 페이지부터 조회한다. 가장 최근 채팅 내역부터 보여주고, 사용자의 필요에 의해 이전 채팅 내역을 조금씩 더 불러와서 보여주어야 하기 때문이다. 따라서 이전 페이지를 불러오는 상향식(역방향) 무한 스크롤 훅이 필요했다.

채팅 내역을 마지막 페이지부터 조회하려면 처음 채팅방에 진입했을 때 마지막 페이지를 알고 있어야 한다. 그래서 채팅방 진입 전에 호출하는 다른 API에서 채팅 내역의 총 페이지 수(totalPage)를 조회해서 채팅 내역을 fetch하는 무한 스크롤 훅에 넘겨주었다.

React Query useInfiniteQuery 무한 스크롤 훅

기존에 React Query(Tanstack Query) 라이브러리를 사용하고 있었는데, 여기에서 제공하는 useInfiniteQuery 훅을 사용하면 무한 스크롤을 손쉽게 구현할 수 있다.

useInfiniteQuery 훅의 queryFn 함수는 pageParam 객체를 파라미터로 받는다. 이 pageParam 파라미터로 불러올 페이지 번호를 넘겨준다. React Query는 친절하게도 이전 페이지 번호를 쉽게 계산할 수 있도록 getPreviousPageParam 옵션을 제공한다.

getPreviousPageParam 함수로 이전 페이지 번호를 계산하기 위해 현재 페이지에서 1을 뺀 값을 리턴한다. 이때 undefined(v5에서는 null도 가능)를 리턴하면 첫 페이지에 도달했다는 것을 의미하여 이전 페이지를 더 이상 계산하지 않는다.

가장 처음 fetch할 때에는 마지막 페이지 번호를 넘겨주어야 하기 때문에 채팅방 진입 이전에 마지막 페이지 번호인 totalPage를 조회했었다. totalPagepageParam의 기본값으로 설정해서 pageParam이 마지막 페이지 번호부터 시작하도록 한다.

useInfiniteQuery 훅은 일반 useQuery 훅과 달리 리턴하는 data가 pages, pageParams key를 가진 객체로 구성되고, pages는 각 페이지의 data를 배열로 가진다. 컴포넌트에서는 1차원 배열로 채팅 내역 UI를 그릴 것이기 때문에 flatMap 메서드로 각 페이지의 data를 모아서 1차원 배열로 만든 chatList를 리턴한다.

export const useChatHistory = () => {
  const { data, isFetching, isRefetching, hasPreviousPage, fetchPreviousPage } =
    useInfiniteQuery(
      {
        queryKey: [QUERY_KEY.chatHistory, totalPage],
        queryFn: ({ pageParam = totalPage || 1 }) => api.postHistory(pageParam),
        getPreviousPageParam: (firstPage) =>
          firstPage.data.nowPageNumber === 1 ? undefined : firstPage.data.nowPageNumber - 1,
      }
    );
    
  return {
    chatList: data?.pages.flatMap((page) => page.data.historyList) ?? [],
    isFetching,
    isRefetching,
    hasPreviousPage,
    fetchPreviousPage,
  };
};

IntersectionObserver 커스텀 훅

무한스크롤의 원리는, 스크롤이 끝에 도달했을 때 이전/다음 페이지를 불러오는 것이다. 스크롤이 끝에 도달했는지 알기 위해서 useIntersectionObserver 커스텀 훅을 작성했다.

IntersectionObserver API를 이용하면 특정 요소가 뷰포트에 나타나는지 감지할 수 있다. ref를 하나 생성해서 useEffect 안에서 observe를 걸어주고, 컴포넌트에서 사용할 수 있도록 리턴한다.

import { useCallback, useEffect, useRef } from "react";

export const useIntersectionObserver = (
  onIntersect: (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void,
  options?: IntersectionObserverInit
) => {
  const ref = useRef<HTMLDivElement>(null);

  const callback = useCallback(
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) onIntersect(entry, observer);
      });
    },
    [onIntersect]
  );

  useEffect(() => {
    if (!ref.current) return;

    const observer = new IntersectionObserver(callback, options);
    observer.observe(ref.current);

    return () => observer.disconnect();
  }, [ref, options, callback]);

  return { ref };
};

채팅 내역 컴포넌트

이제 재료를 조합할 차례다. React Query 무한 스크롤 훅으로 불러온 chatList 데이터를 map으로 UI에 뿌려준다. 그리고 IntersectionObserver 커스텀 훅에서 observe를 걸어놓은 ref(observerRef)를 이전 페이지를 불러올 지점에 놓는다. 상향식 무한 스크롤이기 때문에 이전 페이지를 불러올 지점은 채팅 목록의 상단, 즉 map으로 뿌려주는 부분 위쪽이 되겠다. 그러면 스크롤이 채팅 목록의 가장 위쪽에 도달했을 때 observerRef를 할당한 div 요소가 보일 것이다.

그리고 observerRef를 할당한 요소가 뷰포트에 보여질 때 실행할 함수, 즉 이전 페이지를 불러오는 fetchPreviousPage 함수를 useIntersectionObserver 훅의 콜백으로 넘겨준다. 첫 페이지에 도달하면 실행하지 않도록 hasPreviousPage를 조건으로 넣어준다.

이전 페이지 불러올 때 보고 있던 위치에 스크롤 유지하기

가장 최근 채팅 내역부터 봐야 하므로 채팅 내역 진입 시 스크롤도 맨 아래로 이동하도록 했다. 그런데 이전 페이지를 불러올 때마다 스크롤을 맨 아래로 내려버린다면, 새로 불러온 페이지가 아닌 마지막 페이지의 하단으로 스크롤이 계속 이동하게 된다. 그렇다고 스크롤을 최초 1회만 맨 아래로 내린다면, 이전 페이지를 불러왔을 때 스크롤 위쪽의 채팅 목록 높이가 늘어나서 사용자가 보는 위치가 달라진다.

이전 페이지의 채팅 내역을 불러오면, 기존 목록의 뒤에 붙는 것이 아니라 앞에 붙기 때문에 스크롤이 늘어나면서 사용자가 보는 채팅 목록의 위치가 이동한다. 즉, 이전 페이지를 불러와서 목록에 추가되면 한 페이지만큼의 스크롤이 늘어나면서 사용자가 보는 위치가 한 페이지만큼 위로 이동하게 된다.

이전 페이지를 불러오더라도 현재 보고 있는 채팅 목록의 위치에 스크롤을 유지할 수 있도록(유지하는 것처럼 보이도록) 해보자. 이전 페이지를 불러오기 전, 현재 스크롤 높이를 저장하고, 채팅 목록 상태가 업데이트되면 전체 스크롤 높이 - 저장된 이전 스크롤 높이 위치로 스크롤을 이동시켜 준다. 그러면 추가된 페이지의 높이만큼 스크롤이 아래로 내려가기 때문에 이전에 보고 있던 채팅 목록 위치를 계속 볼 수 있다.

const ChatContainer = () => {
  const [prevHeight, setPrevHeight] = useState(0);
  
  const chatRef = useRef<HTMLDivElement>(null);
  
  const { chatList, isFetching, isRefetching, hasPreviousPage, fetchPreviousPage } = useChatHistory();
  
  const { ref: observerRef } = useIntersectionObserver((entry, observer) => {
    observer.unobserve(entry.target);
    setPrevHeight(chatRef.current?.scrollHeight ?? 0);
    if (hasPreviousPage && !isFetching) fetchPreviousPage();
  });

  useEffect(() => {
    if (isRefetching) return;
    scrollTo({ top: document.body.scrollHeight - prevHeight });
  }, [chatList]);
  
  return (
    <div ref={chatRef}>
      <div ref={observerRef} />
        {chatList.map((chat) => (
          ...
}

트러블슈팅: 나갔다 들어왔을 때 다시 마지막 페이지부터 불러오려면? 무한 스크롤 쿼리 캐시 삭제하기

무한 스크롤 구현 후 테스트를 하는데 한 가지 문제점이 발견되었다.
채팅방 진입 후 가장 마지막 페이지부터 시작해서 위로 스크롤하며 첫 페이지까지 모두 불러왔을 때, 채팅방 페이지를 나갔다가 다시 들어오면 1페이지부터 마지막 페이지까지 줄줄이 fetch가 되는 것이었다. pageParam 값이 캐싱되어서 이전 값을 기억하기 때문에 발생하는 문제로 보였다.

내가 원하는 것은 나갔다가 들어왔을 때, 즉 채팅방 컴포넌트가 다시 마운트될 때 pageParam 값도 초기화되어서 마지막 페이지만 다시 조회하는 것이었다. 따라서 useEffect에서 클린업을 통해 채팅방 컴포넌트가 언마운트될 때 캐시를 삭제하려 했다.

처음에는 언마운트될 때 queryClient.invalidateQueries를 시도해 보았으나 refetch만 될 뿐 pageParam 값이 초기화되지는 않았다. 그런데 비슷한 문제를 해결한 글을 발견하여 queryClient.removeQueries를 실행하니 해결되었다. invalidateQueries는 캐싱된 pageParam으로 refetch를 할 뿐이고, pageParam 값을 초기화하려면 캐시에서 해당 쿼리를 삭제해야 하는 것이었다.

  useEffect(() => {
    // 클린업 함수
    return () => {
      // 쿼리 캐시 삭제하여 pageParam 초기화
      queryClient.removeQueries({ queryKey: [QUERY_KEY.chatHistory] });
      // 전체 페이지 수 조회하는 다른 API refetch
      queryClient.invalidateQueries({ queryKey: [QUERY_KEY.totalPage] });
    };
  }, []);

이제 스크롤을 올려 이전 페이지들을 조회한 뒤 나갔다가 다시 들어오면 다시 가장 마지막 페이지만 조회된다.

🔧 개선 2. 채팅 내역 상태(state) 쌓아서 UI 업데이트하기

기존에는 React-Query로 가져온 data를 그대로 채팅 내역으로 보여줬지만, 채팅 내역을 계속 refetch하지 않으려면 새로 조회하는 FAQ의 Request/Response를 채팅 내역에 추가해주어야 했다. 그러기 위해서 전체 채팅 내역을 state로 관리했다.

지난 채팅 내역(History)을 상태에 담기

먼저 무한 스크롤 쿼리로 불러온 채팅 내역을 state에 담는다.

  const [chatList, setChatList] = useState<T.ChatList[]>([]);

  useEffect(() => {
    if (!data?.pages) return;
    setChatList(data?.pages.flatMap((page) => page.data.historyList) ?? []);
  }, [data]);

새로 조회하는 FAQ를 상태에 업데이트하기

FAQ 조회할 때에는 useMutation 훅을 사용했기 때문에 onSuccess 콜백에서 상태를 업데이트했다. (React Query 공식 문서에 따르면 서버 데이터에 변경을 일으킬 때 Query 대신 Mutation을 사용하는 것을 권장하는데, FAQ 조회 API는 조회라고 부르지만 채팅 내역 History를 업데이트하는 사이드 이펙트가 있었기 때문에 useMutation 훅을 사용했다.)

FAQ 조회 API의 Response를 채팅 내역 state에 추가하기 위해, 백엔드 개발자에게 요청해서 채팅 내역(History) Response 데이터와 FAQ Response 데이터의 구조를 통일했다.
FAQ 조회 API를 호출할 때 보내는 Request로 보낸 채팅 데이터를 state에 추가하고, Response로 받은 채팅 데이터를 state에 추가했다.

export const useFAQQuestion = () => {
  const { mutate } = useMutation((request: T.FAQQnARequest) => {
    mutateFn: api.postQuestion(request),
    onSuccess: ({ data: { chat } }, { text }) => {
      setChatList((prev) => [
        ...prev,
        { id: chat.id - 1, text, time: chat.time },
        chat,
      ]);
    },
  });

  return { mutate };
};

이렇게 해서 지난 채팅 내역(History)은 최초 1회만 불러오고, 무한 스크롤로 가져오는 데이터의 양을 제한하며, 필요한 API만으로 UI를 업데이트하도록 채팅 내역 조회 방식을 개선할 수 있었다!

참고
카카오톡 대화창 UI같은 상향식 무한스크롤 구현하기
[인사이트아웃] React-Query 무한스크롤 적용기

profile
공부 기록

0개의 댓글