TanStack Query: Query Key Factory에서 Query Factory로 전환하는 실무 가이드
라이브러리 하나가 멈췄을 때, 우리는 그 위에 쌓아 올린 코드 전체를 다시 바라보게 됩니다. @lukemorales/query-key-factory가 TanStack Query v5를 공식 지원하지 않는다는 사실을 마이그레이션 도중 마주쳤을 때, 그 당혹감은 꽤 컸습니다. 그런데 막상 걷어내고 나니, 오히려 더 단단한 구조가 남았습니다. 이 글은 그 전환 과정을 코드 레벨로 기록한 실무 가이드입니다.
메타설명이 아닌 도입부
라이브러리 하나가 멈췄을 때, 우리는 그 위에 쌓아 올린 코드 전체를 다시 바라보게 됩니다. @lukemorales/query-key-factory가 TanStack Query v5를 공식 지원하지 않는다는 사실을 마이그레이션 도중 마주쳤을 때, 그 당혹감은 꽤 컸습니다. 그런데 막상 걷어내고 나니, 오히려 더 단단한 구조가 남았습니다. 이 글은 그 전환 과정을 코드 레벨로 기록한 실무 가이드입니다.
왜 지금 전환해야 하는가: 라이브러리 지원 중단 문제
@lukemorales/query-key-factory는 query key 관리를 체계화하는 데 분명한 공헌을 했습니다. createQueryKeys, mergeQueryKeys API는 계층적 키 구조와 TypeScript 타입 안전성을 동시에 제공하며 많은 팀에서 표준처럼 사용했습니다.
그러나 현재 상황은 다릅니다.
- Issue #73이 열린 채 해결되지 않고 있습니다 — TanStack Query v5 호환성 문제입니다.
- v5 지원을 위한 PR이 제출됐지만 닫힌 상태입니다.
- 메인테이너의 활동이 사실상 중단된 것으로 보입니다.
TanStack Query v5는 queryOptions()라는 강력한 내장 유틸리티를 새로 도입했습니다. 이 함수는 query key factory 패턴이 외부 라이브러리로 해결하려 했던 문제들을 TanStack Query 자체가 직접 해결하는 방향으로 설계됐습니다. 전환의 이유가 단순한 "더 좋은 것"이 아니라, 외부 의존성 리스크 제거라는 점에서 타이밍이 맞습니다.
Before/After: 코드로 보는 전환의 실체
Before — createQueryKeys 기반 패턴
// features/events/queries/eventKeys.ts
import { createQueryKeys } from '@lukemorales/query-key-factory';
export const eventKeys = createQueryKeys('events', {
all: null,
lists: null,
list: (filters: EventFilter) => ({ filters }),
details: null,
detail: (id: string) => ({ id }),
});
// features/events/queries/eventQueries.ts
import { useQuery } from '@tanstack/react-query';
import { eventKeys } from './eventKeys';
import { fetchEventDetail } from '../api';
export const useEventDetail = (id: string) =>
useQuery({
queryKey: eventKeys.detail(id).queryKey,
queryFn: () => fetchEventDetail(id),
});
이 방식은 _def, queryKey 프로퍼티 등 라이브러리가 주입하는 내부 구조에 의존합니다. v5로 업그레이드하면 eventKeys.detail(id).queryKey가 올바른 타입을 반환하지 못하거나 런타임 오류를 발생시킵니다.
After — queryOptions() 기반 Query Factory 패턴
// features/events/api/eventQueries.ts
import { queryOptions } from '@tanstack/react-query';
import { fetchEventList, fetchEventDetail } from './eventApi';
const eventQueryKeys = {
all: () => ['events'] as const,
lists: () => [...eventQueryKeys.all(), 'list'] as const,
list: (filters: EventFilter) => [...eventQueryKeys.lists(), filters] as const,
details: () => [...eventQueryKeys.all(), 'detail'] as const,
detail: (id: string) => [...eventQueryKeys.details(), id] as const,
};
export const eventQueries = {
list: (filters: EventFilter) =>
queryOptions({
queryKey: eventQueryKeys.list(filters),
queryFn: () => fetchEventList(filters),
staleTime: 1000 * 60 * 5,
}),
getEventDetails: (id: string) =>
queryOptions({
queryKey: eventQueryKeys.detail(id),
queryFn: () => fetchEventDetail(id),
staleTime: 1000 * 60 * 10,
}),
};
// 호출부 — 변경 없음
import { useQuery } from '@tanstack/react-query';
import { eventQueries } from '@/features/events/api/eventQueries';
export const EventDetailPage = ({ id }: { id: string }) => {
const { data } = useQuery(eventQueries.getEventDetails(id));
// ↑ 스프레드 없이도 동일하게 동작
return <div>{data?.title}</div>;
};
핵심 포인트는 호출부가 그대로 유지된다는 것입니다. ...eventQueries.getEventDetails(id) 스프레드 방식도, 인자 없이 직접 전달하는 방식도 모두 useQuery가 받아들입니다. 마이그레이션 비용이 호출부까지 전파되지 않습니다.
_def 없이 계층 키를 재현하는 패턴
@lukemorales/query-key-factory의 killer feature 중 하나는 invalidateQueries에서의 계층적 부분 매칭이었습니다. eventKeys.all을 무효화하면 하위 모든 키가 함께 무효화됐죠. 순수 객체 방식으로도 동일하게 구현할 수 있습니다.
// 계층 구조 유지 — _def 없이
const eventQueryKeys = {
all: () => ['events'] as const,
lists: () => [...eventQueryKeys.all(), 'list'] as const,
list: (filters: EventFilter) => [...eventQueryKeys.lists(), filters] as const,
details: () => [...eventQueryKeys.all(), 'detail'] as const,
detail: (id: string) => [...eventQueryKeys.details(), id] as const,
};
// invalidateQueries 계층 제어 — 완전히 동일하게 동작
queryClient.invalidateQueries({ queryKey: eventQueryKeys.all() });
// → ['events'] 로 시작하는 모든 키 무효화
queryClient.invalidateQueries({ queryKey: eventQueryKeys.lists() });
// → ['events', 'list'] 로 시작하는 키만 무효화
queryClient.invalidateQueries({ queryKey: eventQueryKeys.detail('123') });
// → ['events', 'detail', '123'] 정확히 일치하는 키만 무효화
TanStack Query의 partial matching은 라이브러리 기능이 아니라 쿼리 엔진 자체의 기능입니다. _def 프로퍼티나 queryKey 래퍼 없이, 배열 앞부분이 일치하면 자동으로 매칭됩니다. 외부 라이브러리가 해결하는 척했던 것이 사실은 TanStack Query가 원래부터 지원하던 것이었습니다.
TypeScript end-to-end 타입 안전성: 외부 라이브러리 없이 확보하는 원리
queryOptions()가 반환하는 객체는 QueryObserverOptions 타입을 완전히 추론합니다. 이 타입 정보는 queryFn의 반환 타입에서 시작해 useQuery, getQueryData, setQueryData, prefetchQuery 전반에 흐릅니다.
// queryOptions의 타입 흐름
const detailOptions = eventQueries.getEventDetails('123');
// detailOptions.queryKey: readonly ['events', 'detail', '123']
// detailOptions.queryFn: () => Promise<EventDetail>
// useQuery — data 타입이 EventDetail로 추론됨
const { data } = useQuery(detailOptions);
// data: EventDetail | undefined ✅
// getQueryData — 별도 제네릭 없이 타입 추론
const cached = queryClient.getQueryData(detailOptions.queryKey);
// cached: EventDetail | undefined ✅
// setQueryData — 잘못된 타입 할당 시 컴파일 오류
queryClient.setQueryData(detailOptions.queryKey, (old) => ({
...old,
title: 42, // ❌ Type 'number' is not assignable to type 'string'
}));
기존 createQueryKeys 방식에서는 getQueryData<EventDetail>(eventKeys.detail('123').queryKey) 처럼 제네릭을 수동으로 명시해야 했습니다. 제네릭을 잘못 적어도 컴파일러가 잡아주지 못했습니다. queryOptions() 방식은 queryFn의 반환 타입이 키와 함께 묶여 이동하기 때문에, 중간 어디서도 타입이 유실되지 않습니다.
as const로 선언된 key 배열과 함께 사용하면 invalidateQueries 호출에서도 narrowed literal type이 유지됩니다.
Feature-Sliced Design과 Query Factory 위치 결정 기준
마이그레이션을 진행하면서 자주 묻게 되는 질문이 있습니다. "이 query factory 파일, 어디에 두어야 하나?"
Feature-Sliced Design(FSD) 기준으로 정리하면 다음과 같습니다.
shared/api 에 두어야 할 것
- 여러 feature에서 공통으로 참조되는 기반 쿼리
- 예: 사용자 세션 쿼리, 공통 코드 쿼리, 설정 데이터 쿼리
- 특정 도메인 모델에 결합되지 않은 유틸성 fetch 래퍼
shared/
api/
sessionQueries.ts ← 어느 feature에서도 import 가능
configQueries.ts
entities/api 에 두어야 할 것
- 도메인 엔티티 단위로 정의되는 CRUD 쿼리
- 다른 feature에서 해당 엔티티 데이터를 읽어야 할 때 참조
invalidateQueries호출 주체가 다른 feature일 수 있는 경우
entities/
event/
api/
eventQueries.ts ← event 엔티티의 공식 query factory
features/api 에 두어야 할 것
- 특정 기능 흐름에만 존재하는 복합 쿼리
- 해당 feature 외부에서 참조되지 않을 것이 명확한 경우
features/
event-registration/
api/
registrationQueries.ts ← 이 feature에서만 사용
실무 판단 기준은 하나입니다. invalidateQueries를 누가 호출하느냐. 만약 mutation이 다른 feature에 있고 그 mutation이 이 쿼리를 무효화해야 한다면, query factory는 entities/ 혹은 shared/로 올라가야 합니다. 단방향 의존성을 지키기 위해서입니다.
결론: 의존성을 줄이면 구조가 보인다
전환을 정리하면 다음과 같습니다.
- 전환 트리거:
@lukemorales/query-key-factoryv5 미지원(Issue #73, 지원 PR 닫힘) - 대체 수단: TanStack Query v5 내장
queryOptions()유틸리티 - 마이그레이션 비용: 호출부(
useQuery(eventQueries.getEventDetails(id))) 변경 없음 - 계층 키 재현: 순수 객체 +
as const배열 체이닝으로_def없이 동일 동작 - 타입 안전성:
queryFn반환 타입이 키와 함께 이동,getQueryData/setQueryData까지 추론 자동화 - 위치 기준:
invalidateQueries호출 주체를 기준으로shared/entities/features레이어 결정
외부 라이브러리는 문제를 해결하기 위해 도입하지만, 때로는 그 라이브러리를 걷어내는 과정에서 원래 기술이 얼마나 성숙했는지를 재발견합니다. TanStack Query v5의 queryOptions()가 정확히 그런 경우입니다.
지금 v5로 마이그레이션을 준비 중이라면, query key factory를 query factory로 전환하는 것을 마이그레이션 체크리스트에 함께 포함시키길 권장합니다. 나중에 할 일을 지금 해두는 것뿐입니다.
댓글
댓글을 불러오는 중…
