본문 바로가기

개발

쿼리 키(React Query Key)를 체계적으로 사용하기

쿼리 키의 중요성과 적절히 사용하는 방법은 공식 문서블로그에 잘 정리되어 있다.

 

오늘은 그 중에서도 Query Key Factory를 실제 프로젝트에 도입한 사례를 정리해보려고 한다.

 

프로젝트 규모가 커지다보면 쿼리 키를 체계적으로 관리하기가 어렵다. 규칙을 세워도 점점 일관성이 깨지거나 휴먼 에러가 생기고, 서로 다른 파일에서 쿼리 키를 참조하는 등 안 좋은 패턴이 발생한다. 위 블로그 글에 나오는 쿼리 키 팩토리를 도입하여 이것을 어느 정도 해소할 수 있었다.

 

const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
};

 

블로그에 나오는 예시다. all이 가장 상위의 키, 그리고 차례대로 모든 리스트(lists), 특정 조건을 만족하는 리스트(list) 키가 존재한다.

 

특정 키 내에 여러 레벨이 고려되어 있는 구조다. 이것을 참고하여 프로젝트에 적용했는데, 순서대로 살펴보면 다음과 같다.

 

1-1. 계층 구조 반영하기

나의 경우 프로젝트에 키들 간의 계층 관계가 존재했다. 많은 실제 프로젝트가 이럴 것이라고 생각한다.

 

예를 들어, todo 하위에 sub-todo 가 있다고 가정해보자. sub-todo는 todo 하나에 귀속되어 있고, 상위의 todo가 완료되면 sub-todo도 모두 서버에서 완료 처리된다. 즉 특정 todo의 완료여부가 변경되면, 하위 sub-todo들이 invalidate되어야 했다.

 

그래서 sub-todo의 키를 다음과 같이 작성해보았다.

 

const todoKeys = {
  // ... 위와 동일
  detail: (id: number) => [...todoKeys.all, id] as const,
};

const subTodoKeys = {
  all: (id: number) => [...todoKeys.detail(id), 'subTodos'] as const,
  // lists: (id: number) => []
  // list: (id: number, filters: filter) => []
};

 

subTodoKeys.all(2)의 결과값이 ['todos', 2, 'subTodos']로 나오는 형태다. todoKeys.detail(2)를 invalidate하면 하위의 모든 sub-todo key들(all, lists, list)은 모두 invalidate될 것이다.

 

계층 구조가 반영은 되었지만, 몇 가지 문제점이 보인다.

 

코드를 유심히 살펴봐야 subTodoKeys에서 todoKeys를 참조한다는 것을 알 수 있고, subTodoKeys에 모든 key에 id라는 파라미터가 들어가야 해서 가독성이 좋지 않다.

 

1-2. 개선

키를 함수 형태로 변경하여 두 가지 문제를 어느 정도 개선할 수 있었다.

 

const subTodoKeys = (todoId: number) => {
  const all = [...todoKeys.detail(todoId), 'subTodos'] as const;
  const lists = () => [...all, 'lists'] as const;
  const list = (filters: Filter) => [...lists(), filters] as const;

  return {
    all,
    lists,
    list,
  };
};

 

todoId 파라미터를 받음으로써 특정 todo에 종속된다는 것이 어느 정도는 표현되었고, 키에서는 todoId를 넣어주지 않아도 되게 변경하여 가독성이 좋아졌다.

 

todoKeys의 프로퍼티로 sub-todo를 넣으면 되지 않느냐고 반문할 수 있을텐데, 실제 프로젝트에는 이보다 더 많은 하위 계층이 존재했고, 각각의 도메인(sub todo와 같은)에도 여러 세부사항이 존재하여 키를 따로 표현하는 것이 더 적절하다고 판단했다.

 

2. 반복되는 코드 제거하기

위와 같은 형태로 계속 쿼리 키를 작성하다보니 특정 쿼리 키 팩토리에 all, lists, list 키가 거의 반드시 존재했다. 이것들을 생성해주는 함수를 정의해서 코드를 간결화할 수 있었다.

 

import type { QueryKey } from "@tanstack/react-query";
import type { Filters } from "src/type";

interface DefaultQueryKeyFactory {
  all: () => QueryKey; // all도 함수 형태로 통일
  lists: () => QueryKey;
  list: (f: Filters) => QueryKey;
}

const generateDefaultFactory = (allKey: QueryKey): DefaultQueryKeyFactory => {
  const all = () => allKey;
  const lists = () => [...all(), "list"];
  const list = (f: Filters) => [...lists(), f];

  return { all, lists, list };
};

// 사용할 때는
const todoKeys = () => generateDefaultFactory(["todos"]);

 

QueryKey 타입이 이미 readonly로 정의되어 있어서, 반복되는 as const를 더 이상 작성하지 않아도 되는 이점도 생겼다.

실제 todo, sub-todo에 적용해보면 다음과 같다.

 

interface TodoKeys extends DefaultQueryKeyFactory {
  detail: (id: number) => QueryKey;
}

const todoKeys = (): TodoKeys => {
  const defaultKeys = generateDefaultFactory(["todos"]);
  const detail = (id: number) => [...defaultKeys.all(), id];

  return {
    ...defaultKeys,
    detail,
  }
};

const subTodoKeys = (todoId: number) => {
  const defaultKeys = generateDefaultFactory([...todoKeys().detail(todoId), "subTodos"]);

  return defaultKeys;
}

 

최종적으로 이러한 형태로 모든 키를 한 파일에 정의해두었다.

 

새로운 키를 정의할 때도 한 번 더 구조를 참고하게 되니 일관성이 유지되었고, 필요할 때 해당 파일에서 키를 가져다써서 휴먼 에러도 방지되었다.

 

키를 체계적으로 관리함으로써 캐시, invalidate 등도 좀 더 효과적으로 활용할 수 있게 되었다.