React Query의 useInfiniteQuery로 복잡한 무한 스크롤, 쉽게 구현하기
React Query의 `useInfiniteQuery` 훅을 사용하여 복잡한 무한 스크롤 기능을 간결하고 효율적으로 구현하는 방법을 알아봅니다. API 설계부터 컴포넌트 구현, 스크롤 기반 로딩까지 상세하게 다룹니다.
상품 목록, 댓글 피드, 채팅 메시지... 무한 스크롤, 언제까지 복잡하게 구현하실 건가요?
사용자는 페이지를 넘기지 않고도 끝없이 콘텐츠를 소비합니다. 상품 목록, 댓글 피드, 채팅 메시지 등, 웹 애플리케이션에서 무한 스크롤은 이제 선택이 아닌 필수가 되었습니다. 하지만 무한 스크롤을 직접 구현하려면 여러 가지 복잡한 문제에 직면하게 됩니다. 페이지네이션 상태 관리, 데이터 로딩 상태 처리, API 요청 중복 방지, 에러 처리까지… 직접 구현하는 것은 꽤나 번거롭고 실수하기도 쉽습니다.
이 글에서는 React Query의 useInfiniteQuery 훅을 활용하여 이러한 복잡성을 해결하고, 견고하고 효율적인 무한 스크롤을 구현하는 방법을 알아보겠습니다. 특히, useInfiniteQuery가 어떻게 페이지네이션 상태 관리와 데이터 페칭을 단순화하는지에 초점을 맞출 것입니다.
useInfiniteQuery란 무엇이고 왜 필요한가?
useInfiniteQuery는 React Query가 제공하는 훅으로, 이름 그대로 '무한한' 데이터를 페칭하는 데 특화되어 있습니다. 기존의 useQuery가 단일 데이터셋을 가져오는 데 집중한다면, useInfiniteQuery는 여러 개의 페이지에 걸쳐 데이터를 순차적으로 가져오는 시나리오를 염두에 두고 설계되었습니다.
이 훅은 다음과 같은 핵심 기능을 제공하여 무한 스크롤 구현을 돕습니다.
- 페이지네이션 상태 자동 관리:
useInfiniteQuery는pageParam이라는 개념을 통해 현재 어떤 페이지의 데이터를 가져와야 하는지 추적합니다. 사용자가 스크롤을 내려 추가 데이터를 요청할 때마다pageParam이 자동으로 업데이트되어 다음 페이지 데이터를 가져올 준비를 합니다. - 데이터 병합: 여러 페이지에서 가져온 데이터들을 하나의 배열로 자연스럽게 병합하여 제공합니다. 이를 통해 개발자는 별도의 상태 관리 없이 모든 데이터를 통합된 형태로 다룰 수 있습니다.
- 상태 관리:
isLoading,isFetching,isError,error,data등 데이터 페칭과 관련된 다양한 상태를 제공하여 UI를 효과적으로 제어할 수 있습니다. fetchNextPage와hasNextPage: 새로운 페이지의 데이터를 가져오기 위한fetchNextPage함수와 더 이상 가져올 페이지가 없는지를 나타내는hasNextPage불리언 값을 제공하여, 사용자가 스크롤 끝에 도달했을 때 추가 데이터 요청을 멈추는 로직을 쉽게 구현할 수 있습니다.
useInfiniteQuery로 무한 스크롤 구현하기
간단한 상품 목록을 예시로 useInfiniteQuery를 사용한 무한 스크롤 구현을 살펴보겠습니다. 여기서는 axios를 사용하여 API 요청을 보낼 것이며, 각 페이지 요청 시 pageParam을 함께 전달하여 서버로부터 해당 페이지의 데이터를 받아옵니다.
1. API 엔드포인트 설계
먼저, 서버는 페이지 번호(page)와 페이지당 항목 수(limit)를 받아 해당 데이터를 반환해야 합니다. 또한, 더 이상 가져올 데이터가 없을 경우를 대비하여 hasNextPage와 같은 정보를 응답에 포함하는 것이 좋습니다. (이 예제에서는 응답에 nextCursor나 isLastPage와 같은 필드가 있다고 가정합니다.)
1// 예시 API 응답 구조 2{ 3 "data": [ 4 { "id": 1, "name": "Product A" }, 5 { "id": 2, "name": "Product B" }, 6 // ... 7 ], 8 "nextCursor": "someCursorValue" // 혹은 "isLastPage": false 9}
2. useInfiniteQuery 설정
React 컴포넌트에서 useInfiniteQuery를 설정합니다. queryKey, queryFn, getNextPageParam이 핵심 설정입니다.
queryKey: 쿼리를 식별하는 고유한 키입니다. 배열 형태로 전달하며, 상태 변화를 감지하는 데 사용됩니다.queryFn: 데이터를 페칭하는 비동기 함수입니다.pageParam을 인자로 받아 API 요청을 보냅니다.getNextPageParam: 이전 페이지의pageParam과 현재 페이지의 응답 데이터를 기반으로 다음 페이지의pageParam을 결정하는 함수입니다. 이 함수가undefined를 반환하면hasNextPage는false가 됩니다.
1import { useInfiniteQuery } from '@tanstack/react-query'; 2import axios from 'axios'; 3 4const fetchProducts = async ({ pageParam = 0 }) => { 5 const response = await axios.get('/api/products', { 6 params: { page: pageParam, limit: 10 }, // 예시: 페이지 번호와 limit 전달 7 }); 8 return response.data; 9}; 10 11function ProductList() { 12 const { 13 data, 14 fetchNextPage, 15 hasNextPage, 16 isFetchingNextPage, 17 isLoading, 18 error 19 } = useInfiniteQuery({ 20 queryKey: ['products'], 21 queryFn: fetchProducts, 22 initialPageParam: 0, // 첫 페이지 요청 시 사용할 pageParam 23 getNextPageParam: (lastPage, allPages) => { 24 // lastPage: 마지막으로 받아온 페이지 데이터 25 // allPages: 지금까지 받아온 모든 페이지 데이터 배열 26 27 // 서버 응답에 'nextCursor'가 있다면 그걸 사용하거나, 'isLastPage'로 판단 28 if (lastPage.isLastPage) { // 서버에서 더 이상 데이터가 없다고 알려줄 때 29 return undefined; 30 } 31 32 // 혹은 다음 페이지의 pageParam을 계산 (예: 페이지 번호 증가) 33 // return lastPage.currentPage + 1; 34 35 // 여기서는 nextCursor를 사용한다고 가정 36 return lastPage.nextCursor; 37 }, 38 // staleTime: 5 * 60 * 1000, // 5분 동안 fresh 상태 유지 (선택 사항) 39 }); 40 41 if (isLoading) return <div>Loading initial products...</div>; 42 if (error) return <div>An error occurred: {error.message}</div>; 43 44 return ( 45 <div> 46 <h1>Products</h1> 47 {data.pages.map((page, i) => ( 48 <React.Fragment key={i}> 49 {page.data.map(product => ( 50 <div key={product.id}>{product.name}</div> 51 ))} 52 </React.Fragment> 53 ))} 54 55 <button 56 onClick={() => fetchNextPage()} 57 disabled={!hasNextPage || isFetchingNextPage} 58 > 59 {isFetchingNextPage 60 ? 'Loading more...' 61 : hasNextPage 62 ? 'Load More' 63 : 'Nothing more to load'} 64 </button> 65 </div> 66 ); 67} 68 69export default ProductList;
3. UI 렌더링 및 추가 데이터 로딩
useInfiniteQuery는 data.pages라는 형태로 모든 페이지의 데이터를 배열로 제공합니다. 각 페이지의 데이터는 page 객체 안에 포함되어 있습니다. 이를 map을 사용하여 렌더링하고, fetchNextPage 함수를 버튼 클릭이나 스크롤 이벤트에 연결하여 추가 데이터를 로드합니다.
스크롤 기반 로딩 구현 (Intersection Observer API 활용)
실제 서비스에서는 버튼 클릭 대신 스크롤이 감지될 때 자동으로 데이터를 로드하는 것이 일반적입니다. Intersection Observer API를 사용하면 이를 효율적으로 구현할 수 있습니다.
1import React, { useEffect, useRef } from 'react'; 2import { useInfiniteQuery } from '@tanstack/react-query'; 3import axios from 'axios'; 4 5// ... (fetchProducts 함수는 위와 동일) 6 7function ProductListWithScroll() { 8 const { 9 data, 10 fetchNextPage, 11 hasNextPage, 12 isFetchingNextPage, 13 isLoading, 14 error 15 } = useInfiniteQuery({ 16 queryKey: ['products'], 17 queryFn: fetchProducts, 18 initialPageParam: 0, 19 getNextPageParam: (lastPage, allPages) => { 20 if (lastPage.isLastPage) return undefined; 21 return lastPage.nextCursor; 22 }, 23 }); 24 25 const loadMoreButtonRef = useRef(null); 26 27 useEffect(() => { 28 if (!loadMoreButtonRef.current) return; 29 30 const observer = new IntersectionObserver( 31 (entries) => { 32 entries.forEach((entry) => { 33 if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { 34 fetchNextPage(); 35 } 36 }); 37 }, 38 { threshold: 0.1 } // 요소가 10% 보일 때 콜백 실행 39 ); 40 41 observer.observe(loadMoreButtonRef.current); 42 43 // 컴포넌트 언마운트 시 observer 정리 44 return () => { 45 if (loadMoreButtonRef.current) { 46 observer.unobserve(loadMoreButtonRef.current); 47 } 48 }; 49 }, [fetchNextPage, hasNextPage, isFetchingNextPage]); 50 51 if (isLoading) return <div>Loading initial products...</div>; 52 if (error) return <div>An error occurred: {error.message}</div>; 53 54 return ( 55 <div> 56 <h1>Products</h1> 57 {data.pages.map((page, i) => ( 58 <React.Fragment key={i}> 59 {page.data.map(product => ( 60 <div key={product.id}>{product.name}</div> 61 ))} 62 </React.Fragment> 63 ))} 64 65 {/* 마지막 요소에 ref를 달아 스크롤 감지 */} 66 {hasNextPage && ( 67 <div ref={loadMoreButtonRef} style={{ height: '10px', background: 'transparent' }}> 68 {isFetchingNextPage ? 'Loading...' : ''} 69 </div> 70 )} 71 {!hasNextPage && !isFetchingNextPage && <div>All products loaded.</div>} 72 </div> 73 ); 74} 75 76export default ProductListWithScroll;
위 코드에서는 useRef를 사용하여 fetchNextPage를 트리거할 요소를 참조하고, useEffect 내에서 Intersection Observer를 설정하여 해당 요소가 화면에 보일 때 fetchNextPage를 호출하도록 했습니다. isFetchingNextPage와 hasNextPage를 통해 로딩 중이거나 더 이상 로드할 데이터가 없을 때는 불필요한 요청을 방지합니다.
useInfiniteQuery 활용 시 고려사항
staleTime과cacheTime: 무한 스크롤 데이터는 자주 변경되지 않는 경우staleTime을 적절히 설정하여 불필요한 재페칭을 줄일 수 있습니다.cacheTime은 기본값으로도 충분할 수 있으나, 메모리 사용량을 고려하여 조정할 수 있습니다.- 에러 처리:
useInfiniteQuery는isError와error상태를 제공합니다.fetchNextPage호출 시 발생하는 에러에 대한 적절한 UI 피드백을 제공해야 합니다. React Query는isFetchingNextPage상태를 통해 현재 페이지 로딩 여부를 알 수 있으므로, 이를 활용하여 사용자에게 로딩 중임을 알리거나 에러 메시지를 표시할 수 있습니다. queryFn의 pageParam:getNextPageParam에서 반환하는 값이queryFn으로 전달됩니다. 서버 API의 페이지네이션 방식(offset-based, cursor-based 등)에 맞춰pageParam을 올바르게 설정하고,queryFn에서 이를 활용해야 합니다. 커서 기반 페이지네이션의 경우,nextCursor와 같은 값을pageParam으로 사용하는 것이 효율적입니다.- 데이터 병합:
data.pages는 배열의 배열 형태입니다. 모든 데이터를 하나의 배열로 다루고 싶다면,useMemo등을 활용하여 데이터를 가공하는 것이 좋습니다.React Query Devtools를 통해data.pages구조를 시각적으로 확인하면 이해에 도움이 됩니다.
마치며
React Query의 useInfiniteQuery는 복잡한 무한 스크롤 구현을 위한 강력하고 간결한 솔루션을 제공합니다. 상태 관리, 데이터 페칭, 페이지네이션 로직을 추상화하여 개발자는 비즈니스 로직에 더 집중할 수 있습니다. getNextPageParam을 올바르게 설정하고, Intersection Observer와 같은 최신 웹 API를 활용하면 사용자 경험까지 향상시키는 동적인 무한 스크롤을 쉽게 구현할 수 있습니다.