React ErrorBoundary를 사용하여 에러 처리 개선하기 (with react-query)

sy u·2022년 7월 12일
38

🚪 들어가기

과거에 컴포넌트 내부 JavaScript에 발생한 에러가 React의 내부 상태를 훼손하고 다음 렌더링에서 에러 방출을 유발했다. 이런 에러는 어플리케이션 코드의 이전 단계의 에러로 발생했지만 React 컴포넌트 내에서 이런 에러를 처리할 수 있는 방법이 없었다.
이를 위해 Error Boundary라는 개념이 도입되었다. 이 글에서는 Error Bondary를 생성해 보고 커스텀 fallback UI 적용, 에러 상태에서 벗어나기, react-query에 적용하기를 다룰 예정이다.

🔎 Error Boundary란?

React 공식홈페이지
UI 일부분(컴포넌트)에 존재하는 JavaScript 에러가 전체 어플리케이션을 중단해서는 안된다.
이를 보완하기 위해 React 16에서 Error Boundary 라는 새로운 개념이 도입되었다.
UI의 일부분에 존재하는 자바스크립트 에러가 전체 애플리케이션을 중단시켜서는 안 됩니다. React 사용자들이 겪는 이 문제를 해결하기 위해 React 16에서는 에러 경계(“error boundary”)라는 새로운 개념이 도입되었습니다.

ErrorBondary는 React 컴포넌트 트리 하위에 있는 컴포넌트 내부에 JavaScript 에러를 캐치하는 컴포넌트이다.
개발하다 보면 예기치 못한 에러가 발생해 흰 화면만 보여주며 전체 어플리케이션이 중단되는 경우를 경험한다.
이렇게 에러가 발생하면 개발자들은 개발자 도구를 열고 console 창을 확인하여 에러 내용을 확인할 수 있지만 사용자에게 무엇이 잘못된지 알 수 없고 또한 어플리케이션의 모든 작업이 중단되어 좋은 경험을 주지 못한다.
따라서 우리는 적절히 에러를 핸들링해 줘야 한다.

Error Boundary 특징

<ErrorBoundary fallback={<div>Error 발생!</div>}>
  <Component />
</ErrorBoundary>

Error Boundary 컴포넌트로 감싼 하위 컴포넌트 트리 어디에서든 JavaScript 에러를 기록하고 깨진 컴포넌트 트리 대신 fallback UI를 보여준다.

렌더링 도중 하위 전체 트리에서 에러를 포착한다.

class 컴포넌트만 Error Boundary가 될 수 있다.

Error Boundary가 에러 메시지 렌더링에 실패하면 상위에서 가장 근접한 Error Boundary로 전파된다.

🛠️ ErrorBoundary 적용하기

우선 React 공식홈페이지에서 제공하는 기본 ErrorBoundary 코드를 참고하여 프로젝트에 적용해 보자

Error Boundary 컴포넌트 생성

class ErrorBoundary extends React.Component {
    constructor(props) {
      super(props);
      this.state = { hasError: false };
    }

    static getDerivedStateFromError(error) {
      console.log('error: ', error);
      // 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트
      return { hasError: true };
    }

		componentDidCatch(error, errorInfo) {
	    // 에러 리포팅 서비스에 에러를 기록
		  console.log('error: ', error);
			console.log('errorInfo: ', errorInfo);
	  }
	
    render() {
      if (this.state.hasError) {
        return <h1>Something went wrong.</h1>;
      }

      return this.props.children;
    }
  }

위 코드를 보면 class 컴포넌트의 static getDerivedStateFromError 메서드와 componentDidCatch를 사용하여 하위 컴포넌트에서 렌더링 중, 발생한 에러를 포착한다.
하지만 두 메서드는 서로 다른 시점에 에러를 포착하며 하는 일도 다르다.

위 코드로 생성한 ErrorBoundary 컴포넌트 하위 컴포넌트에서 에러가 발생했다고 가정해 보자. 두 메서드 모두 에러를 포착하고 인자로 받은 error를 console 창에 표시한다. 하지만 확인해 보면 static getDerivedStateFromError에서 표시한 error 정보가 componentDidCatch에서 표시한 error 정보 보다 위에 표시된 것을 확인할 수 있다.

static getDerivedStateFromError

error를 매개변수로 받고 갱신될 state 값을 반환한다.
확인한 내용을 바탕으로 알아보자면 하위 컴포넌트의 렌더링 중 에러가 발생하면 static getDerivedStateFromError렌더 단계에서 호출된다.
여기서 말하는 렌더 단계는 렌더링 결과로 수집한 내용으로 Virtual DOM을 생성하고 이전 Virtual DOM과 비교하는 단계까지를 말한다.

