먼저 읽기: TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기 · TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기
TanStack Query v5에서 무한 스크롤을 볼 때 먼저 잡아야 할 기준
무한 스크롤은 화면 아래에 닿으면 다음 데이터를 가져오는 기능처럼 보이지만, 실제 구현에서 먼저 흔들리는 부분은 스크롤 감지가 아니라 다음 요청에 넘길 값입니다. 이 글은 TanStack Query v5의 useInfiniteQuery를 기준으로 pageParam, getNextPageParam, data.pages, fetchNextPage가 어떻게 이어지는지 정리합니다.
추가 요청·취소·오류에서 기존 페이지 지키기
하나의 infinite query key는 모든 pages와 pageParams를 함께 보관합니다. 일반 useQuery와 같은 key를 쓰면 데이터 모양이 충돌하므로 예제는 [‘products’,’infinite’,category]를 사용합니다. pageParam은 queryKey에 매번 추가하는 페이지 번호가 아니라 해당 캐시 안에서 페이지를 이어 읽는 입력입니다. 검색어·정렬·카테고리처럼 목록 자체를 바꾸는 조건은 key에 넣습니다.
추가 요청 가드는 isFetchingNextPage만 보지 않고 isFetching을 봅니다. 기존 페이지의 백그라운드 refetch도 진행 중일 수 있기 때문입니다. fetchNextPage의 기본 cancelRefetch:true는 빠른 중복 호출에서 이전 결과를 무시하고 새 호출을 시작할 수 있습니다. 실습은 동기 ref 잠금으로 같은 렌더 사이의 연속 callback을 막고 cancelRefetch:false로 진행 중 요청의 교체를 피합니다. 이것이 임의의 병렬 페이지 수집을 보장한다는 뜻은 아닙니다.
sentinel을 로딩 화면에서 렌더하지 않으면 첫 effect에서는 ref.current가 null입니다. 성공 후 hasNextPage나 isFetching 변화에 따라 effect가 다시 실행되어 감지 요소를 등록하도록 의존성을 구성합니다. cleanup에서 disconnect하고 API가 없는 환경에서는 버튼을 남깁니다. 다음 페이지 실패 후에도 sentinel이 보이면 무한 자동 재시도가 생길 수 있어 자동 observer는 isError일 때 중단하고 사용자가 버튼으로 재시도하게 합니다.
추가 로딩 실패는 isFetchNextPageError, 기존 페이지 재조회 실패는 isRefetchError로 구분합니다. data가 있을 때는 목록을 유지합니다. queryFn의 signal을 fetch에 넘기면 취소가 실제 요청까지 전달됩니다. fixture도 AbortSignal을 소비하여 cancelQueries 이후 미완성 페이지가 누적되지 않는지 검사합니다. HTTP 환경에서는 fetch(url, { signal })와 response.ok 검사를 함께 적용하세요.
stale infinite query의 재조회는 저장된 첫 페이지부터 순서대로 진행되며 새 cursor를 이어 받습니다. 오래된 cursor로 중복·누락을 만드는 위험을 줄이지만 서버가 불안정한 정렬이나 중복 항목을 반환하면 클라이언트가 자동으로 모든 항목을 dedupe해 주지는 않습니다. 고정 정렬과 안정적인 cursor 계약부터 확인합니다. 캐시를 직접 갱신할 때는 pages와 pageParams의 대응 관계를 보존하세요.
maxPages를 적용하면 한쪽 페이지가 제거되므로 “항상 처음부터 지금까지 모두 표시”하는 UI와는 결과가 달라집니다. 양방향으로 되돌아가야 하면 getPreviousPageParam과 서버의 이전 cursor도 설계하고, 제거로 인한 스크롤 위치 변화까지 확인해야 합니다. 이 실습은 세 페이지로 종료하며 maxPages는 적용하지 않습니다.
무한 스크롤이 직접 구현하면 복잡해지는 이유

