·

[JSON] (4) JSON 스키마에 어노테이션으로 의미와 맥락 담기: 데이터가 스스로를 설명하게 만들기

검증을 통과한 데이터라도 그 값이 무엇을 의미하는지는 여전히 불분명할 수 있습니다. 이 글에서는 JSON 스키마의 어노테이션 시스템을 중심으로 데이터의 구조·의미·맥락을 하나의 스키마에 통합하는 방법을 설명합니다.

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

검증을 통과한 데이터를 받았는데도 그 데이터가 무엇을 의미하는지 알 수 없는 일이 잦습니다. 회원가입 이벤트 한 건이 들어왔는데 state 필드의 값이 "CA"라고 합시다. 검증은 무사히 통과합니다. 문자열이고 두 글자고 빈 값도 아닙니다. 그런데 이 값을 받아 든 사람은 한참을 들여다봅니다. 미국 주 약자인지, 회원 가입 단계의 상태 코드인지, 주문 처리 상태인지가 값만으로는 알 수 없기 때문입니다.

이 답답함이 4편의 출발점입니다. 3편에서는 잘못된 데이터를 시스템 입구에서 막는 검증의 메커니즘과 도구를 다뤘습니다. 그러나 검증은 “이 데이터가 형식을 따르는가”에 답할 뿐, “이 데이터가 무엇을 의미하는가, 왜 만들어졌는가, 어떻게 쓰여야 하는가”에는 답하지 않습니다. 통과한 데이터를 받아 든 다음에 사람과 시스템이 의미를 다시 짚어야 하는 문제가 남습니다. 4편에서는 위키 문서에 흩어져 있던 그 의미를 스키마 안에 담는 방법을 다룹니다.


1. 검증을 넘어 의미로: 단언과 어노테이션

JSON 스키마의 키워드는 두 갈래로 나뉩니다. 단언(assertion, 어서션)과 어노테이션(annotation)입니다.

갈래역할검증에 영향대표 키워드
단언인스턴스가 만족해야 할 조건 선언있음type, required, minimum, enum, pattern
어노테이션인스턴스에 관한 메타 정보 첨부없음title, description, examples, default, deprecated, readOnly, writeOnly

단언 키워드는 인스턴스가 만족해야 할 조건을 선언합니다. type: "string"은 “이 값은 문자열이어야 한다”, required: ["userId"]는 “이 키가 반드시 있어야 한다”, minimum: 0은 “이 숫자는 0 이상이어야 한다”는 선언입니다. 조건이 만족되지 않으면 검증이 실패합니다.

반면, 어노테이션 키워드는 검증 통과·실패에 영향을 주지 않습니다. 인스턴스에 관한 정보를 사람과 도구에 전달할 뿐입니다. title로 짧은 제목을 붙이고, description으로 의미와 맥락을 설명하고, examples로 예시 값을 남기고, default로 기본값을 지정합니다. 어노테이션이 있든 없든 검증 결과는 바뀌지 않습니다.

아래 스키마에서 두 갈래가 어떻게 공존하는지 보겠습니다.

{
  "type": "object",
  "required": ["userId", "channel"],
  "properties": {
    "userId": {
      // --- 단언: 검증에 영향 ---
      "type": "string",
      "pattern": "^U[0-9]{8}$",
      // --- 어노테이션: 검증에 영향 없음 ---
      "title": "사용자 ID",
      "description": "U + 숫자 8자리로 구성된 고유 식별자. 회원가입 시 자동 생성된다.",
      "examples": ["U00000001", "U12345678"]
    },
    "channel": {
      "type": "string",
      "enum": ["organic", "ad", "referral"],
      "title": "유입 채널",
      "description": "사용자가 서비스에 처음 도달한 경로. 마케팅 어트리뷰션 분석에 쓰인다.",
      "default": "organic"
    },
    "agreedToTerms": {
      "type": "boolean",
      "title": "이용약관 동의 여부",
      "deprecated": true,
      "description": "v2에서 consent 객체로 대체 예정. 2025-06 이후 제거된다."
    }
  }
}

type, required, pattern, enum은 단언입니다. 조건을 만족하지 않으면 검증이 실패합니다. title, description, examples, default, deprecated는 어노테이션입니다. 이 키워드를 전부 지워도 검증 결과는 동일합니다. 그러나 이 키워드가 있기 때문에 스키마를 읽는 사람은 userId가 어떤 형태인지, channel이 왜 존재하는지, agreedToTerms가 언제 사라지는지를 스키마만 보고 파악할 수 있습니다.

갈래역할검증에 영향대표 키워드
단언인스턴스가 만족해야 할 조건 선언있음type, required, minimum, enum, pattern
어노테이션인스턴스에 관한 메타 정보 첨부없음title, description, examples, default, deprecated, readOnly, writeOnly

1) 표준 어노테이션 키워드

표준 어노테이션 키워드는 Meta-Data 보캐뷸러리 안에 모여 있습니다. 2020-12 기본 메타스키마가 이 보캐뷸러리를 포함하기 때문에, 별도 설정 없이 바로 쓸 수 있습니다.

키워드역할위 스키마의 사용 예
title짧은 사람 친화적 제목"사용자 ID", "유입 채널"
description긴 설명. 의미·목적·맥락"U + 숫자 8자리로 구성된 고유 식별자..."
examples예시 값 배열["U00000001", "U12345678"]
default기본값"organic"
deprecated폐기 예정 표시true (agreedToTerms 필드)
readOnly읽기 전용 표시서버가 생성하는 id, createdAt 등에 사용
writeOnly쓰기 전용 표시클라이언트가 보내지만 응답에는 빠지는 password 등에 사용

