JSON Schema 오류가 이해되지 않을 때: Ajv 오류 위치와 메시지 관리
핵심 요약: 검증기는 기계 오류를 내지만 사용자는 어느 입력 상자가 틀렸는지 알고 싶어 합니다. Ajv errors 배열, instancePath와 schemaPath, 그룹화와 문구를 다룹니다.

핵심부터 말하면, instancePath를 먼저 읽어 실패한 데이터를 찾고, schemaPath로 발동한 규칙을 찾으세요. required와 additionalProperties는 메시지를 고르기 전에 params에서 진짜 키 이름을 꺼내야 합니다.
검증기의 일은 어느 규칙이 실패했는지 말하는 것입니다. 그걸 "이 입력 상자가 틀렸다"로 번역하는 것은 당신의 일입니다.
1. errors 배열이 실제로 주는 것
Ajv는 검증 실패 시 던지지 않고 결과를 validate.errors에 둡니다. 기본 설정은 첫 오류만 유지하며, allErrors가 전체 목록을 만듭니다. 단일 오류 객체에는 중요한 다섯 필드가 있고, 이를 이해하는 것이 이후 모든 것의 전제입니다:
| 필드 | 의미 | 예 |
|---|---|---|
instancePath |
실패한 값의 JSON Pointer | /email |
schemaPath |
스키마에서 실패한 키워드의 위치 | #/properties/email/format |
keyword |
위반된 제약의 이름 | format |
params |
해당 제약의 기계 가독 파라미터 | { "format": "email" } |
message |
버전마다 바뀌는 영어 문장 | must match format "email" |
실제 오류는 이렇게 생겼습니다:
[
{
"instancePath": "/email",
"schemaPath": "#/properties/email/format",
"keyword": "format",
"params": { "format": "email" },
"message": "must match format \"email\""
}
]
message는 사람을 위해 쓴 문장이지 안정적인 인터페이스가 아닙니다. Ajv가 버전 간에 문구를 바꿀 수 있고, 다국어 제품에 쓸 수 없습니다. 정규식으로 절대 파싱하지 마세요, 클라이언트에 그대로 반환하지도 마세요.
2. instancePath와 schemaPath는 서로 다른 질문에 답함
instancePath는 데이터의 어느 조각이 실패했는지, schemaPath는 어느 규칙이 발동했는지 말합니다. 두 좌표계이며 대칭적이지 않습니다:
instancePath는 RFC 6901 JSON Pointer입니다. 빈 문자열은 루트를,/items/0/price는 첫 배열 요소의 price 필드를 뜻합니다.schemaPath는#으로 시작해 스키마 문서 안의 키워드를 가리킵니다.$ref를 넘으면 참조된 스키마 안을 가리키므로, 어느 파일에서 왔는지 알려면$id가 필요합니다./나~가 포함된 키는 각각~1,~0으로 이스케이프됩니다. 표시 전에 되돌리지 않으면 UI에 이해하기 어려운 기호가 나옵니다.
예를 들어 instancePath: "/email"과 schemaPath: "#/properties/email/format"이 함께 있으면 루트 객체의 email 필드가 format 검사를 통과하지 못했다는 뜻입니다.
가장 흔한 함정은 required와 additionalProperties입니다. 그들의 instancePath는 문제가 된 키가 아니라 부모 객체를 가리킵니다.
| 키워드 | instancePath가 가리키는 곳 |
진짜 키가 사는 곳 |
|---|---|---|
required |
부모 객체(루트에서는 빈 문자열) | params.missingProperty |
additionalProperties |
부모 객체 | params.additionalProperty |
type |
실패한 값 자체 | — |
enum |
실패한 값 자체 | params.allowedValues |
오류를 폼 필드에 매핑할 때 이 두 경우를 먼저 처리하세요. 그렇지 않으면 "email이 없다"가 "루트 객체 오류"가 되어 사용자는 어디에 입력해야 할지 모릅니다.
3. allErrors와 verbose: 완전성 대 비용
기본 allErrors: false는 Ajv를 첫 실패에서 멈춥니다. 명령행 도구에는 괜찮지만 폼에는 참혹합니다. 사용자는 필드 하나를 고치고 제출하고, 다음 오류를 보고, 폼이 통과할 때까지 세 번 반복합니다.
| 설정 | 동작 | 이점 | 비용 |
|---|---|---|---|
allErrors: false |
첫 오류만 | 가장 빠르고 메모리 최소 | 반복 제출 |
allErrors: true |
모든 오류 수집 | 한 번에 모든 문제 | 복잡한 스키마에서 오류 수가 폭발 |
verbose: true |
오류마다 schema와 data 첨부 |
서버 측 진단이 쉬움 | 크기가 크게 증가, 절대 노출하지 말 것 |
messages: false |
message 필드 제거 |
페이로드 감소 | 영어 디버깅 단서를 잃음 |
권장 분담: 서버 검증에는 allErrors: true를 켜고 전체 배열을 내부 로그에 유지하며, 공개 응답에는 화이트리스트 필드만 담습니다. verbose는 조사 중에만 켜세요. 모든 오류에 매칭된 스키마 조각과 사용자 데이터를 내장해 메모리를 소비하고 민감한 값을 로그로 흘릴 수 있습니다.
오류 볼륨도 흔한 문제입니다. 실패한 anyOf 분기는 자식 오류 여러 개와 요약 오류 하나를 내고, 분기가 많은 oneOf는 더 심합니다. 배열을 그대로 보여 주면 사용자는 한 필드에 거의 중복인 메시지 다섯 개를 봅니다. 렌더링 전에 그룹화해야 합니다.
4. 필드 단위로 오류 그룹화
그룹화는 세 가지를 합니다. 경로를 필드 이름으로 되돌리고, required와 additionalProperties의 오프셋을 바로잡고, 같은 필드의 여러 오류를 가장 실행 가능한 하나로 합칩니다.
/** One validation error ready for display */
interface FieldError {
field: string;
keyword: string;
params: Record<string, unknown>;
}
/**
* Ajv 오류를 필드 단위로 그룹화합니다.
* @param errors Ajv가 생성한 오류 배열
* @returns 필드 경로를 키로, 각 값이 해당 오류 목록인 맵
*/
function groupByField(errors: Array<Record<string, any>>): Record<string, FieldError[]> {
const grouped: Record<string, FieldError[]> = {};
for (const errorItem of errors) {
const keyword: string = errorItem.keyword;
let field: string = errorItem.instancePath;
if (keyword === 'required') {
const parentPath: string = errorItem.instancePath;
field = `${parentPath}/${errorItem.params.missingProperty}`;
} else if (keyword === 'additionalProperties') {
field = `${errorItem.instancePath}/${errorItem.params.additionalProperty}`;
}
const normalizedField = field === '' ? '(root)' : field;
(grouped[normalizedField] ??= []).push({
field: normalizedField,
keyword,
params: errorItem.params ?? {},
});
}
return grouped;
}
우선순위로 병합하세요. required(누락)가 먼저, 그다음 type, 그다음 enum/const, 그다음 format/pattern, 그다음 길이와 범위. 필드마다 가장 높은 우선순위 메시지만 보여 주고 나머지는 로그에 둡니다. 이유는 간단합니다. 타입이 틀렸을 때 길이 메시지는 소음이고, 타입을 고치는 것이 유일하게 합리적인 다음 행동입니다.
oneOf와 anyOf의 요약 오류는 대개 실행 가능한 정보를 담지 않습니다. 렌더링 중에 건너뛰고, 구체적인 필드를 가리키는 분기 오류만 유지하세요.
5. 커스텀 메시지: 플러그인 경로와 템플릿 경로
Ajv에는 다국어 문구가 없으므로 실용적인 두 가지 선택이 있습니다.
선택 하나: ajv-errors 플러그인. errorMessage 키워드를 더해 메시지가 제약 옆에 삽니다:
{
"properties": {
"email": {
"type": "string",
"format": "email",
"errorMessage": {
"format": "Please enter a valid email address",
"type": "Email must be a string"
}
}
}
}
장점은 문구가 필드를 따라다니고 스키마가 문서 역할도 한다는 것입니다. 단점은 텍스트가 한 언어로 고정되어 다국어화하려면 여러 스키마를 유지해야 한다는 것입니다. 플러그인은 allErrors: true가 설정되어야만 모든 메시지를 대체합니다. 그 플래그를 잊으면 커스텀과 기본 영어 메시지가 섞입니다.
선택 둘: 키워드-템플릿 표. 검증기는 keyword와 params만 주고, 애플리케이션이 문구를 만듭니다:
| 키워드 | 템플릿 | 사용 가능 파라미터 |
|---|---|---|
required |
Missing required field: {field} | missingProperty |
type |
Expected type: {type} | type |
minLength |
Enter at least {limit} characters | limit |
maximum |
Must not exceed {limit} | limit |
format |
Invalid format: {format} | format |
enum |
Must be one of the allowed options | allowedValues |
pattern |
Value does not match the expected format | pattern (에코하지 마세요) |
선택 둘은 여러 언어와 일관된 어조가 필요한 제품에 어울립니다. 검증과 표시를 분리하고, 번역을 스키마 밖에 두며, 프런트엔드가 같은 템플릿을 재사용하게 합니다.
6. 다국어화: 메시지는 검증기에 속하지 않음
문구를 언어 팩으로 옮기는 것이 메시지 관리의 출발점입니다. 언어 팩은 템플릿과 키를 저장하며 필드 이름은 절대 저장하지 않습니다. 필드 라벨은 폼 설정이나 API 계약에서 오고, 둘은 렌더링 시 결합됩니다.
/** Ajv 키워드별 검증 메시지 템플릿 */
const VALIDATION_TEMPLATES: Record<string, (params: Record<string, any>) => string> = {
required: (params) => `Missing required field: ${params.missingProperty}`,
type: (params) => `Expected type: ${params.type}`,
minLength: (params) => `Enter at least ${params.limit} characters`,
maximum: (params) => `Must not exceed ${params.limit}`,
format: () => 'Invalid format',
};
/**
* 사용자 대상 검증 메시지를 하나 만듭니다.
* @param keyword 실패한 제약의 이름
* @param params 제약 파라미터
* @returns 다국어 메시지 텍스트
*/
function buildMessage(keyword: string, params: Record<string, any>): string {
const template = VALIDATION_TEMPLATES[keyword];
return template ? template(params) : 'The value is invalid';
}
여기서 세 가지 규칙이 중요합니다:
- 문장을 이어 붙이지 마세요. "Email format is invalid"와 그 중국어 대응은 어순을 공유하지 않으며, 문자열 조립은 언젠가 둘 중 하나에서 비문을 만듭니다.
- 복수형과 단위를 i18n 라이브러리에 맡기세요. 템플릿에 "characters"를 하드코딩하지 마세요.
1 character와3 characters는 다릅니다. - 필드 라벨도 번역하세요.
params.missingProperty는email이지만 UI는 "Email address"를 말해야 합니다. 그 매핑은 언어 팩이 아니라 폼 설정에 두세요.
7. 클라이언트에 내부 구조를 반환하지 말 것
schemaPath, params, message를 함께 반환하는 것은 개발 중에는 편하고 운영에서는 정보 유출입니다. allowedValues는 내부 열거를, pattern은 검증 정규식을, schemaPath는 파일 구조와 필드 이름을 드러냅니다. 경우에 따라 공격자에게 약점 지도를 넘기는 셈입니다.
공개 응답에는 정확히 세 가지만 담습니다. 필드 경로(또는 폼 필드 이름), 안정적인 오류 코드, 사용자 대상 텍스트. 원시 keyword 대신 짧고 안정적인 코드를 써서 나중에 검증기를 바꿀 여지를 남기세요.
{
"type": "about:blank",
"title": "Request validation failed",
"status": 422,
"errors": [
{ "field": "email", "code": "invalid_format", "message": "Please enter a valid email address" },
{ "field": "items[0].price", "code": "invalid_pattern", "message": "Amount must have exactly two decimals" }
]
}
서버 로그는 반대를 담아야 합니다. 전체 오류 배열, 요청 id, 스키마 버전. 그러면 조사에서 "어느 규칙이 이것을 막았나"에 답할 수 있습니다. 요청 본문 전체를 로그에 쏟지 말고, 실패 경로와 키워드를 유지하세요.
8. 프런트엔드 표시: 필드 강조에서 에디터 마커까지
오류는 결국 UI에 도달하고, 표시는 문맥에 달려 있습니다:
| 문맥 | 표시 | 핵심 동작 |
|---|---|---|
| 폼 | 필드 아래 빨간 텍스트, 첫 오류에 포커스 | 경로를 폼 이름으로 매핑, 배열 인덱스 유지 |
| 대량 테이블 가져오기 | 행 번호, 열 이름, 요약, 내보내기 | /items/3/price를 행 4, price 열로 |
| JSON 에디터 | 인라인 마커와 문제 패널 | 파서로 JSON Pointer를 행/열로 매핑 |
| API 디버깅 도구 | schemaPath를 유지한 구조화 오류 목록 |
개발자 대상이라 더 많은 디테일 허용 |
구현 시 세 가지 유의점:
- 필드마다 첫 오류만 보여 주고 나머지는 "2 more issues"로 접어 폼이 빨간색에 빠지지 않게 합니다.
- 지목에는 배열 인덱스를 유지하지만 라벨에서는 버리세요. "행 4, price"가
/items/3/price보다 읽기 쉽습니다. - 에디터 시나리오에는 위치 매핑이 필요합니다. 필드 이름이 반복되면 단순 문자열 검색은 신뢰할 수 없습니다. 파서가 노드마다 오프셋을 제공하고, 이를 행/열로 변환하세요.
JSON이 폼이 아니라 사용자가 직접 입력한 것이라면 문서가 문법적으로 유효한지 먼저 확인하세요. 문법 오류는 파싱 중에 발생하며 스키마 검증에 도달하지 않습니다. JSON 파싱 및 포매팅이 정확한 행과 열을 보고해 스키마 오류를 쫓는 것보다 훨씬 빠릅니다. 샘플에서 스키마를 추론할 때 과도하게 엄격한 스키마가 생기는 이유는 자동 생성한 JSON Schema가 실제 데이터를 계속 거부하는 이유를, 검증기 설정과 키워드 체계는 JSON Schema: 기초부터 운영까지를 참고하세요.
9. 자주 묻는 질문
오류가 하나만 나오는 이유는?
allErrors가 기본 false라 Ajv는 첫 실패에서 반환합니다. 폼 검증에는 명시적으로 켜세요. 아니면 사용자가 매번 반복 제출해야 모든 문제를 발견합니다.
required 오류의 경로가 왜 비어 있나?
required의 instancePath는 부모 객체를 가리키며, 루트에서는 빈 문자열입니다. 빠진 키 이름은 params.missingProperty에 있습니다. 둘을 이어 붙여 전체 경로를 만드세요. instancePath만 보여 주면 쓸모없는 "루트 객체 오류"가 됩니다.
message를 사용자에게 직접 보여도 되나?
권장하지 않습니다. 버전마다 문구가 바뀌는 영어 산문이고 다국어화 능력이 없습니다. keyword와 params에서 텍스트를 생성하고 message는 로그에만 남기세요.
커스텀 오류 메시지가 왜 무시되나?
먼저 allErrors: true가 켜졌는지 확인하세요. ajv-errors는 그 플래그가 있어야만 모든 메시지를 대체할 수 있습니다. 그다음 errorMessage가 실패한 키워드와 같은 수준에 있는지 확인하세요. 한 단계만 어긋나도 인식되지 않습니다.
운영에서 안전하게 반환할 수 있는 필드는?
필드 경로, 안정적인 오류 코드, 사용자 대상 텍스트입니다. schemaPath, params, message는 서버 로그에 두세요. 내부 열거, 정규식, 파일 구조를 노출하지 않고 디버깅을 지원합니다.