[번역] React Query FAQs

cnsrn1874·2022년 8월 17일
0

이 글은 TkDodo원글을 번역한 것입니다. 의역이 있으며 오타, 오역 피드백은 댓글로 부탁드립니다.


저는 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가 이전 데이터를 유지하도록 하는 겁니다.

const { data, isPreviousData } = useQuery(
  ['item', id],
  () => fetchItem({ id }),
  // ⬇️ 이렇게 하면 됩니다.
  { keepPreviousData: true }
)

keepPreviousData 플래그를 true로 하면 React Query는 id 2의 데이터를 가져오는 동안 id 1의 데이터를 계속 보여줄 겁니다. 거기에 더해, query 결과의 isPreviousData 플래그는 true로 설정될테고 여러분은 그에 따른 UI로 대응할 수 있습니다. 오래된 데이터에다가 백그라운드 로딩 스피너를 보여주거나, 데이터에 불투명도를 넣어서 해당 데이터가 최신이 아니라는 걸 표시할 수도 있죠. 어떻게 할지는 전적으로 여러분에게 달려있습니다. React Query는 수단을 제공할 뿐이죠.

왜 업데이트가 안 보이죠?

Query Cache와 직접 상호작용할 때, 여러분은 mutation의 응답에 따른 업데이트mutation에서 무효화를 하길 원하는데, 업데이트가 화면에 반영되지 않는다거나 단순히 “작동하지 않는다”는 제보를 저는 가끔 받습니다. 이 경우, 문제의 원인은 둘 중 하나로 귀결됩니다.

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>
  )
}

QueryClientQueryCache를 보관하므로 새로운 client를 생성하면 비어있는 새로운 캐시를 얻을 수 있습니다. 그러니까 만약 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를 props으로 내려주지 않아도 되게 해주는 겁니다.

아니면 그 대신 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입니다. client에 프로덕션에서 잘 작동하는 기본 옵션값이 있겠지만 테스트에서는 다른 옵션값을 사용하는 것이 합리적일 수 있습니다. 한 가지 예시가 테스트 중에 retry 옵션을 끄는 것인데, 끄지 않으면 잘못된 쿼리를 테스트 할 때 테스트가 시간 초과될 수 있기 때문입니다.

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

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

App 컴포넌트 안에서 queryClient를 생성할 수 밖에 경우가 있습니다(위에서 보여줬듯이). 서버사이드 렌더링을 사용할 때 여러 사용자가 동일한 client를 공유하지 않도록 하는 게 예시가 될 수 있겠네요.

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

마지막으로, queryClient의 기본값에서 다른 훅들을 사용하고 싶다면, App 안에서 queryClient를 생성해야 합니다. 실패한 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(['items'])}
    >
      invalidate items
    </button>
  )}
</UseQueryClient>

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

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

왜 에러를 받을 수 없죠?

네트워크 요청이 실패하면 query가 error 상태로 전환되는 게 이상적입니다. 만약 전환되지 않고 query가 성공한 것으로만 확인된다면, 이건 queryFn이 실패한 Promise을 반환하지 않았다는 걸 의미합니다.

기억하세요. React Query는 상태 코드나 네트워크 요청에 대해 아예 모르거나 신경쓰지 않습니다. React Query는 queryFn이 제공해야 하는 resolved Promise나 rejected Promise를 필요로 합니다.

React Query가 rejected Promise를 받을 경우 retry를 시작할 수도, 오프라인 상태일 경우 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가 아니라면 rejected 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()
})

Logging

로깅을 목적으로 queryFn 안에서 에러를 catch 하는 게 두 번째 원인입니다. 에러를 catch 한 뒤, 에러를 다시 throw하지 않으면 암묵적으로 성공한 Promise를 반환하게 되는 겁니다.

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

에러를 다시 throw 하는 걸 기억하세요.

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

깔끔하게 에러를 핸들링 하는 대안은 useQueryonError 콜백을 사용하는 겁니다.

useQuery(
  ['todos', todoId],
  async () => {
    const { data } = await axios.get('/todos/' + todoId)
    return data
  },
  { onError: (error) => console.error(error) }
)

저는 확실히 콜백을 선호합니다. 그리고 에러를 다루는 다른 방법들에 대해서는 #11: React Query Error Handling에서 읽어볼 수 있습니다.

profile
프론트엔드 개발자

0개의 댓글