2026년 03월 25일

5

Tanstack Query의 효율적인 추상화: queryOptions 활용

Tanstack Query

image

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개의 제네릭에 대한 정확한 타입 정의가 필요 => 타입 정의 복잡도 증가!

이 패턴은 표면적으로는 로직을 캡슐화한 것처럼 보이지만, 실무에서는 다음과 같은 세가지 문제점을 야기합니다.

1. 타입 추론의 복잡도 증가

options를 인자로 받는 순간, ts의 타입 추론이 불명확해지거나 unknown으로 처리되는 경우가 빈번합니다. 이를 해결하기 위해 복잡한 제네릭 정의가 수반되며, 이는 코드의 가독성을 저해합니다.

2. 훅의 규칙(Rules of Hooks) 제약

훅은 오직 React 컴포넌트 내부나 다른 훅 내부에서만 호출할 수 있습니다. 이는 이벤트 핸들러, Prefetching 로직, 혹은 최근 주목받는 Tanstack Router의 loader 단계에서 해당 로직을 재사용하는 것을 원천적으로 차단합니다.

3. 추상화 비용

단순히 설정을 공유하기 위해 매번 새로운 훅을 생성하는 것은 관리 포인트의 증가로 이어집니다.

해결책: queryOptions API를 통한 설정의 분리

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는 훅이 아닌 단순 함수 및 객체이기 때문에, 런타임 제약 없이 자유로운 호출이 가능해집니다.

1. 컴포넌트 내 데이터 조회

1useQuery(invoiceOptions(id))

2. 데이터 미리 가져오기 (prefetching)

1const queryClient = useQueryClient(); 2queryClient.prefetchQuery(invoiceOptions(id))

3. 캐시 확인 후 가져오기

1const queryClient = useQueryClient(); 2const data = await queryClient.ensureQueryData(invoiceOptions(id))

queryOptions 도입 시 얻게 되는 이점

queryOptions 사용은 다음과 같은 전략적 이점을 제공합니다.

  • 완벽한 타입 안정성 (Type Safety): 별도 제네릭 선언 없이도 select 옵션을 포함한 모든 데이터 흐름의 타입이 환벽하게 추론됩니다. 이는 개발자의 실수를 방지하고 생산성 향상에 직결됩니다.
1const { data: totalAmount } = useQuery({ 2 ...invoiceOptions(id), 3 select: (data) => data.amounts.reduce((a, b) => a + b, 0) // 타입 완벽 추론 4})
  • 컴포넌트 경계를 넘는 재사용성: 컴포넌트 외부의 이벤트 핸들러에서 데이터 prefetching을 수행하거나, 라우터 진입 시점에 데이터를 미리 로드하는 로직에서 동일한 설정을 그대로 사용할 수 있습니다.
  • 중앙 집중식 쿼리 관리: 쿼리 키와 페칭 함수를 단일 지점에서 관리함으로써, 프로젝트 전반의 데이터 일관성을 유지하기 용이합니다.

프론트엔드 개발에서 '추상화'는 양날의 검과 같습니다. 너무 얇으면 중복이 발생하고, 너무 두꺼우면 유연성을 잃습니다. Tkdodo는 그 중간 지점에서 queryOptions를 활용한 합리적인 의견을 제시했습니다.

queryOptions API는 쿼리 로직을 더 낮은 수준(Low-level)의 설정 데이터로 다룸으로써, 우리에게 더 높은 수준의 자유도와 안전성을 부여합니다.

커스텀 훅은 React 생명주기와 결합된 복잡한 로직이 필요할 때 사용하고, 단순한 데이터 페칭 정의라면 queryOptions를 고려해보면 어떨까요?

HS
@Hyungseok Kwon
Journey To Solution