본문 바로가기

TIL

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

이번 프로젝트를 진행하면서 무한스크롤을 구현해봤습니다.

타 회사에서 무한스크롤 구현하는 포스팅을 보고 참고해서 비교해 보면서 코드 리팩토링을 할 부분에 대해 작성해보겠습니다.

 

NoticeTable.tsx
"use client";

import { DataTable } from "@/components/common/table/table";
import { columns } from "./notice-columns";
import { Row, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import { useGetNoticeQuery } from "../queries/get-notice";
import { Button } from "@/components/ui/button";
import { Loader2, Plus } from "lucide-react";
import { z } from "zod";
import { noticeListSchema } from "../schema";
import { useMemo, useRef } from "react";

export default function NoticeTable() {
  const buttonRef = useRef<HTMLButtonElement>(null);
  const router = useRouter();

  const { data, hasNextPage, fetchNextPage, isFetchingNextPage, isPending } =
    useGetNoticeQuery();

  const pages = useMemo(() => {
    const itemCnt = data?.pages[0]?.itemCnt || 0;
    return data?.pages
      ?.map((li) => li?.itemList || [])
      .flat()
      .map((notice, i) => {
        const customIndex = itemCnt - i;
        return {
          ...notice,
          customIndex,
        };
      });
  }, [data]);

  const table = useReactTable({
    columns,
    data: pages || [],
    getCoreRowModel: getCoreRowModel(),
  });

  const onRowClick = (row: Row<z.infer<typeof noticeListSchema.element>>) => {
    router.push(`/customer-center/${row.original.noticeIdx}`);
  };

  const onClick = () => {
    if (!hasNextPage) return;
    if (buttonRef.current === null) return;
    buttonRef.current.disabled = true;

    fetchNextPage();
    if (buttonRef.current === null) return;
    if (hasNextPage === true) {
      buttonRef.current.disabled = false;
    }
  };

  const disabled = !hasNextPage || isFetchingNextPage;

  return (
    <>
      <div className="flex items-center justify-between border-b px-4 py-2">
        <div className="flex items-center gap-2"></div>
        <span className="text-sm font-bold">
          총 {data?.pages[0]?.itemCnt?.toLocaleString() ?? 0} 건
        </span>
      </div>
      <DataTable
        table={table}
        columns={columns}
        className="min-h-[410px] rounded-none border-none"
        onRowClick={onRowClick}
        placeholder={
          isPending ? "데이터를 불러오는 중입니다." : "데이터가 없습니다."
        }
      />

      <div className="h-12 border-t p-1 text-center">
        {hasNextPage && (
          <Button
            variant="ghost"
            className="flex h-full w-full items-center justify-center text-base font-bold"
            onClick={onClick}
            ref={buttonRef}
            disabled={disabled}
          >
            <Plus className="h-6 w-6" />
            더보기
            {isFetchingNextPage && (
              <Loader2 className="mr-2 h-5 w-5 animate-spin" />
            )}
          </Button>
        )}
      </div>
    </>
  );
}
get-notice.tsx
import { useAxios } from "@/hooks/useAxios";
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
import { NoticeList } from "../schema";
import { ResType } from "@/types/type";

export const useGetNoticeQuery = () => {
  const axios = useAxios();

  const getNotice = async (pageParam: number) => {
    const res = await axios.get<
      ResType<{ itemCnt: number; itemList: NoticeList }>
    >("/api/client/notice/list", {
      params: {
        pageNum: pageParam,
      },
    });

    return res.data?.data;
  };

  return useInfiniteQuery({
    queryKey: ["GET", "notice"],
    queryFn: ({ pageParam }) => getNotice(pageParam),
    initialPageParam: 1,
    getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
      const totalCount = lastPage?.itemCnt ?? 0;

      if (Math.ceil(totalCount / 10) <= lastPageParam) {
        return null;
      }

      return lastPageParam + 1;
    },
    refetchOnWindowFocus: false,
    placeholderData: keepPreviousData,
  });
};

 

