주요 포인트 한눈에 보기
TanStack Query에서 queryFn은 단순히 데이터를 가져오는 함수가 아니라,
캐시 계층과 서버 요청 사이를 연결하는 유일한 계약 지점입니다.
이 글에서는 queryFn의 역할을 사용법이 아닌 구조·설계 관점에서 정리하며,
queryKey로 설계한 캐시 구조 안에서 요청 로직이 어디까지 책임져야 하는지를 단계적으로 살펴봅니다.
- TanStack Query에서 queryFn의 위치
- queryFn은 단순 fetch 함수가 아니다
- queryFn에 조건문을 넣지 말아야 하는 이유
- queryFn의 책임 범위 정리
- 실무에서 자주 쓰는 queryFn 설계 패턴
- 정리
- FAQ
TanStack Query에서 queryFn의 위치
TanStack Query에서 queryFn은 캐시 계층과 실제 서버 요청을 연결하는 지점입니다. 서버 데이터를 전역 캐시로 관리하는 구조에서 queryKey가 무엇을 식별할지 결정한다면, queryFn은 해당 데이터가 필요해졌을 때 어떤 요청을 수행할지를 정의합니다.
// shared/hooks/useCart.ts
import { useQuery } from '@tanstack/react-query';
import { CartService } from '../services/cartService';
export const cartKeys = {
all: ['cart'] as const,
list: (userId: string) => ['cart', userId] as const,
};
export function useCart(userId: string | null) {
return useQuery({
queryKey: cartKeys.list(userId ?? ''),
queryFn: () => CartService.getUserCart(userId as string),
enabled: !!userId,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
});
}
이 훅은 “로그인한 사용자 장바구니 조회”라는 하나의 유즈케이스를 캡슐화합니다. 컴포넌트는 장바구니를 어떻게 가져오는지 알 필요 없이, useCart를 호출해 결과만 사용합니다.
// pages/OrderCartPage.tsx
import { useCart } from '@/shared/hooks/useCart';
export default function OrderCartPage() {
const userId = user?.uid ?? null;
const { data: cart, isLoading, error } = useCart(userId);
if (isLoading) return ;
if (error) return ;
return ;
}
이 코드에서 queryFn은 “장바구니 데이터를 어떻게 가져올 것인가”만을 책임집니다. 캐시 여부 판단, 재요청 시점, 중복 요청 방지는 모두 TanStack Query 캐시 계층이 담당하며, queryFn은 오직 서버 요청 로직만 수행합니다.
// src/shared/services/cartService.ts
// 내부 구현을 모두 이해할 필요는 없으며,
// "Firebase에서 장바구니 데이터를 조회한다" 정도로 받아들이면 충분합니다.
static async getUserCart(userId: string): Promise {
const cartRef = doc(db, this.COLLECTION_NAME, userId);
const cartSnap = await getDoc(cartRef);
if (!cartSnap.exists()) {
return null;
}
const data = cartSnap.data();
return {
id: cartSnap.id,
userId: data.userId,
items: data.items || [],
totalAmount: data.totalAmount || 0,
totalItems: data.totalItems || 0,
updatedAt: data.updatedAt?.toDate() || new Date(),
};
}
이 구조에서 queryFn은 컴포넌트의 상태 로직과 분리되어 있으며,
캐시가 필요하다고 판단하는 순간 실행되는 순수한 요청 함수로 동작합니다.
queryFn은 단순 fetch 함수가 아니다
많은 경우 queryFn을 단순한 fetch 또는 axios 호출 함수로만 인식합니다. 이 관점에서는 queryFn이 단순히 데이터를 가져오는 유틸 함수처럼 보이기 쉽습니다. 하지만 TanStack Query에서 queryFn은 단순 호출 함수가 아니라, 캐시 계층에 의해 실행되는 하나의 실행 단위입니다.
TanStack Query는 컴포넌트가 아니라 캐시 상태를 기준으로 쿼리 실행 여부를 판단합니다. 데이터가 신선한지, 이미 캐시에 존재하는지, 무효화되었는지에 따라 실행 여부가 결정되며, 이 판단 과정에는 queryFn이 직접 관여하지 않습니다.
queryFn은 언제 실행될지를 스스로 결정하지 않습니다. 실행 시점은 캐시 계층과 옵션 조합에 의해 이미 결정된 상태이며, queryFn은 그 결정 결과를 전제로 실제 서버 요청을 수행하는 역할만 담당합니다.
이 때문에 queryFn을 단순 fetch 함수처럼 취급하고 내부에서 실행 조건이나 상태 분기를 처리하기 시작하면, TanStack Query가 제공하는 캐시 제어 모델과 충돌하게 됩니다. queryFn은 “실행이 필요하다고 판단된 이후 무엇을 요청할 것인가”에만 집중해야 하며, 실행 여부 판단은 캐시 계층에 맡기는 것이 구조적으로 올바른 설계입니다.
queryFn에 조건문을 넣지 말아야 하는 이유
queryFn 내부에 조건문을 두는 패턴은 실무에서 매우 자주 등장합니다. 인증 정보나 필수 파라미터가 아직 준비되지 않은 상태에서 요청을 방어하려는 의도로 사용되는 경우가 많습니다. 하지만 이 방식은 TanStack Query의 설계 철학과는 맞지 않는 지점을 포함하고 있습니다.
// ❌ 조건 분기를 queryFn 내부에서 처리하는 패턴
queryFn: () => {
if (!userId) return null;
return CartService.getUserCart(userId);
}
이 코드가 문제 되는 이유는, 하나의 조건문 안에 서로 다른 두 가지 의도가 섞여 있기 때문입니다. 하나는 userId가 없을 때 요청 자체를 실행하지 않으려는 의도이고, 다른 하나는 데이터가 없음을 정상 상태로 표현하려는 의도입니다. 이 두 책임을 queryFn 내부에서 동시에 처리하면, 쿼리의 역할 경계가 흐려집니다.
실행 여부는 캐시 계층이 판단해야 할 문제이며, queryFn 내부에서 조건을 처리하면 쿼리의 실행 이유와 결과를 명확히 구분하기 어려워집니다.
enabled로 실행 조건을 분리해야 하는 이유
enabled 옵션은 이 쿼리가 현재 조건에서 생성·실행되어도 되는지를 판단하는 스위치 역할을 합니다. 실행 여부를 enabled로 분리하면, queryFn은 실행이 결정된 이후의 요청 로직만 담당하게 되어 역할이 명확해집니다.
// ✅ 실행 조건과 실행 로직을 분리한 패턴
useQuery({
queryKey: ['cart', userId],
queryFn: () => CartService.getUserCart(userId),
enabled: !!userId,
});
이 구조에서는 userId가 없는 상태에서는 쿼리 자체가 생성·스케줄링되지 않아, 불필요한 요청이나 애매한 상태 값이 생기지 않습니다.
return null이 가지는 의미
queryFn이 null을 반환하면,
TanStack Query는 이를 실패나 예외 상황이 아닌
정상적으로 완료된 요청의 결과로 인식합니다.
즉, 요청은 실행되었고 성공 상태로 종료되었으며,
그 결과 데이터가 null이라는 의미입니다.
이때 쿼리 상태는 isSuccess === true가 되며,
isLoading이나 isError 상태로 분기되지 않습니다.
따라서 null 반환은
“요청이 실행되지 않았다”는 의미가 아니라
“실행 결과가 null이다”라는 의미로 해석되어야 합니다.
// queryFn이 null을 반환하는 경우
useQuery({
queryKey: ['profile', userId],
queryFn: async () => {
const snap = await getDoc(doc(db, 'profiles', userId));
if (!snap.exists()) {
return null; // 정상 성공 상태 + data === null
}
return snap.data();
},
});
위 예제에서 프로필 문서가 존재하지 않는 경우,
쿼리는 실패하지 않고 성공 상태로 종료되며
data 값만 null로 설정됩니다.
이처럼 null은
“존재하지 않음”이 도메인 의미로 명확한 경우에만
정상 결과로 사용되어야 합니다.
반대로 리스트 조회와 같이
“데이터가 없을 수 있음”이 기본 전제인 경우에는
null보다 []가 훨씬 명확한 표현입니다.
// ✅ 리스트 조회에서 권장되는 패턴
useQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('/api/posts');
return res.ok ? res.json() : [];
},
});
[]는 “조회 결과가 비어 있음”이라는 의미가 명확하지만,
null은 로딩 실패, 조건 미충족, 데이터 부재 중
어느 경우인지 해석이 모호해질 수 있습니다.
따라서 리스트 조회에서는
null 반환을 피하는 것이 구조적으로 안전합니다.
특히 실행 조건을 제어하기 위한 목적으로
queryFn에서 null을 반환하는 것은
TanStack Query의 실행 모델과 맞지 않습니다.
실행 여부는 반드시 enabled 옵션으로 분리해야 합니다.
// 실행 조건을 null 반환으로 처리
useQuery({
queryKey: ['cart', userId],
queryFn: () => {
if (!userId) return null;
return CartService.getUserCart(userId);
},
});
// 실행 조건은 enabled로 분리
useQuery({
queryKey: ['cart', userId],
queryFn: () => CartService.getUserCart(userId),
enabled: !!userId,
});
정리하면 null 반환은
“요청을 실행하지 않음”을 표현하기 위한 수단이 아니라,
“실행 결과가 null임”을 나타내는 값입니다.
실행 조건 제어와 데이터 표현을 분리하는 것이
queryFn 설계를 안정적으로 유지하는 핵심입니다.
잘못된 패턴과 권장 패턴 비교
// ❌ 권장되지 않는 패턴
useQuery({
queryKey: ['cart', userId],
queryFn: () => {
if (!userId) return null;
return CartService.getUserCart(userId);
},
});
// ✅ 권장되는 패턴
useQuery({
queryKey: ['cart', userId],
queryFn: () => CartService.getUserCart(userId),
enabled: !!userId,
});
이처럼 실행 조건은 enabled로, 데이터 요청은 queryFn으로 분리하면 각 책임이 명확해지고, 캐시 구조와 무효화 전략을 예측 가능하게 유지할 수 있습니다.
queryFn의 책임 범위 정리
queryFn이 책임져야 할 것은 명확합니다.
서버 요청 수행, 요청에 필요한 파라미터 전달, 그리고 서버에서 발생한 에러를 TanStack Query 계층으로 전달하는 역할입니다.
queryFn은 서버와의 통신 결과를 왜곡하지 않고 그대로 반환해야 하며,
캐시 계층이 그 결과를 판단할 수 있도록 돕는 위치에 놓여 있습니다.
반대로 인증 여부 판단, UI 상태 분기, 실행 조건 제어와 같은 로직은 queryFn의 책임이 아닙니다.
이러한 로직이 queryFn 내부로 유입되기 시작하면,
캐시 단위가 불분명해지고 쿼리 재사용성과 무효화 전략이 함께 흐려집니다.
역할 분리가 유지될수록 캐시 구조는 단순해지고 예측 가능해집니다.
실무에서 자주 쓰는 queryFn 설계 패턴
실무에서는 queryFn 내부에서 직접 API를 호출하기보다, 서비스 레이어를 분리해 호출하는 패턴이 자주 사용됩니다. 이 방식의 핵심은 queryFn을 “요청 실행자”로 한정하고, 실제 데이터 접근 방식이나 구현 세부 사항은 서비스 레이어로 숨기는 데 있습니다.
export function useCart(userId: string) {
return useQuery({
queryKey: ['cart', userId],
queryFn: () => CartService.getUserCart(userId),
enabled: !!userId,
});
}
이 패턴에서는 queryKey가 데이터의 범위를 정의하고, queryFn은 “어떤 서비스를 호출할지”만 결정합니다. CartService 내부 구현이 REST API이든, Firebase이든, 혹은 다른 데이터 소스이든 관계없이 queryFn의 형태는 변하지 않습니다.
그 결과 UI 컴포넌트와 서버 통신 로직이 직접 결합되지 않으며, queryFn은 테스트와 교체가 쉬운 얇은 계층으로 유지됩니다. 또한 동일한 서비스 함수를 다른 쿼리나 mutation에서도 재사용할 수 있어, 데이터 접근 규칙을 한 곳에서 관리할 수 있습니다.
이 구조에서는 queryKey, queryFn, 서비스 로직이 각각의 책임을 명확히 가지며 코드 레벨에서 고정됩니다. queryKey는 캐시 구조를, queryFn은 실행 계약을, 서비스 레이어는 실제 데이터 접근을 담당하게 되어, 애플리케이션 규모가 커져도 설계 일관성을 유지하기 쉬워집니다.
정리
queryFn은 자유롭게 작성하는 함수가 아니라, TanStack Query 캐시 모델과 계약된 실행 인터페이스입니다. 이 계약이 흐려지기 시작하면 캐시 구조와 실행 흐름은 빠르게 복잡해집니다.
queryFn 설계 핵심 요약
- queryKey: 캐시의 범위를 정의한다
- enabled: 쿼리의 실행 가능 여부를 판단한다
- queryFn: 실행이 결정된 이후, 서버 요청만 수행한다
이 세 가지 책임이 섞이기 시작하면 TanStack Query의 캐시 모델은 예측하기 어려워집니다. 반대로 역할 분리가 유지될수록 캐시 구조는 단순해지고, 쿼리의 동작은 안정적으로 추적할 수 있게 됩니다.
FAQ
Q. queryFn 안에서 try/catch를 써야 하나요?
에러를 가공해야 하는 특별한 이유가 없다면,
에러를 그대로 throw하는 편이 TanStack Query의 에러 처리 흐름과 잘 맞습니다.
Q. queryFn에서 userId를 직접 받아도 되나요?
가능합니다. 다만 실행 조건과 파라미터 전달의 책임을 명확히 분리하는 것이 중요합니다.
Q. queryFn에서 여러 API를 호출해도 되나요?
기술적으로는 가능하지만,
캐시 단위와 데이터 책임이 모호해질 수 있으므로 주의가 필요합니다.
Q. enabled 없이 queryFn 조건 분기만으로 충분한가요?
가능은 하지만,
실행 여부와 실행 로직을 분리하지 않으면 구조가 복잡해질 수 있습니다.
Q. queryFn은 항상 Promise를 반환해야 하나요?
네, queryFn은 비동기 결과를 반환해야 하며,
동기 값은 내부적으로 Promise로 래핑됩니다.
Q. 면접에서 queryFn의 역할을 한 문장으로 설명하라고 하면 어떻게 답하나요?
queryFn은 캐시 계층이 실행을 결정한 이후, 실제 서버 요청을 수행하고 그 결과를 캐시로 전달하는 계약된 실행 함수라고 설명할 수 있습니다.