TanStack Query 페이지네이션 실습: placeholderData로 이전 목록 유지하기

2026.09.12·수정 2026.09.14·약 29분·작성: 해비·블로그 소개

이 글에서 완성하는 것

이전·다음 페이지 이동을 useQuery로 구현하고, v5 placeholderData와 keepPreviousData로 대기 중 목록을 유지합니다. 마지막 페이지, 이동 실패, 재시도와 캐시 복귀까지 테스트합니다.

페이지가 바뀌면 새 queryKey로 데이터를 요청합니다. 요청 중에는 이전 목록을 placeholderData로 보여 주고, 새 응답이 오면 목록을 교체합니다.
페이지가 바뀌면 새 queryKey로 데이터를 요청합니다. 요청 중에는 이전 목록을 placeholderData로 보여 주고, 새 응답이 오면 목록을 교체합니다.

선수 학습은 Provider와 첫 useQuery, queryKey 설계, queryFn 계약입니다. 이 글에서는 같은 Provider 안에서 기존 캐시를 관찰한다는 전제 위에 사용자 동작을 추가합니다.

1. 프로젝트를 실행하고 관찰할 상태 정하기

실습 ZIP을 풀고 프로젝트 폴더에서 아래 명령을 실행합니다. Node.js 22.12 이상 또는 24 LTS와 npm이 필요합니다. 별도 API 키나 서버는 필요하지 않습니다. 이 글의 코드는 React 19.3.0, TanStack Query 5.102.8, Vite 8.3.0을 사용합니다.

npm install
npm run dev
npm test
npm run build

index.html은 문서와 진입점, src/styles.css는 흑백 기본 스타일, src/main.jsx는 안정적인 QueryClient와 Provider, src/api.js는 비동기 fixture, src/App.jsx는 UI를 담당합니다. JSX 파일은 JavaScript 기반이며 TypeScript 설정 없이 실행합니다. 테스트 파일과 Vitest 설정도 ZIP에 포함됩니다. 직접 의존성은 package.json에서 정확한 버전으로 고정했으며, 최초 설치 후 생성되는 lockfile을 보관하면 전이 의존성 재현에도 도움이 됩니다.

fixture는 지연 뒤 Promise를 완료하는 메모리 함수입니다. 실제 HTTP 요청이나 데이터베이스를 호출하지 않으며, 새로고침하면 메모리 상태가 초기화됩니다. 버튼과 캐시의 상태 전이를 학습하기 위한 실행 환경이고 실제 서버의 영속성이나 네트워크 장애 복구를 증명하는 환경은 아닙니다.

src/main.jsx

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

전체 코드

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>TanStack Query 실습</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

package.json

{
  "name": "tanstack-query-pagination-placeholderdata",
  "private": true,
  "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",
    "@testing-library/user-event": "14.6.7",
    "@testing-library/jest-dom": "7.0.1"
  }
}

src/App.jsx

import React, { useState } from 'react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { fixture, pageKey } from './api.js';
export function App({ api = fixture }) {
  const [page, setPage] = useState(1);
  const query = useQuery({
    queryKey: pageKey(page),
    queryFn: () => api.list(page),
    placeholderData: keepPreviousData,
    staleTime: 60_000,
    retry: false,
  });
  const nextDisabled =
    query.isPlaceholderData || query.isFetching || !query.data?.hasMore;
  return (
    <main>
      <h1>수업 페이지 탐색</h1>
      <p>선택한 페이지: {page}</p>
      {query.isPending && <p role="status">첫 데이터를 불러오는 중</p>}
      {query.isError && (
        <section>
          <p role="alert">{query.error.message}</p>
          <button disabled={query.isFetching} onClick={() => query.refetch()}>
            이 페이지 다시 시도
          </button>
        </section>
      )}
      {query.data && (
        <section aria-busy={query.isFetching}>
          <p>표시 중인 페이지: {query.data.page}</p>
          {query.isPlaceholderData && (
            <p role="status">이전 페이지를 표시하며 새 페이지를 기다립니다.</p>
          )}
          <ul>
            {query.data.items.map((item) => (
              <li key={item.title}>{item.title}</li>
            ))}
          </ul>
          {!query.isPlaceholderData && !query.data.hasMore && (
            <p>마지막 페이지입니다.</p>
          )}
        </section>
      )}
      <nav aria-label="페이지 이동">
        <button
          disabled={page === 1}
          onClick={() => setPage((value) => Math.max(1, value - 1))}
        >
          이전
        </button>
        <button
          disabled={nextDisabled}
          onClick={() => {
            if (!nextDisabled) setPage((value) => value + 1);
          }}
        >
          다음
        </button>
      </nav>
      <button disabled={nextDisabled} onClick={() => api.failNextPage(page + 1)}>
        다음 페이지 실패시키기
      </button>
    </main>
  );
}

