콘텐츠로 이동

Query 추상화: Custom Hook vs queryOptions

이전 글에서 API 레이어 설계와 TanStack Query 도입까지 다뤘습니다. 이번 글에서는 TanStack Query를 사용할 때 쿼리 로직을 어떻게 추상화할 것인가에 대해 다룹니다.

가장 흔한 접근은 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)을 모두 올바르게 연결하는 건 현실적으로 매우 어렵습니다.

타입 문제 외에도 세 가지 구조적 한계가 있습니다.

React 훅은 컴포넌트나 다른 훅 내부에서만 호출 가능합니다. 하지만 쿼리 설정이 필요한 곳은 훨씬 다양합니다.

// 라우트 로더에서 프리페칭 — 훅 사용 불가
const loader = async ({ params }) => {
await queryClient.ensureQueryData(/* 여기서 useInvoice를 쓸 수 없음 */);
};
// 이벤트 핸들러에서 프리페칭 — 훅 사용 불가
const handleHover = () => {
queryClient.prefetchQuery(/* 여기서도 사용 불가 */);
};

2. 로직 공유가 아니라 설정 공유

섹션 제목: “2. 로직 공유가 아니라 설정 공유”

커스텀 훅의 본래 목적은 상태를 포함한 로직의 재사용입니다. 하지만 쿼리 추상화에서 공유하고 싶은 건 queryKeyqueryFn이라는 설정값입니다. 훅이라는 도구가 이 목적에 맞지 않습니다.

useQuery로 감싸면 그 훅은 useQuery 전용입니다.

// useSuspenseQuery를 쓰고 싶다면? 새 훅을 만들어야 함
function useSuspenseInvoice(id: number) {
return useSuspenseQuery({
queryKey: ["invoice", id],
queryFn: () => fetchInvoice(id),
});
}
// useQueries로 병렬 호출하고 싶다면? 또 다른 방법이 필요
useQueries({
queries: ids.map((id) => /* useInvoice를 여기에 끼울 수 없음 */),
});

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는 모든 사용처가 공유해야 하는 queryKeyqueryFn만 담고, 나머지는 각자 필요한 곳에서 추가합니다.

Custom Hook (useInvoice)queryOptions (invoiceOptions)
사용 환경컴포넌트/훅 내부만어디서든
구현 유연성useQuery 종속useQuery, useSuspenseQuery, useQueries 등 자유
타입 안전성제네릭 연결이 복잡자동 추론
옵션 확장파라미터 추가 또는 제네릭 지옥스프레드로 합성
본질훅(로직 공유 도구)설정 객체(설정 공유 도구)

최고의 추상화는 설정 불가능합니다.

invoiceOptions가 좋은 추상화인 이유는, 공유해야 할 최소한의 설정(queryKey, queryFn)만 포함하고 나머지는 사용처에 위임하기 때문입니다. 커스텀 훅으로 모든 옵션을 받으려고 하면 인터페이스가 복잡해지고, 받지 않으면 유연성이 떨어집니다.

쿼리 설정을 공유하고 싶다면 훅이 아니라 옵션 팩토리를 만드세요. 도구의 목적에 맞는 추상화를 선택하는 것이 중요합니다.

참고: TkDodo - Creating Query Abstractions