static getDerivedStateFromError 내부에는 side effects가 발생할 만한 작업을 해서는 안 된다.

  • 반환된 값으로 state를 갱신하고 다음 렌더링 때, fallback UI를 표시한다.
  • fallback UI를 렌더링할 때 주로 사용한다.

componentDidCatch

반면 componentDidCatch커밋 단계에서 호출되어 실행된다.
여기서 말하는 커밋 단계는 Virtual DOM을 이용해 계산된 모든 변경사항 실제 DOM에 적용하는 단계를 말한다.
따라서 이 메서드 내부에는 side effects가 발생해도 된다. 따라서 에러 정보에 대한 로그를 남길 때, 주로 사용된다.

  • 두개의 매개변수를 받는다.
    • error 에러에 대한 정보
    • info 어떤 컴포넌트가 오류를 발생했는지에 대한 내용을 포함한 componentStack키를 갖고 있는 객체
  • 커밋 단계에서 호출되기 때문에 side effect가 발생해도 괜찮다.
  • 에러 정보를 기록할 때 주로 사용된다.

🚫 Error Boundary가 포착하지 않는 에러

렌더링 과정에서 에러를 포착하기 때문에 포착되지 않는 에러들이 있다.
그 사례를 알아보자

  • 이벤트 핸들러
  • try/catch문을 이용해 포착하면된다.
  • 비동기적 코드 (setTimeout, requestAnimationFrame콜백)
  • 서버 사이드 렌더링
  • 하위에서가 아닌 Error Boundary 자체에서 발생하는 에러

생성한 ErrorBoundary 컴포넌트로 하위 컴포넌트 감싸기

<ErrorBoundary>
  <ChildComponent />
</ErrorBoundary>

🚚 fallback UI를 prop으로 전달하여 재사용 가능한 ErrorBoundary 컴포넌트 생성하기

위 단계까지만 진행해도 ErrorBoundary로 감싸진 하위 컴포넌트에서 JavaScript 에러가 발생했을 때, 더 이상 흰 화면이 나타나지 않고 fallback UI가 화면에 표시되며 어플리케이션이 손상되지 않을 것이다.

그러나 위에서 생성한 ErrorBoundary 컴포넌트는 에러가 발생한 컴포넌트 대신 위치할 에러에 관한 메시지가 컴포넌트 내부에 정의되어 있기 때문에 에러가 발생한 컴포넌트에 따라 조금 더 자세한 내용을 화면에 표시하고 싶을 때 적절하지 않다.
결국 다른 컴포넌트에의 에러를 처리하기 위해 수많은 ErrorBoundary 컴포넌트를 생성해야 하는데 이는 비효율적이다. 따라서 우리는 ErrorBoundary 컴포넌트를 재사용 가능하게 만들어야 한다.

1. fallback prop을 전달받도록 ErrorBoundary 컴포넌트로 수정

import React, { ErrorInfo, ReactNode } from 'react';

interface Props {
  children?: ReactNode;
  fallback: React.ElementType;
}

interface State {
  hasError: boolean;
  info: Error | null;
}

class ErrorBoundary extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = {
      hasError: false,
      info: null,
    };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, info: error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.log('error: ', error);
    console.log('errorInfo: ', errorInfo);
  }

  render() {
    const { hasError, info } = this.state;
    const { children } = this.props;
    if (hasError) {
      return <this.props.fallback error={info} />;
    }
    return children;
  }
}

export default ErrorBoundary;

2. 생성한 ErrorBoundary 컴포넌트로 하위 컴포넌트 감싸고 fallback UI 전달

import React from 'react';
import Error from '../Error';
import ErrorBoundary from '../ErrorBoundary';
import ChildComponent from '../ChildComponent';

const Main = () => (
   <ErrorBoundary fallback={Error}>
     <ChildComponent />
   </ErrorBoundary>
);

export default Main;

🔑 key prop 사용하여 Error 상태 벗어나기

이제 Error 컴포넌트를 fallback UI로 전달하여 ErrorBoundary를 재사용할 수 있게 되었다. 하지만 여기에는 fallback UI가 보이는 상황에서 벗어날 수 없는 또 다른 문제가 존재한다.

어플리케이션 손상을 막기 위해 에러가 발생한 일부 컴포넌트만 fallback UI를 대체하여 사용했다. 그러나 우리는 사용자가 에러가 발생한 상황에서 벗어날 수 있는 작업을 하면 fallback UI가 아닌 정상적인 컴포넌트를 화면에 보여줘야 한다.

