[Next.js] TanStack Query V5 무한 스크롤 구현

Simon·2024년 9월 24일
0
post-thumbnail

useInfiniteQuery 사용 이유

팀 프로젝트 요구사항에 따르면 초기 페이지에서는 서버 사이드 렌더링(SSR)을 통해 목록 10개를 보여주고, 이후에는 클라이언트 사이드에서 무한 스크롤 기능을 통해 목록을 추가로 요청하여 받아와야 한다. 이 기능을 효율적으로 구현하기 위해 TanStack Query(v5)의 useInfiniteQuery 훅과 react-intersection-observer 라이브러리의 useInView 훅을 사용했다.

useInfiniteQuery를 사용하면 페이지네이션 처리다음 페이지 요청을 간편하게 관리할 수 있고, 자동으로 캐싱 및 리페치 기능을 제공하여 성능 최적화를 할 수 있다. 특히, 페이지네이션과 무한 스크롤을 함께 구현할 때 API 호출을 효율적으로 제어할 수 있다는 장점이 있다.

이전에는 TanStack Query에서 useQueryuseMutation을 간단하게 사용해 본 경험이 있지만, 무한 스크롤과 같은 기능은 이번 프로젝트에서 처음으로 시도해 보는 부분이다.

초기 SSR을 실행하는 홈페이지 코드


const Home = async () => 
  const datas = await response.json() 데이터를 받아 왓다고 가정
  return (
	<div>
        <CardList datas={datas} />
      </div>
    
  );
};

export default Home;

실제 코드에서 그냥 정말 이번 기능 구현하면서 볼 부분말고 다 지웠다. 서버에서 데이터를 10개 받아서 CardList에 전달했다고 가정하겠다.

코드를 설명하기전에 내가 사용한 useInfiniteQuery의 설정 옵션과 리턴 값으로 사용한 것들에 대해서 설명하겠다. 따로 이 코드만 추출해서 보여주겠다.

useInfiniteQuery 구성 코드

useInfiniteQuery는 반환 값 속성이 몇 개 추가된 것을 제외하면 useQuery와 동일하다고 공식 문서에서 설명하고 있다.

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
    queryFn: async ({ pageParam = 0 }) => {
      const axios = getInstance();
      const response = await axios.get<Gathering[]>('gatherings', {
        params: { offset: pageParam, limit },
      });
      return response.data;
    },
    getNextPageParam: (lastPage, allPages) => {
      return lastPage.length === limit ? allPages.length * limit : undefined; // 다음 offset 계산
    },
    initialPageParam: 0,
    queryKey: ['gatherings'],
    initialData: {
      pages: [gatherings],
      pageParams: [0],
    },
  });

여기서 사용한 useInfiniteQuery 옵션들에 대해서 먼저 적어보겠다.

useInfiniteQuery 구성 옵션

queryFn: 데이터를 패칭하는 비동기 함수

getNextPageParam: 서버에서 데이터를 페이지 단위로 불러오는 경우 다음 페이지의 요청을 관리하기 위해 사용

getNextPageParam 매개변수

  • lastPage: 마지막으로 받아온 페이지의 데이터이다. 이를 통해 마지막 페이지가 있는지, 다음 페이지가 있는지 등의 정보를 확인할 수 있다.
  • allPages: 지금까지 불러온 모든 페이지의 데이터 배열이다. 이 배열을 통해 현재까지 불러온 모든 데이터를 확인할 수 있다.

initialPageParam: 첫 번째 페이지를 가져올 때 사용할 기본 페이지 매개변수 (필수)

queryKey: 서버에서 데이터를 가져오는 요청을 식별하는 데 사용되는 키

initialData: 쿼리가 처음 실행될 때 사용할 초기 데이터

  • pages는 각 페이지의 데이터를 포함하는 배열
  • pageParams는 각 페이지에 대한 파라미터 배열이다. 여기서는 초기값으로 0을 설정하여 첫 번째 페이지의 오프셋을 나타낸다.

useInfiniteQuery 반환 속성

