"사용자에게 어떤 에러 경험을 제공하고 있나요?”
웹 서비스가 점점 복잡해지고 기능이 늘어날수록, 다양한 이유로 예기치 않은 에러가 발생할 수 있습니다.
서버 API 호출이 실패하거나, 인증 토큰이 만료되거나, 클라이언트 코드에서 예외가 발생할 수도 있죠.
이럴 때 사용자에게 아무런 안내 없이 화면이 멈춰버린다면, 서비스 신뢰도와 사용 경험에 적지 않은 영향을 줄 수 있습니다. 반면, 에러를 예측하고 적절하게 처리하는 구조를 갖추면, 사용자에게 안정적인 인상을 줄 수 있고, 개발자 입장에서도 유지보수와 디버깅이 쉬워집니다.
이 글에서는 리액트 환경(React + Vite)에서 구현한 에러 처리 시스템의 설계 구조와 적용한 과정을 공유하려고 합니다.
먼저, 다양한 에러 상황을 발생 원인과 처리 방식에 따라 분류했습니다.
에러는 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에 위임됩니다.
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는 단순한 화면이 아니라, 우리가 제품을 얼마나 책임감 있게 만들고 있는지를 보여주는 부분이라는 생각이 듭니다.
이 글에서 다룬 것처럼,
“에러 처리”는 더 이상 귀찮은 예외가 아니라, 제품의 품질을 지탱하는 강력한 기반이 될 수 있습니다.
“예기치 못한 상황을 '예상 가능한 흐름'으로 바꾸는 것이 제품을 개발하는 사람들에게 주어진 중요한 과제이자 책임이 아닐까요?”
💡 이 글에서 소개한 방식은 하나의 예시일 뿐입니다.
여러분의 서비스에 맞게 조정하며 활용해보시길 바랍니다!