Rust에서 JSON 다루기: serde 역직렬화 실전 정리

5 분 소요

실전 강좌 3편에서 serde로 JSON Lines 로그를 받는 것을 잠깐 봤습니다. 이 글은 그 serde를 실전 폭으로 넓힙니다. API 응답 처리, 설정 파일, 데이터 파이프라인까지, Rust에서 JSON을 만나는 곳이면 어디든 통하는 패턴 모음입니다.

기본형: derive 한 줄 #

설치
cargo add serde --features derive
cargo add serde_json
src/main.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct User {
    id: u64,
    email: String,
    active: bool,
}

let json = r#"{"id": 7, "email": "kim@example.com", "active": true}"#;
let user: User = serde_json::from_str(json)?;         // JSON → 구조체
let back = serde_json::to_string_pretty(&user)?;      // 구조체 → JSON

Deserialize가 읽기, Serialize가 쓰기입니다. 파싱과 동시에 타입 검증이 끝난다는 것이 이 방식의 핵심 가치입니다. id에 문자열이 오거나 email이 없으면 from_str가 Err를 반환하므로, 이 줄을 통과한 User는 이후 코드 어디서든 완전한 값입니다. 파이썬의 json.loads처럼 dict를 받은 뒤 키 존재를 하나하나 확인하는 방어 코드가 통째로 사라집니다.

현실 JSON 1: 없을 수도 있는 필드 — Option과 default #

실제 API 응답은 필드가 있다가 없다가 합니다. 대응 도구는 둘이고 의미가 다릅니다.

src/main.rs
#[derive(Debug, Deserialize)]
struct Profile {
    email: String,
    nickname: Option<String>,     // 없으면 None — "없음"을 데이터로 유지
    #[serde(default)]
    retry_limit: u32,             // 없으면 0 (타입의 기본값)
    #[serde(default = "default_timeout")]
    timeout_secs: u64,            // 없으면 30
}

fn default_timeout() -> u64 { 30 }

“없음 자체가 의미 있는 정보"면 Option으로 남기고, “없으면 정해진 값으로 채우면 그만"이면 default를 씁니다. 설정 파일 구조체는 대부분 default 계열로 채우는 것이 사용성에 좋고, API 응답은 Option으로 받아 없음을 명시적으로 처리하는 쪽이 안전합니다.

현실 JSON 2: 이름 규칙이 다르다 — rename_all #

JSON 세계는 camelCase, Rust는 snake_case입니다. 필드마다 개별 rename을 붙일 필요 없이 한 줄로 규칙을 선언합니다.

src/main.rs
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiResponse {
    user_id: u64,        // JSON의 "userId"와 대응
    created_at: String,  // JSON의 "createdAt"과 대응
}

반대로 오타·스키마 변경을 빨리 잡고 싶은 내부 시스템 간 통신이라면 #[serde(deny_unknown_fields)]를 붙여 “모르는 필드가 오면 에러"로 조일 수도 있습니다. 외부 API에는 비추천입니다. 상대가 필드를 추가할 때마다 깨지기 때문입니다.

구조를 모를 때: Value로 더듬기 #

스키마를 정하기 전 탐색 단계이거나, 구조가 반쯤 자유형인 JSON은 serde_json::Value로 받습니다.

src/main.rs
let v: serde_json::Value = serde_json::from_str(json)?;
let city = v["address"]["city"].as_str().unwrap_or("불명");

Value는 어떤 JSON이든 담는 열거형이고, 인덱스 접근과 as_str·as_u64 계열로 더듬습니다. 편한 대신 타입 보증이 없어져서, 검증이 파싱 시점에서 사용 시점으로 흩어집니다. 탐색은 Value로, 코드가 자리를 잡으면 구조체로 승격이 건강한 순서입니다. 절충으로, 대부분은 구조체로 받되 자유형 부분만 metadata: Value 필드로 남기는 혼합도 자주 씁니다.

형태가 여러 개인 JSON: enum으로 받기 #

이벤트 스트림처럼 “종류에 따라 필드가 다른” JSON은 Rust 열거형과 정확히 대응합니다.

src/main.rs
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Event {
    UserCreated { id: u64, email: String },
    UserDeleted { id: u64 },
}

// {"type": "user_created", "id": 7, "email": "kim@example.com"} → Event::UserCreated

tag = "type"은 “type 필드를 보고 variant를 고르라"는 내부 태그 방식으로, 이벤트·메시지 계열 JSON의 사실상 표준 형태입니다. 태그 필드가 아예 없는 응답(성공이면 데이터, 실패면 에러 객체 같은)은 #[serde(untagged)]로 “모양이 맞는 variant를 순서대로 시도"하게 할 수 있습니다. untagged는 편리하지만 실패 시 에러 메시지가 “어느 variant도 안 맞음"으로 뭉툭해지므로, 태그를 넣을 수 있는 설계라면 태그 방식이 우선입니다. 받은 뒤의 처리는 기초 5편의 match가 모든 variant 처리를 강제해 주는, 그 구조 그대로입니다.

에러 읽는 법 #

src/main.rs
let result: Result<User, _> = serde_json::from_str(broken);
if let Err(e) = result {
    eprintln!("{e}"); // invalid type: string "7", expected u64 at line 1 column 9
}

serde_json의 에러는 무엇이 틀렸는지와 함께 line·column 위치를 담고 있습니다. 대용량 파일 처리에서 이 위치 정보는 디버깅 시간과 직결되므로, 에러를 삼키지 말고 anyhow의 context에 실어 위로 올리는 것이 정석입니다. “몇 번째 줄의 어느 필드가 왜"까지 나오는 에러 보고는 JSON 파이프라인의 품질 그 자체입니다.

정리 #

  • 기본형은 derive(Serialize, Deserialize) + from_str·to_string입니다. 파싱을 통과한 구조체는 이후 코드 전체에서 완전한 값입니다.
  • 없을 수 있는 필드는 의미로 구분합니다. 없음이 정보면 Option, 채우면 그만이면 default.
  • 이름 규칙 차이는 rename_all = "camelCase" 한 줄로 해결합니다. 내부 통신은 deny_unknown_fields로 조일 수 있습니다.
  • 탐색 단계는 Value, 자리 잡으면 구조체로 승격합니다. 자유형 부분만 Value로 남기는 혼합형도 실용적입니다.
  • 종류별로 모양이 다른 JSON은 태그 방식 enum(tag = "type")이 표준이고, untagged는 태그가 정말 없을 때의 차선입니다.
  • 에러에는 line·column이 들어 있습니다. context에 실어 올려서 “어디가 왜"가 나오는 에러 보고를 만드는 것까지가 실전입니다.
X