2) 어노테이션이 실제로 쓰이는 곳

어노테이션이 포함된 스키마는 검증기뿐 아니라 여러 도구가 함께 소비합니다. React JSON Schema Formtitle을 폼 라벨로, default를 입력 칸 초기값으로, readOnly를 입력 비활성화에 씁니다. Swagger·OpenAPI 도구는 descriptionexamples로 API 문서 페이지를 자동 생성합니다. IDE 자동완성은 titledescription을 코드 작성 중 도움말로 표시합니다. 데이터 카탈로그 도구는 같은 어노테이션으로 데이터 사전 항목을 자동 갱신합니다.

어노테이션은 단순한 주석이 아닙니다. 스키마 한 곳에 의미를 담아 두면 폼·문서·자동완성·카탈로그가 그 정보를 각자의 용도로 가져다 씁니다. 위키에 흩어져 있던 데이터 정의를 스키마 안으로 끌어들이는 결정의 효과가 여기에 있습니다.


2. 어노테이션 추출: 검증 결과에서 메타 정보를 함께 꺼내기

1절에서 다룬 어노테이션은 스키마에 정적으로 적혀 있는 것이었습니다. userIdtitle은 항상 “사용자 ID”이고, channeldefault는 항상 "organic"입니다. 이런 경우에는 도구가 스키마 파일을 직접 읽어서 어노테이션을 꺼내 쓰면 됩니다.

그런데 스키마에 anyOfif/then 같은 조건 분기가 들어가면 상황이 달라집니다. 같은 필드라도 인스턴스 값에 따라 적용되는 어노테이션이 바뀌기 때문에, 스키마만 읽어서는 “이 인스턴스의 이 필드에 실제로 적용되는 어노테이션이 뭔지”를 결정할 수 없습니다.

1) 조건에 따라 어노테이션이 달라지는 경우

주문 데이터를 예로 보겠습니다. 주문이 결제 전(pending)이면 배송지를 아직 수정할 수 있지만, 배송이 시작되면(shipped) 배송지는 택배사에 넘어간 확정 주소이므로 수정할 수 없어야 합니다.

{
  "type": "object",
  "required": ["status", "shippingAddress"],
  "anyOf": [
    {
      // 결제 전: 배송지 수정 가능
      "properties": {
        "status":          { "const": "pending" },
        "shippingAddress": {
          "description": "주문자가 지정한 배송 희망 주소. 결제 전에는 변경 가능."
        }
      }
    },
    {
      // 배송 시작: 배송지 확정, 수정 불가
      "properties": {
        "status":          { "const": "shipped" },
        "shippingAddress": {
          "readOnly": true,
          "description": "택배사에 전달된 확정 배송 주소."
        }
      }
    }
  ]
}
// 인스턴스 A: status가 "pending"
{ "status": "pending", "shippingAddress": "서울시 강남구 ..." }
// → 첫 번째 서브스키마 매칭
// → 수집되는 어노테이션: description "주문자가 지정한 배송 희망 주소. 결제 전에는 변경 가능."

// 인스턴스 B: status가 "shipped"
{ "status": "shipped", "shippingAddress": "서울시 강남구 ..." }
// → 두 번째 서브스키마 매칭
// → 수집되는 어노테이션: readOnly true, description "택배사에 전달된 확정 배송 주소."

같은 shippingAddress 필드인데, status 값에 따라 수집되는 어노테이션이 다릅니다. 이 처리를 폼 렌더러나 문서 생성기가 직접 하려면 JSON 스키마의 조건 분기 로직을 재구현해야 합니다.

2) 어노테이션 추출이란

JSON 스키마 검증기는 원래 통과/실패만 판정하면 끝입니다. 스키마에 title이나 description이 적혀 있어도, 검증 결과에는 “통과” 또는 “실패”에 관련된 내용만 나옵니다.

어노테이션 추출(annotation extraction)은 이 동작을 확장한 것입니다. 검증기가 통과/실패를 판정하는 것에 더해, 인스턴스의 각 위치에 매칭된 어노테이션 키워드의 값을 함께 수집하고, 검증 결과에 포함시켜 출력합니다. 2019-09 드래프트에서 표준 메커니즘으로 도입되었습니다.

어노테이션 추출이 켜진 검증기로 위 주문 스키마의 인스턴스 B(status: "shipped")를 검증하면, 결과는 다음과 같은 형태가 됩니다.

검증 결과:
  - 통과/실패: 통과
  - 어노테이션:
    - /shippingAddress:
        readOnly: true
        description: "택배사에 전달된 확정 배송 주소."

어노테이션 추출이 꺼진 검증기였다면 “통과”만 나오고 끝입니다. 추출이 켜져 있기 때문에, 검증기가 두 번째 서브스키마가 매칭되었다는 사실을 이용해 해당 어노테이션까지 수집해준 것입니다.

이 결과를 받아 가는 쪽은 크게 세 곳입니다.

활용하는 곳활용하는 어노테이션동작
폼 렌더러readOnly, description, defaultreadOnly: true 필드를 비활성화, description을 필드 설명으로 표시, default로 초기값 채움
API 문서 생성기title, description, examples엔드포인트별 필드 설명을 자동 생성
데이터 카탈로그deprecated, description폐기 예정 필드를 자동 표시, 마이그레이션 안내 연결

