JSON Toolkit

자동 생성한 JSON Schema가 실제 데이터를 계속 거부하는 이유

핵심 요약: 생성된 스키마는 종종 유효한 데이터를 막습니다. 샘플이 풍부할수록 스키마가 나빠지는 이유, 그리고 다중 레코드 샘플링, 의미 완성, 오류 지목의 워크플로를 배웁니다.

중급약 14분 소요2026-10-30 발행2026-10-30 업데이트
JSON-to-Schema 초안과 실제 데이터의 충돌을 보여 주는 표지

핵심부터 말하면, 먼저 required/optional 경계를 수작업으로 수렴시키세요. "모두 필수"를 끈 상태에서 모양이 다른 5~20개의 레코드를 넣은 뒤 스키마를 생성하고 format, enum, additionalProperties를 수동으로 더합니다. 필드가 꽉 찬 단일 샘플은 항상 너무 엄격합니다.

생성기는 붙여 넣은 단일 레코드만 볼 수 있습니다. "이 레코드에는 이메일이 있다"를 "모든 레코드에 이메일이 있어야 한다"로 읽으므로, 샘플이 완전할수록 더 많이 요구합니다.

1. 생성기는 구조 매핑을 할 뿐, 업무를 이해하지 않음

JSON 덩어리를 생성기에 넘기면 정확히 한 가지를 합니다. 값의 구조를 순회하며 각 노드를 스키마 키워드로 번역합니다. 매핑 표 전체는 고정되어 있습니다:

샘플의 값 추론되는 타입 비고
"hello" {"type": "string"} 이메일, 날짜, UUID를 구분하지 않음
42 {"type": "number"} 정수를 integer로 추론하지 않음
3.14 {"type": "number"} 위와 같음
true {"type": "boolean"} —
null {"type": "null"} null만 인식하며 "nullable"은 결코 표현하지 않음
[1, "a"] {"type": "array", "items": {"type": ["number", "string"]}} 요소 타입을 유니언
{"a": 1} {"type": "object", "properties": {...}, "required": [...]} required가 관건

문제는 모두 마지막 행에 있습니다. required는 타입에서 나오지 않고 "이 샘플에 이 키가 존재한다"에서 나옵니다. 샘플이 단일 레코드이면 그 진술은 "모든 키가 필수"와 같습니다.

그리고 실제 API가 반환하는 데이터는 거의 항상 절반이 선택 필드입니다. 여기서 충돌이 시작됩니다.

2. 같은 모양의 레코드 다섯 개, 다섯 가지 승인률

먼저 측정부터 합니다. 아래 다섯 레코드를 보는 사람은 누구나 "같은 모양"이라고 말할 것입니다:

[
  {
    "id": 1,
    "name": "Ada",
    "email": "ada@example.com",
    "tags": ["a"],
    "score": 10
  },
  { "id": 2, "name": "Bob", "tags": [], "score": 7 },
  {
    "id": 3,
    "name": "Cy",
    "email": "cy@example.com",
    "tags": ["a", "b"],
    "score": 8.5
  },
  { "id": 4, "name": "Dee", "email": null, "tags": ["c"], "score": 9 },
  {
    "id": 5,
    "name": "Eve",
    "email": "eve@example.com",
    "tags": ["d"],
    "score": 6,
    "admin": true
  }
]

email은 다섯 레코드 중 3개에 나타나고, admin은 한 번만 나타나며, 레코드 2는 tags 배열이 비어 있습니다.

이제 각 레코드에서 개별적으로 스키마를 생성한 뒤, 각 결과로 다섯 레코드 전체를 검증합니다(검증기: ajv 8):

생성 출처 키 수 생성된 required 5개 중 승인
레코드 1 5 id, name, email, tags, score 3개 레코드
레코드 2 4 id, name, tags, score 5개 레코드
레코드 3 5 id, name, email, tags, score 3개 레코드
레코드 4 5 id, name, email, tags, score 1개 레코드
레코드 5 6 id, name, email, tags, score, admin 1개 레코드

