제가 일했던 모든 팀은 결국 자기들만의 API 응답 포맷을 발명합니다. 처음에는 무해해 보이죠 — 여기 작은 래퍼 객체 하나, 저기 커스텀 에러 모양 하나 — 그러다 6개월 뒤에는 네 번째 버전의 에러 파싱 미들웨어를 쓰고 있고 코드 리뷰에서 data.userdata.result.user 중 어느 쪽이 "올바른" 경로인지 논쟁하고 있습니다. 이 모든 것을 해결하는 보편적 표준은 없지만, 프로덕션에서 버티는 패턴과 확실히 되물어 무는 안티 패턴은 있습니다. 실제로 설계 문서에 넣을 만한 내용을 여기 적어둡니다.

일관된 성공 응답

모든 팀이 토론하는 첫 번째 질문: 모든 응답을 {"status": "ok", "data": {...}} 같은 엔벨로프로 감싸야 할까? 솔직한 답은 — 기본적으로는 아마 아닙니다. 엔벨로프는 HTTP 상태 코드가 프록시와 모바일 네트워크 전반에서 항상 신뢰할 수 없던 2000년대 초반에 더 말이 됐습니다. 오늘날에는 리소스 자체가 스스로를 말하게 두는 평탄한 응답이 거의 항상 더 깔끔합니다. 엔벨로프는 부분 성공하는 벌크 작업처럼 진짜 혼합 페이로드를 반환하는 엔드포인트에 남겨두세요.

json
// ✅ Good — flat, direct, the order IS the response
// GET /v1/orders/ord_9kZ2m
{
  "id": "ord_9kZ2m",
  "status": "fulfilled",
  "customer_id": "cus_4xA1p",
  "total_amount": 149.99,
  "currency": "USD",
  "created_at": "2026-03-15T11:42:00Z",
  "line_items": [
    { "sku": "HDPHN-BLK-XM5", "quantity": 1, "unit_price": 149.99 }
  ]
}

// ❌ Avoid — unnecessary envelope adds a layer clients have to unwrap every time
{
  "status": "success",
  "code": 200,
  "data": {
    "order": {
      "id": "ord_9kZ2m"
    }
  }
}

감싸는 게 말이 되는 때는 리소스 자체의 일부가 아닌 메타데이터를 같이 담아야 할 때입니다 — 페이지네이션 커서, 트레이싱을 위한 요청 ID, 벌크 엔드포인트의 부분 실패 요약. 단순한 GET /orders/:id에는 주문이 곧 응답입니다. response.id가 그냥 되는데 클라이언트에게 response.data.order.id를 쓰게 하지 마세요. 참조할 스펙을 원한다면, JSON:API는 엔벨로프를 언제 어떻게 쓸지 정확히 정의한 의견 있는 잘 고민된 표준입니다 — 통째로 채택하지 않더라도 읽어볼 만합니다.

에러 응답 — RFC 7807 Problem Details 사용

커스텀 에러 모양은 가장 흔한 통합 고통의 원천 중 하나입니다. 모든 API는 결국 약간씩 다른 것을 갖게 됩니다 — {"error": "..."}, {"message": "...", "code": 42}, {"errors": [...]} — 그리고 여러분의 API를 소비하는 모든 클라이언트가 전용 에러 파싱 로직을 써야 합니다. IETF가 RFC 7807 — Problem Details for HTTP APIs로 이것을 해결했습니다. 에러에 대한 일관된 JSON 구조를 정의하고, Content-Typeapplication/problem+json으로 갖는 가벼운 표준입니다. 이를 채택하면 여러분의 에러 포맷은 어떤 개발자든 문서를 찾지 않고도 읽을 수 있는 것이 됩니다.

