Frontend

[Frontend] React Error Boundary 뜯어보기

teddy bear 2026. 7. 14. 15:43
반응형

react-error-boundary 내부는 어떻게 동작할까

지난 글에서 React의 Error Boundary 개념과 react-error-boundary 라이브러리의 사용법을 다뤘습니다. 이번에는 한 걸음 더 들어가, 이 라이브러리가 내부적으로 어떤 원리로 동작하는지 직접 구현해보며 파헤쳐 보겠습니다.

⚠️ 아래 코드는 실제 라이브러리 소스를 그대로 옮긴 것이 아니라, 핵심 동작 원리를 이해하기 위해 단순화하여 재구성한 예시입니다. 실제 구현은 TypeScript 타입, 엣지 케이스 처리 등이 훨씬 정교합니다.


1. 이 라이브러리가 제공하는 것

react-error-boundary는 React가 기본으로 제공하지 않는 세 가지를 채워줍니다.

  1. 바로 쓸 수 있는 ErrorBoundary 컴포넌트 — 매번 클래스를 새로 작성할 필요가 없음
  2. resetKeys를 통한 자동 리셋 — 특정 값이 바뀌면 에러 상태를 자동으로 초기화
  3. useErrorBoundary 훅 — 함수형 컴포넌트의 이벤트 핸들러·비동기 코드에서 발생한 에러를 상위 Error Boundary로 위임

이 세 가지를 하나씩 뜯어보겠습니다.


2. 기본 뼈대: state 기반 렌더링 스위치

가장 바깥쪽 껍데기는 우리가 지난 글에서 봤던 것과 동일한 원리입니다. getDerivedStateFromError로 에러를 state에 저장하고, render()에서 state에 따라 분기합니다.

class ErrorBoundary extends React.Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error, info) {
    this.props.onError?.(error, info);
  }

  resetErrorBoundary = (...args) => {
    this.props.onReset?.({ reason: 'imperative-api', args });
    this.setState({ error: null });
  };

  render() {
    const { error } = this.state;

    if (error !== null) {
      // fallback, FallbackComponent, fallbackRender 세 방식을 모두 지원
      if (React.isValidElement(this.props.fallback)) {
        return this.props.fallback;
      }
      if (typeof this.props.fallbackRender === 'function') {
        return this.props.fallbackRender({ error, resetErrorBoundary: this.resetErrorBoundary });
      }
      if (this.props.FallbackComponent) {
        const FallbackComponent = this.props.FallbackComponent;
        return <FallbackComponent error={error} resetErrorBoundary={this.resetErrorBoundary} />;
      }
      throw error; // fallback을 아무것도 안 넘겼다면 그대로 위로 던짐
    }

    return this.props.children;
  }
}

여기서 흥미로운 지점은 세 가지 폴백 방식(fallback / FallbackComponent / fallbackRender)을 하나의 컴포넌트가 모두 지원한다는 점입니다. 정적인 JSX만 필요하면 fallback, error와 resetErrorBoundary에 접근해야 하면 FallbackComponent나 fallbackRender를 쓰는 식으로 사용처의 취향에 맞춰 고를 수 있게 설계된 것이죠.


3. resetKeys는 어떻게 동작할까

resetKeys는 배열 형태의 prop으로, 이 배열의 값이 바뀌면 에러 상태를 자동으로 리셋시켜줍니다. 예를 들어 라우트가 바뀌었을 때 이전 페이지의 에러가 남아있지 않도록 하는 데 유용합니다.

<ErrorBoundary resetKeys={[pathname]}>
  <PageContent />
</ErrorBoundary>

이걸 구현하려면 이전 렌더링의 resetKeys와 현재 resetKeys를 비교해야 합니다. 클래스 컴포넌트에서 "다음 props가 들어왔을 때 이전 값과 비교"하는 표준적인 방법은 getDerivedStateFromProps입니다.

