[번역] The Query Options API

이춘구·2024년 1월 18일
10

translation

목록 보기
10/13
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에서도 여러 인수가 아니라 객체 하나를 전달할 수 있었습니다. 다만 그다지 널리 알려지지 않았을 뿐이죠. 대부분의 앱에게 breaking change가 된 이유는 공식 문서와 많은 블로그(여기 포함)의 예시들이 모두 이전 API를 사용했기 때문입니다.

그렇다면 이렇게 변경한 이유는 뭘까요?

더 나은 추상화

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

게다가 하나의 객체로 모든 걸 규정하는 추상화는 쿼리 옵션을 여러 함수 간에 공유하고 싶을 때 아주 좋은 걸로 밝혀졌습니다. 저는 이걸 React Query meets React Router을 작성하면서, prefetch와 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'])를 호출할 때 어떤 타입이 반환될지 알 방법이 없기 때문입니다.

따라서 함수 호출에 타입 매개변수를 제공해 직접 해결할 수 밖에 없습니다.

// 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개의 답글