json
// POST /v1/orders — 422 Unprocessable Entity
// Content-Type: application/problem+json
{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Validation Failed",
  "status": 422,
  "detail": "The order could not be created because one or more fields are invalid.",
  "instance": "/v1/orders/requests/req_7bN3k",
  "errors": [
    {
      "field": "line_items[0].quantity",
      "message": "Quantity must be a positive integer."
    },
    {
      "field": "shipping_address.postal_code",
      "message": "Postal code is required for US shipments."
    }
  ]
}
  • 예측 가능한 파싱: 클라이언트는 사람이 읽는 메시지(detail), 기계가 읽는 카테고리(type), 본문에 미러링된 HTTP 상태(status)가 어디에 있는지 항상 알게 됩니다.
  • 설계상 확장 가능: 스펙은 필드 레벨 검증 디테일을 위한 errors 같은 추가 필드를 명시적으로 허용합니다 — 우회하는 게 아닙니다.
  • 툴링 지원: OpenAPI 3.x는 application/problem+json을 응답 콘텐츠 타입으로 지원하므로, 생성된 문서와 클라이언트 SDK가 이 형태를 네이티브로 이해합니다.
  • type URI는 단순한 문자열이 아니라 문서입니다: 그것을 에러를 설명하는 실제 페이지로 향하게 하면, 지원 티켓을 셀프 서비스 답변으로 방금 대체한 겁니다.

HTTP 상태 코드 + JSON 본문 함께

상태 코드와 JSON 본문은 중복이 아닙니다 — 서로 다른 역할을 합니다. 상태 코드는 HTTP 레이어(프록시, 캐시, 브라우저, 모니터링 도구)에 무슨 일이 일어났는지 알려줍니다. JSON 본문은 애플리케이션 레이어에 알려줍니다. 둘 다 정확해야 합니다. MDN HTTP 상태 참조가 어느 코드가 맞는지 논쟁을 해결하는 가장 빠른 방법입니다. 팀을 가장 자주 헷갈리게 하는 것은 400 vs 422 (둘 다 클라이언트 에러지만, 422는 구체적으로 구문은 유효했고 서버가 이해했다는 뜻 — 의미론이 틀렸다는 것), 그리고 401 vs 403 (401은 "누구세요?", 403은 "당신이 누군지는 알지만 — 이 작업은 못 합니다")입니다.

json
// 400 Bad Request — malformed JSON or missing required field at the HTTP level
{
  "type": "https://api.example.com/problems/bad-request",
  "title": "Bad Request",
  "status": 400,
  "detail": "Request body is not valid JSON."
}

// 422 Unprocessable Entity — valid JSON, but business rules rejected it
{
  "type": "https://api.example.com/problems/insufficient-inventory",
  "title": "Insufficient Inventory",
  "status": 422,
  "detail": "HDPHN-BLK-XM5 has 0 units available; requested 2.",
  "instance": "/v1/orders/requests/req_7bN3k"
}

// 404 Not Found — resource doesn't exist (or you don't want to reveal it does)
{
  "type": "https://api.example.com/problems/not-found",
  "title": "Order Not Found",
  "status": 404,
  "detail": "No order with ID ord_XXXXX exists in this account."
}
  • 200 OK — 본문을 반환하는 성공적인 GET, PUT, PATCH
  • 201 Created — 리소스를 생성한 성공적인 POST; 새 리소스를 가리키는 Location 헤더 포함
  • 204 No Content — 응답 본문 없는 성공적인 DELETE 또는 액션; JSON 불필요
  • 400 Bad Request — 잘못된 요청 구문, 서버가 파싱조차 못함
  • 401 Unauthorized — 인증 자격 증명이 없거나 유효하지 않음
  • 403 Forbidden — 인증은 됐지만 허용되지 않음
  • 404 Not Found — 리소스가 존재하지 않음
  • 409 Conflict — 상태 충돌 (예: 중복 주문, 낙관적 잠금 실패)
  • 422 Unprocessable Entity — 유효한 구문, 의미적/비즈니스 검증 실패
  • 429 Too Many Requests — 레이트 리밋 도달; 항상 Retry-After 헤더 포함
  • 500 Internal Server Error — 서버 측에서 뭔가 깨졌음; 본문에 스택 트레이스를 절대 노출하지 마세요

날짜와 시간 — 항상 ISO 8601

Unix 타임스탬프는 깔끔해 보입니다 — 그냥 숫자니까요. 하지만 함정입니다. 1710499200은 초인가요 밀리초인가요? (둘 다 흔합니다. JavaScript의 Date.now()는 밀리초를, POSIX는 초를 줍니다.) 어느 시간대인가요? 변환기 없이는 로그에서 읽을 수 없습니다. 1970년 이전 날짜를 깔끔하게 표현할 수 없습니다. 그리고 아직 마이그레이션하지 않은 시스템에서는 2038년에 32비트 정수를 오버플로합니다. ISO 8601 문자열이 이 모든 걸 해결합니다. UTC를 쓰고 항상 시간대 오프셋을 포함하세요 — 끝의 Z+00:00 없이 맨몸인 2026-03-15T11:42:00은 모호하고 로컬 시간을 가정하는 클라이언트에서 결국 버그를 냅니다.

