[React Query] 무한 스크롤 구현하기

windowook·2024년 10월 31일
post-thumbnail

12.18 코드 수정

🌱 머리말

최근에 개발을 완료하고 배포해 둔 앱에서 처음으로 무한 스크롤을 구현해봤습니다.

무한 스크롤은 유저가 끊기지 않는 UX 흐름을 가져가기에 좋은 UI라고 생각했습니다. 그래서 마침 이번 프로젝트가 카드 리스트를 보여주는 페이지가 있었고 적용시키기 좋은 상황이었죠. 어떻게 구현하는지 제가 구현했던 코드를 보여드리면서 설명해보도록 하겠습니다. 핵심은 두 가지입니다.

  • useInview, useInfiniteQuery

useInview

npm install react-intersection-observer
yarn add react-intersection-observer

먼저 useInview를 사용하기 위해서는 이것을 제공하는 라이브러리인 react-intersection-observer를 설치해야 합니다.

import { useInview } from 'react-intersection-observer';

const { ref, inView, entry } = useInview({
  threshold: 0,
}) 
return (
  	// ...
	<div ref={ref}>
  		{inView && '요소가 뷰에 들어왔습니다.'}
	</div>
)

useInview는 threshold를 파라미터로 전달받습니다. threshold는 0 ~ 1 범위의 값으로 설정할 수 있습니다. '요소가 뷰포트에 얼마나 보였을 때 기준으로 동작할지'를 설정합니다.

반환되는 값 중 ref는 Observe할 요소에 대해 refer를 하기 위해 넣는 값입니다. ref가 포함된 컴포넌트가 threshold만큼 화면에서 보일 때, inView를 true로 만듭니다. 따라서 threshold를 0으로 설정할 경우 조금이라도 보이면 inView가 true가 됩니다.

이 기능을 어떻게 응용할 것인지 감이 오시나요? 페이지 전체 범위 혹은 무한 스크롤을 구현하고자 하는 요소 전체 범위를 기준으로 보면 스크롤을 내렸을 때 바닥이라고 부를 수 있는 부분이 있겠죠. 여기에 ref를 배치시킵니다. inView가 true가 되면, 다음 페이지의 내용을 불러오는 fetchNextPage를 실행하는거죠. 이 로직이 무한 스크롤의 핵심 메커니즘입니다.

useInfiniteQuery

https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery#useinfinitequery

npm install @tanstack/react-query or yarn add @tanstack/react-query 중에서 패키지 매니저에 맞게 명령어를 터미널에서 실행하여 리액트 쿼리를 설치해주세요.

ReactQueryClientProvider

'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export const queryClient = new QueryClient({});

export default function ReactQueryClientProvider({
  children,
}: React.PropsWithChildren) {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

리액트 쿼리가 설치되어있지 않다면 설치하고 Provider 컴포넌트를 생성해줍니다. 그 후에 일반 리액트 프로젝트라면 App.tsx(jsx), Next.js 페이지 라우터라면 _app.tsx, 저와 같은 앱 라우터라면 layout.tsx에 Provider로 children을 감싸줍니다.

공식 문서는 어떻게 되어있나

const {
  data,
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  promise,
  status,
  ...result
} = useInfiniteQuery({
  queryKey,
  queryFn: ({ pageParam }) => fetchPage(pageParam),
  initialPageParam: 1,
  ...options,
  getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
    lastPage.nextCursor,
  getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) =>
    firstPage.prevCursor,
})

무한 스크롤을 구현하기 위해서 리액트 쿼리에서 제공하는 useInfiniteQuery라는 훅을 사용합니다.
이 훅은 데이터를 페이지 단위로 요청하고, 마지막 페이지까지 로드될 때까지 fetchNextPage 메서드를 통해 다음 페이지 데이터를 가져옵니다. 원래는 useQuery 훅으로도 무한 스크롤을 구현할 수는 있지만 더 복잡하고 다양한 툴들이 필요합니다. 그 복잡한 구현을 간소화해서 구현할 수 있도록 만들어진 훅이 useInfiniteQuery입니다.