다섯 스키마 중 어느 것도 정확하지 않지만, 모든 데이터를 승인하는 유일한 스키마는 email이 빠진 레코드 2에서 나옵니다. 단순한 이유로 이깁니다. 한 번도 나타나지 않은 키는 required에 절대 쓰이지 않습니다.

레코드 4는 더 교훈적입니다. email 값이 null이라 생성기가 {"type": "null"}로 굳혀 버리고, 문자열 이메일을 가진 나머지 네 레코드는 모두 거부됩니다.

3. "가장 완전한 레코드를 붙여넣기"가 최악인 이유

직관은 "필드가 가장 많은 샘플을 붙여 넣으면 스키마가 완전해진다"입니다. 측정 결과는 정반대이며, 서드파티가 더 큰 통제 실험을 돌렸습니다.

absurdtools는 3000개의 무작위 "데이터 패밀리"를 사용했고, 패밀리마다 8개 레코드, 각 선택 필드는 대략 절반의 레코드에 존재하게 했으며, 키 수가 다른 샘플에서 생성한 스키마가 얼마나 많은 데이터를 승인하는지 측정했습니다:

샘플 키 수 패밀리 데이터 승인 비율
2 100.0%
3 56.8%
4 34.8%
5 23.4%
6 17.7%
7 14.9%

결론은 "약간의 하락"이 아니라 완전한 역전입니다. 최소 샘플이 95퍼센트의 패밀리에서 가장 느슨한 스키마를 만들었고, 가장 완전한 샘플은 단 0.1퍼센트에서만 그렇게 했습니다.

이유는 required의 의미, 즉 "모두"에 있습니다. 선택성은 오직 부재를 통해서만 관찰됩니다. 모든 필드가 있는 레코드를 붙여 넣으면 "이 키 중 하나도 빠지면 안 된다"고 말하는 것이고, 필드가 빠진 레코드를 붙여 넣으면 "이 키는 없을 수 있다"는 증거를 주는 것입니다.

한 문장으로 기억하세요. 샘플의 가치는 완전성에 있지 않고, 어떤 부재를 덮는지에 있습니다.

레코드가 몇 개면 충분한가

같은 실험 배치는 수렴 속도도 측정했습니다. "지금까지 모든 레코드에 있는 키만 required로 유지"하는 규칙 아래 레코드를 하나씩 누적할 때, required가 진짜 필수 집합으로 수렴하는 레코드 수의 중앙값은 3입니다. 6개면 패밀리의 약 90퍼센트, 12개면 전부를 덮습니다.

따라서 매일 5~10개를 샘플링하는 것이 노력 대비 가장 좋고, 20개 이상을 억지로 넣어도 추가 이득은 아주 작습니다.

그러나 전제가 있습니다. 이 레코드는 진짜 서로 다른 상황에서 와야 합니다. 같은 템플릿에서 복사한 다섯 레코드는 두 번째부터 새로운 정보를 전혀 주지 않습니다. 필드 집합이 동일해서 required가 키 하나만큼도 좁아질 수 없습니다.

4. 해결: 여러 레코드를 한 번에 넣고 부재가 말하게 하기

단일 레코드 샘플링은 필연적으로 너무 엄격하므로, 올바른 접근은 생성기가 여러 레코드를 한 번에 보게 하고 "모든 레코드에 있을 때만 required"라는 규칙으로 required를 수렴시키는 것입니다.

이 사이트의 생성기는 배열을 직접 받고 "모두 필수" 토글이 있습니다. 토글을 끄면 발생 횟수로 결정합니다. 모든 레코드가 지닌 키만 required에 씁니다.

여전히 그 다섯 레코드입니다. 이번에는 배열로 묶어 함께 추론하고, items를 요소 계약으로 씁니다:

