1. 들어가며: 쿼리 키가 왜 중요한가
React Query(TanStack Query)를 처음 도입하면 대부분 이런 식으로 시작합니다.
useQuery({ queryKey: ['product-list'], queryFn: fetchProducts });
useQuery({ queryKey: ['product', productId], queryFn: () => fetchProduct(productId) });
useQuery({ queryKey: ['project-health', productId], queryFn: () => fetchHealth(productId) });
컴포넌트가 몇 개 없을 때는 문제없습니다. 그런데 도메인이 늘어나고, 팀원이 늘어나고, 기능이 쌓이면 어느 순간 이런 상황이 찾아옵니다.
-
"product 관련 캐시를 전부 날려야 하는데, 키가 어디어디 있지?"
-
"['product', id]라고 썼는데 누군가 ['products', id]로 썼고, 둘이 다른 캐시를 바라보고 있었다"
-
"이 쿼리 키에 파라미터 추가했더니 기존 캐시가 그대로 남아서 stale 데이터가 노출됐다"
React Query에서 쿼리 키는 단순한 식별자가 아닙니다. 캐시의 주소이자, 무효화의 단위입니다. 여기서 "무효화"란 React Query에게 "이 데이터 이제 오래됐으니까 다음에 필요하면 서버에서 다시 가져와"라고 알려주는 것입니다.
queryClient.invalidateQueries({ queryKey: ['product'] })를 호출하면, 키 배열의 앞부분이 ['product']로 시작하는 모든 쿼리가 한 번에 무효화됩니다. 예를 들어 ['product', 'list']도, ['product', '123']도 전부 해당됩니다. 이걸 "prefix matching(앞부분 일치)"이라고 하는데, 쉽게 말하면 폴더 구조처럼 상위 키가 하위 키를 전부 포함하는 방식입니다.
이 구조를 각 파일에 제각각 정의해두면, 나중에 아무도 전체 구조를 파악하지 못하게 됩니다. 이 글은 저희 팀이 이 문제를 해결하기 위해 적용한 Query Key Factory 패턴을 소개합니다.
2. 안티패턴에서 Query Key Factory로
안티패턴: 키를 흩어놓으면 생기는 일
가장 흔한 문제 케이스입니다. ['products']와 ['product']는 다른 키고, ['project-health']는 product와 전혀 연관 없어 보이지만 실제로는 "특정 product의 데이터가 바뀌었을 때 같이 무효화돼야 하는" 것들입니다.
-
무효화 코드를 작성할 때마다 관련 키를 직접 기억해서 열거해야 합니다
-
키 오타가 발생해도 런타임 에러가 없어 디버깅이 어렵습니다
-
파라미터가 바뀌거나 키 구조가 변경될 때 모든 사용처를 찾아 수정해야 합니다
해결: 도메인별 Key Factory
해결책은 쿼리 키를 도메인별 객체(factory)로 중앙 관리하는 것입니다.
// product.cm.keys.ts
export const productCmKeys = {
all: () => ['product', 'cm'] as const,
lookups: (params: { stageId?: string }) =>
[...productCmKeys.all(), 'findProductLookups', params] as const,
projectHealthSummaries: (params: { productId?: string }) =>
[...productCmKeys.all(), 'findProjectHealthSummaries', params] as const,
releaseTimeline: (params: { productId?: string }) =>
[...productCmKeys.all(), 'findReleaseTimeline', params] as const,
productNode: (params: { productId?: string }) =>
[...productCmKeys.all(), 'findProductNode', params] as const,
};
패턴의 핵심 규칙은 두 가지입니다. 첫째, all()이 공통 루트(뿌리)를 반환합니다. 모든 하위 키는 all()을 앞에 펼쳐서(...스프레드) 붙이기 때문에, invalidateQueries({ queryKey: productCmKeys.all() })로 이 도메인 전체를 한 번에 무효화할 수 있습니다. 둘째, 각 함수는 파라미터(조건값)를 받아 완성된 키를 반환합니다. React Query는 키를 내부적으로 안정적인 해시 값으로 변환해 비교하기 때문에, { productId: '123' }과 { productId: '456' }은 자동으로 서로 다른 캐시로 구분됩니다.
3. queryOptions / useQueries / invalidateQueries와 결합
queryOptions: 쿼리 설정을 한 곳에 모아두기
Query Key Factory는 queryOptions 헬퍼(TanStack Query v5에서 추가된 유틸 함수)와 함께 쓸 때 더욱 강력해집니다. 쿼리 키와 fetching 함수(queryFn)를 하나의 함수로 묶어두면, 훅에서 쓸 때와 그 외의 상황에서 쓸 때 모두 동일한 설정을 재사용할 수 있습니다.
// findProjectHealthSummaries.query.ts
export const findProjectHealthSummariesQuery = (params: Params) =>
queryOptions<FetchResponse<ProjectHealthRdo[]>>({
queryKey: productCmKeys.projectHealthSummaries(params),
queryFn: () => ProductProjSeekApi.findProjectHealthSummaries(params).then((res) => res.data),
});
// useFindProjectHealthSummaries.ts
export const useFindProjectHealthSummaries = (params: Params = {}) => {
const { data, isLoading } = useQuery(findProjectHealthSummariesQuery(params));
return { projectHealthSummaries: data?.fetchResult ?? ([] as ProjectHealthRdo[]), isLoading };
};
// 사용 예시
await queryClient.prefetchQuery(findProjectHealthSummariesQuery({ productId }));
React에서는 훅을 컴포넌트 바깥이나 일반 함수 안에서 호출할 수 없습니다. 그런데 queryOptions로 설정을 분리해두면, 서버사이드 렌더링이나 화면 진입 전 데이터를 미리 불러오는 prefetch 상황에서도 같은 설정을 그대로 쓸 수 있습니다. queryOptions가 반환하는 객체는 useQuery에도, prefetchQuery에도 그대로 전달됩니다.
useQueries: 여러 쿼리를 동시에 날리기
useQuery는 한 번에 하나의 데이터만 가져옵니다. 그런데 여러 프로젝트의 상세 정보를 동시에 가져와야 하는 경우엔 useQueries를 씁니다. findAgileProjectQuery가 설정을 반환하는 함수이기 때문에, projectIds 배열을 map으로 돌려서 쿼리 목록을 깔끔하게 만들 수 있습니다. 각 프로젝트는 고유한 키를 갖기 때문에 캐시도 독립적으로 관리되고, 하나가 무효화돼도 나머지에는 영향이 없습니다.
export const useFindAgileProjectDetails = (projectIds: string[]) => {
const results = useQueries({
queries: projectIds.map((id) => findAgileProjectQuery({ projectId: id })),
});
const agileProjectDetails: AgileProjectDetailRdo[] = results
.map((r) => r.data?.fetchResult)
.filter((d): d is AgileProjectDetailRdo => d != null);
const isLoading = results.some((r) => r.isLoading);
return { agileProjectDetails, isLoading };
};
invalidateQueries: 범위를 골라서 캐시 날리기
이 패턴의 가장 실용적인 부분입니다. 무효화 범위를 "전체 도메인" → "특정 쿼리" → "특정 파라미터의 쿼리"로 단계적으로 좁혀서 선택할 수 있습니다. 어떤 키가 있는지 외울 필요 없이, 코드에서 productCmKeys.까지만 입력해도 IDE 자동완성으로 해당 도메인의 쿼리 목록이 전부 나타납니다.
// 특정 쿼리 하나만 무효화
queryClient.invalidateQueries({
queryKey: productCmKeys.projectHealthSummaries({ productId }),
});
// product 도메인 전체 무효화
queryClient.invalidateQueries({ queryKey: productCmKeys.all() });
// mutation 성공 후 여러 도메인 무효화
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productCmKeys.all() });
queryClient.invalidateQueries({ queryKey: teamCmKeys.all() });
},
4. 폴더 구조와 마치며
폴더 구조: 도메인별 분리
이 패턴을 적용하면 자연스럽게 다음과 같은 폴더 구조가 됩니다.
feature/
├── product/
│ └── cm/
│ ├── queries/
│ │ ├── product.cm.keys.ts ← 키 factory
│ │ └── findProductLookups.query.ts ← queryOptions 함수
│ ├── hooks/
│ │ └── useFindProductLookups.ts ← 훅
│ └── index.ts
├── team/
└── staffing/
...
*.cm.keys.ts가 해당 도메인의 캐시 구조 전체를 담은 문서 역할을 합니다. 새 쿼리를 추가할 때 여기에 키 함수 하나를 추가하고, *.query.ts에서 그 키를 참조합니다.
마치며
Query Key Factory 패턴을 한 줄로 요약하면, 쿼리 키를 함수로 만들어 한 곳에서 관리하고, 캐시 계층을 코드로 직접 표현하는 것입니다.
처음엔 "굳이 이렇게까지?"라는 생각이 들 수 있습니다. 저도 그랬습니다. 그런데 도메인이 늘어날수록, 팀원이 늘어날수록 효과가 분명하게 나타납니다. 키를 문자열로 여기저기 흩어두면 나중에 캐시 구조를 파악하는 게 점점 어려워지고, 무효화 코드도 "이거 빠진 거 없나?”라는 불안한 수작업이 됩니다. 팩토리로 관리하면 *.keys.ts 파일 하나만 열어도 전체 구조가 한눈에 보입니다.
walrus