ErrorBoundary는 어떻게 에러를 잡는걸까

kimgho
react
ErrorBoundary는 어떻게 에러를 잡는걸까

시작하며

이전 게시글인 비동기 흐름의 제어, React Suspense 들여다보기에서는 Suspense가 Promise를 던져 비동기 상태를 관리하는 방식을 살펴보았습니다. 그렇다면 데이터를 불러오다 실패하여 에러가 발생했을 때, 이 에러는 어디서 어떻게 잡아서 처리해야 할까요?

React에서 Suspense와 ErrorBoundary는 묶음 상품과 같습니다. 유연한 비동기 렌더링과 적절한 에러 처리를 구현하려면 이 두 가지 개념을 모두 이해하는 것이 중요합니다. 따라서 이번 글에서는 컴포넌트 트리 내에서 에러가 전파되고 처리되는 원리와 함께, ErrorBoundary가 어떻게 동작하는지 자세히 알아보겠습니다.

ErrorBoundary란?

ErrorBoundary는 하위 컴포넌트에서 throw된 에러를 catch하여 그에 따른 Fallback UI를 보여줄 수 있습니다. 하위 컴포넌트에서 에러가 발생했을 경우 이를 각 컴포넌트에서 처리하는 로직을 작성하지 않고, 에러가 발생한 컴포넌트와 가장 가까운 ErrorBoundary에 위임하여 선언적으로 에러를 처리할 수 있는 방식입니다.

1. 지역적으로 에러를 처리하는 방식

기존에는 데이터를 불러오는 컴포넌트 내부에서 직접 에러 상태를 관리하고 분기 처리를 해야 했습니다.

function UserProfile() {
  const [data, setData] = useState(null);
  const [isError, setIsError] = useState(false);
 
  useEffect(() => {
    fetchUserData()
      .then(setData)
      .catch(() => setIsError(true));
  }, []);
 
  // 에러 처리와 로딩 처리가 컴포넌트 내부에 혼재되어 있습니다.
  if (isError) return <div>데이터를 불러오는데 실패했습니다.</div>;
  if (!data) return <div>로딩 중...</div>;
 
  return <div>{data.name}님의 프로필</div>;
}

이 방식은 컴포넌트마다 에러 처리 로직(isError)을 중복 작성해야 하며, 컴포넌트 본연의 UI 렌더링 역할보다 상태 관리에 코드가 더 길어지는 단점이 있습니다.

2. ErrorBoundary로 처리하는 방식

ErrorBoundary를 사용하면 에러 처리 로직을 외부 컴포넌트에 위임하여 비즈니스 로직에만 집중할 수 있습니다.

// 성공적인 렌더링 로직(Happy Path)에만 집중하는 컴포넌트
function UserProfile() {
  // 내부에서 에러가 발생하면 가장 가까운 ErrorBoundary로 던집니다(throw).
  const data = useUserData();
 
  return <div>{data.name}님의 프로필</div>;
}
 
// 상위에서 에러 상태와 로딩 상태를 선언적으로 관리
function App() {
  return (
    <ErrorBoundary fallback={<div>데이터를 불러오는데 실패했습니다.</div>}>
      <Suspense fallback={<div>로딩 중...</div>}>
        <UserProfile />
      </Suspense>
    </ErrorBoundary>
  );
}

이렇게 작성하면 하위 컴포넌트인 UserProfile은 데이터가 성공적으로 불러와졌을 때의 UI만 깔끔하게 작성할 수 있습니다. 에러(ErrorBoundary)와 로딩(Suspense)에 대한 관심사는 부모 컴포넌트로 분리되어 더욱 관리하기 쉬운 코드가 됩니다.

React 컴포넌트의 생명주기

본격적으로 ErrorBoundary를 설명하기 전, 컴포넌트의 생명주기를 알고 가야 다음 나오는 내용을 더 쉽게 이해할 수 있으므로 잠시 생명주기에서 필요한 내용만 설명하겠습니다.

컴포넌트 생명주기

위 다이어그램은 React 클래스 컴포넌트의 생명주기를 나타냅니다. 현재 React 개발에서는 대부분 함수형 컴포넌트와 Hook을 사용하고 있지만, ErrorBoundary만큼은 클래스 컴포넌트로 작성해야 합니다.

