Lobsters

Deser: Rethinking Rust Serialization

Deser: Rust 직렬화를 다시 생각하다

Rust 직렬화 라이브러리 Serde의 데이터 모델과 재귀 설계가 만드는 제약을 짚고, 이벤트를 힙에 쌓는 새 라이브러리 Deser를 소개합니다. Deser는 오류 위치와 확장 값 처리, 중첩 데이터의 스트리밍을 개선하지만 protobuf를 지원하지 않으며 성능과 바이너리 크기에서 비용을 치릅니다.

AI 요약

Serde는 Rust 생태계의 사실상 표준 직렬화 라이브러리지만, 널리 쓰이는 만큼 설계에서 비롯한 제약을 고치기 어렵습니다. 글쓴이는 Sentry에서 마주친 사례를 바탕으로 새 라이브러리 Deser를 소개합니다. Deser는 Serde와 비슷한 derive 사용성을 유지하면서 내부 구조를 바꿨습니다.

Serde 설계에서 드러나는 제약

Serde는 JSON처럼 데이터 자체가 타입을 알려주는 형식과, 읽기 전에 타입을 알아야 하는 protobuf·bincode 같은 형식을 하나의 trait 체계로 다룹니다. 편리한 통합이지만 형식마다 지원 가능한 기능이 달라지고, 일부 제약은 실행 중에야 드러납니다.

또한 Serde의 고정된 데이터 모델은 값을 버퍼링할 때 형식이 알고 있던 정보를 잃을 수 있습니다. 내부 태그 enum은 태그를 읽기 전까지 필드를 저장해야 합니다. 이 과정에서 serde_json의 임의 정밀도 숫자 기능이 사용하는 특수한 map 표현을 알아보지 못해 역직렬화가 실패할 수 있습니다. flatten으로 버퍼링한 JSON 객체에서는 숫자형 키가 문자열로 남아 u32로 읽히지 않는 사례도 제시합니다. 버퍼가 원래 위치 정보를 보존하지 못해 오류 위치가 문서 끝을 가리키기도 합니다.

필드별 변환 함수에도 제약이 있습니다. Serde의 deserialize_with는 함수 하나를 Option이나 Vec 안쪽 값에 적용하지 못합니다. 래퍼마다 별도 함수를 만들고 기본값 처리도 챙겨야 합니다. 이런 문제는 개별 버그라기보다 설계에서 나오며, 안정성 보장 때문에 기존 구현을 깨지 않고 해결하기 어렵다고 설명합니다.

Deser가 바꾼 역직렬화 구조

Serde에서는 타입이 역직렬화 과정을 이끕니다. 타입이 원하는 값의 종류를 요청하고, 포맷 구현이 visitor를 호출합니다. 중첩 값마다 재귀 호출이 이어져 깊은 입력은 호출 스택을 씁니다. Deser에서는 포맷이 다음 값의 종류를 알리고 sink에 이벤트를 보냅니다. 중첩 값이 시작되면 sink가 직접 재귀하지 않고 새 sink를 driver에 돌려줍니다. driver는 상태를 arena 같은 힙 메모리에 보관합니다. 직렬화 쪽도 중첩 emitter가 값을 반환하는 방식으로 재귀를 피합니다.

이 구조는 입력 처리를 멈췄다가 재개하거나, JSON·CBOR·MessagePack을 스트림으로 읽는 데 유리합니다. sink와 emitter가 Send를 지원해 I/O를 기다리는 동안 작업을 다른 스레드로 옮기는 용도에도 맞습니다. 반면 타입 정보가 없는 자기 기술 형식에 초점을 맞추기 때문에 protobuf처럼 타입을 미리 알아야 하는 형식은 의도적으로 제외합니다. Serde의 문제를 해결하려면 지원 형식이나 다른 설계에서 타협해야 한다는 설명입니다.

확장 값과 형식 지원

Deser의 어댑터는 타입으로 표현합니다. Hex를 Vec 안에 넣거나 DisplayFromStr을 Option<Vec<_>> 안에서 쓰고, 검증 어댑터로 값의 범위나 비어 있지 않은 조건을 검사할 수 있습니다. 알 수 없는 enum 태그와 값을 기록해 두었다가 나중에 다시 처리하는 기능도 예시로 듭니다.

XML 예제에서는 namespace를 접두사가 아니라 URI로 비교하고, 같은 이름의 요소가 중간에 다른 요소를 사이에 두고 반복돼도 Vec에 모읍니다. 글은 Serde 기반 quick-xml이 namespace를 무시하고, 반복 요소 처리 과정에서 값을 잃을 수 있는 상황과 비교합니다. TOML 날짜·시간도 별도 확장 값으로 다룹니다. TOML에서는 원래 날짜 형식을 유지하고 JSON으로 옮길 때는 문자열로 씁니다. Serde 데이터 모델에서는 이런 값을 magic key가 든 map으로 우회 표현해야 하는 사례와 대조합니다.

Deser는 JSON 계열, YAML, TOML, CBOR, MessagePack, XML, plist와 CSV·TSV, URL 인코딩 데이터, 환경 변수 등을 지원합니다. 다만 비용도 분명합니다. JSON 읽기 속도는 입력에 따라 serde_json보다 33% 빠르거나 60% 느렸고, 평균으로는 약 10% 느렸습니다. 쓰기는 3배 빠른 경우부터 70% 느린 경우까지 차이가 나며 평균은 비슷했습니다. YAML과 TOML에서 더 빠른 결과는 라이브러리 구현 차이의 영향도 있다고 덧붙입니다. 컴파일 시간은 조금 나아지고 derive 코드의 release 빌드 시간은 Serde 대비 약 2.3배 빨랐지만, 바이너리 크기는 더 커집니다. 내부 구현에는 힙에 빌린 sink 연결을 보관하는 unsafe 코드도 씁니다. 글쓴이는 Deser가 Serde를 대체할 가능성은 낮다고 보면서도, 현재는 조건에 따라 대체재로 검토할 만한 단계에 이르렀다고 설명합니다.

Lobsters 반응

  • @dprkh — `use deser::{Deserialize, Serialize}; use deser_encoding::Hex; use deser_validate::{Check, NonEmpty, Range}; #[derive(Debug, Serialize, Deserialize)] pub struct Config { // 최소 하나의 256비트 키를 각각 16진수로 작성 #[deser(as = Check<NonEmpty, Vec<Hex>>)] secret_keys: Vec<[u8; 32]>, } 이 예제가 ‘검증하지 말고 파싱하라(parse, don't validate)’ 원칙에 어긋난다는 점을 알아챘습니다. 이 라이브러리는 newtype도 여전히 지원하나요?

원문: Armin Ronacher의 개인 블로그 / 번역·요약: Trawling