[번역] React Query FAQs

이춘구·2022년 8월 17일
8

translation

목록 보기
3/13

Photo by Mahesh Patel

TkDodoReact Query FAQs을 번역한 글입니다.


마지막 업데이트: 2024.05.18

저는 18개월 동안 React Query에 관련된 수많은 질문들에 답해왔습니다. 커뮤니티에 속해 질문에 답하는 게 애초에 제가 오픈소스에 참여하게 된 계기였고, React Query에 관한 글을 쓰는 데에도 큰 요인이 되었습니다.

저는 질문에 답하는 게 여전히 재밌습니다. 그 중에서도 특히 잘 설명되어 있고 흔치 않은 질문들이 그렇습니다. 제 말이 무슨 뜻인지 모르거나 좋은 질문법이 궁금하다면 저의 글 How can I?를 읽어보길 바랍니다.

간단히 답할 수 있지만 글로 작성하려면 약간 품이 드는 반복되는 질문들도 몇 개 봤습니다. 이 글의 내용은 주로 그런 질문들에 대한 것입니다. 또다시 그런 질문들을 봤을 때, 사람들에게 안내해줄 자료를 만들어 놓는 거죠.

각설하고, 주요 질문과 그에 대한 저의 소소한 의견을 드리겠습니다.

어떻게 하면 refetch에 매개변수를 넘길 수 있나요?

제 짧은 답변은 여전히 “불가능”입니다. 하지만 여기엔 타당한 이유가 있습니다. 여러분 스스로 refetch에 매개변수를 전달하고 싶다고 생각할 때마다, 실제론 그게 아닌 경우가 일반적입니다.

매개변수로 refetch 하는 코드는 대개 이렇게 생겼습니다.

const { data, refetch } = useQuery({
  queryKey: ['item'],
  queryFn: () => fetchItem({ id: 1 }),
})

<button onClick={() => {
  // 🚨 이렇게 하는 게 아니에요
  refetch({ id: 2 })
}})>Show Item 2</button>

query는 인자나 변수에 의존합니다. 위의 코드에서 queryKey[‘item’]으로 정의했기 때문에 fetch 하는 모든 것들은 해당 key로 저장됩니다. 다른 id로 refetch 한다고 해도, key가 같으므로 캐시에서 같은 장소에 기록될 겁니다. 그러니 id 2의 데이터는 id 1의 데이터를 덮어쓸 것이고, id 1의 데이터에 접근하려고 하면 그 데이터는 이미 사라지고 없는 거죠.

queryKey에 따라 다른 응답을 캐시하는 건 React Query의 장점 중 하나입니다. “매개변수로 refetch"라는 가상의 api는 그 장점을 없애버릴 겁니다. 이것이 refetch가 "변수가 같으면 같은 요청을 반복"하기로 되어있는 이유입니다. 그러니까 본질적으로 여러분은 refetch를 원하는 게 아니라 id가 다른 새 fetch를 원하는 겁니다!

React Query를 효과적으로 사용하려면 선언적 접근법을 받아들여야 합니다. queryFn이 데이터를 fetch 하는 데 필요한 모든 의존성은 queryKey가 정의한다는 거죠. 이걸 지킨다면, refetch를 위해 해야 할 일은 의존성 업데이트 뿐입니다. 아래는 좀 더 현실적인 예시입니다.

// dynamic-query-key

const [id, setId] = useState(1)

const { data } = useQuery({
  queryKey: ['item', id],
  queryFn: () => fetchItem({ id }),
})

<button onClick={() => {
  // ✅ 명시적으로 refetch하지 않고 id만 설정합니다
  setId(2)
}})>Show Item 2</button>

setId는 컴포넌트를 리렌더링하고, React Query는 새 key를 사용해 fetch를 시작합니다. 그리고 id 1과는 별도로 캐싱을 하죠.

또한 선언적 접근법은 id를 업데이트 하는 위치나 방법에 관계없이 query 데이터가 항상 id와 "동기화"되는 걸 보장합니다. 그렇게 여러분의 생각은 “저 버튼을 클릭하면 refetch 되게 하고 싶다”에서 “항상 현재 id에 해당하는 데이터를 보고 싶다”로 바뀌는 겁니다.

그리고 반드시 useState로 id를 저장할 필요는 없고, 클라이언트 측 상태를 저장하는 방법이라면(zustand, redux 등) 무엇이든 가능합니다. 위의 예시에선 URL도 저장하기 좋은 곳입니다.

const { id } = useParams()

const { data } = useQuery({
  queryKey: ['item', id],
  queryFn: () => fetchItem({ id }),
})

