번역

React Query로 에러 처리하기

미래에서 온 개발자 2024. 1. 21. 15:40

들어가기에 앞서
React Query를 사용해 에러 핸들링을 하면서 많은 도움을 받았던 아티클을 공유하고자 합니다. React Query의 메인테이너인 TkDodo의 블로그 포스팅 React Query Error Handling 번역한 포스팅임을 밝힙니다. 번역에 오류가 있는 경우 댓글로 알려주시면 감사하겠습니다. 
 


 

에러 처리는 비동기 데이터 작업, 특히 데이터를 가져오는 작업(fetch)의 필수적인 부분이다. 우리는 모든 요청이 성공하는 것이 아니며, 모든 프로미스(Promise)가 이행(fulfilled)되는 것도 아니라는 사실을 직시해야 한다. 

처음부터 이 부분에 집중하지 않는 경우가 많다. 에러 처리는 나중에 생각하고, '잘 되는 케이스'를 먼저 처리하곤 하는 것이다. 

하지만 에러를 어떻게 처리할지 생각하지 않으면 사용자 경험에 부정적인 영향을 미칠 수 있다. 이를 방지하기 위해 에러 처리와 관련해 React Query가 어떤 옵션들을 제공하는지 살펴보자. 

 

사전조건 

React Query가 에러를 올바르게 처리하려면 거부된 프로미스(rejected Promise)가 필요하다. 다행히도 axios 같은 라이브러리로 작업을 할 때 바로 이러한 기능을 사용할 수 있다.

4xx 또는 5xx와 같은 잘못된 상태 코드에 대해 거부된 프로미스를 제공하지 않는 fetch API나 다른 라이브러리로 작업하는 경우, queryFn에서 직접 변환해야 한다. 이 부분은 공식 문서에서 다루고 있다.

 

표준 예시 

에러를 보여주는 대부분의 예시가 어떤 모습인지 살펴 보자.

 

function TodoList() {
  const todos = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos
  })

  if (todos.isPending) {
    return 'Loading...'
  }

  // ✅ standard error handling
  // could also check for: todos.status === 'error'
  if (todos.isError) {
    return 'An error occurred'
  }

  return (
    <div>
      {todos.data.map((todo) => (
        <Todo key={todo.id} {...todo} />
      ))}
    </div>
  )
}

 


위의 예제 코드에서는 React Query에서 제공한 (status enum에서 파생된) isError boolean 플래그를 확인해서 에러를 처리하고 있다.

이 방법은 일부 시나리오에서는 괜찮지만 몇 가지 단점도 있다.

1. 백그라운드 에러를 잘 처리하지 못한다.

백그라운드 refetch가 실패했다고 해서 전체 Todo List를 unmount하고 싶을까? API가 일시적으로 다운되었거나 속도 제한에 도달했을 수도 있는데, 이러한 경우라면 몇 분 후에 다시 작동할 수도 있다. 이 상황을 개선하는 방법을 알아보려면 #4: React Query에서 상태 확인을 살펴 보면 된다.

2. 쿼리를 사용하려는 모든 컴포넌트에서 이 코드를 작성해야 한다면 보일러 플레이트가 될 수 있다.

두 번째 문제를 해결하기 위해 React가 직접 제공하는 훌륭한 기능을 사용할 수 있다.

 

에러 바운더리(Error Boundaries)

에러 바운더리는 렌더링 중에 발생하는 런타임 에러를 잡아내어, 에러에 적절히 반응하고 폴백 UI를 표시할 수 있도록 하는 React의 기본 개념이다.

에러 바운더리를 사용하면 컴포넌트를 원하는 단위로 에러 바운더리로 감싸서 나머지 UI가 해당 에러의 영향을 받지 않도록 할 수 있기 때문에 좋다.

에러 바운더리가 처리할 수 없는 한 가지는 비동기 에러를 잡는 것인데, 비동기 에러는 렌더링 중에 발생하는 에러가 아니기 때문이다. 따라서 에러 바운더리가 React Query에서 작동하도록 하기 위해 라이브러리 내부에서 에러를 포착하고 다음 렌더링 사이클에서 에러 바운더리가 해당 에러를 포착할 수 있도록 에러를 다시 던진다.

이는 에러 처리에 대한 매우 천재적이면서도 간단한 접근 방식이라고 생각하며, 이를 작동시키기 위해서는 쿼리에 throwOnError 플래그를 전달하기만 하면 된다 (또는 default config를 통해 제공해도 된다).

 

function TodoList() {
  // ✅ will propagate all fetching errors
  // to the nearest Error Boundary
  const todos = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    throwOnError: true,
  })

  if (todos.data) {
    return (
      <div>
        {todos.data.map((todo) => (
          <Todo key={todo.id} {...todo} />
        ))}
      </div>
    )
  }

  return 'Loading...'
}



