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

핵심부터 말하면, 먼저 $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.
빈도 높은 세 가지 오해:
required는 키가 존재함만 요구:{"name": ""}는 유효합니다. 빈 문자열을 거부하려면minLength: 1을 더해야 합니다.additionalProperties: false는 호환성 문제를 키움: 클라이언트에서 필드 하나가 더 오면 페이로드 전체가 실패하므로, API를 발전시키려면 모든 호출자가 동시에 업그레이드해야 합니다.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 판별자를 더하세요.