@tanstack/react-query의 useInfiniteQuery로 무한스크롤 적용하기 + 동일한 데이터가 계속해서 불러와지는 문제

김민서·2024년 1월 29일

whyyouarebroke

목록 보기
4/15

상품 목록에 무한 스크롤을 적용해보았다.

@tanstack/react-query(react-query의 최신 버전)의 useInfiniteQueryreact-intersection-observer를 사용했다.

먼저 사용할 해당 라이브러리들을 import 한다.

import { useInView } from "react-intersection-observer";
import { useInfiniteQuery } from "@tanstack/react-query";

그리고 useInfiniteQuery를 통해 데이터를 무한 스크롤링 형태로 가져오는 로직을 작성한다.

useInfiniteQuery

공식 문서 참고
쿼리 키(queryKey)를 설정해주고, 쿼리 함수(queryFn)를 작성한다.
쿼리 함수를 보면 파라미터로 받은 pageParam을 getProducts라는 함수의 인자로 넣어주는데, 여기서 pageParam은 가져올 데이터의 페이지를 가리키는 포인터라고 생각하면 된다.
pageParamgetNextPageParam 함수의 리턴값을 통해 받아올 수 있다.

const { data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, status } = useInfiniteQuery({
    queryKey: ["products"],
    queryFn: ({ pageParam }) => getProducts(pageParam),
    initialPageParam: null,
    getNextPageParam: (querySnapshot: DocumentData) => {
      if (querySnapshot.length < 12) {
        return null;
      } else {
        return querySnapshot[querySnapshot.length - 1].createdAt;
      }
    },
  });

나의 경우엔 한 페이지당 데이터를 12개씩 받아오는 걸로 정했다.
pageParam의 초기값인 initialPageParam은 null로 설정해주었고, getNextPageParam에서 파라미터로 받은 '이전 쿼리 호출에서 반환된 데이터 배열(querySnapshot)'의 맨 마지막 데이터의 생성일자(createdAt 필드: 상품을 생성할때 timestamp를 같이 넣어줬다. 사진 참고)를 리턴했다.
즉, 매번 불러오는 페이지마다 맨 마지막(12번째) 상품의 createdAt 필드값이 pageParam이 되는 것이다.

동일한 데이터가 계속해서 불러와지는 문제

처음에는 이렇게만 설정해줘서 처음 상품부터 계속 똑같은 데이터만 12개씩 불러와지는 문제가 있었다.

return querySnapshot[querySnapshot.length - 1];

'맨 마지막 데이터를 기준으로 그 이후의 값을 불러온다'라는 것만 생각해서 저렇게 해놓으면 알아서 다음 데이터가 불러와질 줄 알았는데, 놓친 부분이 있었다.
return값이 되는 pageParam 값을 기준으로 이후의 데이터가 불러와지는 것이기 때문에 pageParam은 '비교가 가능한' 값이어야 한다.
따라서, products 컬렉션에 'createdAt' 필드를 추가하여 상품을 등록한 시점의 timestamp를 같이 넣어줬고, 이 값을 기준으로 오름차순 정렬하여 데이터(상품)을 비교할 수 있도록 했다.
당연히 코드도 다음과 같이 변경하여 문제를 해결할 수 있었다.

return querySnapshot[querySnapshot.length - 1].createdAt;

그리고 나서, 앞에서 queryFn으로 넣어줬던 데이터를 가져오는 함수인 getProducts를 작성한다.
Firebase의 Firestore에서 데이터를 가져오기 때문에 공식 문서를 토대로 작성하였다.

아까 보내줬던 pageParam값을 여기에서 인자로 받게 된다.
pageParam이 존재하는 경우(not null), startAfter(firestore)에 pageParam 값을 넣어주어 해당 포인터로부터의 이후의 데이터를 받아올 수 있도록 한다. 공식 문서

const getProducts = async (pageParam: number) => {  
    let products: DocumentData[] = [];
    let finalQuery = query(collection(db, "products"), orderBy("createdAt", "desc"));

    if (pageParam) {
      finalQuery = query(finalQuery, startAfter(pageParam), limit(12));
    } else {
      finalQuery = query(finalQuery, limit(12));
    }
  
  	const querySnapshot = await getDocs(finalQuery);
    querySnapshot.forEach((doc) => {
      products.push(doc.data());
    });
    return products;
  };

react-intersection-observer

react-intersection-observer는 브라우저의 Intersection Observer API를 리액트에서 쉽게 사용할 수 있도록 해주는 라이브러리인데, 해당 API는 요소가 화면에 나타나는 여부를 감지한다.
이를 무한 스크롤 구현에 적용시키면, 어떠한 요소를 화면에 그린 다음, 그 요소가 화면에 나타나면 추가로 데이터를 로드할 수 있게 할 수 있다.

이제 이렇게 작성해 놓은 함수들을 언제 불러와야 할 지 설정해놔야 한다.
먼저 react-intersection-observer의 useInView 훅을 사용하여 inViewRef와 inView 상태를 생성한다.

그리고 목록 맨 밑에 div 요소를 하나 생성하여 ref에 inViewRef를 전달하여 DOM 요소에 연결한다.
유저가 스크롤을 해서 목록의 맨 밑에 도달하여 해당 div 요소를 보게 되면 inViewRef는 이를 감지한다.

그러면 inView의 상태가 변경이 되는데(true로 변경),
useEffect를 사용하여 만약 inView가 true이면, 즉 요소가 화면에 보이면, fetchNextPage 함수를 호출하여 다음 페이지의 데이터를 가져온다.
fetchNextPage의 함수를 통해 가져온 데이터는 data에 담겨있기 때문에 이를 화면에 그리면 된다.

  const [inViewRef, inView] = useInView({
    triggerOnce: false,
  });
  
  useEffect(() => {
    if (inView) {
      fetchNextPage();
    }
  }, [inView]);

  return (
    <>
      <h3 className="text-xl">전체 상품</h3>
      <div>
        {data?.pages.map((page, index) => (
          <div key={index}>
            {page ? (
              <div className="grid grid-cols-4 gap-1">
                {page.map((value: DocumentData, index: number) => {
                  return (
                    {/* 상품 */}
                    <div key={index} />
                  );
                })}
              </div>
            ) : (
              <>상품이 존재하지 않습니다.</>
            )}
          </div>
        ))}
      </div>
      <div ref={inViewRef}>
        {isFetchingNextPage && <p>loading...</p>}
      </div>
    </>
  );
}

0개의 댓글