에러 처리 전략 — ErrorBoundary부터 try-catch까지
컴포넌트가 렌더링 도중 에러를 던지면, 앱 전체가 하얀 화면이 되어도 괜찮을까요?
React 앱에서 에러는 피할 수 없습니다. API 응답이 예상과 다르거나, null 체크를 빠뜨리거나, 외부 라이브러리가 예외를 던질 수 있습니다. 중요한 것은 에러가 발생했을 때 앱 전체가 죽지 않고 우아하게 대처하는 것입니다.
ErrorBoundary 기본 개념
ErrorBoundary는 자식 컴포넌트 트리에서 발생한 JavaScript 에러를 잡아 대체 UI를 보여주는 React 컴포넌트입니다.
클래스 컴포넌트로만 만들 수 있다
현재까지 ErrorBoundary는 클래스 컴포넌트로만 구현할 수 있습니다. getDerivedStateFromError와 componentDidCatch에 대응하는 Hook이 아직 없기 때문입니다.
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;
}
}
사용 방법
<ErrorBoundary fallback={<ErrorPage />}>
<App />
</ErrorBoundary>
ErrorBoundary가 잡는 것과 못 잡는 것
잡을 수 있는 에러
- 자식 컴포넌트의 렌더링 중 발생한 에러
- 생명주기 메서드(useEffect 포함)에서 발생한 에러
- constructor에서 발생한 에러
잡을 수 없는 에러
- **이벤트 핸들러 **:
onClick,onChange등에서 발생한 에러 - ** 비동기 코드 **:
setTimeout,Promise내부의 에러 - ** 서버 사이드 렌더링 **: SSR 중 발생한 에러
- ErrorBoundary 자체 의 에러
function ProblematicComponent() {
const handleClick = () => {
// 이 에러는 ErrorBoundary가 잡지 못한다
throw new Error('이벤트 핸들러 에러');
};
// 이 에러는 ErrorBoundary가 잡는다
if (someCondition) {
throw new Error('렌더링 에러');
}
return <button onClick={handleClick}>클릭</button>;
}
ErrorBoundary 배치 전략
1. 앱 전체를 감싸는 최상위 바운더리
function App() {
return (
<ErrorBoundary fallback={<FullPageError />}>
<Router>
<Routes />
</Router>
</ErrorBoundary>
);
}
최후의 안전망입니다. 하얀 화면 대신 에러 페이지를 보여줍니다.
2. 페이지 단위 바운더리
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. 위젯 단위 바운더리
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를 사용하면 훨씬 편합니다.
npm install react-error-boundary
기본 사용
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로 자동 복구
function SearchResults({ query }) {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
resetKeys={[query]}
// query가 변경되면 에러 상태 자동 초기화
>
<Results query={query} />
</ErrorBoundary>
);
}
사용자가 검색어를 바꾸면 에러 상태가 자동으로 리셋됩니다. 수동으로 "다시 시도" 버튼을 누르지 않아도 됩니다.
useErrorBoundary Hook
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 + 상태 관리
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 패턴
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는 서로 다른 상태를 처리합니다.
function UserPage({ userId }) {
return (
<ErrorBoundary FallbackComponent={ErrorUI}>
{/* 에러 발생 시 ErrorUI 표시 */}
<Suspense fallback={<Skeleton />}>
{/* 로딩 중 Skeleton 표시 */}
<UserProfile userId={userId} />
</Suspense>
</ErrorBoundary>
);
}
- Suspense fallback: 데이터가 준비될 때까지 보여주는 로딩 UI
- ErrorBoundary fallback: 렌더링 에러 시 보여주는 에러 UI
조합 패턴
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 밖에서 발생하는 에러를 위한 전역 처리도 필요합니다.
// 전역 에러 핸들러
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);
};
}, []);
에러 리포팅
프로덕션에서는 에러를 수집하고 모니터링하는 것이 중요합니다.
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-boundary의 showBoundary로 전달해야 합니다.
ErrorBoundary를 루트에만 배치하면 앱 전체가 죽음
하나의 ErrorBoundary만 루트에 배치하면, 작은 컴포넌트의 에러가 앱 전체를 fallback UI로 교체합니다. 페이지별, 기능별로 ** 계층적으로 배치 **하여 에러를 격리해야 합니다.
정리
| 에러 유형 | 처리 방법 |
|---|---|
| 렌더링 에러 | ErrorBoundary — 계층적 배치로 에러 격리 |
| 이벤트 핸들러 에러 | try-catch + 상태 관리 |
| 비동기 에러 | showBoundary로 ErrorBoundary에 전달 또는 직접 처리 |
| Suspense + ErrorBoundary | 로딩과 에러를 선언적으로 분리 처리 |
| 프로덕션 | Sentry 등 에러 리포팅 서비스 연동 필수 |
에러 처리의 핵심은 "앱 전체가 죽지 않게 하는 것"입니다. 에러를 잡을 수 없는 것이 아니라, 잡을 준비가 되어 있지 않은 것이 문제입니다.