[번역] The Query Options API

이춘구·2024년 1월 18일
4

translation

목록 보기
11/12
post-thumbnail

Photo by Dan Scott

TkDodoThe Query Options API를 번역한 글입니다.


약 3개월 전에 React Query v5가 출시되면서 라이브러리 역사상 가장 큰 "breaking" change(하위 호환성이 손상되는 변경사항)가 있었습니다. 이제 React Query의 모든 함수는 인수 여러 개가 아니라 객체 하나만 전달받습니다. 이 객체를 Query Options라고 부르는데, 쿼리 생성에 필요한 모든 옵션이 들어있기 때문입니다.

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

이건 useQuery 호출뿐만 아니라 쿼리 무효화 같은 명령형 작업에도 해당됩니다.

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

엄밀히 말해 이 API는 새로운 게 아닙니다. React Query 함수 대부분이 오버로딩 되어있었기에 이미 v3에서도 여러 개의 인수 대신 객체 하나를 전달할 수 있었습니다. 다만 그다지 널리 알려지지 않았을 뿐이죠. 공식 문서와 많은 블로그(여기 포함) 글의 예제들이 전부 이전 API를 사용했기 때문에 이건 대부분의 앱에게 breaking change였습니다.

그러면 저희는 왜 이렇게 바꾼 걸까요?

더 나은 추상화

먼저, 그 모든 오버로딩은 메인테이너에게는 번거로운 작업이며 사용자에게는 명확하지가 않습니다. "왜 같은 함수를 여러 방식으로 호출할 수 있는 거예요? 다른 것보다 나은 방식이 있는 건가요?" 그러므로 API를 간소화해서 처음 시작하는 사람도 쉽게 이해할 수 있도록 하는 게 하나의 목표였습니다. "항상 객체 하나만 전달하라". 이보다 간단하고 확장성이 좋을 순 없습니다.

게다가 모든 걸 규정하는 하나의 객체는 여러 함수 간에 쿼리 옵션을 공유하기 아주 좋은 추상화인 것으로 드러났습니다. 저는 이걸 React Query meets React Router을 작성했을 때, 프리페칭과 useQuery 호출 간에 쿼리 옵션을 공유하고자 한 지점에서 "우연히" 발견했습니다. 지금은 보통 쿼리를 재사용하기 위한 첫번째 방식으로 그냥 커스텀 훅을 만들 수도 있습니다. 하지만 prefetching처럼 명령형 함수 호출이 관련된 경우엔 그게 안 됩니다. 그래서 저는 무언가를 제시했고, Alex는 그게 좋은 패턴이라고 언급해 줬습니다.

R. Alex Anderson
저는 이 React Query 패턴을 @TkDodo가 React Router에 대해 작성한 블로그 글에서 처음 봤습니다.

훌륭하네요 👏

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

모든 함수의 인터페이스가 같고, 그 인터페이스가 객체 하나를 받는다면, 해당 객체를 쿼리 정의로 추상화하는 것이 아주 합리적입니다. 일단 존재하기만 하면 어디로든 전달할 수 있죠.

// todos-query
const todosQuery = {
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 5000,
}

// ✅ 됩니다
useQuery(todosQuery)

// 🤝 그럼요
queryClient.prefetchQuery(todosQuery)

// 🙌 오 예
useSuspenseQuery(todosQuery)

// 🎉 완전 되죠
useQueries([{
  queries: [todosQuery]
}]

지나고 보니 이 패턴이 쿼리의 주요한 추상화로 알맞게 느껴져서 모든 곳에 적용하고 싶었습니다. 그런데 딱 하나 문제가 있었습니다.

타입스크립트

타입스크립트가 초과 프로퍼티를 처리하는 방식은 좀 별납니다. 인라인으로 초과 프로퍼티를 넣으면 타입스크립트는 이럴 겁니다. "너 왜 이래? 이건 말도 안 되잖아. 나 에러 뱉을래."

// inlined-objects
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)

좋네요, 위에 보이는 오타를 잡아내잖아요. 하지만 패턴이 제시하는 것처럼 객체 전체를 상수로 추상화하면 어떻게 될까요?

// no-error
const todosQuery = {
  queryKey: ['todos'],
  queryFn: fetchTodos,
  stallTime: 5000,
}

useQuery(todosQuery)

타입스크립트 플레이그라운드

에러가 없네요. 🙈

타입스크립트는 이런 상황에 상당히 관대합니다. 런타임에 "여분의" 프로퍼티인 stallTime은 아무런 해도 끼치지 않으며, 해당 프로퍼티가 필요한 상황에서는 쿼리 옵션 객체를 사용할 것이기 때문입니다. 타입스크립트는 이걸 알 수가 없습니다. 그리고 staleTime이 옵셔널이기 때문에 지금은 전달하지도 않고 있죠. 물론 이건 "유효"하긴 하지만 우리가 기대하는 게 아니고 찾기 힘든 실수가 될 수 있습니다.

queryOptions

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

// queryOptions
export function queryOptions(options) {
  return options
}

