Rust에서 구조체 없이 JSON 다루기: serde_json

Rust에서 구조체 없이 JSON 다루기: serde_json

Serde로 데이터를 직렬화할 때는 보통 구조체를 먼저 선언하고 #[derive(Serialize, Deserialize)]를 붙이는데요. 데이터의 모양을 미리 알고 있다면 이 방식이 가장 안전하고 깔끔합니다.

그런데 현업에서는 구조체로 미리 못 박기 애매한 경우도 자주 만나게 됩니다. 외부 API가 내려주는 응답 중 일부 필드만 필요하거나, 응답 구조가 자주 바뀌거나, 요청 본문을 그때그때 즉석에서 조립해야 할 때가 그렇죠. 이럴 때 구조체를 일일이 정의하는 건 오히려 번거롭습니다.

그래서 이 글에서는 serde_json이 제공하는 json! 매크로와 Value 타입으로 구조체 없이 JSON을 다루는 방법을 살펴보겠습니다.

설치하기

serde_json만 있으면 json! 매크로와 Value 타입은 바로 쓸 수 있습니다. 다만 뒤에서 다룰 to_value/from_value처럼 구조체와 변환하는 기능까지 쓰려면 serde의 파생 매크로도 함께 필요하니, 둘 다 의존성에 등록해두겠습니다.

Cargo.toml
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

JSON을 표현하는 Value 타입

구조체를 쓰지 않고 JSON을 다루려면, JSON이라는 데이터 자체를 담을 수 있는 타입이 하나 필요한데요. serde_json은 이를 위해 Value라는 열거형(enum)을 제공합니다.

pub enum Value {
    Null,              // null
    Bool(bool),        // true, false
    Number(Number),    // 정수, 실수
    String(String),    // 문자열
    Array(Vec<Value>), // 배열
    Object(Map<String, Value>), // 객체
}

JSON 명세에 등장하는 모든 값의 종류가 열거형의 변형(variant)으로 하나씩 대응됩니다. 배열과 객체의 내부 값도 다시 Value라서, 중첩된 JSON을 재귀적으로 표현할 수 있죠. 즉 어떤 형태의 JSON이든 결국 하나의 Value로 담을 수 있다는 뜻입니다.

Value를 직접 손으로 조립하는 건 번거로우니, 보통은 다음에 살펴볼 json! 매크로로 만듭니다.

json! 매크로로 JSON 만들기

json! 매크로를 쓰면 마치 JSON을 그대로 적듯이 Value를 만들 수 있습니다. 중괄호로 객체를, 대괄호로 배열을 표현하는 것이 실제 JSON 문법과 똑같아서 직관적이죠.

use serde_json::json;

fn main() {
    let fruit = json!({
        "name": "Apple",
        "count": 10,
        "is_fresh": true,
        "tags": ["red", "sweet"]
    });

    println!("{fruit}");
}

ValueDisplay 트레이트(trait)를 구현하고 있어서 {}로 바로 출력하면 JSON 문자열로 찍힙니다.

결과
{"count":10,"is_fresh":true,"name":"Apple","tags":["red","sweet"]}

여기서 한 가지 눈여겨볼 점이 있는데요. 우리가 적은 순서는 name, count, is_fresh, tags였는데 출력은 알파벳순으로 정렬되어 나왔습니다. 이는 Value::Object가 기본적으로 BTreeMap을 사용하기 때문인데, 키 순서를 입력한 그대로 유지하고 싶다면 serde_jsonpreserve_order 기능을 켜주면 됩니다.

사람이 보기 좋게 들여쓰기된 형태로 만들고 싶다면 to_string_pretty 함수를 사용합니다.

let pretty = serde_json::to_string_pretty(&fruit).unwrap();
println!("{pretty}");
결과
{
  "count": 10,
  "is_fresh": true,
  "name": "Apple",
  "tags": [
    "red",
    "sweet"
  ]
}

json! 매크로의 진가는 값 자리에 변수나 표현식을 그대로 끼워 넣을 수 있다는 데 있습니다. 리터럴뿐만 아니라 런타임에 계산된 값으로도 JSON을 조립할 수 있는 것이죠.

use serde_json::json;

fn main() {
    let name = "Banana";
    let count = 5;

    let fruit = json!({
        "name": name,
        "count": count,
        "is_fresh": count > 0, // 표현식도 그대로 사용 가능
    });

    println!("{fruit}");
}
결과
{"count":5,"is_fresh":true,"name":"Banana"}

Value에서 값 꺼내기

JSON을 만들기만 하는 게 아니라, 받아온 Value에서 원하는 값을 꺼내 읽어야 할 때도 많은데요. 가장 간단한 방법은 대괄호 인덱싱입니다. 객체는 키 문자열로, 배열은 정수 인덱스로 접근할 수 있고, 중첩된 값도 연달아 이어서 파고들 수 있습니다.