useQuery와 같이 queryKey, queryFn이란 파라미터를 전달해야하고 initialPageParam, getNextPageParam 등과 같은 파라미터도 전달해야 합니다. 공식 문서에 있는 getPreviousPageParam은 구현에 필수적이지 않아서 저는 사용하지 않았습니다.

  • queryKey: 데이터를 식별하는 고유한 키입니다. 캐싱과 refetching을 자동으로 관리하는 데 사용됩니다.
  • queryFn: 데이터를 페이징하여 가져오는 함수입니다.
  • initialPageParam: 기본 페이지 값을 설정합니다.
  • getNextPageParam: 다음 페이지가 존재하는지 확인하는 함수입니다.

반환되는 값들 중 일부에 대해서도 설명해드리겠습니다.

  • data: 전체 페이지 데이터를 담고 있는 객체로, pages라는 배열로 각 페이지 데이터가 들어있습니다.
    이를 통해 전체 무한 스크롤 데이터에 접근할 수 있습니다.
  • fetchNextPage: 다음 페이지 데이터를 가져오는 함수입니다.
    주로 스크롤 이벤트나 "더 보기" 버튼과 결합해 호출합니다.
  • hasNextPage: 더 가져올 페이지가 있는지 나타내는 boolean 값입니다.
  • isFetchingNextPage: 다음 페이지 데이터를 로드하는 동안 true로 설정되어 로딩 상태를 UI에 표시할 수 있습니다.

🌱 코드 구현

'use client';

import { useEffect, useRef } from 'react';
import { useInView } from 'react-intersection-observer';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMapStore, useCheckStore, useUserStore } from 'utils/store';
import CircularProgress from '@mui/material/CircularProgress';

export default function InfiniteSidebar() {
  const { ref: collectedRef, inView: collectedInView } = useInView({
    threshold: 0.5,
  });
  
  const {
    data: fetchedCollectedCafe,
    fetchNextPage: fetchNextCollectedPage,
    hasNextPage: hasNextCollectedPage,
    isFetchingNextPage: isFetchingNextCollectedPage,
  } = useInfiniteQuery({
    enabled: !!userId && userId !== 'no-user',
    initialPageParam: 0,
    queryKey: ['collectedCafe', userId],
    queryFn: async ({ pageParam }) => {
      const response = await getAllCollectedCafes(userId, pageParam, 4);
      return response;
    },
    getNextPageParam: lastPage =>
    lastPage.nextCursor !== null ? lastPage.nextCursor : null,
    staleTime: 1000 * 60 * 3,
    gcTime: 1000 * 60 * 5,
  });

  useEffect(() => {
    if (fetchedCollectedCafe) {
      const allCafes = fetchedCollectedCafe.pages.flatMap(page => page.data);
      setCollectedCafe(allCafes);
    }
  }, [fetchedCollectedCafe, setCollectedCafe]);
  
  useEffect(() => {
    if (
      collectedInView &&
      hasNextCollectedPage &&
      !isFetchingNextCollectedPage
    ) {
      fetchNextCollectedPage();
    }
  }, [
    collectedInView,
    hasNextCollectedPage,
    fetchNextCollectedPage,
    isFetchingNextCollectedPage,
  ]);
  
  
  return (
    <div>
      <div className="flex justify-center sticky">
        <span className="font-dpixel">
          수집한 카드 수 : {collectedCount}
        </span>
      </div>

      {fetchedCollectedCafe?.pages?.map((page, i) => (
        <div key={`page-${i}`} className={cardDivStyle}>
          {page.data.map((cafe: CollectedCafeFromSupabase) => (
            <CollectedCafe
              key={cafe.id}
              name={cafe.name}
              ratings={cafe.rating}
              photoUrl={cafe.photoUrl}
              address={cafe.address}
              phoneNum={cafe.phoneNum}
              onClick={() => handleCollectedCafeClick(cafe)}
              />
          ))}
        </div>
      ))}

      {isFetchingNextCollectedPage && <CircularProgress />}

      <div ref={collectedRef} className="w-[22rem]"></div>
    </div>
  )
};

제가 구현했던 프로젝의 UI 컴포넌트에서 일부를 가져와서 조금 수정한 코드입니다.
세부적인 내부 데이터는 여러분들의 프로젝트에서는 다른 데이터를 쓰실테니 로직에만 집중해주세요.

파라미터 중심 설명

enabled는 useInfiniteQuery를 사용할 수 있는 조건을 걸어주는 파라미터입니다. 저는 zustand로 관리 중인 전역 상태 userId를 쿼리 키로 사용중이기도 하고 getAllCollectedCafes라는 서버 액션의 파라미터로 사용중이기도 해서 이 값이 null이거나 로그인 되어있지 않은 상태일 때 문자열이라면 실행되지 않게 하였습니다.

