이 글에서 완성하는 것
TanStack Query 첫 useQuery를 위해 Provider를 한 번 배치하고, 로컬 비동기 fixture로 pending·error·empty·success와 수동 refetch 화면을 분리합니다.
- 실행 프로젝트
- 검증 범위
- 완성 결과와 범위
- QueryClient와 Provider
- queryKey와 queryFn
- 네 가지 화면 상태와 refetch
- 전체 프로젝트 파일
- 검증과 실제 HTTP 전환
먼저 읽기: JavaScript fetch 오류 처리: Promise부터 404까지 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드 · TanStack Query vs Zustand: 서버 상태와 클라이언트 상태 차이
완성 결과와 로컬 fixture 범위
화면은 요청 중 안내, 실패 오류와 다시 시도, 빈 목록, 성공 목록을 각각 표시합니다. 성공 뒤에도 “다시 불러오기”를 눌러 같은 query를 재요청할 수 있습니다. 네트워크 서버 대신 30ms를 기다리는 결정적 로컬 fixture를 사용하므로 API 키가 필요하지 않습니다.
fixture의 배열과 mode는 현재 JavaScript 프로세스 메모리에만 있습니다. 브라우저 새로고침 뒤 유지되는 서버 저장소가 아니며 실제 영속성을 흉내 낸다고 보아서는 안 됩니다. 첫 query의 상태 전이를 재현하기 위한 학습 입력입니다.

QueryClient를 한 번 만들고 Provider 배치하기
선수 지식은 React JavaScript Vite 앱을 실행하는 방법과 컴포넌트, props, async 함수입니다. 빈 폴더를 만든 뒤 이 실습의 모든 코드 파일을 표시된 경로 그대로 저장하고 npm install, npm run dev, npm run build, npm test를 차례로 실행합니다. Vite 프로젝트 구성은 Vite 공식 시작 안내에서도 확인할 수 있습니다. 기존 React 프로젝트에 의존성만 추가할 때는 npm install @tanstack/react-query를 사용합니다. 이 예제는 TanStack Query 5.102.8, React 19.3.0에서 작성했습니다. QueryClient를 렌더마다 새로 만들면 캐시도 새 인스턴스로 바뀔 수 있으므로 클라이언트 진입 파일 main.jsx의 모듈 범위에서 한 번 만듭니다. SSR에서는 요청마다 별도 QueryClient가 필요합니다.
retry를 false로 둔 이유는 오류 fixture가 한 번의 실패 뒤 즉시 error 화면으로 가도록 하기 위해서입니다. 실제 HTTP 서비스에서는 실패 원인과 요청의 안전성을 고려해 재시도 정책을 정해야 합니다. main.jsx는 QueryClientProvider 안에 App을 넣어 useQuery가 client를 찾을 수 있게 합니다.
queryKey와 비동기 queryFn 정의하기
queryKey는 캐시에서 데이터를 식별하는 주소입니다. 같은 의미의 수업 목록은 같은 ["lessons"] 키를 사용합니다. queryFn은 Promise를 반환하고, 실패를 표현할 때는 Error를 throw해야 useQuery가 error 상태로 전환할 수 있습니다.
기본 fixture는 한 건을 반환합니다. 테스트에서는 load 함수를 주입해 빈 배열, 실패, 재시도 성공을 각각 만듭니다. 실제 앱의 queryFn은 같은 반환 타입을 유지하면서 fetch("/api/lessons")와 응답 상태 검사로 교체할 수 있습니다.