// ✅ url을 바꾸고 useParams가 가져가게 한다
<Link to="/2">Show Item 2</Link>

이 접근법의 가장 좋은 점은 상태를 관리할 필요가 없고, 공유할 수 있는 URL이 생기며, 사용자가 브라우저의 뒤로가기 버튼으로 각 item들을 탐색할 수 있다는 겁니다.

로딩 상태

queryKey를 바꾸면 query가 다시 하드 로딩* 상태가 되는 걸 봤을 겁니다. 기대한대로 동작하는 것이며, key를 바꾸면 바뀐 key에 대한 데이터는 아직 없기 때문입니다.

하드 로딩: 쿼리에 대한 데이터가 캐시에 없으며 쿼리가 아직 완료되지 않은 상태 - 옮긴이

그런 급격한 전환을 완화할 방법은 많습니다. 해당 key의 placeholderData를 설정하거나 새 key의 데이터를 미리 prefetch하는 방식이 있죠. 이 문제를 해결하기 위한 좋은 접근법은 query가 이전 데이터를 유지하게 하는 겁니다.

// keep-previous-data

> 1 import { keepPreviousData } from '@tanstack/react-query'
  2
  3 const { data, isPlaceholderData } = useQuery({
  4   queryKey: ['item', id],
  5   queryFn: () => fetchItem({ id }),
> 6   // ⬇️ 이렇게요
> 7   placeholderData: keepPreviousData,
  8 })

이렇게 설정하면 React Query는 id 2의 데이터를 가져오는 동안 id 1의 데이터를 계속 보여줍니다. 게다가 query가 반환하는 isPlaceholderData 플래그는 true가 되므로 그에 따른 UI로 대응할 수 있습니다. 데이터 뿐만 아니라 백그라운드 로딩 스피너를 보여주거나, 데이터에 opacity를 넣어서 해당 데이터가 최신이 아니라는 걸 표시할 수도 있죠. 어떻게 할지는 전적으로 여러분에게 달려있습니다. React Query는 수단을 제공할 뿐이죠.

업데이트
v5 이전에는 useQuerykeepPreviousData: true 플래그를 따로 전달해야 했지만, 이제 placeholderData와 결합되었습니다. 자세한 내용은 RFC를 참조하세요.

왜 업데이트 결과가 안 보이죠?

mutation 응답에 따른 업데이트mutation에서 무효화를 하고 싶어서 Query Cache를 직접 건드릴 때, 업데이트가 화면에 반영되지 않는다거나 단순히 "안 된다"는 제보를 가끔 받습니다. 이 경우, 문제의 원인은 둘 중 하나로 귀결됩니다.

1. 쿼리 키가 일치하지 않음

쿼리 키는 결정론적으로 해시되므로 참조 안정성이나 객체의 key 순서를 신경쓸 필요가 없습니다. 하지만 queryClient.setQueryData를 호출할 때는, key가 기존의 key와 완전히 일치해야 합니다. 예를 들어, 아래 두 key는 일치하지 않습니다.

// non-matching-keys

1  ['item', '1']
2  ['item', 1]

key 배열의 두번째 값은 1번 줄에서 string, 2번에서는 number입니다. 이런 일은 숫자로 주로 사용하다가 useParams로 URL을 읽어서 string을 받게 되면 발생할 수 있습니다.

이럴 땐 무슨 key가 있으며, 지금 무슨 key를 fetch 중인지 명확히 알 수 있게 해주는 React Query Devtools가 최고입니다. 그래도 이런 성가신 디테일을 항상 주시하시기 바랍니다!

이 문제의 해결책으로 TypescriptQuery Key Factories를 추천합니다.

2. QueryClient가 안정적이지 않음

대부분의 예시는 queryClient를 App 컴포넌트의 바깥에 생성해서 참조값을 안정되게 합니다.

// standard-example

>  1 // ✅ App 바깥에서 생성됨
>  2 const queryClient = new QueryClient()
   3
   4 export default function App() {
   5   return (
   6     <QueryClientProvider client={queryClient}>
   <7       <Example />
   8     </QueryClientProvider>
   9   )
  10 }

QueryClientQueryCache를 보유하므로 client를 새로 생성하면 우리는 텅 빈 새 cache를 받게 됩니다. 그러니까 App 컴포넌트 안에서 client를 생성한 뒤, 모종의 이유(라우트 변경 등)로 App 컴포넌트가 리렌더링 되면 기존 cache는 버려지는 거죠.

