호진방 블로그
기술

Error Boundary를 이용하여 똑똑하게 에러처리해보기

# React Error Boundary를 이용한 에러처리 경험을 소개합니다.

2024년 05월 29일

도입 배경

❗ 현재 Offispace 프로젝트에서는 서버 측 에러 페이지 404.tsx와 공통 서버 에러 페이지 _error.tsx로 구분되어 관리되고 있다. 그러나 클라이언트 측 에러는 별도로 관리되고 있지 않아, 다음과 같은 문제가 발생한다.


발생 가능한 문제 시나리오

글 상세페이지에서는 총 2개의 API가 호출된다.

현재는 API가 제대로 동작하여 글 상세 페이지가 잘 보여지지만, 어느 한 곳이라도 에러가 발생한다면?

MSW를 이용하여 글 상세 데이터를 가져오는 API에 임의로 에러를 발생시켜보자.

export const postHandlers = [
  /* ----- 글 상세 데이터 가져오기 api ----- */
  http.get(`https://joo-api.store/posts/25`, () => {
    return HttpResponse.json('Not found', {
      status: 400,
    });
  }),
];

❗ 댓글은 정상적으로 가져오고 있지만, 글 상세 데이터를 가져오는 중 에러가 발생해 화면이 아예 터져버렸다. 따로 에러 처리를 하지 않아, 빈 화면이 출력되고 있는데, 유저 입장에서는 로딩중인지, 에러가 발생한건지, 화면이 넘어가긴 한건지 알 수 없어, 매우 큰 불편함이 생길것이다.



React Error Boundary 도입

React에서 제공하는 Error Boundary 라는 개념을 이용하여 위에서 발생한 문제를 내용을 해결할 수 있다. Error Boundary는 렌더링 중 hasError 상태를 추적하여 하위 트리 에서 발생하는 에러를 잡아내어 처리 할 수 있도록 도와준다.

(https://nextjs.org/docs/pages/building-your-application/configuring/error-handling)

Error Boundary는 위와 같이 작성된다. 다만 Next에서 제공되는 코드는 type이 적용되지 않아 Error Boundary Type관련 글을 찾아보고 적용시켜줬다.

https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/error_boundaries/

이제 코드를 뜯어보자.


ErrorBoundary 클래스 정의
class ErrorBoundary extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

constructor: 컴포넌트의 초기 상태를 설정하고, hasErrorfalse로 초기화한다.


static getDerivedStateFromError 메서드
static getDerivedStateFromError() {
  return { hasError: true };
}

해당 메서드는 컴포넌트에서 에러가 발생했을 때 호출되고, hasErrortrue로 설정하여 상태를 업데이트하는 역할을 담당한다.


componentDidCatch 메서드
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
  console.log({ error, errorInfo });
}

위 메서드는 실제로 에러가 발생했을 때 호출되며, 에러 정보와 함께 추가적인 에러 정보를 콘솔에 로그로 출력하는 역할을 담당한다. 주로 에러 추적 서비스에 에러 정보를 보낼 때 사용된다.


render 메서드
render() {
  if (this.state.hasError) {
    if (this.props.fallbackComponent != null) {
      return <>{this.props.fallbackComponent}</>;
    }
 
    return <div>문제가 발생했어요.</div>; //임의로 에러 문구 작성
  }
  return this.props.children;
}

동작 과정 요약

  1. 초기화: constructor를 통해 hasError 상태를 false로 설정
  2. 에러 발생: 자식 컴포넌트에서 에러가 발생하면 getDerivedStateFromError가 호출되어 hasError 상태를 true로 설정.
  3. 에러 처리: componentDidCatch가 호출되어 에러와 에러 정보를 콘솔에 출력.
  4. 렌더링: render 메서드는 hasError 상태를 확인하여, true인 경우 fallbackComponent나 기본 에러 메시지를 렌더링하고, false인 경우 자식 컴포넌트들을 그대로 렌더링한다.

공식문서에서 제공하는 사용법은 매우 간단하다. 위 코드를 사용하여 ErrorBoundary.tsx 컴포넌트를 만들고, 사용처에서 해당 컴포넌트를 감싸주면 된다. 단순히 ErrorBoundary를 감싸주는것만으로도 전체 클라이언트 에러를 감지 할 수 있게 되는것이다.

_app.tsx
<QueryClientProvider client={queryClient}>
  <Hydrate state={pageProps.dehydratedState}>
    <ErrorBoundary>
      {loading && <LoadingSpinner />}
      <Component {...pageProps} />
    </ErrorBoundary>
  </Hydrate>
</QueryClientProvider>

결과

ErrorBoundary로 앱을 감싸주고 확인해보면, 이전과 달리 빈 화면이 아닌, "에러 발생"이라는 문구가 표시되면서 사용자에게 에러가 발생했음을 확실히 인지 시킬 수 있다.