스키마의 조건 분기 로직을 각 도구가 직접 해석할 필요 없이, 검증기가 수집한 결과를 가져다 쓰면 되는 구조입니다.

3) JSON 스키마 내부에서도 쓰이는 어노테이션

어노테이션 추출은 외부 도구만을 위한 메커니즘이 아닙니다. JSON 스키마 내부에서 키워드 간 통신에도 쓰입니다.

대표적인 예가 unevaluatedProperties입니다. 이 키워드는 “다른 키워드(properties, patternProperties, additionalProperties 등)에 의해 아직 평가되지 않은 프로퍼티”에 대해 제약을 거는 키워드입니다. 동작하려면 “어떤 프로퍼티가 이미 평가되었는가”를 알아야 하는데, 이 정보는 properties 같은 키워드가 검증 과정에서 남기는 어노테이션입니다.

{
  "type": "object",
  "allOf": [
    {
      "properties": {
        "name":  { "type": "string" },
        "email": { "type": "string", "format": "email" }
      }
    }
  ],
  // unevaluatedProperties의 값은 스키마.
  // false = "어떤 값도 통과시키지 않는 스키마"
  // → name, email 외의 프로퍼티가 있으면 실패
  "unevaluatedProperties": false
}
// ✅ 통과: name과 email은 properties에서 평가됨, 그 외 프로퍼티 없음
{ "name": "홍길동", "email": "hong@example.com" }

// ❌ 실패: age는 어느 키워드에서도 평가되지 않았으므로 unevaluatedProperties에 걸림, "must NOT have unevaluated properties" 반환
{ "name": "홍길동", "email": "hong@example.com", "age": 30 }

properties가 검증 과정에서 “나는 nameemail을 평가했다”는 어노테이션을 남기고, unevaluatedProperties가 그 어노테이션을 읽어서 “그러면 나머지 프로퍼티에 대해 false를 적용하겠다”고 판단하는 구조입니다. 어노테이션 수집이 없으면 이 키워드는 동작할 수 없습니다.

4) 검증기별 어노테이션 추출 지원

모든 검증기가 어노테이션 추출을 지원하지는 않습니다. 어노테이션을 수집하려면 검증기가 단축 평가(short-circuit)를 하지 못합니다. 예를 들어 anyOf에서 첫 번째 서브스키마가 통과하더라도, 나머지 서브스키마가 어노테이션을 남길 수 있기 때문에 전부 평가해야 합니다. 어노테이션 수집을 켜면 검증 성능이 떨어질 수 있다는 뜻이므로, 필요한 경우에만 활성화하는 것이 일반적입니다.

검증기언어어노테이션 추출 지원
HyperjumpJavaScript기본 동작으로 지원
json-everythingC#지원. List·Hierarchical 출력 포맷에서 어노테이션 포함
AjvJavaScript2020-12 검증은 지원하지만, 표준 어노테이션 수집은 미완성

자바스크립트 진영에서 가장 많이 쓰이는 Ajv는 검증 자체는 잘 동작하지만, 표준 출력 포맷의 어노테이션 수집이 완전한 형태로 들어가 있지 않습니다. 어노테이션 추출이 핵심인 프로젝트라면 검증기를 선택할 때 이 차이를 확인해야 합니다.


3. 점진적 폐기: deprecated 키워드

API나 데이터 형식의 한 필드를 정리하려고 할 때, 한 번에 끊으면 그 필드를 쓰는 클라이언트가 깨집니다. 한동안 두 형식을 동시에 살려 두고, 클라이언트가 새 형식으로 옮겨 갈 시간을 주는 것이 일반적입니다.

deprecated: true는 이 과도기에 쓰는 어노테이션입니다. 검증을 막지 않으면서 “곧 사라질 필드”임을 스키마에 표시합니다. 기존 클라이언트가 그 필드를 계속 보내도 시스템은 통과시키되, 이 스키마를 읽는 도구나 문서 생성기는 해당 필드에 경고를 띄우거나 마이그레이션 안내를 표시합니다.

결제 API에서 카드 정보 필드 구조를 변경하는 사례로 보겠습니다. v1에서는 cardNumber에 카드번호를 평문 문자열로 받았는데, v2에서는 보안 요구사항에 따라 paymentToken으로 토큰화된 값을 받도록 바꿉니다. 두 형식을 동시에 받는 전환 단계의 스키마입니다.

{
  "$schema": "<https://json-schema.org/draft/2020-12/schema>",
  "$id": "<https://example.com/api/v1.5/payment>",
  "type": "object",
  "properties": {
    "cardNumber": {
      "type": "string",
      "pattern": "^[0-9]{13,19}$",
      // deprecated + description으로 폐기 사실과 마이그레이션 방향을 함께 전달
      "deprecated": true,
      "description": "평문 카드번호. v2부터 paymentToken으로 대체. 가이드: <https://example.com/migrations/payment-token>"
    },
    "paymentToken": {
      "type": "string",
      "description": "PCI DSS 규격에 따라 토큰화된 결제 수단 식별자.",
      "examples": ["tok_1234567890abcdef"]
    }
  }
}

deprecateddescription과 함께 쓸 때 효과가 커집니다. description에 왜 폐기되는지와 마이그레이션 안내를 적으면, 스키마를 읽는 것만으로 어떤 필드로 옮겨야 하는지까지 알 수 있습니다.