그 이유는 렌더링 과정에서 발생하는 하위 컴포넌트의 에러를 포착하기 위한 핵심 생명주기 메서드들이 아직 Hook으로는 제공되지 않기 때문입니다. ErrorBoundary 역할을 하려면 다음 두 가지 메서드 중 최소 하나를 정의해야 합니다

  1. static getDerivedStateFromError(error)

    • 하위 컴포넌트 렌더링 중 에러가 발생했을 때 호출됩니다.
    • 에러를 인자로 받아 새로운 상태(state)를 반환함으로써, 다음 렌더링 시 Fallback UI를 보여줄 수 있게 합니다.
    • 렌더 단계(Render Phase)에서 호출되므로 상태 업데이트 외에 부수 효과(Side effect)를 발생시켜서는 안 됩니다.
  2. componentDidCatch(error, info)

    • 하위 컴포넌트에서 발생한 에러가 DOM에 반영된 후 커밋 단계(Commit Phase)에서 호출됩니다.
    • 에러 객체와 함께 에러가 발생한 컴포넌트 스택 정보(info.componentStack)를 받아 에러 모니터링 서비스(Sentry 등)에 로깅하는 용도로 주로 사용됩니다.

ErrorBoundary의 원리

우선 공식문서에서 나와있는 ErrorBoundary를 살펴보겠습니다.

import * as React from "react";
 
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
 
  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI.
    return { hasError: true };
  }
 
  componentDidCatch(error, info) {
    logErrorToMyService(
      error,
      // Example "componentStack":
      //   in ComponentThatThrows (created by App)
      //   in ErrorBoundary (created by App)
      //   in div (created by App)
      //   in App
      info.componentStack,
      // Warning: `captureOwnerStack` is not available in production.
      React.captureOwnerStack(),
    );
  }
 
  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return this.props.fallback;
    }
 
    return this.props.children;
  }
}

어떻게 Error를 잡는가? (React 내부 동작 원리)

ErrorBoundary가 에러를 포착하는 메커니즘은 단순한 자바스크립트의 try...catch와 유사해 보이지만, **React의 내부 렌더링 과정(Fiber 아키텍처)**과 엮여 훨씬 더 정교하게 작동합니다.

1. 에러 전파와 트리 거슬러 올라가기

에러가 발생하는 순간, React는 일반적인 렌더링 작업을 즉시 중단합니다. 하위 트리 어딘가에서 에러가 던져지면(throw), React는 해당 컴포넌트의 Fiber 노드에서부터 부모 방향으로 트리를 거슬러 올라가기 시작합니다.

이 과정에서 React가 찾는 것은 생명주기 메서드인 getDerivedStateFromErrorcomponentDidCatch를 구현한 클래스 컴포넌트입니다. 가장 가까운 ErrorBoundary 컴포넌트를 만나면, React는 에러를 전달하고 렌더링의 주도권을 넘깁니다. 이때 에러가 발생한 하위 트리의 기존 렌더링 작업물은 모두 버려집니다.

2. Suspense와 동일한 메커니즘

처음에 가졌던 의문으로 돌아가보겠습니다. Suspense와 ErrorBoundary는 왜 묶음 상품처럼 느껴질까요? 그 이유는 내부적으로 던지기(throw)라는 같은 메커니즘을 런타임에서 공유하고 있기 때문입니다.

  • Suspense: 컴포넌트가 렌더링 도중 데이터를 불러와야 할 때, 완료되지 않은 Promise를 throw 합니다. React는 이를 캐치한 후 가장 가까운 <Suspense>를 찾아 Fallback UI(로딩 화면)를 보여줍니다.
  • ErrorBoundary: 컴포넌트 렌더링 도중 문제가 생기면, 일반적인 자바스크립트 Error 객체를 throw 합니다. React는 이를 캐치한 후 가장 가까운 ErrorBoundary를 찾아 Fallback UI(에러 화면)를 보여줍니다.

결국 두 개념 모두 하위 컴포넌트가 렌더 단계에서 무언가를 던지면(throw), 상위의 선언적 컴포넌트가 이를 잡아채어(catch) 안전하게 처리한다는 철학을 바탕으로 만들어진 것입니다.

3. 상태 업데이트와 회복

ErrorBoundary는 에러를 전달받으면 static getDerivedStateFromError를 통해 자신의 상태를 { hasError: true }로 업데이트합니다. 상태가 변했으므로 컴포넌트는 다시 렌더링되며, 원래 그려야 할 자식들(props.children) 대신 대체 UI(fallback)를 화면에 그립니다. (DOM 업데이트가 끝난 뒤 componentDidCatch가 호출되어 로그를 전송합니다.)

그렇다면 대체 UI 화면에서 다시 원래의 정상적인 화면으로 복구하려면 어떻게 해야 할까요?

간단합니다. ErrorBoundary 내부에 있는 hasError 상태를 다시 false로 되돌려주기만 하면 됩니다. React는 상태가 변했으니 다시 props.children의 렌더링을 처음부터 시도하게 되며, 이때 하위 컴포넌트가 에러 없이 정상적으로 실행된다면 앱은 에러 상태에서 회복하게 됩니다.

