🔥 React Query 오류 처리

970자
12분

비동기 데이터 작업, 특히 데이터 가져오기에서 오류 처리는 필수적입니다. 모든 요청이 성공하지 않고, 모든 Promise가 이행되지 않는다는 사실을 인정해야 합니다.

대개 우리는 처음부터 오류 처리에 집중하지 않습니다. 먼저 '이상적인 상황'을 다루고 나서 오류 처리를 나중에 고려하는 경향이 있습니다.

하지만 오류 처리 방법을 미리 생각하지 않으면 사용자 경험에 부정적인 영향을 미칠 수 있습니다. 이러한 문제를 피하기 위해 React Query가 제공하는 오류 처리 옵션들을 살펴보겠습니다.

사전 준비

React Query가 오류를 올바르게 처리하려면 거부된(rejected) Promise가 필요합니다. 다행히 axios 같은 라이브러리를 사용하면 이를 쉽게 얻을 수 있습니다.

Fetch API나 4xx 또는 5xx 같은 오류 상태 코드에 대해 거부된 Promise를 제공하지 않는 다른 라이브러리를 사용한다면, queryFn 내에서 직접 변환 작업을 해야 합니다. 이에 대한 자세한 내용은 공식 문서에서 확인할 수 있습니다.

기본 예제

오류 표시에 관한 대부분의 예제는 다음과 같습니다:

function TodoList() {
  const todos = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos
  })
 
  if (todos.isPending) {
    return '로딩 중...'
  }
 
  // ✅ 표준 오류 처리
  // todos.status === 'error'로도 확인 가능
  if (todos.isError) {
    return '오류가 발생했습니다'
  }
 
  return (
    <div>
      {todos.data.map((todo) => (
        <Todo key={todo.id} {...todo} />
      ))}
    </div>
  )
}
 
javascript

여기서는 React Query가 제공하는 isError 불리언 플래그(이는 status 열거형에서 파생됨)를 확인하여 오류 상황을 처리합니다.

이 방법은 일부 상황에서는 괜찮지만, 몇 가지 단점도 있습니다:

  1. 백그라운드 오류를 잘 처리하지 못합니다. 백그라운드 새로고침이 실패했다고 해서 전체 할 일 목록을 언마운트하고 싶지는 않을 것입니다. API가 일시적으로 다운되거나 속도 제한에 도달했을 수 있으며, 이 경우 몇 분 후에 다시 작동할 수 있습니다. 이런 상황을 개선하는 방법은 #4: React Query의 상태 확인에서 자세히 알아볼 수 있습니다.
  2. 쿼리를 사용하는 모든 컴포넌트에서 이 작업을 반복해야 한다면 코드가 중복될 수 있습니다.

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

오류 경계

오류 경계는 React의 일반적인 개념으로, 렌더링 중 발생하는 런타임 오류를 잡아내어 적절히 대응하고 대체 UI를 표시할 수 있게 해줍니다.

이는 컴포넌트를 원하는 수준으로 오류 경계로 감쌀 수 있어 좋습니다. 그러면 UI의 나머지 부분은 해당 오류의 영향을 받지 않습니다.

오류 경계가 할 수 없는 한 가지는 비동기 오류를 잡는 것입니다. 비동기 오류는 렌더링 중에 발생하지 않기 때문입니다. React Query에서 오류 경계를 작동시키기 위해, 라이브러리는 내부적으로 오류를 잡아 다음 렌더 주기에 다시 던져 오류 경계가 이를 잡을 수 있게 합니다.

이는 오류 처리에 대한 매우 훌륭하면서도 단순한 접근 방식이라고 생각합니다. 이를 작동시키기 위해 필요한 것은 쿼리에 throwOnError 플래그를 전달하는 것뿐입니다(또는 기본 설정을 통해 제공할 수 있습니다):

function TodoList() {
  // ✅ 모든 가져오기 오류를
  // 가장 가까운 오류 경계로 전파합니다
  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 '로딩 중...'
}
 
javascript

v3.23.0부터는 throwOnError에 함수를 제공하여 어떤 오류를 오류 경계로 보내고 어떤 오류를 로컬에서 처리할지 세밀하게 조정할 수 있습니다:

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  // 🚀 서버 오류만 오류 경계로 전달됩니다
  throwOnError: (error) => error.response?.status >= 500,
})
 
javascript

이는 뮤테이션에도 적용되며, 폼 제출 시 매우 유용합니다. 4xx 범위의 오류는 로컬에서 처리할 수 있고(예: 백엔드 유효성 검사 실패), 모든 5xx 서버 오류는 오류 경계로 전파할 수 있습니다.