// unstable-query-client

   1 export default function App() {
   2   // 🚨 이건 좋지 않아요
   3   const queryClient = new QueryClient()
   4
   5   return (
   6     <QueryClientProvider client={queryClient}>
   7       <Example />
   8     </QueryClientProvider>
   9   )
  10 }

client를 반드시 App 컴포넌트 안에서 생성해야 한다면, instance ref나 React의 state를 사용해서 참조 안정성을 보장해 주세요.

// stable-query-client

   1 export default function App() {
   2   // ✅ 안정적임
   3   const [queryClient] = React.useState(() => new QueryClient())
   4
   5   return (
   6     <QueryClientProvider client={queryClient}>
   7       <Example />
   8     </QueryClientProvider>
   9   )
  10 }

이걸 주제로 한 블로그 글(useState for one-time initializations)이 따로 있습니다.

왜 useQueryClient()를 사용해야 하나요...

그냥 client를 import 할 수 있는데요...?

QueryClientProvider는 생성된 queryClient를 React Context에 넣어 앱 전체에 퍼뜨립니다. 이걸 잘 읽어올 수 있는 최선책이 useQueryClient입니다. 이렇게 하면 별도의 구독을 생성하지 않고 리렌더링을 유발하지도 않습니다(위처럼 client가 안정적일 경우). client를 prop으로 내려줘야 하는 걸 방지하죠.

아니면 그 대신 client를 export하고 필요한 곳에서 import할 수도 있긴 합니다.

// exported-query-client

>  1 // ⬇️ export해서 import할 수 있게 함
>  2 export const queryClient = new QueryClient()
   3
   4 export default function App() {
   5   return (
   6     <QueryClientProvider client={queryClient}>
   7       <Example />
   8     </QueryClientProvider>
   9   )
  10 }

하지만 useQueryClient를 선호하는 이유가 몇 가지 있습니다.

1. useQuery도 useQueryClient을 사용함

useQuery를 호출하면, 내부적으로 useQueryClient를 호출합니다. useQueryClient는 가장 가까운 client를 React Context 안에서 조회합니다. 별 거 아니죠. 하지만 여러분이 import한 client가 context의 client와 다른 상황에 처하기라도 한다면, 피할 수도 있었던 버그를 추적하느라 힘들어질 겁니다.

2. 앱을 client로부터 분리함

App 컴포넌트에서 정의하는 client는 프로덕션 client입니다. 프로덕션에서 잘 동작하는 기본 설정값이 있겠지만, 테스트에서는 다른 설정값을 사용하는 게 합리적일 수 있습니다. 한 가지 예시가 테스트 중에 retry 옵션을 끄는 것인데, 끄지 않으면 잘못된 쿼리를 테스트할 때 시간이 초과될 수 있기 때문입니다.

React Context를 의존성 주입 메커니즘으로 사용할 때의 큰 장점은 앱을 앱이 의존하는 것들로부터 분리한다는 것입니다. useQueryClient는 위쪽 트리에 "특정한" client가 아닌 "아무" client라도 있길 기대합니다. 하지만 프로덕션 client를 직접 import 하면 그 장점을 잃게 되죠.

3. export 할 수 없는 경우가 있음

(위에서 보았듯) queryClientApp 컴포넌트 안에서 생성해야 하는 경우가 가끔 있습니다. 예시로 SSR을 사용할 때, 여러 사용자가 같은 client를 공유하지 않도록 하기 위한 경우가 있겠네요.

App들이 격리되어야 하는 마이크로 프런트엔드도 그렇습니다. client를 App 밖에서 생성한 뒤 같은 페이지에서 같은 App을 두 번 사용하면, 두 App은 client를 공유하게 됩니다.

마지막으로, queryClient의 기본값 내부에서 다른 훅을 사용하고 싶다면 queryClientApp 안에서 생성해야 합니다. mutation이 실패하면 토스트를 띄우는 전역 에러 핸들러를 생각해보죠.

// use-other-hooks

   1 export default function App() {
>  2   // ✅ App 바깥에서 useToast를 호출할 수 없었습니다...
>  3   const toast = useToast()
   4   const [queryClient] = React.useState(
   5     () =>
   6       new QueryClient({
   7         mutationCache: new MutationCache({
>  8           // ⬇️ 여기에 필요하니까요.
>  9           onError: (error) => toast.show({ type: 'error', error }),
  10         }),
  11       })
  12   )
  13
  14   return (
  15     <QueryClientProvider client={queryClient}>
  16       <Example />
  17     </QueryClientProvider>
  18   )
  19 }

queryClient를 저렇게 생성하면, client를 export해서 App에서 import할 수 있는 방법은 없습니다.

