
Query Factory
최근 사내 프로젝트를 진행하면서 Tanstack Query 기반의 queryKey 관리 문제를 인식하고, queryKey factory 및 query Factory (queryOptions 기반) 패턴으로 개선해 나간 경험을 공유합니다.
바로 내용에 들어가기에 앞서, tanstack Query에 대해 조사한 내용을 얘기해보려 해요.
- Powerful asynchronous state management for TS/JS, React, Solid, Vue, Svelte and Angular
tanstack Query 홈페이지 첫 문장인데요. 강력한 비동기 상태 관리 라이브러리라고 소개를 하고 있어요. 그 아래 문장은
- Toss out that granular state management, manual refetching and endless bowls of async-spaghetti code. TanStack Query gives you declarative, always-up-to-date auto-managed queries and mutations that directly improve both your developer and user experiences.
복잡한 상태 관리와 수동으로 refetch하는 로직, 비동기 스파게티 코드를 없애고 선언적이며 자동으로 관리되는 query와 mutation을 통해 DX와 UX 모두를 향상시킨다는 것이에요.
- 서버 상태 중심의 데이터 흐름을 단순화
- 자동 캐싱과 무효화 -> 성능 향상 및 UX 개선
- 선언적이고 재사용 가능한 API -> 유지보수와 협업 효율 증가
그렇다면 queryKey 관리 문제가 있었다고 했는데, queryKey는 뭘까요?
TanStack Query에서 queryKey는 각 쿼리를 고유하게 식별하기 위한 의존성 식별자에요.
마치 React의 useEffect
에서 의존성 배열이 바뀔 때 effect가 다시 실행되듯이,
Svelte의 $effect
블록에서 반응형 값이 변경될 때 블록이 다시 실행되는 것처럼, queryKey가 바뀌면 queryFn도 다시 실행되죠.
예를 들어,
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId);
});
이 queryKey
는 이 쿼리를 고유하게 식별하는 ID 역할을 하며, invalidateQueries
, refetchQueries
등의 API에서도 기준이 되죠.
하지만 queryKey를 위 처럼 하드코딩해서 관리할 경우, 어디에서 어떤 키가 쓰이는지, 키 중복, 네이밍 불일치, 오타 발생 가능, queryClient를 통한 캐시 무효화가 어려움 등의 문제들이 발생할 수 있어요. 따라서 이렇게 관리하는 방법을 벗어나 queryKey factory 및 queryOptions 기반 구조로 비동기 상태 관리를 구조화한 경험에 대해 공유하려고 해요.
1. 문제 인식: queryKey 하드코딩으로 인한 유지보수 문제
Tanstack Query를 도입한 이후, 각 쿼리에 고유한 queryKey를 지정하는 방식으로 서버 상태를 관리하고 있었는데, 프로젝트가 커지고 팀원과의 협업이 늘어남에 따라 다음과 같은 문제가 점차 드러났어요.
queryClient.invalidateQueries
나refetchQueries
호출 시, 해당 queryKey를 정확히 기억하거나 추적해야 함 -> 생산성 저하queryKey
하드코딩에 따른 중복 정의, 오타, 명명 규칙, 불일치로 의도하지 않은 캐시 무효화queryKey
가 문자열 배열로 직접 작성되어 있어 자동완성 미지원, 코드 추적 어려움으로 인한 DX 저하
export const useLicenseList = () => {
return createQuery<License[]>({
queryKey: ['license', 'list'], // 하드코딩된 queryKey
queryFn: fetchLicenseList,
});
};
이러한 코드가 늘어날 수록
- 동일한 쿼리를 여러 곳에서 사용할 때 queryKey를 매번 수동으로 작성해야하고,
invalidateQueries
시 정확한 키를 찾기 위해 코드 베이스를 훑어야하고,- 여러 명의 개발자가 각자 키를 정의할 경우 일관성 없는 네이밍 발생등의 문제가 생겨요.
그래서 queryKey
를 구조화하고 체계적으로 관리할 필요성을 느끼게 되었어요.
2. QueryKey 계층화 및 queryKey factory 도입
우선 queryKey
를 구조화하고, 반복 선언을 줄이며 일관성을 유지할 수 있도록 queryKey factory 패턴을 도입했어요.
- 도메인 단위 분리: 예를 들어
[...lincenseKeys.all, 'list']
,[...licenseKeys.all, 'detail', id]
형태로 구조화 - 함수형 키 생성: queryKey를 직접 문자열 배열로 쓰지 않고, 함수를 통해 생성
- 일관된 네이밍 규칙: 도메인, 세부 유형, 식별자 순으로 구성하여 무효화 범위 정밀 제어
export const licenseKeys = {
all: ['license'] as const,
list: () => [...licenseKeys.all, 'list'] as const,
detail: (id: string) => [...licenseKeys.all, 'detail', id] as const,
review: (id: string) => [...licenseKeys.all, 'review', id] as const,
};
이 형태로 정의한 키는 명확한 무효화 범위 지정이 가능해요.
$updateLicense.mutate(payload, {
onSuccess: () => {
onclose();
queryClient.invalidateQueries({ queryKey: licenseKeys.detail(id) });
toast.success('라이선스가 수정되었습니다.');
},
});
이렇게 queryKey factory로 관리할 시 다음과 같은 이점도 생겼어요.
- queryKey 중복 및 오타 방지 -> 타입 안정성과 자동완성 향상
- invalidate / refetch 시 해당 키를 외부에서 쉽게 참조 가능 -> 생산성 향상
- queryKey의 계층 구조 덕분에 특정 도메인 전체 무효화 가능 -> ex:
licenseKey.all()
3. queryOptions 기반 Query Factory 패턴으로의 전환
queryKey를 factory로 관리하는 것처럼, queryFn과 옵션(staleTime 등)도 객체로 추상화하여 상수처럼 재사용하는 패턴이 있는데요. 자바스크립트에선 다음과 같이 잘 동작해요.
const licenseQuery = {
queryKey: ['license'],
queryFn: fetchLicense,
staleTime: 5000,
};
const licenseData = createQuery(licenseQuery);
하지만 타입스크립트에선 이 방식에 단점이 있어요. ts는 구조적 타입 시스템(structural typing)을 기반으로 해서, 인라인 객체에 대해서는 오타나 잘못된 필드를 즉시 경고하지만 사전 선언된 객체에서는 오류를 감지하지 못해요.
// 오타가 있는 경우 - 인라인 선언 시 오류 발생
createQuery({
queryKey: ['license'],
queryFn: fetchLicense,
stateTime: 6 * 1000, // X staleTime 오타 - 오류 감지
});
// 상수 객체로 분리했을 경우 오류 감지 안됨
const licenseQuery = {
queryKey: ['license'],
queryFn: fetchLicense,
stateTime: 6 * 1000, // X 오류 무시됨
};
createQuery(licenseQuery); // 타입 오류 발생하지 않음
또한 queryClient.getQueryData(...)
를 사용할 때도 반환 타입이 unknown으로 추론되어 명확하지 않으며, 직접 제네릭 타입을 지정해야 하는 불편함이 있어요. 그러나 이 문제는 tanstack-query v5의 queryOptions가 나오면서 해결됐죠.
queryOptions란?
queryOptions
라는 헬퍼 함수는 런타임에선 단순한 identity 함수처럼 동작하지만, 타입 레벨에서는 queryKey와 queryFn을 연결하여 다음과 같은 이점을 제공해요.
- 잘못된 옵션 필드 작성 시 즉시 타입 오류 발생 (ex: stateTime -> staleTime 오타 감지)
getQueryData
,setQueryData
등에서 반환 타입을 자동으로 추론queryKey
와queryFn
이 묶여 있으므로, 캐시 키를 기반으로 정확한 데이터 타입 추론 가능
const licenseQuery = queryOptions({
queryKey: ['license'],
queryFn: fetchLicense,
staleTime: 6 * 1000,
});
const data = queryClient.getQueryData(licenseQuery.queryKey);
queryKey factory는 queryKey의 재사용성과 일관성을 높였지만, queryFn과의 결합도는 낮았어요. 그래서 queryOptions
를 활용한 Query Factory 패턴으로 확장 해봤어요.
이 구조의 핵심은 다음과 같아요.
queryKey
,queryFn
,stateTime
,enabled
등을 하나의 객체로 선언queryKey
와queryFn
의 응집력 향상 -> DX 및 유지보수성 개선- React 한정 -> useQuery, useSuspenseQuery, prefetchQuery 등 여러 메서드에 일관되게 전달 가능
import { queryOptions } from '@tanstack/svelte-query';
export const licenseQueries = {
keys: {
all: ['license'] as const,
list: () => [...licenseKeys.all, 'list'] as const,
detail: (id: string) => [...licenseKeys.all, 'detail', id] as const,
review: (id: string) => [...licenseKeys.all, 'review', id] as const,
},
list: () =>
queryOptions({
queryKey: licenseQueries.keys.list(),
queryFn: fetchLicenseList,
staleTime: 60 * 1000,
}),
detail: (id: string) =>
queryOptions({
queryKey: licenseQueries.keys.detail(id),
queryFn: () => fetchLicenseDetail(id),
enabled: !!id,
}),
review: (id: string) =>
queryOptions({
queryKey: licenseQueries.keys.review(id),
queryFn: () => fetchLicenseReview(id),
enabled: !!id,
}),
};
이 패턴은 React 환경에서는 커스텀 훅을 대체할 정도로 간결성과 재사용성이 좋아졌고, Svelte 환경에서는 createQuery 기반으로도 쉽게 모듈화가 가능해졌어요.
4. Svelte 환경에서의 적용: createQuery 기반 구조화
React 환경에서는 queryOptions 객체를 그대로 useQuery
, useSuspenseQuery
, getQueryData
등에 넘길 수 있지만,
Svelte Query는 CreateQuery
를 통해 명시적인 스토어 등록 방식을 체택해요. 따라서 다음과 같이 구조화 해서 사용했어요.
- queryOptions 기반 Query Factory 설계 유지: 기존의
licenseQueries
와 같이queryKey
,queryFn
,staleTime
,enabled
등을 모두 포함하는 queryOptions 기반 구조는 그대로 유지해요.
import { queryOptions } from '@tanstack/svelte-query';
export const licenseQueries = {
keys: {
all: ['license'] as const,
detail: (id: string) => [...licenseKeys.all, 'detail', id] as const,
},
detail: (id: string) =>
queryOptions({
queryKey: licenseQueries.keys.detail(id),
queryFn: () => fetchLicenseDetail(id),
enabled: !!id,
}),
};
- 각 queryOptions를 기반으로 createQuery를 래핑한 훅 생성: Svelte에서는 상태 구독이 필요한 만큼 createQuery(…)를 통해 반환되는 store를 사용할 수 있도록 별도 훅으로 분리해요.
import { createQuery } from '@tanstack/svelte-query';
import { licenseQueries } from '../queries/licenseQueries';
export function useLicenseDetail(id: string) {
return createQuery(licenseQueries.detail(id));
}
- Svelte 컴포넌트에서 사용 예시
<script lang="ts">
import { useLicenseDetail } from '$lib/hooks/useLicenseQuery';
const licenseQuery = useLicenseDetail('abc123');
</script>
{#if $licenseQuery.isPending}
<p>로딩 중...</p>
{:else if $licenseQuery.isError}
<p>오류 발생: {$licenseQuery.error.message}</p>
{:else}
<h2>{$licenseQuery.data.name}</h2>
<p>유효기간: {$licenseQuery.data.expirationDate}</p>
{/if}
이렇게 React 처럼 다양한 메서드를 사용하는 대신, Svelte에서는 명시적으로 createQuery(…)에 넣어 사용함으로써 queryOptions 기반 설계 철학은 그대로 유지하고 Svelte에 맞는 방식으로 적용하였어요.
5. 마무리 하며
이번 프로젝트에서 Tanstack Query의 queryKey 하드코딩 문제를 발견하고, 이를 계층적인 queryKey factory -> queryOptions 기반 Query Factory 구조로 발전시켜 나간 과정은 단순한 코드 리팩토링을 넘어서 팀 전체가 tantack query에 대해 좀 더 깊이 있는 이해를 할 수 있는 좋은 계기가 되었어요.
전체 내용을 요약해보면
- queryKey를 구조화하고 factory 패턴으로 일원화함으로서 오타, 중복, 네이밍 불일치 문제를 예방했고,
- queryFn과 queryKey를 한 객체로 묶는 queryOptions 패턴을 도입하면서 코드 응집도가 높아졌어요.
- TS 환경에서 오타나 잘못된 속성 사용을 즉시 감지할 수 있게 되었고, 타입 추론이 정확하게 동작하여 DX가 개선되었어요.
도입 후 체감된 변화는
- 쿼리 관련 파일이 체계적으로 정리되어 신규 기능 개발이나 API 연동 시 빠른 속도로 개발이 가능해졌고,
- 팀원 간 공유되는 쿼리 구조가 명확해져 리뷰 속도를 향상시켰으며
- invalidateQueries, prefetchQuery 등도 queryKey를 외부에서 가져와 안전하게 호출 가능하게 되었어요.
처음에는 queryKey만 따로 모으는 수준의 개선을 생각했지만, Tanstack Query v5에서 제공하는 queryOptions 도입은 기대 이상으로 좋았어요. 특히 Svelte 처럼 hooks 추상화과 덜 일반화된 환경에서도 queryOptions + createQuery 조합은 높은 재사용성과 응집도를 제공하여 앞으로도 프로젝트 전반에서 일관된 패턴으로 활용 가능할 듯 해요.
비동기 상태 관리의 복잡도를 줄이고, 코드 예측 가능성과 일관성을 확보하는 것. 이번 Query Factory 패턴을 도입한 구조 전환은 이러한 목표에 더 가까워지는 듯한 작업이었어요.