React

React - useQuery 를 사용할 때 에러가 발생한다면..?

myDav 2024. 7. 11. 01:11

리액트 쿼리(React-Query)는 리액트 애플리케이션에서 서버 상태를 효과적으로 관리하고 캐싱할 수 있도록 도와주는 라이브러리이다. 서버에서 데이터를 가져오고, 캐시하고, 동기화하는 작업을 단순화하며, 데이터를 더 쉽게 관리할 수 있게 해준다. 

 

useQuery 를 사용해서 서버와 통신을 하다가 에러가 발생하면 어떤 현상이 생길까? 그리고 이 에러를 어떻게 처리할 수 있을지 알아보자.

 

 

 

아래는 기본적인 useQuery 사용예시이다. `getUserInfo` 함수를 이용해 유저 정보를 요청하고, useQuery를 이용해서 받아온 데이터를 필요한 곳에서 사용할 수 있다. 

const getUserInfo = async (id: string) => await api.get('/user').json(); //'ky' 사용

const useUserInfo = (id: string) => {
    return useQuery({
    	queryKey: [user, ${id}],
        queryFn: () => getUserInfo(id);
    });
};

// 사용시
function App () {
    const { data } = useUserInfo(id);
	
    return (
    	<div>{data.userName}</div>
    )
}

 

만약 여기서 에러가 발생한다면 어떻게 될지 아래와 같이 구조를 변경하고, 테스트해보았다.

 

const getUserInfo = async (id: string) => {
    throw new Error("Something's wrong!")
    // return await api.get('/user').json();
}

 

단순한 방법으로, queryFn에서 에러를 throw 하였다.

 

그 결과, 아무것도 나타나지 않고 그저 흰 바탕의 브라우저 화면만 보일 뿐이었다!

분명히, 에러를 던지지만 에러가 터저나오지 않는다! 그렇다면 useQuery 에서 반환한 error, isError 에러를 콘솔로 찍어보았다.

 

에러는 잘 찍힌다.

처음에는 error = null. isError 플래그는 false 였다가, 에러가 발생했을 때 잘 찍히는 것을 볼 수 있다. 

 

위 결과로 보았을 때, useQuery는 에러가 발생했을 때, 이를 상위로 전파하지 않고, 컴포넌트 안에서 고이 간직하고 있다는 것을 알 수 있다. 이러한 방식으로 useQuery 사용시 발생한 에러를 처리할 때는 해당 useQuery를 사용한 컴포넌트 내부에서 아래와 같은 방식으로 처리할 수 있다.

// 아주 심플하게..
function App() {
    const {data, isError } = useUserInfo(id);
    
    if (isError) return <ErrorPage />
    
    return (...)
 }

 

친절하다. 에러를 터뜨리지 않고 isError 또는 error 객체에 담아서 보내주니, 이렇게 컴포넌트 내부에서 에러 핸들링을 할 수 있다.

 

하지만, 현실 세계는 프로그램은 위와 같이 간단하지 않다. 이것보다 몇 배는 복잡한 상태들과, 비즈니스 로직들이 존재하고 서로 혼합되어서 이런 에러 처리 로직이 들어가 있으면 관심사 분리가 제대로 되지 않아서, 코드를 읽어 내려갈 때 가독성이 떨어지게 된다. 그리고 이러한 코드 내에서 기획 추가/변경과 같은 유지보수를 한다면 아마 문제는 걷잡을 수 없이 커지게 될 것이다.

 

만약, 에러를 해당 컴포넌트가 아닌 상위 컴포넌트에서 처리하거나 또는 에러바운더리로 던지고 싶다면, useQuery`throwOnError` 옵션을 켜주면, useQuery는 발생한 에러를 그대로 던지게 된다.  

 

 

throwOnError 옵션을 켰을 때, 이전 콘솔과는 다르게 에러로 찍히고 에러가 잡히지 않아서 Uncaught Error 가 발생한다.

 

이 에러는 최상단 에러 바운더리에 잡히지도 않았다. 왜냐하면 비동기 작업 중에 발생하는 에러이고, 비동기시 발생한 에러는 잡지 못하기 때문이다.

 

