TanStack Query

TanStack Query 무한 스크롤 사용법: useInfiniteQuery로 목록 이어 불러오기

2026.05.07·수정 2026.05.12·약 21분

TanStack Query v5에서 무한 스크롤을 볼 때 먼저 잡아야 할 기준

무한 스크롤은 화면 아래에 닿으면 다음 데이터를 가져오는 기능처럼 보이지만, 실제 구현에서 먼저 흔들리는 부분은 스크롤 감지가 아니라 다음 요청에 넘길 값입니다. 이 글은 TanStack Query v5의 useInfiniteQuery를 기준으로 pageParam, getNextPageParam, data.pages, fetchNextPage가 어떻게 이어지는지 정리합니다.

무한 스크롤이 직접 구현하면 복잡해지는 이유

TanStack Query 무한 스크롤: useInfiniteQuery 구현 흐름 핵심 개념을 설명하는 첫 번째 본문 이미지

상품 목록이나 게시글 목록을 만들 때 무한 스크롤은 처음에는 단순해 보입니다. 현재 페이지 번호를 하나 들고 있다가, 사용자가 화면 아래로 내려오면 페이지 번호를 하나 증가시키고 데이터를 더 가져오면 될 것처럼 보입니다.

문제는 화면이 조금만 실제 서비스에 가까워져도 상태가 빠르게 늘어난다는 점입니다. 첫 로딩 상태, 추가 로딩 상태, 전체 아이템 배열, 현재 페이지, 마지막 페이지 여부, 요청 실패 상태, 중복 요청 방지 조건을 따로 관리해야 합니다. 여기에 검색어, 카테고리, 정렬 조건이 붙으면 “지금까지 받아온 목록을 유지할지, 다시 첫 페이지부터 불러올지”도 함께 결정해야 합니다.

스크롤 이벤트만 기준으로 보면 아래처럼 흐름을 잡기 쉽습니다. 하지만 이 방식은 기능이 커질수록 데이터 요청의 기준이 컴포넌트 내부 상태에 흩어지기 쉽습니다.

const [page, setPage] = useState(1);
const [items, setItems] = useState<Product[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);

이런 상태가 전부 잘못된 것은 아닙니다. 다만 무한 스크롤의 핵심이 “화면 아래 감지”에만 있는 것은 아니라는 점을 먼저 잡아야 합니다. 실제로 자주 꼬이는 부분은 스크롤 위치보다 다음 요청에 넘길 값입니다. 다음 페이지 번호를 어디서 계산할지, 더 이상 데이터가 없다는 판단을 어디서 끝낼지, 필터가 바뀌었을 때 기존 데이터와 새 데이터를 어떻게 분리할지가 더 큰 기준이 됩니다.

TanStack Query의 useInfiniteQuery는 이 지점을 정리해주는 훅입니다. 스크롤을 대신 감지해주는 도구가 아니라, 페이지 단위 응답을 누적하고 다음 요청에 사용할 pageParam 흐름을 관리해주는 역할입니다.

가 맡는 역할

무한 스크롤을 구현할 때 역할을 나누면 구조가 선명해집니다. API 함수는 데이터를 가져오고, useInfiniteQuery는 페이지 단위 캐시와 다음 요청 값을 관리하고, IntersectionObserver는 화면 끝에 도달했는지를 감지합니다.

이 구분이 필요한 이유는 TanStack Query가 스크롤 이벤트를 처리하지 않기 때문입니다. useInfiniteQuery가 해주는 일은 “다음 페이지를 가져와야 할 때 무엇을 기준으로 요청할 것인가”입니다. 그래서 구현할 때도 스크롤 코드부터 작성하기보다 API 응답 구조와 getNextPageParam 반환값을 먼저 정해야 합니다.

pageParam은 어디서 생기는가

TanStack Query v5 기준으로 initialPageParam은 첫 요청에 사용할 값입니다. 첫 번째 요청에서는 이 값이 queryFnpageParam으로 들어갑니다. 두 번째 요청부터는 getNextPageParam이 반환한 값이 다음 pageParam이 됩니다.

useInfiniteQuery({ queryKey: ['products'], queryFn: ({ pageParam }) => fetchProducts({ cursor: pageParam }), initialPageParam: null, getNextPageParam: (lastPage) => lastPage.nextCursor});

여기서 initialPageParamnull로 둔 이유는 첫 요청에는 아직 커서가 없다는 의미를 표현하기 위해서입니다. API가 페이지 번호 기반이라면 1이나 0을 쓸 수 있고, cursor 기반이라면 null을 사용할 수 있습니다. 중요한 것은 첫 요청 값과 다음 요청 값의 의미가 API와 맞아야 한다는 점입니다.

