Error Boundary를 사용해 효율적으로 에러 핸들링하기

태현·2023년 11월 11일
8
post-thumbnail

개요

크레센도 프로젝트에서 에러 처리한 과정과 고민 과정에 대해 소개하려고 합니다.
기존의 에러 처리 흐름은 이러하였습니다.

1차적으로 Axios Interceptors의 사용자 권한 관련(401, 403) 에러 핸들링
2차적으로 mutation onError 콜백 함수에서 토스트 메시지 및 추가적인 에러 처리

하지만, 이러한 이유로 이것만으론 에러를 발생할 경우 대응하기는 어렵습니다.

  1. GET 요청이 실패할 경우
  2. Interceptors에서 세부적인 에러 핸들링이 어려움

다른 예시도 들어보겠습니다.

const Home = () => {
  return (
    <>
      <Header />
      <ToDoList />
      <Footer />
    </>
  );
};

const TodoList = () => {
  const { data, error, isError } = useGetTodos();

  if (error?.response.status === 401 || error?.response.status === 403) {
    return <div>접근 권한이 없습니다.</div>;
  }

  if (error) {
    return <div>알 수 없는 에러가 발생했습니다.</div>;
  }

  return (
    <ul>
      {data.map(({ id, content }) => (
        <li key={id}>{content}</li>
      ))}
    </ul>
  );
};

이처럼 error 객체를 통해 분기처리하거나 try ~ catch 로 예외처리를 해야 할 것입니다.

에러 처리에 대한 고민

여기서 저는 이러한 고민을 하게 됩니다.

  • Suspense를 통해 선언적으로 비동기 컴포넌트의 로딩 상태를 관리한 것처럼 에러 처리도 이와 같은 방식으로 처리할 수 있을까?
  • 네트워크 에러를 한 곳에서 처리할 수는 없을까?
  • 불가피한 상황에서 에러가 발생했을 때 API를 재요청 보내 사용자가 재시도 할 수 있는 방법은 없을까?
  • 특정 컴포넌트가 에러가 발생하였지만 React 트리 전체가 깨져서 사용자가 빈 화면을 봐야되는 상황을 해결할 수는 없을까?

Error Boundary란?

공식 문서에서는 이렇게 소개합니다.

UI의 일부에 있는 JavaScript 오류가 전체 앱을 깨서는 안 됩니다. 리액트 사용자의 이 문제를 해결하기 위해 리액트 16은 새로운 개념의 "Error Boundary"를 도입했습니다.

Error Boundary는 하위 구성 요소 트리의 어느 곳에서나 자바스크립트 오류를 잡아내고, 해당 오류를 기록하고, 충돌한 구성 요소 트리 대신 Fallback UI를 표시하는 React 구성 요소입니다. 오류 경계는 렌더링 중, 라이프사이클 방식에서, 그리고 그 아래에 있는 전체 트리의 생성자에서 오류를 잡아냅니다.

즉, 비동기를 수행하는 컴포넌트가 에러가 발생했을 경우 스크립트 전체가 깨지는 것을 방지하고 Fallback UI를 표시합니다.

고민 결과 ..

Axios Interceptors에서 잡지 못한 에러는 queryClient로, queryClient에서 잡지 못한 에러는 Error Boundary로 넘어가게 됩니다.
그래서 저는 이처럼 3가지 케이스로 처리하는 것이 자연스럽다고 생각했습니다.

  • get이 실패한 상황 : Error Boundary
  • post, put, patch, delete이 실패한 상황 : 토스트 메시지 노출
  • 사용자 권한 에러 : Interceptors에서 수행

Suspense를 감싸는 Error Boundary

Suspense를 감싸는 ErrorBoundary로 해당 컴포넌트 부분만 에러 Fallback을 보여줍니다.

type ErrorBoundaryProps = {
  fallback: ComponentType<ErrorFallbackProps>;
  reset?: VoidFunction;
};

type ErrorBoundaryState = {
  error: AxiosError | null;
};

/**
 * Suspense를 감싸는 컴포넌트가 에러를 발생했을 때 처리하는 컴포넌트입니다.
 * 에러를 다시 throw 할지 결정하는 shouldRethrow state가 포함되어 있습니다.
 */

class ErrorBoundary extends Component<
  PropsWithChildren<ErrorBoundaryProps>,
  ErrorBoundaryState
> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = {
      shouldRethrow: false,
      error: null,
    };
  }

  // render 되기 전 error 정보를 state에 저장
  static getDerivedStateFromError(error: AxiosError): ErrorBoundaryState {
    if (error.response?.status === 404) {
      return {
        shouldRethrow: true,
        error,
      };
    }
    return { shouldRethrow: false, error };
  }

  resetError() {
    if (this.props.reset) {
      this.props.reset();
    }

    this.setState({ error: null, shouldRethrow: false });
  }

  render() {
    const { fallback: Fallback, children } = this.props;
    const { error, shouldRethrow } = this.state;

    if (shouldRethrow) {
      throw error;
    }

    if (error) {
      return (
        <Fallback error={error} resetErrorBoundary={() => this.resetError()} />
      );
    }

    return children;
  }
}

export default ErrorBoundary;

다시 throw 할 지 여부를 결정하는 shouldRethrow가 포함되어 있습니다. render 이전 단계에서 getDerivedStateFromError가 실행되고 404 에러일 경우 상위 Errorboundary에서 에러 처리를 수행하도록 error를 throw합니다.

404 케이스를 나눈 이유는 404 응답을 받은 경우 해당 컴포넌트 부분만이 아닌 사용자가 경로를 잘못 입력했을 때의 404 페이지처럼 통일하고 싶었습니다.

에러 케이스에 따라 적절히 나눠 작성하였습니다.

const ErrorFallback = ({ error, resetErrorBoundary }: ErrorFallbackProps) => {
  const status = Number(error.response?.status);
  const isNotAuthorized = status === 403;
  const { title, content, button } = getErrorMessage(status);
  
  if (status === 404) return <NotFound reset={resetErrorBoundary} />;

  return (...)
};
const getErrorMessage = (status: number) => {
  switch (status) {
    case 403:
      return {
        title: '스터디 그룹 멤버만 확인하실 수 있어요.',
        content: '',
        button: '이전페이지로 이동',
      };
    default:
      return {
        title: '잠시 후 다시 시도해주세요.',
        content: '요청사항을 처리하는데\n 실패했습니다.',
        button: '다시 시도하기',
      };
  }
};

const { reset } = useQueryErrorResetBoundary();

<ErrorBoundary fallback={ErrorFallback} reset={reset}>
    <Suspense fallback={<StudyListSkeleton />}>
       <StudyList />
    </Suspense>
</ErrorBoundary>

React-Query를 사용하기 때문에 쿼리 오류를 재설정하는 함수를 추가적으로 props로 넘겼습니다.

스터디 멤버가 아닐 경우 과제 상세페이지에 진입할 시 403 응답을 보내줍니다. 스크립트 전체가 깨지는 것이 아닌 해당 컴포넌트 부분만 에러 화면을 렌더링합니다.

재시도하기

예기치 않은 에러가 발생했을 때 사용자는 재시도 버튼을 통하여 재요청을 할 수 있습니다.

루트 레벨에서 에러를 처리하는 GlobalErrorBoundary

ErrorBoundary에서 처리하지 못한 에러(404)를 처리하고 interceptors, queryClient에서 놓친 에러를 처리합니다.

type ErrorBoundaryProps = {
  fallback: ComponentType<ErrorFallbackProps>;
  reset?: () => void;
};

type ErrorBoundaryState = {
  error: AxiosError | null;
};

/**
 * Error Boundary에서 놓쳤거나 감싸지 않은 컴포넌트에서 발생한 에러를 루트 레벨에서 처리합니다.
 */

class GlobalErrorBoundary extends Component<
  PropsWithChildren<ErrorBoundaryProps>,
  ErrorBoundaryState
> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = {
      error: null,
    };
  }

  static getDerivedStateFromError(error: AxiosError): ErrorBoundaryState {
    return { error };
  }

  resetError() {
    if (this.props.reset) {
      this.props.reset();
    }

    this.setState({ error: null });
  }

  render() {
    const { fallback: Fallback, children } = this.props;
    const { error } = this.state;

    if (error) {
      return (
        <Fallback error={error} resetErrorBoundary={() => this.resetError()} />
      );
    }

    return children;
  }
}

export default GlobalErrorBoundary;

진입점 파일에 감싸줍니다.

<Layout>
    <GlobalErrorBoundary fallback={ErrorFallback} reset={reset}>
         <Component {...pageProps} />
     </GlobalErrorBoundary>
</Layout>

고의적으로 존재하지 않는 쿼리 파라미터를 url에 직접 입력해보겠습니다.


API로부터 404 응답을 받게 되고 404 페이지를 렌더링합니다.

마무리를 지으며

Suspense, ErrorBoundary를 통해 비동기 상태를 선언적으로 다룰 수 있어 DX(개발자 경험)과 UX(사용자 경험) 모두 잡을 수 있어 굉장히 유용했습니다.

0개의 댓글