src/App.test.jsx

import React from 'react';
import { test, expect, vi } from 'vitest';
import { render, screen, act, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { App } from './App.jsx';
import { pageKey } from './api.js';
function deferred() {
  let resolve;
  let reject;
  const promise = new Promise((yes, no) => {
    resolve = yes;
    reject = no;
  });
  return { promise, resolve, reject };
}
const result = (page) => ({
  page,
  totalPages: 2,
  hasMore: page < 2,
  items: [{ title: `수업 ${page}` }],
});
function show(api) {
  const client = new QueryClient({
    defaultOptions: { queries: { retry: false, gcTime: Infinity } },
  });
  render(
    <QueryClientProvider client={client}>
      <App api={api} />
    </QueryClientProvider>,
  );
  return client;
}
test('첫 pending, 이전 데이터 유지, 다음 잠금, 마지막 페이지, fresh 캐시 복귀', async () => {
  const first = deferred();
  const second = deferred();
  const api = {
    list: vi
      .fn()
      .mockReturnValueOnce(first.promise)
      .mockReturnValueOnce(second.promise),
  };
  const client = show(api);
  const user = userEvent.setup();
  expect(screen.getByText('첫 데이터를 불러오는 중')).toBeInTheDocument();
  await act(async () => first.resolve(result(1)));
  await screen.findByText('수업 1');
  await user.click(screen.getByRole('button', { name: '다음', exact: true }));
  expect(screen.getByText('선택한 페이지: 2')).toBeInTheDocument();
  expect(screen.getByText('표시 중인 페이지: 1')).toBeInTheDocument();
  expect(screen.getByRole('button', { name: '다음', exact: true })).toBeDisabled();
  expect(client.getQueryData(pageKey(2))).toBeUndefined();
  await act(async () => second.resolve(result(2)));
  await screen.findByText('수업 2');
  expect(screen.queryByText('수업 1')).not.toBeInTheDocument();
  expect(screen.getByText('마지막 페이지입니다.')).toBeInTheDocument();
  expect(screen.getByRole('button', { name: '다음', exact: true })).toBeDisabled();
  await user.click(screen.getByRole('button', { name: '이전', exact: true }));
  expect(screen.getByText('수업 1')).toBeInTheDocument();
  expect(api.list).toHaveBeenCalledTimes(2);
  cleanup();
  client.clear();
});
test('페이지 전환 실패 시 오류를 표시하고 같은 페이지 재시도 후 회복한다', async () => {
  const second = deferred();
  const api = {
    list: vi
      .fn()
      .mockResolvedValueOnce(result(1))
      .mockReturnValueOnce(second.promise)
      .mockResolvedValueOnce(result(2)),
  };
  const client = show(api);
  const user = userEvent.setup();
  await screen.findByText('수업 1');
  await user.click(screen.getByRole('button', { name: '다음', exact: true }));
  await act(async () => second.reject(new Error('2페이지 읽기 실패')));
  await screen.findByRole('alert');
  expect(screen.queryByText('수업 1')).not.toBeInTheDocument();
  expect(screen.getByRole('button', { name: '이전', exact: true })).toBeEnabled();
  await user.click(screen.getByRole('button', { name: '이 페이지 다시 시도' }));
  await screen.findByText('수업 2');
  expect(api.list).toHaveBeenLastCalledWith(2);
  cleanup();
  client.clear();
});
test('전환 도중 이전으로 복귀하면 늦게 끝난 다음 응답이 현재 화면을 덮지 않는다', async () => {
  const second = deferred();
  const api = {
    list: vi.fn().mockResolvedValueOnce(result(1)).mockReturnValueOnce(second.promise),
  };
  const client = show(api);
  const user = userEvent.setup();
  await screen.findByText('수업 1');
  await user.click(screen.getByRole('button', { name: '다음', exact: true }));
  await user.click(screen.getByRole('button', { name: '이전', exact: true }));
  await act(async () => second.resolve(result(2)));
  expect(screen.getByText('수업 1')).toBeInTheDocument();
  expect(screen.queryByText('수업 2')).not.toBeInTheDocument();
  expect(client.getQueryData(pageKey(1)).page).toBe(1);
  cleanup();
  client.clear();
});

src/api.js

export const pageSize = 2;
export const pageKey = (page) => ['lessons', 'page', { page, pageSize }];
export function createFixture() {
  const lessons = ['키 설계', '요청 함수', '저장하기', '캐시 수명', '페이지 이동'];
  let failPage = null;
  return {
    failNextPage(page) {
      failPage = page;
    },
    async list(page) {
      await new Promise((resolve) => setTimeout(resolve, 450));
      if (failPage === page) {
        failPage = null;
        throw new Error(`${page}페이지 읽기 실패`);
      }
      const totalPages = Math.ceil(lessons.length / pageSize);
      if (page < 1 || page > totalPages) throw new Error('페이지 범위를 벗어났습니다.');
      return {
        page,
        totalPages,
        hasMore: page < totalPages,
        items: lessons
          .slice((page - 1) * pageSize, page * pageSize)
          .map((title) => ({ title })),
      };
    },
  };
}
export const fixture = createFixture();

src/main.jsx

import React from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { App } from './App.jsx';
import './styles.css';
const client = new QueryClient({
  defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
createRoot(document.getElementById('root')).render(
  <QueryClientProvider client={client}>
    <App />
  </QueryClientProvider>,
);

src/setup.js

import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(cleanup);

src/styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font:
    17px/1.7 system-ui,
    sans-serif;
}
main {
  max-width: 760px;
  margin: 48px auto;
  padding: 24px;
}
h1 {
  font-size: 28px;
}
section,
form {
  border-top: 1px solid #bbb;
  padding: 20px 0;
}
label {
  display: block;
}
input,
button {
  font: inherit;
  padding: 8px 12px;
  border: 1px solid #777;
  color: inherit;
  background: #fff;
}
button {
  cursor: pointer;
  margin: 8px 8px 8px 0;
}
button:disabled {
  color: #777;
  background: #eee;
  cursor: default;
}
:focus-visible {
  outline: 3px solid #333;
  outline-offset: 3px;
}
[role='alert'] {
  border-left: 4px solid #333;
  padding-left: 12px;
}
li {
  padding: 5px 0;
}

vite.config.js

import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: { environment: 'jsdom', setupFiles: './src/setup.js' },
});

