(번역) #8a: Leveraging the Query Function Context

176
content-logo

Table of Contents

본문

TkDodoLeveraging the Query Function Context를 번역한 문서입니다.

우리는 모두 엔지니어로서 개선을 목표로 하며, 시간이 지날수록, 이러한 노력이 성공하기를 희망합니다. 우리는 아마도 기존의 생각을 무효화하거나, 기존의 생각에 도전하는 새로운 것을 배울 수도 있습니다. 또는 우리가 이상적이라고 생각했던 패턴이 지금 우리가 필요한 수준으로까지 확장되지 못할 것을 깨닫기도 합니다.

React Query를 사용하기 시작한지도 꽤 오랜 시간이 지났습니다. 저는 이 여정에서 정말 대단한 것을 배웠고 많은 것을 “경험”했다고 생각합니다. 저는 제 블로그를 가능한한 최신 상태로 유지해서, 여러분들이 다시 방문해서 다시 읽으며 개념이 여전히 유효하다는 것을 알아가셨으면 좋겠습니다. 이는 지금 훨씬 중요한데요. Tanner LinsleyReact Query 공식 문서에 제 블로그 링크를 걸어두었기 때문입니다.

따라서 저는 Effective React Query keys에 추가 게시물을 작성하기로 했습니다. 이 게시물의 내용을 이해하기 위해서 해당 글을 먼저 읽고 와주세요.

논란이 될 수 있는 의견 (Hot take)

💡 인라인 함수를 사용하지 마세요

주어진 Query Function Context를 활용하고 객체 키를 생성하는 쿼리 키 팩토리를 사용하세요.

인라인 함수는 queryFn에 매개변수를 전달하는 가장 쉬운 방법입니다. 왜냐하면 커스텀 훅 안에서 사용 가능한 다른 변수를 클로저로 사용할 수 있기 때문입니다. 끝나지 않는 우리의 할 일 예시를 다시 보시죠.

// inline-query-fn

type State = 'all' | 'open' | 'done';
type Todo = {
  id: number;
  state: TodoState;
};
type Todos = ReadonlyArray<Todo>;

const fetchTodos = async (state: State): Promise<Todos> => {
  const response = await axios.get(`todos/${state}`);
  return response.data;
};

export const useTodos = () => {
  // 이 훅을 통해 url 같은 곳에서 현재 사용자의 선택 사항을
  // 불러온다고 가정하겠습니다.
  const { state } = useTodoParams();

  // ✅ queryFn 는 전달 받은 상태를 클로저로 사용하는 인라인 함수입니다.
  return useQuery({
    queryKey: ['todos', state],
    queryFn: () => fetchTodos(state),
  });
};

이 예시를 알아보셨을 수도 있을 것 같아요. 이 예시는 #1: Practical React Query - 쿼리 키를 의존성 배열처럼 사용하세요를 약간 변형한 것입니다. 이는 간단한 예시에서는 잘 동작할지 몰라도 매개변수가 많아지면 상당한 문제를 일으킬 수 있습니다. 큰 규모의 앱에서는 많은 필터 및 정렬 옵션이 있을 수 있으며, 개인적으로 10개의 매개변수가 전달되는 것도 봤습니다.

쿼리에 정렬을 추가하는 상황을 가정해보겠습니다. 저는 이러한 작업을 바닥에서부터 접근하는 것을 좋아합니다. 즉, queryFn부터 시작해서 컴파일러가 저에게 다음으로 필요한 것을 알려주도록 하는 방식입니다.

// sorting-todos

type Sorting = 'dateCreated' | 'name';
const fetchTodos = async (state: State, sorting: Sorting): Promise<Todos> => {
  const response = await axios.get(`todos/${state}?sorting=${sorting}`);
  return response.data;
};

이는 확실히 커스텀 훅의 fetchTodos를 호출하는 부분에서 에러를 일으키겠네요. 한 번 고쳐봅시다.

// useTodos-with-sorting

export const useTodos = () => {
  const { state, sorting } = useTodoParams();

  // 🚨 실수가 보이시나요? ⬇️
  return useQuery({
    queryKey: ['todos', state],
    queryFn: () => fetchTodos(state, sorting),
  });
};

문제점을 이미 발견하셨을 수도 있을 것 같습니다. queryKey는 실제 의존성과 동기화되지 않았고, 이에 대한 어떠한 빨간 줄의 경고도 없습니다. 😔 위의 케이스에서는 이슈를 매우 빠르게 발견할 것입니다. (통합 테스트를 통해 발견되면 좋겠습니다.) 왜냐하면 정렬 기준을 변경해도 데이터를 자동으로 다시 불러오지 않을 것이기 때문입니다. 그리고 솔직히 얘기하면 이 간단한 예시에서는 꽤 확실하게 찾을 수 있는 이슈입니다. 하지만 저는 지난 몇 달 동안 queryKey가 실제 의존성과 맞지 않는 것을 몇 차례 봐왔습니다. 더 복잡한 경우에는 이슈를 추적하기 어려워질 수도 있습니다. React가  react-hooks/exhaustive-deps eslint rule을 제공하는 이유도 이 이슈를 피하기 위해서 입니다.

