JSON Toolkit

JSON Schema: 기초부터 운영까지

핵심 요약: 키워드는 많지만 매일 쓰는 것은 열두 개 정도입니다. type/properties/required/$ref만 제대로 쓰고 오류 메시지를 잘 처리하면 실무 사례의 80퍼센트를 덮습니다.

중급약 14분 소요2026-10-30 발행2026-10-30 업데이트
JSON Schema: 기초부터 운영까지 표지

핵심부터 말하면, 먼저 $schema를 2020-12로 고정하고, $defs + $ref로 공유 구조를 추출하세요. oneOf는 정확히 하나의 분기만 일치해야 할 때만 쓰고, 나머지는 판별자나 anyOf를 선호하세요.

스키마는 문서의 부록이 아니라 실행하고 테스트하고 생성할 수 있는 자산입니다.

1. 버전부터 바로잡기

버전 $schema 값 상태 비고
draft-04 http://json-schema.org/draft-04/schema# 구식 레거시 프로젝트와 일부 OpenAPI 2.0 도구
draft-07 http://json-schema.org/draft-07/schema# 널리 사용 OpenAPI 3.0의 사실상 기반
2019-09 https://json-schema.org/draft/2019-09/schema 사용 가능 $defs와 주석 어휘 도입
2020-12 https://json-schema.org/draft/2020-12/schema 현재 권장 prefixItems 도입, items 의미 변경

신규 프로젝트에는 2020-12를 쓰세요. 2020-12에서 items는 "나머지 요소"만 제약하며, 튜플식 선언은 prefixItems로 옮겨졌습니다. draft-07에서 이전할 때 가장 흔한 함정입니다.

2. 최소한으로 쓸 만한 스키마

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/user.json",
  "type": "object",
  "required": ["id", "name"],
  "properties": {
    "id": { "type": "string", "minLength": 1 },
    "name": { "type": "string", "minLength": 1, "maxLength": 64 },
    "age": { "type": "integer", "minimum": 0, "maximum": 150 },
    "tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
  },
  "additionalProperties": false
}

이 스키마는 가장 흔한 네 가지 관심사를 덮습니다. 타입 제약, 필수 필드, 범위 제한, 배열 요소 제약.

3. 키워드 지도

3.1 일반

키워드 목적 주의점
type 타입, 배열 가능 "type": ["string", "null"]은 nullable을 의미
enum 열거 값 값이 유일해야 함
const 단일 값 제약 버전 번호 같은 고정 값에 적합
$ref 다른 스키마를 참조 전체 스키마를 참조하며, 참조하는 객체의 형제 키워드와 병합되지 않음

3.2 숫자

multipleOf, maximum, exclusiveMaximum, minimum, exclusiveMinimum. draft-04에서 exclusiveMaximum은 불리언이었고(maximum과 함께 사용), draft-07부터 독립적인 숫자 키워드가 되었다는 점을 주의하세요. 그래서 "옛 문서에서 복사한 스키마"는 엄격 모드에서 실패하는 경우가 많습니다.

3.3 문자열

minLength, maxLength, pattern(ECMA-262 정규식), format(주석).

minLength는 Unicode 코드 포인트를 셉니다. UTF-16 단위도 바이트도 아닙니다. JavaScript의 string.length로 검증하면 결과가 달라집니다.

3.4 배열

키워드 목적
prefixItems 2020-12의 튜플식 제약, 인덱스별로 항목을 하나씩 매칭
items 나머지 요소에 대한 균일 제약(2020-12)
contains + minContains/maxContains 일치 항목이 최소/최대 몇 개 있어야 하는지
uniqueItems 요소가 모두 달라야 함(딥 비교이며 큰 배열에서는 피하세요)
minItems / maxItems 길이 제약

3.5 객체

properties, required, patternProperties, additionalProperties, propertyNames, minProperties, maxProperties, dependentRequired, dependentSchemas.

빈도 높은 세 가지 오해:

  1. required는 키가 존재함만 요구: {"name": ""}는 유효합니다. 빈 문자열을 거부하려면 minLength: 1을 더해야 합니다.
  2. additionalProperties: false는 호환성 문제를 키움: 클라이언트에서 필드 하나가 더 오면 페이로드 전체가 실패하므로, API를 발전시키려면 모든 호출자가 동시에 업그레이드해야 합니다.
  3. properties는 선언되지 않은 키를 제한하지 않음: 기본적으로 허용됩니다.

3.6 논리 구성

