컴포넌트가 렌더링 도중 에러를 던지면, 앱 전체가 하얀 화면이 되어도 괜찮을까요?

React 앱에서 에러는 피할 수 없습니다. API 응답이 예상과 다르거나, null 체크를 빠뜨리거나, 외부 라이브러리가 예외를 던질 수 있습니다. 중요한 것은 에러가 발생했을 때 앱 전체가 죽지 않고 우아하게 대처하는 것입니다.

ErrorBoundary 기본 개념

ErrorBoundary는 자식 컴포넌트 트리에서 발생한 JavaScript 에러를 잡아 대체 UI를 보여주는 React 컴포넌트입니다.

클래스 컴포넌트로만 만들 수 있다

현재까지 ErrorBoundary는 클래스 컴포넌트로만 구현할 수 있습니다. getDerivedStateFromErrorcomponentDidCatch에 대응하는 Hook이 아직 없기 때문입니다.

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

  // 에러 발생 시 state 업데이트 → 대체 UI 렌더링
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  // 에러 로깅 (사이드 이펙트)
  componentDidCatch(error, errorInfo) {
    console.error('ErrorBoundary caught:', error);
    console.error('Component stack:', errorInfo.componentStack);
    // 에러 리포팅 서비스에 전송
    reportError(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <h1>문제가 발생했습니다.</h1>;
    }
    return this.props.children;
  }
}

사용 방법

JSX
<ErrorBoundary fallback={<ErrorPage />}>
  <App />
</ErrorBoundary>

ErrorBoundary가 잡는 것과 못 잡는 것

잡을 수 있는 에러

  • 자식 컴포넌트의 렌더링 중 발생한 에러
  • 생명주기 메서드(useEffect 포함)에서 발생한 에러
  • constructor에서 발생한 에러

잡을 수 없는 에러

  • **이벤트 핸들러 **: onClick, onChange 등에서 발생한 에러
  • ** 비동기 코드 **: setTimeout, Promise 내부의 에러
  • ** 서버 사이드 렌더링 **: SSR 중 발생한 에러
  • ErrorBoundary 자체 의 에러
JSX
function ProblematicComponent() {
  const handleClick = () => {
    // 이 에러는 ErrorBoundary가 잡지 못한다
    throw new Error('이벤트 핸들러 에러');
  };

  // 이 에러는 ErrorBoundary가 잡는다
  if (someCondition) {
    throw new Error('렌더링 에러');
  }

  return <button onClick={handleClick}>클릭</button>;
}

ErrorBoundary 배치 전략

1. 앱 전체를 감싸는 최상위 바운더리

JSX
function App() {
  return (
    <ErrorBoundary fallback={<FullPageError />}>
      <Router>
        <Routes />
      </Router>
    </ErrorBoundary>
  );
}

최후의 안전망입니다. 하얀 화면 대신 에러 페이지를 보여줍니다.

2. 페이지 단위 바운더리

JSX
function App() {
  return (
    <ErrorBoundary fallback={<FullPageError />}>
      <Header />
      <ErrorBoundary fallback={<PageError />}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </ErrorBoundary>
      <Footer />
    </ErrorBoundary>
  );
}

페이지에서 에러가 나도 Header와 Footer는 살아 있습니다.

3. 위젯 단위 바운더리

JSX
function Dashboard() {
  return (
    <div className="dashboard">
      <ErrorBoundary fallback={<WidgetError name="차트" />}>
        <ChartWidget />
      </ErrorBoundary>
      <ErrorBoundary fallback={<WidgetError name="테이블" />}>
        <TableWidget />
      </ErrorBoundary>
      <ErrorBoundary fallback={<WidgetError name="통계" />}>
        <StatsWidget />
      </ErrorBoundary>
    </div>
  );
}

차트 위젯이 깨져도 테이블과 통계는 정상 동작합니다. 가장 세밀한 에러 격리입니다.

react-error-boundary 라이브러리

직접 클래스 컴포넌트를 작성하는 대신 react-error-boundary를 사용하면 훨씬 편합니다.

BASH
npm install react-error-boundary

기본 사용

JSX
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <h2>문제가 발생했습니다</h2>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>다시 시도</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, info) => {
        // 에러 리포팅
        reportToService(error, info);
      }}
    >
      <Dashboard />
    </ErrorBoundary>
  );
}

resetKeys로 자동 복구

JSX
function SearchResults({ query }) {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      resetKeys={[query]}
      // query가 변경되면 에러 상태 자동 초기화
    >
      <Results query={query} />
    </ErrorBoundary>
  );
}

사용자가 검색어를 바꾸면 에러 상태가 자동으로 리셋됩니다. 수동으로 "다시 시도" 버튼을 누르지 않아도 됩니다.

useErrorBoundary Hook

JSX
import { useErrorBoundary } from 'react-error-boundary';

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

  const handleFetch = async () => {
    try {
      const data = await fetchData();
      setData(data);
    } catch (error) {
      // 이벤트 핸들러/비동기 에러를 ErrorBoundary로 전파
      showBoundary(error);
    }
  };

  return <button onClick={handleFetch}>데이터 불러오기</button>;
}

showBoundary를 사용하면 이벤트 핸들러나 비동기 코드의 에러도 ErrorBoundary로 전달할 수 있습니다.

이벤트 핸들러 에러 처리

ErrorBoundary가 잡지 못하는 이벤트 핸들러 에러는 직접 처리해야 합니다.

