---
id: api-error-format-rfc7807
title: API Error Format — RFC 7807 Problem Details
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [api, error, rfc7807, vibe-coding]
tech_stack: { language: "TS", applicable_to: ["Backend"] }
applied_in: []
aliases: [RFC 7807, problem details, error envelope, error code, validation errors]
---

# API Error Format — RFC 7807

> 모든 endpoint 일관된 error 형식. **`application/problem+json`** + type / title / status / detail / extension. 사용자에 명확 + 디버깅 traceId.

## 📖 핵심 개념
- type: error 종류 URI (문서 link).
- title: 짧은 요약.
- status: HTTP code.
- detail: 사람용 메시지.
- instance: 이 문제의 unique URI (선택).
- extensions: 추가 필드 (errors, traceId).

## 💻 코드 패턴

### 표준 응답
```ts
res.status(400)
   .type('application/problem+json')
   .json({
     type: 'https://api.acme.com/errors/validation',
     title: 'Validation failed',
     status: 400,
     detail: 'One or more fields are invalid',
     instance: `/orders`,
     errors: [
       { path: 'email', message: 'invalid format', code: 'invalid_email' },
       { path: 'age', message: 'must be positive', code: 'min_value' },
     ],
     traceId: req.headers['x-request-id'],
   });
```

### Error class hierarchy
```ts
abstract class ApiError extends Error {
  abstract readonly status: number;
  abstract readonly type: string;
  abstract readonly title: string;

  toProblem(req: Request) {
    return {
      type: this.type,
      title: this.title,
      status: this.status,
      detail: this.message,
      instance: req.originalUrl,
      traceId: req.headers['x-request-id'],
    };
  }
}

class ValidationError extends ApiError {
  readonly status = 400;
  readonly type = 'https://api.acme.com/errors/validation';
  readonly title = 'Validation failed';
  constructor(public readonly errors: { path: string; message: string }[]) {
    super('validation');
  }
  override toProblem(req: Request) {
    return { ...super.toProblem(req), errors: this.errors };
  }
}

class NotFoundError extends ApiError {
  readonly status = 404;
  readonly type = 'https://api.acme.com/errors/not-found';
  readonly title = 'Not found';
}

class ConflictError extends ApiError {
  readonly status = 409;
  readonly type = 'https://api.acme.com/errors/conflict';
  readonly title = 'Conflict';
}

class UnauthorizedError extends ApiError {
  readonly status = 401;
  readonly type = 'https://api.acme.com/errors/unauthorized';
  readonly title = 'Unauthorized';
}
```

### Error middleware (Express)
```ts
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
  if (err instanceof ApiError) {
    return res.status(err.status)
              .type('application/problem+json')
              .json(err.toProblem(req));
  }
  
  if (err instanceof ZodError) {
    const valErr = new ValidationError(
      err.errors.map(e => ({ path: e.path.join('.'), message: e.message }))
    );
    return res.status(400)
              .type('application/problem+json')
              .json(valErr.toProblem(req));
  }

  // Unknown — 500
  log.error('unhandled', err);
  res.status(500)
     .type('application/problem+json')
     .json({
       type: 'https://api.acme.com/errors/internal',
       title: 'Internal server error',
       status: 500,
       traceId: req.headers['x-request-id'],
     });
});
```

### 사용
```ts
app.get('/orders/:id', async (req, res) => {
  const order = await findOrder(req.params.id);
  if (!order) throw new NotFoundError(`order ${req.params.id}`);
  res.json(order);
});

app.post('/orders', async (req, res) => {
  const data = OrderSchema.parse(req.body); // Zod throws → middleware 변환
  ...
});
```

### Client side type
```ts
interface Problem {
  type: string;
  title: string;
  status: number;
  detail?: string;
  instance?: string;
  traceId?: string;
  [key: string]: unknown;
}

interface ValidationProblem extends Problem {
  errors: { path: string; message: string; code?: string }[];
}

async function fetchOrders() {
  const r = await fetch('/orders');
  if (!r.ok) {
    const problem: Problem = await r.json();
    if (r.status === 400 && 'errors' in problem) {
      return setFieldErrors((problem as ValidationProblem).errors);
    }
    throw new Error(problem.title);
  }
  return r.json();
}
```

### Error code (machine-readable)
```ts
{
  type: 'https://api.acme.com/errors/insufficient-funds',
  title: 'Insufficient funds',
  status: 402,
  detail: 'Account balance is too low',
  errors: [
    { code: 'insufficient_balance', current: '50.00', required: '100.00' }
  ],
}
```

→ Client 가 type / code 로 분기.

### i18n (사용자 메시지)
```ts
{
  type: '...',
  title: i18n.t('errors.notFound.title'),
  detail: i18n.t('errors.notFound.detail', { resource: 'order' }),
}
```

서버는 일반 / fallback. Client 가 code 보고 자체 번역도 OK.

### 보안 (정보 누출)
```ts
// ❌ Detail 에 stack / SQL / internal info
{ detail: 'duplicate key value violates unique constraint "users_email_key"' }

// ✅
{ detail: 'Email already in use' }
```

→ Internal 은 log 로, 사용자에는 generic.

### Retry hint
```ts
// 503 / 429 시
{
  type: '...',
  title: 'Service unavailable',
  status: 503,
  retryAfter: 30,  // seconds
}
```

또는 `Retry-After` 헤더.

## 🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| Public API | RFC 7807 항상 |
| Internal / GraphQL | 표준 X — GraphQL errors |
| Validation | extensions: errors[] |
| Multi-error | 같이 한 응답 |
| Auth 실패 | 401 + WWW-Authenticate |
| Rate limit | 429 + Retry-After |

## ❌ 안티패턴
- **모든 에러 200 + `{success: false}`**: status code 의미 잃음.
- **Stack trace 노출**: 보안. internal log.
- **Error message i18n 가정 + 중요 비즈니스 분기**: code 사용.
- **Detail 가 SQL / internal class name**: leak.
- **Field error 없이 "validation failed"**: 어떤 field 모름.
- **Type URI 가 dead link**: 문서 hosting.
- **Inconsistent (어떤 endpoint 는 다른 형식)**: middleware 통일.

## 🤖 LLM 활용 힌트
- ApiError class hierarchy + middleware = 통일.
- Zod / 검증 → ValidationError 자동 변환.
- type URI = 문서 link.

## 🔗 관련 문서
- [[API_REST_Best_Practices]]
- [[API_OpenAPI_Spec]]
- [[Security_OWASP_Top_10_Practical]]