getNextPageParam은 마지막으로 받아온 페이지를 보고 다음 요청 값을 반환합니다. 다음 페이지가 더 이상 없다면 null 또는 undefined를 반환해야 합니다. 이 반환값이 곧 hasNextPage 판단과 이어지기 때문에, 무한 스크롤이 끝나지 않거나 너무 빨리 멈출 때 가장 먼저 확인할 지점입니다.

data.pages는 최종 배열이 아니라 페이지 묶음이다

useInfiniteQuery의 결과에서 data.pages는 모든 아이템이 한 번에 합쳐진 배열이 아닙니다. 각 요청의 응답이 페이지 단위로 쌓인 배열입니다. 예를 들어 1페이지, 2페이지, 3페이지를 가져왔다면 data.pages 안에는 세 개의 응답 객체가 들어갑니다.

const products = data?.pages.flatMap((page) => page.items) ?? [];

화면에는 보통 상품 카드 배열 하나가 필요하기 때문에 flatMap으로 각 페이지의 items를 펼쳐서 렌더링합니다. 이때 원본 구조를 완전히 잊어버리면 안 됩니다. 페이지별 응답 구조는 다음 커서 계산, 수동 캐시 수정, refetch 동작을 이해할 때 다시 필요해집니다.

함께 제공되는 data.pageParams는 각 페이지를 가져올 때 사용한 pageParam 목록입니다. 일반적인 목록 렌더링에서는 자주 직접 쓰지 않지만, 무한 쿼리가 어떤 기준으로 페이지를 쌓아왔는지 확인할 때 도움이 됩니다.

TanStack Query v5 기준 기본 코드 흐름

예시는 상품 목록 API로 잡겠습니다. 화면에는 상품 카드가 반복되고, 서버는 한 번에 일정 개수의 상품만 내려줍니다. 응답에는 다음 요청에 사용할 nextCursor가 포함되어 있다고 가정합니다.

type Product = { id: number; name: string; price: number;
}; type ProductPage = { items: Product[]; nextCursor: number | null;
};

API 응답에 nextCursor가 있다는 것은 클라이언트가 다음 페이지 번호를 임의로 계산하지 않아도 된다는 의미입니다. 마지막으로 받은 응답에서 서버가 다음 기준을 알려주고, 클라이언트는 그 값을 다음 요청에 넘깁니다.

async function fetchProducts({ cursor }: { cursor: number | null }): Promise<ProductPage> { const searchParams = new URLSearchParams(); if (cursor !== null) { searchParams.set('cursor', String(cursor)); } const queryString = searchParams.toString(); const response = await fetch(`/api/products${queryString ? `?${queryString}` : ''}`); if (!response.ok) { throw new Error('상품 목록을 불러오지 못했습니다.'); } return response.json();
}

첫 요청에서는 커서가 없으므로 null을 넘기고이후 요청에서는 이전 응답의 nextCursor를 넘기는 방식입니다. 이 구조를 잡아두면 컴포넌트에서 현재 페이지 번호를 직접 관리할 필요가 줄어듭니다.

자동 무한 스크롤을 바로 붙이기 전에, 먼저 버튼 방식으로 다음 페이지 요청을 확인하면 흐름을 디버깅하기 쉽습니다. fetchNextPage를 눌렀을 때 네트워크 요청이 어떻게 나가는지, nextCursor가 다음 요청으로 이어지는지 먼저 확인할 수 있기 때문입니다.

import { useInfiniteQuery } from '@tanstack/react-query'; function ProductList() { const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError} = useInfiniteQuery({ queryKey: ['products'], queryFn: ({ pageParam }) => fetchProducts({ cursor: pageParam }), initialPageParam: null as number | null, getNextPageParam: (lastPage) => lastPage.nextCursor}); const products = data?.pages.flatMap((page) => page.items) ?? []; if (isPending) { return <p>상품을 불러오는 중입니다.</p>; } if (isError) { return <p>{error.message}</p>; } return ( <section> <ul> {products.map((product) => ( <li key={product.id}> <strong>{product.name}</strong> <span>{product.price.toLocaleString()}원</span> </li> ))} </ul> <button type="button" disabled={!hasNextPage || isFetchingNextPage} onClick={() => fetchNextPage()} > {isFetchingNextPage ? '더 불러오는 중' : hasNextPage ? '더 보기' : '마지막 상품입니다'} </button> </section> );
}