src/main.jsx 전체 코드 보기

2. 페이지 번호를 응답과 캐시 주소에 포함하기

이번 화면은 이전·다음 버튼으로 페이지를 교체합니다. 한 번에 2개씩 읽고 총 5개 수업이 있어 마지막 3페이지에는 한 개만 남습니다. 이미 읽은 결과를 아래로 계속 붙이는 useInfiniteQuery 방식과 다릅니다. page는 선택한 페이지 번호이고, query.data.page는 현재 표시 중인 응답의 번호입니다. 새 페이지를 기다릴 때 이 두 값이 잠깐 달라질 수 있습니다.

응답을 바꾸는 page와 pageSize를 queryKey에 함께 담습니다. key의 page만 바꾸고 queryFn에서 고정 숫자를 읽거나, 반대로 queryFn만 바꾸고 key를 유지하면 페이지 캐시의 의미가 깨집니다. 이 예제는 1부터 시작하므로 배열 slice를 계산할 때만 page – 1을 사용합니다. API가 0부터 시작한다면 경계에서 한 번 변환하고 UI와 계약을 일관되게 맞춥니다.

src/api.js

src/api.js 전체 코드 보기

hasMore와 totalPages는 목록 API가 확정한 메타데이터 역할을 합니다. 마지막 항목 수가 pageSize보다 작은지 추측하기보다 응답의 hasMore를 사용합니다. 실제 서버가 정확히 pageSize개를 마지막 페이지로 반환해도 hasMore: false이면 다음 버튼은 멈춥니다. 빈 목록이 가능한 API라면 totalPages와 빈 페이지 응답 계약도 함께 정해야 합니다.

3. v5 placeholderData로 페이지 전환 중 목록 유지하기

v5에서는 keepPreviousData: true 옵션 대신 import한 keepPreviousData 함수를 placeholderData에 전달합니다. 이 함수는 이전 성공 데이터를 다음 query가 준비되는 동안 observer의 임시 표시값으로 돌려줍니다. 새 페이지 응답이 준비되면 화면은 그 결과로 교체됩니다. 목록을 useState에 복사하거나 배열에 수동으로 누적할 필요가 없습니다.

