Serde는 Rust의 훌륭한 직렬화 라이브러리이며, 오랫동안 Rust로 생산성 있게 개발할 수 있었던 큰 이유이기도 합니다. 하지만 Sentry에서 일할 때부터 Serde의 몇 가지 한계가 꽤 답답하게 느껴졌습니다. 다만 Serde가 생태계에서 차지하는 영향력이 워낙 커서 이를 대체하기는 쉽지 않습니다. 더 나은 대안을 만들려면 아픈 타협을 감수해야 할 가능성이 높다는 점도 어려움을 더합니다.
다음은 Serde 기능끼리 서로 잘 맞물리지 않거나 예상치 못한 한계가 드러나는 코너 케이스 세 가지입니다.
serde_json의 arbitrary_precision 기능을 켠 상태에서 internally tagged enum을 사용하는 경우입니다.
#[derive(Deserialize)]
#[serde(tag = "type")]
enum Shape {
Circle { radius: f64 },
}
serde_json::from_str::<Shape>(r#"{"type": "Circle", "radius": 1.5}"#)
// error: invalid type: map, expected f64
Serde의 데이터 모델에는 임의 정밀도 숫자를 위한 자리가 없어서, serde_json는 매직 키를 가진 맵으로 값을 전달하는 in-band signalling 방식을 씁니다. 그런데 enum은 태그를 만나기 전까지 필드를 버퍼에 담아 두어야 하고, 이 버퍼는 매직 키의 존재를 알지 못합니다. Cargo feature는 통합되어 적용되므로, 의존성 그래프에 있는 크레이트 중 하나만 이 feature를 켜도 문제가 발생합니다.
#[derive(Deserialize)]
struct Stats {
scores: HashMap<u32, u32>,
}
#[derive(Deserialize)]
struct Report {
name: String,
#[serde(flatten)]
stats: Stats,
}
serde_json::from_str::<Report>(r#"{"name": "x", "scores": {"42": 23}}"#)
// error: invalid type: string "42", expected u32 at line 1 column 35
Stats 단독으로는 {"scores": {"42": 23}}를 문제없이 파싱합니다. JSON 키는 항상 문자열이고, serde_json는 타입이 정수를 요구할 때만 이를 정수로 변환하기 때문입니다. 하지만 flatten가 값을 버퍼에 담고 나면 "42"는 그저 문자열이 됩니다. 게다가 에러는 문제의 키가 아니라 문서 끝을 가리킵니다.
fn from_hex<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> { ... }
#[derive(Deserialize)]
struct Theme {
#[serde(deserialize_with = "from_hex")]
primary: u32,
#[serde(deserialize_with = "from_hex")]
accent: Option<u32>,
}
//error[E0308]: `?` operator has incompatible types
// |
// | #[serde(deserialize_with = "from_hex")]
// | ^^^^^^^^^^ expected `Option<u32>`, found `u32`
// |
//help: try wrapping the expression in `Some`
// |
// | #[serde(deserialize_with = Some("from_hex"))]
// | +++++ +
함수는 타입 파라미터로 넘길 수 없으므로, from_hex를 Option, Vec, 맵의 내부 값에 적용할 방법이 없습니다. 래퍼마다 함수를 따로 작성해야 하고, from_opt_hex를 쓰는 순간 #[serde(default)]를 함께 추가하는 것을 잊지 않는 한 그 필드는 더 이상 선택 사항이 아니게 됩니다.
이 중 Serde에서 쉽게 고칠 수 있는 버그는 없습니다. 모두 Serde의 설계에서 비롯된 문제이고, 그 설계는 Serde의 안정성 보장으로 보호받고 있기 때문입니다.
저는 2022년에 Deser라는 실험을 시작했습니다. Serde의 사용자 경험은 그대로 가져오되, miniserde에서 영감을 받은 완전히 다른 아키텍처 위에 올린 Rust 직렬화 라이브러리입니다. 끝까지 마무리하지 못한 채 몇 년간 묵혀 두었다가 다시 꺼내 들었고, 이제는 한번 살펴볼 만한 수준에 이르렀다고 생각합니다. 이 분야를 탐구해 보고 싶은 분들께 영감을 주는 것만으로도 의미가 있을 것입니다.
이름은 Serde의 앞뒤를 뒤집은 것입니다. Deser는 Serde를 거꾸로 읽은 셈입니다. Serde에서는 타입이 역직렬화 과정을 주도합니다. Deserialize 구현체가 deserializer에 기대하는 값의 종류를 요청하면, 포맷이 visitor를 다시 호출하는 식입니다. 중첩된 값은 모두 재귀로 처리하므로, Serde의 역직렬화는 중첩 단계마다 스택이 늘어날 수밖에 없습니다.
반면 Deser는 이 흐름을 뒤집습니다. 포맷이 다음 값의 타입을 타입에 알려 주고, 이벤트를 sink로 밀어 넣습니다. sink가 중첩된 값의 시작을 만나면 직접 호출하지 않고 새 sink를 driver에 넘깁니다. driver는 모든 상태를 힙(정확히는 arena)에 보관합니다. 반대로 값을 내보낼 때는 emitter가 중첩된 값을 재귀 호출하지 않고 반환합니다.
이는 Deser가 protobuf처럼 self describing이 아닌 포맷을 지원할 수 없다는 뜻이기도 합니다. 이런 포맷은 설계 단계에서 아예 의도적으로 제외했습니다. Serde를 "고치려면" 다른 무언가를 포기해야 한다는 말입니다.
Deser의 아이디어 대부분은 막대한 양의 신뢰할 수 없는 JSON을 처리하는 Sentry Relay에서 출발했습니다. Sentry에서 일하는 동안 같은 문제를 계속 마주쳤는데, 상당수는 Serde의 버그라기보다 설계에서 비롯된 결과였습니다. Serde의 안정성 보장 때문에 이 문제 중 다수는 모든 포맷과 직접 작성한 모든 구현을 깨뜨리지 않고서는 고칠 수 없습니다. 이런 문제는 대부분 세 가지 결정에서 나옵니다.
모든 포맷이 하나의 trait 세트를 공유한다. Serde는 self describing 포맷(JSON, YAML, TOML 등)과 읽는 쪽이 타입을 미리 알아야 하는 포맷(postcard, bincode, protobuf 등)을 모두 지원합니다. 매우 유용하지만, 일부 기능은 특정 포맷에서만 동작하고 그 사실을 런타임에야 알게 됩니다. 또 derive한 struct가 JSON에서 객체 대신 배열도 조용히 받아들이는 것처럼 이상한 부분도 있습니다.
고정된 데이터 모델은 버퍼링할 때 정보가 유실된다. internally tagged enum, untagged enum, flatten는 값을 어떻게 처리할지 결정하기 전에 버퍼에 담아 두어야 합니다. 그런데 이 버퍼는 포맷이 알고 있던 정보를 전부 담지 못하고, 에러는 위치 정보를 잃습니다. 생태계의 확장 기능들도 임의 정밀도 숫자 같은 것을 표현하려고 in-band signalling에 의존합니다.
호출 스택을 이용한 재귀. 중첩 단계마다 스택 공간을 씁니다. 포맷은 재귀 제한으로 이를 막지만, 제한이 없는 코드 경로(쓰기, 동적 값)를 거치는 순간 깊게 중첩된 데이터가 프로세스를 죽일 수 있습니다. 또한 추가 입력을 기다리는 동안 역직렬화를 일시 중지할 수도 없습니다.
이와 관련된 Serde 이슈 중 상당수는 몇 년째 열려 있고, 저도 예전에 Serde를 남용하는 이야기를 쓴 적이 있습니다. 그동안 여러 사람이 다양한 방향으로 접근했습니다. 기능 대부분을 덜어내고 빠른 컴파일과 재귀 없는 구조를 택한 시도도 있었습니다. dtolnay가 직접 만든 miniserde가 그 대표적인 예이고, Deser의 trait 설계도 원래 이를 본떴습니다. 최근에는 런타임 리플렉션을 택하거나, 바이너리 포맷에 초점을 맞춘 새 데이터 모델을 내놓은 시도도 있었습니다.
Serde 설계의 문제점을 모두 정리한 내용이 궁금하시다면, 제가 관리하는 긴 목록을 참고하세요.
우선 Serde를 대체할 수 있으리라고는 생각하지 않습니다. orphan rule 때문에 Serde는 생태계에 매우 단단히 뿌리내리고 있습니다. 하지만 크레이트 작성자가 통제할 수 있는 부분도 있습니다. Deser의 경우 그것은 완성도입니다.
현재 Deser는 YAML, JSON, TOML, CBOR, JSON5 등 주요 self describing 포맷을 모두 구현했고, 격차를 확실히 좁히기 위해 XML과 plist까지 지원합니다. 특히 XML은 Serde가 지원을 거부해 온 영역이라 그 차이가 뚜렷합니다(자세한 내용은 아래에서 다룹니다). 적어도 포맷 지원 때문에 Deser를 쓰지 못하는 일은 없어야 합니다.
두 번째 문제는 Serde의 문제를 실제로 해결하려면 보통 컴파일 시간이나 런타임 성능에서 상당한 비용을 치러야 한다는 점입니다. Deser도 예외가 아닙니다. 컴파일 시간은 Serde보다 조금 낫지만 바이너리 크기는 꽤 더 커지고, 런타임 성능은 엇갈립니다. 수치로 보면 대체로 비슷하지만, 포맷 구조에 따라서는 일부 트레이드오프 때문에 상당히 손해를 보기도 합니다.
그래도 지금은 최소한 원칙적으로는 drop-in replacement로 쓸 수 있는 상태이며, 사용자에 따라서는 이 트레이드오프가 충분히 괜찮을 수 있습니다.
Deser는 겉모습에서 Serde와 크게 달라지려 하지 않습니다. 대부분의 경우 Serialize와 Deserialize를 derive한 뒤, 원하는 포맷 구현 크레이트와 함께 쓰면 됩니다. 대부분의 attribute도 매우 비슷하지만, 문자열 대신 Rust 표현식을 받는다는 점이 다릅니다.
use deser::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
#[deser(rename_all = "camelCase")]
pub struct Account {
id: u64,
account_holder: String,
#[deser(default)]
is_deactivated: bool,
}
let account: Account = deser_json::from_str(json)?;
설계 차이는 serializer나 deserializer를 직접 구현할 때 더 분명해집니다. 서로를 재귀적으로 호출하는 visitor 대신, 타입을 역직렬화하면 파서가 직접 내보내는 이벤트를 받는 sink가 만들어지고, 직렬화하면 값을 내주는 emitter가 만들어집니다. 중첩된 sink와 emitter는 driver에 넘겨져 힙에 보관됩니다. miniserde에서 통째로 가져온 이 설계에는 몇 가지 흥미로운 결과가 따라옵니다.
Send이기도 해서 IO를 기다리는 동안 스레드 사이를 옮겨 다닐 수 있으므로 tokio와 함께 쓰기에도 훨씬 편합니다. JSON, CBOR, MessagePack 같은 포맷은 원한다면 스트림으로 파싱할 수도 있습니다.DateTime, Uuid 등)으로 표현하며, 이를 이해하지 못하는 포맷을 위한 fallback도 함께 들어 있습니다. Serde처럼 매직 키를 가진 객체로 값을 몰래 실어 나르는 in-band signalling에 의존하지 않습니다.여기에 제가 그냥 갖고 싶었던 기능도 많이 더했습니다.
as = Option<Vec<DisplayFromStr>>)다음은 이 기능 몇 가지를 한데 보여 주는 작은 설정 타입입니다.
use deser::adapters::DisplayFromStr;
use deser::de::Recording;
use deser::{Deserialize, Serialize};
use deser_encoding::Hex;
use deser_validate::{Check, NonEmpty, Range};
use ipnet::IpNet;
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
// at least one 256-bit key, each written as hex
#[deser(as = Check<NonEmpty, Vec<Hex>>)]
secret_keys: Vec<[u8; 32]>,
// `IpNet` knows nothing about deser, but has `FromStr` and `Display`
#[deser(as = Option<Vec<DisplayFromStr>>)]
allowed_networks: Option<Vec<IpNet>>,
listeners: Vec<Listener>,
}
#[derive(Debug, Serialize, Deserialize)]
#[deser(tag = "type", rename_all = "snake_case")]
pub enum Listener {
Unix { path: PathBuf },
Tcp {
host: IpAddr,
#[deser(as = Check<Range<1, 65535>>)]
port: u16,
},
// types this version does not know are kept and written back
#[deser(other)]
Other(#[deser(tag)] String, Recording),
}
어댑터는 타입이므로 Hex를 Vec 안에, DisplayFromStr를 Vec 안의 Option 안에 넣을 수 있습니다. validator도 어댑터여서, Check<NonEmpty, Vec<Hex>>는 키를 디코딩한 뒤 최소 하나 이상 있는지 검사합니다. catch-all variant는 태그와 나머지 전체를 기록한 내용을 보관하므로, 나중에 처리하고 싶을 때 사용할 수 있습니다.
저는 에러에 관심이 많아서, 값이 잘못됐을 때 어떻게 되는지 보여 드리겠습니다.
secret_keys = ["9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"]
allowed_networks = ["10.0.0.0/8", "fd00::/8"]
[[listeners]]
type = "unix"
path = "/run/app.sock"
[[listeners]]
host = "127.0.0.1"
port = 0
type = "tcp"
[[listeners]]
type = "quic"
host = "::1"
alpn = ["h3"]
let config: Config = deser_toml::Deserializer::from_str(input)
.deserialize_with(|driver| driver.push_layer(PathLayer::new()))?;
이 예제에서는 internally tagged enum의 태그가 맨 뒤에 오므로, 태그를 알 때까지 값을 버퍼에 담아야 합니다. Serde에서는 이것이 까다롭고, 위치 정보를 붙이려고 편법을 쓰면 오히려 위치를 잃게 됩니다. 반면 Deser는 path layer를 켜면 구조의 어느 지점에 문제가 있는지 알려 줍니다.
Unexpected: invalid value: must be between 1 and 65535 at line 10 column 8 (path: listeners[1].port)
Deser는 확장성을 정말 중요하게 생각하는데, XML은 Deser와 Serde의 차이가 가장 극명하게 드러나는 예입니다. 다음은 저자 정보에 Dublin Core를 섞어 쓴 Atom entry입니다.
use chrono::{DateTime, Utc};
use deser::Deserialize;
use deser_value::Value;
use deser_xml::DeserializerConfig;
deser_xml::namespace!(
atom = "http://www.w3.org/2005/Atom",
dc = "http://purl.org/dc/elements/1.1/",
);
#[derive(Debug, Deserialize)]
struct Entry {
#[deser(rename = atom!("title"))]
title: String,
#[deser(rename = dc!("creator"))]
creators: Vec<String>,
#[deser(rename = atom!("updated"))]
updated: DateTime<Utc>,
}
// entries we understand, and everything else is kept as it is
#[derive(Debug, Deserialize)]
#[deser(untagged)]
enum Item {
Entry(Entry),
Other(Value),
}
let item: Item = DeserializerConfig::new()
.resolve_namespaces(true)
.from_str(r#"
<entry xmlns="http://www.w3.org/2005/Atom"
xmlns:d="http://purl.org/dc/elements/1.1/">
<title>Deser</title>
<d:creator>John</d:creator>
<updated>2026-09-29T21:00:00Z</updated>
<d:creator>Jane</d:creator>
</entry>
"#)?;
XML은 네임스페이스를 사용하므로, 이름은 문서가 우연히 쓴 접두사가 아니라 네임스페이스로 매칭해야 합니다. 여기서 문서는 d:라고 쓰고 타입은 dc!라고 선언했습니다. atom!("title")는 그냥 문자열 {http://www.w3.org/2005/Atom}title인데, attribute가 표현식이라서 가능합니다. 두 creator 사이에 다른 요소가 끼어 있어도 하나의 Vec로 모이고, updated의 텍스트는 바로 chrono datetime으로 들어갑니다. enum이 untagged라서 variant를 고르기 전에 entry 전체를 버퍼에 담아야 하는데, Deser의 버퍼는 두 creator를 모두 보존합니다. 그 결과 John과 Jane이 담긴 Entry가 나옵니다.
Serde용 XML 크레이트 중 가장 인기 있는 quick-xml은 접두사를 버리고 네임스페이스를 완전히 무시하므로, 다른 네임스페이스의 <x:title>도 entry의 title로 아무렇지 않게 받아들입니다. 하지만 분리된 리스트 부분은 훨씬 더 심각합니다. 평범한 Entry는 creator에 대해 중복 필드 에러로 실패하며, overlapped-lists feature를 켜야만 넘어갑니다(앞서 말했듯 이는 어떤 크레이트든 켤 수 있는 전역 가산(additive) 플래그입니다). 이 feature를 켜면 quick-xml이 요소 끝까지 미리 읽으면서 그 사이의 모든 내용을 버퍼에 담는데, 제한을 직접 설정하지 않으면 상한이 없습니다.
그런데 이 feature는 quick-xml을 struct에 직접 연결해 버퍼링이 일어나지 않을 때만 도움이 됩니다. struct를 untagged enum으로 감싸면 Serde가 entry 자체를 버퍼에 담습니다. 그 버퍼에서 읽으면 Entry가 creator를 두 번 보게 되어 다시 실패합니다. 그 대안은 맵인데, 맵은 마지막 creator만 남기고 에러도 내지 않습니다. feature를 켜든 켜지 않든 결과는 다음과 같습니다.
Other({"creator": {"$text": "Jane"}, "title": {"$text": "Deser"}, ...})
John이 사라진 것을 확인할 수 있습니다.
TOML datetime 같은 포맷 고유의 확장 타입도 비슷한 사례입니다. TOML은 이를 네이티브로 지원하지만 Serde의 데이터 모델에는 없어서, toml 크레이트는 매직 키를 가진 맵으로 전달합니다. Deser에서 datetime은 확장 값이며, 이를 아는 포맷은 그대로 유지하고 그 외 포맷은 문자열로 씁니다.
let value: Value = deser_toml::from_str("released = 2026-09-29T21:00:00+02:00")?;
deser_json::to_string(&value)?;
// {"released":"2026-09-29T21:00:00+02:00"}
deser_toml::to_string(&value)?;
// released = 2026-09-29T21:00:00+02:00
같은 코드를 serde_json::Value로 실행하면 {"released":{"$__toml_private_datetime":"2026-09-29T21:00:00+02:00"}}가 나오고, 값을 chrono::DateTime로 읽으려 하면 invalid type: map, expected an RFC 3339 formatted date and time string 에러와 함께 아예 실패합니다.
Deser가 적어도 이론상 멋지다는 것은 알겠는데, 그 대가는 무엇일까요?
공짜는 아닙니다. 이 설계는 동적 디스패치와 힙에 있는 sink 및 emitter에 의존하기 때문에 런타임 오버헤드가 상당합니다. 제가 JSON으로 직접 측정해 보니, 데이터에 따라 serde_json depending보다 읽기가 33% 빠른 경우부터 60% 느린 경우까지 다양했고, 평균적으로는 약 10% 느렸습니다. 쓰기는 3배 빠른 경우부터 70% 느린 경우까지 있었고, 평균적으로는 비슷합니다. YAML과 TOML은 Serde 기반 크레이트보다 눈에 띄게 빠르지만, 이는 아키텍처보다 포맷 구현 덕분입니다.
컴파일 시간은 조금 나아지지만 극적인 수준은 아닙니다. 모든 것을 monomorphize하지 않으므로 derive 코드의 릴리스 빌드는 Serde보다 약 2.3배 빠르고, 실제로는 재컴파일이 덜 필요해서 사용자 코드에서도 조금 더 이득을 봅니다.
Deser의 설계가 제대로 동작하려면 내부적으로 unsafe도 써야 합니다. 대부분은 빌려온 sink의 체인을 힙에 유지하기 위한 것입니다. Miri와 에이전트가 있는 요즘에는 괜찮다고 생각하지만, 불안해하는 분들이 있다는 것도 압니다.
그리고 가장 큰 비용은 결국 이것이 Serde가 아니라는 점입니다.
생각보다 꽤 많습니다. 코어 외에 derive도 지원합니다.
떠올릴 수 있는 JSON 계열은 모두 지원합니다: JSON, JSONC, JSON5, HJSON. (재미있는 사실은 이들이 모두 하나의 공유 파서 템플릿에서 생성된다는 점입니다.) 바이너리 포맷으로는 CBOR와 MessagePack을 지원합니다. 그 밖에 YAML 1.1과 1.2, TOML, XML, Apple plist의 세 가지 형식 전부, 그리고 CSV/TSV, urlencoded 데이터, 환경 변수도 다룹니다. 좀 더 별난 기능으로는 경로 정보 첨부, 위치 데이터 캡처, 디버그 출력도 있습니다. 파싱하면서 검증을 수행할 수 있고, base64 외에 다른 바이너리 인코딩을 선택할 수도 있습니다. 또한 serde와 연동하거나 동적 값을 캡처하고, 포맷 간 트랜스코딩을 하거나 tokio와 연결할 수 있습니다.
문서는 docs.rs/deser에서 볼 수 있고, 코드는 GitHub에 다양한 예제와 함께 공개되어 있습니다.