하지만, 해당 방식은 댓글은 정상적으로 가져오고 있지만, 여전히 글 상세 데이터에서 에러가 발생했다고 해서 전체 페이지를 사용할 수 없게 되는 문제가 발생한다. 이는 매우 좋지 않은 사용자 경험이라고 판단하여, 에러의 범위를 좁히는 방식으로 해결하고자 했다. 글 상세 API에서 에러가 발생하면 해당 부분만 에러 처리하고, 댓글은 정상적으로 작동하는것이다.


PostDetail.tsx
const PostDetail = () => {
...
  return (
    ...
  );
};
 
export default WrapErrorBoundary;
 
function WrapErrorBoundary() {
  return (
    <ErrorBoundary>
      <PostDetail />
    </ErrorBoundary>
  );
}

따라서 위 코드처럼 ErrorBoundary 를 에러 발생 컴포넌트인 PostDetail 를 감싸서 export default 해주었다. Error Boundary는 렌더링 중 hasError 상태를 추적하여 하위 트리 에서 발생하는 에러를 잡아내어 처리 할 수 있다라고 소개했는데, 이 동작을 이용해 하단에서 발생한 에러가 상위 트리까지 올라가는 과정을 차단하고, 미리 에러를 처리할 수 있도록 한것이다.


이제 다시 에러를 발생시켜보면, 전과 달리 댓글을 가져오는 API는 정상적으로 작동하여 화면에 잘 출력되고, 에러가 발생한 글 상세 API 부분만 에러 처리가 된 것을 볼 수 있다. 이를 통해 사용자는 앱이 완전히 멈춘 것이 아니라 특정 부분에서 오류가 발생했음을 인지하게 되어 안심 할 수 있게 된다.

해당 ErrorBoundary 컴포넌트는 댓글을 가져오거나, 글 상세 데이터를 가져오는 중 에러가 발생할 때 분기적으로 오류 컴포넌트의 디자인을 다르게 적용할 수 있다. 즉, 특정 API에 대해서 별도의 fallback 에러 컴포넌트를 사용할 수 있는 것이다.


변경된 ErrorBoundary
interface Props {
  children: React.ReactNode;
  fallbackComponent?: React.ReactNode;
}
 
interface State {
  hasError: boolean;
}
 
class ErrorBoundary extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }
  static getDerivedStateFromError() {
    return { hasError: true };
  }
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.log({ error, errorInfo });
  }
  render() {
    if (this.state.hasError) {
      if (this.props.fallbackComponent != null) {
        return <>{this.props.fallbackComponent}</>;
      }
 
      return <div>문제가 발생했어요.</div>;
    }
    return this.props.children;
  }
}
 
export default ErrorBoundary;
 
// !--    PostDetail      --!
function WrapErrorBoundary() {
  return (
    <ErrorBoundary
      fallbackComponent={
        <div className="mt-[100px] mb-[170px]">
          <div className="flex flex-col items-center justify-center">
            <Image src="/error.png" width={100} height={100} alt="error" />
            <div className="text-space-purple font-bold text-lg">
              글을 가져오는 중 일시적인 오류가 발생했습니다.
            </div>
            <div className="text-gray-500 text-md font-semibold">잠시 후 다시 시도해주세요</div>
          </div>
        </div>
      }
    >
      <PostDetail />
    </ErrorBoundary>
  );
}

따라서 기존 코드에 fallbackComponent?: React.ReactNode 를 추가시켜 Props로 컴포넌트를 받을 수 있도록 설정하고, 이를 return {this.props.fallbackComponent} 코드를 통해 fallbackComponent 가 들어온다면 해당 컴포넌트를 렌더링 시킬 수 있도록 수정했다.

이제 위 화면처럼 글을 가져올 때 에러가 발생하면 fallbackComponent로 넘겨준 에러 컴포넌트가 화면에 표시된다.


해당 작업을 진행하면서, 그렇다면 각 컴포넌트별로 API를 구분하여 사용처 전부 errorboundary로 묶어주고 각 API마다 fallbackComponent로 어디서 어떤 에러가 발생했는지 사용자에게 정확하게 인지시켜줄 수 있지 않을까? 라는 생각을 가지게 됐지만, 이는 현실적으로 어려운 일 일것이다.


따라서 위 코드처럼, 인증 에러, 네트워크 에러 등 공통적으로 일어날 수 있는 케이스로 에러를 최대한 분류하고, 그에 따라 선언적으로 에러를 처리하면, 사용자는 상황에 따라 좀 더 다듬어진 에러를 파악 할 수 있을것이다.


me
@banhogu
안녕하세요 배움을 나누며 함께 전진하는 1년차 주니어 개발자 방호진입니다.