에러도 UX다 – React에서의 체계적인 에러 처리 전략

date
May 19, 2025
slug
error-as-ux-react-error-handling
author
status
Public
tags
React
summary
type
Post
thumbnail
thumbnail_.jpg
category
updatedAt
May 21, 2025 02:45 PM
 
"사용자에게 어떤 에러 경험을 제공하고 있나요?”
 

1. 들어가며

웹 서비스가 점점 복잡해지고 기능이 늘어날수록, 다양한 이유로 예기치 않은 에러가 발생할 수 있습니다.
서버 API 호출이 실패하거나, 인증 토큰이 만료되거나, 클라이언트 코드에서 예외가 발생할 수도 있죠.
이럴 때 사용자에게 아무런 안내 없이 화면이 멈춰버린다면, 서비스 신뢰도와 사용 경험에 적지 않은 영향을 줄 수 있습니다. 반면, 에러를 예측하고 적절하게 처리하는 구조를 갖추면, 사용자에게 안정적인 인상을 줄 수 있고, 개발자 입장에서도 유지보수와 디버깅이 쉬워집니다.
이 글에서는 리액트 환경(React + Vite)에서 구현한 에러 처리 시스템의 설계 구조와 적용한 과정을 공유하려고 합니다.
 

2. 에러를 분류하자

먼저, 다양한 에러 상황을 발생 원인과 처리 방식에 따라 분류했습니다.
에러 유형
예시
처리 방식
서버 에러
API 요청 실패 (500, 404 등)
사용자 메시지 + UI
클라이언트 에러
JS 런타임 에러
ErrorBoundary 처리
인증 에러
토큰 만료, 401 등
자동 로그아웃 + 로그인 리디렉션
커스텀 에러
특정 UX 흐름 필요
컴포넌트에서 개별 처리
네트워크 에러
인터넷 끊김 등
Retry 유도 or 토스트 알림
 

3. 에러 처리의 전체 흐름

에러는 Axios에서 시작해 공통 포맷으로 변환되고, 처리 로직을 거쳐 UI까지 이어집니다.
graph TD A[Axios 에러 발생] --> B[createServerError: 에러 포맷 표준화] B --> C[analyzeError: 카테고리 분류 및 메시지 추출] C --> D[handleError: 처리 전략 결정] D -->|ALERT| E[toast.error 호출] D -->|AUTH| F[로그아웃 + 로그인 리다이렉션] D -->|ERROR_BOUNDARY| G[throw to ErrorBoundary] D -->|CUSTOM| H[컴포넌트 onError에서 처리]
 

4. Axios에서 시작하는 에러 표준화

Axios는 다양한 에러 형태를 던집니다

  • 서버 응답 에러 (error.response 있음)
  • 네트워크 에러 (response 없음)
  • 인증 실패 (401 → 토큰 갱신 로직 필요)
이 모든 경우를 createServerError(code, message)정형화된 객체로 감싸기 때문에(표준화하기), 이후 로직이 간단해집니다.
 
// base-axios.ts instance.interceptors.response.use( (response) => response, async (error: AxiosError<ErrorResponse>) => { // 네트워크 에러 (인터넷 끊김 등) if (!error.response) { return Promise.reject( createServerError('NETWORK_ERROR', '네트워크 상태를 확인해주세요.') ); } const status = error.response.status; const data = error.response.data; // 에러 코드 우선순위: 서버 제공 code > HTTP 상태 기반 매핑 > fallback const errorCode: ServerErrorCode = (data?.code as ServerErrorCode) || HTTP_STATUS_TO_ERROR_CODE[status] || 'UNKNOWN_ERROR'; // 메시지는 서버 메시지 → Axios 에러 메시지 → 클라이언트 정의 메시지 순으로 fallback const errorMessage = // 1. 서버가 내려준 message가 우선 data?.message // 2. 없으면 Axios 자체의 에러 메시지 || error.message // 3. 마지막으로 클라이언트에서 정의한 메시지 사용 || SERVER_ERROR_MESSAGES[errorCode]; return Promise.reject(createServerError(errorCode, errorMessage)); } );
 
