
프론트 개발을 하다 보면 항상 느끼는게 있다.
아 에러 처리하기
ㅈㄴ귀찮다.
API 붙이고, 성공 케이스 잘 나오면 기분 좋다.
그런데 아래 케이스까지 고려하려는 순간 갑자기 개발을 때려치우고 싶어진다.
이걸 초반에 정리하지 않으면 프로젝트가 커질수록 에러 처리는 점점 지저분해진다.
가장 쉬운 방법은 이거다.
toast.error(error.response.data.message);
끝. 편하다.
그런데 여기서 바로 문제가 생긴다.
한국어 서비스인데 서버 에러는 영어라면?
글로벌 서비스인데 서버 에러가 한국어라면?
서버 메시지가 바뀌면?
영어는 어느 정도 읽을 수 있어도,
한국어는 외국인에게 만국 공통어가 아니다.
한국어 만국공통어 기원 1일차
서버 메시지를 그대로 노출하는 방식은 다국어 환경에서 구조적으로 불안하다.
예를 들어 서버에서 이런 메시지가 내려온다고 하자.
Forbidden
개발자는 바로 이해한다.
"아! 이 계정은 접근 권한이 없구나."
하지만 일반 사용자 입장에서 이 메시지는 설명이 아니다.
그냥 갑자기 영어 단어 하나 튀어나온 것에 불과하다.
서버 메시지는 디버깅 중심이다.
하지만, 사용자 메시지는 맥락 중심이어야 한다.
이 둘은 목적이 다르다.
백엔드 영역에서는 예외 처리, 에러 포맷 통일, 에러 코드 정의 등이 거의 기본이다.
- 에러 객체 표준화
- HTTP Status 기반 분기
- 커스텀 예외 클래스
- 글로벌 예외 핸들러
반면 프론트는?
보통은 이런 패턴이 많다.
- Error Boundary로 에러 페이지 이동
- axios 인터셉터에서 일부 분기
- 특정 훅에서 onError로 하드코딩 처리
문제는 일관성이다.
특정 API 하나 실패했다고 전체 페이지를 날려버리는 건 UX적으로 좋지 않다.
이런 경우까지 전부 에러 페이지로 보내는 건 과하다.
에러는 국소적으로 처리해야 한다.
이를 해결하기 위해 많이 고민했다.
가장 흔한 접근은, 401이나 403 같은 권한 관련 에러와 5xx 같은 서버 에러를
axios 인터셉터에서 전역으로 처리하는 방식이다.
하지만 그 외의 에러들은? 보통은 서버 메시지를 그대로 보여주거나,
특정 상황마다 하드코딩으로 처리하는 경우가 대부분이다.
내가 선택한 방향은 이렇다.
이렇게 레이어를 나누면, 인터셉터에서 이미 처리된 에러(401 → 로그인 리다이렉트 등)는
MutationCache까지 도달하지 않거나, 도달하더라도 중복 처리되지 않도록 설계할 수 있다.
먼저 서버와 프론트 사이에 에러 응답 포맷을 합의해야 한다.
/**
* @description
* 서버에서 내려오는 에러 응답의 표준 포맷입니다.
*
* - code: 프론트에서 다국어 매핑에 사용할 에러 코드
* - message: 디버깅용 메시지 (사용자에게 직접 노출하지 않음)
*/
interface ServerErrorResponse {
code: string;
message: string;
}
서버 응답 예시:
{
"code": "USER_NOT_AUTHORIZED",
"message": "User does not have permission to access this resource"
}
여기서 핵심은 code는 프론트가 다국어 매핑에 사용하고,
message는 개발자 디버깅 용도로만 활용한다는 것이다.
import { AxiosError } from "axios";
/**
* @description
* 서버 에러 응답에서 에러 코드를 추출한 뒤,
* 다국어 메시지로 변환합니다.
*
* @flow
* 1. AxiosError에서 서버 응답의 code 필드를 추출한다.
* 2. code가 존재하면 다국어 키로 변환을 시도한다.
* 3. 매핑된 메시지가 없으면 fallback 메시지를 반환한다.
*
* @param error - AxiosError 또는 일반 Error 객체
* @param fallbackMessage - 매핑 실패 시 사용할 기본 메시지
* @returns 사용자에게 표시할 에러 메시지 문자열
*/
export const getErrorMessage = (
error: Error,
fallbackMessage: string
): string => {
if (error instanceof AxiosError) {
const serverErrorCode = error.response?.data?.code;
if (serverErrorCode) {
// t()는 i18next 등 다국어 라이브러리의 번역 함수
const translatedMessage = t(`errors.${serverErrorCode}`);
// 키가 그대로 반환되면 매핑이 없는 것이므로 fallback 사용
if (translatedMessage !== `errors.${serverErrorCode}`) {
return translatedMessage;
}
}
}
return fallbackMessage;
};
MutationCache는 React 컴포넌트 바깥에서 실행되기 때문에,
React 컨텍스트에 의존하는 toast 라이브러리를 직접 호출할 수 없다.
이를 해결하기 위해 ref 기반 bridge 패턴을 사용한다.
/**
* @description
* React 외부(MutationCache 등)에서 toast를 호출하기 위한
* bridge 모듈입니다.
*
* @flow
* 1. ToastProvider 컴포넌트에서 마운트 시 setToastRef로 참조를 등록한다.
* 2. MutationCache 등 React 외부에서 getToastRef로 참조를 가져온다.
* 3. 가져온 참조를 통해 toast.error(), toast.success()를 호출한다.
*/
interface ToastReference {
success: (message: string) => void;
error: (message: string) => void;
}
let toastReference: ToastReference | null = null;
export const setToastRef = (reference: ToastReference) => {
toastReference = reference;
};
export const getToastRef = (): ToastReference | null => {
return toastReference;
};
이제 핵심인 MutationCache를 구현한다.
import { MutationCache } from "@tanstack/react-query";
import { getErrorMessage } from "src/shared/libs/api/axios/error/error.util";
import { getToastRef } from "src/shared/libs/toast/toast-bridge";
/**
* @description
* 전역 mutation 에러/성공 토스트를 자동 처리하는 MutationCache 팩토리입니다.
*
* @flow
* 1. mutation 에러 발생 시 meta.skipGlobalErrorToast 여부를 확인한다.
* 2. meta.errorMessage가 있으면 getErrorMessage로 사용자 메시지를 생성한다.
* 3. toast bridge를 통해 에러 토스트를 표시한다.
* 4. mutation 성공 시 meta.successMessage가 있으면 성공 토스트를 표시한다.
*
* - meta.errorMessage: 에러 시 표시할 fallback 메시지 (서버 코드 매핑 우선)
* - meta.successMessage: 성공 시 표시할 토스트 메시지
* - meta.skipGlobalErrorToast: true면 전역 에러 토스트 비활성화
*/
export const createMutationCache = () =>
new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (mutation.meta?.skipGlobalErrorToast) return;
if (!mutation.meta?.errorMessage) return;
const toast = getToastRef();
if (!toast) return;
const fallbackMessage = mutation.meta.errorMessage as string;
toast.error(getErrorMessage(error, fallbackMessage));
},
onSuccess: (_data, _variables, _context, mutation) => {
if (!mutation.meta?.successMessage) return;
const toast = getToastRef();
if (!toast) return;
toast.success(mutation.meta.successMessage as string);
},
});
TypeScript에서 meta에 커스텀 필드를 사용하려면 타입을 확장해야 한다.
이 설정이 없으면 meta.errorMessage 접근 시 타입 에러가 발생한다.
import "@tanstack/react-query";
/**
* @description
* TanStack Query의 Register 인터페이스를 확장하여
* mutation meta에 커스텀 필드 타입을 등록합니다.
*/
declare module "@tanstack/react-query" {
interface Register {
mutationMeta: {
errorMessage?: string;
successMessage?: string;
skipGlobalErrorToast?: boolean;
};
}
}
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
gcTime: 1000 * 60 * 5,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchOnMount: true,
retry: 1,
},
mutations: {
retry: 0,
},
},
mutationCache: createMutationCache(),
})
);
그러면 이렇게 구조를 잡았을때의 장점은 뭘까?
보통 프론트에서 mutation을 사용하여 에러 처리를 할때 다음과 같이 처리한다.
useMutation({
mutationFn: saveUserProfile,
onError: (error) => {
...
toast.error("저장에 실패했습니다.");
...
},
});
MutationCache를 활용하면?
useMutation({
mutationFn: saveUserProfile,
meta: {
errorMessage: "저장에 실패했습니다.",
successMessage: "저장되었습니다.",
},
});
onError, onSuccess 콜백이 사라지고, 선언적으로 메시지만 지정하면 된다.
코드 밀도가 낮아지고 가독성이 올라간다.
이전엔, 이게 서버 통신 에러인지, 프레임워크 에러인지 일일히 디버깅을 해봐야 했지만, 이제는
이런 식으로 에러 레이어가 명확히 분리된다.
전역 처리가 적합하지 않은 경우, skipGlobalErrorToast로 전역 토스트를 끄고
개별 컴포넌트에서 직접 처리할 수 있다.
useMutation({
mutationFn: submitPayment,
meta: {
skipGlobalErrorToast: true,
},
onError: (error) => {
// 결제 실패는 모달로 상세하게 안내
openPaymentErrorModal(error);
},
});
조금 더 나아가면 이런 구조가 좋다.
서버는
{
"code": "USER_NOT_AUTHORIZED",
"message": "User does not have access"
}
프론트의 다국어 파일은
{
"errors": {
"USER_NOT_AUTHORIZED": "이 기능에 대한 접근 권한이 없습니다.",
"DUPLICATE_EMAIL": "이미 사용 중인 이메일입니다.",
"INVALID_INPUT": "입력값을 확인해 주세요."
}
}
프론트에서는
t(`errors.${code}`);
여기서 가장 중요한 것은
code만 관리한다.code에 매핑된 번역이 없으면 fallback 메시지를 표시한다.프론트에서는 에러 처리를 초반에 깊게 배우지 않는 경우가 많다.
그래서 대부분 "그때그때 처리"로 흘러간다.
하지만 에러 처리도 결국 아키텍처 문제다.
한 번 구조를 잡아두면 이후 개발 속도와 안정성이 확연히 달라진다.
에러 처리는 귀찮다.
하지만 구조를 만들어두면, 그 귀찮음은 한 번으로 끝난다.