v3.23.0부터는 throwOnError 함수를 제공하여 어떤 에러를 에러 바운더리로 보낼지, 어떤 에러를 로컬에서 처리할지를 커스터마이징할 수도 있다.

 

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  // 🚀 only server errors will go to the Error Boundary
  throwOnError: (error) => error.response?.status >= 500,
})



이 방법은 mutation에서도 동작하며, form을 제출할 때 매우 유용하다. 4xx 범위의 에러는 로컬에서 처리할 수 있지만(예: 일부 백엔드 유효성 검사에 실패한 경우), 5xx 서버 에러는 모두 에러 바운더리로 전파할 수 있다.

업데이트 :
v5부터는 throwOnError 플래그 대신 useErrorBoundary를 사용한다. 

 

 

에러 알림 표시하기

일부 use cases에서 화면에 경고 배너(Alert banner)를 렌더링하는 대신 어딘가에서 나타나서 자동으로 사라지는 에러 토스트 알림 메시지(toast notification)를 보여주는 것이 더 나을 수 있다. 이러한 알림 방식은 일반적으로 react-hot-toast에서 제공하는 것처럼 명령형 API로 열린다.

 

import toast from 'react-hot-toast'

toast.error('Something went wrong')


그렇다면 React Query에서 에러가 발생했을 때 어떻게 해야 할까?

 

 

onError 콜백

업데이트 : 
v5의 useQuery에서 onErroronSuccess 콜백이 제거되었다. 그 이유에 대한 자세한 내용은 여기에서 확인할 수 있다.

 

const useTodos = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    // ⚠️ looks good, but is maybe _not_ what you want
    onError: (error) =>
      toast.error(`Something went wrong: ${error.message}`),
  })



언뜻 보기에 onError 콜백은 fetch가 실패했을 때 side effect를 수행하는 데 필요한 것처럼 보인다. 그리고 custom hook을 딱 한 번만 사용하면 작동한다! 

useQueryonError 콜백은 모든 Observer에 대해 호출되므로, 애플리케이션에서 useTodos를 두 번 호출하면 네트워크 요청이 한 번만 실패하더라도 두 번의 에러 토스트가 뜨게 된다.

개념적으로 볼 때, onError 콜백은 useEffect와 유사하게 동작한다고 생각할 수 있다. 따라서 위의 예제를 해당 구문으로 확장하면 모든 컨슈머에 대해 실행되는 것이 더 명확해진다. 

 

const useTodos = () => {
  const todos = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos
  })

  // 🚨 effects are executed for every component
  // that uses this custom hook individually
  React.useEffect(() => {
    if (todos.error) {
      toast.error(`Something went wrong: ${todos.error.message}`)
    }
  }, [todos.error])

  return todos
}

 

물론 콜백을 커스텀 훅에 추가하지 않고 훅의 호출에 추가하는 경우에는 완전히 괜찮다. 하지만 모든 옵저버에게 fetch가 실패했음을 알리고 싶지 않고, 사용자에게 문제가 되는 fetch가 실패했다는 것을 딱 한 번만 알리고 싶다면 어떻게 해야 할까? 이를 위해 React Query에는 다른 레벨에서 사용할 수 있는 콜백이 있다.

 

전역 콜백 (global callback)

 

QueryCache를 생성할 때 전역 콜백을 제공해야 한다. 이는 new QueryClient를 생성할 때 암시적으로 일어나지만 우리는 이를 커스터마이징할 수도 있다. 

 

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) =>
      toast.error(`Something went wrong: ${error.message}`),
  }),
})


이제 각 쿼리에 대해 에러 토스트가 한 번만 표시되며, 이는 정확히 우리가 원하는 것이다.🥳 또한 요청당 한 번만 실행되도록 보장되고, 예를 들어 defaultOptions처럼 덮어쓸 수 없기 때문에 수행하고자 하는 모든 종류의 에러 추적 또는 모니터링을 두기에 가장 적합한 장소일 것이다.

 

정리

React Query에서 에러를 처리하는 세 가지 주요 방법은 다음과 같다. 

 

- useQuery에서 반환한 error 속성

- (쿼리 자체 또는 전역 QueryCache / MutationCache에 있는) onError 콜백
- 에러 바운더리 사용


원하는 대로 혼합하여 사용할 수 있으며, 개인적으로 선호하는 방법은 (최신 데이터를 사용하지 않은, 즉 stale한 UI를 그대로 유지하기 위해) 백그라운드 refetch에 대한 에러 토스트를 보여주고, 그 외 나머지는 전부 로컬 또는 에러 바운더리에서 처리하는 방식이다. 

 

// background-error-toasts

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      // 🎉 only show error toasts if we already have data in the cache
      // which indicates a failed background update
      if (query.state.data !== undefined) {
        toast.error(`Something went wrong: ${error.message}`)
      }
    },
  }),
})