여러분이 client를 export하려는 이유에 대한 제 최선의 추측은, "쿼리 무효화를 해야 하는 컴포넌트가 있는데 레거시인 클래스 컴포넌트라서 훅을 사용하지 못하기 때문"입니다. 이런 상황에 처해있고 함수 컴포넌트로 리팩터링할 수 없다면, render props로 구현하는 걸 고려해보세요.

// useQueryClient-render-props

const UseQueryClient = ({ children }) => children(useQueryClient())
// usage

<UseQueryClient>
  {(queryClient) => (
    <button
      onClick={() => queryClient.invalidateQueries({
        queryKey: ['items']
      })}
    >
      invalidate items
    </button>
  )}
</UseQueryClient>

아 그리고 이건 useQuery나 다른 모든 훅에서도 똑같이 할 수 있습니다.

// useQuery-render-props

const UseQuery = ({ children, ...props }) => children(useQuery(props))
// usage

<UseQuery queryKey={["items"<]} queryFn={fetchItems}>
  {({ data, isPending, isError }) => (
    // 🙌 여기서 jsx를 반환하세요.
  )}
</UseQuery>

왜 에러를 받을 수 없죠?

네트워크 요청이 실패하면 query가 error 상태로 전환되는 게 이상적입니다. 그게 아니라 query가 여전히 성공한 걸로 확인된다면, queryFn이 실패한 Promise을 반환하지 않았다는 뜻입니다.

기억하세요. React Query는 상태 코드나 네트워크 요청에 대해 아예 모르고 신경쓰지도 않습니다. React Query는 queryFn이 제공해야 하는 resolve 또는 reject된 Promise가 필요합니다.

React Query는 reject 된 Promise를 확인하면 재시도를 시작하거나, 오프라인 상태라면 query를 중지하고 결국 query를 error 상태로 만들 수도 있기 때문에 꽤나 중요하고 제대로 이해해야 합니다.

fetch API

다행히, axiosky 등 많은 데이터 fetch 라이브러리는 4XX나 5XX같은 에러 상태 코드를 실패한 Promise로 변환합니다. 그래서 네트워크 요청이 실패하면 query도 실패하는 거죠. 하지만 내장 fetch API는 예외인데, 요청이 네트워크 에러 때문에 실패해야만 실패한 Promise를 반환합니다.

물론 이게 공식 문서에 나와있지만 못 봤다면 걸림돌이 됐을 겁니다.

// wrong-fetch-api-example

   1 useQuery({
   2   queryKey: ['todos', todoId],
   3   queryFn: async () => {
   4     const response = await fetch('/todos/' + todoId)
>  5     // 🚨 4xx이나 5xx은 에러로 취급되지 않아요
>  6     return response.json()
   7   },
   8 })

이걸 해결하려면 응답이 ok인지 확인하고, ok가 아니라면 reject 된 Promise로 변환해야 합니다.

// correct-fetch-api-example

   1 useQuery({
   2   queryKey: ['todos', todoId],
   3   queryFn: async () => {
   4     const response = await fetch('/todos/' + todoId)
>  5     // ✅ 4xx와 5xx를 실패한 Promise로 변환하세요
>  6     if (!response.ok) {
>  7       throw new Error('Network response was not ok')
>  8     }
   9     return response.json()
  10   },
  11 })

로깅

제가 두 번째로 많이 본 원인은 로깅을 하려고 queryFn 안에서 에러를 catch하는 것입니다. 에러를 catch하고 다시 throw하지 않으면, 암묵적으로 성공한 Promise를 반환하게 됩니다.

// wrong-logging-example

useQuery({
  queryKey: ['todos', todoId],
  queryFn: async () => {
    try {
      const { data } = await axios.get('/todos/' + todoId)
      return data
    } catch (error) {
      console.error(error)
      // 🚨 여기서 "텅 빈" Promise<void>가 반환돼요
    }
  },
})

로깅이 하고 싶다면 에러를 다시 throw하는 걸 잊지마세요.

// correct-logging-example

useQuery({
  queryKey: ['todos', todoId],
  queryFn: async () => {
    try {
      const { data } = await axios.get('/todos/' + todoId)
      return data
    } catch (error) {
      console.error(error)
      // ✅ 여기서 실패한 Promise가 반환돼요
      throw error
    }
  },
})

에러 처리용으로 너무 장황하지 않은 대안은 QueryCache의 onError 콜백입니다. 에러를 처리하는 다른 방법들은 #11: React Query Error Handling에서 더 읽어보실 수 있습니다.

왜 queryFn이 호출되지 않는 거죠?