json
// ✅ Good — unambiguous, human-readable, timezone-explicit
{
  "created_at": "2026-03-15T11:42:00Z",
  "updated_at": "2026-04-01T08:15:33Z",
  "scheduled_delivery": "2026-03-18T00:00:00Z",
  "expires_at": "2026-04-15T23:59:59Z"
}

// ❌ Avoid — ambiguous, unreadable, seconds vs ms confusion
{
  "created_at": 1710499200,
  "updated_at": 1743494133000,
  "scheduled_delivery": "15/03/2026",
  "expires_at": "April 15, 2026"
}

Null vs 생략된 필드

이 둘은 같지 않으며 혼동하면 엣지 케이스에서만 드러나는 미묘한 버그를 만듭니다. Null은 필드가 존재하고, 서버가 그것을 알고 있고, 현재 값이 "아무것도 아님"임을 뜻합니다 — 아직 배송되지 않은 주문의 fulfilled_at 타임스탬프처럼요. 필드를 완전히 생략한다는 것은 이 맥락에서 적용되지 않음을 뜻합니다 — 반품되지 않은 주문의 return_tracking_number처럼요. 클라이언트가 "fulfilled_at": null을 보면, 그 필드가 이 리소스의 스키마에 포함되어 있고 명시적으로 설정되지 않았음을 알게 됩니다. 필드가 없다면, 클라이언트는 이 응답의 범위 밖이라고 처리해야 합니다 — PATCH로 부분 업데이트할 때 중요합니다. null을 보내는 것은 "이 필드를 지우세요"이고; 생략하는 것은 "건드리지 마세요"입니다.

json
// Order that exists but hasn't shipped yet
// fulfilled_at: null — we know about this field, it's just not set yet
// return_tracking_number: omitted — returns don't apply to this order
{
  "id": "ord_9kZ2m",
  "status": "processing",
  "created_at": "2026-03-15T11:42:00Z",
  "fulfilled_at": null,
  "shipped_at": null,
  "tracking_number": null,
  "total_amount": 149.99
}

// PATCH /v1/orders/ord_9kZ2m — cancel the scheduled delivery
// Only include fields you want to change
{
  "scheduled_delivery": null,
  "status": "cancelled"
}
// "total_amount" is omitted — we're NOT zeroing it out, just not touching it

페이지네이션 — 오프셋보다 커서

오프셋 페이지네이션(?page=3&per_page=20)은 구현이 직관적이고 설명이 쉽지만, 라이브 데이터에서는 조용히 깨집니다. 클라이언트가 페이지 2와 페이지 3 사이에 페이지네이션 중일 때 레코드가 삽입되면 — 그들은 항목을 건너뛰게 됩니다. 레코드가 삭제되면 중복을 보게 됩니다. 자주 변하는 어떤 데이터셋(주문, 이벤트, 알림)에든 커서 기반 페이지네이션이 올바른 기본값입니다. 클라이언트에게 결과 집합 내 위치를 나타내는 불투명한 커서(보통 base64 인코딩된 ID나 타임스탬프)를 줍니다. 다음 페이지는 그 정확한 지점에서 시작하며, 삽입이나 삭제와 무관합니다. 오프셋 페이지네이션은 데이터셋이 안정적이고 사용자가 진짜로 47페이지로 점프해야 하는 관리 UI에는 괜찮습니다. 무한 스크롤 하는 어떤 모바일 클라이언트에도 괜찮지 않습니다.

json
// GET /v1/orders?limit=20&cursor=eyJpZCI6Im9yZF85a1oybSJ9
{
  "orders": [
    { "id": "ord_9kZ2m", "status": "fulfilled", "total_amount": 149.99, "created_at": "2026-03-15T11:42:00Z" },
    { "id": "ord_8jY1l", "status": "processing", "total_amount": 89.00, "created_at": "2026-03-14T09:10:00Z" }
  ],
  "pagination": {
    "next_cursor": "eyJpZCI6Im9yZF84alk1bCJ9",
    "has_more": true,
    "limit": 20
  }
}

