Rust에서 구조체 없이 JSON 다루기: serde_json
Serde로 데이터를 직렬화할 때는 보통 구조체를 먼저 선언하고 #[derive(Serialize, Deserialize)]를 붙이는데요.
데이터의 모양을 미리 알고 있다면 이 방식이 가장 안전하고 깔끔합니다.
그런데 현업에서는 구조체로 미리 못 박기 애매한 경우도 자주 만나게 됩니다. 외부 API가 내려주는 응답 중 일부 필드만 필요하거나, 응답 구조가 자주 바뀌거나, 요청 본문을 그때그때 즉석에서 조립해야 할 때가 그렇죠. 이럴 때 구조체를 일일이 정의하는 건 오히려 번거롭습니다.
그래서 이 글에서는 serde_json이 제공하는 json! 매크로와 Value 타입으로 구조체 없이 JSON을 다루는 방법을 살펴보겠습니다.
설치하기
serde_json만 있으면 json! 매크로와 Value 타입은 바로 쓸 수 있습니다.
다만 뒤에서 다룰 to_value/from_value처럼 구조체와 변환하는 기능까지 쓰려면 serde의 파생 매크로도 함께 필요하니, 둘 다 의존성에 등록해두겠습니다.
[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}");
}
Value는 Display 트레이트(trait)를 구현하고 있어서 {}로 바로 출력하면 JSON 문자열로 찍힙니다.
{"count":10,"is_fresh":true,"name":"Apple","tags":["red","sweet"]}
여기서 한 가지 눈여겨볼 점이 있는데요.
우리가 적은 순서는 name, count, is_fresh, tags였는데 출력은 알파벳순으로 정렬되어 나왔습니다.
이는 Value::Object가 기본적으로 BTreeMap을 사용하기 때문인데, 키 순서를 입력한 그대로 유지하고 싶다면 serde_json의 preserve_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_value와 from_value가 다리 역할을 해줍니다.
먼저 to_value는 Serialize를 구현한 구조체를 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_value는 Value를 Deserialize를 구현한 구조체로 되돌립니다.
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_value로 Value를 거쳤을 때는 키가 알파벳순(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를 쓰고 언제 구조체를 쓸까
마지막으로 두 방식을 어떻게 선택하면 좋을지 정리해보겠습니다.
데이터의 구조가 명확하고 안정적이라면 구조체로 직렬화하는 방식이 더 낫습니다. 필드 이름이나 타입이 틀리면 컴파일 시점에 바로 잡아주고, 코드만 봐도 데이터의 모양이 한눈에 드러나기 때문이죠.
반면 응답 구조를 미리 알 수 없거나, 일부 필드만 필요하거나, 요청 본문을 그때그때 조립해야 한다면 Value와 json!이 훨씬 편합니다.
구조체를 정의하는 수고 없이 유연하게 JSON을 다룰 수 있으니까요.
물론 둘 중 하나만 고를 필요는 없습니다.
앞서 본 to_value와 from_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