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

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

queryFn은 결과와 실패를 전달하는 실행 계약입니다

queryKey는 어떤 데이터를 식별할지, enabled는 자동 요청을 시작할 조건을, queryFn은 실행 후 무엇을 반환할지를 정합니다. 함수 안의 조건문 자체가 문제는 아닙니다. 입력 검증·HTTP 상태 검사·응답 변환·정상적인 데이터 부재를 구분하는 조건문은 필요합니다.

캐시와 서비스 사이의 역할

TanStack Query queryFn의 start loading fetch success error retry 상태 흐름

컴포넌트는 조회 상태와 화면을 연결하고, queryFn은 데이터 접근 함수를 호출합니다. 캐시의 신선도·옵션·observer의 상태에 따라 자동 실행이 결정되며 refetch나 fetchQuery 같은 명시적 실행도 가능합니다. “컴포넌트와 무관하게 캐시만 판단한다”로 단순화하지 마세요. 네트워크 I/O가 있는 함수이므로 수학적인 순수 함수도 아닙니다.

const cartKeys = { detail: userId => ['cart', userId] };
function useCart(userId) {
  return useQuery({
    queryKey: cartKeys.detail(userId),
    queryFn: ({ queryKey, signal }) => getCart(queryKey[1], signal),
    enabled: Boolean(userId),
  });
}

이 코드는 getCart 서비스가 있다는 전제의 구조 발췌입니다. 키에서 사용자 ID를 읽으면 캐시 식별자와 실제 요청 인자가 따로 어긋날 여지가 줄어듭니다. 아래 완성 실습은 같은 구조를 프로필 API로 구현합니다. 기존 장바구니 예처럼 서비스 뒤에 REST나 Firebase를 둘 수 있지만, 각 SDK의 취소 지원은 따로 확인해야 합니다.

서비스 분리의 기존 응용 사례는 CartService 원본 코드에서 볼 수 있습니다. 외부 프로젝트는 본 실습의 실행 의존성이 아닙니다.

성공·실패·undefined 계약

공식 가이드는 Promise가 데이터로 resolve되거나 오류로 reject되는 함수를 설명합니다. 설치된 v5 타입은 동기 결과도 허용하지만 네트워크 예제는 async 함수를 사용합니다. 성공값은 undefined가 아니어야 합니다. return을 빠뜨리거나 catch에서 오류를 삼켜 undefined로 끝내면 올바른 성공 응답이 아닙니다.

결과 Query 해석 사용 사례
객체·배열 성공 데이터 상세·목록
[] 빈 목록 성공 검색 결과 0건
null 명시적인 없음 성공 등록되지 않은 프로필
throw / rejected Promise 실패, 재시도 정책 적용 HTTP 500·형식 오류
undefined 허용되지 않는 성공값 누락된 return을 수정

fetch는 404나 500 응답만으로 Promise를 reject하지 않습니다. response.ok를 검사해 오류를 던져야 합니다. 이 실습은 프로필의 404를 “아직 등록하지 않음”으로 합의한 경우라 null을 반환합니다. 모든 API의 404를 null로 바꾸라는 규칙은 아닙니다. 권한 오류나 서버 장애를 빈 배열로 덮으면 사용자에게 장애가 정상적인 빈 결과로 보입니다.

enabled와 입력 가드는 함께 사용합니다

ID가 아직 없다면 enabled: !!id로 자동 실행을 막습니다. 캐시가 없는 disabled query는 status가 pending이면서 fetchStatus는 idle입니다. 따라서 isPending만 보고 “요청 중”이라고 쓰면 선택 전에도 로딩이 끝나지 않는 화면처럼 보입니다. 먼저 ID 선택 안내를 처리하거나 실제 첫 실행 중임을 나타내는 isLoading을 사용하세요.

enabled가 false라도 useQuery가 반환한 refetch는 수동 실행할 수 있습니다. 아래 서비스의 if (!id) throw ...는 그래서 유용합니다. enabled는 서버 인증·권한 검사도 대신하지 않습니다. 타입 안전한 비활성화가 필요하면 skipToken도 선택할 수 있지만 skipToken 상태에서는 refetch로 요청을 시작할 수 없습니다.

// null은 실행하지 않음이 아니라 정상 성공값입니다.
queryFn: async () => {
  if (!userId) return null;
  return getCart(userId);
}

위 코드를 선택했다면 “사용자 없음도 정상 결과 null”이라는 도메인 계약이 됩니다. 요청 대기를 표현하려는 의도라면 enabled로 분리하는 편이 상태를 더 정확히 드러냅니다. 반대로 응답이 없음을 정상 결과로 나타내기 위한 null 분기는 그대로 사용합니다.

AbortSignal과 응답 변환

TanStack Query에서 queryFn이 요청 책임만 맡고 queryKey와 enabled가 실행 조건을 분리하는 구조