이 폐기 흐름을 단계별로 정리하면 다음과 같습니다.

단계스키마 상태동작클라이언트 권장 행동
안정 (v1)cardNumber만 존재, deprecated 없음정상 통과, 경고 없음기존 형식 그대로 사용
전환 (v1.5)두 필드 공존, cardNumberdeprecated: true + 마이그레이션 안내통과하되 도구·문서에 경고 표시paymentToken으로 마이그레이션 시작
정리 (v2)cardNumber 제거, paymentToken만 남음옛 필드를 보내면 검증 단계에서 차단마이그레이션 완료

4. 빌드 타임과 런타임: 어노테이션을 언제 꺼내 쓰는가

어노테이션을 꺼내 쓰는 시점은 두 가지로 나뉩니다.

[빌드 타임 추출]
┌──────────┐      ┌──────────┐      ┌──────────────┐
│  스키마    │ ──►  │  컴파일   │ ──►  │   정적 산출물   │
│          │      │  (한 번)  │      │  (API 문서,   │
│          │      │          │      │   카탈로그)    │
└──────────┘      └──────────┘      └──────────────┘

[런타임 추출]
┌──────────┐
│  스키마    │ ──┐
└──────────┘   │    ┌──────────┐      ┌──────────────┐
               ├──► │  검증기    │ ──► │  검증 결과 +    │
┌──────────┐   │    │  (매번)   │      │  어노테이션 맵  │
│ 인스턴스   │ ──┘    └──────────┘      └──────────────┘
└──────────┘
빌드 타임 추출런타임 추출
시점배포 전, 스키마 변경 시검증 시점, 요청마다
입력스키마만스키마 + 인스턴스
조건 분기 처리불가. 정적으로 읽을 수 있는 것만가능. 인스턴스 값에 따라 매칭된 어노테이션 수집
산출물API 문서, 카탈로그, IDE 정의 파일검증 결과 + 어노테이션 맵
비용한 번 생성, 이후 무료매 검증마다 수집 비용 발생

빌드 타임 추출은 스키마를 미리 읽어서 정적 산출물을 만드는 방식입니다. API 문서 페이지, 데이터 카탈로그 항목, IDE 자동완성용 정의 파일이 여기에 해당합니다. 스키마가 바뀌지 않는 한 한 번 만들어 두면 계속 쓸 수 있습니다. 인스턴스가 필요 없고, 스키마 파일만 읽으면 됩니다.

런타임 추출은 2절에서 다룬 어노테이션 추출입니다. 검증기가 인스턴스를 검증하면서 매칭된 어노테이션을 함께 수집해 결과에 담아 줍니다. 빌드 타임과 달리 인스턴스 값이 있어야 동작하고, 조건 분기에 따라 수집되는 어노테이션이 달라집니다.

1) 빌드 타임이 필요한 경우와 런타임이 필요한 경우

빌드 타임 추출의 목적은 스키마의 구조를 문서화하는 것입니다. “이 API의 필드가 뭐고 각각 무슨 뜻인지”를 사람이나 도구에게 알려주는 것입니다.

[빌드 타임 추출]
┌──────────┐      ┌──────────┐      ┌──────────────┐
│   스키마   │ ──►  │  컴파일   │ ──►  │   정적 산출물   │
│          │      │  (한 번)  │      │  (API 문서,   │
│          │      │          │      │   카탈로그)    │
└──────────┘      └──────────┘      └──────────────┘

빌드 타임 추출은 스키마가 바뀔 때만 한 번 실행하면 됩니다. 조건 분기 없이 정적으로 적혀 있는 어노테이션을 꺼내는 경우에 씁니다.

사용 사례꺼내 쓰는 어노테이션도구 동작
API 문서 자동 생성title, description, examplesSwagger UI·Redoc이 스키마를 읽어 문서 페이지 자동 생성
데이터 카탈로그 갱신title, description, deprecated카탈로그 도구가 스키마를 읽어 데이터 사전 항목 자동 갱신
IDE 자동완성title, description, defaultIDE가 스키마를 읽어 코드 작성 중 도움말·기본값 힌트 표시

런타임 추출의 목적은 특정 인스턴스에 실제로 적용되는 메타 정보를 결정하는 것입니다. “이 요청의 이 필드가 지금 수정 가능한지, 어떤 설명이 붙는지”를 검증 시점에 확정하는 것입니다.

[런타임 추출]
┌──────────┐
│  스키마    │ ──┐
└──────────┘   │    ┌──────────┐      ┌──────────────┐
               ├──► │  검증기    │ ──►  │  검증 결과 +   │
┌──────────┐   │    │  (매번)   │      │  어노테이션 맵  │
│ 인스턴스   │ ──┘    └──────────┘      └──────────────┘
└──────────┘

런타임 추출은 인스턴스 값에 따라 적용되는 어노테이션이 달라지는 경우에 씁니다. 2절의 주문 예제처럼 status"pending"인지 "shipped"인지에 따라 readOnly 여부가 바뀌는 경우, 빌드 타임에는 답을 낼 수 없고 요청이 들어온 시점에 검증기가 수집해야 합니다.

사용 사례꺼내 쓰는 어노테이션동작
상태별 폼 제어readOnly, writeOnly주문 상태에 따라 배송지 입력 칸 잠금/해제
권한별 필드 표시readOnly, writeOnly일반 사용자에게는 잠그고 관리자에게는 열기
조건별 필드 설명description인스턴스 값에 따라 다른 설명 표시

