글을 시작하며

Powerful asynchronous state management for TS/JS, React, Solid, Vue, Svelte and Angular

https://tanstack.com

강력한 비동기 상태관리 도구 Tanstacak-Query는 작성일 기준 npm에서 300만회 이상 다운로드 될 정도로 많은 프런트엔드 개발자들에게 사랑받고 있습니다. gdgd

저도 마찬가지로 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를 관리하는 방법들

Effective React Query Keys

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로 들어간 모든 Queryinvalidate 시킨다던가 말이죠. 아래 예시를 통해 이 개념을 보다 명확히 이해할 수 있습니다.

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']
      })
    },
  })
}

invalidateQueriesqueryKey['todos', 'list']를 전달해줌으로서 ['todos', 'list', { filters: 'all' }]에 해당하는 쿼리와
['todos', 'list', { filters: 'done' }]에 해당하는 쿼리를 한번에 무효화할 수 있었습니다.

이러한 구조적 접근은 Query Keys를 체계적으로 관리하며, 관련 쿼리들에 대한 일관된 처리를 가능하게 합니다.

Query Key factories를 사용

하지만, 이대로 계속해서 수동으로 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 Key FactoryQuery 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 값이 됩니다. 그리고 detaillist의 경우는 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의 관리를 더욱 효율적이고 체계적으로 만듭니다. 이는 학습 곡선이 낮고, 실제 프로젝트에 적용하기 쉬운 라이브러리입니다.

The Query Options API

Effective React Query Keys를 작성한 tkdodo가 다룬 또 다른 주제, The Query Options APIQuery 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)

이 경우에도 런타임 에러는 발생하지 않지만, 의도하지 않은 방식으로 코드가 작동할 수 있으며, 문제를 발견하기 어려울 수 있습니다.

queryOption

queryOptions 함수는 이러한 문제를 해결하는 데 유용한 도구입니다. Tanstack Query의 공식 문서에서는 queryOptions를 사용하여 여러 위치에서 queryKeyqueryFn을 공유하면서도 서로 밀접하게 관리할 수 있는 방법 중 하나로 소개합니다.

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 Factories

거의 다 왔습니다! 조금만 더 집중해봅시다! 💪🏼

그래서 새롭게 제안된 패턴은 Query key Factories가 아닌 Query Factories로 이 접근법은 Query Keys만이 아닌 Query 자체의 추상화를 더욱 진전시키는 방법입니다. 이 방식은 queryOptions를 활용하여 Query KeysQuery Function을 통합 관리합니다.

글 초반에 언급했듯이, queries.tsTanstack Query의 API를 래핑한 커스텀 훅을 내보내며 실제 쿼리 함수와 쿼리 키를 포함하고 있습니다. 하지만 Query들을 집합으로부터 직접 사용하게 되면, 커스텀 훅은 아래와 같이 간소화됩니다.

const useTodos = () => useQuery(todosQuery)

이러한 변화는 커스텀 훅을 통한 추상화의 가치를 다소 감소시키며, 따라서 Query Factories 패턴을 사용함으로써, 커스텀 훅에 과도하게 의존하는 상황을 줄일 수 있게 됩니다. 이는 Query Key Factories에서 한 단계 더 발전한 모델로, Query KeysQueryFn을 분리 배치했던 초기 접근의 한계를 극복하고, 타입 안정성, 코드의 위치 선정 및 개발 경험을 동시에 개선하는 전략을 제안합니다.

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뿐만 아니라 queryFnstaleTime 등 추가적인 옵션까지 함께 관리함으로써 코드의 응집도가 향상됩니다. 이는 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를 만들고, 이들을 어떻게 구조화할지에 대한 분명한 기준을 마련하는 것입니다.

긴 글 읽어주셔서 감사합니다.

비고

참고자료

관련하여 공부해보면 좋을법한 것들

  • 구조적 타이핑
  • 타입 신선도(Freshness)

기타

  • Effective React Query Keys에서 'Always use Array Keys'에 대한 내용은 생략했습니다.
  • 글에 부족한 점이나 오류가 있다면 언제라도 알려주세요. 🙂
  • 주제와 관련된 이야기 및 커피챗도 언제나 환영입니다. 🙌🏻

수정기록

  • 2024.03.17 첫 포스팅
  • 2024.03.24 Query Keys 스타일 통일, 어색한 문장 일부 교정
profile
Time waits for no one

4개의 댓글

comment-user-thumbnail
2024년 3월 27일

query간의 의존성을 어떻게 하면 효율적으로 관리하고, 하나의 작업이 끝났을 때 의존성을 가진 query들을 어떻게 무효화 시켜야 하는지 고민이 많았는데 많은 도움이 됐습니다!

1개의 답글
comment-user-thumbnail
2024년 5월 19일

저도 비슷한 고민을 했었는데 공감이 많이 됐네요. 결론적으로 분명한 기준을 두고 시스템화하는 것이 중요한 것 같습니다. 너무 잘 읽었습니다.

1개의 답글