버튼을 누르면 fetchNextPage가 실행되고, TanStack Query는 getNextPageParam으로 계산된 값을 다음 queryFn에 넘깁니다. 이때 getNextPageParamnull 또는 undefined를 반환하면 다음 페이지가 없는 상태가 됩니다.

isPendingisFetchingNextPage를 나눠 보는 점도 중요합니다. 첫 화면에 아직 보여줄 데이터가 없는 상태와 이미 목록이 있는 상태에서 다음 페이지를 추가하는 상태는 사용자에게 다르게 보여야 합니다. 초기 로딩에는 목록 대신 로딩 문구를 보여줄 수 있지만, 추가 로딩에는 기존 목록을 유지한 채 하단에 작은 로딩 문구를 붙이는 식이 자연스럽습니다.

IntersectionObserver로 다음 페이지 요청 연결하기

버튼으로 흐름을 확인했다면, 그 다음에 화면 하단 감지를 붙입니다. 여기서는 브라우저의 IntersectionObserver를 사용합니다. 리스트 아래에 비어 있는 감지 요소를 두고, 그 요소가 화면에 들어오면 fetchNextPage를 호출하는 방식입니다.

주의할 부분은 감지 자체보다 호출 조건입니다. 하단 요소가 화면에 들어오는 순간은 한 번만 발생하지 않습니다. 레이아웃이 다시 계산되거나 추가 데이터가 붙는 과정에서 여러 번 감지될 수 있습니다. 그래서 hasNextPageisFetchingNextPage를 함께 확인해야 합니다.

import { useEffect, useRef } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query'; function ProductInfiniteList() { const loadMoreRef = useRef<HTMLDivElement | null>(null); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError, error} = useInfiniteQuery({ queryKey: ['products'], queryFn: ({ pageParam }) => fetchProducts({ cursor: pageParam }), initialPageParam: null as number | null, getNextPageParam: (lastPage) => lastPage.nextCursor}); useEffect(() => { const target = loadMoreRef.current; if (!target) { return; } const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, { rootMargin: '160px' }); observer.observe(target); return () => { observer.disconnect(); }; }, [fetchNextPage, hasNextPage, isFetchingNextPage]); const products = data?.pages.flatMap((page) => page.items) ?? []; if (isPending) { return <p>상품을 불러오는 중입니다.</p>; } if (isError) { return <p>{error.message}</p>; } return ( <section> <ul> {products.map((product) => ( <li key={product.id}> <strong>{product.name}</strong> <span>{product.price.toLocaleString()}원</span> </li> ))} </ul> <div ref={loadMoreRef} aria-hidden="true" /> {isFetchingNextPage && <p>상품을 더 불러오는 중입니다.</p>} {!hasNextPage && <p>더 이상 불러올 상품이 없습니다.</p>} </section> );
}

rootMargin을 주면 감지 요소가 화면에 완전히 닿기 전에 미리 다음 데이터를 요청할 수 있습니다. 사용자가 실제로 바닥에 닿은 뒤에 요청을 시작하면 목록이 잠깐 끊겨 보일 수 있기 때문에, 카드 리스트에서는 어느 정도 여유를 두는 쪽이 자연스럽습니다.

여기서 fetchNextPage를 호출하는 조건은 단순히 entry.isIntersecting만으로 끝내지 않습니다. 다음 페이지가 없으면 호출하지 않아야 하고이미 추가 요청 중이면 다시 호출하지 않아야 합니다. 이 조건이 빠지면 같은 하단 요소가 보이는 동안 요청이 겹쳐 나갈 수 있습니다.

다만 이 예시는 구조를 이해하기 위한 기본형입니다. 실제 프로젝트에서는 리스트 컨테이너가 스크롤 영역인지, 전체 브라우저 화면이 스크롤 영역인지에 따라 IntersectionObserverroot 설정을 추가로 검토할 수 있습니다.

실제 적용 전 확인할 부분

TanStack Query 무한 스크롤: useInfiniteQuery 구현 흐름 적용 흐름을 설명하는 두 번째 본문 이미지

무한 스크롤이 예상대로 동작하지 않을 때 observer 코드부터 의심하기 쉽습니다. 하지만 실제로는 API 응답 구조나 getNextPageParam 반환값에서 문제가 생기는 경우가 많습니다. 아래 항목은 코드를 붙이기 전에 먼저 확인할 기준입니다.

필터와 검색어는 에 포함해야 한다

상품 목록에 카테고리, 검색어, 정렬 조건이 있다면 이 값들은 queryKey에 들어가야 합니다. 그래야 “운동화 목록의 2페이지”와 “가방 목록의 2페이지”가 같은 캐시로 섞이지 않습니다.

