금요일 오후 4시에 슬랙 알림이 울렸다.
CS팀에서 올린 메시지였다. "고객이 앱에 접속하면 하얀 화면만 보인다고 합니다." 캡처 이미지를 열어봤다. 진짜 하얀 화면이었다. 헤더도 없고, 사이드바도 없고, 아무것도 없었다. 브라우저 콘솔을 열어보니 에러 하나가 찍혀 있었다. 대시보드 우측 하단에 있는 작은 알림 배지 컴포넌트에서 undefined의 .length를 읽으려다 터진 거였다.
알림 배지. 유저가 안 봐도 되는, 그 작은 빨간 동그라미 하나가 전체 앱을 날렸다.
왜 React는 이렇게 잔인한가
React 16부터 렌더링 중 발생한 에러를 잡아줄 ErrorBoundary가 없으면, 전체 컴포넌트 트리가 언마운트된다. 이건 React 팀의 의도적인 설계다. Dan Abramov가 직접 설명한 바에 따르면, 깨진 UI를 그대로 보여주는 것보다 아무것도 안 보여주는 게 낫다는 판단이었다. 예를 들어 메시지 앱에서 잘못된 사람에게 메시지가 보이는 것보다는 아예 안 보이는 게 낫다고.
맞는 말이다. 하지만 현실에서는 알림 배지 하나 때문에 전체 대시보드가 사라지면 안 된다.
ErrorBoundary 기본: 왜 아직도 class 컴포넌트인가
ErrorBoundary는 React에서 몇 안 되는 class 컴포넌트가 필수인 영역이다. componentDidCatch와 getDerivedStateFromError 라이프사이클이 Hook으로 제공되지 않기 때문이다. React 팀에서 Hook 버전을 만들겠다고 한 적은 있지만, 아직까지 나오지 않았다.
가장 기본적인 형태는 이렇다.
import React, { Component, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("ErrorBoundary caught:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <div>문제가 발생했습니다.</div>;
}
return this.props.children;
}
}
export default ErrorBoundary;이걸 앱 최상단에 감싸면 최소한 하얀 화면은 막을 수 있다.
<ErrorBoundary fallback={<div>앱에 문제가 발생했습니다. 새로고침해주세요.</div>}>
<App />
</ErrorBoundary>그런데 이게 끝이면 이 글을 쓸 이유가 없다.
경계를 어디에 둘 것인가
앱 최상단에만 ErrorBoundary를 두면, 어디서 에러가 터지든 fallback은 동일하다. 전체 앱이 "문제가 발생했습니다"로 바뀌는 거다. 알림 배지가 터졌는데 대시보드 전체가 사라지는 건 이 구조 때문이다.
Kent C. Dodds가 강조하는 패턴이 granular error boundaries다. ErrorBoundary를 계층적으로 배치하는 것이다.
우리 팀이 실제로 적용한 구조는 이렇다.
App ErrorBoundary (최후의 방어선)
├── Layout
│ ├── Header ErrorBoundary
│ ├── Sidebar ErrorBoundary
│ └── Main Content
│ ├── Page ErrorBoundary (라우터 레벨)
│ │ ├── 대시보드 위젯 A ErrorBoundary
│ │ ├── 대시보드 위젯 B ErrorBoundary
│ │ └── 알림 배지 ErrorBoundary ← 여기서 터져도
│ └── ...
알림 배지가 터지면? 알림 배지 자리에만 fallback이 보인다. 대시보드의 나머지는 멀쩡하다. 이게 전부다.
실제 코드로 보면 이런 모양이 된다.
function Dashboard() {
return (
<div className="grid grid-cols-3 gap-4">
<ErrorBoundary fallback={<WidgetError name="매출 현황" />}>
<RevenueWidget />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError name="주문 목록" />}>
<OrderListWidget />
</ErrorBoundary>
<ErrorBoundary fallback={<BadgeError />}>
<NotificationBadge />
</ErrorBoundary>
</div>
);
}모든 컴포넌트를 다 감쌀 필요는 없다. 기준은 단순하다. 이 컴포넌트가 터졌을 때, 주변 기능까지 같이 사라지면 안 되는가? 그렇다면 경계를 친다.
Fallback UI는 생각보다 중요하다
처음에는 fallback을 대충 만들었다. "오류가 발생했습니다."라는 텍스트 한 줄. 그런데 유저 입장에서 이건 아무 정보도 없는 메시지다. 뭘 해야 하는지 모른다.
좋은 fallback UI에는 세 가지가 있어야 한다. 뭐가 안 되는지, 유저가 뭘 할 수 있는지, 그리고 다시 시도하는 방법.
interface ErrorFallbackProps {
error: Error;
resetErrorBoundary: () => void;
}
function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
return (
<div role="alert" className="p-4 border border-red-200 rounded-lg bg-red-50">
<h3 className="font-semibold text-red-800">
이 영역을 불러오지 못했습니다
</h3>
<p className="mt-1 text-sm text-red-600">
{error.message}
</p>
<button
onClick={resetErrorBoundary}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
다시 시도
</button>
</div>
);
}물론 에러 메시지를 유저에게 그대로 보여줄지는 판단이 필요하다. "Cannot read properties of undefined"를 유저가 봐서 좋을 건 없다. 프로덕션에서는 유저 친화적 메시지로 바꾸고, 개발 환경에서만 원본 에러를 노출하는 식으로 분리한다.
에러 리포팅: Sentry 연동
ErrorBoundary가 에러를 잡는 건 좋은데, 개발자가 그 에러를 모르면 소용이 없다. componentDidCatch에서 Sentry로 에러를 보내는 게 기본이다.
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
Sentry.withScope((scope) => {
scope.setExtra("componentStack", errorInfo.componentStack);
scope.setTag("errorBoundary", this.props.boundaryName ?? "unknown");
Sentry.captureException(error);
});
}여기서 boundaryName을 넘기는 게 핵심이다. Sentry 대시보드에서 "ErrorBoundary에서 에러가 잡혔다"만 보이면 어디서 터진 건지 알 수 없다. boundaryName에 "dashboard-notification" 같은 이름을 넣어두면 알림 영역에서 터진 건지 매출 위젯에서 터진 건지 바로 파악된다.
우리 팀은 이걸 적용하고 나서 에러 대응 시간이 눈에 띄게 줄었다. 예전에는 "앱이 하얀 화면입니다"라는 CS 티켓이 오면 어디서 터졌는지 찾는 데부터 시간이 걸렸는데, 이제는 Sentry 알림에 boundary 이름이 찍혀서 바로 해당 컴포넌트로 간다.
에러에서 복구하기: resetKeys 패턴
"다시 시도" 버튼이 있어도, ErrorBoundary의 state를 리셋하는 것만으로는 부족한 경우가 많다. 같은 props로 같은 컴포넌트를 다시 렌더링하면 같은 에러가 또 난다.
Kent C. Dodds의 react-error-boundary 라이브러리가 제공하는 resetKeys 패턴이 이 문제를 해결한다. 특정 값이 바뀌면 에러 상태를 자동으로 리셋하는 것이다.
import { ErrorBoundary } from "react-error-boundary";
function UserProfile({ userId }: { userId: string }) {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={()=> {
// 에러 상태 리셋 시 추가 정리 작업
}}
resetKeys={[userId]}
>
<UserDetail userId={userId} />
</ErrorBoundary>
);
}userId가 바뀌면 ErrorBoundary가 자동으로 리셋된다. 유저 A의 프로필에서 에러가 났어도 유저 B로 이동하면 깨끗한 상태로 다시 시도한다. 이게 없으면 한 번 에러가 난 영역은 페이지를 새로고침하기 전까지 계속 fallback 상태로 남는다.
직접 구현한다면 이런 식이다.
class ErrorBoundaryWithReset extends Component<
Props & { resetKeys?: unknown[] },
State
> {
constructor(props: Props & { resetKeys?: unknown[] }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidUpdate(prevProps: Props & { resetKeys?: unknown[] }) {
if (this.state.hasError && this.props.resetKeys) {
const hasChanged = this.props.resetKeys.some(
(key, i) => key !== prevProps.resetKeys?.[i]
);
if (hasChanged) {
this.setState({ hasError: false });
}
}
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <div>문제가 발생했습니다.</div>;
}
return this.props.children;
}
}ErrorBoundary가 못 잡는 것들
여기서부터가 진짜 중요한 부분이다.
ErrorBoundary는 렌더링 중에 발생한 동기 에러만 잡는다. 다음은 못 잡는다.
- 이벤트 핸들러 안에서 발생한 에러
- 비동기 코드 (
setTimeout,Promise,async/await) - 서버 사이드 렌더링
- ErrorBoundary 자체에서 발생한 에러
이벤트 핸들러 에러가 안 잡히는 건 처음 알았을 때 꽤 당황스러웠다. 버튼 클릭 핸들러에서 에러가 나면 ErrorBoundary를 통과해서 그냥 콘솔에 찍힌다.
function BrokenButton() {
const handleClick = () => {
// 이 에러는 ErrorBoundary가 못 잡는다
throw new Error("클릭 핸들러에서 터짐");
};
return <button onClick={handleClick}>클릭</button>;
}이벤트 핸들러 에러를 잡으려면 try-catch를 직접 써야 한다.
function SafeButton() {
const handleClick = () => {
try {
riskyOperation();
} catch (error) {
// 에러를 state로 던져서 ErrorBoundary가 잡게 만든다
throw error; // 이래도 안 잡힘
}
};
return <button onClick={handleClick}>클릭</button>;
}위 코드에서 throw error를 해도 ErrorBoundary는 못 잡는다. 이벤트 핸들러는 React의 렌더링 사이클 바깥이기 때문이다.
실전에서 쓰는 방법은 에러를 state에 저장하고 렌더링 시점에 다시 던지는 것이다.
import { useState, useCallback } from "react";
function useErrorHandler() {
const [error, setError] = useState<Error | null>(null);
if (error) {
throw error; // 렌더링 중에 던지므로 ErrorBoundary가 잡는다
}
const handleError = useCallback((error: Error) => {
setError(error);
}, []);
return handleError;
}
// 사용
function SafeButton() {
const handleError = useErrorHandler();
const handleClick = async () => {
try {
await riskyAsyncOperation();
} catch (error) {
handleError(error as Error);
}
};
return <button onClick={handleClick}>클릭</button>;
}react-error-boundary 라이브러리에 useErrorBoundary라는 Hook이 이 패턴을 이미 구현해두고 있다. 바퀴를 다시 발명할 필요가 없다.
import { useErrorBoundary } from "react-error-boundary";
function DataLoader() {
const { showBoundary } = useErrorBoundary();
useEffect(() => {
fetchData().catch((error) => {
showBoundary(error);
});
}, []);
return <div>...</div>;
}우리 팀이 실제로 쓰는 ErrorBoundary
여러 번의 시행착오를 거쳐서 정착한 버전이다. Sentry 연동, resetKeys, fallback 렌더링 함수를 모두 포함한다.
import React, { Component, ReactNode } from "react";
import * as Sentry from "@sentry/react";
interface ErrorBoundaryProps {
children: ReactNode;
boundaryName: string;
fallbackRender?: (props: {
error: Error;
resetErrorBoundary: () => void;
}) => ReactNode;
resetKeys?: unknown[];
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface ErrorBoundaryState {
error: Error | null;
}
class AppErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
Sentry.withScope((scope) => {
scope.setExtra("componentStack", errorInfo.componentStack);
scope.setTag("errorBoundary", this.props.boundaryName);
Sentry.captureException(error);
});
this.props.onError?.(error, errorInfo);
}
componentDidUpdate(prevProps: ErrorBoundaryProps) {
if (this.state.error && this.props.resetKeys) {
const hasChanged = this.props.resetKeys.some(
(key, i) => key !== prevProps.resetKeys?.[i]
);
if (hasChanged) {
this.setState({ error: null });
}
}
}
resetErrorBoundary = () => {
this.setState({ error: null });
};
render() {
const { error } = this.state;
if (error) {
if (this.props.fallbackRender) {
return this.props.fallbackRender({
error,
resetErrorBoundary: this.resetErrorBoundary,
});
}
return (
<div className="p-4 text-center text-gray-500">
이 영역을 불러올 수 없습니다.
<button
onClick={this.resetErrorBoundary}
className="ml-2 text-blue-500 underline"
>
다시 시도
</button>
</div>
);
}
return this.props.children;
}
}
export default AppErrorBoundary;사용하는 쪽 코드는 이렇다.
<AppErrorBoundary
boundaryName="dashboard-revenue"
resetKeys={[selectedDate]}
fallbackRender={({ error, resetErrorBoundary })=> (
<div className="p-6 bg-gray-50 rounded-lg">
<p className="text-gray-600">매출 데이터를 불러오지 못했습니다.</p>
<button onClick={resetErrorBoundary} className="mt-2 text-blue-600">
다시 불러오기
</button>
</div>
)}
>
<RevenueChart date={selectedDate} />
</AppErrorBoundary>이 구조가 좋은 이유는 boundaryName이 강제라는 점이다. 선택이 아니라 필수 prop으로 만들어놔서, ErrorBoundary를 쓸 때마다 "이 경계의 이름이 뭐지?"를 생각하게 된다. Sentry에서 어디서 터졌는지 바로 보이는 건 이 작은 강제 덕분이다.
비동기 데이터 페칭과 ErrorBoundary
React Query나 SWR을 쓴다면 비동기 에러 처리가 한결 깔끔해진다. React Query의 useQuery는 throwOnError 옵션을 제공한다.
const { data } = useQuery({
queryKey: ["revenue", selectedDate],
queryFn: () => fetchRevenue(selectedDate),
throwOnError: true, // 에러 발생 시 가장 가까운 ErrorBoundary로 전파
});이렇게 하면 API 호출이 실패했을 때 ErrorBoundary가 잡아준다. 컴포넌트 안에서 에러 상태를 직접 관리할 필요가 없어진다. isError 체크하고 에러 UI 보여주고 하는 보일러플레이트가 사라진다.
다만 모든 쿼리에 throwOnError: true를 거는 건 권하지 않는다. 검색 자동완성처럼 실패해도 조용히 넘어가야 하는 기능이 있다. 이런 건 컴포넌트 레벨에서 isError를 체크하는 게 맞다.
프로덕션에 배포하기 전 체크리스트
팀에 ErrorBoundary 규칙을 도입한 뒤로, PR 리뷰에서 확인하는 항목이 생겼다.
새로운 페이지를 만들면 페이지 레벨 ErrorBoundary가 있는가? 독립적으로 동작하는 위젯이나 섹션이면 개별 ErrorBoundary로 감쌌는가? 외부 API를 호출하는 컴포넌트면 에러 발생 시 fallback이 의미 있는 정보를 보여주는가?
이걸 자동화하고 싶어서 ESLint 커스텀 룰을 만들까 고민한 적도 있는데, 결국 안 만들었다. 어디에 경계를 칠지는 맥락에 따라 달라지는 판단이라 룰로 강제하기 어려웠다. 대신 PR 템플릿에 체크리스트 항목 하나를 추가했다. 그것만으로 충분했다.
금요일 오후에 하얀 화면 장애를 겪은 이후로, 우리 팀에서 같은 종류의 장애가 다시 발생한 적은 없다. ErrorBoundary는 화려한 기술이 아니다. 하지만 프로덕션에서 유저가 하얀 화면을 보는 것과 "이 영역을 불러올 수 없습니다. 다시 시도"를 보는 것 사이의 차이는 상당히 크다. 그 차이를 만드는 데 필요한 코드는 100줄도 안 된다.
