NextJS + React Query로 Infinite Scroll 구현하기

1. Infinite Scroll 이란?
무한 스크롤(Infinite Scroll)은 사용자가 페이지의 하단에 도달 했을 때 자동으로 새로운 콘텐츠를 로드하는 웹 디자인 패턴입니다. Intagram, Youtube, 넷플릭스와 같은 소셜 미디어 플랫폼에서 흔히 볼 수 있는 이 기능은 사용자 경험을 크게 향상 시키는 중요한 요소입니다. 사용자는 뷰포트 내에 표시되는 콘텐츠만 보고 이탈 할 수 도 있기 때문에, 모든 리스트들을 한번에 로드하는 것이 아닌 사용자가 원하는 만큼 콘텐츠를 로드하는 것이 중요합니다.
1.1 무한 스크롤의 장점
- 사용자 경험 향상
- 페이지 로드 시간 단축
- 서버 부하 감소
2. NextJs + React Query로 무한 스크롤 구현
2.1 프로젝트 설정
"next": "15.0.3"
"react-intersection-observer": "^9.13.1"
"@tanstack/react-query": "^5.61.5"
"nuqs": "^2.2.3"
"zod": "^3.23.8"
라이브러리 버전은 위에처럼 셋팅을 해주었고, Nextjs15 버전에서 작업을 진행하였습니다. 15버전에서는 기존에 사용하던 라이브러리들이 호환이 안되는 경우가 있어서 주의가 필요합니다.
2.2 React Query QueryClient 설정하기
React Query를 Next.js 환경에서 사용할 때는 서버사이드와 클라이언트사이드의 특성을 고려한 설정이 필요합니다. 아래 코드를 통해 효율적인 QueryClient 설정 방법을 알아보겠습니다.
import { isServer, QueryClient } from '@tanstack/react-query'; function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); } // 브라우저에서 사용할 QueryClient 인스턴스 let browerQueryClient: QueryClient | undefined; // QueryClient 인스턴스를 가져오는 함수 function getQueryClient() { if (isServer) { // 서버사이드에서는 항상 새로운 인스턴스 생성 return makeQueryClient(); } if (!browerQueryClient) { // 클라이언트사이드에서는 싱글톤 패턴 사용 browerQueryClient = makeQueryClient(); } return browerQueryClient; } export { makeQueryClient, getQueryClient };
provider.tsx 에서 각종 프로바이더들을 설정합니다. nuqs는 Next.js에 특화된 URL 쿼리 파라미터 관리 라이브러리입니다. URL 쿼리 파라미터를 통한 상태 관리 지원을 합니다.
// providers.tsx // provider.tsx 에서 각종 프로바이더들을 설정합니다. 'use client'; import { ReactNode } from 'react'; import { QueryClientProvider } from '@tanstack/react-query'; import { getQueryClient } from '@/react-query/client'; import { NuqsAdapter } from 'nuqs/adapters/next/app'; export default function Providers({ children }: { children: ReactNode }) { const queryClient = getQueryClient(); return ( <QueryClientProvider client={queryClient}> <NuqsAdapter>{children}</NuqsAdapter> </QueryClientProvider> ); }
Root Layout에서 프로바이더를 import해서 사용하면 됩니다.
// layout.tsx export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}> <Providers>{children}</Providers> </body> </html> ); }
2.3 서버 액션으로 API 통신 구현하기
Next.js의 Server Actions를 활용하여 제품 데이터를 가져오는 API 통신을 구현해보겠습니다. Server Actions를 사용하면 클라이언트에서 직접 API를 호출하는 대신, 서버 사이드에서 안전하게 데이터를 가져올 수 있습니다. 상세한 로직은 아래와 같습니다. 자세한 코드 내용은 생략하겠습니다.
Zod를 통한 데이터 타입 검증을 진행하였고, 데이터 타입 검증에 실패하면 에러 메시지를 반환하는 로직을 구현하였습니다.(서버응답이 타입에 맞지 않는 경우 에러 메시지를 반환하는 로직을 구현하였습니다.)
'use server'; import { ITEM_PER_LIMIT } from '@/contants/infinite-scroll'; import { ProductResponse, ProductResponseSchema } from '@/schemas/quote.schema'; import { ActionResult } from '@/types/action-result'; const BASE_URL = 'https://dummyjson.com'; // 제품 목록 조회 export async function getProducts(pageParams: number): Promise<ActionResult<ProductResponse>> { const response = await fetch(`${BASE_URL}/products?limit=${ITEM_PER_LIMIT}&skip=${pageParams}`); if (!response.ok) { return { status: 'error', error: 'Failed to fetch quotes' }; } const result = await response.json(); const { success, data: productData } = ProductResponseSchema.safeParse(result); if (!success) { return { status: 'error', error: 'Invalid response data' }; } return { status: 'success', data: productData }; } // 제품 검색 export async function searchProducts({ pageParam, search, }: { pageParam: number; search: string; }): Promise<ActionResult<ProductResponse>> { const response = await fetch( `${BASE_URL}/products/search?q=${search}&limit=${ITEM_PER_LIMIT}&skip=${pageParam}` ); if (!response.ok) { return { status: 'error', error: 'Failed to fetch quotes' }; } const result = await response.json(); const { success, data: productData } = ProductResponseSchema.safeParse(result); if (!success) { return { status: 'error', error: 'Invalid response data' }; } return { status: 'success', data: productData }; }
2.4 무한 스크롤 커스텀 훅 구현하기
React Query와 Intersection Observer를 활용하여 무한 스크롤 기능을 구현해보겠습니다. 검색 기능까지 포함된 완성도 높은 커스텀 훅을 만들어보겠습니다. 아래는 전체 코드입니다.
const getProductQueryKey = (keyword?: string) => { return keyword ? queryKeys.products.search(keyword) : queryKeys.products.all; }; const fetchProducts = async (pageParam: number = 0, keyword?: string) => { return keyword ? searchProducts({ pageParam, search: keyword }) : getProducts(pageParam); }; const useInfiniteProductQuery = () => { const { q } = useProductSearch(); const debouncedKeyword = useDebounce(q, 500); const { ref, inView } = useInView(); const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteQuery({ queryKey: getProductQueryKey(debouncedKeyword), queryFn: async ({ pageParam = 0 }) => { const result = await fetchProducts(pageParam, debouncedKeyword); console.log(result, 'result'); if (result.status === 'error') { throw new Error(result.error); } return result.data; }, initialPageParam: 0, getNextPageParam: (lastPage: ProductResponse, allPages: ProductResponse[]) => { const pageCount = allPages.length; const totalCount = lastPage.total; return totalCount > ITEM_PER_LIMIT * pageCount ? pageCount * ITEM_PER_LIMIT : undefined; }, }); useEffect(() => { if (inView && hasNextPage) { fetchNextPage(); } }, [inView, hasNextPage, fetchNextPage]); return { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, ref, }; }; export default useInfiniteProductQuery;
2.4.1 쿼리 키 설정
const getProductQueryKey = (keyword?: string) => { return keyword ? queryKeys.products.search(keyword) : queryKeys.products.all; };
쿼리 키를 동적으로 관리하여 검색어 유무에 따라 다른 캐시를 사용합니다.
2.4.2 데이터 페칭 함수
const fetchProducts = async (pageParam: number = 0, keyword?: string) => { return keyword ? searchProducts({ pageParam, search: keyword }) : getProducts(pageParam); };
검색어 유무에 따라 적절한 API를 호출하는 유틸리티 함수입니다.
2.4.3 무한 스크롤 커스텀 훅
const useInfiniteProductQuery = () => { // 검색어 상태 관리 const { q } = useProductSearch(); const debouncedKeyword = useDebounce(q, 500); // Intersection Observer 설정 const { ref, inView } = useInView(); // React Query Infinite Query 설정 const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteQuery({ queryKey: getProductQueryKey(debouncedKeyword), queryFn: async ({ pageParam = 0 }) => { const result = await fetchProducts(pageParam, debouncedKeyword); if (result.status === 'error') { throw new Error(result.error); } return result.data; }, initialPageParam: 0, getNextPageParam: (lastPage: ProductResponse, allPages: ProductResponse[]) => { const pageCount = allPages.length; const totalCount = lastPage.total; return totalCount > ITEM_PER_LIMIT * pageCount ? pageCount * ITEM_PER_LIMIT : undefined; }, }); // Intersection Observer를 통한 무한 스크롤 구현 useEffect(() => { if (inView && hasNextPage) { fetchNextPage(); } }, [inView, hasNextPage, fetchNextPage]); return { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, ref, }; };
코드에 핵심 로직들이므로 하나씩 상세하게 살펴보겠습니다.
useInfiniteQuery는 무한 스크롤 구현을 위한 React Query의 핵심 훅입니다. 이 훅이 제공하는 주요 값들을 하나씩 살펴보겠습니다.
const { data, // 페이지별 데이터 배열 isLoading, // 초기 로딩 상태 isFetchingNextPage, // 다음 페이지 로딩 상태 hasNextPage, // 다음 페이지 존재 여부 fetchNextPage, // 다음 페이지 로드 함수 } = useInfiniteQuery(options);
각 값들의 역할을 자세히 알아보겠습니다:
1.data
- pages 배열: 각 페이지의 데이터
- pageParams 배열: 각 페이지의 파라미터 정보
// 예시 데이터 { pages: [ { products: [ { id: 1, title: "Essence Mascara Lash Princess", description: 'The Essence Mascara Lash Princess is a popular mascara known for its volumizing and lengthening effects. Achieve dramatic lashes with this long-lasting and cruelty-free formula.', category: 'beauty', price: 9.99, discountPercentage: 7.17, rating: 4.94, stock: 5, tags: ['beauty', 'mascara'], sku: 'RCH45Q1A', weight: 2, warrantyInformation: '1 month warranty', shippingInformation: 'Ships in 1 month', availabilityStatus: 'Low Stock', images: [ 'https://cdn.dummyjson.com/products/images/beauty/Essence%20Mascara%20Lash%20Princess/1.png', ], thumbnail: 'https://cdn.dummyjson.com/products/images/beauty/Essence%20Mascara%20Lash%20Princess/thumbnail.png', }, ], total: 194, skip: 0, limit: 20, }, ]; "pageParams": [ 0 ] }
2.상태 플래그들
- isLoading: 첫 페이지 로딩 중 여부
- isFetchingNextPage: 추가 페이지 로딩 중 여부
- hasNextPage: 더 로드할 페이지가 있는지 여부
getNextPageParam 함수는 다음 페이지의 존재 여부를 결정하는 핵심 로직입니다. 이 함수는 현재 페이지와 이전 페이지들의 데이터를 받아서 다음 페이지의 파라미터를 반환합니다. 이 값이 undefined인 경우 더 이상 페이지를 로드하지 않습니다.
getNextPageParam: (lastPage, allPages) => { // allPages는 이전 페이지들의 데이터를 담은 배열입니다. // lastPage는 현재 페이지의 데이터입니다. const pageCount = allPages.length; // 현재 로드된 페이지 수 const totalCount = lastPage.total; // 전체 아이템 수, 응답 데이터를 보면 total값이 있습니다. // allPages 예시 데이터 // [{products: [...], total: 194}}, {products: [...], total: 194}] // lastPage 예시데이터 // {products: [...], total: 194} // 다음 페이지 존재 여부 계산 return totalCount > ITEM_PER_LIMIT * pageCount ? pageCount * ITEM_PER_LIMIT // 다음 페이지 시작점 : undefined; // 더 이상 페이지 없음 };
3.1 페이지네이션 계산 예시
예를 들어 한 페이지당 10개의 아이템을 보여주고, 총 25개의 아이템이 있다면:
1.첫 페이지 로드(0-9)
pageCount = 1
totalCount = 25
nextPageParam = 10 // 다음 페이지 있음
2.두번째 페이지 로드(10-19)
pageCount = 2
totalCount = 25
nextPageParam = 20 // 다음 페이지 있음
3.마지막 페이지 로드(20-24)
pageCount = 3
totalCount = 25
nextPageParam = undefined // 더 이상 페이지 없음
2.4.4 react-intersection-observer 활용
const { ref, inView } = useInView(); useEffect(() => { if (inView && hasNextPage) { fetchNextPage(); } }, [inView, hasNextPage, fetchNextPage]);
ref로 지정한 요소가 화면에 보일 때 트리거되는 콜백 함수입니다. 즉, 마지막 아이템이 보일 때 다음 페이지를 로드하는 역할을 합니다.(fetchNextPage 함수 호출)
3. 각종 훅
여기서 사용되는 각종 훅입니다. 자세한 내용은 코드로 확인해주세요.
3.1 useDebounce
검색 기능 구현 시 사용자가 입력할 때마다 API를 호출하면 불필요한 요청이 많이 발생합니다. 이를 방지하기 위해 디바운싱(Debouncing)을 구현해보겠습니다.
import { useEffect, useState } from 'react'; const useDebounce = (value: string, delay: number) => { // 디바운스된 값을 관리할 상태 const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { // delay 시간 후에 값을 업데이트 const timerId = setTimeout(() => { setDebouncedValue(value); }, delay); // 클린업 함수로 이전 타이머 제거 return () => clearTimeout(timerId); }, [value, delay]); // value나 delay가 변경될 때마다 타이머 재설정 return debouncedValue; }; export default useDebounce;
3.2 useProductSearch
Next.js에서 검색 상태를 URL 쿼리 파라미터로 관리하는 방법을 알아보겠습니다. nuqs 라이브러리를 사용하여 검색어를 URL과 동기화하고 히스토리를 관리해보겠습니다.
'use client'; import { parseAsString, useQueryState } from 'nuqs'; const useProductSearch = () => { // URL 쿼리 파라미터 상태 관리 const [q, setQ] = useQueryState( 'q', // 쿼리 파라미터 키 parseAsString .withDefault('') // 기본값 설정 .withOptions({ history: 'push', // 브라우저 히스토리에 추가 }) ); // 검색어 변경 핸들러 const handleSearchChange = async (search: string) => { if (q === search) { return; // 같은 검색어면 무시 } await setQ(search); // URL 업데이트 }; return { q, handleSearchChange }; }; export default useProductSearch;
4. 무한 스크롤 제품 목록 구현하기
이제 지금까지 구현한 모든 기능을 조합하여 최종적인 제품 목록 컴포넌트를 만들어보겠습니다.
export default function ProductLists() { // 무한 스크롤 커스텀 훅 사용 const { isLoading, data, ref, isFetchingNextPage } = useInfiniteProductQuery(); // 모든 페이지의 제품을 하나의 배열로 병합 const products = data?.pages.flatMap((page) => page.products) || []; // 로딩 상태에 따른 스켈레톤 개수 설정 const skeletonCount = isLoading ? 10 : isFetchingNextPage ? 5 : 0; return ( <div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 max-w-[1500px] mx-auto"> {/* 제품 목록 렌더링 */} {!isLoading && products.map((product) => ( <Card key={product.id}> <CardHeader> <div className="relative aspect-square"> <Image src={product.thumbnail} alt={product.title} fill className="object-cover transition-transform group-hover:scale-105" sizes="(min-width: 1024px) 20vw, (min-width: 768px) 25vw, 50vw" priority /> </div> </CardHeader> <CardContent className="p-4"> <div className="flex items-center justify-between"> <p className="font-semibold text-lg">{product.title}</p> </div> <p className="mt-2 line-clamp-2 text-sm text-muted-foreground"> {product.description} </p> </CardContent> </Card> ))} {/* 로딩 스켈레톤 */} {skeletonCount > 0 && [...Array(skeletonCount)].map((_, i) => <ProductSkeleton key={`skeleton-${i}`} />)} {/* Intersection Observer 타겟 */} <div className="col-span-full h-px" ref={ref} /> </div> ); }
이렇게 구현된 제품 목록은 사용자 경험을 최적화하고, 성능도 고려한 완성도 높은 컴포넌트입니다. React Query, Intersection Observer, Next.js의 다양한 기술을 조합하여 효율적이고 사용자 친화적인 UI를 구현했습니다.