TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기

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

TanStack Query queryFn으로 데이터 요청 로직을 어떻게 분리하나요?

queryFn은 특정 queryKey의 데이터를 어떻게 가져올지 정의하는 함수입니다. 요청과 응답 처리 로직을 분리하면 컴포넌트는 대기·성공·실패의 화면 표현에 집중할 수 있습니다.

적용 조건·범위: queryFn과 queryKey가 같은 변수를 기준으로 움직이도록 구성하는 것이 중요합니다.

본문 근거: tanstack.com/query/latest/docs/framework/react/guides/queries · tanstack.com/query/latest/docs/framework/react/guides/query-functions

이 기술만 따로 연습하기

이 개념 직접 실행

버튼을 조작하고 로딩·결과·캐시의 변화를 확인합니다.

예제 크게 보기 (새 탭)

기대 결과: 200은 응답 읽기, 500은 HTTP 500 오류 분기입니다. Response 객체만 생성했으며 실제 서버 요청은 없습니다.

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

실행 환경은 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 request(status: number, signal: AbortSignal) {
  await wait();
  signal.throwIfAborted();
  const response = new Response(JSON.stringify({ title: '응답 읽기' }), { status });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return await response.json() as { title: string };
}
function Demo() {
  const [status, setStatus] = useState(200);
  const query = useQuery({
    queryKey: ['response', status],
    queryFn: ({ signal }) => request(status, signal),
  });
  return (
    <main>
      <button onClick={() => setStatus(200)}>200 응답</button>
      <button onClick={() => setStatus(500)}>500 응답</button>
      <p>{query.isPending ? '요청 중' : query.isError
        ? query.error.message : query.data.title}</p>
    </main>
  );
}

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

자가 확인·변형: response.ok 검사와 throw를 잠시 제거하면 500이어도 성공 데이터처럼 읽힙니다. fetch도 HTTP 실패 상태만으로 reject하지 않는다는 점과 연결해 보세요. signal은 실제 fetch에서는 fetch(url, { signal })로 전달합니다.

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

queryFn 기본 구조

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


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


  return response.json();
}

useQuery에서는 함수 자체를 전달합니다.

useQuery({
  queryKey: ['tasks'],
  queryFn: getTasks,
});

다음처럼 즉시 실행한 결과를 전달하면 안 됩니다.

queryFn: getTasks()

queryFn은 TanStack Query가 필요한 시점에 호출할 함수입니다.

API 모듈로 분리

app/lib/tasks-api.ts

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


export async function getTasks(
  signal?: AbortSignal,
): Promise<Task[]> {
  const response = await fetch('/api/tasks', {
    signal,
  });


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


  return response.json();
}


export async function getTask(
  id: string,
  signal?: AbortSignal,
): Promise<Task> {
  const response = await fetch(
    '/api/tasks/' + id,
    { signal },
  );


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


  return response.json();
}

QueryFunctionContext의 signal

TanStack Query는 queryFn에 context를 전달합니다.

useQuery({
  queryKey: ['tasks'],
  queryFn: ({ signal }) =>
    getTasks(signal),
});

요청이 더 이상 필요하지 않게 되었을 때 fetch가 AbortSignal을 사용할 수 있도록 연결할 수 있습니다.

queryKey 값으로 상세 조회

useQuery({
  queryKey: ['tasks', taskId],
  queryFn: ({ signal }) =>
    getTask(taskId, signal),
  enabled: Boolean(taskId),
});

queryFn과 queryKey가 같은 변수를 기준으로 움직이도록 구성하는 것이 중요합니다.

queryFn에서 하지 않을 일

queryFn은 서버 데이터 요청에 집중합니다.

다음 로직을 섞지 않는 편이 좋습니다.

modal 열기
Zustand selectedTaskId 변경
toast 표시
컴포넌트 state 직접 변경

UI 부수 효과와 서버 요청 책임을 분리합니다.

오류 처리 기준

fetch에서 HTTP 오류를 반드시 확인합니다.

if (!response.ok) {
  const message =
    'HTTP ' + response.status;


  throw new Error(message);
}

TanStack Query는 queryFn이 던진 오류를 query error 상태로 처리합니다.

학습 완료 체크리스트

  • queryFn의 역할을 설명할 수 있습니다.
  • 컴포넌트에서 fetch 로직을 분리할 수 있습니다.
  • response.ok를 확인해 HTTP 오류를 던질 수 있습니다.
  • AbortSignal을 fetch에 전달할 수 있습니다.
  • 목록과 상세 요청 함수를 재사용할 수 있습니다.

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

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

TaskListClient 내부의 fetch를 app/lib/tasks-api.ts로 옮깁니다.

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

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

TaskFlow 프로젝트 적용

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

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

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

시작본에서 직접 바꾸기

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

  1. app/lib/tasks-api.ts — 이전 단계에 없던 파일을 만듭니다.
  2. app/tasks/TaskListClient.tsx — 기존 파일의 같은 위치를 갱신합니다.

변경 이유와 결과: 컴포넌트의 HTTP 검사를 app/lib/tasks-api.ts로 분리합니다. AbortSignal·response.ok와 배열 응답 검사를 함께 연결합니다.

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

app/lib/tasks-api.ts 전체 파일

