API 에러 응답 형식 설계하기: 제각각인 에러 응답을 RFC 9457 Problem Details로 통일하는 법

어떤 API는 문자열을, 어떤 API는 { error }를, 어떤 API는 { message, code }를 돌려준다면 프론트엔드는 에러마다 다른 코드를 써야 한다. 좋은 에러 응답의 조건과 표준 형식인 RFC 9457 Problem Details, 필드별 검증 에러, 내부 정보 숨기기, Node.js로 한 곳에서 처리하는 방법까지 정리했다.

에러마다 모양이 다르다면

한 서비스의 API들이 이런 에러를 돌려준다고 해보자.

// 로그인 API
"비밀번호가 틀렸습니다"

// 회원가입 API
{ "error": "이메일 중복" }

// 게시글 API
{ "success": false, "msg": "권한 없음", "errCode": 1003 }

// 결제 API
{ "message": "Error: Cannot read properties of undefined (reading 'card')" }

프론트엔드는 API마다 에러를 꺼내는 코드를 따로 써야 하고, 어떤 에러는 사용자에게 보여줄 수 없는 내부 메시지다. HTTP 상태 코드로 "어떤 종류의 실패인지"를 알렸다면, 이제 본문으로 "무엇이, 왜, 어떻게 실패했는지"를 일관된 모양으로 알려줄 차례다.

좋은 에러 응답은 모든 API에서 같은 모양이고, 프로그램이 분기할 수 있는 코드와 사람이 읽을 메시지를 함께 담고, 내부 정보는 담지 않는다.


좋은 에러 응답의 조건

조건왜 필요한가예시
모든 API에서 같은 구조프론트엔드가 에러 처리 코드 하나로 모든 API를 다룰 수 있다항상 { type, title, status, detail, ... }
기계가 읽을 코드메시지 문구가 바뀌어도 분기 로직이 깨지지 않는다"code": "email_taken"
사람이 읽을 메시지개발자 디버깅, 그대로 보여줘도 되는 안내"detail": "이미 가입된 이메일입니다."
필드별 에러 목록폼에서 어느 입력칸이 틀렸는지 표시할 수 있다"errors": [{ "field": "email", ... }]
추적 ID사용자 문의가 오면 서버 로그에서 바로 찾을 수 있다"traceId": "8f2c..."
내부 정보 없음스택 트레이스, SQL, 내부 IP는 공격의 단서가 된다❌ "Cannot read properties of undefined"

프론트엔드가 메시지 문자열로 분기하게 만들면 안 된다. if (error.message === "이미 가입된 이메일입니다") 같은 코드는 문구를 다듬거나 번역하는 순간 깨진다. 분기는 항상 변하지 않는 code로 하자.


표준이 있다: RFC 9457 Problem Details

에러 응답 형식을 처음부터 고민할 필요는 없다. HTTP API의 에러 형식을 정한 표준인 RFC 9457 (Problem Details for HTTP APIs) 이 있다. 2023년에 이전 버전인 RFC 7807을 대체했다.

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/email_taken",
  "title": "이미 사용 중인 이메일입니다",
  "status": 409,
  "detail": "taken@example.com은(는) 이미 가입되어 있습니다.",
  "instance": "/users"
}
필드의미
type에러 종류를 식별하는 URI. 그 주소에 에러 설명 문서를 두면 좋다 (없으면 "about:blank")
title에러 종류의 짧은 요약. 같은 type이면 항상 같은 문구
statusHTTP 상태 코드 (응답의 상태 코드와 같게)
detail이번 발생 건에 대한 구체적인 설명
instance문제가 발생한 요청 경로나 식별자

Content-Type은 application/problem+json을 쓴다. 표준 필드 외에 확장 필드를 자유롭게 추가할 수 있어서, code, errors, traceId 같은 필드를 더해도 된다.

Spring(Java)의 ProblemDetail, ASP.NET Core의 ProblemDetails처럼 주요 프레임워크가 이 형식을 기본으로 지원한다. 사내 규칙을 새로 만드는 것보다 표준을 따르면 다른 팀이나 외부 개발자도 바로 이해한다.


검증 에러: 필드별로 알려주기

폼 검증 실패는 여러 필드가 동시에 틀릴 수 있다. 첫 번째 에러 하나만 돌려주면, 사용자는 고치고 제출하고 또 다른 에러를 보는 일을 반복하게 된다. 틀린 필드를 한 번에 알려주자.

{
  "type": "https://api.example.com/errors/validation_failed",
  "title": "입력값이 올바르지 않습니다",
  "status": 422,
  "detail": "2개 필드를 확인해주세요.",
  "instance": "/users",
  "code": "validation_failed",
  "errors": [
    { "field": "email", "code": "invalid_format", "message": "이메일 형식이 올바르지 않습니다." },
    { "field": "password", "code": "too_short", "message": "비밀번호는 8자 이상이어야 합니다." }
  ]
}