{
  "oneOf": [
    { "type": "object", "required": ["cardNumber"], "properties": { "cardNumber": { "type": "string" } } },
    { "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string" } } }
  ]
}
키워드 의미 흔한 실패
allOf 모두 만족해야 함 여러 분기가 같은 필드에 충돌하는 제약을 부여
anyOf 최소 하나를 만족 오류 메시지가 특정 분기로 추적되기 어려움
oneOf 정확히 하나를 만족 데이터가 두 분기와 일치해 실패하며, "당연히 맞아 보이는" 경우에도 발생
not 만족해서는 안 됨 드물게 쓰이며 오류 메시지가 가장 불친절

oneOf는 "유효한 데이터가 검증 실패"를 낳는 키워드로 가장 많습니다. 분기 제약이 겹치면(예: 둘 다 추가 필드를 허용), 한 레코드가 여러 분기와 일치해 oneOf가 그대로 실패합니다. 그럴 때는 anyOf로 바꾸거나, 분기 안에 const 판별자 필드를 쓰세요.

{
  "type": "object",
  "required": ["kind"],
  "properties": { "kind": { "enum": ["text", "image"] } },
  "if": { "properties": { "kind": { "const": "image" } } },
  "then": { "required": ["url", "width", "height"] },
  "else": { "required": ["content"] }
}

판별자 스타일이 oneOf보다 읽기 쉽고 더 나은 오류를 냅니다.

4. 분할과 재사용

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/order.json",
  "$defs": {
    "money": { "type": "string", "pattern": "^-?\\d+\\.\\d{2}$" },
    "address": {
      "type": "object",
      "required": ["city"],
      "properties": { "city": { "type": "string" }, "detail": { "type": "string" } }
    }
  },
  "type": "object",
  "required": ["total", "shipTo"],
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "shipTo": { "$ref": "#/$defs/address" }
  }
}
  • $defs는 2019-09에서 도입된 형태이며, draft-07은 definitions를 씁니다.
  • 파일 간 참조는 상대 $id를 쓰지만, 운영에서 런타임에 원격 스키마를 가져오는 것에 의존하지 마세요. 정적 파일로 번들하세요.
  • 참조 옆에 제약을 추가할 수 없습니다($ref의 형제 키워드 동작은 버전마다 다름). 구성이 필요하면 allOf를 쓰세요.

5. Ajv로 런타임 검증

import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
import orderSchema from './order.json' with { type: 'json' };

const ajv = new Ajv2020({
  allErrors: true,
  strict: true,
  allowUnionTypes: true,
});
addFormats(ajv);

const validateOrder = ajv.compile(orderSchema);

export function assertOrder(input: unknown): void {
  if (validateOrder(input)) {
    return;
  }
  const detail = (validateOrder.errors ?? []).map((errorItem) => ({
    path: errorItem.instancePath || '/',
    keyword: errorItem.keyword,
    message: errorItem.message,
    params: errorItem.params,
  }));
  throw new Error(`Order data failed validation: ${JSON.stringify(detail)}`);
}

요점:

  • compile은 한 번만, 모듈 상위에서 호출하세요. 요청마다 재컴파일하면 10ms 검증이 수백 밀리초가 됩니다.
  • strict: true는 잘못 입력한 키워드(예: minLength 대신 minlength)를 조기에 드러내므로 켜 둘 만합니다.
  • ajv-formats가 있어야 format: "email"과 format: "date-time"이 실제로 효력을 가집니다.
  • 2020-12는 ajv/dist/2020 진입점이 필요합니다. 기본 내보내기를 쓰지 마세요.

6. 오류 메시지 설계 방법

검증기가 주는 것은 "어느 키워드가 실패했는가"이고, 사용자가 원하는 것은 "어느 입력 상자가 틀렸는가"입니다. 매핑 계층을 더할 만합니다:

const FIELD_LABEL: Record<string, string> = {
  '/name': 'Name',
  '/age': 'Age',
  '/tags': 'Tags',
};

/**
 * Ajv 오류를 표시용 메시지로 변환합니다.
 * @param errors Ajv 오류 목록
 * @returns 표시용 메시지 배열
 */
function toMessages(errors: Array<{ instancePath: string; keyword: string; params: unknown }>): string[] {
  return errors.map((errorItem) => {
    const label = FIELD_LABEL[errorItem.instancePath] ?? errorItem.instancePath;
    if (errorItem.keyword === 'required') {
      const missing = (errorItem.params as { missingProperty: string }).missingProperty;
      return `${label} is missing the required field ${missing}`;
    }
    if (errorItem.keyword === 'minLength') {
      return `${label} is too short`;
    }
    return `${label} failed validation (${errorItem.keyword})`;
  });
}