그러나 타입 레벨에서는 위에서 본 오타 문제를 해결할 뿐만 아니라(수정된 플레이그라운드), queryClient의 다른 부분들을 좀 더 타입-안전하게 만드는 걸 도와줄 수도 있는 진정한 동력원입니다.

DataTag

React Query에서 queryClient.getQueryData와 그 비슷한 함수들이 살짝 성가시게 하는 점 하나가 항상 있었는데, 타입 레벨에서 unknown을 반환한다는 겁니다. 그 이유는 React Query에는 사전(up-front) 정의와 중앙화된 정의가 없어서 queryClient.getQueryData(['todos'])를 호출할 때 React Query가 반환값 타입을 알 수 있는 방법이 없기 때문입니다.

따라서 함수 호출에 타입 매개변수를 넣어서 스스로 해결할 수 밖에 없습니다.

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

분명히 하자면, 이게 타입 단언보다 안전한 건 전혀 아니지만 적어도 저희를 위해 유니온 타입에 undefined가 추가될 겁니다. 그래도 fetchTodos 엔드포인트의 반환값을 리팩터링 했을 때 여기서 새로운 타입을 알려주는 일은 없겠죠. 😔

하지만 이제 queryKeyqueryFn을 같이 두는(co-locate) 함수가 있으니, queryFn의 타입과 연계해서 queryKey를 "태그"할 수 있습니다. queryOptions로 생성된 queryKeygetQueryData에 전달하면 어떻게 되는지 보세요.

// tagged-query-key
const todosQuery = queryOptions({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 5000,
})

const todos = queryClient.getQueryData(todosQuery.queryKey)
//    ^? const todos: Todo[] | undefined

타입스크립트 플레이그라운드

🤯

이건 세상에 하나뿐인 Mateusz Burzyński가 기여한, 그야말로 타입스크립트 마법입니다. todosQuery.queryKey를 보면 문자열 배열 뿐만 아니라 queryFn 반환값의 정보도 들어있음을 알 수 있습니다.

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

그리고 getQueryData(와 setQueryData같은 다른 함수들)이 이 정보를 읽어서 우리를 위해 타입을 추론해줄 겁니다. 이건 React Query에 완전히 새로운 수준의 타입 안전성을 제공하는 동시에 쿼리 옵션을 더 쉽게 재사용할 수 있도록 해줍니다. DX 대성공이네요. 🚀

쿼리 팩토리

저는 이 패턴과 queryOptions 헬퍼를 모든 곳에서 사용하고 싶습니다. 심지어 추상화를 하기 위한 첫번째 선택지가 커스텀 훅이 되지 않는 데까지 나아가고 싶습니다. 커스텀 훅이 하는 게 아래 코드가 전부라면 커스텀 훅은 조금 무의미해 보이죠.

// custom-hooks
const useTodos = () => useQuery(todosQuery)

컴포넌트에서 useQuery를 직접 호출하는 건 전혀 잘못된 게 아니고, 가끔 useSuspenseQuery와 조합하려는 경우가 특히나 그렇습니다. 훅이 useMemo를 사용한 메모이제이션처럼 뭔가를 더 한다면 당연히 추가해도 괜찮습니다. 하지만 저는 그런 것에 예전만큼 바로 손이 가진 않을 거예요.

그리고 저는 이제 Query Key Factories를 조금 다르게 바라봅니다. 이런 깨달음을 얻게 되었거든요.

QueryFunction에서 QueryKey를 분리하는 건 실수였다

queryKeyqueryFn의 의존성을 정의합니다. queryFn 안에서 사용되는 모든 것은 키가 되어야 하죠. 그렇다면 키는 한 곳에 모아 정의해놓고 함수는 키와 멀리 떨어진 커스텀 훅 내부에 둘 이유가 있나요?

그러지 말고 두 패턴을 결합1하면 타입-안전성, 같이 두기(co-location), 뛰어난 DX라는 세 가지 장점을 모두 얻을 수 있습니다. 🚀

Query.gg 🔮
이건 제가 ui.dev와 함께 작업 중인 새로운 공식 React Query 강의에서 가르치고 있는 패턴 중 하나입니다. 이 강의에서는 React Query가 내부적으로 어떻게 동작하는지 기본 원리를 이해하고, 확장 가능한 React Query 작성법을 배울 수 있습니다. 지금까지 제가 만든 콘텐츠가 마음에 드셨다면 query.gg도 마음에 드실 거예요.

쿼리 팩토리의 예시는 이런 식이 되겠네요.

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

여기엔 계층 생성과 쿼리 무효화에 사용할 수 있는 키 전용 엔트리와 queryOptions 헬퍼로 생성된 완전한 쿼리 객체의 혼합체가 들어있습니다.


1) Query Options + Query Key Factory = Query Factory - 옮긴이

profile
프런트엔드 개발자

2개의 댓글

comment-user-thumbnail
2024년 1월 25일

안녕하세요! TKDodo레포에 머지된 번역 작업물 주소가 더이상 유효하지않다고 나와서 알려드리려 왔습니다!

1개의 답글