pending·error·empty·success와 refetch 표시하기
첫 결과가 아직 없을 때 isPending을 먼저 검사합니다. isError이면서 기존 데이터가 없으면 error.message와 재시도 버튼을 보여줍니다. 성공 데이터가 빈 배열인 것은 오류가 아니므로 별도의 빈 상태로 처리합니다. 마지막에 데이터 목록을 렌더하면 데이터가 준비된 경로와 준비되지 않은 경로가 분리됩니다.
refetch는 현재 query를 다시 실행합니다. 이벤트 핸들러는 반환 Promise를 기다릴 필요가 없어 void query.refetch()로 의도를 표시했습니다. 기존 데이터가 있는 재요청에서는 isPending 대신 isFetching이 켜질 수 있으므로 성공 화면의 버튼을 비활성화하고 “갱신 중…”을 표시합니다.
isPending과 isFetching은 질문이 다릅니다. isPending은 아직 성공 데이터가 없어 첫 화면을 무엇으로 채울지 결정할 때 쓰고, isFetching은 백그라운드 요청을 포함해 queryFn이 현재 실행 중인지 알려 줍니다. 이미 목록이 보이는 재요청에서 전체 목록을 로딩 문구로 바꾸기보다 기존 데이터를 유지하고 버튼에 갱신 상태를 표시할 수 있습니다.
queryKey에 필터가 추가되면 그 필터도 키에 포함해야 합니다. 예를 들어 완료 수업만 요청한다면 [“lessons”, { status: “done” }]처럼 서버 응답을 바꾸는 입력을 주소에 담습니다. 서로 다른 결과가 같은 키를 쓰면 캐시에서 구분할 근거가 사라집니다. 반대로 표현만 다른 값이 같은 데이터를 뜻한다면 키 생성 함수를 공유해 표기를 통일합니다.
오류 화면의 다시 시도 버튼은 사용자가 실패에서 회복할 경로를 제공합니다. 빈 상태의 새로고침 버튼은 요청은 성공했지만 현재 항목이 없다는 사실을 유지하면서 최신 결과를 다시 확인합니다. 오류와 빈 상태를 분리하면 장애를 “데이터 없음”으로 숨기지 않고, 정상적인 빈 목록을 실패처럼 경고하지도 않습니다.
갱신 실패는 첫 요청 실패와 구분합니다. 기존 데이터가 있는 상태에서 refetch가 실패하면 isError가 참이면서 data도 남아 있을 수 있습니다. 아래 코드는 isError && data === undefined일 때만 전체 오류 화면을 띄우고, isRefetchError에서는 이전 목록 위에 갱신 실패 안내를 표시합니다. isFetching은 네트워크 요청 중 여부이며, 일시 정지 상태에서는 false일 수 있습니다.
실습: 첫 요청과 갱신 실패를 구분하는 수업 목록
아래 파일은 하나의 완성 프로젝트입니다. 전체 실습 ZIP 내려받기 후 빈 폴더에서 압축을 풀어 실행하세요. HTML, CSS, JavaScript 파일을 분리했습니다. Node.js 22.12 이상이 필요하며 의존성 설치에는 인터넷 연결이 필요합니다.
npm install
npm test
npm run build
npm run dev
확인한 조합은 React 19.3.0, TanStack Query 5.102.8, Vite 8.3.0, Vitest 4.1.11입니다. 요청은 외부 서버 대신 로컬 fixture를 사용하며 실제 저장·인증·HTTP 서버를 제공하지 않습니다. 테스트는 매 렌더마다 새 QueryClient를 만들어 캐시를 분리합니다.
package.json
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Query 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
package.json
{
"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 from 'react';
import { useQuery } from '@tanstack/react-query';
export async function fetchLessons() {
await new Promise((resolve) => setTimeout(resolve, 30));
return [{ id: 1, title: '첫 Query 수업' }];
}
export default function App({ load = fetchLessons }) {
const query = useQuery({ queryKey: ['first', 'lessons'], queryFn: load });
if (query.isPending)
return (
<main>
<p role="status">불러오는 중…</p>
</main>
);
if (query.isError && query.data === undefined)
return (
<main>
<p role="alert">{query.error.message}</p>
<button onClick={() => void query.refetch()}>다시 시도</button>
</main>
);
return (
<main>
<h1>수업 목록</h1>
{query.isRefetchError && (
<p role="alert">갱신 실패: {query.error.message}. 이전 결과를 표시합니다.</p>
)}
{query.data.length === 0 ? (
<p>등록된 수업이 없습니다.</p>
) : (
<ul>
{query.data.map((lesson) => (
<li key={lesson.id}>{lesson.title}</li>
))}
</ul>
)}
<button disabled={query.isFetching} onClick={() => void query.refetch()}>
{query.isFetching ? '갱신 중…' : '다시 불러오기'}
</button>
</main>
);
}
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 } } });
createRoot(document.getElementById('root')).render(
<QueryClientProvider client={client}>
<App />
</QueryClientProvider>,
);
src/styles.css
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #fff;
color: #222;
font-family: system-ui, sans-serif;
line-height: 1.7;
}
main {
max-width: 760px;
margin: 40px auto;
padding: 24px;
}
button,
input,
select {
font: inherit;
padding: 8px 12px;
border: 1px solid #777;
background: #fff;
color: #222;
margin: 4px;
}
button:disabled {
color: #777;
}
section {
border-top: 1px solid #aaa;
padding-block: 16px;
}
li {
margin-block: 8px;
}
:focus-visible {
outline: 2px solid #222;
outline-offset: 3px;
}
test/App.test.jsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { expect, test, vi } from 'vitest';
import App from '../src/App.jsx';
function show(props = {}) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
const view = render(
<QueryClientProvider client={client}>
<App {...props} />
</QueryClientProvider>,
);
return { client, ...view };
}
test('pending 후 빈 성공을 오류와 구분한다', async () => {
let resolve;
const load = () =>
new Promise((done) => {
resolve = done;
});
show({ load });
expect(screen.getByRole('status')).toHaveTextContent('불러오는 중');
resolve([]);
expect(await screen.findByText('등록된 수업이 없습니다.')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
test('첫 요청 실패에서 사용자 재시도로 회복한다', async () => {
const load = vi
.fn()
.mockRejectedValueOnce(new Error('통신 실패'))
.mockResolvedValueOnce([{ id: 1, title: '회복한 수업' }]);
show({ load });
await screen.findByRole('alert');
await userEvent.setup().click(screen.getByRole('button', { name: '다시 시도' }));
expect(await screen.findByText('회복한 수업')).toBeInTheDocument();
expect(load).toHaveBeenCalledTimes(2);
});
test('백그라운드 갱신 중 버튼을 잠그고 갱신 실패에도 기존 목록을 유지한다', async () => {
let reject;
const load = vi
.fn()
.mockResolvedValueOnce([{ id: 1, title: '기존 수업' }])
.mockImplementationOnce(
() =>
new Promise((_, fail) => {
reject = fail;
}),
);
show({ load });
await screen.findByText('기존 수업');
await userEvent.setup().click(screen.getByRole('button', { name: '다시 불러오기' }));
expect(screen.getByRole('button', { name: '갱신 중…' })).toBeDisabled();
expect(screen.getByText('기존 수업')).toBeInTheDocument();
reject(new Error('일시 장애'));
await screen.findByRole('alert');
expect(screen.getByText('기존 수업')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '다시 불러오기' })).toBeEnabled();
});
test/setup.js
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(cleanup);
vite.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
oxc: { jsx: { runtime: 'automatic' } },
test: { environment: 'jsdom', setupFiles: './test/setup.js' },
});
index.html
vite.config.js
src/main.jsx
src/styles.css
src/App.jsx
test/setup.js
test/App.test.jsx
검증 결과와 적용 범위
첫 pending→빈 성공, 첫 실패→사용자 재시도 성공, 갱신 중 버튼 잠금, 갱신 실패 시 이전 목록 보존을 확인합니다.
이 프로젝트는 Vitest/jsdom 테스트와 Vite production build를 통과했습니다. jsdom은 실제 브라우저가 아니므로 레이아웃·키보드 포커스의 브라우저별 차이·실제 네트워크·CORS·서버 권한·전송 취소까지 검증한 것은 아닙니다. 테스트의 fixture와 주입 함수는 정해진 입력으로 화면과 Query 동작을 확인하는 용도입니다.
공식 문서
2026-09-12에 확인한 React v5 문서입니다. 실습은 설치된 5.102.8에서 별도로 검증했습니다.
연결 학습: 최초 조회와 백그라운드 갱신 구분
목표·선행 지식: QueryClientProvider 설정 뒤 로딩·오류·빈 결과를 각각 표현합니다.
isPending은 데이터가 아직 준비되지 않은 상태를, isFetching은 조회 진행 여부를 나타냅니다. disabled 쿼리는 요청하지 않으면서 pending일 수 있으므로 두 값을 같은 뜻으로 취급하지 않습니다. 갱신 실패 시 기존 데이터가 있으면 이를 유지하면서 오류를 알려줄 수 있습니다.
직접 확인할 과제
성공 배열, 빈 배열, 처음부터 실패, 기존 데이터가 있는 상태의 재조회 실패를 각각 확인하세요. retry를 테스트에서 꺼서 오류 표시 시점을 분명하게 확인합니다.
공통 실습 ZIP · 다음 학습 · 라이브러리 선택 가이드
공통 ZIP은 버전을 고정한 학습 예제입니다. UI 파일은 Radix·Sonner·Embla 기반 축약 구현이며 shadcn CLI 생성물과 동일하지 않습니다. 적용 범위와 실행 방법은 ZIP의 README를 확인하세요.
이 글이 도움이 되었나요?
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의 새 글을 확인할 수 있습니다.