공통점

  1. 무한 스크롤 구현:
    • 두 코드 모두 useInfiniteQuery를 사용하여 무한 스크롤을 구현하고 있습니다. 이 방식은 데이터를 페이지 단위로 로드하고, 사용자가 스크롤을 내릴 때마다 다음 페이지의 데이터를 로드하는 방식입니다.
  2. 쿼리 캐싱과 프리패칭:
    • 두 코드 모두 react-query의 쿼리 캐싱 기능을 활용하여 데이터를 효율적으로 관리하고 있습니다. 특히, useSearchProductQuery에서는 세션 스토리지에서 저장된 데이터를 활용하여 프리패칭(prefetching)합니다.

차이점

  1. 데이터 패칭 방식:
    • useSearchProductQuery: queryFn을 사용하여 API 호출을 직접 정의하고, 쿼리의 메타데이터를 통해 페이지의 시작 위치와 상품 수를 조정합니다. 이는 API 호출을 더 유연하게 제어할 수 있게 해줍니다.
    • useGetNoticeQuery: 단순히 페이지 번호를 queryFn에 전달하여 데이터를 패칭하며, 페이지 파라미터와 총 아이템 수를 기반으로 다음 페이지의 존재 여부를 결정합니다.
  2. 세션 스토리지 사용:
    • useSearchProductQuery: 세션 스토리지를 활용하여 사용자가 이전에 클릭한 상품의 위치를 저장하고, 다음 페이지 로드 시 해당 위치로 스크롤을 자동으로 이동합니다. 이는 사용자의 위치를 유지하는데 유용합니다.
    • useGetNoticeQuery: 세션 스토리지를 사용하지 않고, 페이지네이션만으로 데이터를 처리합니다. 스크롤 위치나 사용자의 상태를 저장하지 않습니다.
  3. 로딩 상태와 UI 업데이트:
    • ProductList 컴포넌트: 데이터 로딩 중에 Skeleton 컴포넌트를 사용하여 사용자에게 로딩 상태를 알려주고, 데이터가 로드되면 ProductCard를 렌더링합니다.
    • NoticeTable 컴포넌트: DataTable을 사용하여 데이터를 테이블 형태로 표시하며, 로딩 중에는 버튼을 비활성화하고, 로딩 스피너를 표시합니다. 데이터가 모두 로드된 후에는 "더보기" 버튼이 제공됩니다.
  4. 데이터 구조와 변환:
    • useSearchProductQuery: data를 products로 변환하여 최종적으로 ProductCard에 전달합니다. 변환 과정이 내부적으로 처리됩니다.
    • useGetNoticeQuery: pages 데이터를 사용하여 각 아이템의 인덱스를 조정하고, 이를 DataTable에 전달합니다. 데이터의 구조를 변환하여 UI에 적합하게 맞춥니다.
  5. 컴포넌트의 UI 및 기능:
    • ProductList: 상품 목록을 스크롤을 통해 무한으로 로드하고, 사용자가 상품을 클릭할 때 스크롤 위치를 저장합니다. UI는 상품 카드와 스켈레톤 로더로 구성됩니다.
    • NoticeTable: 공지사항을 테이블로 렌더링하며, "더보기" 버튼을 통해 추가 데이터를 로드할 수 있습니다. 데이터는 테이블로 표시되며, 클릭 시 상세 페이지로 이동합니다.

요약

  • useSearchProductQuery는 더 복잡한 로직을 통해 사용자의 스크롤 위치를 기억하고, 더 유연하게 데이터 패칭을 제어합니다. 세션 스토리지를 사용하여 사용자 경험을 개선합니다.
  • useGetNoticeQuery는 간단한 페이지네이션 방식으로 데이터를 처리하며, 로딩 상태와 UI 업데이트에 중점을 둡니다. 스크롤 위치 관리 기능은 없지만, UI는 깔끔하게 구현되어 있습니다.

 

변환 과정

  1. 세션 스토리지 및 스크롤 위치 관리 추가
    • sessionStorage를 사용하여 사용자의 스크롤 위치와 클릭 상태를 저장하고, 데이터를 패칭할 때 이를 고려하도록 수정합니다.
  2. 쿼리 함수 수정
    • 페이지네이션 로직을 useSearchProductQuery처럼 더 유연하게 조정하여, 데이터 요청 시 메타데이터를 활용합니다.
  3. 프리패칭 로직 추가
    • useSearchProductQuery처럼 프리패칭 로직을 추가하여, 데이터가 로드되기 전에 사용자의 상호작용 상태를 기억하고 로드 시 이를 반영합니다.