class ErrorBoundary extends React.Component {
  state = { error: null };
  prevResetKeys = this.props.resetKeys;

  static getDerivedStateFromError(error) {
    return { error };
  }

  static getDerivedStateFromProps(props, state) {
    // 에러가 있는 상태에서 resetKeys가 하나라도 바뀌었다면 리셋
    if (state.error !== null && props.resetKeys) {
      const changed = props.resetKeys.some(
        (key, index) => !Object.is(key, state.prevResetKeys?.[index])
      );
      if (changed) {
        return { error: null, prevResetKeys: props.resetKeys };
      }
    }
    return { prevResetKeys: props.resetKeys };
  }

  // ... (이하 동일)
}

핵심은 얕은 비교(shallow comparison) 를 배열 요소마다 Object.is로 수행한다는 점입니다. 객체 전체를 깊게 비교하지 않는 이유는, 배열 안에 원시값(문자열 id, 숫자 등)을 넣는 것이 일반적인 사용 패턴이기 때문입니다.

💡 실무 팁: resetKeys에 객체나 배열을 직접 넣으면 매 렌더링마다 새로운 참조가 생성되어 의도치 않게 계속 리셋될 수 있습니다. [userId]처럼 원시값을 넣는 것이 안전합니다.


4. useErrorBoundary: 함수형 컴포넌트에서 에러를 "위로 던지는" 트릭

가장 흥미로운 부분입니다. React의 Error Boundary는 렌더링 중 발생한 에러만 잡습니다. 그런데 useErrorBoundary 훅은 useEffect나 이벤트 핸들러 같은, 원래 Error Boundary가 관여할 수 없는 영역의 에러까지 잡아낼 수 있게 해줍니다. 어떻게 가능할까요?

답은 "강제로 렌더링 중 에러가 발생한 것처럼 만든다" 입니다.

function useErrorBoundary() {
  const [state, setState] = useState({ error: null });

  // state.error가 설정된 상태로 다시 렌더링되면,
  // 아래 throw가 "렌더링 도중 발생한 에러"가 되어
  // 상위 Error Boundary가 정상적으로 캐치할 수 있게 된다.
  if (state.error !== null) {
    throw state.error;
  }

  const showBoundary = useCallback((error) => {
    setState({ error });
  }, []);

  const resetBoundary = useCallback(() => {
    setState({ error: null });
  }, []);

  return { showBoundary, resetBoundary };
}

동작 순서를 정리하면:

1. 비동기 작업(fetch 등)에서 에러 발생
        ↓
2. catch 블록에서 showBoundary(error) 호출 → setState 실행
        ↓
3. React가 리렌더링을 트리거
        ↓
4. 컴포넌트 함수 본문이 다시 실행되며 `if (state.error) throw state.error` 도달
        ↓
5. 이번엔 "렌더링 중"에 던져진 것이므로, 상위 ErrorBoundary가 정상적으로 캐치

즉, 진짜로 이벤트 핸들러 안에서 에러를 잡아채는 것이 아니라, 에러를 state에 담아두었다가 다음 렌더링 사이클에서 다시 던지는 우회 전략입니다. setState가 리렌더링을 예약하고, 그 리렌더링의 함수 본문 실행 자체가 React 렌더링 단계 안에 있다는 사실을 이용한 것이죠.

function DataFetcher() {
  const { showBoundary } = useErrorBoundary();

  useEffect(() => {
    fetchData().catch((error) => showBoundary(error));
  }, []);

  return <div>...</div>;
}

5. withErrorBoundary: HOC로 감싸는 패턴

컴포넌트를 매번 <ErrorBoundary>로 감싸는 대신, 고차 컴포넌트(HOC) 형태로 제공하는 withErrorBoundary도 같은 원리 위에서 동작합니다.

