🔥 쿼리 옵션 API

1016자
13분

React Query 버전 5가 약 3개월 전에 출시되었습니다. 이 버전에서는 라이브러리 역사상 가장 큰 파괴적인 변경 사항(breaking change) 하나가 도입되었습니다. 이제 모든 함수는 여러 인자 대신 하나의 객체만 받습니다. 우리는 이 객체를 쿼리 옵션(Query Options)이라고 부릅니다. 쿼리를 생성하는 데 필요한 모든 옵션이 포함되어 있기 때문입니다.

- useQuery(
-   ['todos'],
-   fetchTodos,
-   { staleTime: 5000 }
- )
+ useQuery({
+   queryKey: ['todos'],
+   queryFn: fetchTodos,
+   staleTime: 5000
+ })
 
diff

이는 useQuery 호출뿐만 아니라 쿼리를 무효화하는 것과 같은 명령형 동작에도 적용됩니다:

- queryClient.invalidateQueries(['todos'])
+ queryClient.invalidateQueries({ queryKey: ['todos'] })
 
diff

엄밀히 말하면 이 API는 새로운 것이 아닙니다. 대부분의 함수에는 오버로드가 있어서 v3에서도 이미 여러 인자 대신 객체를 전달할 수 있었습니다. 다만 이 방식이 적극적으로 권장되지 않았을 뿐입니다. 모든 예제, 문서, 많은 블로그 포스트(이 글 포함)에서 이전 API를 사용했기 때문에 대부분의 앱에서 이는 파괴적인 변경이었습니다.

그렇다면 우리는 왜 이런 변경을 했을까요?

더 나은 추상화

첫째, 많은 오버로드를 갖는 것은 유지 보수자에게 부담이 되고 사용자에게도 명확하지 않습니다. 왜 같은 함수를 여러 방식으로 호출할 수 있는지, 한 방식이 다른 방식보다 나은지 의문이 들 수 있습니다. 따라서 API를 간소화하여 새로운 사용자가 이해하기 쉽게 만드는 것이 하나의 목표였습니다. "항상 하나의 객체만 전달하라"는 원칙은 간단하면서도 확장성이 뛰어납니다.

또한, 하나의 객체로 모든 것을 제어하는 방식이 다른 함수 간에 쿼리 옵션을 공유하고자 할 때 매우 좋은 추상화라는 것이 밝혀졌습니다. 나는 "React Query meets React Router" 글을 쓰면서 우연히 이 사실을 발견했습니다. 이 글에서는 미리 가져오기와 useQuery 호출 사이에 쿼리 옵션을 공유하고자 했습니다. 보통은 쿼리를 재사용하기 위해 사용자 정의 훅을 작성하는 것이 주된 방법입니다. 하지만 미리 가져오기와 같은 명령형 함수 호출이 포함된 경우에는 이 방법이 통하지 않습니다. 그래서 나는 새로운 방법을 고안했고, Alex는 이를 좋은 패턴이라고 언급했습니다:

@ralex1993

React Query의 이 패턴을 처음 본 것은 @TkDodo의 React Router 블로그 포스트에서였습니다.
정말 훌륭해요 👏"

https://dev.to/tkdodo/react-query-meets-react-router-38f3

lecture image

모든 함수가 동일한 인터페이스, 즉 단일 객체를 받는다면 그 객체를 쿼리 정의로 추상화하는 것이 매우 합리적입니다. 이렇게 정의한 쿼리를 어디에서나 사용할 수 있습니다:

const todosQuery = {
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 5000,
}
 
// ✅ 작동합니다
useQuery(todosQuery)
 
// 🤝 물론이죠
queryClient.prefetchQuery(todosQuery)
 
// 🙌 당연히 됩니다
useSuspenseQuery(todosQuery)
 
// 🎉 완벽합니다
useQueries([{
  queries: [todosQuery]
}]
 
javascript

돌이켜 보면, 이 패턴은 쿼리를 위한 주요 추상화로 아름답게 느껴집니다. 나는 이를 모든 곳에 적용하고 싶었습니다. 하지만 한 가지 문제가 있었습니다:

타입스크립트

타입스크립트가 초과 속성을 처리하는 방식은 매우 특별합니다. 객체를 인라인으로 작성하면 타입스크립트는 "왜 이렇게 하는 거야? 말이 안 되는데, 오류를 내보내야겠어"라고 생각합니다:

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  stallTime: 5000,
})
 
typescript

TypeScript playground

객체 리터럴은 알려진 속성만 지정할 수 있지만, stallTime은 타입 UseQueryOptions<Todo[], Error, Todo[], string[]>에 존재하지 않습니다. 'staleTime'을 쓰려고 한 것 아닌가요?(2769)

이는 위와 같은 오타를 잡아내기 때문에 좋습니다. 하지만 우리 패턴이 제안하는 대로 전체 객체를 상수로 추상화하면 어떻게 될까요?

const todosQuery = {
  queryKey: ['todos'],
  queryFn: fetchTodos,
  stallTime: 5000,
}
 