빌드 타임으로는 미리 만들어 둘 수 없는 결정이 있습니다. 2절의 주문 예제가 그 경우입니다. status"pending"인지 "shipped"인지는 요청이 들어와야 알 수 있고, 그에 따라 shippingAddressreadOnly가 붙는지 여부가 결정됩니다. 이 외에도 사용자 권한에 따라 readOnly·writeOnly를 다르게 적용하거나, 사용자 로케일에 따라 다른 description을 내려주는 경우가 런타임 추출이 필요한 자리입니다.

2) 런타임 추출의 성능 비용

런타임 추출을 켜면 검증 성능이 떨어질 수 있습니다. anyOf에서 첫 번째 서브스키마가 통과하더라도, 나머지 서브스키마가 어노테이션을 남길 수 있기 때문에 전부 평가해야 합니다. 어노테이션을 수집하지 않을 때는 한 분기만 통과하면 나머지를 건너뛸 수 있는데, 수집을 켜는 순간 이 단축 평가가 사라집니다. JSON 스키마 명세도 이 비용을 명시하고 있으므로, 어노테이션이 실제로 필요한 경우에만 수집을 켜는 것이 일반적입니다.


5. 표준 출력 포맷: 검증 결과를 도구 사이에서 주고받는 약속

검증기가 어노테이션을 수집하더라도, 결과를 검증기마다 다른 형식으로 내놓으면 도구 통합이 막힙니다. 폼 렌더러는 구조 A를 기대하고, 문서 생성기는 구조 B를 기대하면 사이마다 변환 코드를 끼워 넣어야 합니다.

JSON Schema 2020-12 Core 명세는 이 문제를 풀기 위해 4가지 표준 출력 포맷을 정의합니다. 정보량이 적은 쪽부터 많은 쪽으로 나열하면 Flag, Basic, Detailed, Verbose입니다.

1) 4가지 포맷의 차이

포맷정보량적합한 상황
Flag통과/실패만트래픽이 큰 API 게이트웨이에서 빠르게 통과 여부만 판단할 때
Basic통과/실패 + 어노테이션·에러 평면 리스트애플리케이션 서버에서 어노테이션을 꺼내 폼 제어나 에러 메시지에 쓸 때
DetailedBasic + 스키마 위치 트리 구조개발 환경에서 어떤 서브스키마가 매칭됐는지 디버깅할 때
Verbose모든 평가 결과의 완전한 트리검증기 구현체를 개발하거나 명세 적합성을 검증할 때

같은 스키마와 같은 인스턴스를 검증해도 포맷에 따라 결과에 담기는 정보가 다릅니다. 예를 들어, 아래 스키마에 배송이 시작된 주문 인스턴스를 넣습니다.

// 스키마: 주문 상태에 따라 shippingAddress의 어노테이션이 달라짐
{
  "type": "object",
  "required": ["status", "shippingAddress"],
  "anyOf": [
    {
      "properties": {
        "status":          { "const": "pending" },
        "shippingAddress": {
          "description": "주문자가 지정한 배송 희망 주소. 결제 전에는 변경 가능."
        }
      }
    },
    {
      "properties": {
        "status":          { "const": "shipped" },
        "shippingAddress": {
          "readOnly": true,
          "description": "택배사에 전달된 확정 배송 주소."
        }
      }
    }
  ]
}
{ "status": "shipped", "shippingAddress": "서울시 강남구 ..." }

Flag 포맷

Flag 포맷은 통과/실패만 내놓습니다.

// Flag: 통과/실패만 내놓음. 어노테이션 없음.
{ "valid": true }

게이트웨이는 이 결과만으로 요청을 통과시킬지 차단할지 판단합니다. 어노테이션이 필요 없는 자리에서는 이것으로 충분합니다.

Basic 포맷

Basic 포맷은 통과/실패에 더해, 수집된 어노테이션을 평면 리스트로 함께 담아 줍니다.

// Basic: 통과/실패 + 수집된 어노테이션 평면 리스트
{
  "valid": true,
  "annotations": [
    {
      // 이 어노테이션이 스키마 어디에서 왔는지
      "keywordLocation": "/anyOf/1/properties/shippingAddress/readOnly",
      // 이 어노테이션이 인스턴스 어느 위치에 적용되는지
      "instanceLocation": "/shippingAddress",
      // 수집된 어노테이션 값
      "annotation": true
    },
    {
      "keywordLocation": "/anyOf/1/properties/shippingAddress/description",
      "instanceLocation": "/shippingAddress",
      "annotation": "택배사에 전달된 확정 배송 주소."
    }
  ]
}

status"shipped"이므로 anyOf의 두 번째 서브스키마가 매칭되었고, shippingAddressreadOnly: true와 해당 description이 수집되었습니다. 폼 렌더러는 이 리스트에서 instanceLocation별로 어노테이션을 꺼내 쓰면 됩니다.

Detailed 포맷

Detailed 포맷은 Basic과 같은 정보를 트리 구조로 담습니다. 어떤 서브스키마가 어디서 평가됐는지를 위계로 보여줍니다.