입력 레코드 수 추론된 required 5개 전체 검증
1개 id, name, email, tags, score 3개 통과
2개 id, name, tags, score 4개 통과
3개 id, name, tags, score 4개 통과
4개 id, name, tags, score 5개 통과
5개 id, name, tags, score 5개 통과

required는 네 번째 레코드가 더해지는 순간 수렴하고 이후 변하지 않습니다. email과 admin은 properties에 올바로 들어가지만 required에는 들어가지 않습니다. 그리고 레코드 4의 email이 null이라 다중 레코드 추론이 {"type": ["string", "null"]}으로 합쳐, 문자열과 null이 모두 통과합니다. 단일 레코드 샘플링은 결코 할 수 없는 일입니다.

이는 absurdtools의 "required는 중앙값 3개 레코드 후 수렴한다"와 같은 규모입니다.

다중 레코드 추론에도 맹점이 있습니다. 이 레코드들의 email이 우연히 전부 null이라면(예: 모두 프로필을 채우지 않은 계정에서 왔다면), 추론 결과가 {"type": "null"}이 되어 문자열 이메일은 여전히 거부됩니다. 마찬가지로 모든 tags 값이 빈 배열이면 요소 타입을 전혀 추론할 수 없습니다.

따라서 샘플링에는 한 가지 더 엄격한 요구가 있습니다. 모든 필드의 모든 형태가 최소 한 번은 나타나야 합니다. 문자열 하나, 숫자 하나, null 하나, 빈 배열 하나를 가져오세요. 어떤 형태도 전부 빠져서는 안 됩니다.

구체적으로:

  1. 로그, 목, 실제 요청에서 5~20개의 응답을 뽑고, 반드시 비정상 행(빈 필드가 있는 것, null이 있는 것, 빈 배열이 있는 것)을 포함합니다.
  2. JSON 배열로 조립해 생성기에 붙여 넣습니다.
  3. "모두 필수"를 끄고 초안 2020-12를 고릅니다.
  4. 결과는 type: array와 items입니다. items 부분을 단일 레코드의 계약으로 씁니다.
  5. 이 스키마로 원본 샘플을 즉시 검증합니다. 100퍼센트가 통과해야 합니다. 하나라도 실패하면 샘플링이 부족하므로 돌아가 레코드를 더하세요.

5단계가 가장 저렴한 자체 점검입니다. 생성기가 넣어 준 데이터조차 감당하지 못한다면 샘플링 방법이 틀렸고, 더 나아갈 이유가 없습니다.

5. 생성기가 채울 수 없는 네 가지 — 직접 써야 함

여러 레코드를 넣은 뒤에도 다음은 여전히 추론되지 않으며 수동으로 더해야 합니다:

빠진 조각 측정된 동작 결과 해결 방법
문자열 형식 "a@b.com"이 {"type": "string"}만 생성 "not an email" 검증이 통과 "format": "email"을 더하고 형식 단언을 켭니다
범위와 열거 score가 0~100으로 제한된다는 것, status가 세 값만 가진다는 것을 알 수 없음 음수, 거대한 값, 쓰레기 열거 값이 모두 통과 minimum / maximum / enum 추가
알 수 없는 필드 정책 additionalProperties를 생성하지 않음 오타 키가 조용히 승인되어 오류보다 더 위험 설정형 파일에는 "additionalProperties": false 추가
nullable와 빈 컬렉션 의미 null이 포함된 단일 샘플은 {"type": "null"}로 고정, 빈 배열은 "items": {}를 생성 유효 값이 거부되거나 배열 요소가 완전히 제약 없음 ["string", "null"]로 바꾸고 빈 배열에 실제 요소를 부여

세 번째 행은 따로 언급할 만합니다. additionalProperties의 기본값은 "허용"입니다. 즉 timeout을 timeuot로 적은 설정 파일이 검증을 통과하고 런타임에서 조용히 아무것도 하지 않습니다. 설정형 스키마에서는 반드시 명시적으로 꺼야 하는 항목입니다.