그렇다면 React Query도 자체적인 eslint 규칙을 마련해야하는 걸까요? 👀

하나의 선택지가 될 수 있을 것 같아요. 또한 babel-plugin-react-query-key-gen 가 있는데요. 이 플러그인은 의존성까지 포함해서 자동으로 쿼리 키를 생성해주면서 문제를 해결합니다. 하지만 React Query는 다른 방식의 내장 방법을 통해 의존성을 처리합니다. 바로 QueryFunctionContext입니다.

업데이트

앞서 언급했던 린트 규칙이 나왔습니다. 문서를 읽어주세요. 🚀

QueryFunctionContext

QueryFunctionContextqueryFn에 매개 변수로 전달되는 객체입니다. 여러분은 infinite 쿼리를 작업할 때 아마도 이미 사용해보셨을 겁니다.

// useInfiniteQuery

// 이게 QueryFunctionContext 입니다 ⬇️
const fetchProjects = ({ pageParam }) => fetch('/api/projects?cursor=' + pageParam);

useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  initialPageParam: 0,
});

React Query는 쿼리에 대한 정보를 queryFn에 주입할 때 해당 객체를 사용합니다. infinite 쿼리의 경우, getNextPageParam의 반환값이 pageParam에 주입됩니다.

하지만, 컨텍스트에는 이 쿼리에서 사용되는 queryKey도 포함되어 있습니다. (그리고 저희는 몇 가지 멋진 기능을 컨텍스트에 더 추가할 예정입니다.) 따라서 클로저를 사용할 필요가 없습니다. React Query에서 제공해 줄 것이기 때문입니다.

// query-function-context

const fetchTodos = async ({ queryKey }) => {
  // 🚀 queryKey로부터 모든 매개변수를 가져올 수 있습니다.
  const [, state, sorting] = queryKey;
  const response = await axios.get(`todos/${state}?sorting=${sorting}`);
  return response.data;
};

export const useTodos = () => {
  const { state, sorting } = useTodoParams();

  // ✅ 매개변수를 수동으로 제공할 필요가 없습니다.
  return useQuery({
    queryKey: ['todos', state, sorting],
    queryFn: fetchTodos,
  });
};

이 방법을 사용하면, 추가적인 매개변수를 queryFn에 적용하는 것은 queryKey에 추가하는 방법으로만 가능합니다.🎉

QueryFunctionContext에 타입을 지정하는 방법 (How to type the QueryFunctionContext)

이 접근 방식의 목표 중 하나는 완전한 타입 안정성을 확보하고, useQuery에 전달된 queryKey로부터 QueryFunctionContext의 타입을 추론하는 것이었습니다. 쉽지 않았지만 React Query는 v3.13.3부터 이를 지원하기 시작했습니다. queryFn을 인라인으로 작성하면 타입이 적절하게 추론되는 것을 보실 것입니다. (제네릭에게 감사를 표합니다.)

// query-key-type-inference

export const useTodos = () => {
  const { state, sorting } = useTodoParams();

  return useQuery({
    queryKey: ['todos', state, sorting] as const,
    queryFn: async ({ queryKey }) => {
      const response = await axios.get(
        // ✅ queryKey가 튜플이기 때문에 안전합니다
        `todos/${queryKey[1]}?sorting=${queryKey[2]}`,
      );
      return response.data;
    },
  });
};

이는 좋은 방법이지만 몇 가지 결함이 있습니다.

  • 여전히 클로저 내부에 있는 것을 사용해서 쿼리를 작성할 수 있습니다.
  • 위의 방식에서 queryKey를 사용하여 url을 작성하는 것은 안전하지 않습니다. 모든 것을 문자열로 변환할 수 있기 때문입니다.

쿼리 키 팩토리 (Query Key Factories)

여기서 쿼리 키 팩토리가 다시 등장합니다. 만약 타입 안정성이 확보된 쿼리 키 팩토리를 통해 키를 구성한다면, 해당 백토리의 반환 타입을 갖고 QueryFunctionContext의 타입을 지정할 수 있습니다. 다음의 예시를 살펴보세요.

// typed-query-function-context

const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (state: State, sorting: Sorting) => [...todoKeys.lists(), state, sorting] as const,
};

const fetchTodos = async ({
  queryKey,
}: // 🤯 팩토리의 키만 허용합니다
QueryFunctionContext<ReturnType<(typeof todoKeys)['list']>>) => {
  const [, , state, sorting] = queryKey;
  const response = await axios.get(`todos/${state}?sorting=${sorting}`);
  return response.data;
};

export const useTodos = () => {
  const { state, sorting } = useTodoParams();

  // ✅ 팩토리를 통해 키를 제작합니다
  return useQuery({
    queryKey: todoKeys.list(state, sorting),
    queryFn: fetchTodos,
  });
};

