백엔드 에러 메시지가 화면에 그대로 나왔다 - i18n 키 매핑과 fallback으로 에러 UI 설계하기

백엔드 에러 메시지가 화면에 그대로 나왔다 - i18n 키 매핑과 fallback으로 에러 UI 설계하기

March 13, 2026

이 메시지를 사용자가 봐도 되는 건가?

이전 글에서 인터셉터를 정비해 모든 에러 경로가 ApiError를 반환하도록 통일했습니다.
이제 error.detail을 안심하고 쓸 수 있게 되었는데, 막상 화면에 렌더링해보니 문제가 생겼어요.

운영 서버에서 버그를 제보받고 디버깅을 하던 중, API 요청이 실패했을 때 화면에 표시되는 메시지를 마주쳤습니다.

예기치 않은 서버 오류가 발생했습니다: (sqlalcheny....

백엔드가 응답 body의 detail 필드에 내려주는 메시지가 그대로 렌더링되고 있었어요.
디버깅하기에는 어떤 에러인지 한눈에 보여서 편했지만, 문득 이걸 사용자가 봐도 되는 건가 하는 생각이 들었습니다.

서버 개발자와 논의해봤습니다.
detail 메시지는 개발자를 위한 디버깅 정보이지, 사용자에게 보여줄 용도로 작성된 것이 아니라는 결론이었어요.
그리고 생각해보니 단순히 “보기 안 좋다”는 문제만이 아니었습니다.

  • 사용자가 이해할 수 없는 기술 용어와 내부 스택 정보가 그대로 노출됩니다
  • 내부 경로나 클래스명 같은 구현 정보가 드러날 수 있어요
  • 백엔드 메시지는 다국어 대응이 되지 않습니다

그래서 프론트엔드가 HTTP 상태 코드를 기준으로 사용자 친화적인 메시지를 직접 관리하기로 했어요.
에러가 났을 때 사용자의 다음 행동을 유도할 수 있도록, 401이면 “로그인이 필요합니다”, 403이면 “접근 권한이 없습니다”처럼요.

조건문 분기의 한계, i18n 키 매핑으로

가장 먼저 떠오르는 방법은 조건문 분기입니다.

// 상태 코드가 늘어날 때마다 코드 수정 필요
if (error.status === 400) return '잘못된 요청입니다'; 
if (error.status === 401) return '인증이 필요합니다'; 
if (error.status === 403) return '접근 권한이 없습니다'; 

프로젝트에 이미 i18next가 도입되어 있었기 때문에, 조건문 대신 상태 코드를 i18n JSON의 키로 매핑하는 구조를 선택했어요.
조건문 분기와 비교했을 때 이점이 명확했습니다.

  • 상태 코드 추가 시 컴포넌트 코드를 수정할 필요 없이 JSON에 키-값만 추가하면 됩니다
  • 다국어 지원은 언어별 JSON 파일에 같은 키 구조를 복제하면 돼요
  • 메시지 수정 시 컴포넌트 코드를 건드리지 않아도 됩니다
{
  "http-error": {
    "title": {
      "400": "잘못된 요청입니다",
      "401": "인증이 필요합니다",
      "404": "페이지를 찾을 수 없습니다"
    },
    "desc": {
      "400": "요청 형식이 올바르지 않습니다. 입력 내용을 확인한 후 다시 시도해주세요.",
      "401": "로그인이 필요한 서비스입니다. 로그인 후 다시 시도해주세요."
    }
  }
}

컴포넌트에서는 동적 키 조회 한 줄로 해결됩니다.

const title = t(`http-error.title.${status}`); 

모든 상태 코드를 정의하지 않는다, 그래서 fallback이 필요하다

HTTP 상태 코드는 수십 개가 존재하지만, 저는 전부 개별 메시지를 정의하지 않았어요.
이 에러 화면의 역할은 디버깅 도구가 아니라, 사용자에게 에러를 인지시키고 다음 행동을 안내하는 것이기 때문입니다.
사용자의 행동이 달라지는 주요 코드(401→로그인, 403→권한 문의, 404→URL 확인)만 개별 메시지를 정의하고, 나머지는 기본 메시지로 충분하다고 판단했습니다.

그런데 일부만 정의하면, 서버가 422나 418 같은 미정의 코드를 내려보낼 때 문제가 생겨요.

Note

i18next는 키를 찾지 못하면 키 경로 자체를 반환하는 것이 기본 동작입니다. 정의하지 않은 상태 코드가 오면 화면에 title.422 같은 문자열이 그대로 렌더링돼요.

사실 i18next의 t() 함수는 배열을 받으면 순서대로 키를 탐색하고, 첫 번째로 존재하는 키의 값을 반환해요.
저는 이 동작을 활용했습니다.

const title = t([`http-error.title.${status}`, 'http-error.title.default']); 
const desc = t([`http-error.desc.${status}`, 'http-error.desc.default']); 

JSON에는 default 키만 추가하면 됩니다.

{
  "http-error": {
    "title": {
      "400": "잘못된 요청입니다",
      "401": "인증이 필요합니다",
      "default": "오류가 발생했습니다"
    },
    "desc": {
      "400": "요청 형식이 올바르지 않습니다. 입력 내용을 확인한 후 다시 시도해주세요.",
      "default": "요청 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요."
    }
  }
}

정의된 코드와 미정의 코드 모두 정상 동작하는지 검증한 결과입니다.

시나리오statustitle 결과desc 결과
정의된 코드400”잘못된 요청입니다""요청 형식이 올바르지 않습니다…”
정의된 코드500”서버 오류가 발생했습니다""요청을 처리하는 중 문제가…”
미정의 코드422”오류가 발생했습니다” (default)“요청 처리 중 문제가…” (default)
미정의 코드418”오류가 발생했습니다” (default)“요청 처리 중 문제가…” (default)

마무리

백엔드의 detail 메시지를 사용자에게 그대로 보여주는 대신, HTTP 상태 코드를 기준으로 프론트엔드가 메시지를 관리하는 구조로 전환했습니다.
i18n JSON에 상태 코드를 키로 매핑하고, 배열 키 fallback으로 미정의 코드까지 안전하게 처리했어요.
새 상태 코드에 대응하려면 JSON에 키-값 한 쌍만 추가하면 되고, 컴포넌트 코드는 수정할 필요가 없습니다.

물론 이 구조에도 한계는 있어요.
같은 400이라도 맥락에 따라 다른 메시지가 필요한 경우, 상태 코드만으로는 분기할 수 없습니다.
서버가 HTTP 상태 코드 외에 커스텀 에러 코드를 사용한다면 별도 매핑 체계가 필요하고요.
현재로서는 상태 코드 기반 분기로 충분하지만, 에러 케이스가 세분화되면 확장 방법을 고민하게 될 것 같습니다.

한편, 에러 메시지를 아무리 잘 만들어도 그걸 보여줄 ErrorBoundary가 없으면 소용이 없다는 걸 곧 알게 됐어요. 다음 글에서 다루겠습니다.

참고: i18next 공식 문서 - Fallback

On this page