에러도 UX다 – React에서의 체계적인 에러 처리 전략
"사용자에게 어떤 에러 경험을 제공하고 있나요?”
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 내부의 에러 처리 흐름
- Axios 응답 에러 → status code 확인
- 백엔드에서 제공한 code 우선 사용
- 없으면 HTTP status → 에러 코드 매핑 테이블로 변환
- 에러 메시지까지 합쳐서 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. 코드 추가 SERVER_ERROR_CODE.INVALID_INVITE_CODE = 'INVALID_INVITE_CODE'; // 2. 카테고리 분류 (예: ALERT로 처리) ERROR_CATEGORIES.ALERT.add(SERVER_ERROR_CODE.INVALID_INVITE_CODE);
15. 결론
에러는 언제든 발생할 수 있습니다. 하지만 어떻게 설계하고 대응하느냐에 따라, 사용자 경험은 완전히 달라집니다.
사용자가 문제를 만났을 때 보게 되는 UI는 단순한 화면이 아니라, 우리가 제품을 얼마나 책임감 있게 만들고 있는지를 보여주는 부분이라는 생각이 듭니다.
이 글에서 다룬 것처럼,
- 에러를 표준화된 구조로 감싸고
- 카테고리 기반으로 처리 전략을 나누며
- UI와 연결되는 흐름까지 일관되게 설계하면
“에러 처리”는 더 이상 귀찮은 예외가 아니라, 제품의 품질을 지탱하는 강력한 기반이 될 수 있습니다.
“예기치 못한 상황을 '예상 가능한 흐름'으로 바꾸는 것이 제품을 개발하는 사람들에게 주어진 중요한 과제이자 책임이 아닐까요?”
💡 이 글에서 소개한 방식은 하나의 예시일 뿐입니다.
여러분의 서비스에 맞게 조정하며 활용해보시길 바랍니다!