use serde_json::json;

fn main() {
    let data = json!({
        "user": {
            "name": "Alice",
            "age": 30
        },
        "scores": [90, 85, 99]
    });

    println!("name = {}", data["user"]["name"]);
    println!("first score = {}", data["scores"][0]);
    println!("missing = {}", data["nope"]); // 없는 키
}
결과
name = "Alice"
first score = 90
missing = null

인덱싱은 편하지만 주의할 점이 있습니다. 존재하지 않는 키로 접근해도 패닉이 나지 않고 Value::Null을 돌려준다는 것이죠. 위에서 data["nope"]null로 찍힌 것이 바로 이 동작입니다. 오타가 있어도 조용히 넘어가버리니, 키의 존재 여부를 명확히 확인하고 싶다면 Option을 반환하는 get 메서드를 쓰는 편이 안전합니다.

또 하나, 인덱싱으로 얻은 결과는 여전히 Value 타입입니다. 그래서 "Alice"처럼 큰따옴표가 붙어 출력되었죠. 이 값을 Rust의 실제 자료형으로 쓰려면 as_str, as_u64, as_bool 같은 변환 메서드로 꺼내야 합니다.

use serde_json::json;

fn main() {
    let data = json!({
        "user": { "name": "Alice", "age": 30 }
    });

    // as_xxx는 Option을 반환하므로 unwrap 또는 패턴 매칭으로 꺼낸다
    let name: &str = data["user"]["name"].as_str().unwrap();
    let age: u64 = data["user"]["age"].as_u64().unwrap();
    println!("name = {name}, age = {age}");

    // get()으로 안전하게 접근하기
    match data.get("user").and_then(|u| u.get("name")) {
        Some(value) => println!("get(): {value}"),
        None => println!("get(): 없음"),
    }
}
결과
name = Alice, age = 30
get(): "Alice"

이제 as_str로 꺼낸 name은 큰따옴표 없는 진짜 &str이 되었습니다. 타입이 맞지 않으면(예를 들어 숫자를 as_str로 꺼내려 하면) None이 반환되니, 이 점도 함께 기억해두면 좋습니다.

Value를 수정하고 동적으로 쌓기

Value는 읽기만 가능한 게 아니라 자유롭게 고칠 수도 있습니다. 인덱싱한 자리에 값을 대입하면 기존 값을 바꾸거나 없던 키를 새로 추가할 수 있죠. 이때 Value 변수를 mut로 선언해야 한다는 점만 주의하면 됩니다.

use serde_json::json;

fn main() {
    let mut data = json!({
        "name": "Alice",
        "age": 30
    });

    data["age"] = json!(31);       // 기존 값 변경
    data["city"] = json!("Seoul"); // 새 키 추가

    println!("{data}");
}
결과
{"age":31,"city":"Seoul","name":"Alice"}

배열에 요소를 하나씩 추가하고 싶다면 as_array_mut으로 내부 벡터의 가변 참조를 얻어 push를 호출합니다. 반복문을 돌면서 JSON 배열을 점진적으로 만들어가는 패턴에 유용합니다.

use serde_json::json;

fn main() {
    let mut list = json!([]);

    if let Some(array) = list.as_array_mut() {
        for id in 1..=3 {
            array.push(json!({ "id": id }));
        }
    }

    println!("{list}");
}
결과
[{"id":1},{"id":2},{"id":3}]

반대로 객체의 키와 값을 하나씩 훑어야 할 때는 as_object로 내부 맵을 빌려와 순회하면 됩니다.

use serde_json::json;

fn main() {
    let user = json!({ "name": "Bob", "age": 25, "admin": true });

    if let Some(map) = user.as_object() {
        for (key, value) in map {
            println!("{key} = {value}");
        }
    }
}
결과
admin = true
age = 25
name = "Bob"

구조체와 Value 사이 변환: to_value와 from_value

구조체 기반 방식과 동적 방식은 양자택일이 아니라 섞어 쓸 수 있습니다. 이미 정의해둔 구조체가 있는데 잠깐 Value로 다뤄야 하거나, 반대로 Value로 받아온 데이터를 구조체로 깔끔하게 정리하고 싶을 때가 있죠. 이럴 때 to_valuefrom_value가 다리 역할을 해줍니다.

먼저 to_valueSerialize를 구현한 구조체를 Value로 변환합니다.

use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Serialize, Deserialize, Debug)]
struct Fruit {
    name: String,
    count: u8,
    is_fresh: bool,
}

fn main() {
    let apple = Fruit {
        name: String::from("Apple"),
        count: 10,
        is_fresh: true,
    };

    let value: Value = serde_json::to_value(&apple).unwrap();
    println!("{value}");
    println!("is_object = {}", value.is_object());
}
결과
{"count":10,"is_fresh":true,"name":"Apple"}
is_object = true