참고: v5 이전에는 throwOnError 플래그가 useErrorBoundary로 알려져 있었습니다.

오류 알림 표시하기

일부 사용 사례에서는 화면에 경고 배너를 렌더링하는 대신 팝업되는 오류 토스트 알림을 표시하는 것이 더 나을 수 있습니다. 이러한 알림은 보통 react-hot-toast가 제공하는 것과 같은 명령형 API를 통해 열립니다:

import toast from 'react-hot-toast'
 
toast.error('문제가 발생했습니다')
 
javascript

그렇다면 React Query에서 오류가 발생했을 때 이를 어떻게 할 수 있을까요?

onError 콜백

참고: onError와 onSuccess 콜백은 v5에서 useQuery에서 제거되었습니다. 그 이유에 대해서는 여기에서 자세히 읽을 수 있습니다.

const useTodos = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    // ⚠️ 좋아 보이지만, 아마도 원하는 것이 _아닐_ 수 있습니다
    onError: (error) =>
      toast.error(`문제가 발생했습니다: ${error.message}`),
  })
 
javascript

얼핏 보면 onError 콜백이 가져오기가 실패할 때 부작용을 수행하는 데 정확히 우리가 필요로 하는 것처럼 보이며, 실제로도 작동합니다 - 단, 사용자 정의 훅을 한 번만 사용하는 경우에 한해서요!

useQueryonError 콜백은 모든 '관찰자(Observer)'에 대해 호출됩니다. 즉, 애플리케이션에서 useTodos를 두 번 호출하면 네트워크 요청이 한 번만 실패해도 두 개의 오류 토스트가 표시됩니다.

개념적으로 onError 콜백이 useEffect와 유사하게 작동한다고 생각할 수 있습니다. 위의 예제를 해당 문법으로 확장하면 이것이 모든 소비자에 대해 실행될 것이라는 점이 더 분명해집니다:

const useTodos = () => {
  const todos = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos
  })
 
  // 🚨 효과는 이 사용자 정의 훅을 사용하는
  // 모든 컴포넌트에 대해 개별적으로 실행됩니다
  React.useEffect(() => {
    if (todos.error) {
      toast.error(`문제가 발생했습니다: ${todos.error.message}`)
    }
  }, [todos.error])
 
  return todos
}
 
javascript

물론 콜백을 사용자 정의 훅에 추가하지 않고 훅을 호출할 때 추가한다면 전혀 문제가 없습니다. 하지만 모든 관찰자에게 가져오기가 실패했다고 알리는 대신 기본 가져오기가 실패했다는 것을 사용자에게 한 번만 알리고 싶다면 어떻게 해야 할까요? 이를 위해 React Query는 다른 수준의 콜백을 제공합니다:

전역 콜백

전역 콜백은 QueryCache를 생성할 때 제공해야 합니다. 이는 new QueryClient를 생성할 때 암시적으로 발생하지만, 다음과 같이 사용자 정의할 수도 있습니다:

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) =>
      toast.error(`문제가 발생했습니다: ${error.message}`),
  }),
})
 
javascript

이제 각 쿼리에 대해 오류 토스트가 한 번만 표시되며, 이는 우리가 원하는 바로 그것입니다. 🎉 또한 이는 수행하고자 하는 모든 종류의 오류 추적이나 모니터링을 위한 가장 좋은 장소일 것입니다. 요청당 한 번만 실행되는 것이 보장되고 기본 옵션처럼 덮어쓸 수 없기 때문입니다.

모든 것을 종합하기

React Query에서 오류를 처리하는 세 가지 주요 방법은 다음과 같습니다:

  • useQuery에서 반환된 error 속성
  • onError 콜백 (쿼리 자체 또는 전역 QueryCache / MutationCache에서)
  • 오류 경계 사용

이들을 원하는 대로 조합하여 사용할 수 있습니다. 개인적으로 선호하는 방식은 백그라운드 새로고침에 대해 오류 토스트를 표시하고(오래된 UI를 유지하기 위해) 나머지는 로컬에서 처리하거나 오류 경계를 사용하는 것입니다:

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      // 🎉 캐시에 이미 데이터가 있는 경우에만 오류 토스트를 표시합니다
      // 이는 백그라운드 업데이트 실패를 나타냅니다
      if (query.state.data !== undefined) {
        toast.error(`문제가 발생했습니다: ${error.message}`)
      }
    },
  }),
})
 
javascript

이렇게 하면 React Query를 사용할 때 오류를 효과적으로 처리하고 사용자에게 더 나은 경험을 제공할 수 있습니다.