프론트엔드는 errors를 돌면서 각 입력칸 아래에 메시지를 표시하면 된다. 다국어 서비스라면 message 대신 code(too_short)를 번역 키로 쓰면 된다.


서버에서 한 곳에서 처리하기 (Node.js)

에러 응답을 API마다 직접 만들면 결국 모양이 제각각이 된다. 에러 클래스 하나와 에러 응답 함수 하나를 두고, 모든 에러가 그곳을 거치게 하자.

import { randomUUID } from "node:crypto";

// 1. 예상한 에러(클라이언트에게 알려줘도 되는 에러)를 표현하는 클래스
class ApiError extends Error {
  constructor(status, code, title, detail, extra = {}) {
    super(detail);
    this.status = status;
    this.code = code;
    this.title = title;
    this.extra = extra;
  }
}

// 2. 모든 에러를 Problem Details 형식으로 바꾸는 한 곳
function sendProblem(req, res, err) {
  const traceId = randomUUID();

  let body;
  if (err instanceof ApiError) {
    body = {
      type: `https://api.example.com/errors/${err.code}`,
      title: err.title,
      status: err.status,
      detail: err.message,
      instance: req.url,
      code: err.code,
      traceId,
      ...err.extra,
    };
  } else {
    // 예상하지 못한 에러: 자세한 내용은 서버 로그에만 남긴다
    console.error(`[${traceId}]`, err);
    body = {
      type: "about:blank",
      title: "Internal Server Error",
      status: 500,
      detail: "요청을 처리하는 중 문제가 발생했습니다.",
      instance: req.url,
      traceId,
    };
  }

  res.statusCode = body.status;
  res.setHeader("Content-Type", "application/problem+json");
  res.end(JSON.stringify(body));
}

비즈니스 로직에서는 상황에 맞는 ApiError를 던지기만 하면 된다.

async function createUser(input) {
  const errors = [];
  if (!isEmail(input.email)) {
    errors.push({ field: "email", code: "invalid_format", message: "이메일 형식이 올바르지 않습니다." });
  }
  if ((input.password ?? "").length < 8) {
    errors.push({ field: "password", code: "too_short", message: "비밀번호는 8자 이상이어야 합니다." });
  }
  if (errors.length > 0) {
    throw new ApiError(422, "validation_failed", "입력값이 올바르지 않습니다",
      `${errors.length}개 필드를 확인해주세요.`, { errors });
  }

  if (await emailExists(input.email)) {
    throw new ApiError(409, "email_taken", "이미 사용 중인 이메일입니다",
      `${input.email}은(는) 이미 가입되어 있습니다.`);
  }
  // ...
}

이 구조로 실제 요청을 보내보면 이렇게 응답한다.

요청응답
이메일 kim, 비밀번호 123422, errors에 두 필드
이미 가입된 이메일409, code: "email_taken"
깨진 JSON400, code: "invalid_json"
DB 연결 실패 같은 예상 못 한 에러500, 일반 안내 + traceId (DB 주소는 서버 로그에만)
정상 요청201 + Location

Express를 쓴다면 sendProblem을 에러 처리 미들웨어((err, req, res, next) => ...)로 등록하면 같은 구조가 된다.


프론트엔드에서 받기

모든 에러가 같은 모양이니, 처리 코드도 하나면 된다.

async function api(url, options) {
  const res = await fetch(url, options);
  if (res.ok) return res.status === 204 ? null : res.json();

  const problem = await res.json().catch(() => ({ status: res.status, title: res.statusText }));
  const error = new Error(problem.detail ?? problem.title);
  error.problem = problem;
  throw error;
}

try {
  await api("/users", { method: "POST", body: JSON.stringify(form) });
} catch (e) {
  const p = e.problem;
  if (p?.code === "validation_failed") {
    p.errors.forEach(({ field, message }) => showFieldError(field, message));
  } else if (p?.code === "email_taken") {
    showFieldError("email", p.detail);
  } else {
    toast(`문제가 발생했습니다. (문의 시 코드: ${p?.traceId ?? "없음"})`);
  }
}

사용자가 고객센터에 traceId를 알려주면, 서버 로그에서 그 요청의 실제 에러를 바로 찾을 수 있다.


정리: 왜 알아두면 좋은가

원칙방법
모든 API에서 같은 모양RFC 9457 Problem Details (application/problem+json)
프로그램이 분기할 기준변하지 않는 code (메시지 문자열로 분기하지 않기)
사람이 읽을 설명title(종류), detail(이번 건)
폼 검증 실패errors 배열로 필드별 한 번에
문의 대응traceId로 서버 로그와 연결
보안예상 못 한 에러는 일반 메시지로, 상세는 로그에만
일관성 유지에러 클래스 + 에러 응답 함수 한 곳

상태 코드가 "어떤 종류의 실패인가"를 알려준다면, 에러 응답 본문은 "그래서 무엇을 하면 되는가"를 알려준다. 형식을 한 번 정해두면 API가 늘어나도 프론트엔드의 에러 처리 코드는 늘어나지 않는다.