이번 프로젝트를 진행하면서 무한스크롤을 구현해봤습니다.
타 회사에서 무한스크롤 구현하는 포스팅을 보고 참고해서 비교해 보면서 코드 리팩토링을 할 부분에 대해 작성해보겠습니다.
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,
});
};
공통점
- 무한 스크롤 구현:
- 두 코드 모두 useInfiniteQuery를 사용하여 무한 스크롤을 구현하고 있습니다. 이 방식은 데이터를 페이지 단위로 로드하고, 사용자가 스크롤을 내릴 때마다 다음 페이지의 데이터를 로드하는 방식입니다.
- 쿼리 캐싱과 프리패칭:
- 두 코드 모두 react-query의 쿼리 캐싱 기능을 활용하여 데이터를 효율적으로 관리하고 있습니다. 특히, useSearchProductQuery에서는 세션 스토리지에서 저장된 데이터를 활용하여 프리패칭(prefetching)합니다.
차이점
- 데이터 패칭 방식:
- useSearchProductQuery: queryFn을 사용하여 API 호출을 직접 정의하고, 쿼리의 메타데이터를 통해 페이지의 시작 위치와 상품 수를 조정합니다. 이는 API 호출을 더 유연하게 제어할 수 있게 해줍니다.
- useGetNoticeQuery: 단순히 페이지 번호를 queryFn에 전달하여 데이터를 패칭하며, 페이지 파라미터와 총 아이템 수를 기반으로 다음 페이지의 존재 여부를 결정합니다.
- 세션 스토리지 사용:
- useSearchProductQuery: 세션 스토리지를 활용하여 사용자가 이전에 클릭한 상품의 위치를 저장하고, 다음 페이지 로드 시 해당 위치로 스크롤을 자동으로 이동합니다. 이는 사용자의 위치를 유지하는데 유용합니다.
- useGetNoticeQuery: 세션 스토리지를 사용하지 않고, 페이지네이션만으로 데이터를 처리합니다. 스크롤 위치나 사용자의 상태를 저장하지 않습니다.
- 로딩 상태와 UI 업데이트:
- ProductList 컴포넌트: 데이터 로딩 중에 Skeleton 컴포넌트를 사용하여 사용자에게 로딩 상태를 알려주고, 데이터가 로드되면 ProductCard를 렌더링합니다.
- NoticeTable 컴포넌트: DataTable을 사용하여 데이터를 테이블 형태로 표시하며, 로딩 중에는 버튼을 비활성화하고, 로딩 스피너를 표시합니다. 데이터가 모두 로드된 후에는 "더보기" 버튼이 제공됩니다.
- 데이터 구조와 변환:
- useSearchProductQuery: data를 products로 변환하여 최종적으로 ProductCard에 전달합니다. 변환 과정이 내부적으로 처리됩니다.
- useGetNoticeQuery: pages 데이터를 사용하여 각 아이템의 인덱스를 조정하고, 이를 DataTable에 전달합니다. 데이터의 구조를 변환하여 UI에 적합하게 맞춥니다.
- 컴포넌트의 UI 및 기능:
- ProductList: 상품 목록을 스크롤을 통해 무한으로 로드하고, 사용자가 상품을 클릭할 때 스크롤 위치를 저장합니다. UI는 상품 카드와 스켈레톤 로더로 구성됩니다.
- NoticeTable: 공지사항을 테이블로 렌더링하며, "더보기" 버튼을 통해 추가 데이터를 로드할 수 있습니다. 데이터는 테이블로 표시되며, 클릭 시 상세 페이지로 이동합니다.
요약
- useSearchProductQuery는 더 복잡한 로직을 통해 사용자의 스크롤 위치를 기억하고, 더 유연하게 데이터 패칭을 제어합니다. 세션 스토리지를 사용하여 사용자 경험을 개선합니다.
- useGetNoticeQuery는 간단한 페이지네이션 방식으로 데이터를 처리하며, 로딩 상태와 UI 업데이트에 중점을 둡니다. 스크롤 위치 관리 기능은 없지만, UI는 깔끔하게 구현되어 있습니다.
변환 과정
- 세션 스토리지 및 스크롤 위치 관리 추가
- sessionStorage를 사용하여 사용자의 스크롤 위치와 클릭 상태를 저장하고, 데이터를 패칭할 때 이를 고려하도록 수정합니다.
- 쿼리 함수 수정
- 페이지네이션 로직을 useSearchProductQuery처럼 더 유연하게 조정하여, 데이터 요청 시 메타데이터를 활용합니다.
- 프리패칭 로직 추가
- 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
'TIL' 카테고리의 다른 글
[HTTP response status codes] Server error responses (0) | 2024.09.02 |
---|---|
[자바스크립트의 패키지 관리 도구] npm과 yarn (2) | 2024.08.28 |
Tailwind CSS, CSS-in-JS, Styled Components 비교: 왜 Tailwind를 선택했는지? (0) | 2024.08.25 |
.gitignore가 작동하지 않을때 (0) | 2024.08.02 |
[Next.js] Nextjs에 Clerk 적용 2 (0) | 2024.07.26 |