import type { Task } from './types';
export type TaskStatus = 'all' | 'active' | 'completed';
export type TaskPage = { items: Task[]; page: number; totalPages: number };
export type TaskCursorPage = { items: Task[]; nextCursor: string | null };
async function request<T>(url: string, options?: RequestInit): Promise<T> {
  const response = await fetch(url, options);
  if (!response.ok) {
    const body = await response.json().catch(() => ({}));
    throw new Error(typeof body.message === 'string' ? body.message : `HTTP ${response.status}`);
  }
  return response.json() as Promise<T>;
}
export async function getTasks(signal?: AbortSignal, status: TaskStatus = 'all', fail = false): Promise<Task[]> {
  const rows = await request<unknown>(`/api/tasks?status=${status}${fail ? '&fail=1' : ''}`, { signal });
  if (!Array.isArray(rows)) throw new Error('작업 배열 응답이 필요합니다.');
  return rows as Task[];
}
export const getTask = (id: string, signal?: AbortSignal) => request<Task>(`/api/tasks/${encodeURIComponent(id)}`, { signal });
export const createTask = (title: string, simulateFailure = false) => request<Task>('/api/tasks', {
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, simulateFailure }),
});
export const updateTask = (id: string, completed: boolean, simulateFailure = false) => request<Task>(`/api/tasks/${encodeURIComponent(id)}`, {
  method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ completed, simulateFailure }),
});
export const getTaskPage = (page: number, signal?: AbortSignal) => request<TaskPage>(`/api/tasks?page=${page}&pageSize=2`, { signal });
export const getTaskCursorPage = (cursor: string | null, signal?: AbortSignal) => request<TaskCursorPage>(`/api/tasks?cursor=${encodeURIComponent(cursor ?? '')}&pageSize=2`, { signal });

app/tasks/TaskListClient.tsx 전체 파일

'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { Task } from '../lib/types';
import { getTasks, type TaskStatus } from '../lib/tasks-api';
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. 컴포넌트의 HTTP 검사를 app/lib/tasks-api.ts로 분리합니다. AbortSignal·response.ok와 배열 응답 검사를 함께 연결합니다.
  4. npm run typecheck와 npm run build가 성공하면 다음 프로젝트 단계로 갑니다.

이 체크포인트는 TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과했습니다. 별도 실제 브라우저 화면 검수는 이 파일 검증 범위에 포함하지 않았습니다.

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

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

이전 프로젝트 단계: TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드. 위 시작본 ZIP 전체를 내려받으면 이전 글을 다시 조립하지 않고 이 단계부터 실행할 수 있습니다.

app/lib/tasks-api.ts로 HTTP 처리와 반환 타입을 옮기고 TaskListClient의 queryFn에서 signal을 전달합니다.

이전 체크포인트

query-step-02-provider

이번 목표

TaskListClient 내부의 fetch를 app/lib/tasks-api.ts로 옮깁니다.

전체 파일·코드 보기

app/tasks/TaskListClient.tsx

'use client';


import { useQuery } from '@tanstack/react-query';
import {
  getTasks,
} from '../lib/tasks-api';


export default function TaskListClient() {
  const query = useQuery({
    queryKey: ['tasks'],
    queryFn: ({ signal }) =>
      getTasks(signal),
  });


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


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


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

Before / After

Before
컴포넌트 안에 URL·fetch·오류 처리 포함


After
UI 컴포넌트
→ query 상태와 렌더링


tasks-api.ts
→ HTTP 요청과 반환 타입

실행 확인

npm run dev
npm run build

Network에서 GET /api/tasks가 정상 호출되고 기존 목록 결과가 동일한지 확인합니다.

현재 확장 단계

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

완료 체크포인트

query-step-03-queryfn

프로젝트 다음 단계

프로젝트 다음 단계에서는 목록·상세·필터 데이터가 서로 충돌하지 않도록 queryKey를 체계적으로 설계합니다.

다음 프로젝트 단계: TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기

공식 문서

프로젝트 적용 후 확인: queryFn은 실패를 어떻게 전달할까요?

관찰 절차 Network에서 GET /api/tasks의 성공 응답과 강제 500 응답을 번갈아 확인합니다. fetch를 쓰는 queryFn에서 response.ok가 거짓일 때 Error를 throw하도록 하고, Devtools에서 success/error 상태와 재시도 횟수를 봅니다.

예상 결과 성공 시 작업 목록, 500 시 오류 분기가 보입니다. fetch는 500 응답만으로 자동 reject되지 않습니다.

오류 점검 오류 UI가 안 뜨면 response.ok 검사와 throw, retry 설정을 확인합니다. 호출이 중복되면 개발 모드와 refetch 조건을 구분합니다.

프로젝트 다음 단계 4회차에서 요청 조건을 queryKey로 구분합니다.

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

글 소개·학습 준비·전체 목차
TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기

TanStack Query · 3 / 15 · 필수 학습 · queryFn

useQuery를 연결한 뒤에는 데이터 요청 로직을 컴포넌트에서 분리하는 것이 좋습니다.

queryFn은 “이 queryKey의 데이터를 어떻게 가져오는가”를 담당합니다. UI 컴포넌트는 요청 주소와 HTTP 처리보다 데이터 상태와 화면 표현에 집중하게 됩니다.

기술 개념을 차례로 읽는다면 다음 글은 TanStack Query staleTime gcTime 차이: 캐시 시간 기준입니다. TaskFlow 프로젝트를 이어 만들 때는 아래 프로젝트 다음 단계를 따르세요.

이번 글에서 배우는 내용

  • queryFn의 역할
  • fetch 로직 분리
  • HTTP 오류 처리
  • 반환 타입 지정
  • AbortSignal 사용
  • 목록과 상세 API 함수 분리

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기