더 쉽게 예를 들면 stop 버튼을 눌러 state가 false가 되면 Error가 발생하고 fallback UI가 표시된다고 가정해 보자. 이때 start 버튼을 눌러 state를 true로 갱신하면 다시 정상적인 컴포넌트를 화면에 보여줘야 한다.

이 문제는 ErrorBoundary 컴포넌트에 key prop을 추가함으로써 해결할 수 있다.
key 값이 변경되면 ErrorBoundary는 언마운트 되고 다시 마운트 된다.
즉, 사용자가 에러가 발생한 상황에서 벗어날 수 있는 작업을 하면 ErrorBoundary는 언마운트 되고 다시 마운트 된다.

import React from 'react';
import Error from '../Error';
import ErrorBoundary from '../ErrorBoundary';
import ChildComponent from '../ChildComponent';

const Main = () => (
   <ErrorBoundary fallback={Error} key={[id]}>
     <ChildComponent />
   </ErrorBoundary>
);

export default Main;

🛠️ react-query에 적용하기

useQuery에서 data를 로드할 때, 발생한 에러를 렌더 단계에서 에러를 발생시키고 가장 가까운 오류 경계로 전파하려면 useErrorBoundary 옵션을 true 해주면 된다.

useErrorBoundary 옵션 설정하기

전역 설정

import { QueryClient, QueryClientProvider } from 'react-query'
 
const App = ({ Component, pageProps }: AppProps) => {
 const queryClientRef = useRef<QueryClient>();
  if (!queryClientRef.current) {
    queryClientRef.current = new QueryClient({
      defaultOptions: {
        queries: {
          useErrorBoundary: true,
        },
      },
    });
  }
   return (
     <QueryClientProvider client={queryClientRef.current}>
       <Component {...pageProps} />
     </QueryClientProvider>
   )
 }

개별 쿼리 설정

 import { useQuery } from 'react-query'
 
 useQuery(queryKey, queryFn, { useErrorBoundary: true })

옵션을 설정하면 데이터를 불러올 때 오류가 발생하면 ErrorBoundary 컴포넌트가 포착할 수 있다.
그런데 우리가 위에서 key prop을 통해 사용자가 Error 상태에서 벗어날 수 있도록 한 것처럼 useQuery 또한 쿼리를 재시도하여 Error에서 벗어날 수 있어야 한다.

Error 발생 후, 쿼리 재시도

Error가 발생한 후, 우리는 다음 렌더링에 쿼리를 재시도할 방법이 필요하다.

QueryErrorResetBoundary

QueryErrorResetBoundary 컴포넌트를 사용하면 하위에 존재하는 모든 쿼리 에러를 재설정할 수 있다.

<QueryErrorResetBoundary>
	{({ reset }) => (
		<ErrorBoundary
        	onReset={reset}
            fallback={ErrorFallback}
            message="사용자를 로드하는데 실패 하였습니다."
        >
        	<Profile />
        </ErrorBoundary>
	)}
</QueryErrorResetBoundary>

물론 여러개 ErrorBoundary도 하위에 놓을 수 있다.

<QueryErrorResetBoundary>
	{({ reset }) => (
		<ErrorBoundary
        	onReset={reset}
            fallback={ErrorFallback}
            message="사용자를 로드하는데 실패 하였습니다."
        >
        	<Profile />
        </ErrorBoundary>
        <ErrorBoundary
        	onReset={reset}
            fallback={ErrorFallback}
            message="포스트를 로드하는데 실패 하였습니다."
        >
        	<PostList />
        </ErrorBoundary>
	)}
</QueryErrorResetBoundary>

useQueryErrorResetBoundary

이 Hook은 가장 근접한 QueryErrorResetBoundary 컴포넌트 하위에 있는 모든 쿼리 오류를 재설정한다. 만약 정의된 QueryErrorResetBoundary 컴포넌트가 없다면 전역으로 설정된다.

const { reset } = useQueryErrorResetBoundary();
<ErrorBoundary
	onReset={reset}
    fallback={ErrorFallback}
    message="사용자를 로드하는데 실패 하였습니다."
>
	<Profile />
</ErrorBoundary>

간단해 보이지만 우리가 React 공홈에서 가져온 ErrorBoundary 컴포넌트로는 작동되지 않는다.

