2025년 08월 14일
Tanstack Query
프로젝트에서 Tanstack Query를 사용하면서 queryKey를 문자열로 직접 관리할 때, 사소한 오타 등으로 인해 캐시가 공유되지 않거나 쿼리 무효화가 제대로 동작하지 않는 이슈를 겪었습니다. 그래서 사소하지만, 이에 대한 고민에 대해 글을 작성합니다.
Tanstack Query에서 queryKey는 데이터 캐싱과 관리를 위한 중요한 식별자입니다. 이 키를 별도 규칙 없이 문자열로 사용하면 다음과 같은 문제들이 발생할 수 있습니다.
queryKey 사용 - 휴먼에러서로 다른 컴포넌트에서 동일한 데이터를 조회한다고 가정할 때, 개발자마다 다른 문자열을 queryKey 로 사용할 수 있습니다.
1// A 컴포넌트 2useQuery({ queryKey: ['posts', postId], ... }); 3 4// B 컴포넌트 5useQuery({ queryKey: ['post', postId], ... }); // 'posts'가 아닌 'post'
만약 API 명세가 변경되어 queryKey 일부를 변경해야 할 때, 문자열로 작성된 모든 키를 개발자가 직접 찾아 수정해야 합니다. 이 과정에서 수정이 하나라도 누락되면 해당 쿼리는 더이상 올바르게 무효화되거나 갱신되지 않는 버그로 이어질 수 있습니다.
언급한 문제들을 사전에 방지하기 위해 queryKey 의 핵심이 되는 문자열을 Enum 으로 정의하여 중앙에서 관리하는 방법을 적용할 수 있습니다.
queryKeys.ts 파일에 Enum 정의먼저 프로젝트에 queryKey 를 모아둘 파일을 생성하고 Enum 을 정의합니다.
1export enum QueryKey { 2 Posts = 'POSTS', 3 Users = 'USERS', 4 Products = 'PRODUCTS', 5}
예를 들어 USER에 종속된 USER_DETAIL, USER_PLAN 과 같은 하위 쿼리키들은 별도 객체로 묶어서 관리합니다.
단순히 Enum에 USER_DETAIL 을 추가하는 것만으로는 USER와의 연관성을 표현하기도 어렵고 동적인 값 (예를 들면 userId)을 처리하기 애매할것입니다.
그리고, 만약 Enum에 UserDetail = 'USER_DETAIL'이라고만 정의하면, 이 키가 USER에 속해있다는 관계를 코드상으로 표현할 수 없고, 동적인 ID '123'을 결합하는 로직이 각 컴포넌트에 흩어지게 됩니다.
1export enum QueryKey { 2 Users = 'USERS', 3 // 다른 도메인들... 4 // Posts = 'POSTS', 5}
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};
이제 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};
이 방식의 가장 큰 장점이라고 생각되는 부분입니다. queryClient로 캐시를 직접 조작할 때 입니다.
1// 게시글 수정 후, 관련 목록을 갱신할 때 2const queryClient = useQueryClient(); 3 4// Enum을 사용하면 자동 완성의 도움을 받을 수 있고, 오타 가능성이 사라짐 5queryClient.invalidateQueries({ queryKey: [QueryKey.Posts] });
queryKey Enum이 프로젝트의 모든 쿼리 키에 대한 유일한 참조가 됩니다. 키를 변경해야 할 경우, 이 Enum 파일 하나만 수정하면 이를 참조하는 모든 코드에 일괄적으로 반영됩니다.queryKey를 작성할 때마다 문자열을 기억해낼 필요 없이 자동 완성 기능을 통해 정확하게 키를 입력할 수 있습니다.처음에는 queryKey를 문자열로 사용하는것이 간단해보였지만, 프로젝트 규모가 커지면서 모든 queryKey를 기억할 수도 없었습니다. Enum을 도입하는 과정 자체는 매우 간단했지만, 그 결과로 얻은 장점이 매우 많았습니다.
이번 경험을 통해서 라이브러리의 핵심 기능을 사용할 때 약간와 규칙과 추상화를 더하는 것만으로도 수많은 오류를 예방하고 코드 품질을 높일 수 있다는 경험을 얻게 된 것 같습니다.
Enum으로 queryKey를 중앙에서 관리한 뒤, 문자열 오타나 invalidateQueries 호출 시의 실수는 확실히 줄었습니다. 특히 프로젝트 초반에는 문자열을 흩뿌리지 않고 한 곳에서 관리한다는 것만으로도 꽤 큰 효과가 있었습니다.
다만 프로젝트 규모가 커지고 도메인이 늘어나면서, Enum 만으로는 해결되지 않는 아쉬움도 조금씩 보이기 시작했습니다.
가장 먼저 느낀 점은, Enum은 어떤 문자열을 사용할지는 통제해주지만 Query Key 구조 자체까지 강제해주지는 못한다는 점이었습니다.
예를 들어 USERS 라는 최상위 키를 Enum으로 관리하더라도, 실제 하위 키를 조합하는 방식은 여전히 개발자마다 다르게 작성될 수 있습니다.
1['USERS', 'detail', userId] 2['USERS', userId, 'detail'] 3['USERS', 'detail', { userId }]
모두 얼핏 보면 비슷해 보이지만, Tanstack Query 입장에서는 전혀 다른 queryKey입니다. 즉, 루트 문자열의 오타는 막을 수 있어도, 세부 구조가 조금씩 달라지면서 캐시가 예상대로 공유되지 않거나 무효화 범위를 헷갈리는 문제는 여전히 남아있었습니다
Tanstack Query는 내부적으로 JSON.stringify와 유사한 방식으로 키를 직렬화합니다. 배열 내의 데이터가 같더라도 순서가 바뀌면 생성되는 해시값이 달라집니다.
또 하나는, 도메인별로 직접 만든 keys 객체의 형태가 점점 달라질 수 있다는 점이었습니다.
처음에는 userKeys, postKeys 같은 팩토리 객체를 만들어 일관되게 관리한다고 생각했지만, 시간이 지나면 다음과 같은 차이가 생기기 쉬웠습니다.
all / list / detail 구조를 사용하고root / items / item 구조를 사용하고결국 중앙 관리는 하고 있었지만, "모든 Query Key를 같은 방식으로 정의한다"는 표준화까지는 이어지지 못했습니다.
그리고 실제로는 queryKey만 따로 관리한다고 해서 관리 포인트가 완전히 줄어드는 것도 아니었습니다. 대부분의 경우 queryKey를 정의한 뒤에는 항상 그에 대응하는 queryFn, 그리고 경우에 따라 invalidation 범위까지 함께 고민해야 했습니다.
예를 들어 아래처럼 작성하는 패턴은 익숙하지만,
1useQuery({ 2 queryKey: userKeys.detail(userId), 3 queryFn: () => fetchUser(userId), 4});
여전히 개발자는 다음 맥락을 직접 기억해야 합니다,
detail(userId) 가 어떤 구조의 key를 만드는지즉, 문자열 상수를 안전하게 관리하는 문제와 Query 자체를 일관된 방식으로 정의하는 문제는 생각보다 다른 문제였습니다.
이런 아쉬움 떄문에, 직접 만든 규칙을 계속 유지하는 대신 조금 도 일관된 방식으로 Query Key를 선언할 수 있는 방법을 찾게 되었습니다.
그 과정에서 알게 된 것이 @lukemorales/query-key-factory 입니다.
이 라이브러리는 Query Key를 단순한 문자열 배열이 아니라 도메인 단위로 구조화된 팩토리 형태로 정의할 수 있게 도와줍니다. 개인적으로 "Enum을 대체한다"기보다는 Enum으로 관리하면서 느꼈던 아쉬움을 조금 더 높은 수준에서 해결해주는 도구에 가깝다고 느꼈습니다.
예를 들면 기존에는 아래처럼 직접 userKeys 객체를 만들었다면,
1export const userKeys = { 2 all: [QueryKey.Users] as const, 3 list: (filters: { role: string }) => [...userKeys.all, 'list', filters] as const, 4 detail: (userId: string) => [...userkeys.all, 'detail', userId] as const, 5 plan: (userId: string) => [...userKeeys.detail(userid), 'plan'] as const, 6}
query-key-factory 를 사용하면 이런 구조를 조금 더 선언적으로 관리할 수 있습니다.
1import { createQueryKeys } from '@lukemorales/query-key-factory'; 2 3export const users = createQueryKeys('users', { 4 list: (filters: { role: string }) => ({ 5 queryKey: [filters], 6 }), 7 detail: (userId: string) => ({ 8 queryKey: [userId], 9 contextQueries: { 10 plan: () => ['plan'], 11 } 12 }) 13})
이 방식의 장점은 단순히 코드가 짧아지는 데 있지 않았습니다. 제가 느낀 장점은 크게 세 가지 였습니다.
기존 방식에서도 계층 구조를 표현할 수는 있었지만, 어디까지나 우리가 직접 규칙을 잘 지켜야 했습니다. 반면 팩토리 기반 방식은 list, detail, plan 같은 관계를 한 곳에서 선언적으로 표현할 수 있어서 이 키가 어떤 도메인에 속하고 어떤 하위 리소스를 가지는지가 더 분명하게 보였습니다.
특히 detail -> plan 처럼 상하 관계가 있는 경우, 단순 문자열 상수보다 훨씬 읽기 쉬웠습니다.
직접 만든 keys 객체는 팀마다, 도메인마다 조금씩 다른 스타일로 흘러가기 쉽습니다. 하지만 라이브러리를 도입하면 기본적인 작성 패턴이 정해지기 때문에, 새로운 도메인을 추가하더라도 비슷한 방식으로 Query Key를 정의하게 됩니다.
즉, '이 프로젝트에서는 Query Key를 어떻게 만들지?'를 새로 합의하기보다, 도구가 제공하는 패턴에 맞춰 자연스럽게 통일할 수 있다는 점이 좋았습니다.
실무에서는 queryKey만 필요한 것이 아니라, 그와 연결된 queryFn, invalidate 범위, 하위 리소스 관게까지 함께 이해해야 하는 경우가 많습니다. 이때 팩토리 형태는 적어도 "이 Query가 어떤 구조를 갖는지"에 대한 정보를 한 군데 모아둘 수 있어서, 코드를 따라가기가 훨씬 편했습니다.
특히 캐시 무효화 시에도 루트, 상세, 하위 리소스 간의 관계를 좀 더 예측 가능하게 다룰 수 있다는 점이 좋았습니다.
반드시 그렇다고 생각하지는 않습니다.
프로젝트 규모가 작거나 Query Key의 종류가 많지 않다면 Enum과 간단한 팩토리 객체만으로도 충분히 좋은 선택이라고 생각합니다.
다만 아래와 같은 상황이라면 @lukemorales/query-key-factory 같은 도구를 한번쯤 고려해볼만 하다고 느꼈습니다.
결국 중여한 것은 Enum이냐 라이브러리냐 자체보다 Query Key를 얼마나 일관된 규칙 아래에서 관리할 수 있느냐라고 생각합니다.
제 경우에는 Enum 도입이 첫 번째 개선이었다면, query-key-factory는 그 다음 단계에서 '직접 만든 규칙의 한계'를 줄여주는 선택지였습니다.
처음에는 queryKey를 문자열로 직접 관리했고, 그다음에는 Enum으로 옮겨갔고, 이후에는 Query Key 자체를 조금 더 구조적으로 선언하기 위해 라이브러리까지 검토하게 되었습니다.
돌이켜보면 이 과정은 “정답을 한 번에 찾았다”기보다는, 프로젝트가 커질수록 어떤 관리 방식이 더 적합한지 조금씩 배워가는 과정에 가까웠습니다.
Tanstack Query의 queryKey는 단순한 문자열 배열처럼 보이지만, 실제로는 캐시 전략과 무효화 범위를 결정하는 중요한 기준입니다. 그래서 규모가 커질수록 “값 하나를 잘 쓰는 것”보다 어떤 규칙으로 정의하고, 어떻게 팀 내에서 일관되게 사용할 것인가가 더 중요해지는 것 같습니다.