import { useEffect, useState } from "react";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { useAxios } from "@/hooks/useAxios";
import sessionStorage from "@utils/sessionStorage";
import { NoticeList } from "../schema";
import { ResType } from "@/types/type";

const SESSIONSTORAGE_KEY = "clickedNotice";

const useGetNoticeQuery = () => {
  const axios = useAxios();
  const queryClient = useQueryClient();
  const [isPrefetchData, setIsPrefetchData] = useState(false);

  const getNotice = async (pageParam: number) => {
    const res = await axios.get<ResType<{ itemCnt: number; itemList: NoticeList }>>("/api/client/notice/list", {
      params: { pageNum: pageParam },
    });
    return res.data?.data;
  };

  useEffect(() => {
    (async () => {
      const getStorage = sessionStorage.getItem(SESSIONSTORAGE_KEY);
      if (!getStorage) return;

      const { clickedGoodsIndex, anchorPosition } = getStorage;

      await queryClient.prefetchInfiniteQuery(["GET", "notice"], (context) => {
        const pageParam = context.pageParam || 1;
        const meta = {
          rowsPerPage: Number(clickedGoodsIndex) < 10 ? 10 : Number(clickedGoodsIndex),
        };
        return getNotice(pageParam);
      });

      const getData = queryClient.getQueryData(["GET", "notice"])?.pages[0];
      if (!getData) return;

      queryClient.setQueryData(["GET", "notice"], {
        pages: [{ ...getData }],
        pageParams: [1],
      });

      if (anchorPosition) {
        window.scrollTo({ top: anchorPosition });
        sessionStorage.removeItem(SESSIONSTORAGE_KEY);
      }
    })();
  }, [queryClient]);

  return useInfiniteQuery({
    queryKey: ["GET", "notice"],
    queryFn: ({ pageParam = 1 }) => getNotice(pageParam),
    getNextPageParam: (lastPage, allPages) => {
      const totalCount = lastPage?.itemCnt ?? 0;
      const hasMore = Math.ceil(totalCount / 10) > allPages.length;
      return hasMore ? allPages.length + 1 : undefined;
    },
    refetchOnWindowFocus: false,
  });
};

export default useGetNoticeQuery;

 

 


 

페이지가 초기 데이터 이상으로 불러와졌을 때, 스크롤 위치를 복원하려면 다음과 같은 방법을 사용할 수 있었습니다.

 

  • 상태 관리의 일관성: 세션 스토리지를 활용하여 사용자의 상태를 저장하고 복원함으로써, 페이지가 재로드되거나 새로 고침되더라도 상태를 유지할 수 있습니다.
  • 유연한 쿼리 로직: 메타데이터를 활용하여 페이지 네비게이션과 데이터 패칭 로직을 더 세밀하게 조정할 수 있습니다. 이는 복잡한 요구 사항을 충족시키는 데 유리합니다.
  • 향상된 사용자 경험: 스크롤 위치 유지와 프리패칭 기능을 통해 사용자에게 더 매끄럽고 빠른 경험을 제공할 수 있습니다.

 

 

참고

https://oliveyoung.tech/blog/2023-10-04/useInfiniteQuery-scroll/

 

useInfiniteQuery로 무한스크롤 구현하기 | 올리브영 테크블로그

무한스크롤 구현 방법과 뒤로가기 시 스크롤 유지하는 방법을 소개합니다.

oliveyoung.tech

https://tech.kakaoenterprise.com/149

 

실전 Infinite Scroll with React

시작하며 안녕하세요. 카카오엔터프라이즈 워크코어개발셀에서 프론트엔드 개발을 담당하고 있는 Denis(배형진) 입니다. 약 1년 전, 저는 프레임워크의 선택, React vs Angular 이라는 포스팅을 통해

tech.kakaoenterprise.com

https://www.bucketplace.com/post/2020-09-10-%EC%98%A4%EB%8A%98%EC%9D%98%EC%A7%91-%EB%82%B4-%EB%AC%B4%ED%95%9C%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B0%9C%EB%B0%9C%EA%B8%B0/