TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드

··설명 약 11분·작성: 해비·블로그 소개

TanStack Query Provider와 첫 useQuery의 대기·성공·실패 상태를 어떻게 연결하나요?

QueryClientProvider가 조회 캐시를 제공하고 useQuery가 대기·성공·실패를 화면에 연결합니다. 먼저 별도 React 앱에서 성공과 실패를 전환해 봅니다.

적용 조건·범위: 위 명령은 이 글의 원문 실습 버전인 5.104.0을 고정해 설치합니다. 최신 패치를 자동 선택하는 명령과 구분하며, 제공된 누적 ZIP에서는 package-lock.json의 설치 버전을 기준으로 확인합니다.

본문 근거: tanstack.com/query/latest/docs/framework/react/installation · tanstack.com/query/latest/docs/framework/react/reference/functions/QueryClientProvider

이 기술만 따로 연습하기

기대 결과: 처음 불러오는 중 → 약 0.4초 뒤 첫 조회 성공. 성공/실패 전환을 누르면 요청 실패 메시지가 표시됩니다.

직접 실행: 환경 준비와 전체 파일

실행 환경은 React·TypeScript와 @tanstack/react-query 5.104.0입니다. React 환경이 없다면 React Vite 사용법 Vite 8 프로젝트 생성 실행 빌드를, 라이브러리 설치는 TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드의 설치 항목을 확인합니다. 아래는 별도 Vite 연습 앱 src/App.tsx 전체 내용입니다. src/main.tsx는 유지하며 이전 TaskFlow나 API 서버는 필요하지 않습니다. 요청 함수는 이 파일 안에 정의된 비동기 모형입니다.

import { useState } from 'react';
import {
  QueryClient,
  QueryClientProvider,
  useQuery
} from '@tanstack/react-query';

const wait = (ms = 400) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const client = new QueryClient({
  defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});

async function readMessage(fail: boolean) {
  await wait();
  if (fail) throw new Error('연습용 요청 실패');
  return { title: '첫 조회 성공' };
}
function Demo() {
  const [fail, setFail] = useState(false);
  const query = useQuery({
    queryKey: ['message', fail],
    queryFn: () => readMessage(fail),
  });
  return (
    <main>
      <button onClick={() => setFail((value) => !value)}>성공/실패 전환</button>
      {query.isPending ? <p>불러오는 중</p>
        : query.isError ? <p>{query.error.message}</p>
        : <p>{query.data.title}</p>}
    </main>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={client}>
      <Demo />
    </QueryClientProvider>
  );
}
npm run dev
npm run build

자가 확인·변형: retry를 false로 두어 실패를 즉시 관찰합니다. Provider를 제거하면 상태 메시지가 아니라 No QueryClient set 오류가 나는 이유를 설명해 보세요.

검증 기록: 위 전체 코드는 타입검사·빌드와 jsdom 동작 검증을 통과했습니다. 실제 브라우저 화면과 누적 Next.js 프로젝트 통합 실행은 별도 확인 대상입니다.

설치

npm install @tanstack/react-query@5.104.0

설치 후 package.json에 의존성이 추가됐는지 확인합니다.

위 명령은 이 글의 원문 실습 버전인 5.104.0을 고정해 설치합니다. 최신 패치를 자동 선택하는 명령과 구분하며, 제공된 누적 ZIP에서는 package-lock.json의 설치 버전을 기준으로 확인합니다.

QueryClient란

QueryClient는 query와 mutation 캐시를 관리하는 객체입니다.

import { QueryClient } from '@tanstack/react-query';


const queryClient = new QueryClient();

React 컴포넌트가 이 QueryClient를 사용하려면 QueryClientProvider가 필요합니다.

Next.js App Router Provider

Provider는 Client Component로 만듭니다.

app/providers.tsx

'use client';


import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';
import { useState } from 'react';


type ProvidersProps = {
  children: React.ReactNode;
};


export default function Providers({
  children,
}: ProvidersProps) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 30_000,
          },
        },
      }),
  );


  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

QueryClient를 컴포넌트 렌더마다 새로 만들지 않고 안정적으로 유지하는 것이 중요합니다.

layout에 연결

app/layout.tsx

import Providers from './providers';


export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko">
      <body>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}