useInfiniteQuery({ queryKey: ['products', { category, keyword, sort }], queryFn: ({ pageParam }) => fetchProducts({ cursor: pageParam, category, keyword, sort}), initialPageParam: null as number | null, getNextPageParam: (lastPage) => lastPage.nextCursor});

검색 조건이 바뀌었는데 이전 목록이 이어 붙는 것처럼 보인다면 queryKey를 먼저 확인해야 합니다. 무한 스크롤은 페이지가 누적되는 구조라서 캐시 키가 애매하면 잘못된 목록이 더 눈에 띄게 드러납니다.

getNextPageParam은 마지막 페이지 기준으로 끝을 알려야 한다

API가 더 이상 가져올 데이터가 없을 때 nextCursornull로 내려준다면, getNextPageParam도 그 값을 그대로 반환하면 됩니다. 페이지 번호 방식이라면 마지막 페이지 여부를 보고 직접 undefined를 반환해야 할 수 있습니다.

getNextPageParam: (lastPage) => { if (!lastPage.hasMore) { return undefined; } return lastPage.nextPage;
}

이 부분이 잘못되면 마지막 페이지 이후에도 계속 요청이 나가거나, 반대로 첫 페이지만 받고 멈춥니다. “스크롤은 감지되는데 다음 데이터가 안 온다”는 상황에서는 fetchNextPage 호출 여부와 함께 hasNextPage 값을 확인해야 합니다.

너무 많은 페이지를 계속 들고 있을 필요는 없다

목록을 계속 아래로 내리는 화면에서는 캐시에 쌓이는 페이지 수가 많아질 수 있습니다. TanStack Query v5에서는 maxPages 옵션으로 무한 쿼리에 저장할 페이지 수를 제한할 수 있습니다. 다만 이 옵션을 사용할 때는 다음 페이지나 이전 페이지를 다시 가져올 기준이 명확해야 합니다.

useInfiniteQuery({ queryKey: ['products'], queryFn: ({ pageParam }) => fetchProducts({ cursor: pageParam }), initialPageParam: null as number | null, getNextPageParam: (lastPage) => lastPage.nextCursor, maxPages: 5});

대부분의 단순 상품 목록에서는 처음부터 maxPages를 넣기보다, 목록 길이가 실제로 길어지고 refetch 비용이 부담되는 시점에 검토해도 늦지 않습니다. 처음 구현 단계에서는 다음 페이지 기준이 정확한지, 중복 요청이 없는지, 필터 변경 시 캐시가 분리되는지를 먼저 보는 것이 낫습니다.

첫 로딩과 추가 로딩을 같은 UI로 처리하지 않는다

무한 스크롤 화면에서 로딩 UI가 어색해지는 이유 중 하나는 첫 로딩과 추가 로딩을 같은 상태로 보는 데 있습니다. 첫 로딩에서는 아직 보여줄 목록이 없지만, 추가 로딩에서는 기존 목록이 화면에 남아 있어야 합니다.

그래서 초기 화면은 isPending을 기준으로 처리하고, 다음 페이지 요청은 isFetchingNextPage를 기준으로 하단에 별도로 표시하는 구성이 자연스럽습니다. 이 차이를 두면 사용자는 목록이 새로 갈아엎어지는 느낌을 덜 받습니다.

마무리: 무한 스크롤은 스크롤보다 페이지 기준이 먼저다

useInfiniteQuery를 처음 보면 무한 스크롤을 만들어주는 훅처럼 느껴질 수 있습니다. 하지만 역할을 나눠보면 TanStack Query는 화면 아래 감지보다 페이지 요청 기준을 다루는 쪽에 더 가깝습니다. 첫 요청 값은 initialPageParam에서 시작하고, 다음 요청 값은 getNextPageParam이 결정합니다.

화면에 렌더링할 때는 data.pages가 페이지 단위 응답 배열이라는 점을 기억해야 합니다. 카드 목록처럼 하나의 배열이 필요하다면 flatMap으로 펼쳐서 사용하고, 원본 구조는 TanStack Query가 다음 요청과 캐시 관리를 위해 유지한다고 보면 됩니다.

실제 구현에서 먼저 확인할 순서는 명확합니다. API가 다음 페이지 기준을 어떻게 내려주는지 보고, 그 값을 getNextPageParam에서 정확히 반환한 뒤, 마지막에 IntersectionObserverfetchNextPage 호출을 연결합니다. 이 순서로 보면 무한 스크롤은 막연한 스크롤 이벤트 작업이 아니라, 페이지 기준을 누적해서 이어가는 데이터 흐름으로 정리됩니다.

같이 읽으면 좋은 글

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기