{
  "valid": true,
  "keywordLocation": "",
  "instanceLocation": "",
  "annotations": [
    // anyOf의 두 번째 서브스키마가 매칭된 트리 노드
    {
      "valid": true,
      "keywordLocation": "/anyOf/1",
      "instanceLocation": "",
      "annotations": [
        {
          "valid": true,
          "keywordLocation": "/anyOf/1/properties/shippingAddress",
          "instanceLocation": "/shippingAddress",
          // 이 위치에서 수집된 어노테이션들
          "annotations": [
            { "keyword": "readOnly", "value": true },
            { "keyword": "description", "value": "택배사에 전달된 확정 배송 주소." }
          ]
        }
      ]
    }
  ]
}

Basic은 /anyOf/1/properties/shippingAddress/readOnly라는 경로 문자열로 출처를 표현하지만, Detailed는 트리 노드를 타고 내려가면서 anyOf → 두 번째 서브스키마 → shippingAddress 순서로 평가 과정을 그대로 보여줍니다. 개발 중에 “왜 이 어노테이션이 수집됐지?”를 추적할 때 유용하지만, 프로덕션에서 매 요청마다 쓰기에는 출력이 과합니다.

Verbose 포맷

Verbose는 Detailed와 구조가 같지만, 매칭되지 않은 서브스키마의 평가 결과까지 전부 포함합니다.

{
  "valid": true,
  "keywordLocation": "",
  "instanceLocation": "",
  "annotations": [
    // anyOf의 첫 번째 서브스키마: status가 "shipped"이므로 매칭 실패
    // Detailed에서는 이 노드가 빠지지만, Verbose에서는 포함됨
    {
      "valid": false,
      "keywordLocation": "/anyOf/0",
      "instanceLocation": "",
      "errors": [
        {
          "keywordLocation": "/anyOf/0/properties/status/const",
          "instanceLocation": "/status",
          "error": "expected 'pending', got 'shipped'"
        }
      ]
    },
    // anyOf의 두 번째 서브스키마: 매칭 성공
    {
      "valid": true,
      "keywordLocation": "/anyOf/1",
      "instanceLocation": "",
      "annotations": [
        {
          "valid": true,
          "keywordLocation": "/anyOf/1/properties/shippingAddress",
          "instanceLocation": "/shippingAddress",
          "annotations": [
            { "keyword": "readOnly", "value": true },
            { "keyword": "description", "value": "택배사에 전달된 확정 배송 주소." }
          ]
        }
      ]
    }
  ]
}

Detailed와 비교하면 /anyOf/0 노드가 추가된 것이 차이입니다. 첫 번째 서브스키마가 왜 매칭되지 않았는지(expected 'pending', got 'shipped')까지 출력에 남습니다. 검증기 구현체를 개발하거나 명세 적합성을 검증할 때는 이 수준의 상세가 필요하지만, 프로덕션에서 매 요청마다 쓰기에는 출력이 과합니다.

한 가지 주의할 점은, JSON 스키마 명세가 구현체에 모든 포맷 지원을 강제하지 않는다는 것입니다. Flag만 필수이고 나머지는 권장입니다. 어노테이션 추출을 실제로 활용하려면 최소 Basic 이상이 필요하므로, 검증기를 고를 때 필요한 포맷이 지원되는지를 함께 확인해야 합니다.


6. format 키워드 다시 보기: 검증에서 의미 부여로

3편에서 format의 두 보캐뷸러리 분리와 검증 강제 방법을 다뤘습니다. 기본 동작은 어노테이션이고, Format-Assertion을 활성화하거나 ajv-formats 같은 플러그인을 써야 검증이 일어난다는 내용이었습니다. 4편에서는 그 어노테이션으로서의 format이 실제로 어디에 쓰이는지를 다룹니다.

JSON 스키마 2020-12의 기본 메타스키마는 Format-Annotation 보캐뷸러리를 가져옵니다. 즉 "format": "email"이라고 적어 두면, 검증기는 그 문자열이 실제로 이메일에 맞는지 검증하지 않고 “이 필드는 이메일 의미를 가진다”는 어노테이션만 수집합니다.

같은 string 타입이라도 email·uri·uuid·date-time은 의미가 전부 다릅니다. format이 없으면 도구는 그 문자열이 무엇을 의미하는지 추측해야 합니다. format이 있으면 그 추측이 사라집니다.

// format이 없는 경우: 도구는 이 문자열이 뭔지 모름
{ "type": "string" }

// format이 있는 경우: 도구는 "이메일"이라는 의미를 바로 알 수 있음
{ "type": "string", "format": "email" }

이 어노테이션을 받아 가는 도구별 동작은 다음과 같습니다.

도구format 어노테이션 활용
폼 렌더러"email" → 이메일 전용 입력 칸으로 전환, "date" → 날짜 피커 표시
API 문서 생성기필드 타입 옆에 email, uuid 등 의미 라벨 표시
데이터 카탈로그같은 format 값을 가진 필드끼리 묶어서 조회 가능

Format-Annotation vs Format-Assertion

format의 동작은 어느 보캐뷸러리가 활성화되어 있느냐에 따라 달라집니다.

보캐뷸러리역할검증에 영향기본 활성 여부
Format-Annotation“이 필드는 이메일 의미를 가진다”는 정보를 전달없음기본 활성
Format-Assertion그 의미가 실제로 맞는지까지 검증있음옵션. 지원하지 않는 구현체가 많음

