Error Handling
개요
애플리케이션에서 발생하는 예상치 못한 오류를 우아하게 처리하고, 사용자에게 명확한 피드백을 제공하며, 복구 가능한 경로를 제시하는 패턴입니다.
주요 기능:
- Error Boundary로 컴포넌트 에러 포착
- Axios Interceptor로 API 에러 공통 처리
- React Query로 재시도 및 에러 상태 관리
- 상태 코드별 차별화된 에러 처리
사용 사례:
- API 호출 실패 처리
- 네트워크 오류 복구
- 인증/권한 에러 처리
- 컴포넌트 렌더링 에러 포착
사용하지 말아야 할 때:
- 정상적인 비즈니스 로직 분기 (if/else 사용)
- 사용자 입력 검증 (form validation 사용)
- 의도된 예외 상황 (예: 빈 검색 결과)
Best Practices
✅ 권장 사항
-
사용자 친화적 메시지
- 기술적 용어 피하기 (“500 Internal Server Error” → “문제가 발생했습니다”)
- 해결 방법 제시 (“다시 시도하거나 고객센터에 문의하세요”)
- 긍정적 표현 (“처리 중 문제 발생” → “잠시 후 다시 시도해주세요”)
-
에러 복구 경로 제공
- “다시 시도” 버튼
- “홈으로 이동” 링크
- “이전 페이지로” 네비게이션
-
적절한 로깅
- 에러 스택 트레이스 기록
- 사용자 컨텍스트 포함 (user ID, 페이지 URL)
- 민감한 정보 제외 (비밀번호, 토큰)
-
에러 분류
- 네트워크 에러: 재시도 가능
- 인증 에러: 로그인 페이지로 리다이렉트
- 권한 에러: 접근 불가 안내
- 서버 에러: 관리자 알림
-
접근성
role="alert"로 스크린 리더 알림- 에러 메시지에 포커스 이동
- 키보드로 복구 버튼 접근 가능
⚠️ 피해야 할 것
-
보안 취약점
- 상세한 에러 스택 노출 (프로덕션)
- 민감한 정보 로그 기록
- 에러 메시지에 내부 시스템 정보 포함
-
UX 문제
- 에러 무시하고 계속 진행
- 모호한 에러 메시지 (“오류 발생”)
- 복구 방법 없이 에러만 표시
-
성능 문제
- 무한 재시도 루프
- 모든 에러를 전역 핸들러로 처리
- 에러 로깅 실패 시 메인 앱 블로킹
구현 패턴
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. Axios Interceptor
API 에러를 공통으로 처리하는 패턴입니다.
import axios, {
AxiosError,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios"
export interface ApiResponse<T = unknown> {
data: T
message?: string | null
success?: boolean
}
// Axios 인스턴스 생성
export const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_SERVER_URL,
timeout: 30 * 1000, // 30초
headers: {
"Content-Type": "application/json",
},
})
// Request Interceptor
axiosInstance.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// 인증 토큰 추가
const token = localStorage.getItem("auth_token")
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 요청 로깅 (개발 환경)
if (process.env.NODE_ENV === "development") {
console.log("API Request:", config.method?.toUpperCase(), config.url)
}
return config
},
(error) => {
return Promise.reject(error)
},
)
// Response Interceptor
axiosInstance.interceptors.response.use(
async (response: AxiosResponse<ApiResponse>) => {
// 응답 로깅 (개발 환경)
if (process.env.NODE_ENV === "development") {
console.log("API Response:", response.status, response.config.url)
}
return response
},
async (error: AxiosError<ApiResponse>) => {
// 에러 응답 처리
if (error.response) {
const { status, data } = error.response
// HTTP 상태 코드별 처리
switch (status) {
case 401:
// 인증 실패 - 로그인 페이지로 리다이렉트
console.error("인증 실패: 로그인이 필요합니다")
localStorage.removeItem("auth_token")
window.location.href = "/login"
break
case 403:
// 권한 없음
console.error("접근 권한이 없습니다")
break
case 404:
// 리소스 없음
console.error("요청한 리소스를 찾을 수 없습니다")
break
case 500:
case 502:
case 503:
// 서버 에러
console.error("서버 오류가 발생했습니다")
break
default:
console.error("API 오류:", data?.message || "알 수 없는 오류")
}
} else if (error.request) {
// 요청은 전송되었으나 응답을 받지 못함 (네트워크 에러)
console.error("네트워크 오류: 서버에 연결할 수 없습니다")
} else {
// 요청 설정 중 에러 발생
console.error("요청 설정 오류:", error.message)
}
return Promise.reject(error)
},
)주요 기능:
- Request Interceptor: 인증 토큰 자동 추가, 요청 로깅
- Response Interceptor: 상태 코드별 에러 처리
- 401 에러: 자동 로그아웃 및 로그인 페이지 리다이렉트
- 네트워크 에러: 연결 실패 감지 및 처리
3. React Query 에러 처리
Query와 Mutation에서 에러를 처리하는 패턴입니다.
Query 에러 처리
import { useQuery } from "@tanstack/react-query"
export function useUsers(params?: GetUsersParams) {
return useQuery({
queryKey: userKeys.list(params),
queryFn: () => getUsers(params),
// 재시도 설정
retry: (failureCount, error) => {
// 401, 403, 404는 재시도하지 않음
const status = (error as any).response?.status
if ([401, 403, 404].includes(status)) {
return false
}
// 최대 3번까지 재시도
return failureCount < 3
},
// 재시도 간격 (exponential backoff)
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// 에러 발생 시 콜백
onError: (error) => {
console.error("사용자 목록 조회 실패:", error)
},
})
}Mutation 에러 처리
import { useMutation, useQueryClient } from "@tanstack/react-query"
export function useDeleteUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userKeys.all })
},
onError: (error) => {
console.error("사용자 삭제 실패:", error)
},
})
}컴포넌트에서 에러 처리
import { useCallback } from "react"
import { useUsers } from "../hooks/queries/use-users"
import { useDeleteUser } from "../hooks/mutations/use-delete-user"
import { useDialog } from "@/stores/dialog"
export function UserList() {
const { alert, confirm } = useDialog()
const { mutateAsync: deleteUser } = useDeleteUser()
const { data: users = [], isLoading, error, refetch } = useUsers()
const handleDelete = useCallback(
async (userId: number) => {
const result = await confirm({
title: "사용자 삭제",
description: "정말 삭제하시겠습니까?",
})
if (!result) return
try {
await deleteUser(userId)
alert("사용자가 성공적으로 삭제되었습니다.")
} catch (error) {
const status = (error as any)?.response?.status
let errorMessage = "사용자 삭제에 실패했습니다."
if (status === 403) errorMessage = "삭제 권한이 없습니다."
else if (status === 404) errorMessage = "사용자를 찾을 수 없습니다."
alert(errorMessage)
}
},
[confirm, deleteUser, alert],
)
// 에러 UI 표시
if (error) {
return (
<div className="p-4 border border-red-500 rounded bg-red-50">
<h3 className="font-bold text-red-700">데이터 로드 실패</h3>
<p className="text-red-600 mt-2">
{error instanceof Error ? error.message : "알 수 없는 오류"}
</p>
<button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded"
>
다시 시도
</button>
</div>
)
}
if (isLoading) return <div>로딩 중...</div>
return (
<div>
{users.map((user) => (
<div key={user.id}>
<span>{user.name}</span>
<button onClick={() => handleDelete(user.id)}>삭제</button>
</div>
))}
</div>
)
}주요 기능:
- Query 재시도: 상태 코드별 조건부 재시도
- Exponential Backoff: 재시도 간격 증가
- 에러 UI: 명확한 에러 메시지 + 재시도 버튼
- Mutation 에러: try/catch로 에러 캐치 및 사용자 알림
성능 고려사항
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>
)
}관련 패턴
- Loading States - 로딩 및 에러 상태 관리
- Authentication - 인증 에러 처리
- Form Validation - 폼 검증 에러
Last updated on