호출돼야 하는 queryFn이 안 된다는 버그를 제보받을 때가 가끔 있습니다. 이 경우 가장 유력한 원인은 initialDatastaleTime을 함께 사용하는 겁니다.

// initialData-and-staleTime

  1 const { data } = useQuery({
  2   queryKey: ['todos'],
  3   queryFn: fetchTodos,
> 4   initialData: [],
> 5   staleTime: 5 * 1000,
  6 })

문제는 새 캐시 항목이 생성될 때마다 initialData가 참작되고, 그 데이터를 캐시에 저장한다는 겁니다. 일단 데이터가 캐시에 들어가면 React Query는 데이터가 어디에서 왔는지 신경쓰지 않습니다(그리고 실제로 모릅니다). queryFn에서 왔거나, 직접 호출한 queryClient.setQueryData에서 왔거나 아니면 initialData 때문일 수도 있죠.

staleTime 설정값과 결합되면, 이제 initialData는 다음 5초 동안 fresh한 데이터로 취급됩니다. 따라서 이 useQuery 인스턴스의 "마운트"는 백그라운드 refetch를 트리거하지 않습니다. 캐시에 fresh한 데이터(빈 배열)가 있으니까 refetch 해야 할 이유가 없죠. 이건 staleTimeuseQuery 자체가 아니라 전역에 적용했을 때 특히 발견하기 어렵습니다.

여기서 핵심 교훈은, initialData는 동기적으로 사용 가능한 "실제" 데이터 즉, 사용자를 위해 기꺼이 캐시할 데이터가 있을 때에만 사용해야 한다는 겁니다. 빈 배열은 실제 데이터가 fetch 될 때까지 표시할 "대체" 데이터에 가까우며, 그런 용도에는 placeholderData를 사용하는 게 더 좋습니다.

// placeholderData-and-staleTime

  1 const { data } = useQuery({
  2   queryKey: ['todos'],
  3   queryFn: fetchTodos,
> 4   placeholderData: [],
  5   staleTime: 5 * 1000,
  6 })

placeholderData는 절대 캐시되지 않으므로 백그라운드 refetch가 항상 실행됩니다. placeholderDatainitialData의 차이에 대한 자세한 내용은 여기에서 확인할 수 있습니다.

(실제로는 우회책에 가까운) 또 다른 해결 방법은 initialData를 처음부터 stale로 지정하는 겁니다. React Query는 initialData를 캐시에 저장할 때 Date.now()를 기본으로 사용합니다. 하지만 initialDataUpdatedAt으로 커스텀할 수 있습니다. 저는 이걸 0(이나 실제로 과거인 시점 언제든)으로 설정해도 백그라운드 업데이트를 잘 트리거한다는 걸 알아냈습니다.

// initialDataUpdatedAt

  1 const { data } = useQuery({
  2   queryKey: ['todos'],
  3   queryFn: fetchTodos,
  4   initialData: [],
> 5   initialDataUpdatedAt: 0,
  6   staleTime: 5 * 1000,
  7 })

이 동작을 발견하기 어려운 또 다른 상황은 페이지네이션 쿼리처럼 동적인 Query Key를 사용할 때입니다.

// paginated-queries

  1 const [page, setPage] = React.useState(0)
  2 
  3 const { data } = useQuery({
  4   queryKey: ['todos', page],
  5   queryFn: () => fetchTodos(page),
  6   initialData: initialDataForPageZero,
  7   staleTime: 5 * 1000,
  8 })

위의 코드는 initialDatapage: 0인 Query의 캐시에만 저장하고, page0에서 1로 바뀌면 queryFn을 호출한다는 걸 표현하고 싶었을 겁니다.

하지만 그런 일은 없습니다. 캐시 안에서는 QueryKey가 다르면 완전히 새로운 Query입니다. 캐시는 여러분의 컴포넌트, 혹은 여러분이 전에 다른 QueryKey를 사용했는지에 대해 아는 게 전혀 없습니다. (위처럼 지정한 경우) initialData에 대해서도 똑같이 적용될 거란 뜻이죠.

그러므로 어떤 Query가 initialData를 캐시해야 하는지 구체적으로 지정해야 합니다.

// correct-initial-data

  1 const [page, setPage] = React.useState(0)
  2 
  3 const { data } = useQuery({
  4   queryKey: ['todos', page],
  5   queryFn: () => fetchTodos(page),
> 6   initialData: page === 0 ? initialDataForPageZero : undefined,
  7   staleTime: 5 * 1000,
  8 })
profile
프런트엔드 개발자

0개의 댓글