2편에서 첫 스키마를 작성하며 type, required, properties를 채우고 $schema와 $id까지 넣었으니 규칙 자체는 갖춰진 셈입니다. 하지만 스키마가 파일에 있다고 데이터가 저절로 걸러지지는 않습니다. 스키마가 실제로 힘을 갖는 순간은 검증기에 물려서 데이터가 지나가는 길목에 세워 둬 정합성이 있는 데이터를 보장할 때입니다.
3편은 그 검증 이야기입니다. 검증기가 스키마와 데이터를 대조하는 원리에서 출발해, 직접 돌려볼 수 있는 도구를 알아보고, 통과하는 데이터와 실패하는 데이터의 실제 모양을 확인합니다.
1. 데이터 검증은 어디에서 일어날까
JSON 스키마에 기반한 데이터 검증을 살펴보기 전에, 데이터 검증은 보통 어디서 일어나는지를 보는 것이 좋습니다. 검증은 한 곳에서만 수행되지 않습니다. 실제 시스템에서는 여러 단계에서 반복적으로 실행되는 경우가 많습니다.
예를 들어 웹 서비스 하나만 봐도:
- 브라우저에서 사용자 입력을 검사하고
- API 서버에서 요청 JSON을 다시 검사하고
- 이벤트 소비 단계에서 한 번 더 검사하고
- 데이터 저장 직전에 마지막 검사를 수행하기도 합니다
각 단계는 목적이 조금씩 다릅니다.
| 검증 위치 | 검증 대상 | 실패 시 처리 |
|---|---|---|
| 브라우저 폼 | 사용자 입력값 | 즉시 오류 메시지 표시 |
| API 서버 | 요청 본문(JSON) | 4xx 응답 반환 |
| 메시지 큐 컨슈머 | 이벤트 데이터 | 데드 레터 큐 이동 |
| 데이터 파이프라인 | 적재 대상 레코드 | 격리 테이블 분리 |
브라우저 검증은 사용자 경험(UI/UX)에 가깝습니다. 입력 도중 바로 오류를 알려주면 사용자가 수정하기 쉽기 때문입니다. 반면 API 서버 검증은 시스템 보호에 가깝습니다. 잘못된 요청이 내부 로직까지 들어오지 못하게 차단하는 역할을 합니다.
메시지 큐나 데이터 파이프라인에서는 데이터 품질 유지가 더 중요해집니다. 형식이 깨진 이벤트를 그대로 처리하면 데이터 수신 이후의 다운스트림 시스템까지 오염될 수 있기 때문입니다. 그래서 실제 운영 환경에서는 “한 번만 검증하는 구조”보다 “여러 단계에서 반복 검증하는 구조”가 더 일반적입니다.
예를 들어:
- 프론트엔드에서 1차 검증
- API 서버에서 2차 검증
- 이벤트 소비 단계에서 3차 검증
처럼 계층적으로 배치합니다.
물론 검증이 많아질수록 비용과 복잡도도 증가합니다. 같은 규칙을 여러 계층에서 중복 유지해야 하고, 검증 비용도 계속 발생합니다. 결국 어디까지 검증할지, 어느 계층이 어떤 책임을 가질지는 시스템 설계 의사결정의 문제입니다.
2. JSON 검증은 어떻게 동작할까
JSON 스키마의 검증은 기본적으로 “데이터가 규칙을 만족하는지 확인하는 과정”입니다.
검증기(validator)는 두 가지 입력을 받습니다.
- 스키마(schema)
- 실제 데이터(instance)
예를 들어 아래 스키마를 보겠습니다.
{
"type": "object", // 전체 값은 object여야 함
"required": [
"userId" // userId 필드는 반드시 존재해야 함
],
"properties": {
"userId": {
"type": "string" // userId는 string이어야 함
},
"signedUpAt": {
"type": "string" // signedUpAt도 string이어야 함
}
}
}검증기는 이런 규칙들을 순서대로 적용하면서 데이터가 조건을 만족하는지 확인합니다. 모든 규칙을 만족하면 valid, 하나라도 어기면 invalid가 됩니다.
예를 들어 아래 데이터는 검증에 실패합니다.
{
"userId": "u123",
"signedUpAt": 1712345678
}userId는 문자열이므로 문제 없지만, signedUpAt은 number입니다. 스키마에서는 string을 기대하고 있으므로 검증 실패가 발생합니다.
하지만 실제 시스템에서 중요한 건 단순히 “성공했는가 실패했는가”만이 아닙니다. 실패했을 때 왜 실패했는지를 정확히 알려주는 것이 더 중요합니다.
그래서 대부분의 검증기는 실패 리포트(report)도 함께 반환합니다. 리포트에는 보통 다음 정보가 들어갑니다.
- 어떤 규칙이 실패했는가
- 데이터의 어느 위치에서 실패했는가
- 어떤 값을 기대했는가
- 실제로 어떤 값이 들어왔는가
예를 들면 이런 식입니다.
/signedUpAt: expected string, got number
혹은:
1번째 레코드의
signedUpAt필드는 string이어야 하지만 number가 들어왔습니다.
같은 형태로 가공해서 사용자에게 보여주기도 합니다.
이런 리포트가 중요한 이유는 검증 실패가 단순한 “오류”가 아니라, 잘못된 데이터를 수정하기 위한 피드백 역할도 하기 때문입니다. API 서버는 어떤 요청이 왜 거절됐는지 알려줄 수 있고, 데이터 파이프라인은 어떤 레코드를 격리해야 하는지 판단할 수 있습니다.
검증 흐름을 단순화하면 아래처럼 볼 수 있습니다.
스키마(schema) ───┐
├─► 검증기(validator) ─► valid / invalid
실제 데이터 ───────┘
2. 온라인 검증기로 먼저 확인해 보기
JSON 스키마를 처음 다룰 때는 라이브러리부터 설치하기보다 웹 검증기부터 써 보는 편이 빠릅니다.
스키마와 테스트 데이터를 붙여 넣기만 하면 바로 검증 결과를 확인할 수 있기 때문입니다.
특히,
- 어떤 키워드가 어떻게 동작하는지 확인하거나
- 실패 리포트가 어떻게 나오는지 보거나
- 특정 다이얼렉트에서 동작 차이를 확인할 때
웹 검증기가 꽤 유용합니다.
JSON 스키마 공식 사이트의 구현체 카탈로그에는 검증기·코드 생성기·문서화 도구 같은 구현체들이 언어별로 정리되어 있습니다.
대표적으로 많이 쓰이는 검증기는 아래 정도입니다.
| 검증기 | 환경 | 지원 다이얼렉트 | 특징 | 적합한 경우 |
|---|---|---|---|---|
| Ajv | JavaScript(Node.js·브라우저) | Draft-04 ~ 2020-12 | 스키마를 JS 함수로 컴파일해 실행 속도가 빠름. 에러 리포트 생태계도 성숙 | Node.js API 서버·실시간 검증 |
| jsonschema (Python) | Python | Draft-03 ~ 2020-12 | 구현이 명세에 충실하고 확장성이 좋음 | Python 백엔드·데이터 처리 |
| networknt/json-schema-validator | Java | Draft-04 ~ 2020-12 | JVM 환경 통합 사례가 많고 스프링 연동이 쉬움 | Java·Spring 기반 서비스 |
| santhosh-tekuri/jsonschema | Go | Draft-04 ~ 2020-12 | Go 환경에서 성능과 안정성 평가가 좋음 | Go 마이크로서비스 |
| Hyperjump | 웹 브라우저 | 2020-12 중심 | 브라우저에서 바로 테스트 가능. 어노테이션 활용 지원이 강함 | 학습·실험·프로토타이핑 |
| JSON 스키마 Validator (Newtonsoft) | 웹 브라우저 | Draft-03 ~ 2020-12 | 다양한 draft를 바로 비교 가능 | 교육·다이얼렉트 비교 |
도구를 고를 때는 단순히 “지원하냐”보다 몇 가지를 같이 보는 편이 좋습니다.
- 어떤 다이얼렉트를 지원하는가
- 에러 리포트가 얼마나 읽기 쉬운가
- 어노테이션 수집을 지원하는가
- 대용량 검증에서 성능이 어떤가
특히 다이얼렉트 지원 여부는 중요합니다.
JSON 스키마는 draft마다 동작과 키워드 의미가 조금씩 달라집니다. 예를 들어 2020-12 기준으로 작성한 스키마를 오래된 draft 기반 검증기에 넣으면 예상과 다른 결과가 나올 수 있습니다.
그래서 실무에서는 보통:
- 웹 검증기로 빠르게 테스트하고
- 의도한 대로 동작하는지 확인한 뒤
- 실제 코드 베이스에 라이브러리를 통합하는
순서로 진행합니다.
3. 통과·실패 케이스를 같이 보기
검증은 “성공하는 데이터”와 “실패하는 데이터”를 같이 봐야 감이 잡힙니다.
다음은 사용자 프로필을 설명하는 간단한 스키마입니다.
{
"$schema": "<https://json-schema.org/draft/2020-12/schema>",
"type": "object",
"required": [
"name",
"email"
],
"properties": {
"name": {
"type": "string",
"minLength": 1 // 빈 문자열 불가
},
"email": {
"type": "string",
"format": "email" // 이메일 형식이어야 함
},
"age": {
"type": "integer",
"minimum": 0 // 음수 불가
},
"interest": {
"type": "string",
"enum": [
"tech",
"design",
"business"
] // 허용된 값만 가능
},
"phone": {
"type": "string",
"pattern": "^010-\\\\d{4}-\\\\d{4}$"
// 010-1234-5678 형식만 허용
}
}
}이 스키마는 아래 조건들을 강제합니다.
name과email은 반드시 존재해야 한다name은 비어 있지 않은 문자열이어야 한다email은 이메일 형식을 따라야 한다age는 0 이상의 정수여야 한다interest는tech,design,business중 하나여야 한다phone은010-1234-5678형식을 따라야 한다
이제 이 조건들을 기준으로 통과·실패 케이스를 비교해 보겠습니다.
// (1) 통과: 모든 조건 만족
{
"name": "홍길동", // required 충족, string이며 minLength: 1 만족
"email": "user@example.com", // required 충족, email format 만족
"age": 31, // integer이며 minimum: 0 만족
"interest": "tech", // enum 허용값 중 하나
"phone": "010-1234-5678" // pattern /^010-\\d{4}-\\d{4}$/ 만족
}
// (2) 실패: required 위반
{
"email": "user@example.com" // 필수 필드인 name 누락
}
// (3) 실패: type 위반
{
"name": "이상민",
"email": "user@example.com",
"age": "31" // string임. integer가 아님
}
// (4) 실패: format 위반
{
"name": "이상민",
"email": "not-an-email" // email 형식이 아님
}
// (5) 실패: enum 위반
{
"name": "이상민",
"email": "user@example.com",
"interest": "music" // 허용되지 않은 값
}
// (6) 실패: pattern 위반
{
"name": "이상민",
"email": "user@example.com",
"phone": "010-12-5678" // 전화번호 형식 불일치
}각 실패 케이스는 서로 다른 규칙에 걸립니다.
(2)는required(3)은type(4)는format(5)는enum(6)은pattern
검증 실패가 발생하면 검증기는 어떤 규칙이 왜 실패했는지 리포트로 반환합니다.
예를 들어 (3) 케이스에서는 age 필드가 문제입니다.
스키마는 integer를 기대하지만 실제 값은 string입니다.
/age: expected integer, got string
(2) 케이스에서는 필수 필드가 누락됐기 때문에:
must have required property 'name'
같은 리포트가 생성됩니다.
마찬가지로 (4)는 이메일 형식 검증 실패, (5)는 enum 허용값 검증 실패, (6)은 전화번호 패턴 검증 실패로 기록됩니다.
중요한 점은 검증기가 실패를 한 건만 반환하지 않는다는 것입니다.
예를 들어 하나의 요청 데이터가
- 필수 필드를 누락했고
- 타입도 틀렸고
- 패턴 조건까지 어겼다면
검증기는 이런 실패들을 보통 한 번에 모아서 반환합니다.
덕분에 클라이언트는 요청을 여러 번 반복해서 수정하지 않고도, 어떤 필드들이 왜 실패했는지 전체 목록을 한 번에 확인할 수 있습니다.
실제 서비스에서는 이런 원시 리포트(raw report)를 전화번호 형식이 올바르지 않습니다. 와 같이 그대로 사용자에게 노출하기보다 같이 사용자 친화적인 메시지로 가공해서 보여줘야 합니다.
4. 제약 언어로써 JSON 스키마
JSON 스키마는 데이터 구조를 설명하는 문서이면서 동시에 데이터가 만족해야 하는 조건들을 선언하는 언어이기도 합니다.
예를 들어 아래 스키마를 보겠습니다.
{
"type": "object", // 전체 값은 object여야 함
"required": [
"name" // name 필드는 반드시 존재해야 함
],
"properties": {
"name": {
"type": "string" // name은 string이어야 함
}
}
}이 스키마는 name만 정의했지만, 그렇다고 다른 필드를 막지는 않습니다.
예를 들어 아래 데이터는 검증을 통과합니다.
{
"name": "이상민",
"age": 31,
"email": "user@example.com"
}age, email은 스키마에 정의되지 않았지만 기본적으로 허용됩니다. JSON 스키마가 기본적으로 개방 모델(open model)로 동작하기 때문입니다.
1) 개방 모델과 폐쇄 모델
JSON 스키마에서 객체 검증은 기본적으로 개방 모델(open model) 입니다. 즉, properties에 정의하지 않은 속성이 들어와도 기본적으로 검증을 통과합니다. 반대로 정의하지 않은 속성을 거부하려면 폐쇄 모델(closed model) 로 바꿔야 하고, 이때 사용하는 키워드가 additionalProperties입니다.
| 모델 | 동작 | 설정 |
|---|---|---|
| 개방 모델 | 정의되지 않은 속성을 허용 | additionalProperties: true 또는 생략 |
| 폐쇄 모델 | 정의되지 않은 속성을 거부 | additionalProperties: false |
예를 들어 아래 스키마는 name만 정의했지만, 다른 속성도 허용합니다.
// 개방 모델: 정의되지 않은 속성을 허용
{
"type": "object",
"properties": {
"name": {
"type": "string" // name은 string이어야 함
}
},
"additionalProperties": true // 생략해도 기본값은 true
}따라서 아래 데이터는 검증을 통과합니다.
{
"name": "이상민",
"age": 31
}age는 스키마에 정의되지 않았지만, 개방 모델에서는 추가 속성을 허용하기 때문입니다.
반대로 정의하지 않은 속성을 막고 싶다면 additionalProperties: false를 명시합니다.
// 폐쇄 모델: 정의되지 않은 속성을 거부
{
"type": "object",
"properties": {
"name": {
"type": "string" // name은 string이어야 함
}
},
"additionalProperties": false // name 외의 속성은 허용하지 않음
}이 경우 같은 데이터는 검증에 실패합니다.
{
"name": "이상민",
"age": 31
}age가 스키마에 정의되지 않은 속성이기 때문입니다. 공개 API처럼 유연성이 필요한 곳에서는 개방 모델이 유리할 수 있고, 내부 시스템 간 계약처럼 입력 형태를 엄격히 맞춰야 하는 곳에서는 폐쇄 모델이 더 적합합니다.
2) 추가 속성에도 규칙을 적용할 수 있다
additionalProperties는 추가 속성을 허용할지 여부만 결정하는 키워드가 아닙니다. 추가 속성 자체에도 별도의 검증 규칙을 적용할 수 있습니다.
{
"type": "object",
"properties": {
"name": {
"type": "string" // name은 반드시 string이어야 함
}
},
"additionalProperties": {
"type": "string" // 정의되지 않은 추가 속성도 모두 string이어야 함
}
}이 스키마에서는 name 외의 필드도 허용됩니다. 대신 추가되는 모든 필드의 값은 반드시 string이어야 합니다.
아래 데이터는 검증을 통과합니다.
{
"name": "이상민",
"team": "platform", // 추가 속성이지만 string
"role": "backend" // 추가 속성이지만 string
}team, role은 properties에 정의되지 않았기 때문에 추가 속성으로 처리됩니다. 그리고 값이 모두 string이므로 검증에 성공합니다.
반면 아래 데이터는 실패합니다.
{
"name": "이상민",
"age": 31 // integer이므로 additionalProperties 조건 위반
}조건을 만족하지 못하기 때문에 validator는 실패를 반환합니다.
실제 에러는 보통 이런 형태로 출력됩니다.
age must be string
처음 JSON 스키마를 사용할 때 가장 많이 헷갈리는 부분이 여기입니다.
많은 사람들이 properties에 정의되지 않은 필드는 자동으로 막힌다고 생각합니다. 하지만 JSON 스키마는 기본적으로 개방 모델(open model)로 동작합니다. 즉 선언되지 않은 속성도 기본적으로 허용합니다.
{
"type": "object",
"properties": {
"name": {
"type": "string" // name 타입만 설명
}
}
// additionalProperties를 선언하지 않았음
// 따라서 추가 속성은 기본적으로 허용됨
}이 스키마도 추가 필드를 막지 않기 때문에, 아래 데이터는 그대로 통과합니다.
{
"name": "이상민",
"age": 31 // 추가 속성이지만 허용됨
}추가 속성을 금지하려면 직접 선언해야 합니다.
{
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"additionalProperties": false // 정의되지 않은 속성 자체를 금지
}이제는 age 자체가 허용되지 않습니다.
{
"name": "이상민",
"age": 31 // 추가 속성이므로 검증 실패
}이번에는 타입 문제가 아니라 정의되지 않은 속성이 존재하기 때문에 실패하고, validator 에러도 달라집니다.
must NOT have additional properties
5. 이질적 데이터 구조: anyOf, oneOf, allOf
결제 정보가 카드·계좌이체·간편결제 중 하나로 들어오거나, 알림 메시지가 푸시·이메일·문자 중 하나의 페이로드를 담는 경우처럼 한 필드에 여러 형태가 올 수 있는 데이터를 이질적 데이터 구조(heterogeneous data structures)라고 부릅니다. JSON 스키마는 이를 세 가지 키워드로 표현합니다.
anyOf | oneOf | allOf | |
|---|---|---|---|
| 통과 조건 | 1개 이상 통과 | 정확히 1개만 통과 | 전부 통과 |
| 겹침 허용 | 허용 | 불허 (2개 이상 통과 시 실패) | 해당 없음 (전부 통과해야 함) |
| 전형적 사용처 | 여러 형태를 느슨하게 허용 | 배타적 형태 강제 | 공통 베이스 + 추가 제약 |
| 실무 주의점 | 의도치 않은 형태가 통과할 수 있음 | 서브스키마 간 겹침이 있으면 예상치 못한 실패 발생 | 서브스키마 간 properties가 충돌하면 어느 객체도 통과 불가 |
실무에서 가장 자주 막히는 지점은 anyOf와 oneOf의 차이입니다. 핵심은 두 서브스키마를 동시에 만족하는 데이터가 들어왔을 때 드러납니다. anyOf는 그 데이터를 통과시키고, oneOf는 실패로 처리합니다. “어느 하나라도 맞으면 OK”인지, “정확히 한 형태여야 OK”인지에 따라 선택이 갈립니다.
1) anyOf: 하나라도 맞으면 통과
anyOf는 나열된 서브스키마 중 하나라도 통과하면 전체가 통과합니다. 두 개 이상이 동시에 통과해도 상관없습니다.
실무에서 anyOf가 필요한 자리는 입력 형태를 느슨하게 허용해야 하는 경우입니다. 연락처 필드에 전화번호든 이메일이든 아무 형태나 받아야 하거나, API 마이그레이션 중에 구버전 포맷과 신버전 포맷을 동시에 허용해야 하는 경우가 전형적입니다. oneOf를 쓰면 두 포맷이 동시에 매칭되는 값이 들어왔을 때 실패하지만, anyOf는 그런 겹침을 허용하기 때문에 과도기에 안전합니다.
연락처 필드에 전화번호와 이메일을 모두 허용하는 예제로 동작을 보겠습니다.
{
"type": "string",
"anyOf": [
{
// 전화번호 형태: 010-1234-5678
"pattern": "^\\d{2,3}-\\d{3,4}-\\d{4}$"
},
{
// 이메일 형태: user@example.com
"pattern": "^[^@]+@[^@]+\\.[^@]+$"
}
]
}// ✅ 전체 통과: 첫 번째 서브스키마(전화번호) 매칭
"010-1234-5678"
// ✅ 전체 통과: 두 번째 서브스키마(이메일) 매칭
"user@example.com"
// ❌ 전체 실패: 어느 서브스키마도 매칭되지 않음
"hello"두 서브스키마를 동시에 만족하는 값이 들어와도 anyOf는 신경 쓰지 않습니다. 하나만 통과하면 되기 때문입니다. 이 점이 oneOf와 갈리는 지점이고, 입력 형태를 엄격하게 하나로 제한할 필요가 없는 경우에 anyOf가 적합한 이유입니다.
2) oneOf와 식별 유니온(discriminated union): 배타적 형태 강제하기
oneOf는 나열된 서브스키마 중 정확히 하나만 통과해야 전체가 통과합니다. 두 개 이상이 동시에 통과하면 실패입니다. 결제 수단처럼 형태가 반드시 하나로 결정되어야 하는 데이터에 씁니다.
실무에서 oneOf를 쓸 때는 서브스키마 간 겹침이 생기지 않도록 설계해야 합니다. 가장 널리 쓰이는 방법이 식별 유니온 패턴입니다. method 같은 식별 필드를 const로 고정해서 서브스키마 간 겹침 자체를 없앱니다.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Payment",
"oneOf": [
{
// method가 "card"일 때만 매칭
"type": "object",
"required": ["method", "cardNumber", "expiry"],
"properties": {
"method": { "const": "card" },
"cardNumber": { "type": "string", "pattern": "^[0-9]{13,19}$" },
"expiry": { "type": "string", "pattern": "^\\d{2}/\\d{2}$" }
}
},
{
// method가 "transfer"일 때만 매칭
"type": "object",
"required": ["method", "bankCode", "accountNumber"],
"properties": {
"method": { "const": "transfer" },
"bankCode": { "type": "string" },
"accountNumber": { "type": "string" }
}
},
{
// method가 "easypay"일 때만 매칭
"type": "object",
"required": ["method", "provider"],
"properties": {
"method": { "const": "easypay" },
"provider": { "type": "string", "enum": ["kakaopay", "naverpay", "tosspay"] }
}
}
]
}{ "method": "card", "cardNumber": "4111111111111111", "expiry": "12/25" }
→ 첫 번째만 통과 → ✅ 전체 통과
{ "method": "transfer", "bankCode": "088", "accountNumber": "110-123-456789" }
→ 두 번째만 통과 → ✅ 전체 통과
{ "method": "card", "bankCode": "088" }
→ 첫 번째 실패 (cardNumber, expiry 누락), 두 번째 실패 (method가 "transfer"가 아님)
→ 어느 서브스키마도 통과하지 못함 → ❌ 전체 실패method가 const로 고정되어 있어 한 인스턴스가 두 서브스키마를 동시에 만족할 수 없습니다. 송신 측이 결제 수단 하나를 명확히 골라 보내도록 스키마 수준에서 강제하는 구조입니다.
anyOf와의 차이를 다시 짚으면, 만약 위 스키마에서 oneOf를 anyOf로 바꾸면 동작 자체는 동일합니다. const로 겹침을 이미 제거했기 때문입니다. 그러나 oneOf를 쓰는 이유는 의도의 명시입니다. “이 데이터는 반드시 한 형태여야 한다”는 제약을 스키마에 남기는 것이고, 또한 나중에 서브스키마를 수정하다가 겹침이 생기면 oneOf가 그 시점에 실패를 잡아줍니다.
3) allOf: 모든 제약을 동시에 만족
allOf는 나열된 서브스키마를 전부 통과해야 전체가 통과합니다. $ref 하나에 필드를 추가하는 정도는 allOf 없이도 되지만, 참조 대상이 둘 이상일 때는 allOf가 필요합니다.
조직 공통 베이스와 도메인별 베이스를 동시에 만족해야 하는 경우가 전형적입니다. 아래 예제에서는 두 개의 공통 스키마를 먼저 정의합니다. 하나는 모든 응답에 id와 createdAt을 강제하는 조직 공통 베이스이고, 다른 하나는 변경 이력 추적을 위한 감사(audit) 베이스입니다.
// common/base-response.json — 조직 공통: 모든 응답에 id, createdAt 필수
{
"$id": "https://api.example.com/common/base-response.json",
"type": "object",
"required": ["id", "createdAt"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"createdAt": { "type": "string", "format": "date-time" }
}
}// common/auditable.json — 감사 추적용: 변경 이력 필드 필수
{
"$id": "https://api.example.com/common/auditable.json",
"type": "object",
"required": ["updatedAt", "updatedBy"],
"properties": {
"updatedAt": { "type": "string", "format": "date-time" },
"updatedBy": { "type": "string" }
}
}사용자 응답 스키마는 이 두 베이스를 allOf로 합치고, 사용자 고유 필드를 세 번째 서브스키마로 추가합니다.
// endpoints/user-response.json
// 조직 공통 + 감사 추적 + 사용자 고유 필드를 allOf로 합침
{
"allOf": [
{ "$ref": "https://api.example.com/common/base-response.json" },
{ "$ref": "https://api.example.com/common/auditable.json" },
{
"type": "object",
"required": ["name", "email"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"email": { "type": "string", "format": "email" }
}
}
]
}세 서브스키마가 모두 통과해야 검증을 통과합니다. 하나라도 실패하면 전체가 실패입니다.
// ✅ 전체 통과: 세 서브스키마 모두 만족
{
"id": "a1b2...",
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-01-02T00:00:00Z",
"updatedBy": "admin",
"name": "홍길동",
"email": "hong@example.com"
}
// ❌ 전체 실패: auditable 스키마 실패 (updatedAt, updatedBy 누락)
{
"id": "a1b2...",
"createdAt": "2025-01-01T00:00:00Z",
"name": "홍길동",
"email": "hong@example.com"
}allOf는 서브스키마를 고르는 것이 아니라 합치는 것입니다. $ref가 하나뿐이면 같은 객체에 필드를 추가하면 되지만, 참조 대상이 둘 이상이면 allOf로 묶어야 합니다. 공통 필드가 바뀌면 해당 베이스 스키마 하나만 수정하면 되기 때문에, 엔드포인트가 늘어나도 스키마 중복이 생기지 않습니다.
6. format 키워드: 표준 포맷의 의미와 함정
format은 문자열의 의미적 형식을 지정하는 키워드입니다. "format": "email"이라고 쓰면 “이 필드는 이메일 형식이다”라는 정보를 스키마에 남기는 셈입니다. 이메일·URL·날짜·UUID처럼 이름이 정해진 표준 포맷이 대상입니다.
| 카테고리 | format 값 | 검증 대상 |
|---|---|---|
| 날짜·시간 | date, time, date-time, duration | RFC 3339 날짜·시간 표기 |
| 식별자 | uuid, uri, iri | UUID, URI/IRI 표기 |
| 연락처 | email, idn-email | 이메일 주소 |
| 네트워크 | ipv4, ipv6, hostname, idn-hostname | IP 주소·호스트명 |
| JSON 내부 | json-pointer, relative-json-pointer | JSON 안의 위치 표기 |
| 정규식 | regex | ECMA-262 호환 정규식 |
1) format의 기본 동작: 검증이 아니라 어노테이션
format에서 실무자가 가장 자주 빠지는 함정은, 이 키워드가 기본적으로 검증을 하지 않는다는 점입니다. JSON 스키마 2020-12의 기본 메타스키마(meta-schema)에서 format은 어노테이션(annotation)으로만 동작합니다. 도구가 이 정보를 API 문서 생성이나 코드 제너레이터의 타입 힌트에 활용할 수는 있지만, "not-an-email" 같은 값이 들어와도 검증을 통과시킵니다.
// 이 스키마는 기본 설정에서 "not-an-email"을 통과시킴
{
"type": "string",
"format": "email"
}// ✅ 통과: format은 기본적으로 어노테이션일 뿐, 검증하지 않음
"not-an-email"
// ✅ 통과: 당연히 올바른 이메일도 통과
"user@example.com"이 동작의 배경은 JSON 스키마 2020-12에서 format의 역할이 두 보캐뷸러리(vocabulary)로 분리된 데 있습니다. 보캐뷸러리는 1편에서 다룬 개념으로, 같은 목적을 가진 키워드들을 한 묶음으로 다루는 단위입니다.
- format-annotation — 기본 활성. 의미 정보만 전달하고 검증은 하지 않음
- format-assertion — 옵션. 활성화하면 실제 검증을 수행
기본 메타스키마는 format-annotation만 가져오기 때문에, 별도 설정 없이 "format": "email"을 써도 검증은 일어나지 않습니다.
2) 어서션(assertion)으로 검증까지 강제하기
검증까지 강제하려면 두 가지 방법이 있습니다.
첫 번째는 밸리데이터 옵션에서 format을 어서션으로 활성화하는 것입니다. Ajv의 경우 ajv-formats 플러그인이 이를 지원합니다.
// Ajv에서 format 검증 활성화
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv();
addFormats(ajv); // 이 한 줄로 format이 어서션으로 동작
const schema = { type: "string", format: "email" };
const validate = ajv.compile(schema);
validate("user@example.com"); // true
validate("not-an-email"); // false — format이 어서션으로 동작하여 실패두 번째는 format-assertion 보캐뷸러리를 가져오는 커스텀 메타스키마를 직접 작성하는 것입니다. 기본 메타스키마의 $vocabulary에서 format-annotation 대신 format-assertion을 true로 선언하면, 해당 메타스키마를 $schema로 참조하는 모든 스키마에서 format이 어서션으로 동작합니다.
{
"$id": "https://example.com/meta/with-format-assertion",
"$vocabulary": {
// JSON 스키마 2020-12 기본 보캐뷸러리들
"https://json-schema.org/draft/2020-12/vocab/core": true,
"https://json-schema.org/draft/2020-12/vocab/applicator": true,
"https://json-schema.org/draft/2020-12/vocab/validation": true,
// format-annotation 대신 format-assertion을 활성화
"https://json-schema.org/draft/2020-12/vocab/format-assertion": true
}
}// 이 스키마는 위 커스텀 메타스키마를 참조하므로 format이 어서션으로 동작
{
"$schema": "https://example.com/meta/with-format-assertion",
"type": "string",
"format": "email"
}첫 번째 방법은 밸리데이터 코드에서 설정하고, 두 번째 방법은 스키마 자체에 선언합니다. 실무에서는 Ajv 플러그인 방식이 간편해서 더 자주 쓰이지만, 밸리데이터에 의존하지 않고 스키마만으로 검증 의도를 명시해야 하는 경우에는 커스텀 메타스키마가 필요합니다.
3) format과 pattern의 역할 차이
format과 비슷해 보이지만 역할이 다른 키워드가 pattern입니다. format은 이름이 정해진 표준 포맷에 쓰고, pattern은 임의 정규식을 직접 지정하는 자리에 씁니다.
// (a) format — "이 필드는 이메일이다"라는 의미까지 도구에 전달
// 검증 여부는 보캐뷸러리 설정에 따라 달라짐
{
"type": "string",
"format": "email"
}
// (b) pattern — 정규식으로 검증을 직접 강제
// 도구에게 "이메일이다"라는 의미 정보는 전달되지 않음
{
"type": "string",
"pattern": "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"
}(a)는 의미 전달에 강하지만 검증 강도가 밸리데이터 설정에 좌우됩니다. (b)는 정규식에 의해 검증이 항상 일어나지만, 도구에게 “이 필드가 이메일이다”라는 의미 정보는 전달되지 않습니다. 의미 전달과 검증 강제가 모두 필요한 필드에서는 format과 pattern을 함께 거는 방식도 실무에서 자주 쓰입니다.
7. JSON 스키마 검증은 어디에 두는가
여기까지 다룬 키워드와 검증 메커니즘은 결국 시스템 어딘가에서 실행되어야 합니다. JSON 스키마 검증이 가장 자연스럽게 들어가는 자리는 API 게이트웨이, 즉 서버가 요청을 받는 입구입니다.
API 게이트웨이에서 JSON 스키마로 요청 본문을 검증하면, 형식·타입·required·format 같은 구조적 문제를 비즈니스 로직에 도달하기 전에 400 응답으로 끊어낼 수 있습니다. 스키마 파일 하나로 검증 규칙이 선언되기 때문에 코드에 검증 로직을 흩뿌리지 않아도 되고, 스키마가 곧 API 명세 역할을 겸하므로 문서와 검증이 따로 노는 문제도 줄어듭니다.
다만 JSON 스키마가 모든 검증을 대신할 수는 없습니다. “이 사용자가 이 주문을 취소할 권한이 있는가”, “이미 배송된 주문은 취소 불가” 같은 도메인 규칙은 스키마만으로 표현할 수 없고, 비즈니스 로직 단계에서 별도로 검증해야 합니다. 외래 키·유니크 제약 같은 무결성 검사는 데이터베이스 수준에서 잡아야 합니다.
결과적으로 JSON 스키마 검증은 시스템 전체 검증 흐름의 한 단계를 담당합니다. 각 단계가 잡을 수 있는 문제가 다르기 때문에, 보안 분야의 다층 방어(defense in depth)와 같은 접근으로 역할을 분담하는 설계가 일반적입니다.
| 검증 단계 | 잡는 문제 | JSON 스키마의 역할 |
|---|---|---|
| 클라이언트(폼 입력) | 형식 오류, 필수 값 누락 | 같은 스키마를 클라이언트에서도 실행해 즉시 피드백 가능 |
| API 게이트웨이 | 형식·타입·required·format | JSON 스키마 검증의 주 위치. 잘못된 요청을 입구에서 차단 |
| 비즈니스 로직 | 권한, 상태 의존, 교차 필드 규칙 | 스키마로 표현 불가. 코드로 별도 검증 |
| 저장소 직전 | 외래 키, 유니크 제약, NOT NULL | 스키마 범위 밖. DB 수준 무결성 검사 |
게이트웨이에서 구조적 문제를 빠르게 걸러내고, 비즈니스 로직에서 도메인 규칙을 잡고, 저장소 직전에서 무결성을 최종 확인하는 구조입니다. JSON 스키마는 이 흐름에서 입구 차단을 맡아, 뒤쪽 단계가 구조적으로 올바른 데이터만 다루도록 보장하는 역할을 합니다.
마무리
3편에서 다룬 내용을 정리하면 다음과 같습니다.
- 검증의 작동 메커니즘: 스키마 + 인스턴스 → 검증기 → 통과·실패 + 리포트
- 검증기 도구 라인업과 선택 기준: 다이얼렉트·어노테이션·리포트·성능
- 통과·실패 케이스가 실제 보이는 모양:
required·type·format·enum·pattern - 제약 언어로서의 JSON 스키마: 개방 모델 기본, 엄격함은 명시적 제약
- 이질적 구조의 표현:
anyOf·oneOf·allOf, 식별 유니온 format키워드의 두 보캐뷸러리: format-annotation 기본, format-assertion 옵션- JSON 스키마 검증의 위치: API 게이트웨이에서 입구 차단, 다층 방어로 역할 분담
검증이 하는 일은 단순히 잘못된 데이터를 잡는 데서 그치지 않습니다. 무엇이 올바른 데이터인지를 송신 측과 수신 측이 스키마로 합의하고, 그 합의를 시스템 입구의 기준으로 쓰는 것까지가 검증의 범위입니다. 합의가 형식적으로 잡혀 있지 않으면 입구 자체가 만들어지지 않습니다.
여기까지 왔으면 자연스럽게 다음 질문이 생깁니다. 검증을 통과한 데이터에 무엇을 더 담을 수 있는가. "format": "email"을 통과한 문자열이 사용자의 회사 이메일인지 복구용 보조 이메일인지를 스키마에서 어떻게 구분할 수 있는가. 4편에서는 검증을 넘어 데이터에 의미와 맥락을 부여하는 어노테이션을 다룹니다.
JSON 시리즈