ErrorBoundary는 만능이 아니다

ErrorBoundary를 통해 모든 에러를 잡을 수 있을 것 같지만 실제론 그렇지 않습니다. ErrorBoundary로 잡을 수 없는 에러에는 여러 종류가 있습니다.

ErrorBoundary 자체 에러

ErrorBoundary는 오직 컴포넌트 트리상에 존재하는 자신의 자식(하위) 컴포넌트에서 발생한 에러만 포착할 수 있습니다. 만약 ErrorBoundary 자체의 내부 로직(render 메서드, 혹은 Fallback UI를 렌더링하는 도중 등)에서 에러가 발생한다면, 자기 자신은 그 에러를 잡아낼 수 없습니다.

이는 자바스크립트의 catch {} 블록 내부 구현체에서 또 다른 에러가 발생했을 때, 해당 에러를 같은 catch 블록이 다시 잡을 수 없는 것과 같은 이치입니다.

자체적인 에러가 발생한 경우 무한 루프에 빠지는 것을 방지하기 위해, 이 에러는 자신이 잡지 않고 트리 상의 더 상위에 있는 다른 ErrorBoundary에게 전파됩니다.

이벤트 핸들러

React는 이벤트 위임 방식을 사용하기 때문에, 모든 이벤트는 개별 컴포넌트가 아닌 최상위 DOM 노드(root)에 부착되어 일괄적으로 처리됩니다. 이러한 원리 때문에 이벤트 핸들러 내부에서 발생하는 에러는 마치 컴포넌트 트리 바깥에서 발생한 것처럼 동작합니다.

하지만 더 근본적인 이유는 ErrorBoundary가 오직 "렌더링 단계 및 생명주기 메서드" 내부에서 발생한 에러만을 포착하도록 설계되었기 때문입니다. 사용자가 버튼을 클릭하다가 에러가 발생하더라도, 이는 화면을 그리는 렌더링 과정에서 발생한 문제가 아닙니다. 따라서 React는 화면의 UI 트리가 깨졌다고 판정하지 않으므로 굳이 현재 화면을 Unmount하고 대체 UI를 보여줄 필요가 없습니다.

만약 버튼 클릭 등 이벤트 핸들러에서 발생한 에러를 억지로 ErrorBoundary로 위임하고 싶다면, useState를 활용해 에러를 상태에 저장한 뒤 렌더링 단계에서 throw하게 만들거나, TanStack Query의 throwOnError 옵션을 활용해야 합니다.

비동기 코드

이 또한 자바스크립트의 비동기 실행 메커니즘을 떠올리면 쉽게 이해할 수 있습니다. setTimeout과 같은 비동기 타이머나 API 호출의 콜백 함수는 콜스택을 빠져나가 백그라운드나 태스크 큐에서 대기하다가, 나중에 비동기적으로 실행됩니다.

function MyComponent() {
  useEffect(() => {
    setTimeout(() => {
      // 렌더링이 모두 끝나고 나중에 실행되므로 ErrorBoundary가 잡을 수 없음
      throw new Error("비동기 에러 발생");
    }, 1000);
  }, []);
 
  return <div>테스트 컴포넌트</div>;
}

비동기 콜백 내부에서 에러가 throw 되는 순간, React의 동기적인 컴포넌트 렌더링 과정(Render & Commit Phase)은 이미 종료되었습니다. 에러가 발생한 시점의 콜스택에는 더 이상 상위에 ErrorBoundary 컴포넌트가 덮고 있지 않기 때문에 이를 잡아낼 수 없는 것입니다.

비동기 에러를 포착하고 싶다면 역시 콜백 내부의 catch 문에서 setState를 호출해 다음 렌더링을 유발하고, 렌더링 도중 에러를 던지도록 처리가 필요합니다.

function MyComponent() {
  const [error, setError] = useState(null);
 
  useEffect(() => {
    setTimeout(() => {
      try {
        // 비동기 작업 중 에러 발생
        throw new Error("비동기 에러 발생");
      } catch (err) {
        // 1. 에러를 상태에 저장하여 다음 렌더링을 트리거합니다.
        setError(err);
      }
    }, 1000);
  }, []);
 
  // 2. 렌더링 단계(Render Phase)에서 상태로 쥐고 있던 에러를 던집니다.
  // 이제 ErrorBoundary가 에러를 정상적으로 잡을 수 있습니다
  if (error) {
    throw error;
  }
 
  return <div>테스트 컴포넌트</div>;
}

서버 사이드 렌더링(SSR)

서버 사이드 렌더링 과정에서 발생한 에러 역시 ErrorBoundary가 잡을 수 없습니다.

