Claude Code로 React Error Boundary를 안전하게 구현하는 실무 가이드
Claude Code로 React Error Boundary를 구현합니다. 포착 범위, 배치, reset, 안전한 로그, 테스트와 프롬프트까지 다룹니다.
React 앱에서 가장 위험한 장애는 작은 위젯 하나가 실패하는 것이 아니라, 그 실패가 전체 화면을 흰 화면으로 만드는 상황입니다. Claude Code에게 단순히 “에러 처리 추가해 줘”라고 요청하면 몇 군데에 try/catch를 넣고 끝날 수 있습니다. 하지만 React 렌더링 중에 발생한 예외는 그런 방식으로 제대로 격리되지 않습니다.
이 글은 Claude Code로 React Error Boundary를 안전하게 구현하는 방법을 정리한 실무 가이드입니다. Error Boundary는 자식 컴포넌트 트리에서 렌더링 중 예상치 못한 예외가 발생했을 때, 전체 앱이 무너지지 않도록 fallback UI를 보여 주는 경계입니다. 원인을 고치는 장치는 아니지만, 영향 범위를 줄이고 사용자에게 다음 행동을 안내하며, 팀이 조사할 수 있는 로그를 남기는 장치입니다.
Masa가 관리자 대시보드에서 처음 시도했을 때는 앱 전체를 하나의 boundary로 감쌌습니다. 흰 화면은 줄었지만, 매출 차트 하나가 깨지면 설정과 결제 메뉴까지 fallback으로 가려졌고, 로그에는 이메일이 포함된 query string도 남았습니다. 이후 프롬프트에 boundary 배치, reset 규칙, PII 마스킹, 테스트 조건을 명시하자 Claude Code가 만든 diff가 훨씬 검토하기 쉬워졌습니다.
React 공식 문서로 기준을 먼저 고정하기
Claude Code에 구현을 맡기기 전에 사실관계를 고정해야 합니다. React 공식 Component reference는 static getDerivedStateFromError로 fallback 상태를 만들고, componentDidCatch에서 로깅 같은 부수 효과를 처리한다고 설명합니다. React 공식 error-boundaries lint 문서도 JSX 주변의 일반 try/catch가 렌더링 예외를 다루는 올바른 방식이 아니라고 정리합니다.
핵심은 Error Boundary가 모든 에러를 잡지 않는다는 점입니다. 자식 컴포넌트가 렌더링, lifecycle, 렌더 과정의 hook 또는 memo에서 던진 예외는 boundary가 받을 수 있습니다. 하지만 클릭 핸들러, 일반 Promise rejection, setTimeout, server-side rendering, 그리고 boundary 자신의 fallback에서 발생한 예외는 별도 처리해야 합니다.
| 위치 | Error Boundary가 포착 | 운영 처리 |
|---|---|---|
| 자식 컴포넌트 렌더 중 예외 | 예 | fallback UI를 보여 주고 마스킹된 로그를 보낸다 |
| 렌더 과정의 hook 또는 memo 예외 | 보통 예 | 예상 가능한 실패는 먼저 검증하고, 예상 밖 예외만 boundary로 보낸다 |
| 버튼 클릭, 폼 제출 핸들러 | 아니오 | handler 안에서 try/catch하고 필요한 경우 상태를 통해 다시 던진다 |
setTimeout, animation callback, 일반 Promise | 아니오 | Promise 실패를 명시적으로 처리하고 retry 경로를 둔다 |
| 서버 렌더링 | 아니오 | 프레임워크의 error page, 서버 로그, HTTP 상태로 처리한다 |
| Error Boundary 자체의 예외 | 아니오 | fallback UI를 단순하게 만들고 더 상위 boundary를 둔다 |
flowchart TD
A["자식 컴포넌트가 렌더 중 예외를 던짐"] --> B["가장 가까운 Error Boundary"]
B --> C["사용자용 fallback UI"]
B --> D["PII를 제거한 에러 로그"]
E["클릭 핸들러나 setTimeout 예외"] --> F["로컬 처리 또는 상태를 통해 boundary로 전달"]
F --> B
라우트 단위와 컴포넌트 단위를 나누기
Error Boundary는 많이 둘수록 좋은 기능이 아닙니다. 앱 전체에 하나만 두면 범위가 너무 넓고, 모든 버튼마다 두면 화면이 작은 fallback 조각으로 흩어집니다. Claude Code에는 어떤 경계가 route-level인지, 어떤 경계가 component-level인지 명확히 지시해야 합니다.
Route-level boundary는 dashboard, settings, billing, editor, admin audit log처럼 화면의 책임이 바뀌는 단위를 보호합니다. 라우트가 바뀌면 boundary도 reset되어야 합니다. 이전 화면의 실패 상태가 다음 화면으로 따라오면 사용자도 개발자도 혼란스럽습니다.
Component-level boundary는 페이지 내부의 독립 영역에 둡니다. 매출 차트, 알림 패널, Markdown preview, 추천 모듈, 외부 embed, 큰 JSON viewer가 좋은 후보입니다. 반대로 일반 input, submit button, heading, icon은 boundary로 감싸지 말고 폼 상태나 validation으로 처리해야 합니다.
판단 기준은 세 가지입니다. 이 영역이 실패해도 사용자가 다른 작업을 계속할 수 있는가. 이 영역만 retry 또는 reload할 수 있는가. 로그에 feature 이름을 붙였을 때 원인 분석이 쉬워지는가. 이 기준은 Claude Code 테스트 전략과도 맞닿아 있습니다. 사용자가 retry하는 단위와 테스트하는 단위가 같을수록 운영이 단순해집니다.
복사해서 쓰는 Error Boundary 컴포넌트
공용 Error Boundary 자체는 class component로 구현합니다. 앱 대부분은 함수 컴포넌트로 유지해도 됩니다. 이 경계 컴포넌트만 React가 제공하는 error boundary lifecycle을 사용합니다.
// src/components/error-boundary/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from "react";
export type ErrorBoundaryFallbackProps = {
error: Error;
resetErrorBoundary: () => void;
};
type ErrorBoundaryProps = {
children: ReactNode;
fallback?: ReactNode | ((props: ErrorBoundaryFallbackProps) => ReactNode);
onError?: (error: Error, info: ErrorInfo) => void;
onReset?: () => void;
resetKeys?: ReadonlyArray<unknown>;
};
type ErrorBoundaryState = {
error: Error | null;
};
function normalizeError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(typeof value === "string" ? value : "Unknown render error");
}
function changedArray(
previous: ReadonlyArray<unknown> = [],
next: ReadonlyArray<unknown> = [],
): boolean {
return (
previous.length !== next.length ||
previous.some((item, index) => !Object.is(item, next[index]))
);
}
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return { error: normalizeError(error) };
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError?.(normalizeError(error), info);
}
componentDidUpdate(previousProps: ErrorBoundaryProps) {
if (
this.state.error &&
changedArray(previousProps.resetKeys, this.props.resetKeys)
) {
this.resetErrorBoundary();
}
}
resetErrorBoundary = () => {
this.props.onReset?.();
this.setState({ error: null });
};
render() {
if (!this.state.error) return this.props.children;
if (typeof this.props.fallback === "function") {
return this.props.fallback({
error: this.state.error,
resetErrorBoundary: this.resetErrorBoundary,
});
}
if (this.props.fallback) return this.props.fallback;
return (
<section role="alert" aria-labelledby="error-boundary-title">
<h2 id="error-boundary-title">Something went wrong</h2>
<p>Please retry. If the problem continues, contact support.</p>
<button type="button" onClick={this.resetErrorBoundary}>
Try again
</button>
</section>
);
}
}
fallback은 정적 ReactNode로도, 함수로도 전달할 수 있습니다. 함수형 fallback은 error와 resetErrorBoundary를 받을 수 있어 retry UI를 만들기 쉽습니다. 다만 사용자 화면에 error.stack, 원본 API 응답, 내부 디버그 메시지를 그대로 보여 주면 안 됩니다. 화면에는 짧은 설명, 안전한 버튼, 필요하면 support reference 정도만 둡니다.
사용자용 fallback UI와 reset
fallback UI는 디버그 로그가 아니라 제품 UI입니다. 무엇이 멈췄는지, 사용자의 데이터가 바뀌었는지, 어떤 행동이 안전한지를 알려야 합니다. 배포 직후 chunk loading 오류가 발생했다면 전체 reload 버튼이 유용할 수 있고, 일반 위젯 오류라면 해당 영역만 retry하는 편이 낫습니다.
// src/components/error-boundary/AppErrorFallback.tsx
import type { ErrorBoundaryFallbackProps } from "./ErrorBoundary";
export function AppErrorFallback({
error,
resetErrorBoundary,
}: ErrorBoundaryFallbackProps) {
const reloadRecommended =
/ChunkLoadError|Loading chunk|dynamically imported module/i.test(
error.message,
);
return (
<section
role="alert"
aria-labelledby="app-error-title"
className="error-fallback"
>
<div>
<p className="error-fallback__eyebrow">This section stopped working</p>
<h2 id="app-error-title">We could not render this part of the page.</h2>
<p>
Your account data was not changed. Retry this section first, then
reload the app if the same message appears again.
</p>
</div>
<div className="error-fallback__actions">
<button type="button" onClick={resetErrorBoundary}>
Try again
</button>
{reloadRecommended ? (
<button type="button" onClick={() => window.location.reload()}>
Reload app
</button>
) : null}
</div>
</section>
);
}
/* src/components/error-boundary/error-fallback.css */
.error-fallback {
border: 1px solid #d7dde8;
border-radius: 8px;
padding: 16px;
background: #fff;
color: #1f2937;
}
.error-fallback__eyebrow {
margin: 0 0 4px;
color: #6b7280;
font-size: 0.875rem;
}
.error-fallback h2 {
margin: 0 0 8px;
font-size: 1.125rem;
}
.error-fallback__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
retry에서 흔한 실수는 boundary state만 지우고, 예외를 만든 입력은 그대로 두는 것입니다. 같은 props, 같은 cache, 같은 route state가 남아 있으면 버튼을 눌러도 바로 다시 fallback으로 돌아갑니다. route key, 검색 조건, user id, refresh counter, data version처럼 실패 상태를 지워도 되는 기준을 resetKeys에 넣습니다.
라우트와 컴포넌트 구현 예시
React Router에서는 route boundary를 얇은 wrapper로 둡니다. 아래 예시는 navigation마다 reset하고, 로그에 feature 이름을 보냅니다. Next.js나 Remix에는 route error 파일이 있지만 원칙은 같습니다. 라우트 변경 시 reset하고, 페이지 실패가 다른 페이지로 번지지 않게 합니다.
// src/AppRoutes.tsx
import { lazy, ReactNode, Suspense } from "react";
import {
createBrowserRouter,
RouterProvider,
useLocation,
} from "react-router-dom";
import { ErrorBoundary } from "./components/error-boundary/ErrorBoundary";
import { AppErrorFallback } from "./components/error-boundary/AppErrorFallback";
import { currentErrorContext, reportReactError } from "./lib/error-reporting";
import { Layout } from "./routes/Layout";
const DashboardPage = lazy(() => import("./routes/DashboardPage"));
const SettingsPage = lazy(() => import("./routes/SettingsPage"));
function RouteBoundary({
children,
feature,
}: {
children: ReactNode;
feature: string;
}) {
const location = useLocation();
return (
<ErrorBoundary
resetKeys={[location.key]}
fallback={(props) => <AppErrorFallback {...props} />}
onError={(error, info) => {
void reportReactError(
error,
info.componentStack,
currentErrorContext(feature),
);
}}
>
<Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
</ErrorBoundary>
);
}
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
path: "dashboard",
element: (
<RouteBoundary feature="dashboard">
<DashboardPage />
</RouteBoundary>
),
},
{
path: "settings",
element: (
<RouteBoundary feature="settings">
<SettingsPage />
</RouteBoundary>
),
},
],
},
]);
export function AppRoutes() {
return <RouterProvider router={router} />;
}
컴포넌트 단위 boundary는 독립적으로 복구 가능한 영역에 둡니다. chart widget, Markdown preview, recommendation panel, third-party embed, JSON viewer가 좋은 후보입니다. 결제 실패, validation error, session 만료는 제품 상태이므로 form이나 page flow 안에서 직접 다룹니다.
비동기와 이벤트 핸들러는 별도 처리
Error Boundary는 클릭 핸들러와 일반 async failure를 자동으로 잡지 않습니다. 예상 가능한 실패는 local UI로 처리합니다. field validation, 인증 실패, 결제 거절은 boundary가 아니라 해당 화면에서 설명해야 합니다. 예상 밖 예외만 state에 저장한 뒤 다음 render에서 던져 boundary로 넘깁니다.
// src/components/error-boundary/useAsyncBoundary.ts
import { useCallback, useState } from "react";
function toError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(typeof value === "string" ? value : "Unknown async error");
}
export function useAsyncBoundary() {
const [error, setError] = useState<Error | null>(null);
if (error) {
throw error;
}
return useCallback((value: unknown) => {
setError(toError(value));
}, []);
}
// src/components/settings/SaveButton.tsx
import { useState } from "react";
import { useAsyncBoundary } from "../error-boundary/useAsyncBoundary";
type SaveButtonProps = {
onSave: () => Promise<void>;
};
export function SaveButton({ onSave }: SaveButtonProps) {
const [pending, setPending] = useState(false);
const throwToBoundary = useAsyncBoundary();
async function handleClick() {
setPending(true);
try {
await onSave();
} catch (error) {
throwToBoundary(error);
} finally {
setPending(false);
}
}
return (
<button type="button" disabled={pending} onClick={handleClick}>
{pending ? "Saving..." : "Save"}
</button>
);
}
Claude Code 프롬프트에는 모든 async 실패를 boundary로 보내지 말라고 적어야 합니다. 400 응답, 잘못된 입력, rate limit, 로그인 만료는 local UI가 맞습니다. Boundary는 예상 밖 예외, 깨진 응답, 렌더링 가정 실패처럼 화면을 무너뜨릴 수 있는 문제를 격리합니다.
PII를 노출하지 않는 로그
PII는 개인을 식별할 수 있는 정보입니다. 이메일, 전화번호, 이름, 주소, token, card number, 자유 텍스트 문의 내용이 여기에 들어갑니다. componentDidCatch는 client error 보고에 적합하지만, payload는 제한하고 마스킹해야 합니다.
로그에는 feature, release, route pathname, error name, 마스킹된 message, stack, componentStack 정도만 보냅니다. query string, form value, Cookie, Authorization header, full URL, raw API response는 보내지 않습니다.
// src/lib/error-reporting.ts
type ClientErrorContext = {
route: string;
release: string;
feature?: string;
userHash?: string;
};
const REDACTIONS: Array<[RegExp, string]> = [
[/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted-email]"],
[/\b(?:\d[ -]*?){13,19}\b/g, "[redacted-number]"],
[/\b(token|secret|password|authorization)=([^&\s]+)/gi, "$1=[redacted]"],
[/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]"],
];
export function redactText(value: string | undefined): string | undefined {
if (!value) return value;
return REDACTIONS.reduce(
(text, [pattern, replacement]) => text.replace(pattern, replacement),
value,
);
}
export function currentErrorContext(feature?: string): ClientErrorContext {
const env = (import.meta as unknown as {
env?: Record<string, string | undefined>;
}).env;
return {
route: typeof window === "undefined" ? "server" : window.location.pathname,
release: env?.VITE_APP_VERSION ?? "dev",
feature,
};
}
export async function reportReactError(
error: Error,
componentStack: string | undefined,
context: ClientErrorContext,
) {
const payload = {
name: redactText(error.name) ?? "Error",
message: redactText(error.message) ?? "Unknown error",
stack: redactText(error.stack),
componentStack: redactText(componentStack),
route: context.route,
release: context.release,
feature: context.feature,
userHash: context.userHash,
};
const body = JSON.stringify(payload);
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
const sent = navigator.sendBeacon(
"/api/client-errors",
new Blob([body], { type: "application/json" }),
);
if (sent) return;
}
await fetch("/api/client-errors", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "omit",
keepalive: true,
body,
});
}
서버에서도 한 번 더 마스킹해야 합니다. 클라이언트 마스킹은 도움이 되지만 보안 경계로 믿으면 안 됩니다. Claude Code에는 client와 server 양쪽에서 redaction을 적용하고, 지원 추적이 필요하면 hash된 user identifier만 사용하라고 지시합니다.
테스트와 검증 명령
Error Boundary는 실패 상황에서만 의미가 있으므로 실패 경로를 테스트해야 합니다. 최소 테스트는 fallback 렌더링, onError 호출, retry reset입니다.
// src/components/error-boundary/ErrorBoundary.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import { ReactNode, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorBoundary } from "./ErrorBoundary";
function Bomb({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) {
throw new Error("profile widget crashed");
}
return <p>Profile loaded</p>;
}
function RetryHarness({ onError }: { onError: ReturnType<typeof vi.fn> }) {
const [broken, setBroken] = useState(true);
return (
<ErrorBoundary
onError={onError}
fallback={({ resetErrorBoundary }) => (
<button
type="button"
onClick={() => {
setBroken(false);
resetErrorBoundary();
}}
>
Retry profile
</button>
)}
>
<Bomb shouldThrow={broken} />
</ErrorBoundary>
);
}
function StaticFallback({ children }: { children: ReactNode }) {
return (
<ErrorBoundary fallback={<p>Could not load this panel.</p>}>
{children}
</ErrorBoundary>
);
}
describe("ErrorBoundary", () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it("renders fallback UI when a child throws", () => {
render(
<StaticFallback>
<Bomb shouldThrow />
</StaticFallback>,
);
expect(screen.getByText("Could not load this panel.")).toBeInTheDocument();
});
it("calls onError with the thrown error and component stack", () => {
const onError = vi.fn();
render(<RetryHarness onError={onError} />);
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0][0].message).toBe("profile widget crashed");
expect(onError.mock.calls[0][1].componentStack).toContain("Bomb");
});
it("can reset and render children again", async () => {
const user = userEvent.setup();
const onError = vi.fn();
render(<RetryHarness onError={onError} />);
await user.click(screen.getByRole("button", { name: "Retry profile" }));
expect(screen.getByText("Profile loaded")).toBeInTheDocument();
});
});
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
npm run typecheck
npx vitest run src/components/error-boundary/ErrorBoundary.test.tsx
npm run build
Claude Code에 줄 안전한 프롬프트
Add React Error Boundaries to this React + TypeScript app.
Constraints:
- Follow the official React Error Boundary model.
- Catch render errors from descendants, but handle event handlers and ordinary async failures separately.
- Implement a shared ErrorBoundary class, user-facing fallback UI, and reportReactError with PII redaction.
- Route-level boundaries must reset on navigation through resetKeys.
- Component-level boundaries should only wrap independent regions such as DashboardChart, MarkdownPreview, and RecommendationPanel.
- Do not log error.stack, query strings, form values, Authorization headers, cookies, or raw API responses without redaction.
- Add Vitest + Testing Library coverage for fallback UI, onError, and retry reset.
- Run npm run typecheck, npx vitest run, and npm run build, then report the results.
Read the existing routing, logging, and CSS conventions first. Keep the diff minimal.
리뷰용 프롬프트도 별도로 둡니다.
Review this diff only from the Error Boundary perspective.
List issues with boundary placement, async errors that are not caught, PII leakage, missing resetKeys, fallback accessibility, and missing tests.
Do not change code. Return file names and line numbers.
실무 유스케이스와 함정
첫 번째는 SaaS dashboard입니다. revenue chart, active users table, notification panel, third-party embed를 각각 감싸면 chart library bug가 settings나 billing까지 막지 않습니다. 로그 feature는 dashboard.revenue-chart처럼 구체적으로 둡니다.
두 번째는 content editor입니다. Markdown preview, image preview, AI summary panel은 실패 가능성이 높지만 body editor와 save button은 사용자가 계속 써야 합니다. preview만 boundary로 감싸고 save 실패는 event handler에서 field 또는 toast UI로 보여 줍니다.
세 번째는 ecommerce나 signup flow입니다. card decline, stock shortage, validation failure는 Error Boundary 문제가 아니라 제품 흐름의 일부입니다. recommendation module, campaign banner, review widget 같은 보조 영역을 boundary로 격리합니다.
네 번째는 admin audit log입니다. 큰 JSON viewer는 formatting 중 예외를 던질 수 있습니다. viewer만 감싸면 운영자는 filter를 바꾸거나 CSV를 export하거나 다른 사용자를 계속 확인할 수 있습니다.
흔한 함정은 JSX를 try/catch로 감싸고 안전하다고 믿는 것, 모든 async 실패를 boundary로 보내는 것, full URL과 query string을 로그로 보내는 것, UI에 stack trace를 보여 주는 것, reset했지만 깨진 입력은 그대로 두는 것, 너무 작은 컴포넌트마다 boundary를 두어 fallback 조각이 난무하게 만드는 것입니다. 팀 단위로 정착시키려면 구현 프롬프트와 리뷰 프롬프트를 Claude Code command로 만들어 두고, 더 넓은 도입은 Claude Code 교육 및 도입 지원으로 자연스럽게 연결할 수 있습니다.
정리
Error Boundary는 범용 exception handler가 아니라 React 렌더링 실패를 격리하는 장치입니다. Claude Code에 맡길 때는 포착 범위, route-level과 component-level 배치, reset 동작, PII 로그 정책, 테스트, 검증 명령을 한 번에 지정해야 합니다.
실제 dashboard에서 적용해 보니 resetKeys와 log redaction 규칙을 먼저 정한 뒤 코드를 생성했을 때 리뷰 시간이 줄었습니다. 하나의 widget이 깨져도 전체 앱이 멈추지 않았고, 로그는 원인 분석에 충분하면서도 사용자 데이터를 노출하지 않았습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code Permission Receipt Pattern: 권한, 증거, 롤백을 남기는 운영
Claude Code 작업마다 허용 범위, 승인 경계, 검증 명령, 롤백 메모, Gumroad와 상담 CTA 확인을 남기는 permission receipt 패턴입니다.
Claude Code/Codex 안전 Agent Harness 설계: 권한, 검증, 롤백
Claude Code와 Codex를 안전하게 운영하기 위한 Agent Harness를 권한 정책, 실행 계획, 검증, 복구 계층으로 설계합니다.
Claude Code 서브에이전트 실전 가이드: 기사와 코드 작업을 안전하게 위임하기
Claude Code 서브에이전트로 기사와 코드 작업을 안전하게 나누는 방법. 위임 규칙, 프롬프트, 실패 사례를 정리합니다.