format에도 함정이 있습니다. draft 2019-09부터 format은 주석 어휘에 속하며, 기본적으로 강제하지 않고 주석만 답니다. format: "email"을 더했는데 불법 값이 여전히 통과한다면, 열에 여덟 번은 검증기가 validateFormats나 해당 형식 플러그인을 켜지 않은 것이지 스키마가 틀린 것이 아닙니다.

여기서 초안 선택에 대해 한마디. 스키마가 OpenAPI 3.1로 들어가면 draft 2020-12여야 합니다. 3.1이 그 버전을 직접 내장하기 때문입니다. 오래된 도구 체인(Swagger 2.0 생태계와 일부 $ref 구현)은 여전히 draft-07을 중심에 둡니다. 둘은 $ref 형제 키워드와 배열 관련 키워드에서 다르게 동작하므로 한 스키마 집합 안에서 섞지 마세요.

6. 문서가 없는 서드파티 API는 응답 샘플에서 계약을 역설계하기

서드파티 콜백 통합, 문서를 쓰지 않는 파트너와 협업, 10년 된 레거시 시스템 유지 — 모두 "데이터는 손에 있는데 명세는 없다"입니다. 여기서 생성기는 단순한 편의를 넘어 계약에 이르는 가장 빠른 길입니다.

방법은 위와 같으며 두 가지 추가 필수가 있습니다:

첫째: 시간과 상태에 걸쳐 샘플링하기. 서드파티 필드는 업무 상태에 따라 자주 바뀝니다. 프로모션이 discount를 더하고, 환불 주문이 refundAt을 더하며, 오류 응답에는 data가 전혀 없습니다. 단일 스냅샷만 잡으면 한 상태만 보게 됩니다.

성공 몇 개, 실패 몇 개, 빈 결과 몇 개를 함께 넣으세요. error와 data는 흔히 상호 배타적으로 나타나며, 다중 레코드 추론이 둘을 properties에 넣고 required에서는 빼서 현실과 정확히 일치시킵니다.

둘째: 스키마를 저장소에 두고 매일 점검을 붙이기. 오늘의 실제 응답을 스키마로 검증하면, 사용자보다 먼저 조용히 사라지거나 타입이 바뀐 필드를 알게 됩니다.

// Run once a day: contract-drift alert
const drifted = records.filter((record) => !validate(record));

if (drifted.length > 0) {
  console.warn(
    `${drifted.length} responses do not match the contract; first difference:`,
    validate.errors,
  );
}

이 관행의 가치는 "손으로 쓰는 수고를 아끼는 것"이 아니라 말로 된 합의를 실행 가능한 경고로 바꾸는 것입니다. 서드파티는 필드를 바꿨다고 미리 알려 주지 않지만, 그 변경은 반드시 로그에 나타납니다.

경계 하나를 분명히 그으세요. 역설계한 스키마는 상대가 지금 반환하는 것을 설명하지, 약속하는 것을 설명하지 않습니다. 전자는 모니터링용이고, 후자만 계약이나 API 문서에 속합니다.

7. 완성 후 검증 오류가 특정 필드를 지목하게 만들기

스키마가 쓸 만해지면 다음 관심사는 읽을 수 있는 오류입니다. ajv 오류 객체에는 다섯 필드가 있고, 앞의 네 개가 유용합니다:

필드 의미 예(id: 0이 minimum: 1 위반)
instancePath 데이터에서 문제가 된 값의 JSON Pointer "/id"
schemaPath 스키마에서 발동한 규칙의 위치 "#/properties/id/minimum"
keyword 실패한 제약의 이름 "minimum"
params 해당 제약의 기계 가독 디테일 { "comparison": ">=", "limit": 1 }
message 버전마다 바뀌는 영어 문장, 파싱하지 마세요 "must be >= 1"

