새소식

반응형
Front-end

[React] Tanstack Query(구 React-Query)에서 query key 관리하는 라이브러리(query-key-factory)

2024.11.16
  • -
반응형

1. 들어가며

Tanstack Query는 React에서 데이터 페칭을 위해 다양한 기능을 제공하는 라이브러리로, 캐싱 및 동기화, 데이터 업데이트 등을 효율적으로 관리할 수 있습니다.

 

Tanstack Query에서 'Query Key'는 핵심 요소 중에 하나로, 데이터를 식별하는 key로 동작하여 데이터의 고유성(unique)을 식별하고 알맞은 캐싱 동작이 이루어지도록 하는 데 중요한 역할을 합니다.

 

2. Query Key Factory

Query Key Factory는 Tanstack Query에서 Query Key를 생성하기 위한 함수나 객체 자체를 의미하빈다. 복잡하거나 계층적인 구조를 갖는 Query Key를 관리해야 하는 경우 유용하게 사용될 수 있는데요.

 

Query Key Factory 기법을 사용하는 이유는 크게 일관성 유지, 재사용성, 가독성 향상 등의 이유로 사용됩니다.

  • 일관성 유지
    • 프로젝트 전반에서 Query Key가 중복되거나 불일치하는 문제를 방지합니다.
    • 동일한 데이터에 대해 항상 같은 Query Key를 생성하도록 보장합니다.
  • 재사용성
    • 동일한 데이터 모델을 다룰 때 Query Key 생성 로직을 반복하지 않고 재사용할 수 있습니다.
    • 새로운 API나 데이터 구조가 추가되어도 Factory를 수정하면 전체 코드에 적용됩니다.
  • 가독성 향상
    • Query Key를 하드코딩하지 않고 의미 있는 구조로 관리하여 가독성을 높입니다.
    • Query Key의 구성과 목적을 명확히 알 수 있습니다.

 

제가 이 글을 정리하면서 가장 와닿는 이 기법의 장점은 '하드코딩'하지 않고에 있는 것 같습니다.

 

Tanstack Query로 프로젝트를 종종 진행하다 보면 query key 작성할 일이 굉장히 많은데 일일히 하드코딩 하기 때문에 오타나 잘못된 쿼리 키가 들어간 경우가 꽤 많이 있었습니다. 하지만 factory를 사용하면 그런 일이 발생할 일이 매우 줄어들 것입니다.

 

Query Key Factory의 구조

Query Key Factory는 보통 객체나 함수로 구현되며, 데이터 엔티티별로 Query key를 생성합니다.

 

  1. 정적 Query Key 생성
    • 특정 리소스에 대해 고정된 Query Key를 생성합니다.
  2. 동적 Query Key 생성
    • 함수에 매개변수를 전달하여, 동적인 Query key를 생성합니다.

 

예시

객체 기반의 Query Key Factory를 구성한 코드는 아래와 같습니다.

export const queryKeyFactory = {
  user: {
    all: ['user'], // 모든 사용자
    detail: (userId) => ['user', userId], // 특정 사용자
  },
  post: {
    all: ['post'], // 모든 게시글
    detail: (postId) => ['post', postId], // 특정 게시글
    comments: (postId) => ['post', postId, 'comments'], // 특정 게시글의 댓글
  },
};

 

이를 사용하려면 아래와 같이 사용할 수 있습니다.

import { queryKeyFactory } from './queryKeyFactory';

// 사용자 상세 데이터 가져오기
useQuery(queryKeyFactory.user.detail(1), fetchUser);

// 게시글 댓글 가져오기
useQuery(queryKeyFactory.post.comments(10), fetchPostComments);

 

함수 기반의 Query Key Factory는 아래와 같습니다.

export const createQueryKey = (prefix, id) => [prefix, id];

// 사용 예시
const userKey = createQueryKey('user', userId);
const postKey = createQueryKey('post', postId);

useQuery(userKey, fetchUser);
useQuery(postKey, fetchPost);

 

3. @lukemorales/query-key-factory

 

GitHub - lukemorales/query-key-factory: A library for creating typesafe standardized query keys, useful for cache management in

A library for creating typesafe standardized query keys, useful for cache management in @tanstack/query - lukemorales/query-key-factory

github.com

 

 

 

이를 라이브러리화하여 조금 더 번거로운 작업을 줄여주고 효율적이게 사용할 수 있도록 도와주는 라이브러리가 있습니다.

 

현재(2024-11-16)기준 1.2k 의 스타수를 갖고 있습니다.

 

먼저 한 파일에 모든 key가 들어간 store를 정의하는 방법이 있습니다.

import { createQueryKeyStore } from "@lukemorales/query-key-factory";

// if you prefer to declare everything in one file
export const queries = createQueryKeyStore({
  users: {
    all: null,
    detail: (userId: string) => ({
      queryKey: [userId],
      queryFn: () => api.getUser(userId),
    }),
  },
  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,
          }),
        }),
      },
    }),
  },
});

 

 

하지만 이렇게 하면 유지보수 관리 측면에서 힘들 수 있기 때문에 선언을 기능별로 정렬하여 세분화할 수 있습니다.

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);

약간의 설명을 하자면 아래와 같습니다.

  • user
    • all: 모든 사용자 데이터를 가져오기 위한 Query Key 입니다. 이 경우 단순한 키 값만 반환합니다.
    • detail: 특정 사용자의 상세 정보를 가져오기 위한 Query Key와 queryFn(데이터를 가져오는 함수)를 정의합니다.
  • todos
    • list: 필터링된 Todo 리스트를 가져오기 위한 query key와 queryFn을 설정합니다.
    • contextQueries: list Query에 연관된 세부 기능을 설정합니다.(예: 검색)

 

