본문으로 건너뛰기
jsonbeautifiers
한국어

5년을 버티는 JSON API 응답 형태

고통스러운 API 이전은 거의 전부, 어느 오후에 내려져 첫 연동으로 굳어 버린 형태 결정으로 거슬러 올라갑니다.

이 페이지의 모든 주장은 직접 측정했거나 출처가 있습니다. 둘 다 아닌 경우에는 그렇다고 명시합니다.

누군가의 v1로 나갔고 지금도 나가고 있는 응답이 여기 있습니다.

[
  { "id": 8102, "name": "Ada" },
  { "id": 8103, "name": "Grace" }
]

3년 뒤 그 컬렉션은 페이지네이션이 필요할 만큼 커졌는데, 커서를 놓을 자리가 없습니다. 최상위가 배열이니까요. 감싸면 이미 모든 클라이언트가 파싱하고 있는 타입이 바뀌므로, 팀은 /v2/users를 내놓고 두 개의 코드 경로를 영원히 유지합니다. 그 배열은 쓰일 당시엔 아무 잘못이 없었습니다. 다만 자랄 자리가 없었을 뿐입니다.

이야기는 그게 전부입니다. 응답 설계는 우아함의 문제가 아니라, 어떤 변경이 계속 싸게 유지되느냐의 문제입니다.

엔벨로프냐, 맨 값이냐

엔벨로프란 페이로드를 키 아래에 둔 최상위 객체입니다.

{
  "data": [ { "id": "8102", "name": "Ada" } ],
  "nextCursor": "eyJpZCI6ODEwM30",
  "hasMore": true
}

반대 논리는 실재합니다. 잡음이고, 모든 클라이언트가 .data를 쓰게 됩니다. 찬성 논리는 객체는 확장 가능하고 맨 배열은 그렇지 않다는 것입니다. 나중에 커서, 총계, 지원 중단 안내, 추적 ID를 더해도 이미 있는 것들의 타입은 하나도 바뀌지 않습니다.

제가 하는 방식은 이렇습니다. 컬렉션은 감싸고, 단일 리소스는 맨 객체를 돌려줍니다. 단일 리소스는 이미 객체이므로, 엔벨로프가 줬을 자랄 자리를 처음부터 갖고 있습니다. 컬렉션에 엔벨로프를 씌우는 이유는, 결국 메타데이터가 필요해지는 쪽이 컬렉션이기 때문입니다.

무엇을 고르든 한 번에 고르세요. 엔드포인트의 절반은 감싸고 절반은 맨 값인 상태는 둘 중 어느 쪽으로 통일하는 것보다 나쁩니다. 그리고 data라는 필드 안에 data라는 필드를 넣지 마세요.

되돌릴 수 없는 필드 타입

ID는 문자열입니다. 언제나요. 아직 작은 정수일 때도 그렇습니다. JavaScript에서 JSON 숫자는 IEEE 754 double이므로, 9007199254740991을 넘는 식별자는 도착하는 순간 조용히 반올림되고 여러분이 보고 있는 것은 다른 레코드가 됩니다. 트위터는 2010년 64비트 스노플레이크 ID로 옮기며 이걸 겪었고 id 옆에 id_str을 내보냈으며, 그 패턴이 자리 잡았습니다. 원리는 JSON의 ID가 자꾸 값이 바뀌는 이유에 있습니다. 설계상의 요점은 더 좁습니다. 식별자는 수량이 아닙니다. 더하지도, 산술적으로 정렬하지도, 평균 내지도 않으므로 숫자 타입은 얻는 것이 없고, UUID로 옮기는 날에 대가를 치르게 합니다.

돈은 최소 단위의 정수이거나 십진 문자열입니다. 결코 실수가 아닙니다.

{ "amountMinor": 1005, "currency": "GBP" }
{ "amount": "10.05", "currency": "GBP" }

1.005는 double로 정확히 표현되지 않으므로 JavaScript에서 1.005 * 100100.49999999999999가 되고, 101이 아니라 100으로 반올림됩니다. 표현을 하나 고르고 통화를 그 옆에 두되, 맨 price: 10.05를 스키마에 절대 들이지 마세요. 나중에 빼낸다는 것은 그 값으로 계산하는 모든 소비자를 감사한다는 뜻입니다.