상품 목록이나 게시글 목록을 만들 때 무한 스크롤은 처음에는 단순해 보입니다. 현재 페이지 번호를 하나 들고 있다가, 사용자가 화면 아래로 내려오면 페이지 번호를 하나 증가시키고 데이터를 더 가져오면 될 것처럼 보입니다.
문제는 화면이 조금만 실제 서비스에 가까워져도 상태가 빠르게 늘어난다는 점입니다. 첫 로딩 상태, 추가 로딩 상태, 전체 아이템 배열, 현재 페이지, 마지막 페이지 여부, 요청 실패 상태, 중복 요청 방지 조건을 따로 관리해야 합니다. 여기에 검색어, 카테고리, 정렬 조건이 붙으면 “지금까지 받아온 목록을 유지할지, 다시 첫 페이지부터 불러올지”도 함께 결정해야 합니다.
스크롤 이벤트만 기준으로 보면 아래처럼 흐름을 잡기 쉽습니다. 하지만 이 방식은 기능이 커질수록 데이터 요청의 기준이 컴포넌트 내부 상태에 흩어지기 쉽습니다.
const [page, setPage] = useState(1);
const [items, setItems] = useState<Product[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
이런 상태가 전부 잘못된 것은 아닙니다. 다만 무한 스크롤의 핵심이 “화면 아래 감지”에만 있는 것은 아니라는 점을 먼저 잡아야 합니다. 실제로 자주 꼬이는 부분은 스크롤 위치보다 다음 요청에 넘길 값입니다. 다음 페이지 번호를 어디서 계산할지, 더 이상 데이터가 없다는 판단을 어디서 끝낼지, 필터가 바뀌었을 때 기존 데이터와 새 데이터를 어떻게 분리할지가 더 큰 기준이 됩니다.
TanStack Query의 useInfiniteQuery는 이 지점을 정리해주는 훅입니다. 스크롤을 대신 감지해주는 도구가 아니라, 페이지 단위 응답을 누적하고 다음 요청에 사용할 pageParam 흐름을 관리해주는 역할입니다.
useInfiniteQuery가 맡는 역할
무한 스크롤을 구현할 때 역할을 나누면 구조가 선명해집니다. API 함수는 데이터를 가져오고, useInfiniteQuery는 페이지 단위 캐시와 다음 요청 값을 관리하고, IntersectionObserver는 화면 끝에 도달했는지를 감지합니다.
이 구분이 필요한 이유는 TanStack Query가 스크롤 이벤트를 처리하지 않기 때문입니다. useInfiniteQuery가 해주는 일은 “다음 페이지를 가져와야 할 때 무엇을 기준으로 요청할 것인가”입니다. 그래서 구현할 때도 스크롤 코드부터 작성하기보다 API 응답 구조와 getNextPageParam 반환값을 먼저 정해야 합니다.
pageParam은 어디서 생기는가
TanStack Query v5 기준으로 initialPageParam은 첫 요청에 사용할 값입니다. 첫 번째 요청에서는 이 값이 queryFn의 pageParam으로 들어갑니다. 두 번째 요청부터는 getNextPageParam이 반환한 값이 다음 pageParam이 됩니다.
useInfiniteQuery({ queryKey: ['products'], queryFn: ({ pageParam }) => fetchProducts({ cursor: pageParam }), initialPageParam: null, getNextPageParam: (lastPage) => lastPage.nextCursor});
여기서 initialPageParam을 null로 둔 이유는 첫 요청에는 아직 커서가 없다는 의미를 표현하기 위해서입니다. 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가 다음 요청으로 이어지는지 먼저 확인할 수 있기 때문입니다.
실행 가능한 컴포넌트는 아래 실습의 App.jsx에 있습니다. 초기·추가 오류, 전체 isFetching 가드와 수동 재시도까지 함께 적용합니다.
버튼을 누르면 fetchNextPage가 실행되고, TanStack Query는 getNextPageParam으로 계산된 값을 다음 queryFn에 넘깁니다. 이때 getNextPageParam이 null 또는 undefined를 반환하면 다음 페이지가 없는 상태가 됩니다.
isPending과 isFetchingNextPage를 나눠 보는 점도 중요합니다. 첫 화면에 아직 보여줄 데이터가 없는 상태와 이미 목록이 있는 상태에서 다음 페이지를 추가하는 상태는 사용자에게 다르게 보여야 합니다. 초기 로딩에는 목록 대신 로딩 문구를 보여줄 수 있지만, 추가 로딩에는 기존 목록을 유지한 채 하단에 작은 로딩 문구를 붙이는 식이 자연스럽습니다.
IntersectionObserver로 다음 페이지 요청 연결하기
버튼으로 흐름을 확인했다면, 그 다음에 화면 하단 감지를 붙입니다. 여기서는 브라우저의 IntersectionObserver를 사용합니다. 리스트 아래에 비어 있는 감지 요소를 두고, 그 요소가 화면에 들어오면 fetchNextPage를 호출하는 방식입니다.
주의할 부분은 감지 자체보다 호출 조건입니다. 하단 요소가 화면에 들어오는 순간은 한 번만 발생하지 않습니다. 레이아웃이 다시 계산되거나 추가 데이터가 붙는 과정에서 여러 번 감지될 수 있습니다. 그래서 hasNextPage와 isFetching를 함께 확인해야 합니다.
실행 가능한 컴포넌트는 아래 실습의 App.jsx에 있습니다. 초기·추가 오류, 전체 isFetching 가드와 수동 재시도까지 함께 적용합니다.
rootMargin을 주면 감지 요소가 화면에 완전히 닿기 전에 미리 다음 데이터를 요청할 수 있습니다. 사용자가 실제로 바닥에 닿은 뒤에 요청을 시작하면 목록이 잠깐 끊겨 보일 수 있기 때문에, 카드 리스트에서는 어느 정도 여유를 두는 쪽이 자연스럽습니다.
여기서 fetchNextPage를 호출하는 조건은 단순히 entry.isIntersecting만으로 끝내지 않습니다. 다음 페이지가 없으면 호출하지 않아야 하고, 이미 추가 요청 중이면 다시 호출하지 않아야 합니다. 이 조건이 빠지면 같은 하단 요소가 보이는 동안 요청이 겹쳐 나갈 수 있습니다.
다만 이 예시는 구조를 이해하기 위한 기본형입니다. 실제 프로젝트에서는 리스트 컨테이너가 스크롤 영역인지, 전체 브라우저 화면이 스크롤 영역인지에 따라 IntersectionObserver의 root 설정을 추가로 검토할 수 있습니다.
실제 적용 전 확인할 부분

무한 스크롤이 예상대로 동작하지 않을 때 observer 코드부터 의심하기 쉽습니다. 하지만 실제로는 API 응답 구조나 getNextPageParam 반환값에서 문제가 생기는 경우가 많습니다. 아래 항목은 코드를 붙이기 전에 먼저 확인할 기준입니다.
필터와 검색어는 queryKey에 포함해야 한다
상품 목록에 카테고리, 검색어, 정렬 조건이 있다면 이 값들은 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가 더 이상 가져올 데이터가 없을 때 nextCursor를 null로 내려준다면, 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를 기준으로 하단에 별도로 표시하는 구성이 자연스럽습니다. 이 차이를 두면 사용자는 목록이 새로 갈아엎어지는 느낌을 덜 받습니다.
커서 세 페이지, 다음 페이지 실패와 재시도를 갖춘 목록 실습
IntersectionObserver 없는 jsdom에서는 더 보기로 진행합니다. 자동 감지는 기본으로 꺼져 있습니다. 첫 페이지에서 실패 버튼을 누른 뒤 2페이지 오류를 만듭니다. 상품 1이 남아 있고 더 보기로 재시도 가능한지, 3페이지 뒤 마지막 버튼이 비활성화되는지 확인합니다. 새로고침 후 자동 감지 켜기로 observer 방식을 비교합니다.
Node.js 22.12 이상(또는 20.19 이상)에서 압축을 푼 폴더로 이동합니다. 외부 API 키는 필요 없습니다. fixture 값은 새로고침·프로세스 종료 후 보존되지 않습니다.
npm install
npm test
npm run build
npm run dev
HTML 진입점·CSS·JavaScript 파일을 분리했습니다. 본문의 기존 API 코드가 설명하는 실제 서버와 ZIP의 메모리 fixture는 다른 실행 범위입니다.
App.jsx
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
App.jsx
import React, { useEffect, useRef, useState } from 'react';
import {
QueryClient,
QueryClientProvider,
useInfiniteQuery,
} from '@tanstack/react-query';
import { createPages, options } from './model.js';
const client = new QueryClient();
const api = createPages();
export function Products({ source = api }) {
const [automatic, setAutomatic] = useState(false);
const query = useInfiniteQuery(options(source));
const sentinel = useRef(null);
const locked = useRef(false);
const { hasNextPage, isFetching, isFetchNextPageError, isError, fetchNextPage } =
query;
async function loadMore() {
if (locked.current || !hasNextPage || isFetching) return;
locked.current = true;
try {
await fetchNextPage({ cancelRefetch: false });
} finally {
locked.current = false;
}
}
useEffect(() => {
const target = sentinel.current;
if (
!automatic ||
!target ||
!hasNextPage ||
isFetching ||
isError ||
typeof IntersectionObserver === 'undefined'
)
return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
},
{ rootMargin: '160px' },
);
observer.observe(target);
return () => observer.disconnect();
}, [automatic, hasNextPage, isFetching, isError, fetchNextPage]);
if (query.isPending) return <p>첫 페이지 로딩</p>;
if (query.isError && !query.data)
return (
<p role="alert">
첫 페이지 실패 <button onClick={() => query.refetch()}>다시 시도</button>
</p>
);
const items = query.data.pages.flatMap((page) => page.items);
return (
<section>
<button onClick={() => setAutomatic(!automatic)}>
{automatic ? '자동 감지 끄기' : '자동 감지 켜기'}
</button>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
{items.length === 0 && <p>상품 없음</p>}
{isFetchNextPageError && <p role="alert">추가 로딩 실패. 목록을 유지합니다.</p>}
{query.isRefetchError && <p role="alert">기존 페이지 갱신 실패</p>}
<button disabled={!hasNextPage || isFetching} onClick={loadMore}>
{isFetching ? '요청 중' : hasNextPage ? '더 보기' : '마지막 페이지'}
</button>
<button onClick={source.fail}>2페이지 한 번 실패시키기</button>
<div ref={sentinel} aria-hidden="true" />
<p>페이지 파라미터: {query.data.pageParams.join(', ')}</p>
</section>
);
}
export default function App() {
return (
<QueryClientProvider client={client}>
<main>
<h1>커서 목록</h1>
<Products />
</main>
</QueryClientProvider>
);
}
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Query 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.jsx"></script>
</body>
</html>
infinite.test.jsx
// @vitest-environment jsdom
import React from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
InfiniteQueryObserver,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
import { Products } from './App.jsx';
import { createPages, options } from './model.js';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
test('next-page error retains prior items and manual retry reaches final page', async () => {
const client = new QueryClient();
const api = createPages();
api.fail();
render(
<QueryClientProvider client={client}>
<Products source={api} />
</QueryClientProvider>,
);
await screen.findByText('상품 1');
fireEvent.click(screen.getByText('더 보기'));
await screen.findByRole('alert');
expect(screen.getByText('상품 1')).toBeTruthy();
fireEvent.click(screen.getByText('더 보기'));
await screen.findByText('상품 2');
fireEvent.click(screen.getByText('더 보기'));
await screen.findByText('상품 3');
expect(screen.getByText('마지막 페이지').disabled).toBe(true);
expect(client.getQueryData(options(api).queryKey).pageParams).toEqual([1, 2, 3]);
client.clear();
});
test('repeated observer callbacks do not duplicate a page; disconnect on unmount', async () => {
const observers = [];
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(cb) {
this.cb = cb;
this.disconnect = vi.fn();
observers.push(this);
}
observe() {}
},
);
const client = new QueryClient();
const api = createPages();
const read = vi.spyOn(api, 'read');
const view = render(
<QueryClientProvider client={client}>
<Products source={api} />
</QueryClientProvider>,
);
await screen.findByText('상품 1');
fireEvent.click(screen.getByText('자동 감지 켜기'));
await waitFor(() => expect(observers.length).toBeGreaterThan(0));
const observer = observers.at(-1);
observer.cb([{ isIntersecting: true }]);
observer.cb([{ isIntersecting: true }]);
await screen.findByText('상품 2');
expect(read).toHaveBeenCalledTimes(2);
view.unmount();
expect(observers.every((o) => o.disconnect.mock.calls.length > 0)).toBe(true);
client.clear();
});
test('cancellation aborts next-page fixture and preserves completed page', async () => {
const client = new QueryClient();
const api = createPages();
const observer = new InfiniteQueryObserver(client, options(api));
const off = observer.subscribe(() => {});
await observer.refetch();
const pending = observer.fetchNextPage();
await client.cancelQueries({ queryKey: options(api).queryKey });
await pending;
expect(client.getQueryData(options(api).queryKey).pageParams).toEqual([1]);
off();
client.clear();
});
main.jsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './style.css';
createRoot(document.getElementById('root')).render(<App />);
model.js
export function createPages() {
let failNext = false;
return {
fail: () => {
failNext = true;
},
read: async ({ pageParam, signal }) => {
await new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
const abort = () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', abort);
resolve();
}, 20);
signal.addEventListener('abort', abort, { once: true });
});
if (pageParam === 2 && failNext) {
failNext = false;
throw new Error('다음 페이지 실패');
}
return {
items: [{ id: pageParam, name: `상품 ${pageParam}` }],
nextCursor: pageParam < 3 ? pageParam + 1 : null,
};
},
};
}
export function options(api, category = 'all') {
return {
queryKey: ['products', 'infinite', category],
queryFn: api.read,
initialPageParam: 1,
getNextPageParam: (page) => page.nextCursor,
retry: false,
};
}
package.json
{
"name": "query-lab-2419",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-query": "5.102.8",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1",
"@testing-library/react": "16.3.3"
}
}
style.css
body {
margin: 0;
color: #222;
background: #fff;
font-family: system-ui, sans-serif;
}
main {
max-width: 760px;
margin: 40px auto;
padding: 24px;
}
button,
select {
padding: 10px;
margin: 6px;
border: 1px solid #888;
background: #f5f5f5;
color: #222;
}
button:disabled {
color: #777;
}
li {
padding: 8px;
border-bottom: 1px solid #ddd;
}
pre {
padding: 16px;
background: #eee;
overflow: auto;
}
index.html
infinite.test.jsx
main.jsx
model.js
package.json
style.css
검증 결과와 이 실습의 경계
Vitest jsdom 환경 3개 테스트: 추가 페이지 실패 후 기존 목록 유지·수동 재시도·마지막 페이지 버튼, 반복 observer callback 중복 방지 및 disconnect, cancelQueries 이후 완성 페이지 유지. Vite production build도 통과했습니다.
실제 브라우저나 HTTP API를 실행한 결과는 아닙니다. jsdom 테스트의 IntersectionObserver는 감지 callback을 모사하므로 rootMargin·스크롤 위치·레이아웃은 검증하지 않습니다.
마무리: 무한 스크롤은 스크롤보다 페이지 기준이 먼저다
useInfiniteQuery를 처음 보면 무한 스크롤을 만들어주는 훅처럼 느껴질 수 있습니다. 하지만 역할을 나눠보면 TanStack Query는 화면 아래 감지보다 페이지 요청 기준을 다루는 쪽에 더 가깝습니다. 첫 요청 값은 initialPageParam에서 시작하고, 다음 요청 값은 getNextPageParam이 결정합니다.
화면에 렌더링할 때는 data.pages가 페이지 단위 응답 배열이라는 점을 기억해야 합니다. 카드 목록처럼 하나의 배열이 필요하다면 flatMap으로 펼쳐서 사용하고, 원본 구조는 TanStack Query가 다음 요청과 캐시 관리를 위해 유지한다고 보면 됩니다.
실제 구현에서 먼저 확인할 순서는 명확합니다. API가 다음 페이지 기준을 어떻게 내려주는지 보고, 그 값을 getNextPageParam에서 정확히 반환한 뒤, 마지막에 IntersectionObserver로 fetchNextPage 호출을 연결합니다. 이 순서로 보면 무한 스크롤은 막연한 스크롤 이벤트 작업이 아니라, 페이지 기준을 누적해서 이어가는 데이터 흐름으로 정리됩니다.
같이 읽으면 좋은 글
- TanStack Query vs Zustand: 서버 상태와 클라이언트 상태 차이
- TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기
- TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기
함께 확인하면 좋은 글
v5 공식 문서 확인
2026-09-12 공식 문서와 설치된 @tanstack/react-query 5.102.8의 실행 결과를 대조했습니다. 문서의 latest 예시는 이후 바뀔 수 있으므로 ZIP의 고정 버전과 함께 확인하세요.
이 글이 도움이 되었나요?
TanStack Query 학습 순서
필수 11개 · 전체 12개
읽음 기록 관리
전체 과정 목차 (12개)
- 필수 길잡이 · TanStack Query vs Zustand: 서버 상태와 클라이언트 상태 차이
- 필수 학습 · TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드
- 필수 학습 · TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기
- 필수 학습 · TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기
- 필수 학습 · TanStack Query staleTime gcTime 차이: 캐시 시간 기준
- 필수 학습 · TanStack Query useMutation 저장 실습: 실패·재시도와 목록 갱신까지
- 선택 참고 · TanStack Query 화면 갱신 문제 해결: 데이터 변경 후 UI가 바뀌지 않을 때
- 필수 학습 · TanStack Query 페이지네이션 실습: placeholderData로 이전 목록 유지하기
- 필수 학습 · TanStack Query 무한 스크롤 사용법: useInfiniteQuery로 목록 이어 불러오기 현재 글
- 필수 선수 · TanStack Query Hydration 오류 해결: QueryClient 설정 기준
- 필수 학습 · TanStack Query Todo 실습: 서버 연결·낙관적 업데이트·실패 롤백
- 필수 학습 · TanStack Query 수동 캐시 정규화: 목록·상세 동기화와 선택 기준
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.