Query 추상화: Custom Hook vs queryOptions
이전 글에서 API 레이어 설계와 TanStack Query 도입까지 다뤘습니다. 이번 글에서는 TanStack Query를 사용할 때 쿼리 로직을 어떻게 추상화할 것인가에 대해 다룹니다.
Custom Hook으로 감싸는 패턴
섹션 제목: “Custom Hook으로 감싸는 패턴”가장 흔한 접근은 useQuery를 커스텀 훅으로 감싸는 것입니다.
function useInvoice(id: number) { return useQuery({ queryKey: ["invoice", id], queryFn: () => fetchInvoice(id), });}queryKey 일관성을 보장하고, 중복 캐시를 방지하며, 타입도 자동 추론됩니다. 깔끔해 보이지만 문제는 옵션을 추가할 때 시작됩니다.
옵션 전달의 복잡성
섹션 제목: “옵션 전달의 복잡성”특정 컴포넌트에서 staleTime을 다르게 주고 싶다면?
function useInvoice(id: number, staleTime?: number) { return useQuery({ queryKey: ["invoice", id], queryFn: () => fetchInvoice(id), staleTime, });}하나는 괜찮지만, enabled, refetchInterval, select 등 필요한 옵션이 늘어날 때마다 파라미터가 계속 추가됩니다.
옵션 객체를 통째로 받으면?
섹션 제목: “옵션 객체를 통째로 받으면?”파라미터 증가를 막기 위해 UseQueryOptions를 직접 받는 방법을 시도할 수 있습니다.
function useInvoice(id: number, options?: Partial<UseQueryOptions>) { return useQuery({ queryKey: ["invoice", id], queryFn: () => fetchInvoice(id), ...options, });}그런데 UseQueryOptions의 기본 제네릭이 unknown이라 data 타입이 unknown으로 풀려버립니다. UseQueryOptions<Invoice>로 지정해도, select에서 반환 타입이 달라지면 타입 에러가 발생합니다.
// 타입 에러 발생useInvoice(1, { select: (invoice) => invoice.createdAt, // string을 반환하지만 Invoice를 기대});네 개의 제네릭(TQueryFnData, TError, TData, TQueryKey)을 모두 올바르게 연결하는 건 현실적으로 매우 어렵습니다.
Custom Hook의 근본적 한계
섹션 제목: “Custom Hook의 근본적 한계”타입 문제 외에도 세 가지 구조적 한계가 있습니다.
1. 훅은 훅에서만 쓸 수 있다
섹션 제목: “1. 훅은 훅에서만 쓸 수 있다”React 훅은 컴포넌트나 다른 훅 내부에서만 호출 가능합니다. 하지만 쿼리 설정이 필요한 곳은 훨씬 다양합니다.
// 라우트 로더에서 프리페칭 — 훅 사용 불가const loader = async ({ params }) => { await queryClient.ensureQueryData(/* 여기서 useInvoice를 쓸 수 없음 */);};
// 이벤트 핸들러에서 프리페칭 — 훅 사용 불가const handleHover = () => { queryClient.prefetchQuery(/* 여기서도 사용 불가 */);};2. 로직 공유가 아니라 설정 공유
섹션 제목: “2. 로직 공유가 아니라 설정 공유”커스텀 훅의 본래 목적은 상태를 포함한 로직의 재사용입니다. 하지만 쿼리 추상화에서 공유하고 싶은 건 queryKey와 queryFn이라는 설정값입니다. 훅이라는 도구가 이 목적에 맞지 않습니다.
3. 특정 구현에 종속
섹션 제목: “3. 특정 구현에 종속”useQuery로 감싸면 그 훅은 useQuery 전용입니다.
// useSuspenseQuery를 쓰고 싶다면? 새 훅을 만들어야 함function useSuspenseInvoice(id: number) { return useSuspenseQuery({ queryKey: ["invoice", id], queryFn: () => fetchInvoice(id), });}
// useQueries로 병렬 호출하고 싶다면? 또 다른 방법이 필요useQueries({ queries: ids.map((id) => /* useInvoice를 여기에 끼울 수 없음 */),});queryOptions: 더 나은 추상화
섹션 제목: “queryOptions: 더 나은 추상화”TanStack Query v5의 queryOptions는 이 모든 문제를 해결합니다.
import { queryOptions } from "@tanstack/react-query";
function invoiceOptions(id: number) { return queryOptions({ queryKey: ["invoice", id], queryFn: () => fetchInvoice(id), });}런타임에서는 입력받은 옵션 객체를 그대로 반환하는 identity 함수입니다. 실질적으로 return { queryKey, queryFn }과 같습니다.
타입 레벨에서는 DataTag 심볼을 통해 queryFn의 반환 타입 정보를 queryKey에 태깅합니다. 덕분에 어떤 훅에 넘기든 타입이 자동 추론됩니다.
어디서든 사용 가능
섹션 제목: “어디서든 사용 가능”// 컴포넌트에서const { data } = useQuery(invoiceOptions(1));// data: Invoice | undefined
// Suspense와 함께const { data } = useSuspenseQuery(invoiceOptions(2));// data: Invoice (undefined 아님)
// 라우트 로더에서const loader = async ({ params }) => { await queryClient.ensureQueryData(invoiceOptions(Number(params.id)));};
// 이벤트 핸들러에서const handleHover = (id: number) => { queryClient.prefetchQuery(invoiceOptions(id));};
// 병렬 쿼리에서useQueries({ queries: ids.map((id) => invoiceOptions(id)),});옵션 합성이 자연스럽다
섹션 제목: “옵션 합성이 자연스럽다”추가 옵션은 사용하는 곳에서 스프레드로 합성합니다.
const { data } = useQuery({ ...invoiceOptions(1), staleTime: 5 * 60 * 1000,});
const { data } = useQuery({ ...invoiceOptions(1), select: (invoice) => invoice.createdAt,});// data: string | undefined — select 반환 타입이 정확히 추론됨invoiceOptions는 모든 사용처가 공유해야 하는 queryKey와 queryFn만 담고, 나머지는 각자 필요한 곳에서 추가합니다.
비교 정리
섹션 제목: “비교 정리”Custom Hook (useInvoice) | queryOptions (invoiceOptions) | |
|---|---|---|
| 사용 환경 | 컴포넌트/훅 내부만 | 어디서든 |
| 구현 유연성 | useQuery 종속 | useQuery, useSuspenseQuery, useQueries 등 자유 |
| 타입 안전성 | 제네릭 연결이 복잡 | 자동 추론 |
| 옵션 확장 | 파라미터 추가 또는 제네릭 지옥 | 스프레드로 합성 |
| 본질 | 훅(로직 공유 도구) | 설정 객체(설정 공유 도구) |
핵심 원칙
섹션 제목: “핵심 원칙”최고의 추상화는 설정 불가능합니다.
invoiceOptions가 좋은 추상화인 이유는, 공유해야 할 최소한의 설정(queryKey, queryFn)만 포함하고 나머지는 사용처에 위임하기 때문입니다. 커스텀 훅으로 모든 옵션을 받으려고 하면 인터페이스가 복잡해지고, 받지 않으면 유연성이 떨어집니다.
쿼리 설정을 공유하고 싶다면 훅이 아니라 옵션 팩토리를 만드세요. 도구의 목적에 맞는 추상화를 선택하는 것이 중요합니다.