CI의 JSON 검증: jsonlint, ajv, jq 파이프라인
핵심 요약: 문법 검증은 파일이 파싱됨을 증명하고, 스키마 검증은 필드가 계약을 따름을 증명합니다. CI에서 중요한 것은 명확한 종료 코드, 오류 위치, 복구 경로입니다.

핵심부터 말하면, CI에서 jq empty가 문법을, ajv-cli가 구조를(여러 형식을 다루는 팀은 check-jsonschema), Prettier가 스타일을 담당하게 하고, 계층으로 연결해 첫 실패에서 멈추세요.
로컬 에디터의 파서를 통과한다고 파일이 CI에 들어갈 자격이 생기지 않고, CI를 통과한다고 필드가 계약과 맞는 것도 아닙니다.
1. 문법 검증과 스키마 검증 분리
"JSON 검증"이라는 표현은 너무 넓게 쓰이며, 그래서 팀이 하나의 도구로 두 다른 문제를 해결하려 합니다:
| 계층 | 답하는 질문 | 대표 도구 | 실패가 뜻하는 것 |
|---|---|---|---|
| 문법 | 이 파일이 유효한 JSON인가? | jq empty, jsonlint, python -m json.tool |
괄호, 쉼표, 인용부호, 인코딩이 깨짐 |
| 스키마 | 필드, 타입, 필수 속성이 계약과 맞나? | ajv-cli, check-jsonschema |
파일은 파싱되지만 업무 구조가 틀림 |
| 스타일 | 들여쓰기와 키 순서가 일관되나? | Prettier, jq -S |
형식 불일치, diff가 시끄러움 |
이 계층들은 서로를 대체하지 못합니다. 문법을 통과한 파일도 스키마에서 실패할 수 있습니다. {"price": "12.5"}는 유효한 JSON이지만 스키마가 price를 숫자로 요구하면 받아들일 수 없습니다. 반대로 스키마가 아무리 완전해도 닫는 중괄호가 빠진 파일은 결코 스키마에 컴파일되지 않습니다.
그래서 파이프라인은 문법 먼저, 구조 다음, 스타일 마지막으로 실행해야 합니다. 한 계층이 실패하면 즉시 멈추세요. 아니면 파싱 오류가 필드 오류로 위장합니다.
2. 문법 계층: jq empty가 최소 의존 옵션
CI 이미지에 jq가 이미 있다면 문법 검증 비용은 거의 없습니다:
jq empty data.json
jq empty는 출력이 없고 파일을 파싱만 합니다. 성공 시 0, 실패 시 0이 아닌 코드와 줄/열 정보로 끝납니다. 전체 Node 의존성 트리를 끌어오는 것보다 가볍고 첫 게이트로 좋습니다.
저장소의 JSON 파일을 배치 검증할 때 node_modules, 빌드 출력, 서드파티 캐시를 제외하세요:
find . -name '*.json' \
-not -path './node_modules/*' \
-not -path './dist/*' \
-print0 | xargs -0 -n1 jq empty
-print0과 xargs -0은 파일 이름의 공백을 처리하고, -n1은 한 파일씩 호출해 오류 메시지가 문제 파일을 알려 주게 합니다. 수백 파일을 한 프로세스에 넘기지 마세요. 오류 메시지가 서로 덮어씁니다.
jsonlint도 흔한 선택이며 대개 npx jsonlint -q data.json으로 호출하고 -q는 오류만 출력합니다. 줄과 열 번호를 보고하지만 유지 관리가 jq보다 덜 활발하고 버전마다 BOM과 주석 관대함이 다릅니다. 더 읽기 좋은 문법 검사기로 취급하고 스키마 검증기로는 쓰지 마세요.
3. 스키마 계층: ajv-cli validate의 기초
Ajv는 JavaScript 생태계에서 가장 널리 쓰이는 스키마 검증기이고, ajv-cli가 이를 명령행 도구로 노출합니다:
ajv validate -s schema.json -d data.json
-s는 스키마 파일, -d는 검증할 데이터를 가리킵니다. 실패 시 프로세스가 0이 아닌 코드로 끝나 CI가 즉시 실패합니다. 실제 프로젝트는 보통 두 플래그를 더합니다:
npx ajv-cli validate \
-s schemas/order.json \
-d 'fixtures/orders/*.json' \
--strict=false \
--errors=text
--strict=false는 오래된 스키마에 도움이 됩니다. 엄격 모드는 알 수 없는 키워드와 의심스러운 required 선언을 오류로 취급하는데, 많은 레거시 스키마가 여기서 실패합니다. --errors=text는 사람이 읽기 좋은 출력을 내고 기본 JSON 구조는 기계에 더 좋습니다. 결과를 누가 소비하는지로 고르세요.
스키마 초안 버전도 맞아야 합니다. Ajv 메이저 버전은 기본 지원 초안이 다르고, ajv-cli는 --spec=draft7, --spec=draft2019, --spec=draft2020으로 하나를 고를 수 있습니다. 스키마가 $schema: "https://json-schema.org/draft/2020-12/schema"를 선언했는데 CLI가 draft-07로 컴파일하면 일부 키워드가 조용히 무시됩니다. 스키마 작성과 버전 차이는 JSON Schema 시작하기를 참고하세요.
형식 검증도 자주 요구됩니다. format 키워드는 기본적으로 주석만 달아서 email, date-time, uri가 실제로 무언가를 강제하게 하려면 ajv-formats를 로드해야 합니다:
npx ajv-cli validate -c ajv-formats -s schema.json -d data.json
이 단계는 놓치기 쉽고, 결과적으로 email 필드가 not-an-email을 받아들입니다. 출시 전에 일부러 잘못된 레코드 하나를 파이프라인에 넣어 보세요. 문서를 열 번 읽는 것보다 규칙이 활성됨을 훨씬 잘 증명합니다.
check-jsonschema: 여러 형식과 더 친절한 오류
팀이 JSON 외에 YAML, TOML, GitHub Actions 설정을 다룬다면 check-jsonschema가 더 편합니다:
check-jsonschema --schemafile schemas/config.json config/app.json
check-jsonschema --schemafile schemas/pipeline.json .github/workflows/ci.yml
실용 장점:
- 네이티브 YAML 지원으로 Kubernetes 매니페스트나 CI 설정처럼 "JSON 모양이지만 YAML로 쓰인" 파일에 맞습니다.
- 오류에 파일 경로, 줄 번호, 필드 경로가 포함되어 위치 비용이 낮습니다.
- pre-commit 훅을 제공해 통합 비용이 저렴합니다.
- SchemaStore에서 흔한 스키마를 가져올 수 있지만 로컬 파일도 받아 네트워크 의존을 피합니다.
언어 간 팀에서는 Python이 이미 있는 경우가 많아 check-jsonschema 설치와 업그레이드가 Node 도구 체인 유지보다 가볍습니다. 문법은 jq, 구조는 이 도구가 담당하게 하면 조합의 유지 오버헤드가 매우 낮습니다.
4. 종료 코드가 유일하게 믿을 만한 CI 신호
출력이 아무리 세련되어도 CI는 종료 코드만 읽습니다. 흔한 세 함정:
| 증상 | 원인 | 해결 |
|---|---|---|
| 오류가 있는데 CI가 초록 | 명령이 ` | |
| 오류가 없는데 CI가 빨강 | diff가 "차이 발견"을 실패로 취급 |
"차이"와 "실행 오류"를 구분 |
| 오류 출력이 잘림 | 병렬 다중 파일 출력이 서로 덮어씀 | 직렬로 실행하거나 파일별 출력 |
jq 종료 코드 의미를 기억하세요. 정상 파싱은 0, -e를 쓰면 표현식 결과가 false나 null일 때 1, 사용 오류와 파일 누락은 2, 스크립트 컴파일 오류는 3입니다. 스키마 도구는 대개 성공 0, 검증 실패 1, 도구나 인자 오류 2를 씁니다. 래퍼를 쓸 때 모든 0 아닌 코드를 "데이터가 틀림"으로 뭉개지 마세요. 아니면 환경 실패가 내용 문제로 오진됩니다.
5. pre-commit: 커밋 전에 문제 잡기
pre-commit의 가치는 "검사 하나 더"가 아니라 10분 CI 실행 후가 아니라 1분 안에 개발자에게 피드백을 주는 것입니다. 최소 구성은 이렇습니다:
repos:
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.33.0
hooks:
- id: check-jsonschema
files: ^configs/.*\.json$
args:
- --schemafile
- schemas/config.json
- repo: local
hooks:
- id: jq-syntax
name: jq syntax check
entry: jq empty
language: system
files: ^configs/.*\.json$
두 디테일이 중요합니다:
files는 검증이 필요한 디렉터리만 매칭해야 합니다.node_modules나 생성 파일에 훅을 실행하지 마세요.- 로컬 훅은 개발자 머신에
jq가 설치되어 있다고 가정합니다. 그 요구를 문서화하거나 컨테이너 훅으로 전환하세요.
프로젝트가 이미 Husky와 lint-staged를 쓴다면 바로 결합하세요:
{
"lint-staged": {
"configs/*.json": ["jq empty", "prettier --check"]
}
}
lint-staged는 스테이징된 파일 목록을 넘기므로 명령이 여러 경로를 받아들여야 합니다. jq empty와 prettier --check 모두 그렇습니다. 훅이 전체 저장소를 스캔하게 하지 마세요. 커밋이 눈에 띄게 느려집니다.
6. GitHub Actions: 복사할 수 있는 파이프라인
아래 워크플로는 문법 검증, 스키마 검증을 수행하고 실패 시 보고서를 보존합니다:
name: json-validate
on:
push:
paths:
- 'schemas/**'
- 'fixtures/**'
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Syntax check
run: |
find fixtures -name '*.json' -print0 | xargs -0 -n1 jq empty
- name: Schema check
run: |
npx --yes ajv-cli validate \
-s schemas/order.json \
-d 'fixtures/orders/*.json' \
--strict=false \
--errors=text
paths 필터는 문서만 바뀐 변경이 검증을 촉발하지 않게 합니다. npx --yes는 의존성을 고정하지 않은 저장소에 맞습니다. 운영 프로젝트는 ajv-cli를 devDependencies에 더하고 버전을 고정해야 합니다. 잠금이 없으면 기본 초안이나 엄격 모드를 바꾸는 업스트림 릴리스가 파이프라인을 하룻밤에 빨갛게 만들 수 있습니다.
7. 오류 위치: 줄 번호에서 필드 경로까지
검증기 가치의 절반은 탐지, 나머지 절반은 위치입니다. 세 종류의 출력을 읽는 법을 익히세요:
- 문법 오류:
jq는 줄과 열을 보고하고 대개 근처 조각을 보여 줍니다. 먼저 괄호와 쉼표를 고치세요. 즉시 인코딩을 의심하지 마세요. - 스키마 오류: Ajv의
instancePath는 데이터 위치(예:/items/0/price),schemaPath는 규칙(예:#/properties/items/items/properties/price/type),keyword는 위반된 제약을 가리킵니다. - 형식 오류:
format검사가 실패하면 메시지가 대개 "must match format "email""만 말합니다.instancePath와 결합해 필드를 식별하세요.
instancePath를 조사 기록에 붙이세요. JSON 벽보다 훨씬 유용합니다. 배열에서는 숫자 인덱스가 어느 레코드가 실패했는지도 알려 줍니다. 업무 키가 더 직관적이면 나중에 매핑 줄을 더하세요. 예: items[0].id = ORD-042.
additionalProperties: false를 과용하거나 실제로 겹치는 oneOf 분기를 쓰는 같은 흔한 스키마 실수는 흔한 JSON Schema 생성 함정에 있습니다. 틀린 검증 규칙이 검증 없음보다 위험합니다. 거짓 안전감을 만들기 때문입니다.
8. 도구 선택 표
| 필요 | 권장 도구 | 이유 | 주의 |
|---|---|---|---|
| 문법만 | jq empty |
의존성 제로, 빠름, 명확한 종료 코드 | 배치 실행 시 파일 이름의 공백 |
| 더 친절한 줄/열 오류 | jsonlint |
읽기 좋은 출력 | 유지 관리 활동 제한 |
| JSON Schema 검증 | ajv-cli |
성숙한 생태계, 구조화 오류 | 초안 버전 정렬, format 지원 로드 |
| JSON과 YAML 혼합 저장소 | check-jsonschema |
다형식, pre-commit 친화 | 스키마 출처와 네트워크 접근 |
| 커밋 전 빠른 피드백 | pre-commit / lint-staged | 스테이징된 파일만 처리 | 훅이 여러 경로를 받아들여야 함 |
| 일관된 형식 | Prettier | 널리 채택 | 파일을 재작성하고 싶지 않으면 --check |
마지막 권장: 일부러 잘못된 레코드 하나를 넣어 파이프라인이 실제로 실패함을 확인하세요. 가장 큰 위험은 복잡한 구성이 아니라 결코 효력을 갖지 않는 구성입니다. "실패할 수 있다"를 수용 기준으로 삼는 것이 "통과한다"를 기준으로 삼는 것보다 신뢰할 만합니다. 더 넓은 명령행 도구 체인은 에디터, 명령행, 자동화에서 이어가세요.
FAQ
jq empty와 jsonlint 중 무엇을 써야 하나?
CI에서는 jq empty를 선호하세요. 의존성 제로, 빠른 시작, 명확한 종료 코드 의미입니다. 더 친절한 줄/열 메시지가 필요하거나 로컬에서 조사할 때 jsonlint를 쓰세요. 둘 다 문법 검증만 하고 필드 구조는 검사하지 않습니다.
ajv가 알 수 없는 키워드를 보고하는 이유는?
먼저 스키마의 $schema 초안이 CLI가 쓰는 버전과 맞는지 확인하세요. 많은 "unknown keyword" 오류는 옛 컴파일러가 새 초안 키워드를 만난 것입니다. 버전이 맞으면 오타를 확인하세요. --strict=false가 레거시 스키마를 동작시키지만, 진짜 문제를 숨기려고 장기적으로 의존하지 마세요.
스키마 검증은 통과하는데 API가 여전히 실패하는 이유는?
스키마는 구조를 검증하지 업무 의미를 검증하지 않습니다. 예: status가 합법적 열거를 가진 문자열이어도 상태 기계가 paid에서 refunded로 직접 이동을 금지할 수 있습니다. 그런 규칙은 JSON Schema가 아니라 코드나 데이터베이스 제약에 속합니다.
실패한 필드를 어떻게 빨리 찾나?
도구 출력의 instancePath나 줄 번호를 읽으세요. Ajv의 instancePath는 데이터를 직접 가리키는 JSON Pointer이고 check-jsonschema는 파일과 줄을 보고합니다. 경로를 기록하고 업무 키로 매핑하면 보통 문제를 찾는 데 몇 분이면 됩니다.
pre-commit이 전체 검증을 실행해야 하나?
아니요. pre-commit은 커밋을 빠르게 유지하려고 스테이징된 파일만 처리해야 합니다. 전체 스캔은 CI나 예약 작업에 속합니다. 분업은 간단합니다. 로컬은 속도, CI는 완전성.