검증까지 강제하고 싶다면 Format-Assertion 보캐뷸러리를 활성화하거나, 3편에서 다뤘던 것처럼 Ajv의 ajv-formats 플러그인을 쓰는 방법이 있습니다. 다만 Format-Assertion을 지원하는 구현체가 많지 않기 때문에, 실무에서는 format으로 의미를 담고 pattern으로 검증을 담는 조합이 더 일반적입니다. 이렇게 하면 어떤 검증기에서도 일관된 동작을 기대할 수 있습니다.

// format으로 의미를 전달하고, pattern으로 검증을 강제하는 조합
{
  "type": "string",
  "format": "email",
  "pattern": "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\\\.[A-Za-z]{2,}$"
}

7. 스키마로 사고하기: 데이터·구조·의미·맥락의 통합

4편에서 다룬 어노테이션·추출·출력 포맷·format을 한데 모으면, JSON 스키마를 보는 시각이 달라집니다. 스키마는 단순한 검증 도구가 아니라, 데이터에 관한 네 가지를 함께 담는 객체입니다.

┌──────────────────────────────────────┐
│              스키마                    │
│                                      │
│  ┌────────────────────────────────┐  │
│  │  구조 (단언 키워드)                │  │
│  │  type, required, properties    │  │
│  └────────────────────────────────┘  │
│                                      │
│  ┌────────────────────────────────┐  │
│  │  의미 (어노테이션 키워드)            │  │
│  │  title, description, format    │  │
│  └────────────────────────────────┘  │
│                                      │
│  ┌────────────────────────────────┐  │
│  │  맥락 (메타 키워드)                │  │
│  │  $id, $schema, $vocabulary     │  │
│  └────────────────────────────────┘  │
│                                      │
│  ┌────────────────────────────────┐  │
│  │  데이터 (인스턴스)                 │  │
│  │  스키마 외부, 검증 대상             │  │
│  └────────────────────────────────┘  │
└──────────────────────────────────────┘
측면JSON 스키마에서의 위치대표 키워드
구조단언 키워드type, required, properties, enum, minimum
의미어노테이션 키워드title, description, examples, format
맥락메타 키워드$id, $schema, $vocabulary
데이터인스턴스 (스키마 외부)검증 대상

스키마 사고가 바꾸는 것

스키마로 사고한다는 건, 새 데이터를 다룰 때 “이 데이터는 어떤 모양인가”(구조)만 묻지 않고, “무엇을 의미하는가”(의미)와 “어디서 왔고 어떤 약속 안에 있는가”(맥락)를 함께 묻는다는 뜻입니다.

스키마 사고 이전스키마 사고 이후
데이터 정의위키·노션·미팅 노트에 흩어짐스키마 한 곳에 모임
변경 처리코드·문서·도구를 따로 갱신스키마만 갱신하면 도구가 따라옴
신규 합류자위키 정독 후에도 코드와 차이 발견스키마 한 파일로 현행 정의 파악
도구 통합도구별 변환 코드 작성표준 출력 포맷으로 도구 간 통합

다만 모든 데이터에 이 접근이 맞는 건 아닙니다. 자유 텍스트나 비정형 데이터는 형식적 정의보다 의미 추출 모델 쪽이 어울리고, 스키마 자체가 자주 변하는 도메인에서는 스키마를 한 곳에 모으는 효과보다 갱신 비용이 더 클 수 있습니다. 네 측면을 전부 담을지, 어디까지 담을지는 도메인의 성격에 맞춰 결정해야 합니다.


8. 어디까지 담을 것인가: 어노테이션 작성의 우선순위

어노테이션을 모든 필드에 채우면 좋겠지만, 실무에서는 작성 시간, 유지 및 검증 비용이 따릅니다.

description을 한 줄 쓰는 데 30초가 든다면, 100개 필드에 채우는 데 50분이 듭니다. 모든 스키마의 모든 필드를 한 번에 채우려 들면 도입 자체가 막힙니다. 유지 비용은 더 큽니다. 데이터의 의미가 바뀌면 어노테이션도 바뀌어야 하는데, 스키마와 실제 의미가 어긋나면 어노테이션이 없는 것보다 못합니다.

그래서 어느 필드부터 채우고 어디는 미룰지를 정하는 우선순위가 필요합니다. 다음 스키마에 우선순위를 적용해 보겠습니다.

{
  "type": "object",
  "required": ["orderId", "status", "amount", "currency", "email"],
  "properties": {

    // ─── 우선순위 높음: 모호한 필드명 ───
    // "status"만 보면 주문 상태인지, 결제 상태인지, 배송 상태인지 알 수 없음
    "status": {
      "type": "string",
      "enum": ["pending", "paid", "shipped", "delivered", "cancelled"],
      "title": "주문 처리 상태",
      "description": "주문의 현재 처리 단계. 결제 상태(paymentStatus)와는 별개.",
      "examples": ["pending", "shipped"]
    },

    // ─── 우선순위 높음: 단위가 있는 숫자 ───
    // "amount": 100 → 100원? 100달러? 100센트?
    "amount": {
      "type": "integer",
      "minimum": 0,
      "description": "결제 금액. 단위는 currency 필드의 최소 단위. KRW면 원, USD면 센트.",
      "examples": [15000, 1999]
    },

    // ─── 우선순위 높음: 코드값 ───
    "currency": {
      "type": "string",
      "description": "ISO 4217 통화 코드.",
      "examples": ["KRW", "USD", "JPY"]
    },

    // ─── 우선순위 낮음: 표준 명명 + format ───
    // 필드명과 format이 의미를 이미 전달하므로 description 생략 가능
    "email": {
      "type": "string",
      "format": "email"
    },

    // ─── 우선순위 낮음: 단순 식별자 ───
    // 명명에서 의미가 자명하므로 어노테이션 생략
    "orderId": {
      "type": "string",
      "format": "uuid"
    }
  }
}