QueryFunctionContext 타입은 React Query가 export한 값입니다. 해당 타입은 queryKey의 타입을 정의하는 한 개의 제네릭을 받습니다. 위의 예시에서는, 키 팩토리의 list함수가 반환하는 값과 동일하게 설정했습니다. const assertions를 사용하기 때문에, 모든 키는 엄격하게 타입이 지정된 튜플이 될 것입니다. 따라서 해당 구조와는 맞지 않는 키를 사용하려고 하면 타입 에러가 발생할 것입니다.

객체 쿼리 키 (Object Query Keys)

위의 접근 방식으로 천천히 전환하는 과정에서, 저는 배열로 된 키가 그렇게 잘 동작하지는 않는다는 것을 알아차렸습니다. 이는 해당 쿼리 키를 구조 분해하는 과정을 살펴보면 더욱 명확해집니다.

// weird-destruct

const [, , state, sorting] = queryKey;

기본적으로 처음 두 개의 부분은 공백으로 두고 (하드 코딩된 todolist의 스코프) 동적인 부분만 남겨뒀습니다. 물론, 첫 번째에 다른 스코프를 바로 추가했지만 이로 인해 url이 다시 한 번 잘못 생성되었습니다.

PR recently i made

소스코드: 최근에 제가 작성한 PR입니다.

여기서 알 수 있듯이, 객체는 명명 구조 분해 할당을 사용하기 때문에 이 문제를 훌륭하게 해결합니다. 더욱이, 객체는 쿼리 키 내부에서 사용할 때 아무런 단점이 없습니다. 왜냐하면 쿼리 무효화를 위한 퍼지 매칭은 객체에 대해서도 배열과 동일하게 작동하기 때문입니다. 이에 관해서 어떻게 작동하는지 관심이 있다면 partialDeepEqual 함수를 살펴보세요.

이 점을 염두에 두고 현재까지 제가 알고 있는 지식을 사용해서 쿼리 키를 구성하는 방법은 다음과 같습니다.

// object-keys

const todoKeys = {
  // ✅ 모든 키는 정확히 하나의 객체로 구성된 배열
  all: [{ scope: 'todos' }] as const,
  lists: () => [{ ...todoKeys.all[0], entity: 'list' }] as const,
  list: (state: State, sorting: Sorting) => [{ ...todoKeys.lists()[0], state, sorting }] as const,
};

const fetchTodos = async ({
  // ✅ 쿼리 키에서 명명된 속성을 추출
  queryKey: [{ state, sorting }],
}: QueryFunctionContext<ReturnType<(typeof todoKeys)['list']>>) => {
  const response = await axios.get(`todos/${state}?sorting=${sorting}`);
  return response.data;
};

export const useTodos = () => {
  const { state, sorting } = useTodoParams();

  return useQuery({
    queryKey: todoKeys.list(state, sorting),
    queryFn: fetchTodos,
  });
};

객체 쿼리 키는 순서가 없기 때문에 퍼지 매칭 기능을 더욱 강력하게 만들어줍니다. 배열 쿼리 키 방식은 모든 할 일 관련 항목, 모든 할 일 목록 또는 특정 분류의 할 일 목록 등을 다룰 수 있습니다. 객체 쿼리 키 방식을 사용하면 이 것도 할 수 있을 뿐만 아니라 원한다면 모든 목록을 다룰 수 있습니다. (예를 들어, 할 일 목록과 프로필 목록 등)

// fuzzy-matching-with-object-keys

// 🕺 할 일 기능과 관련된 모든 것을 제거
queryClient.removeQueries({
  queryKey: [{ scope: 'todos' }],
});

// 🚀 모든 할 일 목록을 초기화
queryClient.resetQueries({
  queryKey: [{ scope: 'todos', entity: 'list' }],
});

// 🙌 모든 스코프에서 모든 할 일 목록을 무효화
queryClient.invalidateQueries({
  queryKey: [{ entity: 'list' }],
});

이 방식은 여러 겹치는 계층 구조를 가진 중첩된 범위가 있는 경우, 하위 범위에 속하는 모든 것을 일치시키고 싶을 때 꽤 유용합니다.

이 방식은 가치가 있을까요? (Is this worth it?)

언제나 그랬듯이, 상황에 따라 다릅니다. 저는 최근 들어 이 방식을 매우 좋아하게 되었어요. (그래서 여러분들께 공유하고 싶었습니다.) 하지만 이 방식에는 분명히 복잡도와 타입 안정성 간의 트레이드 오프가 있습니다. 쿼리 키를 키 팩토리 내부에서 구성하는 것은 약간 복잡합니다. (왜냐하면 쿼리 키는 최상위 레벨에서는 여전히 배열이어야 하기 때문입니다.) 그리고 키 팩토리의 반환 타입에 따라서 컨텍스트를 타이핑하는 것도 그리 쉬운 일이 아닙니다. 만약 팀이 작은 규모이거나 api 인터페이스가 간소하거나 또는 순수 자바스크립트만 사용한다면, 해당 방식을 사용하지 않을 수도 있습니다. 일반적으로, 주어진 상황에서 가장 적합한 도구와 접근 방식을 선택하세요. 🙌