날짜는 오프셋을 명시한 RFC 3339 문자열입니다. "2026-09-05T14:30:00Z"처럼요. 유닉스 타임스탬프도, "05/09/2026"도 아니며, 무엇보다 오프셋 없는 현지 시각이어서는 안 됩니다. 그건 아무 탈 없이 파싱되고 몇 시간씩 틀립니다. JSON에는 날짜 타입이 없으므로, 이 관행은 코드 리뷰가 강제할 때에만 존재합니다. 나머지는 JSON의 날짜·시간 형식이 다룹니다.

null, 부재, 빈 값

네 가지 형태, 네 가지 의미.

형태 의미
"middleName": "Jane" 값을 안다
"middleName": null 값이 없다는 것을 안다
키 없음 모르거나, 불러오지 않았거나, 허용되지 않았다
"tags": [] 태그가 0개임을 안다

실수는 잘못된 관행을 고르는 것이 아니라 넷을 일관성 없이 쓰는 것입니다. 그러면 클라이언트는 “이 사용자는 중간 이름이 없다”와 “당신이 부분 조회를 요청했다”를 구별하지 못합니다. 필드마다 정하고 그 선을 지키세요.

함정이 둘. JSON.stringify는 값이 undefined인 키를 버리고 null은 남기므로, JavaScript 생성 측은 변수에 값이 할당됐는지에 따라 부재와 null 사이를 오갑니다. 그리고 JSON 스키마의 required는 키가 존재한다는 주장이지 null이 아니라는 주장이 아닙니다. {"name": null}required: ["name"]을 만족합니다. null이 아님을 뜻하려면 타입 쪽에 쓰세요.

{
  "type": "object",
  "required": ["name", "middleName"],
  "properties": {
    "name":       { "type": "string" },
    "middleName": { "type": ["string", "null"] }
  }
}

첫 초안은 스키마 생성기로 실제 페이로드에서 뽑고, null 허용 여부는 손으로 고치세요. 생성기가 보는 것은 마침 표본에 들어 있던 값들뿐입니다.

이름 짓기

camelCase나 snake_case 중 하나를 골라 모든 엔드포인트의 모든 키에 적용하고, 그 대화를 끝내세요. 한 문서 안에서 표기가 섞여 있다는 것은 두 팀이 절반씩 쓰고 서로 읽지 않았다는 가장 분명한 신호이며, 키를 구조체 필드에 기계적으로 대응시키는 값싼 요령도 깨뜨립니다. ts보다 created_at입니다. 주석이 필요한 키는 더 나은 이름이 필요한 키입니다.

오류

오류 본문에는 서로 다른 세 가지가 필요한데, 대부분은 하나만 내보냅니다.

{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Insufficient funds",
  "status": 402,
  "detail": "Balance is 320 minor units, transfer requires 1005.",
  "code": "INSUFFICIENT_FUNDS",
  "pointer": "/transfer/amountMinor"
}

클라이언트가 분기에 쓰는 안정된 기계용 코드. 절대 바꾸지 않겠다고 약속하는 것입니다. 사람이 읽는 메시지. 문구를 바꾸거나 번역할 자유를 유지하고, 어떤 클라이언트도 이것을 대조해서는 안 됩니다. 그리고 문제의 필드를 가리키는 포인터. 요청 본문에 대해 기계적으로 풀리도록, 되도록 RFC 6901의 JSON Pointer로요.

RFC 9457(Problem Details for HTTP APIs)은 type, title, status, detail, instance를 표준화하고 확장 멤버를 명시적으로 허용하므로, 그것을 채택하면서도 자체 code를 함께 실을 수 있습니다. 기존 구현 대부분이 알고 있는 이름인 RFC 7807을 대체한 문서입니다. 이를 쓰면 남의 도구가 이미 이해하는 형태를 얻습니다. {"error": "뭔가 잘못됐습니다"}로는 결코 얻을 수 없는 것이죠. 검증 실패는 첫 건이 아니라 전부 돌려주세요.

커서가 오프셋을 이긴다

오프셋 페이지네이션은 동시 쓰기가 있는 순간부터 조용히 데이터를 잃습니다. 1페이지가 1행부터 50행을 돌려줍니다. 위쪽에 한 행이 삽입됩니다. 2페이지의 offset=50은 이제 예전의 50행에서 시작하므로, 소비자는 그 레코드를 두 번 봅니다. 삭제는 반대로 작동해 레코드를 통째로 건너뜁니다. 아무 오류도 나지 않습니다. 몇 주 뒤 대사 불일치로 드러날 뿐입니다.

