도입을 결정하고 나면 그다음은 빈 파일 앞에 서는 일입니다. 1편에서 어휘(다이얼렉트·보캐뷸러리·메타-스키마)를 잡았고, JSON 스키마가 검증·문서화·계약·자동화 네 영역을 한꺼번에 풀어준다는 점도 짚었습니다. 그런데 막상 새 파일에 {를 찍고 나면 그다음 한 줄이 잘 떠오르지 않습니다. 어디서부터 어디까지를 한 스키마로 묶을지, 무엇을 분리할지, 어떤 키워드를 어디에 넣을지를 한꺼번에 정해야 합니다.
첫 스키마를 작성할 때 발목을 잡는 건 문법이 아니라 결정입니다. type이 뭔지 외우는 건 한 번 읽으면 끝나지만, “이 객체에서 email을 필수로 둘지, 선택으로 둘지” 같은 결정은 데이터의 본질을 정하는 일이기도 합니다. 이 편에서는 첫 스키마를 직접 따라 만들어 보는 5단계 절차에서 출발해, 자주 쓰는 키워드를 자세히 다루고, 재귀적 데이터 구조와 스키마 참조($ref)를 짚은 뒤, 마지막으로 작성 과정에서 마주치는 다섯 가지 의사결정을 정리합니다.
1. 빈 파일에서 첫 스키마까지: 5단계로 따라가기
스키마를 처음 작성해 볼때는 단계를 하나씩 짚어가는 게 도움이 됩니다. 사용자 프로필을 표현하는 JSON 데이터를 떠올려 봅시다. 이름, 이메일, 생년월일, 관심 카테고리 배열, 그리고 주소 객체가 들어 있는 형태입니다.
| 단계 | 하는 일 | 이 예시에서는 | 흔한 실수 |
|---|---|---|---|
| 1단계 | 실제 데이터를 먼저 적어본다 | 김지윤의 프로필 JSON을 한 조각 적어봄 | 데이터 없이 머릿속으로만 스키마를 짜기 시작함 |
| 2단계 | 최상위 형식을 정한다 | 프로필 한 건이니까 "type": "object" | 프로필 목록인지 프로필 한 건인지를 안 정하고 넘어감 |
| 3단계 | 각 필드의 타입과 필수 여부를 정한다 | name은 문자열·필수, interests는 문자열 배열·선택 | required에 넣어야 할 키를 properties에만 적고 빠뜨림 |
| 4단계 | $schema와 $id를 넣는다 | 2020-12 다이얼렉트 선언 + 스키마 URI 부여 | $schema를 안 넣어서 검증기가 다이얼렉트를 모름 |
| 5단계 | 검증기로 스키마 자체가 유효한지 확인한다 | Hyperjump 같은 온라인 검증기에 붙여넣어봄 | 스키마에 오타나 문법 오류가 있는 채로 운영에 들어감 |
1) 예시 데이터부터 적어보기
머릿속으로만 생각하면 첫 줄이 안 써집니다. 실제 데이터를 먼저 적어보고 거기서 시작하는 게 빠릅니다. 사용자 프로필 데이터 한 조각을 적어보면 이렇습니다.
// 사용자 프로필 예시 데이터
{
"name": "김지윤",
"email": "jiyoon@example.com",
"birthdate": "1990-03-15",
"interests": ["coffee", "books"],
"address": {
"city": "Seoul",
"zip": "06134"
}
}
2) 최상위 타입 정하기
위 데이터는 프로필 한 건이니까 type은 object입니다. 프로필 여러 건을 담는 데이터라면 최상위는 array가 되고, 그 안의 요소가 객체가 됩니다. 최상위가 object냐 array냐에 따라 3단계에서 properties로 시작할지 items로 시작할지가 달라집니다.
3) 키워드로 제약 걸기
각 필드의 type을 정하고, 반드시 있어야 하는 키는 required 배열에 넣습니다. 이 단계가 첫 스키마 작성의 본 작업입니다.
{
// 최상위는 객체
"type": "object",
// name과 email은 반드시 있어야 함
"required": ["name", "email"],
"properties": {
// 문자열
"name": { "type": "string" },
// 문자열 + 이메일 형식
"email": { "type": "string", "format": "email" },
// 문자열 + 날짜 형식 (required에 없으므로 선택)
"birthdate": { "type": "string", "format": "date" },
// 문자열 배열 (선택)
"interests": {
"type": "array",
"items": { "type": "string" }
},
// 중첩 객체 (선택)
"address": {
"type": "object",
"properties": {
"city": { "type": "string" },
"zip": { "type": "string" }
}
}
}
}
4) $schema와 $id 넣기
$schema로 다이얼렉트를 선언하고, $id로 이 스키마의 URI를 붙입니다. 식별자 세부는 6편(운영)에서 다시 다루니, 첫 스키마에서는 이 두 줄만 넣으면 됩니다.
{
// 2020-12 다이얼렉트 사용
"$schema": "<https://json-schema.org/draft/2020-12/schema>",
// 이 스키마의 고유 식별자
"$id": "<https://example.com/schemas/user-profile>",
// 문서용 제목
"title": "User Profile",
"type": "object",
"required": ["name", "email"],
"properties": {
"name": { "type": "string" },
"email": { "type": "string", "format": "email" },
"birthdate": { "type": "string", "format": "date" },
"interests": {
"type": "array",
"items": { "type": "string" }
},
"address": {
"type": "object",
"properties": {
"city": { "type": "string" },
"zip": { "type": "string" }
}
}
}
}
5) 검증기로 스키마 자체를 검증하기
작성한 스키마가 유효한 JSON 스키마인지는 메타-스키마에 비춰보면 알 수 있습니다. Hyperjump 같은 온라인 검증기에 붙여넣으면 바로 확인됩니다. 스키마에 오타나 문법 오류가 있는 채로 운영에 들어가는 걸 막으려면, 이 단계를 빼먹지 않는 게 좋습니다.
2. 자주 쓰는 키워드 다섯 묶음
5단계 절차를 잡았으면, 이제 키워드를 좀 더 깊이 들여다볼 차례입니다. 첫 스키마에서 쓰는 키워드는 대부분 다음 다섯 묶음 안에 있습니다.
| 키워드 | 하는 일 | 주의할 점 |
|---|---|---|
type | 데이터 타입 제한 | number와 integer가 다름, null 허용 처리 |
required + properties | 필수 키 지정 + 키별 스키마 | 둘이 분리되어 있어서 빠뜨리기 쉬움 |
items / prefixItems | 배열 요소 제한 | 2020-12에서 의미가 바뀜, 이전 다이얼렉트와 다르게 동작 |
enum / const | 허용값 목록 / 단일 허용값 | enum 안의 값 타입과 type의 관계 |
minimum·maximum·pattern 등 | 숫자·문자열 범위 제한 | 경계값 포함 여부, 정규식 호환성 |
1) type: number와 integer는 다르다
JSON에서는 정수와 실수가 같은 Number 타입이지만, JSON 스키마는 둘을 구분합니다. number는 정수와 실수를 모두 받고, integer는 소수점이 있으면 거릅니다.
{
"type": "object",
"properties": {
// number: 정수, 실수 모두 통과
"price": { "type": "number" },
// integer: 정수만 통과
"quantity": { "type": "integer" }
}
}
// 통과: price는 실수 OK, quantity는 정수
{ "price": 29.5, "quantity": 3 }
// 실패: quantity에 소수점이 들어옴
{ "price": 29.5, "quantity": 3.5 }
null을 허용해야 하는 필드는 type을 배열로 씁니다.
{
"type": "object",
"properties": {
// 문자열이거나 null일 수 있는 필드
"nickname": { "type": ["string", "null"] }
}
}
// 통과: 문자열
{ "nickname": "지윤" }
// 통과: null
{ "nickname": null }
// 실패: 숫자는 허용 안 됨
{ "nickname": 123 }
2) required와 properties: 따로 놀아서 빠뜨리기 쉽다
“이름은 문자열이고 필수다”를 한 줄에 적고 싶지만, JSON 스키마는 타입과 필수 여부를 따로 선언합니다. properties에는 넣었는데 required에 안 넣는 실수가 첫 스키마에서 가장 흔합니다.
// required가 비어있는 스키마
{
"type": "object",
"required": [],
"properties": {
"name": { "type": "string" }
}
}
// 통과: name이 없어도 required에 안 들어있으니 통과됨
{}
// required에 name을 넣은 스키마
{
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string" }
}
}
// 실패: name이 없음 (required 위반)
{}
// 실패: name이 있지만 숫자임 (properties의 type 위반)
{ "name": 123 }
// 통과: name이 있고 문자열임
{ "name": "김지윤" }
3) items와 prefixItems: 2020-12에서 바뀐 키워드
배열 요소가 전부 같은 타입이면 items를 씁니다. 위치마다 다른 타입이면 prefixItems를 씁니다.
// 모든 요소가 같은 타입인 배열
{
"type": "object",
"properties": {
"interests": {
"type": "array",
// 배열의 모든 요소가 문자열이어야 함
"items": { "type": "string" }
}
}
}
// 통과: 전부 문자열
{ "interests": ["coffee", "books"] }
// 실패: 숫자가 섞여 있음
{ "interests": ["coffee", 123] }
// 위치마다 타입이 다른 튜플
{
"type": "object",
"properties": {
"record": {
"type": "array",
// 첫 번째는 문자열, 두 번째는 정수
"prefixItems": [
{ "type": "string" },
{ "type": "integer" }
]
}
}
}
// 통과: 순서대로 문자열, 정수
{ "record": ["김지윤", 29] }
// 실패: 순서가 뒤바뀜
{ "record": [29, "김지윤"] }
2020-12 이전 다이얼렉트에서는 items에 배열을 넘겨서 튜플을 처리했습니다. 같은 키워드가 다이얼렉트마다 다르게 동작하니, 쓰는 다이얼렉트를 먼저 확인하는 게 안전합니다. JSON 스키마 2020-12 릴리스 노트에 이 변경이 정리되어 있습니다.
4) enum과 const: 허용값 좁히기
enum은 허용값 목록, const는 딱 하나의 값만 허용합니다.
{
"type": "object",
"properties": {
// enum: 세 값 중 하나만 허용
"status": {
"type": "string",
"enum": ["pending", "paid", "shipped"]
},
// const: 정확히 이 값만 허용
"country": {
"const": "KR"
}
}
}
// 통과: status가 허용값 안에 있고, country가 "KR"
{ "status": "paid", "country": "KR" }
// 실패: "cancelled"는 enum에 없음
{ "status": "cancelled", "country": "KR" }
// 실패: "US"는 const와 다름
{ "status": "paid", "country": "US" }
enum을 쓰면 그 안의 값으로 타입이 자연스럽게 결정되기 때문에 type을 따로 안 넣어도 됩니다. 다만 enum 안에 서로 다른 타입의 값을 섞으면 (예: [1, "one", true]) 폼 생성기나 코드 생성기 같은 도구에서 예상과 다르게 동작할 수 있으니 주의가 필요합니다.
5) 범위 제약: 경계값을 포함하는가
minimum은 경계값을 포함하고, exclusiveMinimum은 제외합니다.
{
"type": "object",
"properties": {
// 0 이상 (0 포함)
"age": {
"type": "integer",
"minimum": 0
},
// 0 초과 (0 제외)
"price": {
"type": "integer",
"exclusiveMinimum": 0
}
}
}
// 통과: age는 0 포함, price는 0 초과
{ "age": 0, "price": 1 }
// 실패: price가 0이면 exclusiveMinimum 위반
{ "age": 0, "price": 0 }
// 실패: age가 음수이면 minimum 위반
{ "age": -1, "price": 100 }
pattern은 정규식으로 문자열을 제한합니다. ECMA-262 호환 정규식이 권장되고, 검증기마다 정규식 엔진이 다를 수 있어서 복잡한 정규식은 여러 검증기에서 테스트해보는 게 안전합니다.
{
"type": "object",
"properties": {
// 한국 휴대폰 번호 형식만 허용
"phone": {
"type": "string",
"pattern": "^010-[0-9]{4}-[0-9]{4}$"
}
}
}
// 통과: 형식에 맞음
{ "phone": "010-1234-5678" }
// 실패: 지역번호는 패턴에 안 맞음
{ "phone": "02-1234-5678" }
3. 재귀 구조: 스키마 안의 스키마
사실 재귀는 이미 여러 번 등장했습니다. properties 안의 각 필드 정의가 그 자체로 스키마이고, items 안에도 스키마가 들어가고, allOf·anyOf·oneOf·not 같은 키워드도 스키마를 받습니다. 1편에서 본 메타-스키마가 자기 자신을 가리키는 구조도 같은 원리입니다. JSON 스키마는 스키마 안에 스키마를 넣을 수 있게 설계되어 있습니다.
이 구조 덕분에 깊이를 미리 정할 수 없는 데이터도 표현할 수 있습니다. 대표적인 예가 댓글 트리입니다. 댓글에 답글이 달리고, 그 답글에 또 답글이 달릴 수 있습니다. 답글의 모양이 댓글과 같으니까, 스키마가 자기 자신을 다시 참조하면 됩니다.
// 댓글 스키마: replies가 자기 자신을 다시 참조
{
"$schema": "<https://json-schema.org/draft/2020-12/schema>",
"$id": "<https://example.com/schemas/comment>",
"type": "object",
"required": ["author", "body"],
"properties": {
"author": { "type": "string" },
"body": { "type": "string" },
// 답글 배열: 각 요소가 다시 이 스키마를 따름
"replies": {
"type": "array",
"items": { "$ref": "#" }
}
}
}
"$ref": "#"이 자기 자신을 가리킵니다. 이 스키마에 맞는 실제 데이터는 이렇게 생겼습니다.
// 댓글 → 답글 → 답글의 답글
{
"author": "김지윤",
"body": "좋은 글이네요",
"replies": [
{
"author": "이서준",
"body": "저도 그렇게 생각합니다",
"replies": [
{
"author": "김지윤",
"body": "감사합니다",
// replies가 비어있으면 여기서 끝
"replies": []
}
]
}
]
}
그림으로 보면 다음과 같습니다.
┌──────────────────────────────────────┐
│ comment │
│ ├ author (string) │
│ ├ body (string) │
│ └ replies (array) │
│ └─ items: $ref "#" │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ comment (자기 자신) │ │
│ │ ├ author │ │
│ │ ├ body │ │
│ │ └ replies │ │
│ │ └ items ─┐ │ │
│ └─────────────────│──┘ │
│ ▼ │
│ (다시 자기) │
└─────────────────────────────────────┘
자기 참조 스키마를 보면 “검증기가 무한 루프에 빠지지 않나?”라는 의문이 들 수 있습니다. 실제 데이터는 replies가 빈 배열인 지점에서 끝나기 때문에, 검증기도 거기서 멈춥니다. 무한히 깊어지는 실제 데이터는 만들어질 수가 없으니 무한 루프 걱정은 안 해도 됩니다.
4. 스키마 참조하기: $ref와 $defs
재귀를 다루면서 $ref가 등장했습니다. 자기 자신을 가리키는 데도 쓰이지만, $ref의 본래 용도는 다른 스키마를 끌어다 쓰는 것입니다. 같은 정의를 여러 곳에서 반복하지 않고 한 곳에 두고 참조하는 키워드입니다.
$ref는 세 가지 형태로 쓸 수 있습니다.
| 형태 | 예시 | 어떻게 동작하는가 | 언제 쓰는가 |
|---|---|---|---|
| 로컬 참조 | "$ref": "#/$defs/address" | #이 현재 문서 최상단, /$defs/address로 경로를 따라감 | 같은 파일 안에서 반복되는 구조를 한 곳에 두고 재사용할 때 |
| 외부 참조 | "$ref": "<https://example.com/schemas/address.json>" | URL에 있는 스키마 파일을 가져와서 적용 | 여러 스키마 파일이 같은 정의를 공유할 때 |
| 식별자 참조 | "$ref": "<https://example.com/schemas/address.json#name>" | $id나 $anchor로 이름 붙여둔 부분을 직접 가리킴 | 외부 스키마의 특정 부분만 골라서 참조할 때 |
같은 파일 안에서 재사용할 정의는 $defs 아래에 모아둡니다. 예를 들어 사용자 프로필에 자택 주소와 사무실 주소가 있는데 둘 다 같은 구조라면, 주소 정의를 $defs에 한 번 적고 양쪽에서 $ref로 가져다 씁니다.
{
"$schema": "<https://json-schema.org/draft/2020-12/schema>",
"$id": "<https://example.com/schemas/user-profile>",
"type": "object",
"properties": {
"name": { "type": "string" },
// 둘 다 같은 주소 정의를 참조
"homeAddress": { "$ref": "#/$defs/address" },
"officeAddress": { "$ref": "#/$defs/address" }
},
// 재사용할 정의를 여기에 모아둠
"$defs": {
"address": {
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string" },
"zip": { "type": "string" }
}
}
}
}
이 스키마에 맞는 데이터는 이렇습니다.
{
"name": "김지윤",
"homeAddress": {
"city": "Seoul",
"zip": "06134"
},
"officeAddress": {
"city": "Seongnam",
"zip": "13494"
}
}
$ref 값에 적힌 #/$defs/address는 JSON Pointer(RFC 6901) 문법입니다. #이 문서 최상단이고, /$defs로 들어가서, /address를 찾으라는 뜻입니다. 이 경로 표기는 외부 참조에서도 동일하게 쓰입니다.
참조 관계를 그림으로 보면 이렇습니다.
┌───────────────────────────────────────────────┐
│ user-profile.json │
│ │
│ homeAddress ──┐ │
│ ├──▶ $defs/address │
│ officeAddress ─┘ │ │
│ │ │
│ └─ city, zip 정의 │
└───────────────────────────────────────────────┘
$id와 $anchor라는 식별자 키워드도 있습니다. $id는 스키마에 URI를 부여하고, $anchor는 스키마 안의 특정 부분에 이름을 붙입니다. 식별자 세부는 6편(운영)에서 다시 다루니, 첫 스키마에서는 $id 한 줄만 넣어두면 됩니다.
1) 로컬 참조와 원격 참조: 언제 어느 쪽을 쓰는가
실무에서는 같은 파일 안에서 가리키느냐(로컬), 다른 파일이나 URL을 가리키느냐(원격)로 나눠서 생각하면 됩니다.
| 비교 축 | 로컬 참조 | 원격 참조 |
|---|---|---|
| 가리키는 곳 | 같은 파일 안 | 다른 파일·URL |
| 동작 방식 | $defs에 정의를 모아두고, $ref로 같은 파일 안에서 가져다 씀 | 별도 파일에 정의를 두고, $ref에 URL을 적어서 가져옴 |
| 언제 쓰는가 | 한 스키마 안에서 같은 구조가 반복될 때 | 여러 스키마 파일이 같은 정의를 공유할 때 |
| 예시 상황 | homeAddress와 officeAddress가 같은 구조 | 사용자·주문·배송 서비스가 전부 같은 주소 구조를 씀 |
| 장점 | 한 파일 안에서 바로 참조, 별도 인프라 불필요 | 조직 전체에서 정의를 통일, 한 번 고치면 모든 서비스에 반영 |
| 단점 | 정의가 많아지면 한 파일이 너무 커짐 | 네트워크에 의존, 스키마가 바뀌면 버전 관리 필요 |
| 예시 | "$ref": "#/$defs/address" | "$ref": "<https://schemas.example.com/common/address.json>" |
로컬 참조는 한 파일 안에서 같은 구조가 반복될 때 씁니다. 앞에서 본 homeAddress와 officeAddress가 같은 모양인 경우처럼, $defs에 한 번 정의해두고 양쪽에서 가져다 쓰는 식입니다. 다만 $defs 아래에 정의가 수십 개 쌓이면 파일 하나가 너무 커져서 오히려 관리가 힘들어집니다.
원격 참조는 여러 스키마 파일이 같은 정의를 공유할 때 씁니다. 예를 들어 한 조직에서 사용자 서비스, 주문 서비스, 배송 서비스가 전부 같은 주소 구조를 쓴다면, 주소 스키마를 별도 파일로 빼서 https://schemas.example.com/common/address.json에 호스팅하고 각 서비스에서 URL로 참조하는 게 깔끔합니다. 대신 네트워크에 의존하게 되고, 주소 스키마가 바뀌면 버전 관리를 해야 합니다.
실무에서 판단 기준은 간단합니다.
- 같은 프로젝트 안에서만 쓰는 정의 → 로컬 참조
- 여러 프로젝트가 공유하는 정의 → 원격 참조 (조직 내부에 호스팅)
- 산업 표준 정의 → 외부 원격 참조
처음부터 원격 참조로 시작하는 경우는 드뭅니다. 보통은 로컬 참조로 시작했다가, 같은 정의가 다른 곳에서도 필요해지는 시점에 원격으로 넘어갑니다.
1단계: 한 스키마 안에서 반복이 생김
사용자 서비스를 만들면서 homeAddress와 officeAddress가 같은 구조라는 걸 알게 됩니다. 이때 $defs에 주소 정의를 빼고 로컬 참조로 씁니다. 여기까지는 한 파일 안에서 끝나는 일입니다.
2단계: 다른 서비스에서 같은 구조가 필요해짐
몇 달 뒤 주문 서비스를 만드는데, 배송지 주소가 필요합니다. 사용자 서비스의 주소 구조와 똑같습니다. 급하니까 사용자 서비스의 $defs/address를 복사해서 주문 서비스 스키마에 붙여넣습니다. 이 시점에서 같은 정의가 두 파일에 존재합니다.
3단계: 정의가 어긋나기 시작함
사용자 서비스 쪽에서 주소에 country 필드를 추가합니다. 그런데 주문 서비스 쪽은 아무도 안 고칩니다. 두 서비스의 주소 구조가 달라지고, 데이터를 주고받을 때 검증이 어긋나기 시작합니다.
4단계: 원격 참조로 전환
이 시점에서 주소 정의를 별도 파일로 빼서 https://schemas.example.com/common/address.json에 호스팅하고, 양쪽 서비스가 이 URL을 $ref로 참조하게 바꿉니다. 이제 주소 구조를 고치면 한 곳만 수정하면 됩니다.
1단계 2단계 3단계 4단계
로컬 참조 복사·붙여넣기 정의 어긋남 원격 참조
user.json user.json user.json user.json
└ $defs/address └ $defs/address └ address+country └ $ref → ┐
order.json order.json order.json│
└ $defs/address └ address (옛날것) └ $ref → ┤
(복사본) ↑ 어긋남! ▼
address.json
(한 곳에서 관리)
그래서 판단 기준은 “지금 몇 곳에서 쓰고 있는가”보다 “앞으로 다른 곳에서도 쓸 가능성이 있는가”입니다. 한 프로젝트 안에서만 쓰인다면 로컬 참조로 충분하고, 복사·붙여넣기를 하는 순간이 오면 그때 원격 참조로 전환하면 됩니다.
2) $ref를 안 쓰면 어떻게 되는가
$ref 없이 같은 정의를 매번 직접 쓰면 어떻게 되는지 봅시다. 주소 구조를 쓰는 곳이 네 군데라면, 네 군데에 전부 같은 정의가 들어갑니다.
// $ref 없이: 같은 주소 정의가 네 번 반복됨
// user-profile.json
{
"type": "object",
"properties": {
"homeAddress": {
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string" },
"zip": { "type": "string" }
}
},
"officeAddress": {
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string" },
"zip": { "type": "string" }
}
}
}
}
// company.json, shipping.json에도 같은 정의가 또 들어감
이 상태에서 주소에 country 필드를 추가하려면, 네 군데를 전부 찾아서 고쳐야 합니다. 한 곳이라도 빠뜨리면 스키마끼리 어긋납니다.
// $ref 사용: 주소 정의는 한 곳에만
{
"type": "object",
"properties": {
"homeAddress": { "$ref": "#/$defs/address" },
"officeAddress": { "$ref": "#/$defs/address" }
},
"$defs": {
// 여기만 고치면 homeAddress, officeAddress 둘 다 반영됨
"address": {
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string" },
"zip": { "type": "string" }
}
}
}
}
그림으로 보면 차이가 분명합니다.
[$ref 미사용] [$ref 사용]
┌────────────────────────┐ ┌────────────────────────┐
│ user-profile │ │ user-profile │
│ home: {주소 정의} │ │ home: $ref ────┐ │
│ office: {주소 정의} │ │ office: $ref ────┤ │
└────────────────────────┘ └───────────────────┤────┘
┌────────────────────────┐ ┌───────────────────┤────┐
│ company │ │ company │ │
│ hq: {주소 정의} │ │ hq: $ref ────┤ │
└────────────────────────┘ └───────────────────┤────┘
┌────────────────────────┐ ┌───────────────────┤────┐
│ shipping │ │ shipping │ │
│ to: {주소 정의} │ │ to: $ref ────┤ │
└────────────────────────┘ └───────────────────┤────┘
│
▼
┌────────────────────────┐
│ address (한 곳) │
│ city, zip │
└────────────────────────┘
변경 시 네 곳 전부 수정 변경 시 한 곳만 수정
소프트웨어 공학의 DRY 원칙(Don’t Repeat Yourself)이 스키마에서도 그대로 적용됩니다. 정의가 한 곳에 있으면 수정도 한 번이면 끝나고, 모든 참조처에 자동으로 반영됩니다.
5. 첫 스키마를 쓸 때 정해야 할 다섯 가지
키워드도 익혔고, $ref로 참조하는 법도 잡았습니다. 하지만 정해야 할 게 남아있습니다. 이 결정들이 이후 검증·문서화·운영의 비용을 좌우하기 때문에 대충 넘기면 나중에 되돌리기 어렵습니다.
| 결정 | 선택지 | 예시 | 추천 기준 |
|---|---|---|---|
| 1. 한 스키마에 뭘 담을 것인가 | 한 엔티티만 / 여러 엔티티 묶음 | 사용자·주문·결제를 한 파일에? 각각 따로? | 한 엔티티 = 한 스키마. 필요하면 $ref로 연결 |
| 2. 어디까지 필수로 둘 것인가 | 모든 키 / 핵심 키만 / 거의 없음 | 주문에서 order_id는 필수, note는 선택 | 이게 없으면 데이터가 의미를 잃는 키만 필수 |
| 3. 정의 안 된 필드를 허용할 것인가 | additionalProperties: true / false | 클라이언트가 nickname을 몰래 보내면? | 외부 API는 false, 내부 진화 중인 데이터는 true |
4. 언제 $ref로 분리할 것인가 | 인라인 / 로컬 $defs / 원격 파일 | 주소 구조가 두 곳에서 반복됨 | 두 곳 이상에서 같은 구조가 나오면 분리 |
| 5. 어느 다이얼렉트를 쓸 것인가 | 2020-12 / Draft 7 | 쓰려는 검증기가 2020-12를 지원하는가? | 새 프로젝트는 2020-12, 도구가 안 되면 Draft 7 |
1) 한 스키마에 뭘 담을 것인가
사용자·주문·결제를 한 스키마에 다 넣으면, 주문 필드 하나를 고칠 때 사용자와 결제 검증까지 흔들릴 수 있습니다. 한 엔티티 = 한 스키마로 나누고, 엔티티 사이 관계가 필요하면 $ref로 연결하는 게 안전합니다.
2) 어디까지 필수로 둘 것인가
모든 키를 required에 넣으면 데이터를 보내는 쪽의 부담이 커집니다. 반대로 required가 비어있으면 빈 객체 {}도 통과되니 검증하는 의미가 없어집니다. “이 키가 없으면 이 데이터가 뭔지 알 수 없다”에 해당하는 키만 필수로 두면 됩니다. 1편의 주문 예시에서 order_id와 status는 필수지만 note는 없어도 주문이 성립합니다.
3) 정의 안 된 필드를 허용할 것인가
additionalProperties: false를 넣으면 스키마에 정의하지 않은 필드가 들어왔을 때 실패합니다. 외부 파트너와 주고받는 API에는 이 엄격함이 안전장치가 됩니다. 오타("emial")도 잡아주고, 예상 밖의 필드("role": "admin")도 차단됩니다. 반대로 내부에서 빠르게 바뀌는 데이터라면 true로 두고 새 필드를 자유롭게 추가할 여지를 남기는 게 나을 수 있습니다.
4) 언제 $ref로 분리할 것인가
같은 구조가 한 곳에서만 쓰이는데 미리 $defs로 빼면 오히려 읽기 불편해집니다. 같은 구조가 두 곳 이상에서 반복되는 시점에 분리하면 됩니다. 앞에서 본 것처럼, 복사·붙여넣기를 하는 순간이 분리 타이밍입니다.
5) 어느 다이얼렉트를 쓸 것인가
2020-12가 JSON Schema 명세의 최신판이고, OpenAPI 3.1도 2020-12를 채택했습니다. 새 프로젝트라면 2020-12로 시작하는 게 맞습니다. 다만 Draft 7이 아직 가장 폭넓게 지원되는 현실도 있어서, 쓰려는 검증기나 도구가 2020-12를 지원하는지 먼저 확인하고, 안 되면 Draft 7로 시작해서 점진적으로 옮기는 게 안전합니다.
마무리
이 글에서는 예시 데이터에서 출발해 첫 스키마를 완성하고, 키워드를 깊이 다루고, $ref로 정의를 재사용하는 법까지 다뤘습니다.
돌아보면, 스키마를 쓰는 과정에서 계속 결정을 내렸습니다. name은 필수로 둘지, birthdate는 선택으로 둘지. 주소 구조를 인라인으로 둘지, $defs로 분리할지. additionalProperties를 false로 잠글지, true로 열어둘지. 이 결정들은 단순히 스키마 파일 하나의 모양을 정하는 일이 아닙니다. “우리 데이터는 이런 모양이고, 이 범위 밖의 데이터는 받지 않겠다”는 합의를 만드는 일입니다.
이 합의가 한 번 잡히면 그 위에 검증이 올라가고, 문서화가 올라가고, 자동화가 올라갑니다. 1편에서 JSON 스키마의 네 가지 역할(검증·문서화·계약·자동화)을 다뤘는데, 그 출발점이 바로 이 첫 스키마입니다. 첫 스키마가 얼마나 정확하게 데이터의 모양을 잡느냐에 따라 그 위에 쌓이는 것들의 품질이 달라집니다.
3편에서는 이 스키마에 실제 데이터를 넣어봅니다. 어떤 데이터가 통과하고, 어떤 데이터가 걸리고, 걸렸을 때 검증기가 어떤 리포트를 내주는지. 스키마를 쓰는 게 “규칙을 정하는 일”이었다면, 검증은 그 규칙이 실제로 작동하는지 확인하는 것입니다.
JSON 시리즈

