2026년 03월 25일
Tanstack Query

Tanstack Query를 사용하는 많은 팀이 공통적으로 겪는 아키텍처적 난제는 쿼리 로직의 추상화 입니다.
현재 제가 다니는 회사는 그동안 코드 재사용을 위해 useQuery를 커스텀 훅으로 감싸는 방식을 택해왔으나, 프로젝트가 거대해질수록 이 방식은 오히려 유연성을 저해하는 요소가 되었습니다.
오늘은 Tanstack Query의 메인 유지보수자인 TkDodo가 제시한 인사이트를 바탕으로, 더 견고한 아키텍처를 위한 queryOptions API 활용 전략을 간단하게 요약해보고자 합니다.
원문: https://tkdodo.eu/blog/creating-query-abstractions
지금까지 많은 개발팀이 선택했던 방식입니다.
1function useInvoce(id: number, options?: Partial<UseQueryOptions>){ 2 return useQuery({ 3 queryKey: ['invoce', id], 4 queryFn: () => fetchInvoce(id), 5 ...options 6 }) 7} 8 9const { data } = useInvoce(queryId, { throwOnError: true, enabled: !!queryId }) 10// data: unknown 11 12// 정확한 타입 추론을 하려면 UseQueryOptions<A, B, C, D>와 같이 13// 4개의 제네릭에 대한 정확한 타입 정의가 필요 => 타입 정의 복잡도 증가!
이 패턴은 표면적으로는 로직을 캡슐화한 것처럼 보이지만, 실무에서는 다음과 같은 세가지 문제점을 야기합니다.
options를 인자로 받는 순간, ts의 타입 추론이 불명확해지거나 unknown으로 처리되는 경우가 빈번합니다. 이를 해결하기 위해 복잡한 제네릭 정의가 수반되며, 이는 코드의 가독성을 저해합니다.
훅은 오직 React 컴포넌트 내부나 다른 훅 내부에서만 호출할 수 있습니다. 이는 이벤트 핸들러, Prefetching 로직, 혹은 최근 주목받는 Tanstack Router의 loader 단계에서 해당 로직을 재사용하는 것을 원천적으로 차단합니다.
단순히 설정을 공유하기 위해 매번 새로운 훅을 생성하는 것은 관리 포인트의 증가로 이어집니다.
TkDodo는 쿼리의 '실행'과 '설정'을 분리할 것을 권장합니다. queryOptions는 쿼리에 필요한 핵심 설정을 객체 형태로 정의하여 어디서든 재사용할 수 있게 돕는 도구입니다.
1import { queryOptions } from '@tanstack/react-query' 2 3// 1. 설정 정의 (컴포넌트 외부에서도 사용 가능) 4export const invoiceOptions = (id: number) => queryOptions({ 5 queryKey: ['invoices'] as const, 6 queryFn: () => fetchInvoice(id), 7}) 8 9// or 객체로 정의 10export const invoiceQueries = { 11 all: () => ['invoices'] as consts, 12 lists: () => queryOptions({ 13 queryKey: [...invoicequeries.all(), 'list'], 14 queryFn: fetchInvoices, 15 }), 16 detail: (id: number) => queryOptions({ 17 queryKey: [...invoiceQueries.all(), id], 18 queryFn: () => fetchInvoice(id), 19 }) 20} 21 22// 2. 사용 (필요한 곳에서 스프레드로 조합) 23const invoiceQuery = useQuery({ 24 ...invoiceOptions(1), 25 select: (data) => data.amount, 26 staleTime: 1000 * 60, 27})
이 queryOptions는 훅이 아닌 단순 함수 및 객체이기 때문에, 런타임 제약 없이 자유로운 호출이 가능해집니다.
1useQuery(invoiceOptions(id))
1const queryClient = useQueryClient(); 2queryClient.prefetchQuery(invoiceOptions(id))
1const queryClient = useQueryClient(); 2const data = await queryClient.ensureQueryData(invoiceOptions(id))
queryOptions 사용은 다음과 같은 전략적 이점을 제공합니다.
1const { data: totalAmount } = useQuery({ 2 ...invoiceOptions(id), 3 select: (data) => data.amounts.reduce((a, b) => a + b, 0) // 타입 완벽 추론 4})
프론트엔드 개발에서 '추상화'는 양날의 검과 같습니다. 너무 얇으면 중복이 발생하고, 너무 두꺼우면 유연성을 잃습니다. Tkdodo는 그 중간 지점에서 queryOptions를 활용한 합리적인 의견을 제시했습니다.
queryOptions API는 쿼리 로직을 더 낮은 수준(Low-level)의 설정 데이터로 다룸으로써, 우리에게 더 높은 수준의 자유도와 안전성을 부여합니다.
커스텀 훅은 React 생명주기와 결합된 복잡한 로직이 필요할 때 사용하고, 단순한 데이터 페칭 정의라면 queryOptions를 고려해보면 어떨까요?