커서는 안정적인 정렬 안에서의 위치를 부호화합니다. 보통 정렬 키에 동순위를 가르는 id를 더한 형태여서, 그 위에서 일어나는 삽입은 무관합니다. 나중에 부호화를 바꿀 수 있도록 커서는 불투명한 것으로 문서화하고, 짧은 페이지에서 끝을 추론하게 하는 대신 hasMore를 명시해 돌려주세요. totalCount는 정말로 필요한 사람이 있고 두 번째 질의의 비용을 낼 각오가 있을 때만 두세요.

더하기만이 공짜 변경이다

진화를 가능하게 하는 계약은 클라이언트 쪽에 있습니다. 모르는 필드는 무시해야 한다는 것입니다. 그것이 지켜지면 필드 추가는 호환을 깨지 않고 계속 배포할 수 있습니다. 소비자가 엄격하게 검증하거나 additionalProperties: false로 타입을 생성한다면, 추가할 때마다 누군가 깨지고 여러분은 영원히 v1에 머뭅니다. 문서 첫 문단에 그렇게 적으세요.

나머지는 전부 버전입니다. 필드 제거, 이름 변경, 타입 변경, 값의 의미 변경, 받아들이는 범위 조이기, null 허용 필드를 비허용으로 바꾸기. 지난 릴리스의 샘플 페이로드와 이번 것을 JSON 비교에 통과시키면, 아무도 의도하지 않은 타입 변화를 잡아냅니다.

이질적인 배열은 아끼는 것보다 소비자에게 더 비싸다

{ "items": [
  { "kind": "comment",  "body": "..." },
  { "kind": "reaction", "emoji": "..." },
  { "id": 7, "legacy": true }
] }

이제 모든 소비자가 분기 코드를 쓰고, 정적 타입 언어를 쓰는 모든 소비자는 태그 유니온을 손으로 씁니다. 형태를 꼭 섞어야 한다면 구별할 수 있게 하세요. 모든 원소에 존재하며, 문서화된 닫힌 값 집합을 가진 필수 kind 말입니다. 그러면 유니온은 기계적으로 도출됩니다. 용서할 수 없는 쪽은 세 번째 원소입니다. 태그 없이 형태가 달라지고 클라이언트가 키를 킁킁대며 찾게 되죠. 어떤 필드가 때로는 문자열이고 때로는 객체인 경우도 마찬가지입니다. 버전 하나를 아끼는 대신 모든 클라이언트에게 영원한 타입 가드를 지웁니다.

응답이 커질 때

모든 엔진에는 문자열 길이의 단단한 천장이 있고, 사람들이 생각하는 것보다 낮습니다. 64비트 V8(크롬과 Node)에서는 536,870,888자이므로, 대략 0.5GB를 넘는 응답은 문자열로 붙들 수조차 없고 파싱은 그다음 이야기입니다. 다른 엔진은 더 높은 곳에 있지만 모두 천장이 있고, 파싱된 객체 트리는 텍스트보다 몇 배의 비용이 듭니다. 그보다 훨씬 앞서, 몇 초짜리 파싱이 메인 스레드를 막습니다.

출구는 셋, API를 흔드는 정도가 작은 순서로. 페이지를 더 잘게 나눠 어떤 응답도 커지지 않게 한다. 줄로 구분된 레코드를 흘려보내 소비자가 닫는 괄호를 기다리는 대신 받는 대로 일하게 한다(NDJSON과 JSON Lines). 아니면 대량 내보내기를 동기 API에서 아예 떼어 낸다. 작업 ID와 완성된 파일에 대한 서명된 URL을 돌려주는 식으로요. 소비자 쪽 이야기는 대용량 JSON 파일 다루기에 있습니다.

점검 목록

  • 컬렉션은 감싸고, 단일 리소스는 맨 객체를 돌려주고, 일관되게 유지한다.
  • ID는 문자열. 돈은 최소 단위 또는 십진 문자열. 날짜는 오프셋이 붙은 RFC 3339.
  • null, 부재, 빈 값이 각각 무엇을 뜻하는지 필드마다 정의한다.
  • 모든 엔드포인트에 표기 관행 하나.
  • 오류는 안정된 코드, 바꿔도 되는 메시지, 필드 포인터를 함께 싣는다. RFC 9457을 고려한다.
  • 커서 페이지네이션, 불투명한 커서, 명시적인 hasMore.
  • 클라이언트에게 모르는 필드는 무시하라고 이르고, 그 밖의 모든 변경은 버전 뒤에 둔다.
  • 이질적인 배열은 전부 필수 kind로 구별한다.

이 중 무엇도 첫날에는 비싸지 않습니다. 전부 천 번째 날에 비싸집니다. 지금 이 이야기를 할 값어치가 있는 이유는 오직 그것뿐입니다.