첫 useQuery

Client Component에서 /api/tasks를 조회합니다.

'use client';


import { useQuery } from '@tanstack/react-query';


type Task = {
  id: string;
  title: string;
  completed: boolean;
};


async function getTasks(): Promise<Task[]> {
  const response = await fetch('/api/tasks');


  if (!response.ok) {
    throw new Error('작업 목록을 불러오지 못했습니다.');
  }


  return response.json();
}


export default function TaskListClient() {
  const {
    data,
    isPending,
    isError,
    error,
  } = useQuery({
    queryKey: ['tasks'],
    queryFn: getTasks,
  });


  if (isPending) {
    return <p>작업을 불러오는 중입니다.</p>;
  }


  if (isError) {
    return <p>{error.message}</p>;
  }


  return (
    <ul>
      {data.map((task) => (
        <li key={task.id}>
          {task.title}
        </li>
      ))}
    </ul>
  );
}

pending, error, success

useQuery는 요청 상태를 직접 제공합니다.

isPending
→ 아직 사용할 데이터가 없는 초기 대기 상태


isError
→ queryFn이 오류를 던짐


data
→ 성공한 서버 데이터

별도 loading state를 중복해서 만들 필요가 없습니다.

queryFn은 오류를 던져야 합니다

fetch는 404나 500 응답만으로 Promise를 reject하지 않습니다.

따라서 response.ok를 확인하고 직접 오류를 던집니다.

if (!response.ok) {
  throw new Error('요청에 실패했습니다.');
}

이 기준은 다음 회차에서 queryFn으로 분리하며 다시 다룹니다.

자주 발생하는 오류

No QueryClient set

Provider 밖에서 useQuery를 호출하면 발생합니다.

No QueryClient set, use QueryClientProvider to set one

Client Component가 Providers 아래에 있는지 확인합니다.

useQuery를 Server Component에서 호출

useQuery는 React hook이므로 사용하는 파일에는 클라이언트 경계가 필요합니다.

'use client';

QueryClient를 렌더마다 생성

const queryClient = new QueryClient();

컴포넌트 함수 내부에서 매 렌더마다 위 코드가 실행되면 캐시가 안정적으로 유지되지 않습니다.

학습 완료 체크리스트

  • TanStack Query를 설치할 수 있습니다.
  • QueryClient와 QueryClientProvider의 역할을 설명할 수 있습니다.
  • useQuery로 API를 조회할 수 있습니다.
  • pending/error/success UI를 처리할 수 있습니다.
  • fetch HTTP 오류를 query error로 변환할 수 있습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 서버 데이터의 조회·변경과 캐시를 관리합니다.

TaskFlow의 GET /api/tasks를 첫 useQuery로 연결합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

중간부터 시작하는 전체 실습 파일

기존 Next.js Route Handler TaskFlow의 /tasks 화면과 /api/tasks를 이어서 확장합니다. 인증 없는 demo 메모리 저장소를 사용하는 로컬 실습이며 계정·외부 DB·API 키가 필요 없습니다. MSW를 활성화하지 않습니다. 서버를 다시 시작하면 데이터가 초기화됩니다.

이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.

시작본에서 직접 바꾸기

시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.

  1. app/(app)/tasks/page.tsx — 기존 파일의 같은 위치를 갱신합니다.
  2. app/api/tasks/route.ts — 기존 파일의 같은 위치를 갱신합니다.
  3. app/layout.tsx — 기존 파일의 같은 위치를 갱신합니다.
  4. app/providers.tsx — 이전 단계에 없던 파일을 만듭니다.
  5. app/tasks/TaskDashboard.tsx — 이전 단계에 없던 파일을 만듭니다.
  6. app/tasks/TaskListClient.tsx — 이전 단계에 없던 파일을 만듭니다.
  7. package-lock.json — 기존 파일의 같은 위치를 갱신합니다.
  8. package.json — 기존 파일의 같은 위치를 갱신합니다.

변경 이유와 결과: QueryClientProvider를 루트에 연결하고 /tasks에서 첫 useQuery로 실제 /api/tasks를 읽습니다. 요청 대기·실패·성공을 표시합니다.

완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.

app/(app)/tasks/page.tsx 전체 파일