반대로 from_valueValueDeserialize를 구현한 구조체로 되돌립니다. json!으로 만든 데이터를 타입 안전한 구조체로 옮겨 담을 때 유용하죠.

use serde_json::json;

fn main() {
    let value = json!({
        "name": "Cherry",
        "count": 50,
        "is_fresh": false
    });

    let cherry: Fruit = serde_json::from_value(value).unwrap();
    println!("{cherry:?}");
}
결과
Fruit { name: "Cherry", count: 50, is_fresh: false }

이렇게 두 함수가 다리를 놓아주는 덕분에, 구조체 기반 방식과 동적 방식을 한 프로그램 안에서 필요에 따라 오갈 수 있습니다.

문자열과 변환: to_string과 from_str

지금까지는 메모리 안의 Value와 구조체를 다뤘지만, 결국 네트워크로 보내거나 파일로 저장하려면 JSON 문자열이 필요한데요. reqwest로 HTTP 통신을 하거나 파일을 읽고 쓸 때 바로 이 변환이 쓰입니다.

문자열로 직렬화할 때는 to_string, 문자열을 파싱할 때는 from_str을 사용합니다. 이 두 함수는 구조체와 Value 양쪽 모두에 똑같이 쓸 수 있습니다.

use serde_json::Value;

fn main() {
    let apple = Fruit {
        name: String::from("Apple"),
        count: 10,
        is_fresh: true,
    };

    // 구조체 → JSON 문자열
    let text: String = serde_json::to_string(&apple).unwrap();
    println!("to_string: {text}");

    // JSON 문자열 → Value (구조체로 받으려면 타입만 바꾸면 된다)
    let parsed: Value = serde_json::from_str(&text).unwrap();
    println!("from_str: {}", parsed["name"]);
}
결과
to_string: {"name":"Apple","count":10,"is_fresh":true}
from_str: "Apple"

여기서 앞서 본 출력과 비교해볼 만한 부분이 있습니다. to_valueValue를 거쳤을 때는 키가 알파벳순(count, is_fresh, name)으로 정렬됐지만, 구조체를 to_string으로 곧장 직렬화하면 필드를 선언한 순서(name, count, is_fresh)가 그대로 유지됩니다. Value를 거치면 BTreeMap을 통과하면서 키가 정렬되기 때문인데, 출력 순서가 중요하다면 이 차이를 알아두면 좋습니다.

JSON 문법이 깨진 문자열을 파싱하면 패닉 대신 Err가 반환됩니다. 그래서 외부에서 들어온 데이터는 unwrap 대신 Result로 받아 에러를 처리하는 것이 안전합니다.

use serde_json::Value;

fn main() {
    let bad = "{ invalid json }";

    match serde_json::from_str::<Value>(bad) {
        Ok(value) => println!("성공: {value}"),
        Err(error) => println!("실패: {error}"),
    }
}
결과
실패: key must be a string at line 1 column 3

에러 메시지에 문제가 발생한 줄과 칸 위치까지 담겨 있어서, 어디서 파싱이 어긋났는지 파악하기 쉽습니다.

언제 Value를 쓰고 언제 구조체를 쓸까

마지막으로 두 방식을 어떻게 선택하면 좋을지 정리해보겠습니다.

데이터의 구조가 명확하고 안정적이라면 구조체로 직렬화하는 방식이 더 낫습니다. 필드 이름이나 타입이 틀리면 컴파일 시점에 바로 잡아주고, 코드만 봐도 데이터의 모양이 한눈에 드러나기 때문이죠.

반면 응답 구조를 미리 알 수 없거나, 일부 필드만 필요하거나, 요청 본문을 그때그때 조립해야 한다면 Valuejson!이 훨씬 편합니다. 구조체를 정의하는 수고 없이 유연하게 JSON을 다룰 수 있으니까요.

물론 둘 중 하나만 고를 필요는 없습니다. 앞서 본 to_valuefrom_value 덕분에, 유연함이 필요한 곳은 Value로 다루다가 안정성이 중요한 핵심 데이터만 구조체로 변환하는 절충안이 가장 현실적인 선택일 때가 많습니다.

마무리

지금까지 serde_json으로 구조체 없이 JSON을 다루는 방법을 살펴봤습니다. JSON을 담는 Value 타입, 이를 직관적으로 만들어주는 json! 매크로, 값을 꺼내는 인덱싱과 as_xxx 메서드, 그리고 구조체와 오가는 to_value/from_value, 문자열과 오가는 to_string/from_str까지 한 번에 정리했네요.

구조체 기반 직렬화의 기초가 더 궁금하다면 Serde 라이브러리 사용법을 먼저 읽어보시길 권합니다. 또한 Serde 애트리뷰트를 그대로 활용해 schemars로 JSON Schema를 자동 생성하면, 동적으로 다루던 JSON에 검증 스키마까지 더할 수 있습니다.

더 자세한 내용은 serde_json 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord