JSON Schema 재사용: $ref, $defs, allOf/oneOf 구성
핵심 요약: $ref는 재사용을, allOf와 oneOf는 구성을 해결하지만 둘을 겹쳐 쌓을 때 문제가 터집니다. $id, $defs, 조건부, 재귀, 번들링, 디버깅을 다룹니다.

핵심부터 말하면, 먼저 공유 구조를 $defs와 $ref로 추출한 뒤 allOf/oneOf를 꺼내세요. 분기가 동시에 일치할 수 있으면 anyOf나 const 태그를 쓰고, oneOf는 정확히 하나의 일치가 정말로 필요한 경우에 남겨 두세요.
복사 붙여넣기는 10분을 아끼고, 3개월 뒤 어느 복사본이 맞는지 아무도 기억하지 못하는 조용한 분기를 만들어 냅니다.
1. 재사용의 세 계층 — 파일을 나누기 전에 하나를 고르기
필드가 늘어나기 시작하면 첫 본능은 모든 제약을 한 파일에 몰아넣는 것입니다. 출시는 가장 빠르지만, 몇백 줄을 넘으면 필드 변경마다 세 곳을 똑같이 고쳐야 합니다. 하나를 놓치면 "로컬에서는 통과, 운영에서는 500"이 됩니다. 재사용은 사실을 한 번 쓰고 다른 곳에서는 참조하는 것입니다.
JSON Schema는 범위가 다른 세 계층을 제공합니다:
| 계층 | 매개체 | 범위 | 대표 용도 |
|---|---|---|---|
| 하위 구조 | $defs + $ref |
하나의 스키마 파일 | 주소, 금액, 페이지네이션, 오류 모양 |
| 파일 | 독립 스키마 + $id |
한 서비스 안의 여러 파일 | 사용자, 주문, 상품 엔터티 |
| 계약 라이브러리 | 스키마 저장소 + 번들 산출물 | 여러 서비스, 여러 언어 | OpenAPI 컴포넌트, 공개된 계약 |
대부분 프로젝트에는 첫 계층으로 충분합니다. 파일 분할은 두 서비스가 정말 같은 계약을 공유하거나, 스키마가 다른 언어가 소비하는 공개 산출물일 때만 이득입니다. 너무 일찍 나누면 $id 해석과 상대 경로 디버깅이 어려워지고, 그 비용이 중복을 피해 아낀 것보다 큰 경우가 많습니다.
간단한 경험 법칙: 구조가 두 곳에 나타나면 복사본 하나는 견딜 만하고, 세 번째에 나타나면 $defs로 승격하세요. 다른 언어나 저장소가 읽어야 할 때만 고유 $id를 가진 독립 파일로 만들 자격이 있습니다.
2. $id는 정체성을 세우고 $ref는 점프를 수행
$id는 주석이 아닙니다. 스키마의 기준 URI를 선언합니다. $ref 값은 URI 참조입니다. 현재 기준 URI에 대해 해석되어 절대 URI가 되고, # 뒤의 프래그먼트가 노드를 찾습니다. 기준 URI가 틀리면 모든 상대 참조가 다른 곳을 가리키는데, 오류는 대개 틀린 호스트를 알려 주지 않고 "can't resolve reference"만 말합니다.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/order.json",
"$defs": {
"address": {
"$anchor": "address",
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string", "minLength": 1 },
"detail": { "type": "string" }
}
}
},
"type": "object",
"required": ["shipTo"],
"properties": {
"shipTo": { "$ref": "#/$defs/address" },
"billTo": { "$ref": "#address" }
}
}
shipTo는 JSON Pointer를, billTo는 앵커를 쓰며 둘 다 같은 노드에 도달합니다. 아래 표는 각 형태의 해석을 보여 줍니다:
| 형태 | 가리키는 곳 | 초안 | 비고 |
|---|---|---|---|
# |
현재 스키마의 루트 | 전체 | 재귀에 흔히 사용 |
#/$defs/address |
$defs 아래의 노드 |
2019-09 이후 | 가장 명확하며 선호됨 |
#address |
이름이 붙은 앵커 | 2020-12의 $anchor |
draft-07에서는 $id: "#address"로 작성 |
common.json |
기준 URI에 상대적인 파일 | 전체 | 등록되거나 번들되어야 함 |
https://example.com/x.json |
절대 URI | 전체 | 운영에서 런타임 가져오기를 피하세요 |
미묘한 함정 하나: $defs 안의 하위 스키마가 자체 $id를 선언하면, 그것이 그 아래 모든 것의 새 기준 URI가 됩니다. 다른 파일에서 복사한 스키마를 붙여 넣으면 남아 있던 $id가 상대 참조를 원래 호스트로 돌리게 되고, 검증기는 실제로 해석을 시도할 때에야 알아챕니다.
3. $defs는 컨테이너일 뿐, 검증 지점이 아님
$defs는 평범한 맵입니다. $ref가 가리키기 전에는 그 안의 무엇도 효력을 갖지 않습니다. $defs 맨 위에 required 목록이 있어도 들어오는 참조가 없으면 아무것도 검증하지 않습니다. 아무리 엄격해 보여도 마찬가지입니다. 그것은 검증기 버그가 아니라 명세가 의도한 동작입니다.
{
"$defs": {
"money": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]{2}$",
"description": "A two-decimal money string that avoids floating-point drift"
},
"page": { "type": "integer", "minimum": 1 }
},
"properties": {
"total": { "$ref": "#/$defs/money" },
"page": { "$ref": "#/$defs/page" }
}
}
팀 안에서 정해 둘 만한 관례 몇 가지:
$defs안의 키는 의미를 갖지 않습니다.address,Address,addr모두 합법이므로 스타일을 합의하세요.- 2019-09 이전에는 키워드가
definitions였습니다. 둘 다$ref로 대상이 될 수 있지만, 엄격 모드는definitions를 알 수 없는 키워드로 보고할 수 있습니다. $defs는 어떤 스키마든 담을 수 있습니다.allOf조각이나 자체$id를 가진 독립 하위 스키마도 포함됩니다.$defs를 템플릿 상속으로 취급하지 마세요. 정의만 저장하며, 의미는 여전히 참조하는 문맥에서 나옵니다.
4. allOf, anyOf, oneOf는 서로 대체할 수 없음
셋 다 여러 분기를 포함하지만 승인 규칙이 다릅니다. 데이터가 두 분기와 일치할 때 allOf와 anyOf는 통과하고 oneOf는 실패합니다. 사람들이 "스키마가 고장 났다"고 결론짓는 가장 흔한 이유입니다.
| 키워드 | 최소 일치 | 최대 일치 | 두 분기와 일치하는 데이터 | 오류 품질 | 대표 용도 |
|---|---|---|---|---|---|
allOf |
전체 | 무제한 | 통과 | 좋음, 제약 하나씩 | 제약 조각 결합 |
anyOf |
1 | 무제한 | 통과 | 나쁨, 분기가 쌓임 | "이메일 또는 전화, 둘 중 하나" |
oneOf |
1 | 1 | 실패 | 나쁨 | 판별 유니언 |
not |
0 | 0 | 실패 | 최악 | 몇몇 값 제외, 드물게 사용 |
{
"allOf": [
{ "$ref": "#/$defs/auditable" },
{ "$ref": "#/$defs/softDeletable" },
{ "required": ["email"] }
],
"unevaluatedProperties": false
}
allOf는 객체 정의를 병합하지 않습니다. 모든 분기가 성립하기만 요구합니다. 각 분기는 독립적으로 평가되므로, 한 분기 안의 additionalProperties: false는 그 분기가 선언한 키만 알고, 다른 분기가 선언한 필드를 추가 필드로 표시합니다. 2020-12의 해결은 알 수 없는 속성 정책을 최상위로 옮기고, 모든 분기 뒤에 한 번 평가되는 unevaluatedProperties: false를 쓰는 것입니다.
anyOf는 "여러 합법적 모양"에 쓰고, "서로 배타적인 여러 모양"에 쓰지 마세요. 두 분기가 동시에 일치할 수 있으면 oneOf는 악명 높은 must match exactly one schema in oneOf를 냅니다.
5. if/then/else와 판별자: 조건을 결정 가능하게 만들기
조건부 제약은 결정 경로가 명시적이라(if가 통과하면 then 적용, 아니면 else 적용) 대개 oneOf보다 나은 오류를 냅니다.
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": {
"properties": { "kind": { "const": "image" } },
"required": ["kind"]
},
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
의도대로 동작하는지 결정하는 세 가지 디테일:
if안의properties항목은 키가 없을 때 실패하지 않습니다. "kind가 존재하고 image와 같다"를 표현하려면required와 함께 쓰세요.if자체는 검증 오류를 절대 내지 않습니다. 실패하면else로 라우팅될 뿐이라, 그 자체만으로 나쁜 데이터를 막을 수 없습니다.then이나else만 단독으로 있으면 무력합니다.if옆에서만 적용됩니다.
유니언 분기가 늘어나면 조건 체인은 유지 불가능한 중첩이 됩니다. 더 견고한 패턴은 분기마다 태그 필드를 고정해 분기가 구성상 서로 배타적이 되게 하는 것입니다:
{
"oneOf": [
{
"properties": { "kind": { "const": "card" } },
"required": ["kind", "cardNumber"]
},
{
"properties": { "kind": { "const": "account" } },
"required": ["kind", "accountId"]
}
]
}
discriminator는 OpenAPI 3.x 확장이며 JSON Schema 키워드가 아닙니다. Ajv는 discriminator: true 옵션을 켜면 지원하지만, 매핑이 mapping에 살아 도구 체인 간 이식성이 없습니다. 스키마가 여러 검증기에서 동작해야 한다면 확장에 전혀 의존하지 않는 const 태그 패턴을 선호하세요.
6. 재귀 참조: 트리, 연결 리스트, 서로를 인용하는 파일
재귀 구조에는 특별한 문법이 필요 없습니다. 자기 자신을 가리키는 $ref면 충분합니다. 검증기는 컴파일 시 참조를 무한히 확장하지 않습니다. 실제 데이터 깊이에 따라 재귀하므로, 몇십 줄로 어떤 높이의 트리도 표현합니다.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/tree-node.json",
"$defs": {
"node": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"children": {
"type": "array",
"items": { "$ref": "#/$defs/node" }
}
}
}
},
"$ref": "#/$defs/node"
}
미리 정해 둘 만한 세 가지 경계:
- 데이터 깊이: 스키마는 한도를 두지 않지만, 검증과 직렬화 모두 깊이에 비례하는 스택을 소비합니다. 공개 API는 말하자면 8단계 같은 업무 한도를 더해야 하며, 그렇지 않으면 악의적으로 깊은 페이로드가 스택을 넘치게 할 수 있습니다.
- 상호 참조: A가 B를, B가 A를 참조하는 것은 합법이지만, 두 스키마를 먼저 등록해야 합니다. 그렇지 않으면 해석 순서가 결과를 좌우합니다.
- nullable 분기: 재귀 필드가
null일 수 있다면"type": ["object", "null"]을 쓰거나oneOf로 감싸세요.$ref는 nullable을 상속하지 않습니다.
7. 파일 간 참조와 번들링: 운영에서 스키마를 절대 가져오지 말 것
파일 간 참조는 개발 중에 검증기가 $id로 네트워크에서 대상을 가져오기 때문에 흔히 "그냥 동작"합니다. 배포 시점에 네트워크 정책을 바꾸면 검증 체인 전체가 끊깁니다. 운영 규칙은 간단합니다. 참조되는 모든 스키마는 프로세스 안에 등록되거나 미리 번들되어야 합니다.
import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };
const ajv = new Ajv2020({ allErrors: true, strict: true });
// Register the referenced file first; the key must equal its $id, or relative refs won't resolve
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');
// Compile the entry schema once, at module scope
export const validateOrder = ajv.compile(orderSchema);
order.json의 $id가 https://bbdrug.com/schemas/order.json이라면, "$ref": "address.json"은 https://bbdrug.com/schemas/address.json으로 해석되어 등록 키와 일치합니다. 한 글자만 달라도 can't resolve reference가 납니다.
세 가지 실용 접근은 이렇게 비교됩니다:
| 접근 | 방법 | 장점 | 비용 |
|---|---|---|---|
| 런타임 등록 | 파일마다 addSchema |
빌드 단계 없음, 파일이 독립적 | 등록 순서와 키를 수동으로 관리 |
| 빌드 시 번들 | ref 파서로 하나의 파일로 병합 | 런타임 의존성 제로 | 산출물이 커지고 diff가 시끄러움 |
| 비동기 로딩 | loadSchema + compileAsync |
자주 바뀌는 스키마 처리 | 네트워크와 캐시 우려, 핵심 경로에서는 피하세요 |
프런트엔드 프로젝트에서는 빌드 시 번들링이 더 안전합니다. 서버에서는 모든 참조가 해석됨을 증명하는 테스트가 있다면 등록만으로 충분합니다. 어느 쪽이든 CI에서 compile을 실행해 참조 오류가 첫 운영 요청이 아니라 병합 전에 드러나게 하세요.
8. 디버깅 순서: 해석, 컴파일, 그다음 분기 검사
거의 모든 $ref 문제는 세 단계로 찾을 수 있으며, 순서가 중요합니다:
| 증상 | 가능한 원인 | 해결 |
|---|---|---|
can't resolve reference ... from id ... |
$id 오타, 잘못된 상대 기준, 대상 미등록 |
$id와 해석된 절대 URI를 출력하고 등록 키와 비교 |
schema with key or id already exists |
같은 $id를 두 번 등록, 또는 compile 반복 호출 |
등록을 모듈 초기로 옮기고 한 번만 실행 |
strict mode: unknown keyword: "definitions" |
draft-07 문법 혼용 | $defs로 이름 변경, 엄격 모드를 끄지 말 것 |
데이터가 두 oneOf 분기와 일치 |
분기 제약 중복, 태그 필드 없음 | 각 분기에 const를 더하거나 anyOf로 전환 |
$defs 안의 제약이 효력 없음 |
아무것도 참조하지 않음 | 참조 경로를 확인, $defs 자체는 아무것도 검증하지 않음 |
디버깅할 때는 message보다 schemaPath를 먼저 읽으세요. 실패한 분기와 키워드를 알려 주므로 영어 문장보다 훨씬 신뢰할 만합니다. 요청마다 재컴파일하지 말고 컴파일된 검증기를 모듈 범위에 캐시하세요. 스키마 변경마다 통과 사례와 실패 사례를 모두 다루세요. 해피 패스만 테스트하는 것은 아무것도 테스트하지 않는 것입니다. 검증기 선택과 오류 메시지 설계는 JSON Schema: 기초부터 운영까지를, 스키마를 타입 생성에 연결하는 방법은 스키마와 TypeScript 타입의 양방향 동기화를 참고하세요.
9. 자주 묻는 질문
$ref가 type이나 description 옆에 있어도 되나?
2020-12는 $ref가 형제 키워드와 함께 있는 것을 허용하며, 그 형제들은 정상 평가됩니다. draft-07에서는 형제가 무시되고 $ref만 적용됩니다. 같은 스키마를 초안에 걸쳐 쓸 때 가장 큰 동작 차이이므로, 이전 전에 검증기가 어느 초안을 지원하는지 확인하세요.
$defs와 definitions 중 무엇을 써야 하나?
새 작업에는 $defs를 쓰세요. 2019-09와 함께 등장했습니다. definitions는 2020-12의 키워드가 아닙니다. $ref: "#/definitions/foo" 같은 포인터는 단순 JSON Pointer라 여전히 해석될 수 있지만, 엄격 모드는 알 수 없는 키워드로 보고합니다. 두 스타일을 섞으면 CI에서만 터지는 지뢰가 남습니다.
oneOf가 유효해 보이는 데이터를 거부하는 이유는?
데이터가 두 분기를 동시에 만족하기 때문입니다. oneOf는 정확히 하나를 요구하며, 객체의 일부만 제약하는 분기는 다른 분기의 필드가 없어도 만족됩니다. 각 분기에 const 태그를 더해 서로 배타적으로 만드세요.
재귀 참조가 영원히 반복되나?
아니요. 검증기는 컴파일 시 참조를 확장하지 않고 실제 데이터 위에서 재귀합니다. 진짜 위험은 스택을 넘칠 만큼 깊은 데이터와 상호 참조 파일 간의 잘못된 해석 순서입니다. 재귀 필드에 업무 수준의 깊이 제한을 두는 것이 어떤 스키마 요령보다 효과적입니다.
운영에서 원격 $ref에 의존해도 되나?
아니요. 원격 참조는 검증 가용성을 네트워크에 묶습니다. 도메인 변경, 인증서 만료, 내부 네트워크 격리는 모두 체인을 끊습니다. 예측 가능한 동작을 원한다면 참조 스키마를 산출물에 번들하거나 시작 시 등록하세요.
$defs와 별도 파일을 어떻게 결정하나?
두 곳에 나타나는 구조는 한 번 복사해도 됩니다. 세 번째 장소에 나타나면 $defs로 승격하세요. 다른 언어나 다른 저장소가 읽어야 할 때만 고유 $id를 가진 독립 파일로 만들 자격이 있습니다.
$defs 안의 제약이 왜 효력이 없나?
$defs는 컨테이너일 뿐이며, 어떤 $ref도 가리키지 않는 노드는 검증에 참여하지 않습니다. $defs 맨 위의 required 목록에 들어오는 참조가 없으면 데이터는 여전히 통과합니다. 그것은 명세이지 검증기 버그가 아닙니다.
additionalProperties: false를 allOf 분기 안에 넣어야 하나?
아니요. 각 분기는 독립 평가되므로 한 분기가 다른 분기가 선언한 필드를 추가 필드로 취급합니다. 2020-12의 접근은 알 수 없는 필드 정책을 최상위로 옮기고, 모든 분기 뒤에 한 번 평가되는 unevaluatedProperties: false를 쓰는 것입니다.