export const dynamic = 'force-dynamic';
import TaskDashboard from '@/app/tasks/TaskDashboard';
export default function TasksPage() { return <TaskDashboard />; }

app/api/tasks/route.ts 전체 파일

import { listTasks, addTask } from '@/app/lib/store';
export async function GET(request: Request) {
  const params = new URL(request.url).searchParams;
  if (params.get('fail') === '1') return Response.json({ message: '실습 조회 실패' }, { status: 500 });
  const status = params.get('status') ?? 'all';
  if (!['all', 'active', 'completed'].includes(status)) return Response.json({ message: '필터 오류' }, { status: 400 });
  const rows = listTasks('demo').filter((task) => status === 'all' || (status === 'completed' ? task.completed : !task.completed));
  return Response.json(rows, { headers: { 'Cache-Control': 'private, no-store' } });
}
export async function POST(request: Request) {
  try {
    const body: unknown = await request.json();
    if (!body || typeof body !== 'object' || !('title' in body)) throw new Error('제목 필요');
    if ('simulateFailure' in body && body.simulateFailure === true) return Response.json({ message: '실습 저장 실패' }, { status: 503 });
    return Response.json(addTask('demo', body.title), { status: 201 });
  } catch {
    return Response.json({ message: '올바른 JSON과 2~100자 제목이 필요합니다.' }, { status: 400 });
  }
}

app/layout.tsx 전체 파일

import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
import Providers from "./providers";
export const metadata: Metadata = {
  title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
  description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
};
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ko">
      <body><Providers>{children}</Providers></body>
    </html>
  );
}

app/providers.tsx 전체 파일

'use client';
import { useState, type ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export default function Providers({ children }: { children: ReactNode }) {
  const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } }));
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

app/tasks/TaskDashboard.tsx 전체 파일

'use client';
import { useState } from 'react';
import TaskListClient from './TaskListClient';
export default function TaskDashboard() {
  return (
    <main>
      <h1>TaskFlow Query</h1>
      <TaskListClient />
    </main>
  );
}

app/tasks/TaskListClient.tsx 전체 파일

'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { Task } from '../lib/types';
type TaskStatus = 'all' | 'active' | 'completed';
async function getTasks(signal?: AbortSignal, _status: TaskStatus = 'all', fail = false): Promise<Task[]> {
  const response = await fetch(`/api/tasks${fail ? '?fail=1' : ''}`, { signal });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const rows: unknown = await response.json();
  if (!Array.isArray(rows)) throw new Error('작업 배열 응답이 필요합니다.');
  return rows as Task[];
}
export default function TaskListClient({ status = 'all', onSelect = () => {} }: {
  status?: TaskStatus; onSelect?: (id: string) => void;
}) {
  const [fail, setFail] = useState(false);
  const query = useQuery({
    queryKey: ['tasks'],
    queryFn: ({ signal }) => getTasks(signal, status, fail),
  });
  return (
    <section aria-label="서버 작업 목록">
      <label><input type="checkbox" checked={fail} onChange={(event) => setFail(event.target.checked)} />실습 조회 실패</label>
      <button onClick={() => void query.refetch()}>목록 다시 확인</button>
      {query.isFetching && <p role="status">요청 중입니다.</p>}
      {query.isPending ? <p>불러오는 중입니다.</p> : query.isError ? <p role="alert">{query.error.message}</p> : query.data.length === 0 ? <p>표시할 작업이 없습니다.</p> : (
        <ul>
          {query.data.map((task) => (
            <li key={task.id}>
              <button onClick={() => onSelect(task.id)}>{task.title}</button>
              <span> · {task.completed ? '완료' : '진행 중'}</span>
            </li>
          ))}
        </ul>
      )}
    </section>
  );
}

완료 확인

  1. 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤 npm ci로 잠금 파일의 의존성을 설치합니다.
  2. npm run dev 실행 후 http://localhost:3000/tasks를 엽니다.
  3. QueryClientProvider를 루트에 연결하고 /tasks에서 첫 useQuery로 실제 /api/tasks를 읽습니다. 요청 대기·실패·성공을 표시합니다.
  4. npm run typecheck와 npm run build가 성공하면 다음 프로젝트 단계로 갑니다.