data: data는 두 가지 속성을 갖는다.

  • data.pages: 모든 페이지를 포함하는 배열

  • data.pageParams: 모든 페이지 매개변수를 포함하는 배열

fetchNextPage:

  • fetchNextPage 함수는 무한 스크롤이나 페이지네이션을 사용할 때, 다음 페이지의 데이터를 불러오는 역할
  • fetchNextPage는 queryFn을 호출하여 다음 페이지의 데이터를 비동기적으로 가져오는 함수

hasNextPage: 가져올 다음 페이지가 있는 경우 true, (getNextPageParam 옵션을 통해 알려짐)

클라이언트 측 무한스크롤 구현 전체 코드

서버 사이드에서 패칭해온 데이터를 전달받는데 따로 변수를 선언하여 저장해서 사용할 필요가 없다. useInfiniteQuery의 구성 옵션 중 하나인 initialData 속성을 사용하여 초기 데이터를 저장한다. 주의할 점은 바로 데이터를 저장하는 게 아니라 코드처럼 pages 속성에 배열로 감싸서 실제 전달 데이터를 추가하고 pageParams 값도 마찬가지로 줘야 한다.

queryFn은 실제 데이터를 요청하는 로직을 포함하는 비동기 함수이다. 함수를 따른 파일에 작성하고 import 하여 사용해도 상관없지만 지금은 한눈에 알아볼 수 있게 작성하였다. params의 offset과 limit를 넘겨주고 있는데 이건 요청하는 api를 보고 어떻게 페이지 단위로 데이터를 요청할지 결정하는 것이기 때문에 절대적인 것이 아니다.

getNextPageParam 서버에서 데이터를 페이지 단위로 불러오는 경우 다음 페이지의 요청을 관리하기 위해 사용한다고 했다.
매개변수로 전달되는 lastPage는 마지막으로 받아온 페이지의 데이터인데 내 코드에서는 지금 limit를 10으로 고정해두고 데이터를 받아오기 때문에 [데이터1, 데이터2, ....데이터 10] 이렇게 마지막으로 받아온 데이터를 의미한다. 그래서 마지막으로 받아온 데이터의 배열이 길이가 limit와 같다면, 10개를 가져왔다면 다음 페이지가 존재한다고 말할 수 있기 때문에 조건문을 사용한 것이고 있을 경우 allPages.length 길이 * limit를 사용하여 offset 값을 전달한다. 이 전달 값은 queryFn의 매개변수로 전달되는 pageParam으로 전달되는 것이다.

allPages는 지금까지 불러온 모든 페이지의 데이터 배열인데 예를 들어, 10개의 데이터를 2번 불러왔다고 가정하면 allPages = [ [데이터 1, 데이터 2, ... 데이터 10], [데이터 11, 데이터 12, ... 데이터 20]] 이런 데이터를 가지고 있을 것이다. 2차원 배열이라는 것에 주의하자!

이제 useInfiniteQuery로 부터 반환된 속성인 data, fetchNextPage, hasNextPage 사용하여 데이터를 요청하고 페이지에 보여줘야 하는데 설명하기전에 무한 스크롤을 하려면 받아온 콘텐츠의 끝 지점에 도달했다는 것을 알아야 하는데 그러기 위해서 react-intersection-observer 라이브러리의 useInView 훅을 알아야 한다.

'use client';

import { Gathering } from '@/lib/definition';
import React, { useEffect } from 'react';
import ProgressCard from './ProgressCard';
import { useInfiniteQuery } from '@tanstack/react-query';
import { getInstance } from '@/utils/axios';
import { useInView } from 'react-intersection-observer';

const limit = 10;

