Skip to Content
PatternsError Handling

Error Handling

개요

애플리케이션에서 발생하는 예상치 못한 오류를 우아하게 처리하고, 사용자에게 명확한 피드백을 제공하며, 복구 가능한 경로를 제시하는 패턴입니다.

주요 기능:

  • Error Boundary로 컴포넌트 에러 포착
  • Axios Interceptor로 API 에러 공통 처리
  • React Query로 재시도 및 에러 상태 관리
  • 상태 코드별 차별화된 에러 처리

사용 사례:

  • API 호출 실패 처리
  • 네트워크 오류 복구
  • 인증/권한 에러 처리
  • 컴포넌트 렌더링 에러 포착

사용하지 말아야 할 때:

  • 정상적인 비즈니스 로직 분기 (if/else 사용)
  • 사용자 입력 검증 (form validation 사용)
  • 의도된 예외 상황 (예: 빈 검색 결과)

Best Practices

✅ 권장 사항

  1. 사용자 친화적 메시지

    • 기술적 용어 피하기 (“500 Internal Server Error” → “문제가 발생했습니다”)
    • 해결 방법 제시 (“다시 시도하거나 고객센터에 문의하세요”)
    • 긍정적 표현 (“처리 중 문제 발생” → “잠시 후 다시 시도해주세요”)
  2. 에러 복구 경로 제공

    • “다시 시도” 버튼
    • “홈으로 이동” 링크
    • “이전 페이지로” 네비게이션
  3. 적절한 로깅

    • 에러 스택 트레이스 기록
    • 사용자 컨텍스트 포함 (user ID, 페이지 URL)
    • 민감한 정보 제외 (비밀번호, 토큰)
  4. 에러 분류

    • 네트워크 에러: 재시도 가능
    • 인증 에러: 로그인 페이지로 리다이렉트
    • 권한 에러: 접근 불가 안내
    • 서버 에러: 관리자 알림
  5. 접근성

    • role="alert"로 스크린 리더 알림
    • 에러 메시지에 포커스 이동
    • 키보드로 복구 버튼 접근 가능

⚠️ 피해야 할 것

  1. 보안 취약점

    • 상세한 에러 스택 노출 (프로덕션)
    • 민감한 정보 로그 기록
    • 에러 메시지에 내부 시스템 정보 포함
  2. UX 문제

    • 에러 무시하고 계속 진행
    • 모호한 에러 메시지 (“오류 발생”)
    • 복구 방법 없이 에러만 표시
  3. 성능 문제

    • 무한 재시도 루프
    • 모든 에러를 전역 핸들러로 처리
    • 에러 로깅 실패 시 메인 앱 블로킹

구현 패턴

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> ) }

관련 패턴

Last updated on