본문으로 건너뛰기
jsonbeautifiers
한국어

JSON 스키마 - 검증이 실제로 작동하는지 가르는 부분들

망가진 JSON 스키마 설정은 대개 오류를 던지지 않습니다. 틀린 데이터에 대해 유효하다고 답할 뿐입니다.

이 페이지의 모든 주장은 직접 측정했거나 출처가 있습니다. 둘 다 아닌 경우에는 그렇다고 명시합니다.

문법 검사는 문서가 파싱된다는 사실만 알려 줍니다. {"user_id": null, "email": 4}를 여러분의 서비스가 받아들여야 하는지에 대해서는 아무 말도 하지 않습니다. 그 틈을 메우라고 스키마가 있고, 둘의 차이는 정확히 말해 둘 가치가 있습니다. 검증기는 “이것이 올바른 형식의 JSON인가”에 답하고, 스키마는 “이것이 약속받은 문서인가”에 답합니다.

사람의 시간을 진짜로 잡아먹는 실패 형태는 좋은 데이터를 거부하는 스키마가 아닙니다. 그건 몇 분이면 눈에 띕니다. 문제는 나쁜 데이터를 받아들이고, 아무 불평 없이 컴파일되고, 1년 내내 valid를 보고하는 스키마입니다.

draft 고르기

답은 둘이고 둘 다 방어할 수 있습니다.

새 작업에는 2020-12. OpenAPI 3.1이 맞춰 가는 식별자이므로 API 기술을 쓰고 있다면 이미 이 방언 안입니다. 이름에 주의하세요. 2020-12는 draft가 끊긴 시점이고, 공표된 것은 2021년 초입니다. 그 URI의 문서가 마지막으로 재게시된 것은 2022년 6월인데, 명세 본문에 대한 패치였지 새 판이 아니었습니다.

도구 지원의 폭을 원한다면 draft-07. 언어와 에디터, 코드 생성기를 통틀어 지원의 꼬리가 가장 길고, JavaScript 생태계 바깥의 많은 검증기가 여전히 이것을 기본으로 봅니다. 스키마를 여러분이 통제하지 못하는 도구가 소비해야 한다면 draft-07이 현실적인 바닥입니다.

하지 말아야 할 것은 $schema를 빼놓고 잘되기를 바라는 일입니다. 방언을 선언하지 않은 스키마는 검증기가 마침 기본으로 정한 것에 따라 해석되는데, 그게 바로 여러분이 없애려던 모호함입니다.

실제로 손대게 될 2020-12의 변경점

셋, 무는 빈도 순서로.

prefixItemsitems의 배열 형태를 대체합니다. draft-07에서 배열 값을 가진 items는 위치별 튜플 검증을 뜻했고 나머지는 additionalItems가 제약했습니다. 2020-12에서는 위치가 prefixItems로 가고, items는 언제나 단일 스키마로서 prefixItems가 덮지 않는 모든 것에 적용됩니다.

unevaluatedPropertiesunevaluatedItemsadditionalPropertiesadditionalItems의 합성 인식 버전입니다. 이유는 아래에서. 사람들이 틀리는 짝이 바로 이것이기 때문입니다.

$dynamicRef$dynamicAnchor는 2019-09의 $recursiveRef$recursiveAnchor를 대체합니다. 호출하는 쪽이 특수화할 수 있는 트리 타입 같은 확장 가능한 재귀 스키마를 쓰는 게 아니라면 평생 만질 일이 없습니다. $recursiveRef를 쓰는 2019-09 스키마를 물려받았다면, 2020-12 검증기 아래에서는 작동하지 않습니다.

조용히 실패하는 Ajv 진입점

Ajv의 기본 export가 구현하는 것은 draft-07뿐입니다. 2020-12와 2019-09 방언은 별도의 진입점에 있습니다. 뻔한 방식으로 연결해 보죠.

const Ajv = require("ajv");           // $schema에 뭐라고 적혔든 draft-07
const ajv = new Ajv({ strict: false });

const schema = {
  type: "object",
  properties: {
    point: {
      type: "array",
      prefixItems: [{ type: "number" }, { type: "number" }],
      minItems: 2,
      maxItems: 2
    }
  }
};

