[번역] React Query FAQs

이춘구·2022년 8월 17일
6

translation

목록 보기
3/11

Photo by Mahesh Patel

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


마지막 업데이트: 2023.10.21

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

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

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

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

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

이에 대한 제 짧은 답변은, 여전히 “할 수 없습니다.”입니다. 하지만 타당한 이유가 있습니다. 여러분은 자신이 원하는 게 refetch에 매개변수를 전달하는 거라고 항상 생각하겠지만, 보통은 그렇지 않습니다.

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

const { data, refetch } = useQuery(['item'], () => 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의 데이터에 접근하려고 하면 그 데이터는 이미 사라지고 없는 거죠.

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

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

const [id, setId] = useState(1)

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

<button onClick={() => {
  // ✅ 명시적인 refetch 없이 id만 다르게 설정합니다.
  setId(2)
}})>Show Item 2</button>

setId는 컴포넌트를 리렌더 시킬테고, React Query는 새로운 key로 fetch를 시작할 겁니다. 그리고 id 1과는 별도로 캐싱을 하겠죠.

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

그리고 id를 저장하는 건 꼭 useState가 아니라, 클라이언트 측 상태를 저장하는 방법이라면(zustand, redux 등) 무엇이든 가능합니다. 위의 예시에선 URL 또한 id를 저장하기 좋은 곳입니다.

const { id } = useParams()

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

// ✅ url을 변경하고, useParams가 가져오도록 합니다.
<Link to="/2">Show Item 2</Link>

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

로딩 상태

query key를 바꾸면 query가 다시 하드 로딩(역자 주. 하드 로딩 vs 소프트 로딩) 상태가 되는 걸 봤을 겁니다. 그게 기대한 동작입니다. key를 바꾸면 바뀐 key에 대한 데이터는 아직 없기 때문이죠.

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

import { keepPreviousData } from '@tanstack/react-query'

const { data, isPlaceholderData } = useQuery({
  queryKey: ['item', id],
  queryFn: () => fetchItem({ id }),
  // ⬇️ 이렇게요.
  placeholderData: keepPreviousData
})

이렇게 설정하면 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는 일치하지 않습니다.

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가 안정적이지 않음

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

// ✅ App 바깥에서 생성했습니다.
const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

QueryCacheQueryClient가 보관하므로, client를 새로 생성하면 텅 빈 새 cache를 받게 됩니다. 그러니까 App 컴포넌트 안에서 client를 생성하고 모종의 이유로 컴포넌트가 리렌더 되면, 캐시는 버려지는 겁니다.

export default function App() {
  // 🚨 이건 좋지 않습니다.
  const queryClient = new QueryClient()

  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

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

export default function App() {
  // ✅ 이건 안정적입니다.
  const [queryClient] = React.useState(() => new QueryClient())

  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

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

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

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

QueryClientProvider는 생성된 queryClient를 리액트 context에 넣어서 앱 전체에 퍼뜨립니다. 이걸 잘 읽어올 수 있는 최고의 방법이 useQueryClient입니다. 이렇게 하면 별도의 구독을 생성하지 않고 리렌더를 유발하지도 않습니다(client가 안정적이라면 말이죠. 위의 예시와 다르게요). client를 prop으로 내려주지 않아도 되게 해주는 겁니다.

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

// ⬇️ import 할 수 있도록 export 했습니다.
export const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

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

1. useQuery도 useQueryClient을 사용함

useQuery를 호출하면, 내부적으로 useQueryClient를 호출하는 겁니다. useQueryClient는 가장 근접한 client를 리액트 context에서 찾을 겁니다. 별 거 아니지만, import한 client가 context의 client와 다르기라도 하면 피할 수 있었던 버그를 추적하느라 힘들어질 겁니다.

2. app을 client에서 분리시켜 줌

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

리액트 Context가 의존성 주입 메커니즘으로 사용될 때의 큰 장점은 앱을 의존성으로부터 분리한다는 것입니다. useQueryClient는 "특정"이 아니라 "모든" client가 상위 트리 내에 존재할 거라 기대합니다. 하지만 프로덕션 client를 직접 import 하면 그 장점을 잃게 되죠.

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

(위에서 보았듯) queryClient를 App 컴포넌트 안에서 생성해야 할 경우가 가끔 있습니다. 서버사이드 렌더링을 사용할 때 여러 사용자가 같은 client를 공유하지 않게 만드는 게 예시가 될 수 있겠네요.

애플리케이션들이 분리되어야 하는 마이크로 프런트엔드로 작업할 때도 그렇습니다. client를 App 밖에서 생성하고, 같은 페이지에서 같은 App을 두번 사용하면 그 둘은 client를 공유하게 될 겁니다.

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

export default function App() {
  // ✅ App 바깥에서 useToast를 호출할 수 없었습니다.
  const toast = useToast()
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        mutationCache: new MutationCache({
          // ⬇️ 여기에 필요했으니까요.
          onError: (error) => toast.show({ type: 'error', error }),
        }),
      })
  )

  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

queryClient를 위처럼 생성한다면, client를 export 하고 App에서 import 할 수 있는 방법이 없죠.

여러분이 client를 export하고 싶어하는 이유를 최선을 다해 추측해보자면, 쿼리 무효화를 수행해야 하는 컴포넌트인데 레거시인 클래스 컴포넌트라 훅을 사용하지 못하기 때문입니다. 만약 이런 경우이고 함수 컴포넌트로 리팩토링 할 수 없다면, render props로 구현하는 걸 고려해보세요.

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

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

const UseQuery = ({ children, ...props }) => children(useQuery(props))
<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 등 많은 데이터 페칭 라이브러리는 4XX나 5XX같은 에러 상태 코드를 실패한 Promise로 변환합니다. 그래서 네트워크 요청이 실패하면 query도 실패하는 거죠. 하지만 내장 fetch API는 예외인데, 요청이 네트워크 에러 때문에 실패해야만 실패한 Promise를 반환합니다.

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

useQuery(['todos', todoId], async () => {
  const response = await fetch('/todos/' + todoId)
  // 🚨 4xx이나 5xx은 에러로 취급되지 않습니다.
  return response.json()
})

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

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

로깅

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

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

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

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에서 더 읽어보실 수 있습니다.

profile
프런트엔드 개발자

0개의 댓글