queryFn의 context.signal을 fetch 또는 취소를 지원하는 서비스에 전달하세요. Query가 요청을 취소하면 이 신호를 소비하는 작업도 중단할 수 있습니다. 신호를 무시한 요청은 컴포넌트가 사라져도 완료되어 캐시를 채울 수 있으므로, “언마운트하면 모든 요청이 무조건 취소된다”는 설명은 정확하지 않습니다.

응답의 필수 필드를 검사하고 Date·숫자·객체 형태를 정규화하는 것도 요청 경계의 책임에 포함할 수 있습니다. 정규화한 반환값은 캐시에 저장됩니다. 특정 컴포넌트가 목록 길이만 원한다면 select로 observer에 전달할 값을 바꿀 수 있고, 원래 캐시 데이터 자체를 교체하지는 않습니다. UI 알림·화면 이동은 반복 실행될 수 있는 queryFn 안에 섞지 않는 편이 좋습니다.

서비스 분리와 실제 HTTP 전환

실습의 fetchProfile은 ID 검증→요청→404 판단→HTTP 실패→JSON 형식 검증 순서입니다. App은 queryKey와 enabled, 상태 표시만 다룹니다. 기본 request에는 30ms fixture를 주입해 API 없이 실행할 수 있습니다. 실제 백엔드가 준비되면 <App request={fetch} />로 바꿀 수 있지만, /api/profiles/:id 서버와 응답 계약, 인증 방식은 별도로 구현해야 합니다.

Firebase 문서 조회라면 exists()가 false일 때 null을 반환하고, 존재하면 필요한 필드를 명시적으로 매핑하는 기존 서비스 패턴도 유효합니다. 서비스 파일 분리는 선택 사항이며 작은 앱에서는 queryFn 안에 직접 작성해도 됩니다. 분리 자체보다 오류를 숨기지 않고 같은 키에 같은 데이터 의미를 반환하는 것이 중요합니다.

자주 묻는 질문

try/catch는 꼭 필요한가요? 문맥을 붙이거나 복구할 때만 사용합니다. 오류를 기록한 뒤 다시 throw하지 않으면 Query가 의도한 실패를 받지 못합니다.

queryFn 안에서 다른 요청을 연달아 실행해도 되나요? 하나의 캐시 결과를 만들기 위한 요청 조합은 가능합니다. 각 요청에 같은 signal을 전달하고 중간 실패를 처리하세요. 서로 독립적으로 캐시해야 할 리소스라면 별도 query를 고려합니다.

함수가 참조하는 필터를 바꾸고 refetch만 호출해도 되나요? 응답을 바꾸는 필터는 queryKey에도 들어가야 캐시가 구분됩니다. queryKey 설계 글에서 범위와 계층을 확인하세요.

실습: 조건부 프로필 조회와 취소 전달

아래 파일은 하나의 완성 프로젝트입니다. 전체 실습 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, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchProfile, fixtureRequest } from './api.js';
export default function App({ request = fixtureRequest }) {
  const [id, setId] = useState('');
  const query = useQuery({
    queryKey: ['profiles', id],
    queryFn: ({ queryKey, signal }) => fetchProfile(queryKey[1], signal, request),
    enabled: !!id,
  });
  return (
    <main>
      <h1>조건부 프로필 요청</h1>
      <label>
        사용자
        <select value={id} onChange={(event) => setId(event.target.value)}>
          <option value="">선택하세요</option>
          <option value="hebi">해비</option>
          <option value="missing">미등록 사용자</option>
        </select>
      </label>
      {!id ? (
        <p>사용자를 선택하세요.</p>
      ) : query.isPending ? (
        <p role="status">요청 중</p>
      ) : query.isError ? (
        <p role="alert">{query.error.message}</p>
      ) : query.data === null ? (
        <p>프로필 없음</p>
      ) : (
        <p>이름: {query.data.name}</p>
      )}
      <button disabled={!id || query.isFetching} onClick={() => void query.refetch()}>
        다시 요청
      </button>
    </main>
  );
}

src/api.js

