Rust JSON Schema 자동 생성: schemars 라이브러리 사용법
API 명세서를 작성하거나 외부에서 들어오는 JSON 데이터를 검증해야 할 때 JSON Schema를 직접 손으로 적어본 적 있으신가요? 필드가 몇 개 안 되면 그럭저럭 버틸 만한데, 구조체 안에 또 구조체가 들어가고 열거형까지 섞이기 시작하면 금세 손이 아파옵니다. 게다가 Rust 코드와 스키마를 따로 관리하다 보면 어느 한쪽이 슬그머니 바뀌어 두 문서가 어긋나는 일도 종종 벌어지죠.
이럴 때 Rust 타입을 기반으로 JSON Schema 문서를 자동으로 만들어주는 라이브러리가 바로 schemars입니다. Serde로 직렬화/역직렬화를 처리하던 그 구조체에 derive 매크로 하나만 더 붙이면, 진실의 원천을 Rust 코드 한 곳으로 모을 수 있어요.
schemars란?
schemars는 구조체나 열거형으로 정의된 Rust 타입에서 JSON Schema(draft 2020-12) 문서를 생성해주는 크레이트입니다.
#[derive(JsonSchema)]를 붙여둔 타입에 대해 schema_for! 매크로를 호출하면, 그 타입의 직렬화 결과와 정확히 일치하는 스키마가 컴파일 시점에 만들어집니다.
한 가지 더 마음에 드는 부분은 Serde 애트리뷰트와 호환된다는 점이에요.
이미 #[serde(rename_all = "camelCase")]로 필드명을 변환하고 있다면, schemars는 그 설정을 그대로 따라가서 스키마의 키도 camelCase로 만들어줍니다.
덕분에 직렬화 결과와 스키마가 어긋날 일이 없습니다.
설치하기
schemars를 사용하려면 Cargo.toml에 다음과 같이 의존성을 추가합니다.
스키마는 결국 JSON으로 출력해야 하므로 serde_json도 같이 넣어두는 것이 일반적입니다.
[dependencies]
schemars = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde는 schemars가 동작하기 위해 꼭 필요한 의존성은 아니지만, 실제로는 Serde 타입과 함께 쓰는 경우가 대부분이라 같이 설치하는 편이 자연스럽습니다.
기본 사용법
가장 단순한 예제부터 시작해볼게요.
과일을 표현하는 구조체에 JsonSchema 트레이트를 derive로 파생시킵니다.
use schemars::{schema_for, JsonSchema};
#[derive(JsonSchema)]
struct Fruit {
name: String,
count: u8,
is_fresh: bool,
}
fn main() {
let schema = schema_for!(Fruit);
println!("{}", serde_json::to_string_pretty(&schema).unwrap());
}
schema_for! 매크로에 타입을 넘기면 Schema 값을 돌려주고, 이 값을 serde_json::to_string_pretty로 직렬화하면 사람이 읽기 좋은 JSON 형태로 출력할 수 있습니다.
실행해보면 다음과 같은 스키마가 만들어집니다.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Fruit",
"type": "object",
"properties": {
"count": {
"type": "integer",
"format": "uint8",
"maximum": 255,
"minimum": 0
},
"is_fresh": {
"type": "boolean"
},
"name": {
"type": "string"
}
},
"required": ["name", "count", "is_fresh"]
}
여기서 눈여겨볼 부분은 u8 타입에서 자동으로 minimum: 0, maximum: 255라는 범위 정보까지 빠져나왔다는 점이에요.
Rust의 풍부한 타입 정보를 그대로 스키마에 반영해주기 때문에, 별다른 추가 작업 없이도 꽤 정확한 검증 규칙이 따라옵니다.
필드 설명 추가하기
스키마는 사람에게 데이터의 의미를 알려주는 문서이기도 한데요.
각 필드에 doc 주석(///)을 적어두면 schemars가 자동으로 description 필드에 옮겨줍니다.
use schemars::{schema_for, JsonSchema};
#[derive(JsonSchema)]
struct Product {
/// 상품명
name: String,
/// 가격 (원)
price: u32,
/// 재고 수량
stock: u32,
}
fn main() {
let schema = schema_for!(Product);
println!("{}", serde_json::to_string_pretty(&schema).unwrap());
}
이렇게 작성하면 생성된 스키마의 각 속성에 description이 함께 들어갑니다.
"name": {
"description": "상품명",
"type": "string"
}
코드 주석을 그대로 문서화로 활용할 수 있으니, OpenAPI나 MCP 서버 도구의 입력 스키마처럼 외부에 노출되는 인터페이스에 특히 유용합니다.
Serde와 함께 쓰기
실무에서는 schemars를 Serde와 같이 쓰는 경우가 가장 많은데요. schemars는 Serde 애트리뷰트를 그대로 인식하기 때문에, 이미 직렬화 동작을 위해 적어둔 설정을 다시 반복할 필요가 없습니다.
다음은 흔히 마주치는 패턴을 한꺼번에 넣어본 예제입니다.
use schemars::{schema_for, JsonSchema};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct User {
/// 사용자 고유 ID
id: u64,
/// 사용자 이름
user_name: String,
/// 이메일 주소 (선택)
#[serde(default)]
email: Option<String>,
role: Role,
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum Role {
Admin,
Member,
Guest,
}
fn main() {
let schema = schema_for!(User);
println!("{}", serde_json::to_string_pretty(&schema).unwrap());
}
#[serde(rename_all = "camelCase")]가 붙어 있으니 user_name 필드는 스키마에서도 userName으로 나오고, #[serde(deny_unknown_fields)] 덕분에 additionalProperties: false까지 자동으로 들어갑니다.
또한 Option<String> 타입은 null을 허용하는 형태로 변환되고, #[serde(default)]가 붙은 email은 required 목록에서 빠집니다.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "User",
"type": "object",
"properties": {
"email": {
"description": "이메일 주소 (선택)",
"type": ["string", "null"],
"default": null
},
"id": {
"description": "사용자 고유 ID",
"type": "integer",
"format": "uint64",
"minimum": 0
},
"role": {
"$ref": "#/$defs/Role"
},
"userName": {
"description": "사용자 이름",
"type": "string"
}
},
"additionalProperties": false,
"required": ["id", "userName", "role"],
"$defs": {
"Role": {
"type": "string",
"enum": ["admin", "member", "guest"]
}
}
}
여기서 흥미로운 점은 Role 열거형이 스키마 본문에 인라인으로 박히지 않고 $defs 영역에 따로 정의되어 $ref로 참조된다는 거예요.
같은 타입이 여러 곳에서 쓰이더라도 정의는 한 번만 등장하니, 스키마 문서가 깔끔하게 정리됩니다.
열거형 다루기
위 예제의 Role처럼 단순한 변형(variant)만 가진 열거형이라면 결과가 깔끔합니다.
하지만 변형마다 다른 데이터를 담는 enum은 어떻게 표현해야 할까요?
이런 경우 Serde의 태깅 전략을 그대로 schemars가 받아들입니다.
가장 많이 쓰는 패턴은 #[serde(tag = "...")]로 내부에 태그 필드를 두는 방식이에요.
use schemars::{schema_for, JsonSchema};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Event {
UserCreated { id: u64, name: String },
UserDeleted { id: u64 },
Heartbeat,
}
fn main() {
let schema = schema_for!(Event);
println!("{}", serde_json::to_string_pretty(&schema).unwrap());
}
이렇게 하면 스키마는 oneOf 배열로 구성되며, 각 분기마다 kind 필드에 특정 문자열 상수가 박힙니다.
"oneOf": [
{
"type": "object",
"properties": {
"kind": { "type": "string", "const": "user_created" },
"id": { "type": "integer", "format": "uint64", "minimum": 0 },
"name": { "type": "string" }
},
"required": ["kind", "id", "name"]
},
{
"type": "object",
"properties": {
"kind": { "type": "string", "const": "user_deleted" },
"id": { "type": "integer", "format": "uint64", "minimum": 0 }
},
"required": ["kind", "id"]
},
...
]
JSON Schema의 oneOf와 const를 활용한 표현이라, 표준 검증기로 그대로 검증할 수 있습니다.
이벤트 페이로드나 명령 메시지처럼 “종류에 따라 모양이 달라지는” 데이터를 다룰 때 특히 잘 어울리는 패턴입니다.
검증 제약 추가하기
타입만으로 표현하기 어려운 추가 제약은 #[schemars(...)] 애트리뷰트로 직접 명시할 수 있어요.
예를 들어 주문 수량처럼 특정 범위 안의 값만 받고 싶다면 range로 최소/최대값을 강제할 수 있습니다.
use schemars::{schema_for, JsonSchema};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, JsonSchema)]
struct Order {
/// 주문 수량 (1 이상 100 이하)
#[schemars(range(min = 1, max = 100))]
quantity: u32,
/// 메모 (최대 200자)
#[schemars(length(max = 200))]
memo: String,
}
range는 숫자형 필드에 minimum과 maximum을 추가하고, length는 문자열의 글자 수 제한을 minLength/maxLength로 변환합니다.
이외에도 정규 표현식 검사를 위한 regex나 콜렉션 크기를 제한하는 옵션도 있으니, 비즈니스 규칙을 스키마 차원에서 보장하고 싶을 때 활용해보세요.
어디에 쓸까?
자동 생성된 스키마가 실제로 어디에 쓰이는지도 잠깐 짚어볼게요.
우선 API 명세 자동화에 잘 어울립니다.
OpenAPI 문서를 만들 때 요청/응답 타입의 스키마를 일일이 YAML로 적는 대신, Rust 타입에서 뽑아 그대로 끼워 넣으면 됩니다.
utoipa나 aide 같은 OpenAPI 통합 크레이트들이 내부적으로 schemars를 활용하는 이유도 여기에 있어요.
다음으로 설정 파일 검증에 유용합니다.
사용자가 작성하는 config.json이나 config.yaml이 있다면, schemars로 스키마를 뽑아두면 IDE의 자동 완성과 검증을 그대로 활용할 수 있습니다.
VS Code의 JSON Schema 연결만 해주면 사용자는 자동 완성과 오류 표시의 혜택을 누리게 되죠.
마지막으로 최근에는 MCP(Model Context Protocol) 서버를 작성할 때 도구의 입력 스키마를 정의하는 용도로도 많이 쓰입니다. LLM이 호출할 도구의 파라미터를 Rust 구조체로 정의하고, schemars로 생성한 스키마를 그대로 도구 정의에 넣어주면 됩니다.
마치며
지금까지 Rust 타입에서 JSON Schema 문서를 자동으로 생성해주는 schemars 크레이트를 살펴봤습니다.
#[derive(JsonSchema)] 한 줄과 schema_for! 매크로만으로 코드와 스키마를 동기화된 상태로 유지할 수 있고, Serde 애트리뷰트를 그대로 인식해주니 기존 직렬화 로직과도 자연스럽게 맞물립니다.
API 명세 작성이나 설정 파일 검증, 그리고 MCP 도구 정의처럼 손으로 스키마를 적던 작업이 있다면 schemars로 부담을 덜어보시면 좋겠어요.
더 자세한 내용은 schemars 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0