다음 initialPageParamuseInfiniteQuery가 데이터를 가져오기 시작할 때 사용할 첫 페이지의 파라미터 값을 정합니다. 이 값은 queryFn에 전달되는 pageParam에도 영향을 줍니다. 저는 0으로 설정했기 때문에 0번째 페이지부터 불러오는 것과 같습니다. useInfiniteQuery로 DB에 데이터를 요청한다면, 콜백 함수에 페이지네이션 처리를 하는 로직도 구현해야 합니다.

getAllCollectedCafes 코드

export async function getAllCollectedCafes(
  userId: string,
  offset: number = 0,
  limit: number = 4,
): Promise<{ data: CollectedCafeFromSupabase[]; nextCursor: number | null }> {
  if (!userId || userId === 'no-user') throw new Error('유효하지 않은 유저 ID');

  const supabase = await createServerSupabaseClient();
  const { data, error } = await supabase
    .from('collected')
    .select('*')
    .eq('userId', userId)
    .order('created_at', { ascending: true })
    .range(offset, offset + limit - 1);

  if (error) throw new Error(error.message);

  const nextCursor = data && data.length === limit ? offset + limit : null;

  return { data: data ?? [], nextCursor };
}

위 서버 액션은 Supabase의 collected라는 테이블로부터 userId로 인덱싱하여 데이터를 가져옵니다. 처음에는 단순히 배열 데이터를 반환하는 비동기 액션으로 구현했지만 offset, limit이라는 파라미터를 추가해주었습니다. 페이지네이션을 처리하기 위해서입니다.

offset은 한 페이지의 시작점을 설정하는 파라미터입니다.
offset이 0이라면 각 페이지는 0번째 데이터부터 시작합니다.

limit은 한번에 가져올 데이터의 개수(제한)을 설정하는 파라미터입니다.
limit에 따라서 페이지에 담기는 데이터 수가 달라집니다.

예를 들어서 data = [item1, item2, item3, item4, item5, item6, item7, item8]일 때
offset = 0, limit = 4라면 한 페이지에 담기는 데이터는 [item1, item2, item3, item4]가 됩니다.

offset = 2, limit = 5라면 [item3, item4, item5, item6, item7]이 됩니다.

그래서 저는 offset, limit을 number 리터럴 타입으로 전달했고,
SQL range를 이용해서 한 페이지에 Row를 4개씩 가져오도록 만들었습니다.

nextCursor를 보면 응답으로 받은 데이터의 길이와 limit을 비교하여 다음 페이지 커서를 지정하고 있는데, 0번째부터 3번째까지 처음 range로 반환되고 나면 nextCursor가 4가 되야하니 값이 딱 맞습니다. 액션이 정상적으로 응답을 받으면 queryFn의 response에 데이터와 함께 nextCursor가 담겨집니다.

그럼 getNextPageParam을 통해 다음 페이지가 존재하는지 확인하며, 있다면 다음 페이지 데이터가 로드됩니다. lastPage는 이전 페이지를 의미하고, queryFn의 response에 담긴 값을 의미합니다. 여기서 nextCursor가 null이 아니라 있다면, 다음 페이지는 nextCursor가 가리키는 곳부터 시작하며 hasNextPage가 true를 반환합니다. 그 후 fetchNextCollectedPage()가 실행됩니다.

정리

  1. 스크롤을 내려서 ref에 도착하면 threshold로 설정한 대로 보자마자 collectedInView가 true가 되고
  2. queryFn으로 실행된 액션의 반환값이 응답으로 왔을 때 getNextPageParam이 nextCursor를 판단하여
    hasNextPage를 boolean 타입으로 반환합니다.
  3. 여기서 isFetchingNextCollectedPage가 아닐 때 useEffect의 콜백 함수인 fetchNextCollectedPage()가 실행되면서 다음 페이지의 데이터를 가져옵니다.
  4. 그 다음 페이지도 동일한 원리로 가져오게 되는 것이죠.

무한 스크롤은 SNS나 쇼핑몰같은 서비스뿐만 아니라 다양한 종류의 앱에서 유저에게 끊임없는 정보를 제공하는 UI로써 널리 사용되고 있습니다. useInfiniteQuery를 사용하여 여러분의 앱에도 무한 스크롤을 편하게 구현해보면 좋을 것 같습니다.

profile
안녕하세요

0개의 댓글