[번역] The Query Options API

이춘구·2024년 1월 18일
2

translation

목록 보기
11/11
post-thumbnail

Photo by Dan Scott

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


약 3개월 전에 React Query v5가 출시되면서 라이브러리 역사상 가장 큰 "breaking" changes(하위 호환성이 손상되는 변경사항)가 있었습니다. 이제 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 changes였습니다.

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

더 나은 추상화

먼저, 그 모든 오버로딩은 메인테이너에게는 번거로운 작업이며 사용자에게는 명확하지가 않습니다. "왜 같은 함수를 여러 방식으로 호출할 수 있는 거예요? 다른 것보다 나은 방식이 있는 건가요?" 그러므로 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'])를 호출할 때, 라이브러리는 반환값의 타입을 알 수 있는 방법이 없기 때문입니다.

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

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