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이면 항상 같은 문구 |
| status | HTTP 상태 코드 (응답의 상태 코드와 같게) |
| 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, 비밀번호 123 | 422, errors에 두 필드 |
| 이미 가입된 이메일 | 409, code: "email_taken" |
| 깨진 JSON | 400, 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가 늘어나도 프론트엔드의 에러 처리 코드는 늘어나지 않는다.