Error Handling
에러 처리 및 복구 구현 패턴입니다.
개요
Error Handling 패턴은 애플리케이션에서 발생하는 예상치 못한 오류를 우아하게 처리하고, 사용자에게 명확한 피드백을 제공하며, 복구 가능한 경로를 제시하는 검증된 구현 방법을 제공합니다. 에러 바운더리, 전역 에러 처리, 재시도 로직, 에러 로깅 등 프로덕션 환경에서 필요한 모든 에러 처리 시나리오를 다룹니다.
사용 사례:
- 컴포넌트 렌더링 에러 포착
- API 호출 실패 처리
- 네트워크 오류 복구
- 404/500 에러 페이지
- 전역 에러 처리
사용하지 말아야 할 때:
- 정상적인 비즈니스 로직 분기 (if/else 사용)
- 사용자 입력 검증 (form validation 사용)
- 의도된 예외 상황 (예: 빈 검색 결과)
기본 패턴
1. Error Boundary 구현
React 컴포넌트 에러를 포착하는 기본 패턴입니다.
"use client";
import React, { Component, ReactNode } from "react";
import { Button, Alert } from "@vortex/ui-foundation";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// 에러 로깅 서비스로 전송
console.error("Error caught by boundary:", error, errorInfo);
// 예: Sentry.captureException(error, { extra: errorInfo })
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
<Alert variant="destructive" className="max-w-md">
<h2 className="text-lg font-bold mb-2">문제가 발생했습니다</h2>
<p className="mb-4">
{this.state.error?.message || "알 수 없는 오류가 발생했습니다"}
</p>
<div className="flex gap-2">
<Button onClick={this.handleReset}>다시 시도</Button>
<Button
variant="outline"
onClick={() => (window.location.href = "/")}
>
홈으로 이동
</Button>
</div>
</Alert>
</div>
);
}
return this.props.children;
}
}
// 사용 예시
export default function App() {
return (
<ErrorBoundary>
<YourComponent />
</ErrorBoundary>
);
}2. API 에러 처리
API 호출 중 발생하는 에러를 처리하는 패턴입니다.
"use client";
import { useState } from "react";
import { Alert, Button } from "@vortex/ui-foundation";
interface ApiError {
message: string;
code?: string;
details?: unknown;
}
export default function ApiErrorHandling() {
const [data, setData] = useState(null);
const [error, setError] = useState<ApiError | null>(null);
const [isLoading, setIsLoading] = useState(false);
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/data");
// HTTP 상태 코드 확인
if (!response.ok) {
const errorData = await response.json();
// 상태 코드별 에러 처리
switch (response.status) {
case 400:
throw new Error("잘못된 요청입니다");
case 401:
throw new Error("인증이 필요합니다");
case 403:
throw new Error("접근 권한이 없습니다");
case 404:
throw new Error("데이터를 찾을 수 없습니다");
case 500:
throw new Error("서버 오류가 발생했습니다");
default:
throw new Error(errorData.message || "요청에 실패했습니다");
}
}
const result = await response.json();
setData(result);
} catch (err) {
// 네트워크 에러 처리
if (err instanceof TypeError && err.message === "Failed to fetch") {
setError({
message: "네트워크 연결을 확인해주세요",
code: "NETWORK_ERROR",
});
} else if (err instanceof Error) {
setError({
message: err.message,
code: "API_ERROR",
});
} else {
setError({
message: "알 수 없는 오류가 발생했습니다",
code: "UNKNOWN_ERROR",
});
}
} finally {
setIsLoading(false);
}
};
return (
<div className="p-4">
{error && (
<Alert variant="destructive" className="mb-4">
<h3 className="font-bold">오류 발생</h3>
<p>{error.message}</p>
{error.code && (
<p className="text-sm mt-2">오류 코드: {error.code}</p>
)}
</Alert>
)}
<Button onClick={fetchData} disabled={isLoading}>
{isLoading ? "로딩 중..." : "데이터 가져오기"}
</Button>
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
</div>
);
}고급 패턴
3. 재시도 로직 구현
네트워크 오류 시 자동으로 재시도하는 패턴입니다.
"use client";
import { useState } from "react";
interface RetryOptions {
maxAttempts?: number;
delay?: number;
backoff?: number;
}
export function useRetry<T>(fn: () => Promise<T>, options: RetryOptions = {}) {
const { maxAttempts = 3, delay = 1000, backoff = 2 } = options;
const [attempts, setAttempts] = useState(0);
const [isRetrying, setIsRetrying] = useState(false);
const executeWithRetry = async (): Promise<T> => {
let lastError: Error | null = null;
let currentDelay = delay;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
setAttempts(attempt);
try {
const result = await fn();
setAttempts(0);
setIsRetrying(false);
return result;
} catch (error) {
lastError = error as Error;
console.log(`Attempt ${attempt}/${maxAttempts} failed`);
// 마지막 시도가 아니면 대기 후 재시도
if (attempt < maxAttempts) {
setIsRetrying(true);
await new Promise((resolve) => setTimeout(resolve, currentDelay));
currentDelay *= backoff; // Exponential backoff
}
}
}
setIsRetrying(false);
throw lastError;
};
return { executeWithRetry, attempts, isRetrying };
}
// 사용 예시
export default function RetryExample() {
const [data, setData] = useState(null);
const [error, setError] = useState<string | null>(null);
const fetchData = async () => {
const response = await fetch("/api/unstable-endpoint");
if (!response.ok) throw new Error("API call failed");
return response.json();
};
const { executeWithRetry, attempts, isRetrying } = useRetry(fetchData, {
maxAttempts: 3,
delay: 1000,
backoff: 2,
});
const handleFetch = async () => {
try {
const result = await executeWithRetry();
setData(result);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "오류 발생");
}
};
return (
<div className="p-4">
{isRetrying && <p>재시도 중... ({attempts}/3)</p>}
{error && <p className="text-red-600">{error}</p>}
<button onClick={handleFetch}>데이터 가져오기</button>
</div>
);
}4. 전역 에러 핸들러
애플리케이션 전역 에러를 처리하는 패턴입니다.
"use client";
import { createContext, useContext, useState, ReactNode } from "react";
import { Alert } from "@vortex/ui-foundation";
interface GlobalError {
id: string;
message: string;
type: "error" | "warning" | "info";
timestamp: number;
}
interface ErrorContextType {
errors: GlobalError[];
addError: (message: string, type?: GlobalError["type"]) => void;
removeError: (id: string) => void;
clearErrors: () => void;
}
const ErrorContext = createContext<ErrorContextType | undefined>(undefined);
export function ErrorProvider({ children }: { children: ReactNode }) {
const [errors, setErrors] = useState<GlobalError[]>([]);
const addError = (message: string, type: GlobalError["type"] = "error") => {
const error: GlobalError = {
id: Math.random().toString(36).substr(2, 9),
message,
type,
timestamp: Date.now(),
};
setErrors((prev) => [...prev, error]);
// 5초 후 자동 제거
setTimeout(() => {
removeError(error.id);
}, 5000);
};
const removeError = (id: string) => {
setErrors((prev) => prev.filter((error) => error.id !== id));
};
const clearErrors = () => {
setErrors([]);
};
return (
<ErrorContext.Provider
value={{ errors, addError, removeError, clearErrors }}
>
{/* 에러 토스트 UI */}
<div className="fixed top-4 right-4 z-50 space-y-2">
{errors.map((error) => (
<Alert
key={error.id}
variant={error.type === "error" ? "destructive" : "default"}
className="max-w-md animate-slide-in"
>
<div className="flex justify-between items-start">
<p>{error.message}</p>
<button
onClick={() => removeError(error.id)}
className="ml-4 text-sm"
>
✕
</button>
</div>
</Alert>
))}
</div>
{children}
</ErrorContext.Provider>
);
}
export function useError() {
const context = useContext(ErrorContext);
if (!context) {
throw new Error("useError must be used within ErrorProvider");
}
return context;
}
// 사용 예시
export default function ExampleComponent() {
const { addError } = useError();
const handleAction = async () => {
try {
await someApiCall();
} catch (error) {
addError("작업에 실패했습니다", "error");
}
};
return <button onClick={handleAction}>실행</button>;
}5. 에러 로깅 통합
Sentry 등 에러 로깅 서비스와 통합하는 패턴입니다.
// lib/error-logger.ts
import * as Sentry from "@sentry/nextjs";
interface ErrorLogOptions {
level?: "error" | "warning" | "info";
tags?: Record<string, string>;
extra?: Record<string, unknown>;
user?: {
id: string;
email: string;
};
}
export function logError(error: Error, options: ErrorLogOptions = {}) {
const { level = "error", tags, extra, user } = options;
// 개발 환경에서는 콘솔에만 출력
if (process.env.NODE_ENV === "development") {
console.error("Error:", error, options);
return;
}
// 프로덕션에서는 Sentry로 전송
Sentry.withScope((scope) => {
scope.setLevel(level);
if (tags) {
Object.entries(tags).forEach(([key, value]) => {
scope.setTag(key, value);
});
}
if (extra) {
scope.setContext("extra", extra);
}
if (user) {
scope.setUser(user);
}
Sentry.captureException(error);
});
}
// 사용 예시
export default function ComponentWithLogging() {
const handleAction = async () => {
try {
await riskyOperation();
} catch (error) {
logError(error as Error, {
level: "error",
tags: {
component: "ComponentWithLogging",
action: "handleAction",
},
extra: {
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
},
user: {
id: "user-123",
email: "user@example.com",
},
});
}
};
return <button onClick={handleAction}>실행</button>;
}Best Practices
✅ 권장 사항
-
사용자 친화적 메시지
- 기술적 용어 피하기 (“500 Internal Server Error” → “문제가 발생했습니다”)
- 해결 방법 제시 (“다시 시도하거나 고객센터에 문의하세요”)
- 긍정적 표현 (“처리 중 문제 발생” → “잠시 후 다시 시도해주세요”)
-
에러 복구 경로 제공
- “다시 시도” 버튼
- “홈으로 이동” 링크
- “이전 페이지로” 네비게이션
-
적절한 로깅
- 에러 스택 트레이스 기록
- 사용자 컨텍스트 포함 (user ID, 페이지 URL)
- 민감한 정보 제외 (비밀번호, 토큰)
-
에러 분류
- 네트워크 에러: 재시도 가능
- 인증 에러: 로그인 페이지로 리다이렉트
- 권한 에러: 접근 불가 안내
- 서버 에러: 관리자 알림
-
접근성
role="alert"로 스크린 리더 알림- 에러 메시지에 포커스 이동
- 키보드로 복구 버튼 접근 가능
⚠️ 피해야 할 것
-
보안 취약점
- 상세한 에러 스택 노출 (프로덕션)
- 민감한 정보 로그 기록
- 에러 메시지에 내부 시스템 정보 포함
-
UX 문제
- 에러 무시하고 계속 진행
- 모호한 에러 메시지 (“오류 발생”)
- 복구 방법 없이 에러만 표시
-
성능 문제
- 무한 재시도 루프
- 모든 에러를 전역 핸들러로 처리
- 에러 로깅 실패 시 메인 앱 블로킹
성능 고려사항
Lazy Error Boundary
// 필요할 때만 에러 UI 로드
import { lazy, Suspense } from "react";
const ErrorFallback = lazy(() => import("./ErrorFallback"));
export function LazyErrorBoundary({ children }) {
return (
<ErrorBoundary
fallback={
<Suspense fallback={<div>로딩 중...</div>}>
<ErrorFallback />
</Suspense>
}
>
{children}
</ErrorBoundary>
);
}Foundation 예제
범용 에러 페이지
Foundation 컴포넌트로 구현한 중립적인 에러 페이지입니다.
import { Button, Alert, Card } from "@vortex/ui-foundation";
export default function FoundationError() {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="max-w-md p-6">
<Alert variant="destructive" className="mb-4">
<h2 className="text-xl font-bold">문제가 발생했습니다</h2>
<p className="mt-2">요청을 처리하는 중 오류가 발생했습니다</p>
</Alert>
<div className="flex gap-2">
<Button onClick={() => window.location.reload()}>다시 시도</Button>
<Button
variant="outline"
onClick={() => (window.location.href = "/")}
>
홈으로
</Button>
</div>
</Card>
</div>
);
}iCignal 예제
Analytics 데이터 로드 에러
iCignal Blue 브랜드를 적용한 데이터 로드 실패 화면입니다.
import "@vortex/ui-icignal/theme";
import { Button, Alert, Card } from "@vortex/ui-icignal";
export default function ISignalError() {
return (
<Card className="p-6 border-blue-500">
<Alert variant="warning" className="mb-4 bg-orange-50 border-orange-500">
<h3 className="font-bold text-orange-700">데이터 로드 실패</h3>
<p className="text-orange-600">
분석 데이터를 불러오는 중 문제가 발생했습니다
</p>
</Alert>
<Button variant="primary" className="bg-blue-500">
데이터 새로고침
</Button>
</Card>
);
}Cals 예제
예약 실패 처리
Cals Pink 브랜드를 적용한 예약 실패 화면입니다.
import "@vortex/ui-cals/theme";
import { Button, Alert, Card, Badge } from "@vortex/ui-cals";
export default function CalsBookingError() {
return (
<Card className="p-6 border-pink-500">
<div className="flex items-center gap-2 mb-4">
<Badge variant="cancelled">예약 실패</Badge>
</div>
<Alert variant="destructive" className="mb-4 border-red-500">
<h3 className="font-bold text-red-700">예약 처리 실패</h3>
<p className="text-red-600">선택한 시간대가 이미 예약되었습니다</p>
</Alert>
<div className="flex gap-2">
<Button variant="primary" className="bg-pink-500">
다른 시간 선택
</Button>
<Button variant="outline">예약 취소</Button>
</div>
</Card>
);
}CodeSandbox
CodeSandbox 예제는 곧 제공될 예정입니다.
로컬에서 실행하기
-
프로젝트 생성
npx @vortex/cli init my-error-project --template vite-react cd my-error-project -
에러 로깅 설정 (선택사항)
pnpm add @sentry/nextjs -
컴포넌트 추가
# Foundation npx @vortex/cli add button alert card --package foundation # iCignal npx @vortex/cli add button alert card --package icignal # Cals npx @vortex/cli add button alert card badge --package cals -
코드 복사 및 실행
pnpm dev
관련 패턴
- Loading States - 로딩 및 에러 상태 관리
- Authentication - 인증 에러 처리
- Form Validation - 폼 검증 에러
Last updated on