export async function fetchProfile(id, signal, request = fetch) {
  if (!id) throw new Error('사용자 ID가 필요합니다.');
  const response = await request(`/api/profiles/${encodeURIComponent(id)}`, { signal });
  if (response.status === 404) return null;
  if (!response.ok) throw new Error(`프로필 요청 실패: ${response.status}`);
  const data = await response.json();
  if (!data || typeof data.name !== 'string') throw new Error('프로필 응답 형식 오류');
  return { id, name: data.name };
}
export function fixtureRequest(url, { signal }) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(new DOMException('취소됨', 'AbortError'));
      return;
    }
    const abort = () => {
      clearTimeout(timer);
      reject(new DOMException('취소됨', 'AbortError'));
    };
    const timer = setTimeout(() => {
      signal?.removeEventListener('abort', abort);
      const id = decodeURIComponent(url.split('/').at(-1));
      resolve({
        status: id === 'missing' ? 404 : 200,
        ok: id !== 'missing',
        json: async () => ({ name: '해비' }),
      });
    }, 30);
    signal?.addEventListener('abort', abort, { once: true });
  });
}

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 };
}
import { fetchProfile, fixtureRequest } from '../src/api.js';
test('enabled가 false이면 idle이며 선택 뒤에만 요청한다', async () => {
  const request = vi.fn(fixtureRequest);
  const { client } = show({ request });
  expect(request).not.toHaveBeenCalled();
  expect(client.getQueryState(['profiles', ''])).toMatchObject({
    status: 'pending',
    fetchStatus: 'idle',
  });
  await userEvent.setup().selectOptions(screen.getByLabelText('사용자'), 'missing');
  expect(await screen.findByText('프로필 없음')).toBeInTheDocument();
  expect(request).toHaveBeenCalledTimes(1);
});
test('404는 도메인상 없음, HTTP 오류와 잘못된 응답은 실패다', async () => {
  const signal = new AbortController().signal;
  await expect(
    fetchProfile('a', signal, async () => ({ status: 404 })),
  ).resolves.toBeNull();
  await expect(
    fetchProfile('a', signal, async () => ({ status: 500, ok: false })),
  ).rejects.toThrow('500');
  await expect(
    fetchProfile('a', signal, async () => ({
      status: 200,
      ok: true,
      json: async () => ({}),
    })),
  ).rejects.toThrow('응답 형식');
  await expect(fetchProfile('', signal, vi.fn())).rejects.toThrow('ID');
});
test('Query 취소 신호가 전송 계층까지 전달된다', async () => {
  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  let observed;
  const request = (url, { signal }) => {
    observed = signal;
    return fixtureRequest(url, { signal });
  };
  const pending = client.fetchQuery({
    queryKey: ['profiles', 'hebi'],
    queryFn: ({ signal }) => fetchProfile('hebi', signal, request),
  });
  const settled = pending.catch((error) => error);
  await client.cancelQueries({ queryKey: ['profiles', 'hebi'] });
  expect(observed.aborted).toBe(true);
  await settled;
  expect(client.getQueryData(['profiles', 'hebi'])).toBeUndefined();
  client.clear();
});

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' },
});

package.json 전체 코드 보기

index.html

index.html 전체 코드 보기

vite.config.js

vite.config.js 전체 코드 보기

src/main.jsx

src/main.jsx 전체 코드 보기

src/styles.css

src/styles.css 전체 코드 보기

src/App.jsx

src/App.jsx 전체 코드 보기

src/api.js

src/api.js 전체 코드 보기

test/setup.js

test/setup.js 전체 코드 보기

test/App.test.jsx

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

검증 결과와 적용 범위

선택 전 pending/idle과 무요청, 선택 후 null 성공, HTTP 500·잘못된 응답·누락 ID 실패, Query 취소가 전달받은 AbortSignal을 중단하는지 확인합니다.

이 프로젝트는 Vitest/jsdom 테스트와 Vite production build를 통과했습니다. jsdom은 실제 브라우저가 아니므로 레이아웃·키보드 포커스의 브라우저별 차이·실제 네트워크·CORS·서버 권한·전송 취소까지 검증한 것은 아닙니다. 테스트의 fixture와 주입 함수는 정해진 입력으로 화면과 Query 동작을 확인하는 용도입니다.

공식 문서

2026-09-12에 확인한 React v5 문서입니다. 실습은 설치된 5.102.8에서 별도로 검증했습니다.

연결 학습: fetch의 HTTP 실패와 조회 취소

목표·선행 지식: queryFn은 성공 데이터를 반환하고 실패를 throw하는 계약을 갖습니다.

fetch는 404·500 응답도 Response로 반환할 수 있으므로 response.ok를 확인합니다. Query가 전달하는 signal을 fetch에 연결하면 취소가 네트워크 작업까지 이어집니다. JSON 파싱 성공과 Todo 스키마의 유효성은 별도 문제입니다.

직접 확인할 과제

존재하지 않는 주소를 호출했을 때 오류 UI로 들어가는지 확인하세요. AbortSignal을 API 함수 인수로 전달하고 조회 취소 시 실패 알림을 불필요하게 띄우지 않는지도 확인합니다.

공통 실습 ZIP · 다음 학습 · 라이브러리 선택 가이드

공통 ZIP은 버전을 고정한 학습 예제입니다. UI 파일은 Radix·Sonner·Embla 기반 축약 구현이며 shadcn CLI 생성물과 동일하지 않습니다. 적용 범위와 실행 방법은 ZIP의 README를 확인하세요.

이 글이 도움이 되었나요?

조회 중

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 피드 구독하기

댓글 남기기