API 쪽에서는 프런트엔드가 표시 방식을 결정할 수 있도록 기계가 읽을 수 있는 구조를 유지하세요:

{
  "type": "https://bbdrug.com/problems/validation",
  "title": "Request data validation failed",
  "status": 422,
  "errors": [
    { "path": "/items/0/price", "keyword": "pattern", "message": "Must be a monetary string with two decimal places" }
  ]
}

7. 성능과 함정

  • 검증 비용은 스키마 복잡도와 데이터 크기에 따라 커집니다. 여러 oneOf 분기는 조합 폭발을 일으켜, 레코드 수천 개에서는 매우 눈에 띄게 됩니다.
  • uniqueItems는 배열 위에서 O(n²) 딥 비교를 합니다. 큰 배열에서는 업무 계층에서 중복을 제거하세요.
  • pattern에서 치명적 백트래킹 패턴(예: (a+)+$)을 피하세요. ReDoS의 고전적인 진입점입니다.
  • additionalProperties: false를 allOf와 결합하면 하위 스키마에서 선언한 필드를 상위가 "추가" 필드로 판단하므로, 단일 스키마로 병합하거나 unevaluatedProperties를 쓰세요.

8. 체크리스트

  • [ ] 2020-12로 고정하고 파일 상단에 $schema를 선언합니다
  • [ ] 복사 붙여넣기 대신 $defs + $ref로 공유 구조를 추출합니다
  • [ ] oneOf보다 판별자를 선호합니다
  • [ ] 검증기의 compile 결과를 재사용하고 엄격 모드를 켭니다
  • [ ] format 강제 여부를 명확히 문서화합니다
  • [ ] 오류 응답이 경로, 키워드, 사용자 대상 메시지를 제공합니다
  • [ ] 스키마를 CI에 넣습니다. JSON Schema Test Suite 사례를 회귀 테스트로 실행하세요

FAQ

JSON Schema draft는 어떻게 고르나?

신규 프로젝트에는 2020-12를 쓰고 파일 상단에 $schema를 선언하세요. 스키마가 OpenAPI 3.1에 들어가면 2020-12여야 합니다. 3.1이 그 버전을 직접 내장하기 때문입니다. draft-07은 여전히 오래된 도구 체인에 묶인 프로젝트에만 남기세요. items와 $ref 형제 키워드 동작이 서로 다르므로 한 스키마 집합에서 섞지 마세요.

additionalProperties: false가 왜 호환성 문제를 키우나?

선언되지 않은 모든 키를 오류로 만들므로, 클라이언트에서 필드 하나가 더 오면 페이로드 전체가 실패합니다. 그러면 API가 발전할 때마다 모든 호출자가 동시에 업그레이드해야 하고, 옛 클라이언트는 거부됩니다. 설정형 스키마는 추가 필드를 명시적으로 닫아야 하지만, 공개 API는 호환성 비용을 저울질해야 합니다.

oneOf와 anyOf 중 무엇을 써야 하나?

데이터가 정확히 하나의 분기와 일치해야 하는 경우에만 oneOf를 쓰세요. 분기 제약이 동시에 성립할 수 있으면 한 레코드가 여러 분기와 일치해 oneOf가 그대로 실패합니다. anyOf로 바꾸거나 각 분기 안에 const 판별자를 더하세요.

참고 자료

관련 문서

스키마와 TypeScript 타입의 양방향 동기화

타입과 스키마는 각자 한 구간만 덮습니다. 하나는 컴파일 타임, 하나는 런타임. 질문은 어느 쪽을 고르느냐가 아니라 두 개를 자동으로 동기화하는 방법입니다.

중급약 11분 소요2026-10-30 업데이트

JSON API 데이터 계약 설계 요점

출시 후 바꾸기 매우 어려운 여섯 가지가 있습니다. 응답 봉투, 오류 형식, 페이지네이션 모양, ID와 시간의 표현, null 대 누락의 의미, 버전 진화입니다.

중급약 13분 소요2026-10-30 업데이트

additionalProperties를 닫지 않으면 Schema 검증은 검증이 아니다

검증을 통과해도 데이터가 깨끗하다는 뜻이 아닙니다. Pydantic은 기본으로 미선언 필드를 무시하고, Zod는 제거하며, Ajv는 그대로 통과시킵니다. 본문은 무음 수용의 세 가지 형태를 보이고, 객체를 닫고 강제 형변환을 제어하며 모든 중첩 단계를 조이는 완전한 방법을 제시합니다.

Intermediate약 8분 소요2026-11-02 업데이트