try-catch + 상태 관리

JSX
function OrderForm() {
  const [error, setError] = useState(null);

  const handleSubmit = async (formData) => {
    try {
      setError(null);
      await submitOrder(formData);
    } catch (err) {
      if (err instanceof ValidationError) {
        setError('입력 값을 확인해주세요.');
      } else if (err instanceof NetworkError) {
        setError('네트워크 연결을 확인해주세요.');
      } else {
        setError('알 수 없는 오류가 발생했습니다.');
        // 예상치 못한 에러는 리포팅
        reportError(err);
      }
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <Alert type="error">{error}</Alert>}
      {/* 폼 필드들 */}
    </form>
  );
}

에러 처리 Hook 패턴

JSX
function useAsyncAction(asyncFn) {
  const [state, setState] = useState({
    loading: false,
    error: null,
    data: null,
  });

  const execute = async (...args) => {
    setState({ loading: true, error: null, data: null });
    try {
      const data = await asyncFn(...args);
      setState({ loading: false, error: null, data });
      return data;
    } catch (error) {
      setState({ loading: false, error, data: null });
      throw error; // 필요하면 재throw
    }
  };

  return { ...state, execute };
}

// 사용
function DeleteButton({ itemId }) {
  const { loading, error, execute } = useAsyncAction(deleteItem);

  return (
    <>
      <button onClick={() => execute(itemId)} disabled={loading}>
        {loading ? '삭제 중...' : '삭제'}
      </button>
      {error && <span className="error">삭제에 실패했습니다</span>}
    </>
  );
}

Suspense fallback vs Error fallback

Suspense와 ErrorBoundary는 서로 다른 상태를 처리합니다.

JSX
function UserPage({ userId }) {
  return (
    <ErrorBoundary FallbackComponent={ErrorUI}>
      {/* 에러 발생 시 ErrorUI 표시 */}
      <Suspense fallback={<Skeleton />}>
        {/* 로딩 중 Skeleton 표시 */}
        <UserProfile userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}
  • Suspense fallback: 데이터가 준비될 때까지 보여주는 로딩 UI
  • ErrorBoundary fallback: 렌더링 에러 시 보여주는 에러 UI

조합 패턴

JSX
function AsyncBoundary({ children, errorFallback, loadingFallback }) {
  return (
    <ErrorBoundary FallbackComponent={errorFallback}>
      <Suspense fallback={loadingFallback}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
}

// 사용
<AsyncBoundary
  errorFallback={ErrorCard}
  loadingFallback={<Skeleton />}
>
  <DataComponent />
</AsyncBoundary>

ErrorBoundary와 Suspense를 조합한 AsyncBoundary 컴포넌트를 만들면 재사용이 편합니다.

전역 에러 처리

ErrorBoundary 밖에서 발생하는 에러를 위한 전역 처리도 필요합니다.

JSX
// 전역 에러 핸들러
useEffect(() => {
  // 처리되지 않은 Promise rejection
  const handleUnhandledRejection = (event) => {
    console.error('Unhandled rejection:', event.reason);
    reportError(event.reason);
  };

  // 처리되지 않은 에러
  const handleError = (event) => {
    console.error('Unhandled error:', event.error);
    reportError(event.error);
  };

  window.addEventListener('unhandledrejection', handleUnhandledRejection);
  window.addEventListener('error', handleError);

  return () => {
    window.removeEventListener('unhandledrejection', handleUnhandledRejection);
    window.removeEventListener('error', handleError);
  };
}, []);

에러 리포팅

프로덕션에서는 에러를 수집하고 모니터링하는 것이 중요합니다.

JSX
function reportError(error, componentStack) {
  // Sentry, DataDog, LogRocket 등
  if (process.env.NODE_ENV === 'production') {
    Sentry.captureException(error, {
      extra: { componentStack },
    });
  } else {
    console.error(error);
  }
}

// ErrorBoundary에 통합
<ErrorBoundary
  FallbackComponent={ErrorFallback}
  onError={(error, info) => {
    reportError(error, info.componentStack);
  }}
>
  <App />
</ErrorBoundary>

주의할 점

ErrorBoundary는 이벤트 핸들러와 비동기 코드의 에러를 잡지 못함

ErrorBoundary는 ** 렌더링 중** 발생한 에러만 잡습니다. onClick 핸들러나 setTimeout, fetch에서 발생한 에러는 try-catch로 직접 처리하거나, react-error-boundaryshowBoundary로 전달해야 합니다.

ErrorBoundary를 루트에만 배치하면 앱 전체가 죽음

하나의 ErrorBoundary만 루트에 배치하면, 작은 컴포넌트의 에러가 앱 전체를 fallback UI로 교체합니다. 페이지별, 기능별로 ** 계층적으로 배치 **하여 에러를 격리해야 합니다.

정리

에러 유형처리 방법
렌더링 에러ErrorBoundary — 계층적 배치로 에러 격리
이벤트 핸들러 에러try-catch + 상태 관리
비동기 에러showBoundary로 ErrorBoundary에 전달 또는 직접 처리
Suspense + ErrorBoundary로딩과 에러를 선언적으로 분리 처리
프로덕션Sentry 등 에러 리포팅 서비스 연동 필수

에러 처리의 핵심은 "앱 전체가 죽지 않게 하는 것"입니다. 에러를 잡을 수 없는 것이 아니라, 잡을 준비가 되어 있지 않은 것이 문제입니다.

댓글 로딩 중...