src/App.jsx

src/App.jsx 전체 코드 보기

placeholderData는 새 페이지의 캐시에 이전 페이지가 성공 저장됐다는 의미가 아닙니다. 테스트에서 2페이지를 기다리는 동안 화면에는 1페이지 항목이 보이지만 client.getQueryData(pageKey(2))는 undefined인지 확인합니다. 임시 표시와 캐시 데이터의 구분을 직접 검사하면 잘못된 페이지 데이터가 저장되었다는 오해를 줄일 수 있습니다.

임시 데이터를 제공하는 동안에는 isPending만으로 이동 중임을 판단할 수 없습니다. 첫 화면은 성공 데이터가 없어 isPending을 보여 주고, 이전 결과를 사용하는 이동 중에는 isPlaceholderData로 안내 문구를 표시합니다. isFetching은 query 함수가 실행 중인지를 나타냅니다. 캐시에 목적 페이지가 이미 있어 즉시 표시되는 경우에는 isPlaceholderData가 켜질 필요가 없습니다.

4. 선택한 번호와 표시한 번호를 구분하고 버튼 막기

다음을 누른 직후 “선택한 페이지: 2”와 “표시 중인 페이지: 1”이 함께 나올 수 있습니다. 이때 이전 데이터라는 설명도 표시하므로 사용자가 1페이지 항목을 2페이지의 확정 결과로 오해하지 않습니다. 기존 목록을 유지한다는 UI 장점은 현재 어떤 정보를 보여 주는지 정직하게 설명할 때 유효합니다.

nextDisabled는 임시 표시 중인지, 현재 요청 중인지, 응답이 다음 페이지를 허용하는지를 합칩니다. 아직 새 결과를 받지 않았을 때 이전 페이지의 hasMore만 보고 번호를 계속 증가시키지 않습니다. 마지막 응답의 hasMore가 false이면 마지막 안내와 비활성화 상태를 유지합니다. 버튼 속성뿐 아니라 핸들러에서도 같은 조건을 검사합니다.

이전 버튼은 요청 중에도 사용할 수 있고, 첫 페이지에서만 잠급니다. 사용자는 오래 걸리는 다음 페이지를 기다리는 대신 이미 본 페이지로 돌아갈 수 있습니다. 이때 늦은 응답이 완료되어도 각각 다른 key에 속하므로 현재 페이지 화면을 덮어쓰지 않아야 합니다. 테스트는 2페이지 Promise를 보류하고 1페이지로 돌아간 뒤 완료시켜 이 동작을 검증합니다.

staleTime: 60_000은 1분 안에 이미 읽은 페이지로 복귀할 때 fresh 캐시를 바로 쓰도록 한 실습 정책입니다. 캐시 보존 시간인 gcTime과 같은 개념이 아닙니다. 테스트는 gcTime: Infinity로 사례 도중 페이지 캐시가 제거되지 않게 하고, 종료 뒤 client.clear()로 정리합니다. gcTime을 0으로 설정하면 페이지를 떠난 즉시 캐시가 사라질 수 있어 fresh 복귀를 검사하려는 테스트 목적과 충돌합니다.

5. 새 페이지 오류와 재시도, 이전 페이지 복귀 처리하기

첫 페이지 성공 뒤 “다음 페이지 실패시키기”를 누르고 다음으로 이동합니다. 요청 중에는 이전 항목이 보입니다. 새 페이지가 오류로 끝나면 이 예제에서 임시 목록은 사라지고 요청한 페이지의 오류가 나타납니다. placeholderData가 실패 뒤에도 이전 목록을 영구 보존한다고 가정하지 않습니다. 이전 버튼으로 돌아가거나 “이 페이지 다시 시도”로 같은 key를 다시 읽을 수 있습니다.

새 key의 첫 요청 실패와 이미 데이터가 있는 같은 key의 백그라운드 갱신 실패는 다릅니다. 후자는 기존 data와 오류가 함께 존재할 수 있습니다. 그래서 코드에서는 isError일 때 컴포넌트를 통째로 조기 반환하지 않고 오류 영역과 data 영역을 독립적으로 렌더링합니다. 여기 제공한 오류 테스트는 새 페이지 전환 실패와 회복을 검증합니다.

