Powerful asynchronous state management for TS/JS, React, Solid, Vue, Svelte and Angular
https://tanstack.com
강력한 비동기 상태관리 도구 Tanstacak-Query
는 작성일 기준 npm에서 300만회 이상 다운로드 될 정도로 많은 프런트엔드 개발자들에게 사랑받고 있습니다.
저도 마찬가지로 Tanstack-Query
가 제공하는 다양한 API들을 유용하게 사용하고 있습니다. 하지만, Tanstack-Query
가 제공하는 기능들을 제대로 사용하기 위해서는 필수적으로 이해해야 하는 개념들이 몇 가지 존재합니다. 오늘 다뤄볼 주제는 그 핵심 개념들 중 하나인 Query Keys
를 관리하는 방법입니다.
공식문서에서는 다음과 같이 Query Keys
를 소개하고 있습니다.
At its core, TanStack Query manages query caching for you based on query keys. Query keys have to be an Array at the top level, and can be as simple as an Array with a single string, or as complex as an array of many strings and nested objects. As long as the query key is serializable, and unique to the query's data, you can use it!
https://tanstack.com/query/latest/docs/framework/react/guides/query-keys
Query Keys의 관리가 제대로 이루어지지 않을 경우, 예상치 못한 문제가 발생할 수 있음을 알 수 있습니다. 많은 조직에서는 이러한 문제를 방지하기 위해 Query Keys를 어떻게 효율적으로 관리할지에 대해 심도 깊게 고민하고 있습니다.
이 글에서는 Tanstck-Query
공식문서에서 다음과 같이 소개하는 두 가지 읽을거리와 비교적 최신에 작성된 The Query Options API를 읽고 어떻게 Query Keys
를 관리하면 좋을지에 대한 생각을 정리해보았습니다.
For tips on organizing Query Keys in larger applications, have a look at Effective React Query Keys and check the Query Key Factory Package from the Community Resources.
https://tanstack.com/query/latest/docs/framework/react/guides/query-keys#further-reading
Query Keys
를 효과적으로 관리하는 것은 Tanstack Query를 사용하는 애플리케이션의 성능과 유지보수성에 큰 영향을 미칩니다. Effective React Query Keys에서는 Query Keys
를 생성할 때 고려해야 할 중요한 점들을 다루고 있으며, 이를 통해 보다 체계적이고 효율적인 데이터 캐싱 전략을 구축할 수 있습니다. Query Keys
를 생성할 때 반드시 고려해야 할 유용한 내용들이 많이 담겨있으므로, 아직 읽지 않으신 분들은 꼭 읽어보시길 권장드립니다.
먼저, Query Keys
를 포함한 Tanstack-Query의 기능들을 어디에 배치할지에 대한 고민입니다. 글에서는 기능 단위로 Query Keys를 배치하는 것을 권장합니다. 이러한 접근은 코드의 모듈성과 가독성을 높이며, 관련 로직을 쉽게 찾을 수 있도록 도와줍니다.
-src
- features
- Profile
- index.tsx
- queries.ts
- Todos
- index.tsx
- queries.ts
queries.ts
에서는 Tanstack-Query
에서 제공하는 API를 활용하는 커스텀 훅을 내보내며, 실제 쿼리 함수와 쿼리 키는 이 파일 안에 위치시킵니다.
다음으로, Query Keys
의 구조화에 대한 고려입니다. 가장 일반적인 요소에서 시작하여 가장 구체적인 요소로 끝나는 구조를 권장하며, 이는 적절한 세분성을 제공합니다. 아래 예시를 통해 이 개념을 보다 명확히 이해할 수 있습니다.
['todos', 'list', { filters: 'all' }]
['todos', 'list', { filters: 'done' }]['todos', 'detail', 1]
['todos', 'detail', 2]
이렇게 작성하게 된다면 ['todos']
가 Query Key
로 들어간 모든 작업을 한번에 관리할 수 있는 장점이 있습니다. 가령, ['todos']
가 Query Keys
로 들어간 모든 Query
를 invalidate
시킨다던가 말이죠. 아래 예시를 통해 이 개념을 보다 명확히 이해할 수 있습니다.
function useUpdateTitle() {
return useMutation({
mutationFn: updateTitle,
onSuccess: (newTodo) => {
queryClient.setQueryData(
['todos', 'detail', newTodo.id],
newTodo
)
// ✅ just invalidate all the lists
queryClient.invalidateQueries({
queryKey: ['todos', 'list']
})
},
})
}
invalidateQueries
의 queryKey
에 ['todos', 'list']
를 전달해줌으로서 ['todos', 'list', { filters: 'all' }]
에 해당하는 쿼리와
['todos', 'list', { filters: 'done' }]
에 해당하는 쿼리를 한번에 무효화할 수 있었습니다.
이러한 구조적 접근은 Query Keys
를 체계적으로 관리하며, 관련 쿼리들에 대한 일관된 처리를 가능하게 합니다.
하지만, 이대로 계속해서 수동으로 Query Keys
를 작성한다면, 오류가 발생하기 쉬울 뿐더러 유지보수가 어려워집니다. 특히, Query Keys에 세분성을 추가하거나 변경이 필요한 경우, 관련된 모든 Query Keys
를 일일이 수정하는 번거로움이 있습니다.
이에 대한 해결책으로, 글쓴이는 각 기능 단위별로 Query Key Factories를 구성하는 방법을 제안합니다. 다음은 앞서 언급된 예시들을 변환한 코드 예시입니다.
// query-key-factory
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
};
// 🕺 remove everything related
// to the todos feature
queryClient.removeQueries({
queryKey: todoKeys.all
})
// 🚀 invalidate all the lists
queryClient.invalidateQueries({
queryKey: todoKeys.lists()
})
// 🙌 prefetch a single todo
queryClient.prefetchQueries({ // ⚠️ (작성자) 이 부분에서 왜 prefetchQueries를 사용했을까요?
queryKey: todoKeys.detail(id),
queryFn: () => fetchTodo(id),
})
이러한 Query Key Factories 접근 방식을 통해, 초기에 수동으로 Query Keys
를 작성할 때보다 훨씬 효율적으로 쿼리 키를 관리할 수 있습니다.
Query Key Factory
는 Query Keys
의 관리를 간소화하고, 번거로운 기억 작업 없이 효율적으로 처리할 수 있도록 돕는 라이브러리입니다. 이 도구는 Query Keys
를 체계적으로 구성하고, 관련된 쿼리 함수와 함께 관리함으로써 개발 과정에서 발생할 수 있는 혼란과 오류를 줄여줍니다.
최근 우아콘에서 진행된 프론트엔드 상태관리 실전 편 with React Query & Zustand에서도 Query Keys
의 명명에 있어 발생하는 고민과 이로 인한 개발 비용의 증가를 지적하며, 잘 설계된 라이브러리의 도입 필요성을 강조했습니다. Query Key Factory
는 이러한 문제를 해결하기 위한 방안으로 Tanstack Query
의 공식 문서에서도 소개되었습니다.
import { createQueryKeys, mergeQueryKeys } from "@lukemorales/query-key-factory";
// queries/users.ts
export const users = createQueryKeys('users', {
all: null,
detail: (userId: string) => ({
queryKey: [userId],
queryFn: () => api.getUser(userId),
}),
});
// queries/todos.ts
export const todos = createQueryKeys('todos', {
detail: (todoId: string) => [todoId],
list: (filters: TodoFilters) => ({
queryKey: [{ filters }],
queryFn: (ctx) => api.getTodos({ filters, page: ctx.pageParam }),
contextQueries: {
search: (query: string, limit = 15) => ({
queryKey: [query, limit],
queryFn: (ctx) => api.getSearchTodos({
page: ctx.pageParam,
filters,
limit,
query,
}),
}),
},
}),
});
// queries/index.ts
export const queries = mergeQueryKeys(users, todos);
이 접근법은 코드의 관리를 용이하게 하고, 각 기능별로 쿼리 키와 함수를 명확히 구분함으로써 관심사의 분리를 잘 달성합니다. Effective React Query Keys에서 제시한 기능 단위의 queries.ts
배치 방식을 더 발전시켜, createQueryKeys
를 사용하여 각 기능별로 쿼리 키와 함수를 정의하고, mergeQueryKeys
를 통해 이들을 병합하여 하나의 queries
객체로 구성했습니다. 이 방식은 프로젝트의 구조를 더욱 명확하게 하고, 쿼리 관리의 효율성을 극대화합니다.
만들어진 queries
객체를 활용하는 방식은 쿼리를 더 효율적이고 명확하게 관리할 수 있게 해줍니다.
import { queries } from '../queries';
export function useTodos(filters: TodoFilters) {
return useQuery(queries.todos.list(filters));
};
export function useSearchTodos(filters: TodoFilters, query: string, limit = 15) {
return useQuery({
...queries.todos.list(filters)._ctx.search(query, limit),
enabled: Boolean(query),
});
};
export function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation(updateTodo, {
onSuccess(newTodo) {
queryClient.setQueryData(queries.todos.detail(newTodo.id).queryKey, newTodo);
// invalidate all the list queries
queryClient.invalidateQueries({
queryKey: queries.todos.list._def,
refetchActive: false,
});
},
});
};
그러나, 코드에서 등장하는 _ctx와 _def와 같은 용어들에 대한 추가적인 이해가 필요해 보입니다. 이러한 용어들은 @lukemorales/query-key-factory
에서 제공하는 기능을 깊이 이해하는 데 중요한 역할을 합니다. 따라서 @lukemorales/query-key-factory
에서 제공하는 주요 함수들을 하나하나 살펴보겠습니다.
createQueryKeys
의 간단한 예제입니다.
export const todos = createQueryKeys('todos', {
detail: (todoId: string) => [todoId],
list: (filters: TodoFilters) => ({
queryKey: [{ filters }],
}),
});
// => createQueryKeys output:
// {
// _def: ['todos'],
// detail: (todoId: string) => {
// queryKey: ['todos', 'detail', todoId],
// },
// list: (filters: TodoFilters) => {
// queryKey: ['todos', 'list', { filters }],
// },
// }
// query-key-factory
// const todoKeys = {
// all: ['todos'] as const,
// lists: () => [...todoKeys.all, 'list'] as const,
// list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
// details: () => [...todoKeys.all, 'detail'] as const,
// detail: (id: number) => [...todoKeys.details(), id] as const,
// };
아래 첨부한 todoKeys
는 위에서 소개해드렸던 Effective React Query Keys
의 예제를 가져와보았습니다. all
에 해당하는 ['todos']
는 createQueryKeys
의 첫 번째 인자로 들어가고, 반환되는 객체의 _def
값이 됩니다. 그리고 detail
과 list
의 경우는 key 값이 queryKey
배열의 두 번째 요소, 그리고 의존되는 값들을 파라미터로 받아 이후의 배열을 채우게 됩니다.
이처럼 @lukemorales/query-key-factory
는 해당 공식문서에서 다음과 같이 언급하는것 처럼 @tanstack/query
의 컨벤션을 따르며 키를 생성합니다.
All keys generated follow the @tanstack/query convention of being an array at top level, including keys with serializable objects:
https://github.com/lukemorales/query-key-factory/blob/main/README.md#standardized-keys
그리고 다음과 같이 queryKey와 함께 useQuery
에서 사용되기를 원하는 옵션들(예: queryFn
)을 선언할 수도 있습니다.
export const users = createQueryKeys('users', {
detail: (userId: string) => ({
queryKey: [userId],
queryFn: () => api.getUser(userId),
}),
});
// => createQueryKeys output:
// {
// _def: ['users'],
// detail: (userId: string) => {
// queryKey: ['users', 'detail', userId],
// queryFn: (ctx: QueryFunctionContext) => api.getUser(userId),
// },
// }'
또한, 다른 쿼리의 컨텍스트 또는 상황에 의존적이거나 관련되어 있을 때 사용하는 contextQueries
도 존재합니다. 이 방식을 통해 주요 쿼리와 연관된 추가적인 쿼리들을 명확하게 구분하고, 주요 쿼리의 결과에 기반한 추가 데이터 요청을 구조화하여 관리할 수 있습니다. 이때 contextQueries
에 작성한 의존된 쿼리는 반환되는 객체의 _ctx
에서 찾아볼 수 있습니다.
// Declare queries that are dependent or related to a parent context (e.g.: all likes from a user):
export const users = createQueryKeys('users', {
detail: (userId: string) => ({
queryKey: [userId],
queryFn: () => api.getUser(userId),
contextQueries: {
likes: {
queryKey: null,
queryFn: () => api.getUserLikes(userId),
},
},
}),
});
// => createQueryKeys output:
// {
// _def: ['users'],
// detail: (userId: string) => {
// queryKey: ['users', 'detail', userId],
// queryFn: (ctx: QueryFunctionContext) => api.getUser(userId),
// _ctx: {
// likes: {
// queryKey: ['users', 'detail', userId, 'likes'],
// queryFn: (ctx: QueryFunctionContext) => api.getUserLikes(userId),
// },
// },
// },
// }
export function useUserLikes(userId: string) {
return useQuery(users.detail(userId)._ctx.likes);
};
export function useUserDetail(id: string) {
return useQuery(users.detail(id));
};
또한, 특정 범주나 컨텍스트에 대한 캐시를 쉽게 관리할 수 있도록 queryKey
나 _def
를 사용합니다. queryKey
는 특정 데이터 요청을 식별하는 데 사용되며, _def
는 특정 범주의 쿼리들에 대한 작업을 용이하게 합니다.
users.detail(userId).queryKey; // => ['users', 'detail', userId]
users.detail._def; // => ['users', 'detail']
Query Key Factories
의 사용은 Effective React Query Keys에서 소개된 Query Key factories 방식을 한층 발전시켜, Query Keys
의 관리를 더욱 효율적이고 체계적으로 만듭니다. 이는 학습 곡선이 낮고, 실제 프로젝트에 적용하기 쉬운 라이브러리입니다.
Effective React Query Keys를 작성한 tkdodo가 다룬 또 다른 주제, The Query Options API는 Query Keys
를 다루는 방법의 진화를 다룹니다. V5 버전 업데이트를 통해 Tanstack Query
는 함수 인수를 단일 Query Options
객체로 받는 방식으로 변경되었습니다. 이 변화는 쿼리의 인터페이스를 추상화하는 과정을 더욱 우아하게 만들었습니다.
const todosQuery = {
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5000,
}
// ✅ works
useQuery(todosQuery)
// 🤝 sure
queryClient.prefetchQuery(todosQuery)
// 🙌 oh yeah
useSuspenseQuery(todosQuery)
// 🎉 absolutely
useQueries([{
queries: [todosQuery]
}]
다만, TypeScript 환경에서는 이러한 작성방식에 문제점이 존재했습니다.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
stallTime: 5000,
})
// Object literal may only specify known properties, but 'stallTime' does not exist in type
//'UseQueryOptions<Todo[], Error, Todo[], string[]>'. Did you mean to write 'staleTime'?(2769)
TypeScript는 객체 리터럴을 직접 함수의 인자로 인라인으로 전달할 때, 이 객체 리터럴에 대해 "과잉 프로퍼티 검사"(excess property checking)를 수행합니다. 따라서, TypeScript는 우리에게 stallTime
은 존재하지 않는 프로퍼티라고 알려줍니다.
하지만 객체를 변수에 할당하는 경우, 구조적 타이핑의 특성으로 인해 TypeScript는 같은 수준의 엄격한 검사를 수행하지 않습니다.
const todosQuery = {
queryKey: ['todos'],
queryFn: fetchTodos,
stallTime: 5000,
}
useQuery(todosQuery)
이 경우에도 런타임 에러는 발생하지 않지만, 의도하지 않은 방식으로 코드가 작동할 수 있으며, 문제를 발견하기 어려울 수 있습니다.
queryOptions
함수는 이러한 문제를 해결하는 데 유용한 도구입니다. Tanstack Query
의 공식 문서에서는 queryOptions
를 사용하여 여러 위치에서 queryKey
와 queryFn
을 공유하면서도 서로 밀접하게 관리할 수 있는 방법 중 하나로 소개합니다.
One of the best ways to share queryKey and queryFn between multiple places, yet keep them co-located to one another, is to use the queryOptions helper. At runtime, this helper just returns whatever you pass into it, but it has a lot of advantages when using it with TypeScript. You can define all possible options for a query in one place, and you'll also get type inference and type safety for all of them.
import { queryOptions } from '@tanstack/react-query'
function groupOptions(id: number) {
return queryOptions({
queryKey: ['groups', id],
queryFn: () => fetchGroups(id),
staleTime: 5 * 1000,
})
}
// usage:
useQuery(groupOptions(1))
useSuspenseQuery(groupOptions(5))
useQueries({
queries: [groupOptions(1), groupOptions(2)],
})
queryClient.prefetchQuery(groupOptions(23))
queryClient.setQueryData(groupOptions(42).queryKey, newGroups)
위의 코드처럼 queryOptions를 사용하면, TypeScript의 타입 추론과 안전성이 모든 옵션에 대해 제공됩니다. 실수로 잘못된 프로퍼티 이름을 사용하더라도 TypeScript는 즉각적으로 이를 알려주어 빠르게 수정할 수 있게 돕습니다.
import { queryOptions } from '@tanstack/react-query'
function groupOptions(id: number) {
return queryOptions({
queryKey: ['groups', id],
queryFn: () => fetchGroups(id),
stalTime: 5 * 1000,
})
}
// usage:
useQuery(groupOptions(1)) // error! 🚨
// Object literal may only specify known properties, but 'stallTime' does not exist in type
//'DefinedInitialDataOptions<Todo[], Error, Todo[], string[]>'. Did you mean to write 'staleTime'?(2769)
결과적으로, queryOptions
함수는 Tanstack Query
를 사용하는 프로젝트에서 Query Keys
와 관련된 옵션을 효율적으로 관리하고, 타입 안전성을 높이는 데 큰 도움을 줍니다. 이는 개발자가 보다 신뢰할 수 있는 코드를 작성하도록 지원하며, 잠재적인 실수를 사전에 방지할 수 있게 합니다.
거의 다 왔습니다! 조금만 더 집중해봅시다! 💪🏼
그래서 새롭게 제안된 패턴은 Query key Factories가 아닌 Query Factories로 이 접근법은 Query Keys
만이 아닌 Query
자체의 추상화를 더욱 진전시키는 방법입니다. 이 방식은 queryOptions
를 활용하여 Query Keys
와 Query Function
을 통합 관리합니다.
글 초반에 언급했듯이, queries.ts
는 Tanstack Query
의 API를 래핑한 커스텀 훅을 내보내며 실제 쿼리 함수와 쿼리 키를 포함하고 있습니다. 하지만 Query들을 집합으로부터 직접 사용하게 되면, 커스텀 훅은 아래와 같이 간소화됩니다.
const useTodos = () => useQuery(todosQuery)
이러한 변화는 커스텀 훅을 통한 추상화의 가치를 다소 감소시키며, 따라서 Query Factories 패턴을 사용함으로써, 커스텀 훅에 과도하게 의존하는 상황을 줄일 수 있게 됩니다. 이는 Query Key Factories에서 한 단계 더 발전한 모델로, Query Keys
와 QueryFn
을 분리 배치했던 초기 접근의 한계를 극복하고, 타입 안정성, 코드의 위치 선정 및 개발 경험을 동시에 개선하는 전략을 제안합니다.
const todoQueries = {
all: () => ['todos'],
lists: () => [...todoQueries.all(), 'list'],
list: (filters: string) =>
queryOptions({
queryKey: [...todoQueries.lists(), filters],
queryFn: () => fetchTodos(filters),
}),
details: () => [...todoQueries.all(), 'detail'],
detail: (id: number) =>
queryOptions({
queryKey: [...todoQueries.details(), id],
queryFn: () => fetchTodo(id),
staleTime: 5000,
}),
}
Query Factories 접근법은 queryOptions
를 활용하여 Query Keys
뿐만 아니라 queryFn
과 staleTime
등 추가적인 옵션까지 함께 관리함으로써 코드의 응집도가 향상됩니다. 이는 Query Keys
와 관련 함수들의 밀접한 연결을 통해 코드의 가독성을 향상시키고, 관련 로직을 보다 직관적으로 표현할 수 있게 도와줍니다. 이러한 방식은 타입 안정성을 강화하며, 개발자의 코드 작성 경험을 더욱 쾌적하게 만들어 줍니다.
먼저, Effective React Query Keys를 통해 Query Keys
의 배치 및 구조화 방법에 대해 고민해보았습니다. 이 과정에서 Query Keys
의 관리를 용이하게 하기 위한 Query Key Factories라는 개념을 탐색했습니다.
프로젝트 규모가 커짐에 따라 신경 써야 하는 부분이 늘어나고, 표준화된 키 작성이나 의존적인 쿼리 작성 시 발생하는 문제점들을 최소화하기 위해 공식 문서에서 소개된 @lukemorales/query-key-factory
를 살펴보았습니다.
Query Keys
를 관리하는 방법에 대한 개인적인 판단 기준은 다음과 같습니다:
Query
의 추상화 방식을 결정하고, 팀원 모두가 동의하는 구조로 Query Factories
를 구축하는 방향으로 진행할 수 있습니다. 이 경우, queryOptions
를 활용하여 자체적인 컨벤션과 구조를 개발하고 유지할 수 있습니다.Query Keys
를 관리하고 싶은 경우:@lukemorales/query-key-factory
와 같이 잘 설계된 라이브러리를 활용하여, Query Keys
관리의 복잡성을 줄이고 일관된 구조를 유지할 수 있습니다.결국 모든 선택에는 trade-off가 존재합니다. 독자적인 규격을 개발했을 경우, Tanstack Query
의 스펙 변경에 따라 해당 규격을 수정해야 할 필요가 있을 수 있으며, 반대로 @lukemorales/query-key-factory를
사용할 경우, 해당 라이브러리의 업데이트가 프로젝트의 요구사항을 충족시키지 못하는 상황도 고려해야 합니다. 가장 중요한 것은 Tanstack Query의 지침을 따라 Query Keys를 만들고, 이들을 어떻게 구조화할지에 대한 분명한 기준을 마련하는 것입니다.
긴 글 읽어주셔서 감사합니다.
TkDodo's blog에 작성된 다음의 글을 참고했습니다.
- Effective React Query Keys
- The Query Options API
TkDodo's blog에 연결된 다음의 한글 번역글을 참고했습니다.
- Effective React Query Keys
- The Query Options API
라이브러리의 공식문서를 참고했습니다.
- @lukemorales/query-key-factory
- Tanstack Query
Query Keys
스타일 통일, 어색한 문장 일부 교정
query간의 의존성을 어떻게 하면 효율적으로 관리하고, 하나의 작업이 끝났을 때 의존성을 가진 query들을 어떻게 무효화 시켜야 하는지 고민이 많았는데 많은 도움이 됐습니다!