·

[JSON] (2) JSON 스키마 작성하기: 빈 파일에서 데이터 구조 정의까지

첫 JSON 스키마를 어디서부터 어떻게 작성해야 할까요? 예시를 바탕으로 핵심 키워드를 설명하고, 재귀 구조·스키마 참조·additionalProperties·다이얼렉트 선택까지 첫 스키마 작성에서 마주치는 실무 결정을 단계별로 정리합니다.

밝은 베이지색 배경 위에 “JSON”과 “(2) JSON 스키마 작성하기”라는 제목이 배치된 프레젠테이션 스타일 이미지. 오른쪽에는 중괄호 형태 장식과 함께 JSON 예시 코드가 적힌 파란색 보드가 있으며, 코드에는 이름, 나이, 기술 스택(JavaScript, Python, SQL), 주소 정보가 포함되어 있다. 좌측 상단에는 “made with DALL-E 3” 문구가 작게 표시되어 있고, 하단 중앙에는 작은 로고 아이콘이 있다.

도입을 결정하고 나면 그다음은 빈 파일 앞에 서는 일입니다. 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) 최상위 타입 정하기

위 데이터는 프로필 한 건이니까 typeobject입니다. 프로필 여러 건을 담는 데이터라면 최상위는 array가 되고, 그 안의 요소가 객체가 됩니다. 최상위가 objectarray냐에 따라 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데이터 타입 제한numberinteger가 다름, null 허용 처리
required + properties필수 키 지정 + 키별 스키마둘이 분리되어 있어서 빠뜨리기 쉬움
items / prefixItems배열 요소 제한2020-12에서 의미가 바뀜, 이전 다이얼렉트와 다르게 동작
enum / const허용값 목록 / 단일 허용값enum 안의 값 타입과 type의 관계
minimum·maximum·pattern숫자·문자열 범위 제한경계값 포함 여부, 정규식 호환성

1) type: numberinteger는 다르다

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) requiredproperties: 따로 놀아서 빠뜨리기 쉽다

“이름은 문자열이고 필수다”를 한 줄에 적고 싶지만, 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) itemsprefixItems: 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) enumconst: 허용값 좁히기

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/addressJSON 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을 적어서 가져옴
언제 쓰는가한 스키마 안에서 같은 구조가 반복될 때여러 스키마 파일이 같은 정의를 공유할 때
예시 상황homeAddressofficeAddress가 같은 구조사용자·주문·배송 서비스가 전부 같은 주소 구조를 씀
장점한 파일 안에서 바로 참조, 별도 인프라 불필요조직 전체에서 정의를 통일, 한 번 고치면 모든 서비스에 반영
단점정의가 많아지면 한 파일이 너무 커짐네트워크에 의존, 스키마가 바뀌면 버전 관리 필요
예시"$ref": "#/$defs/address""$ref": "<https://schemas.example.com/common/address.json>"

로컬 참조는 한 파일 안에서 같은 구조가 반복될 때 씁니다. 앞에서 본 homeAddressofficeAddress가 같은 모양인 경우처럼, $defs에 한 번 정의해두고 양쪽에서 가져다 쓰는 식입니다. 다만 $defs 아래에 정의가 수십 개 쌓이면 파일 하나가 너무 커져서 오히려 관리가 힘들어집니다.

원격 참조는 여러 스키마 파일이 같은 정의를 공유할 때 씁니다. 예를 들어 한 조직에서 사용자 서비스, 주문 서비스, 배송 서비스가 전부 같은 주소 구조를 쓴다면, 주소 스키마를 별도 파일로 빼서 https://schemas.example.com/common/address.json에 호스팅하고 각 서비스에서 URL로 참조하는 게 깔끔합니다. 대신 네트워크에 의존하게 되고, 주소 스키마가 바뀌면 버전 관리를 해야 합니다.

실무에서 판단 기준은 간단합니다.

처음부터 원격 참조로 시작하는 경우는 드뭅니다. 보통은 로컬 참조로 시작했다가, 같은 정의가 다른 곳에서도 필요해지는 시점에 원격으로 넘어갑니다.

1단계: 한 스키마 안에서 반복이 생김

사용자 서비스를 만들면서 homeAddressofficeAddress가 같은 구조라는 걸 알게 됩니다. 이때 $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_idstatus는 필수지만 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로 분리할지. additionalPropertiesfalse로 잠글지, true로 열어둘지. 이 결정들은 단순히 스키마 파일 하나의 모양을 정하는 일이 아닙니다. “우리 데이터는 이런 모양이고, 이 범위 밖의 데이터는 받지 않겠다”는 합의를 만드는 일입니다.

이 합의가 한 번 잡히면 그 위에 검증이 올라가고, 문서화가 올라가고, 자동화가 올라갑니다. 1편에서 JSON 스키마의 네 가지 역할(검증·문서화·계약·자동화)을 다뤘는데, 그 출발점이 바로 이 첫 스키마입니다. 첫 스키마가 얼마나 정확하게 데이터의 모양을 잡느냐에 따라 그 위에 쌓이는 것들의 품질이 달라집니다.

3편에서는 이 스키마에 실제 데이터를 넣어봅니다. 어떤 데이터가 통과하고, 어떤 데이터가 걸리고, 걸렸을 때 검증기가 어떤 리포트를 내주는지. 스키마를 쓰는 게 “규칙을 정하는 일”이었다면, 검증은 그 규칙이 실제로 작동하는지 확인하는 것입니다.


JSON 시리즈

(1) JSON과 JSON 스키마 이해하기

(2) JSON 스키마 작성하기

(3) JSON 스키마로 데이터 검증하기

(4) JSON 스키마에 어노테이션으로 의미와 맥락 담기

(5) JSON 스키마 어휘 확장하기

(6) JSON 스키마를 운영에 적용하기