hyunsu blog

홈강좌Rust 실무 16편 중 5편째

Rust serde — 구조체와 JSON 을 오가는 표준

목차 · 4
  1. 01붙이는 법
  2. 02구조체 → JSON → 구조체
  3. 03잘못된 JSON 은 이유를 말해 준다
  4. 04실습 — 속성으로 모양 맞추기

serde 는 Rust 구조체를 JSON 같은 형식으로 바꾸고(직렬화) 되돌리는(역직렬화) 사실상의 표준이다. #[derive(Serialize, Deserialize)] 한 줄이면 되고, 잘못된 JSON 은 어느 칸이 왜 틀렸는지 오류로 알려 준다. rename_all·default·skip_serializing_if 같은 속성으로 JSON 의 모양을 맞춘다.

01 붙이는 법

serde 는 바꾸는 틀을, serde_json 은 JSON 형식을 맡는다. derive 기능을 켜야 #[derive(Serialize)] 를 쓸 수 있다.

toml
[package]
name = "notes"
version = "0.1.0"
edition = "2024"

[dependencies]
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.154"

02 구조체 → JSON → 구조체

rust
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct Note {
    title: String,
    tags: Vec<String>,
    pinned: bool,
}

fn main() {
    let note = Note {
        title: "Learn serde".to_string(),
        tags: vec!["rust".to_string(), "json".to_string()],
        pinned: true,
    };

    let json = serde_json::to_string_pretty(&note).unwrap();
    println!("{json}");

    let back: Note = serde_json::from_str(&json).unwrap();
    println!("{back:?}");
}
text
     Running `target/debug/notes`
{
  "title": "Learn serde",
  "tags": [
    "rust",
    "json"
  ],
  "pinned": true
}
Note { title: "Learn serde", tags: ["rust", "json"], pinned: true }
  • #[derive(Serialize, Deserialize)] 는 입문 22편의 #[derive(Debug)] 처럼 바꾸는 코드를 컴파일러가 대신 쓰게 한다. 칸 이름이 그대로 JSON 키가 된다.
  • serde_json::to_string_pretty 는 줄을 바꿔 보기 좋게, to_string 은 한 줄로 만든다.
  • serde_json::from_str 은 거꾸로 JSON 을 구조체로 읽는다. 무엇으로 읽을지 let back: Note 처럼 타입을 적는다(입문 30편의 parse 와 같다).
  • 둘 다 Result 를 돌려준다. 여기서는 예제를 짧게 하려고 unwrap 했다.

03 잘못된 JSON 은 이유를 말해 준다

밖에서 들어오는 JSON 은 믿을 수 없다. 세 가지 입력을 읽어 보자.

rust
use serde::Deserialize;

#[derive(Deserialize)]
struct Note {
    title: String,
    pinned: bool,
}

fn main() {
    let inputs = [
        r#"{"title": "ok", "pinned": false}"#,
        r#"{"title": "no pin"}"#,
        r#"{"title": "bad", "pinned": "yes"}"#,
    ];
    for input in inputs {
        match serde_json::from_str::<Note>(input) {
            Ok(note) => println!("ok: {} (pinned: {})", note.title, note.pinned),
            Err(e) => println!("error: {e}"),
        }
    }
}
text
     Running `target/debug/notes`
ok: ok (pinned: false)
error: missing field `pinned` at line 1 column 19
error: invalid type: string "yes", expected a boolean at line 1 column 32
  • 첫째는 맞다.
  • 둘째는 pinned 칸이 없다(missing field). 몇째 줄 몇째 글자에서 알아챘는지도 적는다.
  • 셋째는 pinned 에 "yes" 라는 문자열이 왔는데 bool 이어야 한다(invalid type).

구조체가 곧 검사 규칙이다. 칸을 하나하나 꺼내 타입을 확인하는 코드를 쓰지 않아도, 모양이 다르면 Err 가 온다. r#"…"# 는 큰따옴표가 든 문자열을 백슬래시 없이 쓰는 문법(raw string)이다.

04 실습 — 속성으로 모양 맞추기

API 는 키를 isPinned 처럼 쓰는데 Rust 는 is_pinned 로 쓰는 경우가 많다. 칸이 없을 때의 기본값, 비어 있으면 빼기도 자주 필요하다.

rust
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Note {
    title: String,
    #[serde(default)]
    is_pinned: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    due_date: Option<String>,
}

fn main() {
    let note: Note = serde_json::from_str(r#"{"title": "draft"}"#).unwrap();
    println!("{note:?}");
    println!("{}", serde_json::to_string(&note).unwrap());

    let with_due = Note { due_date: Some("2026-11-30".to_string()), ..note };
    println!("{}", serde_json::to_string(&with_due).unwrap());
}
text
     Running `target/debug/notes`
Note { title: "draft", is_pinned: false, due_date: None }
{"title":"draft","isPinned":false}
{"title":"draft","isPinned":false,"dueDate":"2026-11-30"}
  • #[serde(rename_all = "camelCase")] — 모든 칸 이름을 camelCase 로 바꿔 읽고 쓴다. is_pinned ↔ isPinned.
  • #[serde(default)] — JSON 에 칸이 없으면 그 타입의 기본값(bool 은 false)을 쓴다. 위 둘째 예처럼 오류가 나지 않는다.
  • #[serde(skip_serializing_if = "Option::is_none")] — None 이면 JSON 에서 아예 뺀다. 둘째 줄에는 dueDate 가 없고, 값을 넣은 셋째 줄에는 있다.
  • Note { due_date: …, ..note } 는 나머지 칸을 note 에서 가져와 새 구조체를 만드는 문법이다.

Note 에 tags: Vec<String> 를 더하고 #[serde(default)] 를 붙여, 태그가 없는 JSON 도 읽히는지 확인해 보자. 다음 편은 명령줄 옵션을 맡는 clap 이다.