JSON Schema 계약 테스트: 검증 규칙을 CI에 용접하기
핵심 요약: 스키마는 작성으로 끝이 아니라 거기서부터 망가지기 시작합니다. 오타 하나가 검증을 조용히 느슨하게 만들고, 리팩터링 하나가 픽스처 전체를 오작동시킵니다. 이 글은 정례·부례·스냅샷 3종 세트, 스키마 자체의 린트, 컴파일 한 번 재사용, CI에 설치하는 네 개의 게이트로 이루어진 실행 가능한 계약 테스트 구성을 다룹니다.

결론: 스키마는 코드이고, 코드에는 테스트가 필요합니다. 정례 픽스처는 통과해야 할 것을 통과시키는 증거, 부례 픽스처는 거부해야 할 것을 거부하는 증거, 스냅샷은 의도치 않은 변경을 감시합니다. 마지막으로 ajv compile과 --ci 스냅샷이 파이프라인에서 야간 경비를 섭니다.
검증 규칙이 깨지는 순간 아무런 오류도 발생하지 않습니다. 느슨해져도 아무도 모르고, 엄격해지면 팀 전체가 감당합니다. 스키마는 설정 파일이 아니라 아무도 테스트하지 않은 코드입니다.
1. 스키마가 깨졌을 때 누가 먼저 아는가
실제로 부딪힌 함정부터 보겠습니다. 스키마를 2020-12 드래프트로 선언하고 Ajv 기본 패키지로 컴파일하면 즉시 예외가 던져집니다:
Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"
Ajv 8의 기본 진입점은 draft-07만 지원하며, 2020-12는 ajv/dist/2020에서 임포트해야 합니다. 또 하나의 함정: strict 모드에서 format: "email"을 썼는데 ajv-formats를 설치하지 않았다면 역시 컴파일 시점에 예외가 발생합니다:
Error: unknown format "email" ignored in schema at path "#/properties/email"
로컬 첫 컴파일에서 이 오류를 보는 것은 좋은 일입니다. 문제는 많은 프로젝트가 컴파일을 요청 도착 시점까지 미룬다는 점입니다. 깨진 스키마는 첫 번째 희생자를 기다립니다. 더 은밀한 것은 반쯤 깨진 스키마입니다. required를 배열이 아닌 문자열로 잘못 쓰면 컴파일이 schema is invalid: data/required must be array를 던지지만, 이 단계가 CI에서 한 번도 실행되지 않았다면 그대로 프로덕션에 묻힙니다.
그러므로 첫 번째 관문은 데이터 검증이 아니라 스키마 자체의 검증입니다. 한 줄입니다:
node scripts/lint-schemas.mjs
// scripts/lint-schemas.mjs
import { readFileSync, readdirSync } from 'node:fs';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
for (const file of readdirSync('schemas')) {
const schema = JSON.parse(readFileSync(`schemas/${file}`, 'utf8'));
ajv.compile(schema);
console.log(`ok ${file}`);
}
왜 npx ajv compile가 아닌가: ajv v8 패키지에는 CLI가 없어 npx ajv는 could not determine executable to run으로 실패합니다. CLI는 별도 패키지 ajv-cli(실측 5.0.0)에 있습니다. 그러나 ajv-cli 5는 draft-07과 draft-2019-09만 지원하므로, 1절의 2020-12 스키마에 쓰면 똑같이 no schema with key or ref "https://json-schema.org/draft/2020-12/schema"를 던집니다. 따라서 2020-12 프로젝트는 본문과 같은 Ajv2020으로 lint하고, npx ajv-cli compile -s '**/*.schema.json' --strict는 draft-07 프로젝트에만 맞습니다.
린트처럼 돌리면 오타, 폐기된 키워드, 미등록 format이 모두 PR 단계에서 드러납니다. 이것이 스키마 테스트와 일반 코드 테스트의 첫 공통점입니다. 먼저 컴파일이 통과하는지 확인한다.
2. 3종 세트: 정례·부례·스냅샷
스키마 테스트의 골격은 세 가지뿐입니다.
정례(valid fixtures): 실제 응답에서 채집한, 통과해야 하는 샘플. 기억으로 손으로 쓰지 말고 staging에서 가져오십시오. 손으로 쓴 정례는 happy path만 커버하고 null, 빈 문자열, 필수만 있는 경계를 놓치기 쉽습니다.
부례(invalid fixtures): 스키마 안의 모든 제약에 대해 최소 하나의 위반 입력을 준비합니다. minimum: 0이면 -1(거부)과 0(통과) 한 쌍, enum이면 목록 밖의 값, additionalProperties: false면 여분 필드. 부례가 정례보다 중요합니다. 스키마가 조용히 나쁜 데이터를 통과시키는 것은 사일런트 버그이며 데이터베이스까지 오염됩니다.
파일명을 어서션으로 만드는 명명 규약은 오픈소스에서 그대로 가져올 수 있습니다:
schemas/order.schema.json
fixtures/order/valid-minimal.json # 통과 필수
fixtures/order/valid-full.json # 통과 필수
fixtures/order/invalid-type.json # 거부 필수
fixtures/order/invalid-extra-field.json # 거부 필수
테스트 프레임워크는 Node 내장 node:test로도 충분합니다:
import { test } from 'node:test';
import { readFileSync, readdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(
JSON.parse(readFileSync('schemas/order.schema.json', 'utf8'))
);
for (const f of readdirSync('fixtures/order')) {
const data = JSON.parse(readFileSync(`fixtures/order/${f}`, 'utf8'));
if (f.startsWith('valid-')) {
test(`${f} 통과해야 함`, () => { assert.ok(validate(data), JSON.stringify(validate.errors)); });
} else {
test(`${f} 거부되어야 함`, () => { assert.ok(!validate(data)); });
}
}
스냅샷: validate.errors를 직렬화해 저장·비교합니다. 누군가 의도치 않게 제약을 바꾸면 스냅샷이 즉시 빨개집니다. Jest/Vitest에서는 expect(errors).toMatchSnapshot() 한 줄이면 되고, CI에서는 반드시 --ci 옵션을 붙이십시오. 새 스냅샷이 자동 생성 대신 실패하기 때문에 드리프트가 묵인되지 않습니다.
3. 오류를 한 번에 전부 모으기
부례 테스트에서 놓치기 쉬운 설정: 기본적으로 검증기는 첫 오류에서 멈춥니다. 데이터가 세 곳에서 깨져도 보이는 곳은 한 곳이고, 고치고 다시 실행하는 과정이 반복됩니다. allErrors: true(Ajv) 또는 iter_errors(Python)를 쓰면 한 번에 전부 보입니다:
from jsonschema import Draft202012Validator
validator = Draft202012Validator(schema)
errors = sorted(validator.iter_errors(bad), key=lambda e: list(e.absolute_path))
for e in errors:
print(list(e.absolute_path), e.validator, e.message)
Python에는 직관에 반하는 사실도 있습니다. validate()가 보고하는 것은 "첫 번째" 오류가 아니라 best_match가 고른 것입니다. 실측에서 id 타입 위반, role 열거 위반, 여분 extra 필드의 세 위반이 있었는데 additionalProperties 한 건만 보고되었고 id의 타입 오류는 숨겨졌습니다. CI에서는 전체를 보기 위해 항상 iter_errors를 씁니다.
더 밟기 쉬운 함정: format은 기본적으로 어서션이 아닙니다. 2020-12는 format을 어노테이션으로 규정하며, Python jsonschema는 format_checker를 넘기지 않으면 검증하지 않습니다. 실측에서 "not-an-email"은 그대로 통과했습니다. Ajv도 ajv-formats 설치와 등록이 필요합니다. format이 중요한 데이터(이메일, 날짜, uuid)에는 검증기 설정이 실제로 검증하고 있음을 증명하는 부례 픽스처가 있어야 합니다.
4. 한 번 컴파일하고, 컴파일러를 일회용으로 쓰지 말 것
로컬 실측(Node v22.13.1, Ajv 8.17, 같은 3층 중첩 스키마, 같은 위반 데이터):
| 작성 방식 | 1만 회 검증 소요 |
|---|---|
매번 new Ajv() 후 compile |
42233 ms |
모듈 수준 인스턴스 + compile 한 번 재사용 |
6.9 ms |
6000배 차이입니다. 검증 함수를 요청 핸들러 안에 두고 호출마다 새 Ajv 인스턴스를 만드는 것이 가장 흔한 성능 사고입니다. 널리 퍼진 오해도 짚겠습니다. "매번 ajv.validate(schema, data)는 재컴파일한다"는 말인데, 실측에서 10만 회 8.2ms로 재사용 compile의 11.2ms와 맞먹었습니다. Ajv는 내부에 스키마 캐시를 두기 때문입니다. 진짜 문제는 캐시 미스가 아니라 인스턴스를 재사용하지 않는 것입니다. Python도 마찬가지로 Draft202012Validator를 재사용하면 매번 validate()를 호출하는 것보다 약 5000배 빠릅니다(2000 회 기준 0.3ms vs 1566ms). 후자는 호출마다 스키마 자체를 검사하기 때문입니다.
5. 게이트를 CI에 용접하기: 네 개의 문
비용이 낮은 순으로 배열합니다. 앞에서 떨어지면 뒤는 실행할 필요가 없습니다:
| 게이트 | 명령 형태 | 잡아내는 것 | 비용 |
|---|---|---|---|
| 스키마 린트 | node scripts/lint-schemas.mjs |
스키마 문법 오류, 폐기 키워드 | 밀리초 |
| 픽스처 검증 | 2절의 테스트 스위트 | 픽스처와 스키마 불일치, 제약 오변경 | 밀리초 |
| 파괴적 변경 diff | json-schema-diff-validator로 main과 비교 |
required 삭제, enum 축소, pattern 강화 | 초 단위 |
| live 응답 검증 | staging에 smoke 테스트, .toMatchSchema() 어서션 |
실제 엔드포인트 드리프트 | 초 단위 |
GitHub Actions 최소 구성:
- run: npm ci
- run: npm run lint:schemas # Ajv2020 lint
- run: npm run test:schema # 픽스처 3종 세트
- run: npx jest --ci --testPathPattern=schema # 스냅샷 드리프트 방지
파괴적 변경 게이트는 특별히 강조할 가치가 있습니다. json-schema-diff-validator 계열 도구는 "required 필드 삭제", "enum에서 값 소실"을 리뷰 단계에서 적색으로 표시합니다. 이는 평범한 API diff 도구가 "비파괴"로 판정하기 쉬운 두 종류의 변경입니다. 관용적인 컨슈머에게는 무해해도 exhaustive switch를 쓰는 컨슈머에게는 프로덕션 크래시입니다.
6. 불리언이 아니라 에러 객체를 어서션하라
부례 테스트에서 validate(data) === false만 확인하는 것은 불충분합니다. 같은 스키마라도 올바른 이유로 거부하는지 확인해야 합니다. 무관한 제약이 우연히 통과했을 수 있으므로입니다. Ajv의 에러 객체에는 유명한 비대칭이 있습니다. required 실패 시 instancePath는 부모 객체를 가리키고, 누락된 필드명은 params.missingProperty에 숨어 있습니다. id 누락 시 실측으로 얻은 객체는:
{ "instancePath": "", "schemaPath": "#/required",
"keyword": "required", "params": { "missingProperty": "id" } }
어서션은 errors.length > 0 같은 막연한 것이 아니라 "keyword=required이고 missingProperty=id인 오류가 존재"로 씁니다. 오류 필드 의미의 전체 해설은 JSON Schema 오류를 못 읽겠다: Ajv 오류 위치 파악과 메시지 개선을 참고하세요.
7. 자주 있는 함정 빠른 대조표
| 함정 | 결과 | 대책 |
|---|---|---|
| 200 happy path만 테스트 | 에러 봉투 드리프트를 아무도 모름 | 에러 응답에도 스키마와 픽스처 구성 |
| 생성기 산출물 무심사 | 단일 샘플에서 추론된 required가 실데이터를 오거부, 팀은 검증을 꺼버림 | 생성 후 required/enum 사람이 검토 |
| 전 버전에 스키마 하나 | v1/v2 차이가 지워짐 | 스키마를 엔드포인트와 함께 버저닝 |
| 실패 시 페이로드 전체를 로그로 | PII와 비밀키가 CI 로그에 유출 | JSON Pointer 경로와 잘라낸 단편만 기록 |
| enum 고정 | 용어 변경(canceled→cancelled)에 검증 붕괴 | enum 변경은 호환성 리뷰로 |
| 검증기 버전 미고정 | 기계마다 동작 불일치 | CI에서 검증기 버전 고정 |
| 권한 테스트 대용으로 사용 | 403 응답이 에러 스키마에 맞아도 역할 로직은 틀림 | 스키마는 구조, 인가는 별도 테스트 |
| format 의미 망각 | 깨진 이메일이 그대로 통과 | ajv-formats 설치 / format_checker 전달, 부례도 구성 |
8. 도입 체크리스트
- 스키마 파일을 버전 관리에 넣고
node scripts/lint-schemas.mjs를 린트에; - 스키마마다 정례 1개 이상 + 제약마다 부례 1개, 파일명이 곧 어서션;
- 에러 객체를 스냅샷화하고 CI에서
--ci로 자동 드리프트 방지; - 검증기 인스턴스는 모듈 수준 재사용, 핸들러 안에서 new 금지;
- 파괴적 변경 diff를 PR에 걸고 required/enum 변경은 강제 리뷰;
- 첫 PR에서 게이트가 실패할 수 있음을 증명: required 하나를 지우고 빨개지는 것을 확인한 뒤 복원.
검증의 강약(언제 additionalProperties를 닫을지)은 별개의 문제입니다. additionalProperties를 닫지 않으면 스키마 검증은 무의미를 참고하세요. 이 글의 주제는 쓰여진 규칙을 지속적으로 신뢰할 수 있게 유지하는 것입니다. 기초부터 다지려면 JSON Schema 입문에서 실무까지부터 시작하세요.
FAQ
스키마 검증과 Pact 계약 테스트의 관계는?
역할 분담입니다. 스키마 검증은 "이 문서가 구조적으로 옳은가"라는 일방향 어서션입니다. Pact 같은 컨슈머 주도 계약 테스트는 "프로바이더가 여전히 컨슈머 X의 구체적 필요를 충족하는가"라는 양방향 검증입니다. 소규모 팀은 스키마 게이트로 시작하고 서비스 수가 늘면 Pact를 도입합니다.
부례 테스트는 몇 개나 쓰면 충분한가?
제약 하나에 부례 하나가 하한선입니다. required, type, minimum, enum, pattern, additionalProperties 등 제약 수만큼 부례가 있어야 하고, 경계값의 거부·수용 한 쌍을 더합니다. 제약 수에 못 미치면 반드시 감시되지 않는 제약이 남습니다.
스키마 변경 중 무엇이 "파괴적"인가?
컨슈머 관점에서 판단합니다. 응답 필드 삭제, enum 삭제·축소, pattern 강화, 필수 요청 필드 추가는 모두 파괴적입니다. 선택적 응답 필드 추가는 보통 무해하지만, 컨슈머 디코더가 미지의 키를 용납한다는 전제가 있습니다. json-schema-diff-validator로 PR 단계에서 자동 비교하고 회색 지대는 사람이 재판합니다.
테스트는 실제 API와 mock 중 무엇을 때려야 하나?
둘 다, 그러나 계층을 나눠서입니다. 픽스처 테스트는 네트워크를 만지지 않고 밀리초 단위로 모든 PR에서 실행됩니다. live 검증은 staging을 때리고 초 단위로 smoke 파이프라인에 속하며, 자격증명이 없으면 skipif로 건너뜁니다. CI에서 실제 API만 도는 팀은 네트워크 요동과 레이트 리밋에 마모되어 결국 테스트를 꺼버립니다.