const ProgressCardList = ({ gatherings }: { gatherings: Gathering[] }) => {
  const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
    queryFn: async ({ pageParam = 0 }) => {
      const axios = getInstance();
      const response = await axios.get<Gathering[]>('gatherings', {
        params: { offset: pageParam, limit },
      });
      return response.data;
    },
    getNextPageParam: (lastPage, allPages) => {
      return lastPage.length === limit ? allPages.length * limit : undefined; // 다음 offset 계산
    },
    initialPageParam: 0,
    queryKey: ['gatherings'],
    initialData: {
      pages: [gatherings],
      pageParams: [0],
    },
  });

  const { ref, inView } = useInView({
    threshold: 0.5, // 50% 보일 때
  });

  // inView가 true일 때 다음 페이지를 fetch
  useEffect(() => {
    if (inView && hasNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, fetchNextPage]);

  return (
    <div className="flex flex-col items-center gap-6 mt-6">
      {data?.pages
        .flat() // 2차원 배열을 1차원으로 평탄화
        .map((gathering, index) => <ProgressCard gathering={gathering} key={index} />)}
      <div ref={ref} /> {/* 스크롤 감지를 위한 ref 추가 */}
    </div>
  );
};

export default ProgressCardList;

react-intersection-observer란?

react-intersection-observer는 React에서 Intersection Observer API를 간편하게 사용할 수 있도록 제공하는 라이브러리다. 이 API는 특정 요소가 뷰포트(사용자의 화면)에 보이는지 여부를 감지하는 데 사용된다. 주로 무한 스크롤(Infinite Scroll), 지연 로딩(Lazy Loading), 애니메이션 트리거 등에서 활용된다.

실제 내 코드 중 사용 부분

 const { ref, inView } = useInView({
    threshold: 0.5, // 50% 보일 때
  });
  • useInView: react-intersection-observer에서 제공하는 훅으로, 해당 요소가 뷰포트에 들어오는지 감지
  • ref: 감지할 요소에 할당되는 참조값이다. 이 요소가 화면에 보이는지 감지한다.
  • inView: 요소가 뷰포트에 보이면 true, 그렇지 않으면 false
  • threshold: 요소가 얼마나 보여야 inViewtrue가 되는지를 결정하는 옵션입니다. 0.5는 요소의 50%가 보일 때 true가 된다.

react-intersection-observer 링크

아래 코드를 보면 data?.pages.flat().map() 부분에서는 data.pages가 2차원 배열로 되어 있기 때문에, 이를 평탄화하여 1차원 배열로 만든 후 .map()을 통해 각 요소에 접근한다.

그 후, 목록의 마지막에 <div> 요소를 추가하고, 이 divuseInView 훅에서 반환된 ref를 연결한다. ref는 스크롤 감지 대상 요소를 지정하는데 사용된다. 이 div가 화면에 나타나는지(뷰포트에 들어오는지) 감지하여, 감지되었을 때 추가 데이터를 불러오는 트리거 역할을 한다.

useEffect 훅은 inViewtrue일 때, 즉 div 요소가 뷰포트에 50% 이상 보이게 되면(threshold: 0.5) fetchNextPage 함수를 호출하여 다음 페이지의 데이터를 요청한다. 이때 hasNextPage다음 페이지가 존재하는지 여부를 나타내는 값으로, true일 때만 추가 데이터를 불러온다.

무한스크롤 구현 전체 코드중 일부분

  const { ref, inView } = useInView({
    threshold: 0.5, // 50% 보일 때
  });

  // inView가 true일 때 다음 페이지를 fetch
  useEffect(() => {
    if (inView && hasNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, fetchNextPage]);

  return (
    <div className="flex flex-col items-center gap-6 mt-6">
      {data?.pages
        .flat() // 2차원 배열을 1차원으로 평탄화
        .map((gathering, index) => <ProgressCard gathering={gathering} key={index} />)}
      <div ref={ref} /> {/* 스크롤 감지를 위한 ref 추가 */}
    </div>
  );

위와 같이 useInfiniteQueryreact-intersection-observeruseInView 훅을 사용하여 서버에서 데이터를 받아 무한 스크롤 기능을 구현할 수 있었다. SSR로 초기 데이터를 렌더링하고, 이후 클라이언트 측에서 추가 데이터를 자동으로 요청하는 방식으로 요구사항을 충족시켰다.

이번 프로젝트에서 처음으로 무한 스크롤 기능을 구현해보았는데, TanStack Query와 Intersection Observer API 덕분에 효율적으로 간단하게 구현할 수 있었다.

profile
포기란 없습니다.

0개의 댓글

관련 채용 정보