// When has_more is false, omit next_cursor entirely (or set to null)
// Clients: fetch next page with ?cursor=<next_cursor> until has_more === false

필드 네이밍 — snake_case vs camelCase

한 가지 컨벤션을 골라 린터로 강제하세요. 실제 선택은 일관성보다 덜 중요합니다. 그래도 말하자면: 주요 소비자가 JavaScript/TypeScript 클라이언트라면 camelCase가 디스트럭처링과 객체 스프레드에 깔끔하게 맞습니다. 주요 소비자가 Python이나 Ruby 백엔드라면 snake_case가 자연스럽게 느껴집니다. 둘 다 서빙한다면, 실용적 해결책은 컨벤션을 문서화하고 클라이언트가 변환 레이어를 쓰게 두는 것입니다 — 리바이버와 함께하는 JSON.parse, Python humps 라이브러리, 또는 프레임워크의 단일 직렬화 설정. 절대 해서는 안 되는 것은 같은 API에서 컨벤션을 섞는 것입니다 — customerIdorder_total 옆에 있다면 서로 이야기하지 않은 다른 엔지니어들이 다른 엔드포인트를 썼다는 신호입니다. 엔드포인트 전반에 걸쳐 필드 이름을 일관되게 문서화하려면 JSON Schema Generator를 쓰세요.

설계 팁: 실제 API 응답을 설계하거나 디버깅할 때, JSON을 JSON Formatter에 붙여넣으세요 — 압축된 응답을 보기 좋게 찍고, 구조를 강조하고, 네이밍 불일치가 클라이언트 SDK로 들어가기 전에 한눈에 볼 수 있게 해줍니다.

버전 관리

두 학파가 있습니다: URL 버전(/v1/orders, /v2/orders)과 헤더 버전 (Accept: application/vnd.example.v2+json 또는 커스텀 API-Version: 2026-03-15 헤더). 실무에서는 거의 매번 URL 버전이 이깁니다. 헤더를 파싱하지 않고도 로그에서 보이고, 설정 없이 모든 HTTP 클라이언트와 동작하고, 브라우저에서 테스트할 수 있고, 간단한 경로 접두사 규칙으로 같은 게이트웨이에서 v1과 v2를 나란히 돌릴 수 있습니다. 헤더 버전은 IANA 미디어 타입 모델에 따르면 이론적으로는 더 RESTful하지만, 눈에 보이지 않는 복잡성을 만듭니다 — URL에서 동일해 보이는 요청이 대부분의 개발자가 먼저 확인하지 않는 헤더에 따라 실제로 다르게 동작합니다. Stripe의 날짜 기반 버전 (Stripe-Version: 2024-06-20)은 대형 플랫폼에는 두 세계의 장점이지만, 그것은 첫 번째 버전 스키마를 고르는 것과는 다른 문제입니다. 무엇을 선택하든, 첫날부터 버전을 매기세요. 프로덕션의 버전 없는 API에 버전을 끼워 넣는 것은 고통스럽고 깔끔하게 끝나는 경우가 드뭅니다. 마이그레이션 테스트 중 두 API 버전의 응답이 구조적으로 건강한지 확인하려면 JSON Validator를 쓰세요.

마무리

이 중 어느 것도 혁신적이지 않습니다 — 하지만 그게 핵심입니다. API 설계에서 가장 고생하는 팀은 기술적으로 틀린 선택을 한 팀이 아닙니다. 엔드포인트마다 다른 선택을 하고 그걸 적어두지 않은 팀입니다. 평탄한 성공 응답. RFC 7807 에러 본문. ISO 8601 날짜. 라이브 데이터에는 커서 페이지네이션. "알려진 비어있음"에는 null, "적용 안 됨"에는 생략. 첫날부터 URL 버전. 이 패턴들은 완벽하지 않지만 예측 가능합니다 — 그리고 예측 가능성이야말로 API를 리버스 엔지니어링할 퍼즐이 아니라 통합하기 즐거운 것으로 만듭니다. 정식 JSON 명세는 스펙 수준의 논쟁을 정리해야 한다면 RFC 8259에 있습니다. 그 위의 모든 것에 대해, 최선의 표준은 여러분 팀이 실제로 적어두고 일관되게 따르는 것입니다.