interceptor 내부의 에러 처리 흐름
  1. Axios 응답 에러 → status code 확인
  1. 백엔드에서 제공한 code 우선 사용
  1. 없으면 HTTP status → 에러 코드 매핑 테이블로 변환
  1. 에러 메시지까지 합쳐서 createServerError로 감싸기
 

📌 createServerError

// server-error.ts export function createServerError( code: ServerErrorCode, message?: string ): ServerErrorType { const error = new Error(message || SERVER_ERROR_MESSAGES[code]) as ServerErrorType; error.code = code; return error; }
이 함수는 일반 Error 객체에 code 속성을 추가함으로써,
  • 타입 가드 (isServerError)를 사용 가능하게 하고,
  • 모든 에러를 동일한 구조로 다룰 수 있게 만들어줍니다. 즉, error.code, error.message를 기반으로 안정적인 UI 분기 처리가 가능합니다.
 

5. 에러를 공통 구조로 감싸기

// types/error.ts export interface ErrorResponse { status: number; code: string; message: string; data?: unknown; } export interface ErrorAnalysisResult { response: ErrorResponse; category: 'ALERT' | 'AUTH' | 'ERROR_BOUNDARY' | 'CUSTOM'; redirectPath?: string; }
이 구조를 기반으로 에러 메시지, UI 전략, 로그 수집, 리다이렉트 경로까지 일관되게 처리합니다.
 

6. 서버 에러 코드와 분류

// errors/server-error.ts export const SERVER_ERROR_CODE = { INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR', USER_NOT_FOUND: 'USER_NOT_FOUND', DUPLICATE_NICKNAME: 'DUPLICATE_NICKNAME', TOKEN_EXPIRED: 'TOKEN_EXPIRED', } as const; export type ServerErrorCode = typeof SERVER_ERROR_CODE[keyof typeof SERVER_ERROR_CODE]; export const ERROR_CATEGORIES = { ALERT: new Set<ServerErrorCode>([ SERVER_ERROR_CODE.USER_NOT_FOUND, ]), AUTH: new Set<ServerErrorCode>([ SERVER_ERROR_CODE.TOKEN_EXPIRED, ]), ERROR_BOUNDARY: new Set<ServerErrorCode>([ SERVER_ERROR_CODE.INTERNAL_SERVER_ERROR, ]), CUSTOM: new Set<ServerErrorCode>([ SERVER_ERROR_CODE.D
이 구조는 각 에러가 어떻게 처리되어야 하는지를 명확히 해줍니다.
예를 들어 USER_NOT_FOUND는 단순 알림, DUPLICATE_NICKNAME은 페이지 이동이 필요한 커스텀 처리로 분리됩니다.
 

7. 에러 분석 및 분류

export function analyzeError(error: unknown): ErrorAnalysisResult { if (isAxiosError(error)) { const status = error.response?.status ?? 500; const code = extractErrorCode(error) ?? 'INTERNAL_SERVER_ERROR'; const response: ErrorResponse = { status, code, message: SERVER_ERROR_MESSAGES[code] ?? '알 수 없는 오류가 발생했습니다.', data: error.response?.data, }; return { response, category: getErrorCategory(code), redirectPath: getRedirectPathForServerCategory(code), }; } return { response: { status: 500, code: 'UNKNOWN', message: '예기치 못한 오류가 발생했습니다.', }, category: 'ERROR_BOUNDARY', }; }
  • axios 에러인지, 클라이언트 에러인지 구분
  • 정의된 에러 코드에 따라 사용자 메시지/처리 전략 결정
 
// extractErrorCode 함수 구현 참고 const extractErrorCode = (error: AxiosError): ServerErrorCode | undefined => { return error.response?.data?.code as ServerErrorCode; }
 

8. 처리 실행 함수 (handleError)