mergeQueryKeys는 여러 Query Key factory를 병합하여 중앙에서 관리할 수 있도록 합니다.

 

 

 

query key 혹은 캐시 관리를 위한 완전체 쿼리를 작성하는 단일 소스로 코드베이스 전체에서 사용하도록 합니다.

import { queries } from '../queries';

export function useUsers() {
  return useQuery({
    ...queries.users.all,
    queryFn: () => api.getUsers(),
  });
};

export function useUserDetail(id: string) {
  return useQuery(queries.users.detail(id));
};
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,
      });
    },
  });
};

 

이처럼 useMutation에서 Query Key를 활용해 데이터 업데이트와 캐시 무효화를 보다 효율적으로 관리할 수 있습니다.

 

가장 좋은 점은 더욱 명시적인 형태로 코드의 의도를 쉽게 이해할 수 있습니다.

  • ex) queries.users.detail(id)

 

4. 여러 기능들

표준화된 key

생성되는 모든 key들은 top level에 배열이 되도록 하는 @tanstack/query 컨벤션을 따르며 직렬화가능한 객체를 가진 key들을 포함합니다.

export const todos = createQueryKeys('todos', {
  detail: (todoId: string) => [todoId],
  list: (filters: TodoFilters) => ({
    queryKey: [{ filters }],
  }),
});

// => createQueryKeys 생성 결과:
// {
//   _def: ['todos'],
//   detail: (todoId: string) => {
//     queryKey: ['todos', 'detail', todoId],
//   },
//   list: (filters: TodoFilters) => {
//     queryKey: ['todos', 'list', { filters }],
//   },
// }

 

 

queryKey는 dynamic query가 아니라면 굳이 넣어주지 않아도 됩니다.

 

useQuery 실행에 필요한 query options 생성

queryKey와 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),
//   },
// }

export function useUserDetail(id: string) {
  return useQuery(users.detail(id));
};

 

contextual quries 생성

상위 컨텍스트(context)에 종속되거나 관련된 쿼리를 선언합니다.(ex: 사용자의 모든 '좋아요')

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);
};

 

직렬화가능한(serializable) key 범위 정의에 대한 액세스

직렬화 가능한 key scope에 쉽게 액세스하고 해당 컨텍스트에 대한 모든 캐시를 무효화할 수 있도록 제공합니다.

users.detail(userId).queryKey; // => ['users', 'detail', userId]
users.detail._def; // => ['users', 'detail']

 

모든 쿼리 키에 대한 단일 액세스 지점 만들기

단일 파일에 쿼리 키 저장소 선언하기

저장소를 한 곳에서 편집하고 유지관리할 수 있습니다.

export const queries = createQueryKeyStore({
  users: {
    all: null,
    detail: (userId: string) => ({
      queryKey: [userId],
      queryFn: () => api.getUser(userId),
    }),
  },
  todos: {
    detail: (todoId: string) => [todoId],
    list: (filters: TodoFilters) => ({
      queryKey: [{ filters }],
      queryFn: (ctx) => api.getTodos({ filters, page: ctx.pageParam }),
    }),
  },
});

 

기능별로 쿼리 키 선언하기

기능 단위의 key를 세밀하게 제어하고 단일 객체로 병합하여 코드베이스의 모든 쿼리 키에 액세스할 수 있습니다:

// 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 }),
  }),
});

// queries/index.ts
export const queries = mergeQueryKeys(users, todos);

 

타입 안전성과 스마트 자동완성

TypeScript는 query key factory의 일류 시민으로, 사용 가능한 모든 쿼리 키와 그 결과물에 대해 사용 편의성과 자동 완성 기능을 제공합니다.

 

키가 직렬화 가능한지 기억이 나지 않으신다고요? 키의 모양이 기억나지 않으신다고요?

 

IDE가 필요한 모든 정보를 보여주기만 하면 됩니다.

 

store의 query keys의 타입을 추론

import { createQueryKeyStore, inferQueryKeyStore } from "@lukemorales/query-key-factory";

export const queries = createQueryKeyStore({
  /* ... */
});

export type QueryKeys = inferQueryKeyStore<typeof queries>;
// queries/index.ts
import { mergeQueryKeys, inferQueryKeyStore } from "@lukemorales/query-key-factory";

import { users } from './users';
import { todos } from './todos';

export const queries = mergeQueryKeys(users, todos);

export type QueryKeys = inferQueryKeyStore<typeof queries>;

 

 

기능별 query keys의 타입을 추론

import { createQueryKeys, inferQueryKeys } from "@lukemorales/query-key-factory";

export const todos = createQueryKeys('todos', {
  detail: (todoId: string) => [todoId],
  list: (filters: TodoFilters) => ({
    queryKey: [{ filters }],
    queryFn: (ctx) => api.getTodos({ filters, page: ctx.pageParam }),
  }),
});

export type TodosKeys = inferQueryKeys<typeof todos>;

 

QueryFunctionContext를 쉽게 입력할 수 있습니다.

query key의 정확한 타입을 queryFn 컨텍스트로 전달받습니다. 이는 QueryFunctionContext를 통해 query key와 queryFn의 타입을 정밀하게 연결할 수 있는 것입니다.

import type { QueryKeys } from "../queries";
// import type { TodosKeys } from "../queries/todos";

type TodosList = QueryKeys['todos']['list'];
// type TodosList = TodosKeys['list'];

const fetchTodos = async (ctx: QueryFunctionContext<TodosList['queryKey']>) => {
  const [, , { filters }] = ctx.queryKey;

  return api.getTodos({ filters, page: ctx.pageParam });
}

export function useTodos(filters: TodoFilters) {
  return useQuery({
    ...queries.todos.list(filters),
    queryFn: fetchTodos,
  });
};

 

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.