프론트는 에러 전처리가 불가능할까?

상만·2026년 3월 3일
post-thumbnail

프론트 개발을 하다 보면 항상 느끼는게 있다.

아 에러 처리하기 ㅈㄴ 귀찮다.

API 붙이고, 성공 케이스 잘 나오면 기분 좋다.
그런데 아래 케이스까지 고려하려는 순간 갑자기 개발을 때려치우고 싶어진다.

  • 401?
  • 403?
  • 500?
  • 서버 메시지 그대로 보여줄까?
  • 내가 가공할까?
  • 다국어는?

이걸 초반에 정리하지 않으면 프로젝트가 커질수록 에러 처리는 점점 지저분해진다.


서버 에러 메시지 그대로 쓰면 안 될까?

가장 쉬운 방법은 이거다.

toast.error(error.response.data.message);

끝. 편하다.
그런데 여기서 바로 문제가 생긴다.

1. 다국어 문제

한국어 서비스인데 서버 에러는 영어라면?

글로벌 서비스인데 서버 에러가 한국어라면?
서버 메시지가 바뀌면?

영어는 어느 정도 읽을 수 있어도,
한국어는 외국인에게 만국 공통어가 아니다.

한국어 만국공통어 기원 1일차

서버 메시지를 그대로 노출하는 방식은 다국어 환경에서 구조적으로 불안하다.

2. 사용자 친화적이지 않다

예를 들어 서버에서 이런 메시지가 내려온다고 하자.

Forbidden

개발자는 바로 이해한다.

"아! 이 계정은 접근 권한이 없구나."

하지만 일반 사용자 입장에서 이 메시지는 설명이 아니다.
그냥 갑자기 영어 단어 하나 튀어나온 것에 불과하다.

서버 메시지는 디버깅 중심이다.
하지만, 사용자 메시지는 맥락 중심이어야 한다.
이 둘은 목적이 다르다.


그러면 서버에는 이런게 없을까?

백엔드 영역에서는 예외 처리, 에러 포맷 통일, 에러 코드 정의 등이 거의 기본이다.

  • 에러 객체 표준화
  • HTTP Status 기반 분기
  • 커스텀 예외 클래스
  • 글로벌 예외 핸들러

반면 프론트는?
보통은 이런 패턴이 많다.

  • Error Boundary로 에러 페이지 이동
  • axios 인터셉터에서 일부 분기
  • 특정 훅에서 onError로 하드코딩 처리

문제는 일관성이다.

에러 페이지로 보내는 게 답일까?

특정 API 하나 실패했다고 전체 페이지를 날려버리는 건 UX적으로 좋지 않다.

  • 모달 안에서 실패
  • 폼 제출 실패
  • 일부 위젯 데이터 실패

이런 경우까지 전부 에러 페이지로 보내는 건 과하다.
에러는 국소적으로 처리해야 한다.

그러면 프론트에선 어떻게 하는게 좋을까?

이를 해결하기 위해 많이 고민했다.

가장 흔한 접근은, 401이나 403 같은 권한 관련 에러와 5xx 같은 서버 에러
axios 인터셉터에서 전역으로 처리하는 방식이다.

하지만 그 외의 에러들은? 보통은 서버 메시지를 그대로 보여주거나,
특정 상황마다 하드코딩으로 처리하는 경우가 대부분이다.

에러 처리의 레이어 분리

내가 선택한 방향은 이렇다.

  1. axios 인터셉터: 401, 403, 5xx 등 HTTP 레벨 에러를 전역 처리한다.
  2. MutationCache / QueryCache: 비즈니스 로직 에러를 전역에서 토스트로 처리한다.
  3. 개별 컴포넌트: 특수한 UX가 필요한 경우에만 직접 처리한다.

이렇게 레이어를 나누면, 인터셉터에서 이미 처리된 에러(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;
};

Toast Bridge 구현

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 전역 에러 처리

이제 핵심인 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);
    },
  });

TanStack Query meta 타입 확장

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;
    };
  }
}

QueryClient에 주입

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(),
    })
);

이 구조의 장점

그러면 이렇게 구조를 잡았을때의 장점은 뭘까?

1. 훅이 짧아진다

보통 프론트에서 mutation을 사용하여 에러 처리를 할때 다음과 같이 처리한다.

useMutation({
  mutationFn: saveUserProfile,
  onError: (error) => {
    ...
    toast.error("저장에 실패했습니다.");
    ...
  },
});

MutationCache를 활용하면?

useMutation({
  mutationFn: saveUserProfile,
  meta: {
    errorMessage: "저장에 실패했습니다.",
    successMessage: "저장되었습니다.",
  },
});

onError, onSuccess 콜백이 사라지고, 선언적으로 메시지만 지정하면 된다.
코드 밀도가 낮아지고 가독성이 올라간다.

2. 에러 레이어가 분리된다

이전엔, 이게 서버 통신 에러인지, 프레임워크 에러인지 일일히 디버깅을 해봐야 했지만, 이제는

  • axios 인터셉터: HTTP 레벨 에러 (401 → 로그인, 403 → 권한 안내, 5xx → 서버 점검 안내)
  • MutationCache: 비즈니스 로직 에러 (유효성 실패, 중복 데이터 등)
  • 개별 컴포넌트: 특수 UX (인라인 에러, 필드별 에러 표시 등)

이런 식으로 에러 레이어가 명확히 분리된다.

3. 특수한 경우는 개별 처리 가능

전역 처리가 적합하지 않은 경우, 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만 관리한다.
  • 에러 메시지 같은 UX 영역은 프론트가 책임진다.
  • code에 매핑된 번역이 없으면 fallback 메시지를 표시한다.

마무리

프론트에서는 에러 처리를 초반에 깊게 배우지 않는 경우가 많다.
그래서 대부분 "그때그때 처리"로 흘러간다.

하지만 에러 처리도 결국 아키텍처 문제다.

  • 전역화할 것인가?
  • 레이어를 분리할 것인가?
  • 서버 메시지를 어디까지 신뢰할 것인가?
  • 다국어는 어디에서 책임질 것인가?

한 번 구조를 잡아두면 이후 개발 속도와 안정성이 확연히 달라진다.

에러 처리는 귀찮다.
하지만 구조를 만들어두면, 그 귀찮음은 한 번으로 끝난다.

profile
20살 프론트엔드 개발자

0개의 댓글