useQuery(todosQuery)
 
typescript

TypeScript playground

오류가 발생하지 않습니다. 🙈

타입스크립트는 이런 상황에서 꽤 관대합니다. 런타임에서 "초과" 속성인 stallTime은 해를 끼치지 않고, 해당 속성이 필요한 컨텍스트에서 이 객체를 사용하고 싶을 수도 있기 때문입니다. 타입스크립트는 이를 알 수 없습니다. 그리고 staleTime은 선택적이기 때문에 우리는 이제 그냥 전달하지 않은 것이 됩니다. 물론 이는 "유효"하지만, 우리가 예상한 것과는 다르며 발견하기 어려운 실수가 될 수 있습니다.

queryOptions

이것이 바로 v5에서 queryOptions라는 타입 안전한 헬퍼 함수를 도입한 이유입니다. 런타임에서는 아무 일도 하지 않습니다:

export function queryOptions(options) {
  return options
}
 
javascript

하지만 타입 수준에서는 위의 오타 문제를 해결할 뿐만 아니라 (수정된 플레이그라운드 참조) queryClient의 다른 부분도 더 타입 안전하게 만드는 데 도움이 됩니다:

DataTag

React Query에서 queryClient.getQueryData와 유사한 함수들에 대해 항상 좀 성가신 점이 있었습니다: 타입 수준에서 이들은 unknown을 반환합니다. React Query에는 중앙 집중식 정의가 없기 때문에 queryClient.getQueryData(['todos'])를 호출할 때 어떤 타입이 반환될지 라이브러리가 알 수 있는 방법이 없습니다.

우리는 함수 호출에 타입 매개변수를 제공하여 직접 도와야 합니다:

const todos = queryClient.getQueryData<Array<Todo>>(['todos'])
//    ^? const todos: Todo[] | undefined
 
typescript

명확히 말하면, 이는 타입 단언을 사용하는 것보다 전혀 안전하지 않지만, 적어도 undefined가 유니온에 추가됩니다. fetchTodos 엔드포인트가 반환하는 내용을 리팩터링하면 여기서 새로운 타입에 대해 알림을 받지 못합니다. 😔

하지만 이제 queryKeyqueryFn을 함께 배치하는 함수가 있으므로 queryFn의 타입을 연결하고 우리의 queryKey를 "태그"할 수 있습니다. queryOptions를 통해 생성된 queryKeygetQueryData에 전달할 때 어떤 일이 일어나는지 주목해 보세요:

const todosQuery = queryOptions({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 5000,
})
 
const todos = queryClient.getQueryData(todosQuery.queryKey)
//    ^? const todos: Todo[] | undefined
 
typescript

TypeScript Playground

🤯

이는 순수한 타입스크립트 마법으로, Mateusz Burzyński가 기여한 것입니다. todosQuery.queryKey를 살펴보면 단순한 문자열 배열이 아니라 queryFn이 반환하는 내용에 대한 정보도 포함하고 있음을 알 수 있습니다:

(property) queryKey: string[] & {
  [dataTagSymbol]: Todo[];
}
 
typescript

이 정보는 getQueryData(그리고 setQueryData와 같은 다른 함수들)에 의해 읽혀 타입을 추론하는 데 사용됩니다. 이는 React Query에 완전히 새로운 수준의 타입 안전성을 제공하면서 동시에 쿼리 옵션을 재사용하기 쉽게 만듭니다. 개발자 경험(DX)에 있어 큰 승리입니다. 🚀

쿼리 팩토리

따라서 내 생각에는 이 패턴과 queryOptions 헬퍼를 모든 곳에서 사용하고 싶습니다. 심지어 사용자 정의 훅이 추상화의 첫 번째 선택이 되지 않을 정도로 나아갈 것 같습니다. 만약 사용자 정의 훅이 하는 일이 다음과 같다면 다소 무의미해 보입니다:

const useTodos = () => useQuery(todosQuery)
 
javascript

컴포넌트에서 직접 useQuery를 호출하는 것은 전혀 문제가 없습니다. 특히 때때로 useSuspenseQuery와 혼합하여 사용하고 싶을 때 더욱 그렇습니다. 물론 훅이 useMemo와 같은 추가적인 메모이제이션을 수행한다면 여전히 추가하는 것이 좋습니다. 하지만 이전처럼 즉시 사용자 정의 훅을 만들지는 않을 것 같습니다.

또한, 이제 쿼리 키 팩토리를 조금 다른 관점에서 보게 되었습니다. 나는 다음과 같은 사실을 깨달았습니다:

쿼리 키를 쿼리 함수와 분리하는 것은 실수였습니다.

queryKey는 우리의 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,
    }),
}
 
javascript

이 팩토리에는 계층 구조를 만들고 쿼리 무효화에 사용할 수 있는 키 전용 항목과 queryOptions 헬퍼로 생성된 완전한 쿼리 객체가 혼합되어 있습니다.