ajv.validate(schema, { point: ["north", "west"] });   // true

prefixItems는 이 검증기가 아는 키워드가 아닙니다. 모르는 키워드는 무시되므로, 값이 두 항목짜리 배열인지만 확인되고 끝입니다. 여러분이 선언한 원소 타입은 한 번도 보이지 않습니다. 스키마가 명백히 금지하는 데이터에 대해 true를 받습니다.

Ajv에도 방어 장치는 있습니다. 기본으로 켜진 strict 모드는 모르는 키워드에 항의하고, 불러오지 않은 메타스키마를 가리키는 $schema가 있으면 오류를 던집니다. 그리고 두 장치 모두 일상적으로 꺼집니다. 스키마가 Ajv가 모르는 어휘를 쓰면 사람들이 가장 먼저 더하는 것이 strict: false이고, 생성된 스키마의 상당수는 $schema 없이 나옵니다. 둘 중 하나만 치워도 남는 건 조용한 통과입니다.

올바른 연결은 이렇습니다.

const Ajv2020 = require("ajv/dist/2020");
const addFormats = require("ajv-formats");

const ajv = new Ajv2020({ strict: true, allErrors: true });
addFormats(ajv);

const validate = ajv.compile(schema);
validate({ point: ["north", "west"] });   // false
console.log(validate.errors);

2019-09의 진입점은 ajv/dist/2019입니다. 나쁘다고 알려진 문서가 실패한다고 주장하는 테스트를 하나 더할 값어치가 있습니다. 음성 테스트가 없는 검증 설정은 아무도 작동을 증명하지 않은 설정입니다.

format은 기본적으로 아무것도 검증하지 않는다

2019-09와 2020-12에서 format은 **주석(annotation)**이지 단언(assertion)이 아닙니다. 명세가 명시적으로 그렇게 말합니다. {"type": "string", "format": "email"}을 본 적합한 검증기는 “이 값은 email로 주석되었다”고 기록한 뒤 "not an email"을 군말 없이 받아들여도 됩니다. 단언을 켜지 않는 한 대부분이 정확히 그렇게 합니다.

그래서 이 스키마는,

{
  "type": "object",
  "properties": {
    "email":     { "type": "string", "format": "email" },
    "created_at":{ "type": "string", "format": "date-time" }
  },
  "required": ["email"]
}

email이 없을 때를 잡고 email이 숫자일 때를 잡습니다. "email": "바나나"는 잡지 못하고, 검증기에 형식을 단언하라고 일러 두지 않았다면 "created_at": "어제"도 잡지 못합니다.

Ajv에서 이건 하나가 아니라 두 가지를 뜻합니다. 실제 구현을 공급하는 ajv-formats를 설치하고 등록할 것, 그리고 올바른 어휘가 작동하도록 자기 방언의 진입점을 쓸 것. Ajv 자체는 형식 구현을 하나도 담고 있지 않습니다. ajv-formats가 없으면 format은 strict 모드에서는 모르는 형식 오류이고, strict를 끄면 아무 일도 하지 않는 장식입니다. 둘 다 검증이 아닙니다.

값의 형태가 정말로 중요하다면 formatpattern으로, 아니면 minLengthmaxLength로 받쳐 주세요. 정규식은 모든 draft의 모든 검증기가 단언하며, 플러그인도 플래그도 필요 없습니다. format은 일부 검증기를 설득하면 강제하게 만들 수 있는 문서일 뿐입니다.

여기서 늘 희생되는 것이 날짜입니다. JSON에는 날짜 타입이 없고, 페이로드 안의 모든 타임스탬프는 사실 그냥 문자열이니까요.

additionalProperties 대 unevaluatedProperties

additionalProperties가 아는 것은 같은 스키마 객체 안의 propertiespatternProperties뿐입니다. $refallOf 가지가 들여온 것은 볼 수 없습니다. 이 한 문장이 “내 스키마가 분명히 정의된 필드를 거부한다”는 부류의 버그 제보 거의 전부를 설명합니다.

{
  "allOf": [{ "$ref": "#/$defs/base" }],
  "properties": { "role": { "type": "string" } },
  "additionalProperties": false
}

