"사용자에게 어떤 에러 경험을 제공하고 있나요?”
웹 서비스가 점점 복잡해지고 기능이 늘어날수록, 다양한 이유로 예기치 않은 에러가 발생할 수 있습니다.
서버 API 호출이 실패하거나, 인증 토큰이 만료되거나, 클라이언트 코드에서 예외가 발생할 수도 있죠.
이럴 때 사용자에게 아무런 안내 없이 화면이 멈춰버린다면, 서비스 신뢰도와 사용 경험에 적지 않은 영향을 줄 수 있습니다. 반면, 에러를 예측하고 적절하게 처리하는 구조를 갖추면, 사용자에게 안정적인 인상을 줄 수 있고, 개발자 입장에서도 유지보수와 디버깅이 쉬워집니다.
이 글에서는 리액트 환경(React + Vite)에서 구현한 에러 처리 시스템의 설계 구조와 적용한 과정을 공유하려고 합니다.
먼저, 다양한 에러 상황을 발생 원인과 처리 방식에 따라 분류했습니다.
| 에러 유형 | 예시 | 처리 방식 |
|---|---|---|
| 서버 에러 | API 요청 실패 (500, 404 등) | 사용자 메시지 + UI |
| 클라이언트 에러 | JS 런타임 에러 | ErrorBoundary 처리 |
| 인증 에러 | 토큰 만료, 401 등 | 자동 로그아웃 + 로그인 리디렉션 |
| 커스텀 에러 | 특정 UX 흐름 필요 | 컴포넌트에서 개별 처리 |
| 네트워크 에러 | 인터넷 끊김 등 | Retry 유도 or 토스트 알림 |
에러는 Axios에서 시작해 공통 포맷으로 변환되고, 처리 로직을 거쳐 UI까지 이어집니다.
이 모든 경우를 createServerError(code, message)로 정형화된 객체로 감싸기 때문에(표준화하기),
이후 로직이 간단해집니다.
1// base-axios.ts
2instance.interceptors.response.use(
3 (response) => response,
4 async (error: AxiosError<ErrorResponse>) => {
5 // 네트워크 에러 (인터넷 끊김 등)
6 if (!error.response) {
7 return Promise.reject(
8 createServerError('NETWORK_ERROR', '네트워크 상태를 확인해주세요.')
9 );
10 }
11
12 const status = error.response.status;
13 const data = error.response.data;
14
15 // 에러 코드 우선순위: 서버 제공 code > HTTP 상태 기반 매핑 > fallback
16 const errorCode: ServerErrorCode =
17 (data?.code as ServerErrorCode) ||
18 HTTP_STATUS_TO_ERROR_CODE[status] ||
19 'UNKNOWN_ERROR';
20
21 // 메시지는 서버 메시지 → Axios 에러 메시지 → 클라이언트 정의 메시지 순으로 fallback
22 const errorMessage =
23 // 1. 서버가 내려준 message가 우선
24 data?.message
25 // 2. 없으면 Axios 자체의 에러 메시지
26 || error.message
27 // 3. 마지막으로 클라이언트에서 정의한 메시지 사용
28 || SERVER_ERROR_MESSAGES[errorCode];
29
30 return Promise.reject(createServerError(errorCode, errorMessage));
31 }
32);
33interceptor 내부의 에러 처리 흐름
createServerError1// server-error.ts
2export function createServerError(
3 code: ServerErrorCode,
4 message?: string
5): ServerErrorType {
6 const error = new Error(message || SERVER_ERROR_MESSAGES[code]) as ServerErrorType;
7 error.code = code;
8 return error;
9}이 함수는 일반 Error 객체에 code 속성을 추가함으로써,
isServerError)를 사용 가능하게 하고,error.code, error.message를 기반으로 안정적인 UI 분기 처리가 가능합니다.1// types/error.ts
2export interface ErrorResponse {
3 status: number;
4 code: string;
5 message: string;
6 data?: unknown;
7}
8
9export interface ErrorAnalysisResult {
10 response: ErrorResponse;
11 category: 'ALERT' | 'AUTH' | 'ERROR_BOUNDARY' | 'CUSTOM';
12 redirectPath?: string;
13}이 구조를 기반으로 에러 메시지, UI 전략, 로그 수집, 리다이렉트 경로까지 일관되게 처리합니다.
1// errors/server-error.ts
2export const SERVER_ERROR_CODE = {
3 INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
4 USER_NOT_FOUND: 'USER_NOT_FOUND',
5 DUPLICATE_NICKNAME: 'DUPLICATE_NICKNAME',
6 TOKEN_EXPIRED: 'TOKEN_EXPIRED',
7} as const;
8
9export type ServerErrorCode = typeof SERVER_ERROR_CODE[keyof typeof SERVER_ERROR_CODE];
10
11export const ERROR_CATEGORIES = {
12 ALERT: new Set<ServerErrorCode>([
13 SERVER_ERROR_CODE.USER_NOT_FOUND,
14 ]),
15 AUTH: new Set<ServerErrorCode>([
16 SERVER_ERROR_CODE.TOKEN_EXPIRED,
17 ]),
18 ERROR_BOUNDARY: new Set<ServerErrorCode>([
19 SERVER_ERROR_CODE.INTERNAL_SERVER_ERROR,
20 ]),
21 CUSTOM: new Set<ServerErrorCode>([
22 SERVER_ERROR_CODE.D이 구조는 각 에러가 어떻게 처리되어야 하는지를 명확히 해줍니다.
예를 들어 USER_NOT_FOUND는 단순 알림, DUPLICATE_NICKNAME은 페이지 이동이 필요한 커스텀 처리로 분리됩니다.
1
2export function analyzeError(error: unknown): ErrorAnalysisResult {
3 if (isAxiosError(error)) {
4 const status = error.response?.status ?? 500;
5 const code = extractErrorCode(error) ?? 'INTERNAL_SERVER_ERROR';
6
7 const response: ErrorResponse = {
8 status,
9 code,
10 message: SERVER_ERROR_MESSAGES[code] ?? '알 수 없는 오류가 발생했습니다.',
11 data: error.response?.data,
12 };
13
14 return {
15 response,
16 category: getErrorCategory(code),
17 redirectPath: getRedirectPathForServerCategory(code),
18 };
19 }
20
21 return {
22 response: {
23 status: 500,
24 code: 'UNKNOWN',
25 message: '예기치 못한 오류가 발생했습니다.',
26 },
27 category: 'ERROR_BOUNDARY',
28 };
29}// extractErrorCode 함수 구현 참고
const extractErrorCode = (error: AxiosError): ServerErrorCode | undefined => {
return error.response?.data?.code as ServerErrorCode;
}handleError)1const handleError = (error: unknown) => {
2 const { response, category, redirectPath } = analyzeError(error);
3
4 switch (category) {
5 case 'AUTH':
6 toast.error(response.message);
7 resetAuthState();
8 router.replace(redirectPath ?? '/login');
9 break;
10 case 'ALERT':
11 toast.error(response.message);
12 break;
13 case 'CUSTOM':
14 // 컴포넌트에서 직접 처리
15 break;
16 case 'ERROR_BOUNDARY':
17 throw error;
18 }
19};1const queryClient = new QueryClient({
2 defaultOptions: {
3 queries: {
4 onError: handleError,
5 throwOnError: (error) => analyzeError(error).category === 'ERROR_BOUNDARY',
6 },
7 mutations: {
8 onError: handleError,
9 throwOnError: (error) => analyzeError(error).category === 'ERROR_BOUNDARY',
10 },
11 },
12});⚠️ throwOnError: true로 설정해야 ErrorBoundary에 위임됩니다.
| 분류 | 처리 방식 | 예시 메시지 |
|---|---|---|
| ALERT | toast.error() | "사용자를 찾을 수 없습니다." |
| AUTH | toast + router.replace('/login') | "세션이 만료되었습니다." |
| ERROR_BOUNDARY | <ErrorBoundary> + Fallback UI | "예기치 못한 오류가 발생했어요" |
| CUSTOM | useMutation({ onError }) 컴포넌트 처리 | "닉네임이 중복되었습니다." |
1// RootLayout.tsx
2
3import { Outlet } from 'react-router-dom';
4import { QueryErrorResetBoundary } from '@tanstack/react-query';
5import ErrorFallback from '@/components/errors/error-fallback';
6import { ErrorBoundary } from 'react-error-boundary';
7import { FullPageLoading } from '@/components/full-page-loading';
8import { Suspense } from 'react';
9
10// 루트 레이아웃에서 react-query + ErrorBoundary를 통합 처리하는 예시입니다.
11// 에러 발생 시 ErrorBoundary → ErrorFallback 렌더
12// reset 호출 시 react-query 캐시 리셋 + Boundary 상태 초기화 → 재시도 가능
13
14const RootLayout = () => {
15 return (
16 <QueryErrorResetBoundary>
17 {({ reset }) => (
18 <ErrorBoundary
19 onReset={reset} // 이 reset은 react-query의 resetQuery 에 연결됨
20 FallbackComponent={(props) => <ErrorFallback {...props} />}
21 >
22 <Suspense fallback={<FullPageLoading />}>
23 <Outlet />
24 </Suspense>
25 </ErrorBoundary>
26 )}
27 </QueryErrorResetBoundary>
28 );
29};
30
31export default RootLayout;
321// lib/error-utils.ts
2
3/**
4 * 에러 객체 → 사용자에게 보여줄 메시지, 액션 버튼, 상태코드 등 UI에 필요한 정보로 변환합니다.
5 * 로직 분기와 메시지 결정은 이 유틸에서 집중적으로 처리하고,
6 * Fallback 컴포넌트는 화면 출력에만 집중할 수 있게 합니다.
7 */
8
9
10export function getErrorUIProps(error: Error, reset: () => void, navigate: ReturnType<typeof useNavigate>, path: string) {
11 const { classification, errorResponse } = analyzeError(error);
12 const errorCode = classification?.type || error.name || 'UNKNOWN_ERROR';
13
14 const isServerError = SERVER_ERROR_CONSTANTS.includes(errorCode as SERVER_ERROR_CODE);
15 const errorMessage = isServerError
16 ? errorResponse?.message ?? '서버 처리 중 오류가 발생했습니다.'
17 : CLIENT_ERROR_MESSAGES[errorCode as keyof typeof CLIENT_ERROR_MESSAGES] ?? '예기치 못한 오류가 발생했습니다.';
18
19 const action = ERROR_ACTIONS[errorCode as SERVER_ERROR_CODE] ?? {
20 action: '홈으로 가기',
21 actionType: 'NAVIGATE_HOME',
22 };
23
24 const onActionClick = action.actionType === 'RETRY_BOUNDARY'
25 ? reset
26 : () => {
27 reset();
28 navigate('/', { replace: true });
29 };
30
31 return {
32 error,
33 errorCode,
34 errorMessage,
35 errorStatus: errorResponse?.status,
36 previousPath: path,
37 actionText: action.action,
38 onActionClick,
39 };
40}1// ErrorFallback 컴포넌트는 UI 관점에서 에러를 어떻게 보여줄지를 담당합니다.
2// resetErrorBoundary는 react-error-boundary에서 주입되며, 에러 상태 초기화 용도로 사용됩니다.
3// 여기선 getErrorUIProps를 통해 로직 분리 + UI 전용 props 추상화함
4
5export default function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
6 const navigate = useNavigate();
7 const location = useLocation();
8
9 const props = getErrorUIProps(error, resetErrorBoundary, navigate, location.pathname);
10
11 const isNetworkLike = ['NETWORK_ERROR', 'SERVICE_UNAVAILABLE'].includes(props.errorCode);
12
13 return isNetworkLike
14 ? <NetworkErrorView {...props} />
15 : <GenericErrorView {...props} />;
16}
17react-error-boundary의 resetErrorBoundary()는 에러 상태를 초기화하여 다시 렌더링을 유도합니다.
1useMutation(createNickname, {
2 onError: (error) => {
3 if (isCustomError(error, 'DUPLICATE_NICKNAME')) {
4 openNicknameModal();
5 }
6 },
7});커스텀 에러는 컴포넌트 내부 UX 흐름과 밀접하게 연관되어 있기 때문에
전역 처리보다 useMutation().onError에서 직접 처리하는 것이 명확하고 유연합니다.isCustomError 예시 코드
1export function isCustomError(error: unknown, code: ServerErrorCode): boolean {
2 return (
3 isServerError(error) &&
4 ERROR_CATEGORIES.CUSTOM.has(error.code)
5 );
6}1import * as Sentry from '@sentry/react';
2
3export function logAPIErrorToSentry(error: AxiosError) {
4 Sentry.withScope((scope) => {
5 scope.setLevel('error');
6 scope.setTags({
7 errorCode: error.response?.data?.errorCode,
8 status: error.response?.status?.toString(),
9 });
10 scope.setContext('Request', {
11 url: error.config?.url,
12 method: error.config?.method,
13 });
14 scope.setContext('Response', {
15 status: error.response?.status,
16 data: error.response?.data,
17 });
18 Sentry.captureException(error);
19 });
20}💡 Tip: 모든 에러를 Sentry에 보내면 ‘노이즈’가 많아지고,
따라서 다음과 같은 기준을 명확히 세워두는 것이 좋습니다.
toast.error)으로 충분한 오류는 로깅하지 않고 무시handleError 내부에서 조건 분기로 로깅 여부를 판단// 예시 코드
if (shouldLogToSentry(error)) {
logAPIErrorToSentry(error);
}이렇게 하면 불필요한 로깅은 줄이고, 중요한 오류만 집중적으로 모니터링할 수 있습니다.
// 1. 코드 추가
SERVER_ERROR_CODE.INVALID_INVITE_CODE = 'INVALID_INVITE_CODE';
// 2. 카테고리 분류 (예: ALERT로 처리)
ERROR_CATEGORIES.ALERT.add(SERVER_ERROR_CODE.INVALID_INVITE_CODE);에러는 언제든 발생할 수 있습니다. 하지만 어떻게 설계하고 대응하느냐에 따라, 사용자 경험은 완전히 달라집니다.
사용자가 문제를 만났을 때 보게 되는 UI는 단순한 화면이 아니라, 우리가 제품을 얼마나 책임감 있게 만들고 있는지를 보여주는 부분이라는 생각이 듭니다.
이 글에서 다룬 것처럼,
“에러 처리”는 더 이상 귀찮은 예외가 아니라, 제품의 품질을 지탱하는 강력한 기반이 될 수 있습니다.
“예기치 못한 상황을 '예상 가능한 흐름'으로 바꾸는 것이 제품을 개발하는 사람들에게 주어진 중요한 과제이자 책임이 아닐까요?”
💡 이 글에서 소개한 방식은 하나의 예시일 뿐입니다.
여러분의 서비스에 맞게 조정하며 활용해보시길 바랍니다!