기본적으로 useQuery는 에러가 발생하더라도 직접 예외를 던지지 않고, 에러 상태를 관리하는 객체를 통해 에러를 제공한다. 하지만 'throwOnError' 옵션을 사용하면, 이러한 동작이 변경되어 에러가 발생할 때, 실제로 예외를 던지게 된다. 이렇게 던지면, React 컴포넌트의 렌더링 단계에서 이 예외를 캐치할 수 있으므로, React의 에러 바운더리에서 이를 잡아내고 적절한 에러 처리를 할 수 있게 된다. (useSuspenseQuery 를 이용하면 throwOnError:true 로 기본 설정되어있는 useQuery 버전을 사용할 수 있다.)

 

더보기

최신 버전인 react-router-dom v6 에서는 보통 createBrowserRouter() 를 이용해서 라우터를 만들게 된다. 이 함수를 사용해서 라우터를 만들면, router 안에서 모든 에러가 처리되기 때문에, v5 와 달리 최상위 에러바운더리를 설정할 때 아래와 같이 설정할 수 없다.

function App() {
    ...
    return (
    	<ErrorBoundary fallback={<ErrorPage />}>
        	<RouterProvider router={router} />
        </ErrorBoundary>
    )
}

 

에러가 ErrorBoundary 까지 빠져나오지 못하고 router 안에서 uncaught 처리가 된다. 때문에, v6 안에서 에러 바운더리 처리를 하고 싶다면 아래와 같이 하나의 라우터마다 개별적으로 에러 바운더리를 달아주는 방법을 이용할 수 있다.

Next.js 에서 App router 를 사용하는 것과 비슷한데..?!

export const router = createBrowserRouter([
  {
    path: "/",
    element: <Header />,
    children: [
      {
        path: ROUTES.index,
        element: (
            <ErrorBoundary ...>
            	<App />
            </ErrorBoundary>
        ),
        ...
      },
    ],
    // errorElement: <ErrorPage />,
  },
  ...
]);

 

코드를 좀 더 DRY 하게 작성하고자 한다면 <Outlet /> 을 사용하는 곳에서 아래와 같이 에러 바운더리를 걸어야 한다.

<ErrorBoundary fallback={<ErrorPage />}>
    <Outlet />
</ErrorBoundary>

 

조금 더 관심사 분리에 초점을 맞춰보자. useQuery 를 사용하는 Data fetching 시에는 <Suspense>를 사용할 수 있고, 이를  <ErrorBoundary> 와 묶어서 HOC, 고차 컴포넌트 형태로 만들어 사용할 수도 있다.

 

interface AsyncBoundaryProps {
    rejectedFallback: ComponentProps<typeof ErrorBoundary>['fallback'];
    pendingFallback: ComponentProps<typeof Suspense>['fallback'];

function withAsyncBoundary<Props = Record<string, never>>(
    WrappedComponent: ComponentType<Props>,,
    { pendingFallback, rejectedFallback }: AsyncBoundaryProps,
) {
	return (props: Props) => {
    	  return (
              <ErrorBoundary fallback={rejectedFallback}>
            	  <Suspense fallback={pendingFallback}>
                	  <WrappedComponent {...props} />
                  </Suspense>
              </ErrorBoundary>
          );
    };
}

 

처음 인터페이스에서 타입을 지정하면서, ComponentProps 를 통해 특정 컴포넌트 타임의 props 타입을 추출하고 있다. 여기선 ErrorBoundary 와 Suspense의 각 props 로 들어가는 fallback 프로퍼티의 타입을 추출한다.

 

그리고 5번째 줄에서 제네릭 Props 의 기본값을 Record<string, never> 즉, 빈 객체로 받고 있다. 이는 Props 타입이 명시되지 않은 경우 기본적으로 '빈 객체' 타입이 사용된다는 의미이다.

 

다음 줄에서는 ComponentType<Props>를 통해 Props 타입을 받아들이는 모든 React 컴포넌트의 타입을 나타내고 있다.

 

사용 예시

function App() {
    ...
    return (...);
}

export default withAsyncBoundary(App, {
    pendingFallback: <Loading />,
    rejectedFallback: <ErrorPage />
});

 

위와 같이 HOC 형태를 통해 Suspense와 ErrorBoundary 를 통합해 사용하면, 데이터 로딩시와 에러가 발생했을 때의 처리 로직을 컴포넌트 내부의 비즈니스 로직 등과 분리하여 관심사를 서로 떼어놓을 수가 있고, 앱 전체 에러 바운더리 또는 로딩 컴포넌를 하나만 두어서 중앙 집중적인 처리보다 더욱 세밀한 핸들링을 할 수 있다.

 

 


Reference

https://tanstack.com/query/latest/docs/framework/react/overview