base에 정의된 모든 프로퍼티가 이제 거부됩니다. 이 층위에서 아는 프로퍼티는 role뿐이기 때문입니다. 마지막 줄을 "unevaluatedProperties": false로 바꾸면, 이 키워드는 제자리 적용자들이 일을 마친 뒤에 돌아가 base가 평가한 모든 것을 보고, 아무도 설명하지 못한 것만 거부합니다.

어림 규칙: 모든 프로퍼티를 그 자리에서 선언한 독립 객체에는 additionalProperties: false. allOf, $ref, if/then, oneOf로 합성된 것에는 unevaluatedProperties: false. 배열에 대한 같은 관계가 unevaluatedItems이고 prefixItems와 자연스럽게 짝을 이룹니다.

required는 존재한다는 뜻이지, 채워졌다는 뜻이 아니다

required는 반드시 존재해야 하는 키의 목록입니다. 그게 전부입니다.

{ "type": "object", "required": ["user_id"] }

{"user_id": null}은 이를 만족합니다. {"user_id": ""}도 그렇습니다. null이 허용되지 않는다면 프로퍼티 스키마에서 그렇게 말하세요. "type": "string"은 그 자체로 null을 배제하고, ["string", "null"]은 null을 허용합니다.

{
  "properties": { "user_id": { "type": "string", "minLength": 1 } },
  "required": ["user_id"]
}

거울에 비친 실수는 null이 될 수 있는 필드를 필수가 아니라고 표시하는 것입니다. 가끔 없는 키와 가끔 null인 키는 소비하는 쪽에 서로 다른 계약이고, 그중 하나를 의도적으로 고르는 일은 응답을 설계하는 작업의 일부입니다.

샘플에서 스키마 생성하기

필드가 마흔 개인 페이로드의 스키마를 손으로 쓰는 일은 사람들이 그냥 건너뛸 만큼 지겹습니다. 실제 응답에서 생성하는 것이 현실적인 출발점인데, 결과가 쓸 만한지를 가르는 요구 조건이 하나 있습니다.

객체의 배열은 첫 원소에서 표본을 뽑을 게 아니라 모든 원소에 걸쳐 합쳐야 합니다. 0번 원소만 취하면 두 가지가 어긋납니다. 3번 원소부터 등장하는 키는 properties에서 통째로 빠지고, 우연히 0번에는 있고 뒤에는 없는 키가 required로 표시되어 스키마가 유효한 줄 아는 데이터를 거부합니다. 올바른 동작은 properties에는 키의 합집합을, required에는 교집합을 쓰고, 타입은 키마다 합치는 것입니다.

스키마 생성기가 하는 일이 그것입니다. 손으로 친 예시가 아니라 실제 API 출력 한 페이지를 먹인 다음 결과를 손보세요. 알려진 형식이 있는 문자열은 조이고, 집합이 닫혀 있는 곳에는 enum을 더하고, additionalProperties 문제는 객체마다 정하세요. 생성된 출력은 초안이지 계약이 아닙니다.

숫자도 한번 볼 만합니다. 생성기는 9007199254740993을 보고 "type": "integer"라고 씁니다. 타입에 대해서는 정직하고, JavaScript가 그 값을 담지 못한다는 사실에 대해서는 침묵합니다.

노력이 되돌아오는 자리

API 계약. 스키마가 곧 제공하는 쪽이 테스트하는 대상이자 소비하는 쪽이 검증에 쓰는 것이 되므로, 호환을 깨는 변경이 고객의 로그가 아니라 CI에서 실패합니다. 설정 검증. 배포 파일의 오타가 세 서비스 건너의 null 역참조가 아니라, 문제의 키를 가리키는 JSON Pointer가 붙은 오류가 됩니다. 그리고 코드 생성. 정의 하나에서 참조 문서와 TypeScript 타입이 함께 나오므로 셋이 어긋날 수가 없습니다.

이 중 어느 것도 스키마를 썼다고 해서 오지 않습니다. 올바르게 연결된 검증기가 실제로 강제하는 스키마를 썼을 때 옵니다.