기존 체크포인트 검증 기록: TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과한 기록입니다. 별도 실제 브라우저 화면 검수는 그 파일 검증 범위에 포함하지 않았습니다. 이번 문장 보완에서 재실행한 결과는 아닙니다.

아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.

누적 실습은 이 절의 프로젝트 단계 링크를 따라 진행합니다. 페이지 끝 학습 순서는 기술 주제 읽기 순서입니다.

이전 프로젝트 단계: TanStack Query vs Zustand: 서버 상태와 클라이언트 상태 차이. 위 시작본 ZIP 전체를 내려받으면 이전 글을 다시 조립하지 않고 이 단계부터 실행할 수 있습니다.

app/providers.tsx와 app/layout.tsx를 연결하고 TaskListClient에서 기존 /api/tasks를 읽습니다. 아래 서버 URL은 누적 프로젝트의 Route Handler가 존재할 때만 유효합니다.

이전 체크포인트

query-step-01-boundary

이번 목표

TaskFlow의 GET /api/tasks를 첫 useQuery로 연결합니다.

변경 파일

package.json
app/providers.tsx
app/layout.tsx
app/tasks/TaskListClient.tsx

Before

Client Component가 직접 fetch 상태를 관리할 가능성

After

QueryClientProvider
→ useQuery(['tasks'])
→ pending / error / success

실행 확인

npm run dev
npm run build

브라우저 Network에서 /api/tasks 요청이 발생하고 목록이 표시되는지 확인합니다.

현재 확장 단계

✓ 1. 서버 상태 경계
→ 2. Provider + useQuery
○ 3. queryFn
○ 4. queryKey
○ 5. 캐시 수명
○ 6~11. mutation·동기화·복구
○ 12~15. SSR·Hydration

완료 체크포인트

query-step-02-provider

프로젝트 다음 단계

프로젝트 다음 단계에서는 컴포넌트 안에 있는 fetch 로직을 queryFn으로 분리하고 HTTP 오류 처리와 재사용 기준을 정합니다.

다음 프로젝트 단계: TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기

공식 문서

프로젝트 적용 후 확인: Provider를 붙이면 요청과 상태가 어떻게 보일까요?

관찰 절차 기존 TaskFlow /tasks에서 새로고침 후 Network의 GET /api/tasks 한 건과 loading → 목록 전환을 봅니다. 요청을 실패시키고 오류 화면을 확인한 뒤 다시 성공 응답으로 복원합니다. React Query Devtools에서 [‘tasks’] 캐시 상태를 확인합니다.

예상 결과 성공 응답은 목록으로, 실패 응답은 오류 UI로 분기하고 캐시에 목록이 나타납니다.

오류 점검 No QueryClient set이면 Provider 범위와 Client Component 경계를 확인합니다. 무한 loading이면 queryFn의 Promise 반환과 응답 종료를 봅니다.

프로젝트 다음 단계 3회차에서 queryFn과 HTTP 오류 처리를 분리합니다.

기존 체크포인트 검증 범위 전체 체크포인트의 빌드·상태 동작은 로컬 검증을 통과했습니다. 실제 브라우저 시각 확인은 별도로 진행해야 합니다.

글 소개·학습 준비·전체 목차
TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드

TanStack Query · 2 / 15 · 필수 학습 · 라이브러리 최초 도입

QueryClientProvider가 조회 캐시를 제공하고 useQuery가 대기·성공·실패 상태를 화면에 연결합니다. 아래 별도 React 앱에서 성공/실패 전환을 먼저 확인합니다. Provider 누락은 요청 실패와 다른 설정 오류이며, 실제 API 연결은 하단 누적 적용에서 구분합니다.

TanStack Query v5에서는 useQuery에 객체 하나를 전달하는 문법을 사용합니다.

기술 개념을 차례로 읽는다면 다음 글은 TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기입니다. TaskFlow 프로젝트를 이어 만들 때는 아래 프로젝트 다음 단계를 따르세요.

이번 글에서 배우는 내용

  • @tanstack/react-query 설치
  • QueryClient 생성
  • QueryClientProvider 연결
  • useQuery 기본 구조
  • isPending, isError, data 처리
  • App Router에서 Client Provider를 두는 위치

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 15개 · 필수 14개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기