JSON을 Rust 구조체로: serde 구조체 생성 가이드(2026)
페이로드를 JSON to Rust 변환기에 붙여넣고 생성된 serde 구조체를 복사하세요. JSON to Rust 구조체 작업은 이게 전부입니다. 설치할 것도 없고, 업로드되는 것도 없으며, 모두 브라우저 안에서 처리됩니다. “지금 당장 구조체가 필요해”라는 상황을 몇 초 만에 해결합니다.
하지만 구조체를 생성하는 것과 올바른 구조체를 생성하는 것은 별개의 문제입니다. 변환기는 추측만 할 수 있을 뿐입니다. 그리고 Rust에서는 그 대가가 더 큽니다. 타입스크립트(TypeScript)에서는 잘못된 추측이 그냥 무시하면 되는 느슨한 any로 떨어질 뿐입니다. 하지만 serde에서는 잘못된 추측이 역직렬화 단계에서 실패합니다. 부동소수점 값이 i64에 들어가거나, 옵션이 아닌 필드에 예상치 못한 null이 들어오면 serde_json::from_str은 데이터 대신 Err를 건네줍니다. Rust에서 구조체를 제대로 잡는 일이 대부분의 언어보다 더 중요한 이유가 여기에 있습니다.
아래에서는 타입 추론이 실제로 어떻게 동작하는지, 대부분의 변환기가 틀리는 숫자 타입 규칙, #[serde(rename)]과 #[serde(rename_all)] 중 무엇을 언제 써야 하는지, 그리고 출력이 항상 컴파일되도록 날짜·동적 키·Rust 키워드를 다루는 방법을 살펴봅니다.
JSON을 Rust로 변환하는 방법
JSON을 Rust로 변환하는 과정은 세 단계입니다.
- JSON을 붙여넣으세요. 객체, 배열, 또는 가공하지 않은 API 응답을 입력창에 넣으세요. 변환은 즉시, 그리고 전적으로 클라이언트 측에서 실행됩니다.
- 출력을 조정하세요. 루트 구조체 이름을 바꾸고, serde derive를 켜고 끄고,
Debug와Clone을 추가하거나, 크레이트 스타일에 맞게pub가시성을 없애세요. - 복사하거나 내려받으세요. 생성된 Rust 코드를 클릭 한 번으로 가져와 프로젝트에 바로 붙여넣으세요.
핵심 작업 경로는 이게 전부입니다. 입력이 압축되어 있거나 유효한지 확실하지 않다면, 먼저 JSON 포맷터를 거쳐 변환기가 깔끔하고 잘 정리된 JSON을 다루도록 하세요. 이 가이드의 나머지 부분에서는 도구가 스스로 추론하지 못하는 경우를 직접 고칠 수 있도록 출력을 읽는 방법을 설명합니다.
JSON 타입이 Rust로 매핑되는 방식
모든 JSON 값에는 대응하는 Rust 타입이 있지만, 그 매핑이 완전히 일대일은 아닙니다.
| JSON 값 | Rust 타입 |
|---|---|
"text" | String |
42 | i64 (큰 값은 u64, 그 이상은 f64) |
3.14, 2e3 | f64 |
true / false | bool |
null | Option<T> (또는 Option<serde_json::Value>) |
[1, 2, 3] | Vec<T> |
{ ... } | 이름이 붙은 struct |
실제 작업이 벌어지는 곳은 객체입니다. 전형적인 REST 페이로드를 예로 들어 보겠습니다.
{
"id": 101,
"name": "Ada Lovelace",
"email": "ada@example.com",
"active": true,
"roles": ["admin", "user"]
}
변환기는 곧바로 serde에 쓸 수 있는 구조체를 만들어 냅니다.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub id: i64,
pub name: String,
pub email: String,
pub active: bool,
pub roles: Vec<String>,
}
각 스칼라 값은 기본 타입으로 매핑되고, roles는 Vec<String>이 됩니다. 깔끔하게 매핑되지 않는 유일한 JSON 타입은 number입니다. JSON에는 숫자 타입이 하나뿐이지만, Rust에서는 i64, u64, f64 중에서 골라야 합니다. 이 선택은 아래 별도의 절 전체에서 다룰 주제인데, 대부분의 생성기가 바로 여기서 틀리기 때문입니다.
변환기가 구조체를 추론하는 방식
네 가지 규칙이 여러분이 입력할 거의 모든 경우를 다룹니다. 객체 형태마다 구조체 하나, 배열은 키 단위 병합, 정밀한 숫자 타입 지정, 그리고 관용적인 필드 이름입니다.
구조 추론: 객체마다 이름이 붙은 구조체 하나
서로 다른 객체 형태는 각각 이름이 붙은 별도의 구조체가 됩니다. 중첩된 객체는 인라인으로 펼쳐지지 않고, 별도의 정의로 끌어올려져 참조됩니다.
{ "repo": "serde", "owner": { "login": "dtolnay", "id": 100 } }
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub repo: String,
pub owner: Owner,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Owner {
pub login: String,
pub id: i64,
}
중첩된 owner는 자체 Owner 구조체가 되고 필드를 통해 참조됩니다. 동일한 형태는 중복 제거되므로, 같은 구조를 가진 두 필드는 복사본을 만드는 대신 하나의 구조체를 공유합니다.
배열 병합과 Option 필드
객체 배열을 넣으면 변환기가 이들을 키 단위로 병합합니다. 일부 요소에는 있지만 다른 요소에는 없는 키는 Option이 됩니다.
{ "users": [{ "id": 1, "nick": "x" }, { "id": 2 }] }
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub users: Vec<User>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: i64,
pub nick: Option<String>,
}
id는 모든 요소에 나타나므로 필수로 남습니다. nick은 두 번째 사용자에 없으므로 Option<String>이 됩니다. 여기에 없는 것에 주목하세요. #[serde(default)]가 없습니다. serde는 이미 Option을 선택적 값으로 취급하고 누락된 키를 None으로 역직렬화하므로, 그 속성은 불필요합니다.
올바른 숫자 타입 지정: i64, u64, f64
이것은 경쟁 도구들이 건너뛰는 규칙이자, 실제 페이로드에서 문제를 일으키는 규칙입니다. 생성기는 세 단계를 적용합니다. 정수는 기본적으로 i64로 매핑되고, 값이 i64::MAX를 넘으면 u64로 승격되며, u64::MAX마저 넘어서면 f64로 물러납니다. 1.0이나 2e3처럼 소수점이나 지수로 표기된 토큰은 크기와 상관없이 f64로 매핑됩니다.
{ "user_id": 12000000000000000000, "balance": 19.99, "retries": 3 }
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub user_id: u64,
pub balance: f64,
pub retries: i64,
}
여기서 user_id는 i64::MAX보다 크므로 u64에 담기고 값이 온전히 왕복(round-trip)됩니다. balance는 소수점을 포함하므로 정수가 아니라 f64입니다. 이것이 중요한 이유는 serde가 역직렬화 시점에 타입을 강제하기 때문입니다. 스노플레이크(snowflake)나 트위터 스타일 ID를 i32로 지정한 구조체를 넘기면 값이 오버플로되고, 부동소수점 필드를 i64로 지정하면 serde는 조용히 잘라내는 대신 오류를 반환합니다. 이 세 단계 규칙 덕분에 생성된 JSON 기반 Rust 구조체가 첫 번째 큰 ID에서 패닉을 일으키는 대신 정상적으로 역직렬화됩니다.
snake_case 필드와 #[serde(rename)]
JSON 키는 흔히 camelCase지만, 관용적인 Rust 필드는 snake_case입니다. 변환기는 필드 이름을 바꾸고, 이를 정확한 원래 JSON 키로 되돌려 매핑하는 #[serde(rename)]을 추가합니다.
{ "login": "octocat", "publicRepos": 15, "followerCount": 9001, "createdAt": "2011-01-25" }
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub login: String,
#[serde(rename = "publicRepos")]
pub public_repos: i64,
#[serde(rename = "followerCount")]
pub follower_count: i64,
#[serde(rename = "createdAt")]
pub created_at: String,
}
login처럼 이미 snake_case인 키에는 rename이 붙지 않습니다. 나머지는 전송 형식으로 깔끔하게 되돌려 매핑되면서도, Rust 코드는 자연스럽게 읽힙니다.
#[serde(rename)] vs #[serde(rename_all)]
이 도구는 필드마다 #[serde(rename)]을 내보내는데, 이 방식은 하나의 페이로드가 같은 객체 안에서 camelCase, snake_case, 그리고 불규칙한 키를 섞어 쓰더라도 언제나 동작하기 때문입니다. 존재하지 않을 수도 있는 공통 규칙을 추론할 필요가 전혀 없습니다.
구조체의 모든 필드가 실제로 하나의 규칙을 공유한다면, 그 속성들을 컨테이너 수준의 rename 하나로 합칠 수 있습니다. 필드별 줄을 지우고 구조체에 #[serde(rename_all = "camelCase")] 하나를 붙이세요.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Root {
pub login: String,
pub public_repos: i64,
pub follower_count: i64,
pub created_at: String,
}
두 버전 모두 같은 JSON을 역직렬화합니다. 키가 섞여 있거나 불규칙할 때는 필드별 rename을 쓰고, 구조체가 균일하고 잡음을 줄이고 싶을 때는 rename_all을 쓰세요. serde는 같은 속성에 "snake_case", "kebab-case", "SCREAMING_SNAKE_CASE" 같은 다른 규칙도 지원합니다.
Rust 키워드와 식별자로 쓸 수 없는 키
JSON 키는 그저 문자열이므로, API가 type이나 match라는 이름의 키를 보내는 것을 막을 방법이 없습니다. 둘 다 Rust의 예약어입니다. 생성기는 이런 키를 적법한 식별자와 rename의 조합으로 정리합니다.
{ "type": "user", "match": true, "first-name": "Ada" }
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
#[serde(rename = "type")]
pub type_: String,
#[serde(rename = "match")]
pub match_: bool,
#[serde(rename = "first-name")]
pub first_name: String,
}
끝에 밑줄을 붙이는 형태(type_)는 r#type 같은 raw 식별자 대신 의도적으로 선택한 것입니다. raw 식별자는 모든 위치에서 모든 키워드를 표현하지 못합니다. 특히 self, crate, super는 raw 식별자로 거부되므로, 정리 후 rename하는 방식만이 언제나 컴파일됩니다. first-name이나 숫자로 시작하는 2fa 같은 식별자로 쓸 수 없는 키도 같은 처리를 받습니다. 유효한 Rust 이름을 만들고, 원래의 JSON 키 문자열로 되돌려 rename합니다.
serde_json으로 역직렬화하기
구조체가 준비되면 Cargo.toml에 의존성을 추가하세요.
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
그러면 파싱은 한 줄로 끝납니다. 이 예제는 그 자체로 완결되어 있으며 수정 없이 컴파일됩니다.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub id: i64,
pub name: String,
pub email: String,
pub active: bool,
pub roles: Vec<String>,
}
fn main() -> Result<(), serde_json::Error> {
let data = r#"{"id":101,"name":"Ada Lovelace","email":"a@ex.com","active":true,"roles":["admin"]}"#;
let root: Root = serde_json::from_str(data)?;
println!("{} has {} role(s)", root.name, root.roles.len());
Ok(())
}
Deserialize derive가 모든 일을 처리합니다. 문자열에는 serde_json::from_str, 바이트 슬라이스에는 from_slice, 파일이나 HTTP 본문에는 from_reader를 쓰고, 값을 다시 JSON으로 직렬화할 때는 serde_json::to_string을 쓰세요. 타입을 제대로 잡으면 이런 보상이 따라옵니다. 여러분이 생성한 serde derive는 런타임 유효성 검사기이므로, Ok를 반환하는 serde_json 역직렬화 호출은 컴파일러가 그럴 것이라고 믿는 정도가 아니라 데이터가 실제로 구조체와 일치했다는 보장입니다.
날짜, 동적 키, 그리고 알 수 없는 필드
일부 형태는 단순한 구조체 필드로 매핑되지 않으며, 생성기는 이런 경우에 합리적인 기본값을 제시하니 그 뒤에 직접 다듬으면 됩니다.
날짜. JSON에는 날짜 타입이 없으므로 ISO 또는 RFC 3339 타임스탬프는 단순한 String으로 들어옵니다. 실제 날짜 처리를 하려면 필드를 chrono 타입으로 바꾸고 chrono의 serde 기능을 켜세요(chrono = { version = "0.4", features = ["serde"] }). 그러면 serde가 RFC 3339를 자동으로 파싱합니다.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub name: String,
pub created_at: DateTime<Utc>,
}
동적 키. ID를 값에 매핑하는 경우처럼 키가 런타임에 달라진다면, 고정된 구조체는 맞지 않는 형태입니다. String을 키로 쓰는 HashMap을 사용하세요.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub scores: HashMap<String, i64>,
}
알 수 없는 추가 필드. 타입이 지정된 구조체를 유지하면서도 모델링하지 않은 키를 담아두려면, HashMap으로 뒷받침되는 #[serde(flatten)] 필드를 추가하세요. 진정으로 동적인 값이라면 serde_json::Value가 만능 그릇입니다. 실제 필요가 타입 생성이 아니라 구조를 검증하는 것이라면, JSON Schema 유효성 검사 가이드에서 계약을 처음부터 끝까지 강제하는 방법을 다룹니다.
json-to-rust vs quicktype vs typeshare vs 수작업
JSON에서 Rust 타입을 만들어 내는 유일한 최선의 방법은 없습니다. JSON이 어디에 있는지, 그리고 어느 방향으로 변환하는지에 따라 달라집니다.
| 방식 | 적합한 경우 | 비고 |
|---|---|---|
| 온라인 변환기(이 도구, transform.tools) | 일회성 변환, 민감한 페이로드, 설치 불필요 | 샘플에서 추론, 완전한 클라이언트 측 실행 |
| quicktype | 다중 언어 출력, 파이프라인 코드 생성 | 역시 샘플 기반, CLI 또는 웹 |
| typeshare | 기존 Rust 타입을 TS·Swift·Kotlin과 공유 | 반대 방향: Rust에서 다른 언어로 |
| schemafy / typify | JSON 스키마에서 생성 | 입력이 샘플이 아니라 스키마 |
| 구조체를 직접 작성 | 아주 작은 페이로드, serde 학습 | 완전한 제어, 규모가 커지면 번거롭고 실수하기 쉬움 |
새겨둘 만한 구분은 이렇습니다. 온라인 변환기와 quicktype은 둘 다 JSON 샘플에서 타입을 추론하는 반면, typeshare는 반대로 동작해 주석이 달린 Rust 타입을 타입스크립트나 Swift로 바꿉니다. schemafy와 typify는 예시 데이터가 아니라 JSON 스키마를 입력으로 받습니다. 코드베이스가 Rust가 아니라 타입스크립트라면, JSON to TypeScript 변환기로 같은 샘플 기반 방식을 적용할 수 있고, JSON to TypeScript 인터페이스 가이드에서 그쪽을 깊이 있게 다룹니다.
JSON에서 Rust를 생성할 때 흔한 함정
생성된 구조체는 출발점입니다. 실제 데이터에 출력을 신뢰하기 전에 다음을 주의하세요.
- 샘플 하나로는 모든 형태가 드러나는 경우가 드뭅니다. 선택적 필드는 여러 배열 요소가 있어야만 추론할 수 있습니다. 대표성 있는 배열을 붙여넣어
Option추론이 운 좋은 객체 하나에서 나온 추측이 아니라 정확한 결과가 되도록 하세요. - 비어 있거나 혼합된 배열은
serde_json::Value로 물러납니다. 추론할 근거가 없을 때 나오는 정직한 답입니다. 구체적인 요소 타입을 얻으려면 더 풍부한 샘플을 넣으세요. - 큰 ID를
i32로 좁히지 마세요. 스노플레이크나 유사한 ID는 2^53을 넘어서 32비트 필드를 오버플로합니다. 생성된i64나u64를 그대로 유지하세요. - 항상
null인 필드는 단순한 선택적 값이 아니라Option<serde_json::Value>가 됩니다. 도구가 추론할 타입이 없으므로, 이를 자리표시자로 여기고 타입을 알게 되면 실제 타입을 지정하세요. serde_json::Value에는serde_json의존성이 필요합니다. 출력에Value가 들어 있는데Cargo.toml에 해당 크레이트가 없으면 코드는 컴파일되지 않습니다.String으로 남겨둔 날짜로는 날짜 연산을 할 수 없습니다. 타임스탬프를 비교하거나 형식을 지정할 계획이라면, 문자열을 직접 파싱하는 대신 chrono 타입으로 바꾸세요.
자주 묻는 질문
구조체가 맞아 보이는데도 serde가 제 JSON을 거부하는 이유는 무엇인가요?
거의 항상 타입 불일치 때문입니다. 흔한 두 가지 원인은 부동소수점 값이 i64 필드에 들어가는 경우와, 가끔 누락되는데도 Option으로 지정되지 않은 키입니다. serde는 역직렬화 시점에 타입을 강제하므로, 필드를 f64나 Option<T>로 고치거나 대표성 있는 샘플에서 구조체를 다시 생성하세요.
JSON 정수는 어떤 Rust 숫자 타입이 되어야 하나요?
기본은 i64입니다. 값이 i64::MAX를 넘으면 u64로 승격하고, u64::MAX를 넘어서면 f64로 물러나세요. 소수점이나 지수로 표기된 숫자는 크기와 상관없이 f64로 매핑됩니다. serde는 정수 필드로 역직렬화되는 부동소수점 값을 거부하기 때문입니다.
JSON이 필드를 생략할 때 그 필드를 선택적으로 만들려면 어떻게 하나요?
Option<T>으로 타입을 지정하세요. serde는 Option을 자동으로 선택적 값으로 취급하고 누락된 키를 None으로 역직렬화하므로 #[serde(default)]는 필요 없습니다. 변환기는 샘플로 넣은 항목 중 일부에 키가 없으면 해당 필드를 Option으로 표시합니다.
#[serde(rename)]을 써야 하나요, 아니면 #[serde(rename_all)]을 써야 하나요?
페이로드가 이름 짓는 방식을 섞어 쓸 때는 필드별 #[serde(rename)]을 쓰세요. 언제나 동작합니다. 구조체의 모든 필드가 하나의 규칙을 따른다면, 필드별 속성을 지우고 구조체에 #[serde(rename_all = "camelCase")] 하나를 대신 붙이세요. 둘 다 동일하게 역직렬화합니다.
type처럼 Rust 키워드인 JSON 키는 어떻게 처리하나요?
생성기는 type_을 내보내고, 원래 키로 되돌려 매핑하는 #[serde(rename = "type")]을 함께 붙입니다. 이 방식은 r#type 같은 raw 식별자보다 견고합니다. raw 식별자는 모든 위치에서 self, crate, super를 다룰 수 없기 때문입니다.
JSON 날짜가 날짜 타입이 아니라 String으로 지정되는 이유는 무엇인가요?
JSON에는 날짜 타입이 없으므로 타임스탬프는 전송 중에는 그저 문자열이며, String이 정직한 기본값입니다. 실제 날짜 처리를 하려면 필드를 chrono의 DateTime<Utc>나 NaiveDate로 바꾸고 chrono의 serde 기능을 켜세요. 그러면 serde가 RFC 3339를 대신 파싱해 줍니다.
생성된 구조체로 JSON을 어떻게 역직렬화하나요?
Cargo.toml에 serde_json을 추가한 뒤 let root: Root = serde_json::from_str(json)?;를 작성하세요. 나머지는 Deserialize derive가 처리합니다. 바이트 슬라이스에는 from_slice, 파일이나 HTTP 본문에는 from_reader를 쓰세요.
온라인 JSON to Rust 구조체 변환기를 쓸 때 제 JSON은 비공개로 유지되나요?
그렇습니다. 변환은 JavaScript로 브라우저 안에서 100% 실행됩니다. 토큰, ID, 고객 데이터를 포함한 여러분의 JSON은 페이지를 벗어나지 않으며 서버로 전송되지 않습니다. 준비가 되면 여러분의 페이로드로 JSON to Rust 변환기를 사용해 보세요.