const handleError = (error: unknown) => { const { response, category, redirectPath } = analyzeError(error); switch (category) { case 'AUTH': toast.error(response.message); resetAuthState(); router.replace(redirectPath ?? '/login'); break; case 'ALERT': toast.error(response.message); break; case 'CUSTOM': // 컴포넌트에서 직접 처리 break; case 'ERROR_BOUNDARY': throw error; } };
 

9. react-query와 연동

const queryClient = new QueryClient({ defaultOptions: { queries: { onError: handleError, throwOnError: (error) => analyzeError(error).category === 'ERROR_BOUNDARY', }, mutations: { onError: handleError, throwOnError: (error) => analyzeError(error).category === 'ERROR_BOUNDARY', }, }, });
⚠️ throwOnError: true로 설정해야 ErrorBoundary에 위임됩니다.
 

10. 에러 UI 처리 전략 요약 (예시)

분류
처리 방식
예시 메시지
ALERT
toast.error()
"사용자를 찾을 수 없습니다."
AUTH
toast + router.replace('/login')
"세션이 만료되었습니다."
ERROR_BOUNDARY
<ErrorBoundary> + Fallback UI
"예기치 못한 오류가 발생했어요"
CUSTOM
useMutation({ onError }) 컴포넌트 처리
"닉네임이 중복되었습니다."
 

11. ErrorBoundary와 통합

// RootLayout.tsx import { Outlet } from 'react-router-dom'; import { QueryErrorResetBoundary } from '@tanstack/react-query'; import ErrorFallback from '@/components/errors/error-fallback'; import { ErrorBoundary } from 'react-error-boundary'; import { FullPageLoading } from '@/components/full-page-loading'; import { Suspense } from 'react'; // 루트 레이아웃에서 react-query + ErrorBoundary를 통합 처리하는 예시입니다. // 에러 발생 시 ErrorBoundary → ErrorFallback 렌더 // reset 호출 시 react-query 캐시 리셋 + Boundary 상태 초기화 → 재시도 가능 const RootLayout = () => { return ( <QueryErrorResetBoundary> {({ reset }) => ( <ErrorBoundary onReset={reset} // 이 reset은 react-query의 resetQuery 에 연결됨 FallbackComponent={(props) => <ErrorFallback {...props} />} > <Suspense fallback={<FullPageLoading />}> <Outlet /> </Suspense> </ErrorBoundary> )} </QueryErrorResetBoundary> ); }; export default RootLayout;
// lib/error-utils.ts /** * 에러 객체 → 사용자에게 보여줄 메시지, 액션 버튼, 상태코드 등 UI에 필요한 정보로 변환합니다. * 로직 분기와 메시지 결정은 이 유틸에서 집중적으로 처리하고, * Fallback 컴포넌트는 화면 출력에만 집중할 수 있게 합니다. */ export function getErrorUIProps(error: Error, reset: () => void, navigate: ReturnType<typeof useNavigate>, path: string) { const { classification, errorResponse } = analyzeError(error); const errorCode = classification?.type || error.name || 'UNKNOWN_ERROR'; const isServerError = SERVER_ERROR_CONSTANTS.includes(errorCode as SERVER_ERROR_CODE); const errorMessage = isServerError ? errorResponse?.message ?? '서버 처리 중 오류가 발생했습니다.' : CLIENT_ERROR_MESSAGES[errorCode as keyof typeof CLIENT_ERROR_MESSAGES] ?? '예기치 못한 오류가 발생했습니다.'; const action = ERROR_ACTIONS[errorCode as SERVER_ERROR_CODE] ?? { action: '홈으로 가기', actionType: 'NAVIGATE_HOME', }; const onActionClick = action.actionType === 'RETRY_BOUNDARY' ? reset : () => { reset(); navigate('/', { replace: true }); }; return { error, errorCode, errorMessage, errorStatus: errorResponse?.status, previousPath: path, actionText: action.action, onActionClick, }; }
// ErrorFallback 컴포넌트는 UI 관점에서 에러를 어떻게 보여줄지를 담당합니다. // resetErrorBoundary는 react-error-boundary에서 주입되며, 에러 상태 초기화 용도로 사용됩니다. // 여기선 getErrorUIProps를 통해 로직 분리 + UI 전용 props 추상화함 export default function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) { const navigate = useNavigate(); const location = useLocation(); const props = getErrorUIProps(error, resetErrorBoundary, navigate, location.pathname); const isNetworkLike = ['NETWORK_ERROR', 'SERVICE_UNAVAILABLE'].includes(props.errorCode); return isNetworkLike ? <NetworkErrorView {...props} /> : <GenericErrorView {...props} />; }
react-error-boundary의 resetErrorBoundary()는 에러 상태를 초기화하여 다시 렌더링을 유도합니다.
QueryErrorResetBoundary와 함께 쓰면, react-query 캐시까지 초기화해 API 재시도를 할 수 있습니다. ⇒ 이를 통해 사용자에게 “다시 시도하기” 흐름을 제공할 수 있습니다.
 

12. 커스텀 에러: 컴포넌트에서 직접 처리

useMutation(createNickname, { onError: (error) => { if (isCustomError(error, 'DUPLICATE_NICKNAME')) { openNicknameModal(); } }, });
커스텀 에러는 컴포넌트 내부 UX 흐름과 밀접하게 연관되어 있기 때문에 전역 처리보다 useMutation().onError에서 직접 처리하는 것이 명확하고 유연합니다.
 
isCustomError 예시 코드
export function isCustomError(error: unknown, code: ServerErrorCode): boolean { return ( isServerError(error) && ERROR_CATEGORIES.CUSTOM.has(error.code) ); }
 

13. Sentry 로깅 전략

  • ✅ 수집
    • 사용자 조작 없이 해결할 수 없는 오류
    • 디버깅을 위한 API 실패 (e.g. 서버 500, 네트워크 장애)
  • ⛔ 제외
    • 사용자가 해결할 수 있는 예상 가능한 오류 (e.g. 로그인 실패)
    • UX 상 이미 안내된 알림성 에러 (toast로 처리되는 404 등)
    •  
import * as Sentry from '@sentry/react'; export function logAPIErrorToSentry(error: AxiosError) { Sentry.withScope((scope) => { scope.setLevel('error'); scope.setTags({ errorCode: error.response?.data?.errorCode, status: error.response?.status?.toString(), }); scope.setContext('Request', { url: error.config?.url, method: error.config?.method, }); scope.setContext('Response', { status: error.response?.status, data: error.response?.data, }); Sentry.captureException(error); }); }
💡 Tip: 모든 에러를 Sentry에 보내면 ‘노이즈’가 많아지고,
중요한 이슈를 놓치기 쉬워집니다.
 
따라서 다음과 같은 기준을 명확히 세워두는 것이 좋습니다.
  • 단순 UX 알림(toast.error)으로 충분한 오류는 로깅하지 않고 무시
  • 예상 외의 예외 상황(서버 오류, 알 수 없는 에러 등)은 반드시 Sentry로 전송
  • 필요하다면 handleError 내부에서 조건 분기로 로깅 여부를 판단
// 예시 코드 if (shouldLogToSentry(error)) { logAPIErrorToSentry(error); }
이렇게 하면 불필요한 로깅은 줄이고, 중요한 오류만 집중적으로 모니터링할 수 있습니다.
 

14. 서버 에러 코드 추가 가이드

  1. 백엔드와 에러 코드 및 의미 정의 협의
  1. 백엔드에서 에러 코드 적용 후 프론트에 코드 추가
// 1. 코드 추가 SERVER_ERROR_CODE.INVALID_INVITE_CODE = 'INVALID_INVITE_CODE'; // 2. 카테고리 분류 (예: ALERT로 처리) ERROR_CATEGORIES.ALERT.add(SERVER_ERROR_CODE.INVALID_INVITE_CODE);
 

15. 결론

에러는 언제든 발생할 수 있습니다. 하지만 어떻게 설계하고 대응하느냐에 따라, 사용자 경험은 완전히 달라집니다.
사용자가 문제를 만났을 때 보게 되는 UI는 단순한 화면이 아니라, 우리가 제품을 얼마나 책임감 있게 만들고 있는지를 보여주는 부분이라는 생각이 듭니다.
 
이 글에서 다룬 것처럼,
  • 에러를 표준화된 구조로 감싸고
  • 카테고리 기반으로 처리 전략을 나누며
  • UI와 연결되는 흐름까지 일관되게 설계하면
 
“에러 처리”는 더 이상 귀찮은 예외가 아니라, 제품의 품질을 지탱하는 강력한 기반이 될 수 있습니다.
 
“예기치 못한 상황을 '예상 가능한 흐름'으로 바꾸는 것이 제품을 개발하는 사람들에게 주어진 중요한 과제이자 책임이 아닐까요?”
 
💡 이 글에서 소개한 방식은 하나의 예시일 뿐입니다.
여러분의 서비스에 맞게 조정하며 활용해보시길 바랍니다!