필터와 정렬을 추가할 때는 응답을 바꾸는 값을 key에 추가하고, 조건 변경 시 첫 페이지로 돌아가는 정책을 정해야 합니다. 서로 관련 없는 검색 조건 사이에서 이전 결과를 유지하면 오해를 만들 수 있으므로 무조건 keepPreviousData를 재사용하지 않습니다. previousQuery의 key를 비교하여 같은 검색 범위에서만 임시 표시하거나, 새 검색에서는 placeholder를 사용하지 않는 선택이 가능합니다.

서버에서 항목이 삭제돼 현재 번호가 전체 페이지 수를 벗어나는 경우에도 정책이 필요합니다. 이 고정 fixture는 범위 초과를 오류로 표현하며 자동으로 다른 페이지로 이동하지 않습니다. 실제 화면에서는 서버가 알려 준 마지막 유효 페이지로 한 번 이동하거나 첫 페이지로 초기화하는 흐름을 정한 뒤 반복 이동이 없는지 검사하세요.

페이지 누적이 필요하면 useInfiniteQuery와 pageParam을 이어서 읽으세요. 캐시 유지 정책은 staleTime과 gcTime에서 다룹니다. v5 옵션은 공식 Paginated QueriesPlaceholder Query Data를 참고하세요.

6. 사용자 행동 테스트와 완료 기준

완료 기준은 첫 로딩과 페이지 이동을 구분하고, 임시 데이터 중 다음 버튼을 잠그며, 마지막 페이지에서 멈추는 것입니다. 첫 테스트는 새 페이지 캐시에 임시 데이터가 저장되지 않는 것과 fresh 페이지 복귀 시 추가 호출이 없는 것을 확인합니다. 두 번째는 새 페이지 오류 뒤 같은 번호 재시도를, 세 번째는 이전으로 복귀한 뒤 늦게 끝난 다른 페이지 응답이 화면을 덮지 않는 것을 검사합니다.

테스트에서는 매 사례마다 새 QueryClient를 만들고 UI를 unmount한 뒤 client.clear()로 정리합니다. api를 props로 주입하여 Promise의 성공·실패 시점을 직접 제어합니다. 고정 시간만 기다리는 테스트와 달리 “아직 요청이 끝나지 않은 순간”을 명시적으로 검사할 수 있습니다. getByRole과 레이블로 실제 화면을 조작하고, 내부 함수 호출 여부에 더해 사용자가 보는 문구와 버튼 상태를 확인합니다.

src/App.test.jsx

src/App.test.jsx 전체 코드 보기

검증 결과: Vitest/jsdom 사용자 동작 테스트 3개 통과, Vite 프로덕션 빌드 통과입니다. 실제 브라우저 실행, HTTP 서버 연결, 배포 검증은 수행하지 않았습니다. 네트워크 환경에서의 취소, 인증, 타임아웃은 실제 API로 연결한 뒤 별도로 확인해야 합니다.

실습 파일과 다음 학습

전체 실습 프로젝트 ZIP 내려받기

이 글이 도움이 되었나요?

조회 중

TanStack Query 학습 순서

필수 11개 · 전체 12개

읽음 기록 관리

전체 과정 목차 (12개)
  1. 필수 길잡이 · TanStack Query vs Zustand: 서버 상태와 클라이언트 상태 차이
  2. 필수 학습 · TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드
  3. 필수 학습 · TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기
  4. 필수 학습 · TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기
  5. 필수 학습 · TanStack Query staleTime gcTime 차이: 캐시 시간 기준
  6. 필수 학습 · TanStack Query useMutation 저장 실습: 실패·재시도와 목록 갱신까지
  7. 선택 참고 · TanStack Query 화면 갱신 문제 해결: 데이터 변경 후 UI가 바뀌지 않을 때
  8. 필수 학습 · TanStack Query 페이지네이션 실습: placeholderData로 이전 목록 유지하기 현재 글
  9. 필수 학습 · TanStack Query 무한 스크롤 사용법: useInfiniteQuery로 목록 이어 불러오기
  10. 필수 선수 · TanStack Query Hydration 오류 해결: QueryClient 설정 기준
  11. 필수 학습 · TanStack Query Todo 실습: 서버 연결·낙관적 업데이트·실패 롤백
  12. 필수 학습 · TanStack Query 수동 캐시 정규화: 목록·상세 동기화와 선택 기준

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기