1) 먼저 어노테이션을 채워야 하는 필드

필드 유형예시권장 어노테이션이유
이름만으로 의미를 알 수 없는 필드status, type, state, flagtitle, description, examples같은 이름이 부서·시스템마다 다른 의미를 가짐
단위가 필요한 숫자 필드price, amount, durationdescription(단위 명시), examples단위가 빠지면 값의 의미가 달라짐
외부 표준을 따르는 코드값country_code, currency, langdescription(준거 표준), examplesISO 3166, ISO 4217 등 어떤 표준을 따르는지 명시해야 시스템 간 합의 가능
폐기 예정 필드3절의 cardNumber 사례deprecated, description(마이그레이션 안내)점진적 폐기의 핵심 표시

2) 어노테이션을 후순위로 작성해도 되는 필드

필드 유형예시이유
format이 의미를 전달하는 필드"format": "email", "format": "date-time"format 어노테이션이 이미 의미를 담고 있음
이름에서 의미가 자명한 식별자id, orderId, uuid추가 설명이 없어도 역할이 명확함
외부에 노출되지 않는 내부 구현용 필드내부 캐시 키, 디버그 플래그 등어노테이션을 소비할 도구가 없음

같은 스키마 안에서도 채워야 하는 필드와 생략해도 되는 필드가 갈립니다. status는 어노테이션 없이는 해석이 불가능하지만, emailorderId는 필드명과 format만으로 의미가 전달됩니다.

3) 작성 가이드라인

titledescription은 폼 렌더러가 라벨이나 필드 설명으로 사용자에게 그대로 노출할 수 있습니다. 내부 메모 스타일로 작성하면 그 문구가 사용자 화면에 그대로 나타납니다. description에 “금액임. KRW=원, USD=센트”라고 적으면 사용자가 폼에서 그 문장을 그대로 읽게 됩니다. “결제 금액입니다. KRW면 원, USD면 센트 단위입니다.”처럼 처음부터 사용자가 읽어도 자연스러운 문장으로 작성해야 합니다.

키워드작성 기준좋은 예나쁜 예
title짧게. 필드명의 사람 친화적 버전. 폼 렌더러가 라벨로 그대로 표시할 수 있으므로 사용자가 읽기 자연스럽게"주문 처리 상태", "유입 채널""이 필드는 주문의 현재 처리 상태를 나타냅니다" (description 역할을 침범)
description“이 필드가 무엇을 의미하는가”를 한두 문장으로. 폼이나 문서에 그대로 노출될 수 있으므로 완결된 문장으로 작성. 단위가 있으면 단위를, 외부 표준을 따르면 표준명을 명시"결제 금액입니다. 단위는 currency 필드의 최소 단위이며, KRW면 원, USD면 센트입니다.""금액. currency 최소 단위. KRW=원, USD=센트" (메모 스타일, 사용자에게 그대로 노출되면 어색)
examples1~3개의 실제 데이터 형태. enum이 있으면 전체를 복사하지 말고 대표값만["pending", "shipped"]["pending", "paid", "shipped", "delivered", "cancelled"] (enum 전체 복사)
default값이 생략됐을 때 도구가 채울 기본값. 검증기는 이 값을 자동으로 채우지 않으므로, 실제 기본값 적용은 애플리케이션 코드에서 처리"organic" (channel 필드의 기본 유입 경로)"" (빈 문자열은 의미 있는 기본값이 아님)
deprecated폐기 예정 필드에만 true로 설정. 반드시 description에 대체 필드와 마이그레이션 안내를 함께 명시deprecated: true + description: "v2부터 paymentToken으로 대체됩니다. 가이드: https://..."deprecated: true만 단독 사용 (어디로 옮겨야 하는지 알 수 없음)
readOnly서버가 생성하고 클라이언트가 수정하면 안 되는 필드에 설정orderId, createdAt 같은 서버 생성 필드클라이언트가 입력해야 하는 name, email에 설정
writeOnly클라이언트가 보내지만 응답에는 포함되지 않는 필드에 설정password, apiSecret 같은 민감 정보 필드응답에도 포함되어야 하는 username에 설정

마무리

4편의 출발점은 검증을 통과한 "CA"라는 값이 무엇을 의미하는지 알 수 없다는 문제였습니다. 어노테이션은 그 답을 스키마 안에 담습니다. 검증이 “이 데이터가 형식을 따르는가”에 답한다면, 어노테이션은 “이 데이터가 무엇을 의미하는가”에 답합니다. 위키에 흩어져 있던 데이터 정의가 스키마 한 곳에 모이고, 폼 렌더러·문서 생성기·데이터 카탈로그가 그 정의를 자동으로 가져다 씁니다.

다만 title, description, deprecated 같은 표준 키워드만으로는 조직 고유의 도메인 의미를 표현하기 어려운 경우가 있습니다. JSON 스키마는 이런 경우를 위해 자체 어휘(vocabulary)를 만들 수 있도록 허용합니다. 5편에서는 표준을 넘어서 조직 고유의 어휘를 설계하는 방법을 다룹니다.


JSON 시리즈

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

(2) JSON 스키마 작성하기

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

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

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

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