react-query 공식 홈페이지의 예제를 보면 react-error-boundary라는 Error Boundary 컴포넌트 라이브러리를 사용하고 있다.
하지만 이 기능 하나가 필요해서 추가로 라이브러리를 설치하고 싶지는 않았다.

그래서 나는 React 공홈에서 가져온 Error Boundary 컴포넌트를 좀 더 커스텀 화 하기로 했다.

🛠️ 쿼리 재시도를 할 수 있는 커스텀 ErrorBoundary 만들기

1. react-query가 제공하는 reset() 내부를 살펴보기

아래 코드는 라이브러리를 살펴보고 간단하게 만든 코드입니다. 실제 코드는 다르기 때문에 직접 확인하시길 바랍니다.

function createValue() {
  var isReset = false;
  return {
    reset: function reset() {
      isReset = true;
    },
  };
}

var ErrorBoundaryContext = React.createContext(createStore());

var useQueryErrorResetBoundary = function useQueryErrorResetBoundary() {
  return React.useContext(ErrorBoundaryContext);
};

var QueryErrorResetBoundary = function QueryErrorResetBoundary({ children }) {
  var value = React.useMemo(function () {
    return createStore();
  }, []);

  return React.createElement(ErrorBoundaryContext.Provider, {
    value: value
  }, typeof children === 'function' ? children(value) : children);
};

QueryErrorResetBoundary는 Provider 컴포넌트 역할이고 Provider value에 reset 함수가 포함되어 있는 것이다.

따라서 useQueryErrorResetBoundary를 사용하면 단지 useContext로 가장 가까운 Provider(QueryErrorResetBoundary)의 value를 가져오는 것이다. 그래서 가장 근접한 QueryErrorResetBoundary 컴포넌트 하위의 모든 쿼리 에러를 재설정할 수 있는 것이다.

여기까지는 이해됐다. 그렇다면 Context에 있는 isReset을 true로 변경하면 되는 것일까? 작업해 보자!

2. ErrorBoundary 컴포넌트 수정하기

우선 마찬가지로 react-error-boundary 라이브러리 내용을 찾아보고 내가 필요한 내용만 나의 ErrorBoundary 컴포넌트에 적용한 내용은 아래와 같다.

import React, { ReactNode } from 'react';

export interface Props {
  isRefresh?: boolean;
  fallback: React.ElementType;
  message?: string;
  onReset?: () => void;
  children?: ReactNode;
}

interface State {
  hasError: boolean;
  info: Error | null;
}

const initialState: State = {
  hasError: false,
  info: null,
};

class ErrorBoundary extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = initialState;
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, info: error };
  }

  onResetErrorBoundary = () => {
    const { onReset } = this.props;
    onReset == null ? void 0 : onReset();
    this.reset();
  };

  reset() {
    this.setState(initialState);
  }

  render() {
    const { hasError, info } = this.state;
    const { children, message, isRefresh } = this.props;

    if (hasError) {
      const props = {
        error: info,
        onResetErrorBoundary: this.onResetErrorBoundary,
      };
      return (
        <this.props.fallback
          isRefresh={isRefresh}
          onRefresh={this.reset}
          onReset={props.onResetErrorBoundary}
          message={message}
        />
      );
    }
    return children;
  }
}

export default ErrorBoundary;

우선 reset 함수를 onReset prop으로 전달받는다. class 내에 onReset prop이 null이 아니라면 해당 함수를 호출하고 ErrorBoundary의 state를 초기 state 값으로 갱신하는 함수를 선언한다.
그다음 fallback UI에 있는 button에 해당 메서드를 이벤트 핸들러로 연결한다.

ErrorBoundary 내에 Error가 발생했다고 가정하면 재실행 할 수 있는 button이 포함된 fallback UI가 표시된다.
해당 버튼을 클릭하면 클릭 이벤트 핸들러로 전달했던 onResetErrorBoundary 메서드가 실행된다. 따라서 다음 렌더링에 query가 재실행되고 정상적으로 전달받았다면 원래 표시돼야 하는 UI가 표시된다.

🗃️ 참고 블로그

Handle errors gracefully with React Error Boundary
What you may not know about the Error Boundary
[React] ErrorBoundary 사용하여 에러 핸들링 하기
React Suspense와 ErrorBoundary를 이용하여 데이터 팬딩처리와 에러 처리 개선하기
React TypeScript Cheatsheet

1개의 댓글

comment-user-thumbnail
2023년 6월 3일

🙇‍♂️🙇‍♂️🙇‍♂️🙇‍♂️🙇‍♂️🙇‍♂️🙇‍♂️🙇‍♂️🙇‍♂️

답글 달기