function withErrorBoundary(Component, errorBoundaryProps) {
  function Wrapped(props) {
    return (
      <ErrorBoundary {...errorBoundaryProps}>
        <Component {...props} />
      </ErrorBoundary>
    );
  }

  // 디버깅 시 컴포넌트 이름이 보이도록 displayName 설정
  Wrapped.displayName = `withErrorBoundary(${Component.displayName || Component.name})`;

  return Wrapped;
}

구조적으로는 단순하지만, displayName을 신경 써서 붙여주는 부분은 React DevTools에서 컴포넌트 트리를 디버깅할 때 실질적인 도움이 됩니다. 이런 디테일이 라이브러리와 직접 작성한 코드의 완성도 차이를 만듭니다.


6. React 19: useTransition과의 관계

React 19부터는 startTransition에 전달한 함수 안에서 발생한 에러가 가장 가까운 Error Boundary에 의해 캐치됩니다. <cite index="9-1">이는 useErrorBoundary 훅의 대안으로 활용될 수 있습니다.</cite>

function AddCommentButton() {
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    startTransition(async () => {
      await addComment(); // 여기서 에러가 나면 상위 ErrorBoundary가 캐치
    });
  }

  return (
    <button disabled={isPending} onClick={handleClick}>
      {isPending ? '추가 중...' : '댓글 추가'}
    </button>
  );
}

이 경우 라이브러리가 별도로 무언가를 하지 않아도, React 자체가 트랜지션 내부의 에러를 렌더링 에러 취급하도록 내부 스케줄러 차원에서 처리해줍니다. useErrorBoundary가 "우회 트릭"으로 렌더링 단계에 에러를 재주입했다면, useTransition은 React가 애초에 이 시나리오를 공식적으로 지원하는 셈입니다. 이 부분은 라이브러리가 아니라 React 코어의 발전 방향을 보여주는 대목이라 흥미롭습니다.


7. 전체 그림 정리

┌─────────────────────────────────────────────┐
│              <ErrorBoundary>                  │
│                                               │
│  state.error ──── getDerivedStateFromError    │
│       │           (렌더링 중 에러 캐치)         │
│       │                                       │
│       ├── resetKeys 비교 ── getDerivedStateFromProps
│       │   (props 변경 시 자동 리셋)             │
│       │                                       │
│       └── fallback / FallbackComponent /      │
│           fallbackRender 중 하나로 렌더링       │
│                                               │
│  ┌─────────────────────────────────────┐     │
│  │  useErrorBoundary()                  │     │
│  │  showBoundary(error) → setState      │     │
│  │       → 리렌더링 시 throw             │     │
│  │       → 부모 ErrorBoundary가 캐치     │     │
│  └─────────────────────────────────────┘     │
└─────────────────────────────────────────────┘

8. 배운 점

이 라이브러리의 구현을 뜯어보면서 얻을 수 있는 인사이트는 두 가지입니다.

  1. React의 제약을 우회하는 패턴: 함수형 컴포넌트에는 componentDidCatch가 없지만, setState → 리렌더링 → throw라는 흐름을 이용하면 "비동기 에러를 렌더링 에러로 변환"할 수 있습니다. 이는 React API가 막아놓은 것을 억지로 뚫는 게 아니라, React가 이미 제공하는 렌더링 사이클을 영리하게 재활용하는 접근입니다.
  2. API 설계의 유연성: fallback / FallbackComponent / fallbackRender 세 가지를 모두 지원하는 것처럼, 좋은 라이브러리는 하나의 "정답"을 강요하지 않고 사용 맥락에 맞는 여러 진입점을 제공합니다.

작은 라이브러리라도 소스를 직접 들여다보면, "왜 이렇게 설계했을까"에 대한 답을 얻을 수 있고 이는 곧 여러분의 코드 설계 감각으로 이어집니다.


다음 글에서는 React 19의 useTransition과 Error Boundary가 함께 만들어내는 새로운 에러 핸들링 패턴을 좀 더 깊게 다뤄보겠습니다.