React의 SSR(renderToString 등)은 컴포넌트 트리를 단일 패스로 실행하여 정적인 HTML 문자열을 만들어내는 과정입니다. 이 과정에서는 클라이언트 사이드처럼 동적으로 상태를 변경하거나 재렌더링을 유발하는 생명주기 메서드들(getDerivedStateFromError, componentDidCatch)이 아예 호출되지 않도록 설계되어 있습니다.

따라서 서버에서 컴포넌트를 렌더링하다가 내부에서 에러가 터지면, ErrorBoundary는 작동하지 않고 서버 렌더링 자체가 실패하게 됩니다.

자주 쓰는 Tanstack Query로 에러 잡아보기

앞서 설명한 것처럼 비동기 통신 등에서 발생한 에러를 ErrorBoundary로 전달하려면, 컴포넌트 내부에서 에러를 상태로 잡아 둔 뒤 렌더링 도중 억지로 throw해야 하는 번거로움이 있습니다. 다행히 우리가 데이터 패칭에 흔히 사용하는 TanStack Query(React Query)는 이런 과정을 옵션 하나로 처리해 줍니다.

throwOnError (v5 기준)

TanStack Query에서 네트워크 에러가 발생했을 때 이 에러를 가장 가까운 ErrorBoundary로 던지고 싶다면, 쿼리 옵션에 throwOnError: true를 추가하기만 하면 됩니다. (v4까지는 useErrorBoundary라는 이름이었습니다)

const { data } = useQuery({
  queryKey: ["user", userId],
  queryFn: fetchUserData,
  // 쿼리 실패 시 렌더 단계에서 자동으로 Error를 throw 해줍니다
  throwOnError: true,
});

이 옵션이 켜져 있으면, 비동기 콜백에서 쿼리가 실패하더라도 TanStack Query가 자체적으로 에러 상태를 캐치한 뒤 다음 렌더링 주기에 에러를 안전하게 다시 던져줍니다. 개발자가 직접 try-catchsetState를 사용해 우회할 필요 없이, 선언적인 코드 작성에만 집중할 수 있게 해주는 핵심 옵션입니다.

애플리케이션의 모든 쿼리를 ErrorBoundary로 처리하고 싶다면, QueryClient 세팅 시 defaultOptions에 전역으로 세팅할 수도 있습니다.

QueryErrorResetBoundary (에러 복구하기)

에러가 발생해 ErrorBoundary에 의해 대체 UI(Fallback)가 나타난 상황을 가정해 보겠습니다. 유저가 대체 UI에서 "다시 시도" 버튼을 누른다면 어떨까요? 단순히 ErrorBoundary가 들고 있던 hasError 상태만 false로 되돌린다고 해서 즉각적으로 데이터가 리패칭되지는 않습니다. TanStack Query의 캐시 내부에는 여전히 "이 쿼리는 에러가 났다"라는 상태가 남아있기 때문입니다.

이럴 때 React 컴포넌트 트리의 에러 상태와 TanStack Query의 오류 캐시를 동시에 초기화해주는 컴포넌트가 바로 QueryErrorResetBoundary입니다.

// 공식문서 제공
import { ErrorBoundary } from "react-error-boundary";
 
import { QueryErrorResetBoundary } from "@tanstack/react-query";
 
const App = () => (
  <QueryErrorResetBoundary>
    {({ reset }) => (
      <ErrorBoundary
        onReset={reset}
        fallbackRender={({ resetErrorBoundary }) => (
          <div>
            There was an error!
            <Button onClick={() => resetErrorBoundary()}>Try again</Button>
          </div>
        )}
      >
        <Page />
      </ErrorBoundary>
    )}
  </QueryErrorResetBoundary>
);

이 패턴을 사용하면, "다시 시도" 하나만으로 화면을 덮고 있던 에러 뷰를 걷어내면서 동시에 TanStack Query가 이전에 실패했던 데이터 패칭을 처음부터 다시 시도하게 만들 수 있습니다.

마치며

이 글을 적다가 재밌었던 것은 바운더리에서 처리 못하는 4가지 였는데, JS와 React에 대해 어느 정도 알고있다면 충분히 유추가 가능한 이유였습니다. 이 부분에서 다시 기본기인 JS와 React를 공부해야겠다고 생각했습니다.. ErrorBoundary와 Suspense의 조합은 UX와 서비스 측면에서 매우 중요하다고 느껴져서 모르고 쓰기보다 정확히 알고 써야 서비스 운영에 도움이 되기 때문에 굉장히 유익한 공부였습니다.

참고

QueryErrorResetBoundary - 공식문서

ErrorBoundary - 공식문서

행복한 시지프 - Tistory

에러바운더리 범위에 대해 - 영문