2025년 08월 14일

6

Tanstack Query의 Query Key 관리: Enum으로 타입 안정성 높이기

Tanstack Query

TanStack Query의 Query Key 관리: Enum으로 타입 안정성 높이기

들어가며

프로젝트에서 Tanstack Query를 사용하면서 queryKey를 문자열로 직접 관리할 때, 사소한 오타 등으로 인해 캐시가 공유되지 않거나 쿼리 무효화가 제대로 동작하지 않는 이슈를 겪었습니다. 그래서 사소하지만, 이에 대한 고민에 대해 글을 작성합니다.

왜 Query Key를 체계적으로 관리해야 할까

Tanstack Query에서 queryKey는 데이터 캐싱과 관리를 위한 중요한 식별자입니다. 이 키를 별도 규칙 없이 문자열로 사용하면 다음과 같은 문제들이 발생할 수 있습니다.

1. 일관성 없는 queryKey 사용 - 휴먼에러

서로 다른 컴포넌트에서 동일한 데이터를 조회한다고 가정할 때, 개발자마다 다른 문자열을 queryKey 로 사용할 수 있습니다.

1// A 컴포넌트 2useQuery({ queryKey: ['posts', postId], ... }); 3 4// B 컴포넌트 5useQuery({ queryKey: ['post', postId], ... }); // 'posts'가 아닌 'post'

2. 리팩토링 시 발생할 수 있는 문제 - 휴먼에러

만약 API 명세가 변경되어 queryKey 일부를 변경해야 할 때, 문자열로 작성된 모든 키를 개발자가 직접 찾아 수정해야 합니다. 이 과정에서 수정이 하나라도 누락되면 해당 쿼리는 더이상 올바르게 무효화되거나 갱신되지 않는 버그로 이어질 수 있습니다.

해결책 - Enum으로 Query Key 관리하기

언급한 문제들을 사전에 방지하기 위해 queryKey 의 핵심이 되는 문자열을 Enum 으로 정의하여 중앙에서 관리하는 방법을 적용할 수 있습니다.

1. queryKeys.ts 파일에 Enum 정의

먼저 프로젝트에 queryKey 를 모아둘 파일을 생성하고 Enum 을 정의합니다.

1export enum QueryKey { 2 Posts = 'POSTS', 3 Users = 'USERS', 4 Products = 'PRODUCTS', 5}

2. 한 걸음 더 나아가기 - 핵심 비즈니스에 종속된 하위 쿼리 키들은 따로 관리

예를 들어 USER에 종속된 USER_DETAIL, USER_PLAN 과 같은 하위 쿼리키들은 별도 객체로 묶어서 관리합니다.

단순히 Enum에 USER_DETAIL 을 추가하는 것만으로는 USER와의 연관성을 표현하기도 어렵고 동적인 값 (예를 들면 userId)을 처리하기 애매할것입니다.

그리고, 만약 Enum에 UserDetail = 'USER_DETAIL'이라고만 정의하면, 이 키가 USER에 속해있다는 관계를 코드상으로 표현할 수 없고, 동적인 ID '123'을 결합하는 로직이 각 컴포넌트에 흩어지게 됩니다.

  • 2-1. 먼저 최상위 도메인으로 Users를 정의합니다.

1export enum QueryKey { 2 Users = 'USERS', 3 // 다른 도메인들... 4 // Posts = 'POSTS', 5}
  • 2-2. userKeys 팩토리 객체 생성

USERS와 관련된 모든 하위 키들을 생성하는 userKeys 객체를 만듭니다. USER_PLAN은 특정 유저(userId)의 상세 정보(detail)에 속한 하위 리소스로 모델링하는 것이 구조적으로 명확합니다.

1import { QueryKey } from './queryKeys'; 2 3export const userKeys = { 4 /** 5 * User 전체에 대한 기본 키 6 * 결과: ['USERS'] 7 */ 8 all: [QueryKey.Users] as const, 9 10 /** 11 * 필터링된 유저 리스트에 대한 키 12 * 결과: ['USERS', 'list', { role: 'admin' }] 13 */ 14 list: (filters: { role: string }) => 15 [...userKeys.all, 'list', filters] as const, 16 17 /** 18 * 특정 유저의 상세 정보 (USER_DETAIL) 19 * 결과: ['USERS', 'detail', 'user-123'] 20 */ 21 detail: (userId: string) => [...userKeys.all, 'detail', userId] as const, 22 23 /** 24 * 특정 유저의 요금제 정보 (USER_PLAN) 25 * 결과: ['USERS', 'detail', 'user-123', 'plan'] 26 */ 27 plan: (userId: string) => [...userKeys.detail(userId), 'plan'] as const, 28};

3. Enum을 사용하여 쿼리 호출하기

이제 useQuery나 useMutation을 사용할 때, 문자열 대신 정의한 Enum을 가져와 사용합니다.

1export const usePostsQuery = (postId: string) => { 2 return useQuery({ 3 // 문자열 'POSTS' 대신 Enum을 사용 4 queryKey: [QueryKey.Posts, postId], 5 queryFn: () => fetchPostById(postId), 6 }); 7};

4. InvalidateQueries 시 안전하게 사용하기

이 방식의 가장 큰 장점이라고 생각되는 부분입니다. queryClient로 캐시를 직접 조작할 때 입니다.

1// 게시글 수정 후, 관련 목록을 갱신할 때 2const queryClient = useQueryClient(); 3 4// Enum을 사용하면 자동 완성의 도움을 받을 수 있고, 오타 가능성이 사라짐 5queryClient.invalidateQueries({ queryKey: [QueryKey.Posts] });

동작방식

  • Single Source of Truth (단일 진실 공급원): queryKey Enum이 프로젝트의 모든 쿼리 키에 대한 유일한 참조가 됩니다. 키를 변경해야 할 경우, 이 Enum 파일 하나만 수정하면 이를 참조하는 모든 코드에 일괄적으로 반영됩니다.
  • 컴파일 타임 에러 체크: 만약 존재하지 않는 Enum을 사용하려고 하면 tsc가 컴파일 에러를 발생시킵니다.
  • 개발 경험 향상: queryKey를 작성할 때마다 문자열을 기억해낼 필요 없이 자동 완성 기능을 통해 정확하게 키를 입력할 수 있습니다.

Results

처음에는 queryKey를 문자열로 사용하는것이 간단해보였지만, 프로젝트 규모가 커지면서 모든 queryKey를 기억할 수도 없었습니다. Enum을 도입하는 과정 자체는 매우 간단했지만, 그 결과로 얻은 장점이 매우 많았습니다.

이번 경험을 통해서 라이브러리의 핵심 기능을 사용할 때 약간와 규칙과 추상화를 더하는 것만으로도 수많은 오류를 예방하고 코드 품질을 높일 수 있다는 경험을 얻게 된 것 같습니다.

HS
@Hyungseok Kwon
Journey To Solution