가장 쉽게 넘어지는 비대칭 하나. required가 실패할 때 instancePath는 빠진 필드가 아니라 부모 객체(대개 빈 문자열, 즉 루트)를 가리킵니다. 빠진 키의 이름은 params.missingProperty에 있습니다. 마찬가지로 additionalProperties가 실패할 때 추가 키 이름은 params.additionalProperty에 있습니다.

따라서 오류를 "필드 → 메시지"로 매핑할 때 이 두 특수 사례를 먼저 처리하세요:

import Ajv from "ajv";

const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);

/** Convert the ajv error array into { fieldPath: [message] } for form highlighting */
function groupByField(errors) {
  const grouped = {};

  for (const error of errors) {
    // For required and additionalProperties, the offending field lives in params, not in instancePath
    const field =
      error.keyword === "required"
        ? error.params.missingProperty
        : error.keyword === "additionalProperties"
          ? error.params.additionalProperty
          : error.instancePath.replace(/^\//u, "").split("/").join(".") ||
            "(root)";

    (grouped[field] ??= []).push(error.message);
  }

  return grouped;
}

if (!validate(data)) console.log(groupByField(validate.errors));

allErrors: true는 선택이 아닙니다. 기본 설정에서 ajv는 첫 오류에서 멈추므로, 사용자는 필드 하나를 고치고 다시 보내고, 다음 오류를 받고, 폼 하나를 채우는 데 세 번 왕복해야 합니다. 커스텀 메시지를 위해 ajv-errors도 쓴다면, 이 플래그가 없으면 플러그인이 메시지 일부만 조용히 대체합니다.

8. 재사용 가능한 도입 프로세스

위 단계를 고정 루틴으로 엮어 새 API를 통합할 때마다 따르세요:

  1. 샘플링: 실제 응답 5~20개를 뽑고, 빈 필드, null, 빈 배열, 추가 필드가 있는 것을 일부러 고릅니다.
  2. 추론: 배열로 묶어 생성기에 붙여 넣고 "모두 필수"를 끈 뒤 items를 씁니다.
  3. 의미 완성: format, enum, minimum/maximum, minItems, additionalProperties를 하나씩 더합니다.
  4. 자체 점검: 원본 샘플이 100퍼센트 통과해야 하며, 아니면 1단계로 돌아가 샘플을 더합니다.
  5. 부정 증명: 제약마다 "실패해야 하는" 입력을 준비해 정말 실패함을 확인합니다. 통과 경로만 테스트하는 것은 테스트하지 않는 것과 같습니다.
  6. 커밋: 스키마를 버전 관리에 넣고, 실패 샘플을 테스트 사례로 유지하며, API가 필드를 바꿀 때마다 테스트를 먼저 실행합니다.

5단계의 부정 테스트는 생략하기 가장 쉽습니다. required에 키를 더하면서 빠진 키가 오류를 낸다는 것을 한 번도 검증하지 않는 것은, 검증기가 내 정신 모델과 동의한다고 베팅하는 것입니다.

import { describe, expect, it } from "vitest";

describe("user schema", () => {
  it("accepts a complete record", () => {
    expect(validate({ id: 1, name: "Ada", tags: [], score: 10 })).toBe(true);
  });

  it("rejects a record without name", () => {
    expect(validate({ id: 2, tags: [], score: 7 })).toBe(false);
    // Also assert the reason, so the test doesn't pass because some other field failed
    expect(
      validate.errors?.some((e) => e.params?.missingProperty === "name"),
    ).toBe(true);
  });
});

마지막 단언이 중요합니다. false만 단언하면 스키마가 망가진 뒤에도 초록으로 남을 수 있습니다. 그때는 전혀 다른 원인으로 실패할 수 있기 때문입니다.

9. FAQ

생성기가 만든 스키마를 바로 운영에 써도 되나

아니요. 그것은 구조의 스냅샷일 뿐입니다. 최소한 형식, 범위, 열거, 알 수 없는 필드 정책을 더하고, 원본 샘플과 부정 샘플을 모두 통과시켜야 합니다. 생성기는 "properties를 손으로 타이핑하는 것이 지겹다"를 해결하지, "업무 규칙을 결정한다"를 해결하지 않습니다.

스키마 작성의 키워드 체계, $ref 분할, 구성 키워드는 JSON Schema: 기초부터 운영까지를 참고하세요.

필드가 가장 많은 레코드가 왜 더 많은 데이터를 거부하나

required가 "샘플에 나타난 것"에서 수집되기 때문입니다. 필드가 꽉 찬 샘플은 모든 선택 키를 필수로 바꾸는데, 실제 데이터에서는 그 키 중 대부분이 빠집니다. 여러 레코드를 넣거나 의도적으로 필드를 생략한 레코드를 넣는 것이 가장 완전한 샘플보다 진실에 가깝습니다.

정수가 integer로 추론되나

이 사이트의 생성기는 그렇게 하지 않습니다. 정수와 부동소수점을 모두 number로 출력하므로 {"price": 5}에서 생성한 스키마가 5.99를 승인합니다. "샘플이 우연히 정수라서 운영에서 소수 가격이 거부되는" 사고를 피하기 위해서입니다. 일부 서드파티 도구는 값에서 integer를 추론하므로, 특히 돈 필드에 주의하세요.

숫자 정밀도와 타입 경계의 디테일은 여섯 가지 JSON 데이터 타입과 숫자 정밀도 함정을 참고하세요.

format을 더했는데 효과가 없나

draft 2019-09부터 format은 기본적으로 주석만 답니다. 검증기에서 형식 단언을 명시적으로 켜거나(ajv는 ajv-formats가 필요), 하드 제약에는 pattern을 쓰세요. 더하면 무엇인가를 막아 줄 거라 가정하지 마세요.

required가 오류를 내는데 데이터에 필드가 분명히 있나

instancePath를 먼저 보세요. required 오류는 부모 객체를 가리키므로, 빠진 키는 그 수준에 없습니다. 흔한 원인은 잘못된 중첩 수준(/profile/name은 루트의 name과 다름), 대소문자 차이(userId 대 userID), 업스트림 직렬화가 undefined를 완전히 버리는 것입니다.

조사 전에 문서 자체가 유효한 JSON인지 확인하세요. 주석, 후행 쉼표 같은 문제는 스키마가 관여하기 전 파싱 단계에서 실패합니다. JSON 파싱 및 포매팅이 문제 줄을 직접 지목합니다.

레코드를 몇 개 샘플링하고, 어떤 초안을 써야 하나?

매일 5~10개를 샘플링하면 가치가 가장 큽니다. required는 중앙값 3개 레코드에서 수렴하고, 12개면 모든 패밀리를 덮습니다. 각 레코드는 진짜 다른 상황에서 와야 하고, 모든 필드의 모든 형태가 최소 한 번은 나타나야 합니다. 초안은 2020-12로 고정하세요. OpenAPI 3.1이 그 버전만 받아들이기 때문입니다.

관대한 additionalProperties 기본값이 왜 더 위험한가?

기본값은 키워드를 전혀 내지 않으므로, 오타 키가 조용히 승인되어 검증을 통과하고 런타임에서 실패합니다. 직접 오류보다 진단하기 어렵습니다. 설정형 스키마는 "additionalProperties": false를 명시해야 하고, API 스키마는 먼저 호환성 비용을 저울질해야 합니다.

역설계한 스키마를 하드 게이트로 써야 하나?

아니요. 역설계한 스키마는 상대가 지금 반환하는 것을 설명하지, 약속하는 것을 설명하지 않으므로 모니터링과 드리프트 경고에 속합니다. 계약이나 API 문서에 들어가는 것은 약속된 계약이어야 합니다.