Rust 실전 가이드
Rust의 핵심 개념과 실무 활용
학습 항목
이미지 로딩 중...
Rust Serde 직렬화 역직렬화 완벽 가이드
Rust의 Serde 라이브러리를 활용한 데이터 직렬화와 역직렬화 완벽 가이드입니다. JSON, YAML, TOML 등 다양한 포맷 변환부터 커스텀 직렬화, 에러 핸들링까지 실무에 필요한 모든 것을 다룹니다.
목차
- Serde 기본 구조와 Derive 매크로 - 데이터 구조를 자동으로 직렬화 가능하게 만들기
- 필드 속성으로 직렬화 커스터마이징 - rename, skip, default로 유연하게 제어하기
- 다양한 데이터 포맷 지원 - JSON, YAML, TOML, MessagePack 변환하기
- 커스텀 직렬화 로직 구현 - serialize_with와 deserialize_with 활용하기
- 에러 핸들링과 검증 - Result 타입과 커스텀 Deserialize 구현하기
- 제네릭과 라이프타임 다루기 - 복잡한 타입의 직렬화
- 성능 최적화 기법 - 스트리밍과 제로 카피 역직렬화
- Enum 직렬화 전략 - 태그 기반 표현으로 타입 안전하게 다루기
1. Serde 기본 구조와 Derive 매크로 - 데이터 구조를 자동으로 직렬화 가능하게 만들기
시작하며
여러분이 Rust로 API 서버를 개발하는데, 클라이언트로부터 받은 JSON 데이터를 구조체로 변환해야 하는 상황을 겪어본 적 있나요? 매번 수동으로 파싱 코드를 작성하다 보면 코드가 길어지고, 타입 안정성도 보장하기 어렵습니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 외부 API와 통신하거나, 설정 파일을 읽어야 할 때마다 복잡한 파싱 로직을 반복해서 작성해야 하는 번거로움이 있죠.
또한 데이터 구조가 변경될 때마다 파싱 코드도 함께 수정해야 하는 유지보수 부담도 큽니다. 바로 이럴 때 필요한 것이 Serde입니다.
Serde의 Derive 매크로를 사용하면 단 한 줄로 구조체를 직렬화/역직렬화 가능하게 만들 수 있습니다. 컴파일 타임에 안전하게 코드가 생성되어 런타임 오버헤드도 거의 없습니다.
개요
간단히 말해서, Serde는 Rust의 데이터 구조를 다양한 포맷(JSON, YAML, TOML 등)으로 변환하거나 그 반대 작업을 수행하는 프레임워크입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 웹 애플리케이션에서는 항상 데이터 교환이 필요합니다.
프론트엔드와 JSON으로 통신하고, 설정은 TOML로 읽고, 로그는 구조화된 형태로 저장해야 하죠. 예를 들어, 사용자 정보를 데이터베이스에서 읽어 JSON API 응답으로 보내는 경우에 매우 유용합니다.
기존에는 문자열을 직접 파싱하고 각 필드를 수동으로 추출했다면, 이제는 #[derive(Serialize, Deserialize)] 한 줄만 추가하면 자동으로 변환 로직이 생성됩니다. Serde의 핵심 특징은 세 가지입니다.
첫째, 제로 코스트 추상화로 런타임 성능이 뛰어납니다. 둘째, 컴파일 타임에 타입 안정성을 보장합니다.
셋째, 다양한 데이터 포맷을 지원하는 확장 가능한 구조입니다. 이러한 특징들이 Rust 생태계에서 Serde가 사실상 표준으로 자리 잡게 만든 이유입니다.
코드 예제
use serde::{Deserialize, Serialize};
// Derive 매크로로 자동 직렬화/역직렬화 구현
#[derive(Serialize, Deserialize, Debug)]
struct User {
id: u64,
username: String,
email: String,
is_active: bool,
}
fn main() {
// JSON 문자열을 User 구조체로 역직렬화
let json_data = r#"{"id":1,"username":"rustacean","email":"rust@example.com","is_active":true}"#;
let user: User = serde_json::from_str(json_data).unwrap();
println!("Deserialized: {:?}", user);
// User 구조체를 JSON 문자열로 직렬화
let json_string = serde_json::to_string(&user).unwrap();
println!("Serialized: {}", json_string);
}
설명
이것이 하는 일: Serde는 Rust의 타입 시스템을 활용하여 컴파일 타임에 직렬화/역직렬화 코드를 자동 생성합니다. 이를 통해 런타임 오버헤드 없이 안전하게 데이터를 변환할 수 있습니다.
첫 번째로, #[derive(Serialize, Deserialize)] 매크로를 구조체에 추가하면 컴파일러가 해당 구조체의 각 필드를 순회하며 변환 코드를 생성합니다. 왜 이렇게 하는지 이유는 명확합니다.
수동으로 변환 코드를 작성하면 실수가 발생하기 쉽고, 구조체가 변경될 때마다 변환 코드도 수정해야 하는 번거로움이 있기 때문입니다. 그 다음으로, serde_json::from_str() 함수가 실행되면서 JSON 문자열을 파싱하고 각 필드를 User 구조체의 대응되는 필드에 매핑합니다.
내부에서는 타입 정보를 바탕으로 자동으로 타입 변환이 일어납니다. 예를 들어 JSON의 숫자는 u64로, 문자열은 String으로, 불리언은 bool로 변환됩니다.
마지막으로, serde_json::to_string() 함수가 구조체의 각 필드를 순회하며 JSON 형식으로 변환하여 최종적으로 문자열을 만들어냅니다. 이 과정에서 Rust의 소유권 시스템 덕분에 메모리 안전성이 보장됩니다.
여러분이 이 코드를 사용하면 복잡한 파싱 로직 없이도 타입 안전한 데이터 변환을 할 수 있습니다. API 응답 처리가 간결해지고, 컴파일 타임에 타입 불일치를 잡아낼 수 있으며, 유지보수가 훨씬 쉬워집니다.
또한 성능 면에서도 수동 파싱과 거의 동일하거나 더 빠른 경우가 많습니다.
실전 팁
💡 Cargo.toml에 serde = { version = "1.0", features = ["derive"] }와 serde_json = "1.0"을 추가해야 Derive 매크로를 사용할 수 있습니다. derive 기능을 빼먹으면 매크로를 사용할 수 없으니 주의하세요.
💡 역직렬화 시 unwrap() 대신 ? 연산자나 match를 사용하여 에러를 적절히 처리하세요. JSON 형식이 잘못되었거나 필드가 누락되면 panic이 발생할 수 있습니다.
💡 구조체의 필드 순서는 JSON 키 순서와 무관합니다. Serde는 필드 이름으로 매칭하므로 순서를 걱정할 필요가 없습니다.
💡 to_string_pretty()를 사용하면 들여쓰기가 적용된 보기 좋은 JSON을 생성할 수 있어 디버깅에 유용합니다.
💡 큰 데이터를 다룰 때는 from_reader()와 to_writer()를 사용하여 스트리밍 방식으로 처리하면 메모리 사용량을 크게 줄일 수 있습니다.
2. 필드 속성으로 직렬화 커스터마이징 - rename, skip, default로 유연하게 제어하기
시작하며
여러분이 외부 API와 통신하는데, API의 JSON 키가 user_name인데 여러분의 Rust 코드는 username으로 작성되어 있다면 어떻게 하시겠어요? 매번 구조체 필드명을 API 스펙에 맞춰 변경하는 것은 코드 가독성을 해칠 수 있습니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 레거시 API를 사용하거나, 다른 언어의 네이밍 컨벤션(snake_case, camelCase 등)을 따르는 API와 통신할 때 필드명 불일치 문제가 생깁니다.
또한 일부 필드는 민감 정보라 로그에 출력하고 싶지 않거나, 선택적 필드는 기본값으로 처리하고 싶을 수도 있습니다. 바로 이럴 때 필요한 것이 Serde의 필드 속성입니다.
#[serde(rename)], #[serde(skip)], #[serde(default)] 등을 사용하면 구조체 정의는 그대로 유지하면서도 직렬화 동작을 세밀하게 제어할 수 있습니다.
개요
간단히 말해서, Serde의 필드 속성은 각 필드의 직렬화/역직렬화 동작을 개별적으로 커스터마이징할 수 있는 강력한 기능입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 실제 프로젝트에서는 데이터 모델과 외부 포맷이 완벽히 일치하는 경우가 거의 없습니다.
외부 API는 snake_case를 사용하는데 Rust 코드는 camelCase를 선호할 수 있고, 일부 필드는 보안상 직렬화에서 제외해야 하며, 없는 필드는 기본값으로 채워야 할 수 있습니다. 예를 들어, 비밀번호 필드는 절대 JSON 응답에 포함되어서는 안 되는 경우가 전형적입니다.
기존에는 별도의 DTO(Data Transfer Object)를 만들어 수동으로 변환했다면, 이제는 하나의 구조체에 속성만 추가하여 다양한 상황에 대응할 수 있습니다. Serde 필드 속성의 핵심 특징은 선언적이고 명확하다는 것입니다.
코드를 읽는 사람이 각 필드가 어떻게 직렬화되는지 한눈에 파악할 수 있습니다. 또한 컴파일 타임에 검증되므로 런타임 에러를 사전에 방지합니다.
이러한 특징들이 코드의 안전성과 유지보수성을 크게 향상시킵니다.
코드 예제
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct Account {
#[serde(rename = "user_id")] // JSON에서는 "user_id"로 표현
id: u64,
#[serde(rename = "user_name")]
username: String,
#[serde(skip_serializing)] // 역직렬화는 되지만 직렬화는 제외
password: String,
#[serde(default)] // 필드가 없으면 기본값 사용
subscription_tier: String,
#[serde(skip_serializing_if = "Option::is_none")] // None이면 생략
last_login: Option<String>,
}
fn main() {
let json = r#"{"user_id":42,"user_name":"alice","password":"secret123"}"#;
let account: Account = serde_json::from_str(json).unwrap();
// password는 출력되지 않음, subscription_tier는 기본값("")으로 채워짐
let output = serde_json::to_string_pretty(&account).unwrap();
println!("{}", output);
}
설명
이것이 하는 일: 필드 속성은 구조체의 각 필드에 메타데이터를 추가하여 Serde가 해당 필드를 어떻게 처리할지 지시합니다. 이를 통해 코드 구조는 유지하면서도 외부 표현을 자유롭게 바꿀 수 있습니다.
첫 번째로, #[serde(rename = "user_id")] 속성은 JSON에서 "user_id"라는 키를 찾아 id 필드에 매핑합니다. 왜 이렇게 하는지 이유는 명확합니다.
API 스펙은 바꿀 수 없지만, Rust 코드에서는 더 짧고 명확한 이름을 사용하고 싶기 때문입니다. 특히 여러 API와 통신할 때 일관된 내부 네이밍을 유지하면서도 각 API의 스펙을 따를 수 있습니다.
그 다음으로, #[serde(skip_serializing)]이 적용된 password 필드는 역직렬화 시에는 JSON에서 값을 읽어오지만, 직렬화 시에는 출력에서 완전히 제외됩니다. 내부에서는 컴파일 타임에 해당 필드를 건너뛰는 코드가 생성됩니다.
이는 보안상 매우 중요한데, 실수로 민감한 정보가 로그나 API 응답에 노출되는 것을 방지합니다. 세 번째로, #[serde(default)] 속성이 있는 subscription_tier는 JSON에 해당 키가 없어도 에러가 발생하지 않고 기본값(String의 경우 빈 문자열)으로 채워집니다.
#[serde(skip_serializing_if = "Option::is_none")]은 값이 None일 때 JSON 출력에서 해당 키 자체를 생략합니다. 이는 불필요한 null 값을 줄여 JSON 크기를 최적화하는 데 유용합니다.
여러분이 이 코드를 사용하면 외부 API 스펙 변경에 유연하게 대응할 수 있고, 보안 문제를 사전에 방지할 수 있으며, 선택적 필드를 우아하게 처리할 수 있습니다. 특히 마이크로서비스 아키텍처에서 여러 서비스 간 데이터 교환 시 각 서비스의 네이밍 컨벤션을 존중하면서도 내부 코드 일관성을 유지할 수 있습니다.
실전 팁
💡 #[serde(rename_all = "camelCase")]를 구조체 레벨에 적용하면 모든 필드를 한 번에 camelCase로 변환할 수 있어, 개별 필드마다 rename을 작성할 필요가 없습니다.
💡 #[serde(default = "default_tier")] 형태로 커스텀 기본값 함수를 지정할 수 있습니다. 빈 문자열이 아닌 "free" 같은 의미 있는 기본값을 설정하세요.
💡 #[serde(skip)]는 직렬화와 역직렬화 둘 다 건너뛰므로, 한쪽만 제어하려면 skip_serializing 또는 skip_deserializing을 사용하세요.
💡 민감한 정보는 skip_serializing 외에도 별도의 구조체로 분리하여 타입 레벨에서 안전성을 보장하는 것이 더 좋은 설계입니다.
💡 #[serde(flatten)]을 사용하면 중첩된 구조체의 필드를 평평하게 만들어 JSON 구조를 단순화할 수 있습니다.
3. 다양한 데이터 포맷 지원 - JSON, YAML, TOML, MessagePack 변환하기
시작하며
여러분의 애플리케이션이 설정은 TOML 파일로 읽고, API는 JSON으로 통신하고, 로그는 MessagePack으로 저장해야 한다면 각각 다른 파싱 라이브러리를 익혀야 할까요? 각 포맷마다 다른 API를 배우고 변환 코드를 작성하는 것은 매우 번거로운 일입니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 마이크로서비스 환경에서는 서비스마다 선호하는 데이터 포맷이 다를 수 있고, 성능이 중요한 부분에서는 바이너리 포맷을, 사람이 읽어야 하는 설정에는 텍스트 포맷을 사용해야 합니다.
포맷마다 별도의 구조체와 변환 코드를 관리하면 코드 중복이 심해지고 유지보수가 어렵습니다. 바로 이럴 때 필요한 것이 Serde의 포맷 독립적인 설계입니다.
구조체 정의는 한 번만 하고, 포맷별 라이브러리만 바꿔 사용하면 됩니다. 코드 재사용성이 극대화되고 일관된 에러 핸들링이 가능합니다.
개요
간단히 말해서, Serde는 데이터 모델과 직렬화 포맷을 완전히 분리하여 하나의 구조체로 여러 포맷을 지원할 수 있게 설계되었습니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 현대 애플리케이션은 다양한 데이터 포맷을 다뤄야 합니다.
REST API는 JSON을 사용하고, 설정 파일은 TOML이 가독성이 좋으며, 고성능 통신에는 MessagePack이나 Bincode가 적합합니다. 예를 들어, 동일한 사용자 정보 구조체를 데이터베이스에서 읽을 때는 bincode로, API 응답은 JSON으로, 설정 파일은 YAML로 다뤄야 할 수 있습니다.
기존에는 각 포맷마다 별도의 변환 로직을 작성했다면, 이제는 serde_json, serde_yaml, serde_cbor 등의 크레이트만 선택하면 됩니다. Serde 생태계의 핵심 특징은 일관된 API입니다.
모든 포맷 라이브러리가 from_str(), to_string(), from_reader(), to_writer() 같은 동일한 패턴을 따릅니다. 또한 각 포맷의 특수 기능(예: YAML의 앵커)도 필요시 사용할 수 있어 유연합니다.
이러한 특징들이 개발자 경험을 크게 향상시키고 학습 곡선을 낮춥니다.
코드 예제
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct Config {
app_name: String,
port: u16,
debug: bool,
}
fn main() {
let config = Config {
app_name: "MyApp".to_string(),
port: 8080,
debug: true,
};
// JSON으로 직렬화
let json = serde_json::to_string_pretty(&config).unwrap();
println!("JSON:\n{}\n", json);
// TOML로 직렬화 (Cargo.toml에 toml = "0.8" 추가 필요)
let toml = toml::to_string(&config).unwrap();
println!("TOML:\n{}\n", toml);
// YAML로 직렬화 (Cargo.toml에 serde_yaml = "0.9" 추가 필요)
let yaml = serde_yaml::to_string(&config).unwrap();
println!("YAML:\n{}", yaml);
// JSON에서 다시 역직렬화
let parsed: Config = serde_json::from_str(&json).unwrap();
println!("Parsed: {:?}", parsed);
}
설명
이것이 하는 일: Serde는 데이터 모델(구조체)과 직렬화 포맷(JSON, YAML 등)을 완전히 분리하는 추상화 계층을 제공합니다. 이를 통해 포맷 변경 시 구조체 코드는 전혀 수정할 필요가 없습니다.
첫 번째로, #[derive(Serialize, Deserialize)] 매크로가 구조체에 적용되면 포맷 독립적인 직렬화 trait를 구현합니다. 왜 이렇게 하는지 이유는 Serde의 핵심 철학입니다.
각 포맷 라이브러리는 이 trait를 소비하여 자신의 포맷으로 변환만 하면 되므로, 구조체는 포맷에 대해 전혀 알 필요가 없습니다. 그 다음으로, serde_json::to_string_pretty(&config)를 호출하면 Serde가 구조체의 Serialize trait를 통해 각 필드를 순회하고, serde_json은 이를 받아 JSON 형식으로 변환합니다.
내부에서는 방문자 패턴(Visitor Pattern)을 사용하여 타입 정보를 유지하면서도 유연하게 변환합니다. TOML이나 YAML도 동일한 Serialize trait를 사용하지만, 최종 출력 형식만 다르게 생성합니다.
마지막으로, 역직렬화 시 serde_json::from_str(&json)은 JSON 문자열을 파싱하여 Serde의 Deserialize trait를 통해 Config 구조체를 생성합니다. 각 포맷 라이브러리가 동일한 trait를 사용하므로 에러 타입과 처리 방식도 일관됩니다.
여러분이 이 코드를 사용하면 포맷 전환이 매우 쉬워집니다. 프로토타이핑 단계에서는 JSON을 사용하다가 성능이 중요해지면 MessagePack으로 바꾸는 것이 라이브러리 import 한 줄 변경으로 가능합니다.
또한 동일한 데이터를 여러 포맷으로 제공하는 API를 쉽게 구현할 수 있습니다. 테스트 시에는 가독성 좋은 YAML을 사용하고 프로덕션에서는 빠른 바이너리 포맷을 사용하는 등의 전략도 가능합니다.
실전 팁
💡 바이너리 포맷(bincode, MessagePack)은 텍스트 포맷보다 5-10배 빠르고 크기도 작으므로, 성능이 중요한 내부 통신에는 바이너리 포맷을 고려하세요.
💡 TOML은 중첩이 깊은 구조에는 적합하지 않습니다. 설정 파일이 복잡하면 YAML이나 JSON을 사용하는 것이 좋습니다.
💡 각 포맷 라이브러리의 버전 호환성을 주의하세요. serde = "1.0"과 serde_json = "1.0" 같이 메이저 버전을 맞춰야 합니다.
💡 웹 API에서 Content-Type 헤더에 따라 포맷을 자동 선택하는 로직을 구현하면 더욱 유연한 API를 만들 수 있습니다.
💡 디버깅 시 ron (Rusty Object Notation) 포맷을 사용하면 Rust 구조와 유사한 형태로 출력되어 가독성이 좋습니다.
4. 커스텀 직렬화 로직 구현 - serialize_with와 deserialize_with 활용하기
시작하며
여러분이 날짜를 DateTime 타입으로 다루는데, 외부 API는 Unix 타임스탬프(숫자)로 주고받는다면 어떻게 하시겠어요? 또는 문자열을 대문자로 저장하지만 소문자로 직렬화해야 하는 특수한 요구사항이 있다면요?
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 레거시 시스템과 통신하거나, 특수한 포맷(ISO 8601이 아닌 커스텀 날짜 형식, 16진수 문자열로 표현된 바이트 배열 등)을 다뤄야 할 때 기본 직렬화로는 한계가 있습니다.
매번 중간 변환 단계를 거치면 코드가 복잡해지고 실수하기 쉽습니다. 바로 이럴 때 필요한 것이 커스텀 직렬화 로직입니다.
serialize_with와 deserialize_with 속성을 사용하면 특정 필드에 대해서만 별도의 변환 로직을 적용할 수 있습니다. 나머지 필드는 기본 동작을 사용하므로 효율적입니다.
개요
간단히 말해서, 커스텀 직렬화는 특정 필드의 변환 로직을 직접 구현하여 기본 동작을 오버라이드하는 기능입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 실제 프로젝트에서는 표준 형식만으로는 해결할 수 없는 복잡한 요구사항이 많습니다.
예를 들어, 금융 시스템에서 금액은 부동소수점이 아닌 정수(센트 단위)로 전송하되 표시는 달러로 해야 하거나, 민감한 정보는 직렬화 시 자동으로 해싱해야 할 수 있습니다. 또 다른 예로, chrono의 DateTime을 RFC 3339가 아닌 커스텀 포맷으로 변환해야 하는 경우가 있습니다.
기존에는 별도의 래퍼 타입을 만들거나 수동 변환 코드를 작성했다면, 이제는 필드 속성으로 깔끔하게 처리할 수 있습니다. 커스텀 직렬화의 핵심 특징은 세밀한 제어입니다.
전체 구조체가 아닌 특정 필드만 커스터마이징할 수 있어 변경 범위가 최소화됩니다. 또한 재사용 가능한 함수로 구현하므로 여러 구조체에서 동일한 로직을 공유할 수 있습니다.
이러한 특징들이 코드 중복을 줄이고 유지보수를 쉽게 만듭니다.
코드 예제
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::{SystemTime, UNIX_EPOCH};
// Unix 타임스탬프를 SystemTime으로 변환하는 커스텀 역직렬화
fn deserialize_timestamp<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
where
D: Deserializer<'de>,
{
let timestamp: u64 = Deserialize::deserialize(deserializer)?;
Ok(UNIX_EPOCH + std::time::Duration::from_secs(timestamp))
}
// SystemTime을 Unix 타임스탬프로 변환하는 커스텀 직렬화
fn serialize_timestamp<S>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let duration = time.duration_since(UNIX_EPOCH).unwrap();
serializer.serialize_u64(duration.as_secs())
}
#[derive(Serialize, Deserialize, Debug)]
struct Event {
name: String,
#[serde(serialize_with = "serialize_timestamp")]
#[serde(deserialize_with = "deserialize_timestamp")]
timestamp: SystemTime,
}
fn main() {
let json = r#"{"name":"UserLogin","timestamp":1704067200}"#;
let event: Event = serde_json::from_str(json).unwrap();
println!("Deserialized: {:?}", event);
let output = serde_json::to_string(&event).unwrap();
println!("Serialized: {}", output);
}
설명
이것이 하는 일: 커스텀 직렬화 함수는 Serde의 기본 변환 로직을 우회하여 개발자가 직접 정의한 변환 규칙을 적용합니다. 이를 통해 표준 형식이 아닌 데이터도 타입 안전하게 처리할 수 있습니다.
첫 번째로, #[serde(deserialize_with = "deserialize_timestamp")] 속성이 적용되면 Serde는 해당 필드를 역직렬화할 때 기본 로직 대신 지정된 함수를 호출합니다. 왜 이렇게 하는지 이유는 SystemTime 타입은 Serde가 표준으로 지원하지만, API가 요구하는 Unix 타임스탬프 형식과는 다르기 때문입니다.
직접 변환 로직을 제공함으로써 외부 포맷과 내부 타입 간의 간극을 메울 수 있습니다. 그 다음으로, deserialize_timestamp 함수 내부에서는 먼저 Deserialize::deserialize(deserializer)를 호출하여 JSON에서 u64 숫자를 읽어옵니다.
그런 다음 UNIX_EPOCH에 해당 초를 더하여 SystemTime으로 변환합니다. 내부에서는 Serde의 Deserializer trait를 통해 타입 안전성이 보장되므로, 잘못된 타입이 들어오면 컴파일 타임이나 역직렬화 시점에 에러가 발생합니다.
세 번째로, 직렬화 시 serialize_timestamp 함수는 반대 과정을 수행합니다. SystemTime에서 Unix epoch 이후의 경과 시간을 계산하여 초 단위 u64로 변환한 후, serializer.serialize_u64()를 호출하여 JSON 숫자로 출력합니다.
이 패턴은 재사용 가능하므로 여러 구조체의 timestamp 필드에 동일하게 적용할 수 있습니다. 여러분이 이 코드를 사용하면 복잡한 타입 변환을 구조체 정의와 함께 명확하게 문서화할 수 있습니다.
코드를 읽는 사람이 해당 필드가 특수한 변환을 거친다는 것을 즉시 알 수 있고, 변환 로직이 함수로 분리되어 있어 테스트하기도 쉽습니다. 또한 외부 API 스펙이 변경되어도 구조체 정의는 그대로 두고 변환 함수만 수정하면 되므로 변경 영향 범위가 최소화됩니다.
실전 팁
💡 커스텀 직렬화 함수는 여러 곳에서 재사용할 수 있도록 별도 모듈에 모아두면 관리가 편리합니다. 예: mod serde_helpers
💡 chrono 크레이트를 사용한다면 이미 다양한 날짜 포맷 변환 헬퍼가 제공되므로 직접 구현하기 전에 확인하세요.
💡 커스텀 역직렬화에서 에러 처리는 D::Error::custom("message")를 사용하여 명확한 에러 메시지를 제공하세요.
💡 복잡한 변환 로직은 단위 테스트를 작성하여 검증하세요. 직렬화 후 역직렬화하여 원본과 같은지 확인하는 round-trip 테스트가 유용합니다.
💡 serde_with 크레이트는 흔히 사용되는 커스텀 직렬화 패턴을 제공하므로, 직접 구현하기 전에 확인하면 시간을 절약할 수 있습니다.
5. 에러 핸들링과 검증 - Result 타입과 커스텀 Deserialize 구현하기
시작하며
여러분이 API로부터 받은 JSON 데이터가 예상과 다른 형식이거나 필수 필드가 누락되었다면 어떻게 처리하시겠어요? unwrap()을 사용하면 프로그램이 즉시 종료되어 사용자 경험을 해치고, 에러 메시지도 불명확합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 외부 API를 호출할 때는 네트워크 오류, 잘못된 데이터 형식, 예상치 못한 필드 등 다양한 에러 상황을 만날 수 있습니다.
프로덕션 환경에서는 에러를 우아하게 처리하여 로깅하고, 사용자에게 적절한 피드백을 주며, 가능하면 복구를 시도해야 합니다. 바로 이럴 때 필요한 것이 적절한 에러 핸들링입니다.
Rust의 Result 타입과 ? 연산자를 활용하면 에러를 명시적으로 전파하고 처리할 수 있습니다. 또한 커스텀 Deserialize 구현으로 데이터 검증을 직렬화 단계에 통합할 수 있습니다.
개요
간단히 말해서, Serde의 에러 핸들링은 Result 타입을 통해 타입 안전하게 에러를 표현하고, 명확한 에러 메시지를 제공하여 디버깅을 쉽게 만듭니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 신뢰할 수 없는 외부 입력을 다룰 때는 항상 에러 가능성을 염두에 두어야 합니다.
JSON 파싱 실패, 타입 불일치, 값 범위 초과 등 다양한 에러가 발생할 수 있습니다. 예를 들어, 사용자 나이 필드가 음수로 들어오거나, 이메일 형식이 잘못되었다면 역직렬화 단계에서 즉시 거부하는 것이 안전합니다.
기존에는 역직렬화 후 별도의 검증 단계를 거쳤다면, 이제는 커스텀 Deserialize 구현으로 파싱과 검증을 한 번에 수행할 수 있습니다. Serde 에러 처리의 핵심 특징은 명확성입니다.
어떤 필드에서 어떤 이유로 실패했는지 구체적인 정보를 제공합니다. 또한 타입 시스템을 활용하여 컴파일 타임에 많은 에러를 잡아낼 수 있습니다.
이러한 특징들이 런타임 에러를 줄이고 디버깅 시간을 단축시킵니다.
코드 예제
use serde::{Deserialize, Deserializer, de};
use std::fmt;
// 검증 로직이 포함된 이메일 타입
#[derive(Debug)]
struct Email(String);
impl<'de> Deserialize<'de> for Email {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
// 이메일 형식 검증 (간단한 예시)
if s.contains('@') && s.len() > 3 {
Ok(Email(s))
} else {
Err(de::Error::custom(format!("Invalid email format: {}", s)))
}
}
}
#[derive(Deserialize, Debug)]
struct User {
name: String,
email: Email, // 자동 검증됨
age: u8, // 0-255 범위 자동 보장
}
fn main() {
// 올바른 데이터
let valid_json = r#"{"name":"Alice","email":"alice@example.com","age":30}"#;
match serde_json::from_str::<User>(valid_json) {
Ok(user) => println!("Valid user: {:?}", user),
Err(e) => eprintln!("Error: {}", e),
}
// 잘못된 이메일
let invalid_json = r#"{"name":"Bob","email":"invalid","age":25}"#;
match serde_json::from_str::<User>(invalid_json) {
Ok(user) => println!("Valid user: {:?}", user),
Err(e) => eprintln!("Validation failed: {}", e),
}
}
설명
이것이 하는 일: 커스텀 Deserialize 구현은 데이터를 읽는 동시에 검증을 수행하여 잘못된 데이터가 구조체에 들어가는 것을 원천 차단합니다. 이를 통해 "불가능한 상태를 표현 불가능하게" 만드는 Rust의 철학을 구현합니다.
첫 번째로, Email 타입에 대한 커스텀 Deserialize 구현에서 String::deserialize(deserializer)?를 호출하여 먼저 문자열을 읽어옵니다. 왜 이렇게 하는지 이유는 JSON에서 기본 타입(여기서는 문자열)을 먼저 추출한 후 추가 검증을 수행하는 것이 일반적인 패턴이기 때문입니다.
? 연산자가 파싱 에러를 자동으로 전파하므로 코드가 간결합니다. 그 다음으로, 읽어온 문자열에 대해 검증 로직을 수행합니다.
s.contains('@') && s.len() > 3으로 간단한 이메일 형식을 확인하고, 통과하면 Ok(Email(s))를 반환합니다. 내부에서는 newtype 패턴을 사용하여 검증된 문자열만 Email 타입으로 감쌉니다.
이렇게 하면 타입 시스템이 검증되지 않은 문자열과 검증된 이메일을 구분할 수 있습니다. 세 번째로, 검증이 실패하면 Err(de::Error::custom())으로 명확한 에러 메시지를 반환합니다.
Serde는 이 에러를 받아 어떤 필드에서 실패했는지 경로 정보와 함께 출력합니다. 예를 들어 "Invalid email format: invalid at line 1 column 45" 같은 구체적인 메시지를 제공하여 디버깅을 돕습니다.
여러분이 이 코드를 사용하면 잘못된 데이터가 시스템에 들어오는 것을 최전선에서 막을 수 있습니다. 구조체에 저장된 데이터는 이미 검증되었다고 신뢰할 수 있으므로, 이후 비즈니스 로직에서는 재검증할 필요가 없습니다.
또한 에러 메시지가 명확하여 클라이언트에게 정확한 피드백을 줄 수 있고, 로그 분석도 쉬워집니다. 프로덕션 환경에서 예상치 못한 panic을 방지하고 우아한 에러 복구가 가능합니다.
실전 팁
💡 실제 프로젝트에서는 validator 크레이트와 결합하여 더 정교한 검증(이메일, URL, 길이 제한 등)을 수행하세요.
💡 에러 메시지는 최종 사용자에게 보여질 수 있으므로, 보안상 민감한 정보(내부 경로, 구현 세부사항 등)를 포함하지 마세요.
💡 anyhow 크레이트를 사용하면 에러 체인을 쉽게 구축하여 근본 원인을 추적할 수 있습니다.
💡 성능이 중요하다면 정규표현식 대신 간단한 문자열 검사를 사용하거나, lazy_static으로 정규표현식을 컴파일하여 재사용하세요.
💡 단위 테스트에서 다양한 잘못된 입력을 테스트하여 에러 메시지가 적절한지 확인하세요. 예상 에러를 테스트하는 것도 중요합니다.
6. 제네릭과 라이프타임 다루기 - 복잡한 타입의 직렬화
시작하며
여러분이 제네릭 타입 Result<T, E>나 라이프타임이 포함된 구조체를 직렬화하려는데 컴파일 에러가 발생한다면 당황스러울 것입니다. Serde는 대부분의 경우 자동으로 처리하지만, 복잡한 타입에서는 추가 조치가 필요할 수 있습니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 제네릭 컨테이너(Vec, HashMap, Option, Result 등)를 중첩하거나, 참조를 포함하는 구조체를 직렬화할 때 trait bound나 라이프타임 에러를 만날 수 있습니다.
이런 에러는 Rust 초보자에게는 이해하기 어렵고, 해결 방법도 명확하지 않아 막막할 수 있습니다. 바로 이럴 때 필요한 것이 Serde의 제네릭 지원과 라이프타임 처리 방법을 이해하는 것입니다.
Derive 매크로가 자동으로 적절한 trait bound를 생성하지만, 때로는 수동으로 제약을 추가하거나 'de 라이프타임을 명시해야 합니다.
개요
간단히 말해서, Serde는 제네릭 타입과 라이프타임을 완벽하게 지원하지만, 타입 매개변수와 라이프타임 주석을 올바르게 사용해야 합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 실제 애플리케이션에서는 단순한 구조체보다 제네릭과 라이프타임을 활용한 복잡한 타입을 더 많이 사용합니다.
예를 들어, API 응답은 Result<Data, Error> 형태가 일반적이고, 제로 카피 파싱을 위해 &str 참조를 사용하며, 다양한 타입을 담는 Vec<T>나 HashMap<K, V>를 자주 사용합니다. 기존에는 제네릭 타입마다 별도의 구체 타입을 만들었다면, 이제는 하나의 제네릭 정의로 모든 경우를 커버할 수 있습니다.
Serde의 제네릭 지원 핵심 특징은 컴파일 타임 검증입니다. 타입 매개변수가 Serialize/Deserialize를 구현하지 않으면 컴파일 에러가 발생하여 런타임 문제를 사전에 방지합니다.
또한 라이프타임 추론이 잘 작동하여 대부분의 경우 명시적 주석이 불필요합니다. 이러한 특징들이 타입 안전성을 유지하면서도 유연한 코드를 작성할 수 있게 합니다.
코드 예제
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// 제네릭 타입 T는 자동으로 Serialize/Deserialize bound를 받음
#[derive(Serialize, Deserialize, Debug)]
struct ApiResponse<T> {
status: u16,
data: Option<T>,
error: Option<String>,
}
// 라이프타임 'de를 사용하여 제로 카피 역직렬화
#[derive(Deserialize, Debug)]
struct BorrowedData<'de> {
#[serde(borrow)] // 문자열을 복사하지 않고 빌림
name: &'de str,
#[serde(borrow)]
tags: Vec<&'de str>,
}
fn main() {
// 제네릭 타입 직렬화
let response = ApiResponse {
status: 200,
data: Some(HashMap::from([("key", "value")])),
error: None,
};
let json = serde_json::to_string(&response).unwrap();
println!("Generic: {}", json);
// 제로 카피 역직렬화
let json_str = r#"{"name":"Rust","tags":["fast","safe"]}"#;
let borrowed: BorrowedData = serde_json::from_str(json_str).unwrap();
println!("Borrowed: {:?}", borrowed);
// borrowed.name과 tags는 json_str을 참조하므로 메모리 할당 없음
}
설명
이것이 하는 일: Serde의 Derive 매크로는 제네릭 타입 매개변수에 자동으로 적절한 trait bound를 추가하고, 라이프타임을 올바르게 전파하여 안전한 메모리 관리를 보장합니다. 첫 번째로, ApiResponse<T> 구조체에 Derive 매크로를 적용하면 컴파일러가 자동으로 T: Serialize와 T: Deserialize<'de> bound를 추가합니다.
왜 이렇게 하는지 이유는 ApiResponse를 직렬화하려면 내부의 T도 직렬화 가능해야 하기 때문입니다. 이는 타입 안전성을 보장하며, T가 직렬화 불가능한 타입이면 컴파일 시점에 에러가 발생합니다.
그 다음으로, BorrowedData<'de> 구조체는 역직렬화 라이프타임 'de를 사용합니다. 내부에서 #[serde(borrow)] 속성은 Serde에게 해당 필드를 복사하지 말고 원본 데이터를 참조하라고 지시합니다.
이는 대용량 JSON을 파싱할 때 메모리 할당을 크게 줄여 성능을 향상시킵니다. 예를 들어, 10MB JSON에서 문자열을 추출할 때 복사 없이 참조만 사용하므로 속도가 몇 배 빨라집니다.
세 번째로, serde_json::from_str()을 호출하면 반환된 BorrowedData의 라이프타임이 입력 문자열 json_str에 묶입니다. 따라서 borrowed를 사용하는 동안 json_str이 유효해야 하며, Rust의 빌림 검사기가 이를 컴파일 타임에 검증합니다.
만약 json_str이 스코프를 벗어나는데 borrowed를 사용하려 하면 컴파일 에러가 발생합니다. 여러분이 이 코드를 사용하면 타입 매개변수 덕분에 코드 재사용성이 극대화됩니다.
ApiResponse<User>, ApiResponse<Product>, ApiResponse<Vec<String>> 등 다양한 타입에 동일한 구조체를 사용할 수 있습니다. 또한 제로 카피 역직렬화로 성능 최적화가 가능하며, Rust의 소유권 시스템이 메모리 안전성을 보장하므로 안심하고 사용할 수 있습니다.
특히 고성능 서버나 임베디드 시스템에서 메모리 할당을 최소화하는 것은 매우 중요한 최적화입니다.
실전 팁
💡 #[serde(borrow)]는 &str과 &[u8] 같은 참조 타입에만 사용할 수 있습니다. String에는 사용할 수 없으므로 주의하세요.
💡 제로 카피 역직렬화는 입력 데이터가 오래 유지될 때만 유용합니다. 바로 처리 후 버려질 데이터라면 오히려 라이프타임 관리가 복잡해질 수 있습니다.
💡 복잡한 제네릭 bound가 필요하다면 수동으로 where T: Serialize + Clone 같은 제약을 추가할 수 있습니다.
💡 serde::de::DeserializeOwned trait는 'de 라이프타임이 없는 타입(예: String)만 허용하므로, 함수 시그니처에서 유용합니다.
💡 제네릭 타입 디버깅 시 cargo expand를 사용하면 Derive 매크로가 생성한 실제 코드를 볼 수 있어 문제 해결에 도움이 됩니다.
7. 성능 최적화 기법 - 스트리밍과 제로 카피 역직렬화
시작하며
여러분이 수백 MB 크기의 JSON 파일을 메모리에 모두 로드하려다가 Out of Memory 에러를 만난 적 있나요? 또는 실시간 스트리밍 데이터를 처리하는데 지연이 발생하여 데이터 손실이 일어난다면요?
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 빅데이터 처리, 로그 분석, 실시간 모니터링 시스템에서는 대용량 데이터를 효율적으로 다뤄야 합니다.
전체 데이터를 메모리에 로드하면 메모리 부족은 물론 GC 부담도 커지고, 첫 번째 데이터를 처리하기까지 대기 시간도 길어집니다. 바로 이럴 때 필요한 것이 스트리밍 파싱과 제로 카피 역직렬화입니다.
Serde는 from_reader()와 to_writer()를 제공하여 데이터를 청크 단위로 처리할 수 있고, #[serde(borrow)]로 불필요한 메모리 복사를 제거할 수 있습니다.
개요
간단히 말해서, 스트리밍 파싱은 전체 데이터를 메모리에 로드하지 않고 필요한 만큼만 읽어 처리하는 기법이고, 제로 카피는 데이터 복사 없이 원본을 참조하여 성능을 극대화하는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 성능과 확장성은 프로덕션 시스템의 핵심 요구사항입니다.
예를 들어, 로그 수집 서버는 초당 수천 개의 JSON 이벤트를 처리해야 하는데, 매번 문자열을 복사하면 CPU와 메모리를 낭비합니다. 또는 대용량 설정 파일을 읽을 때 전체를 메모리에 로드하면 시작 시간이 느려지고 메모리 사용량도 급증합니다.
기존에는 파일을 전체 읽은 후 파싱했다면, 이제는 파일을 읽으면서 동시에 파싱할 수 있습니다. Serde 성능 최적화의 핵심 특징은 제로 코스트 추상화입니다.
고수준 API를 사용하면서도 수동 최적화와 동일한 성능을 얻을 수 있습니다. 또한 메모리 안전성이 보장되므로 버퍼 오버플로우 같은 저수준 버그를 걱정할 필요가 없습니다.
이러한 특징들이 안전하고 빠른 시스템을 구축할 수 있게 합니다.
코드 예제
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{BufReader, BufWriter};
#[derive(Serialize, Deserialize, Debug)]
struct LogEntry<'a> {
#[serde(borrow)]
level: &'a str,
#[serde(borrow)]
message: &'a str,
timestamp: u64,
}
fn stream_write_example() -> std::io::Result<()> {
let file = File::create("/tmp/logs.json")?;
let writer = BufWriter::new(file);
// 스트리밍 방식으로 쓰기 - 메모리에 전체 데이터 유지 안 함
let logs = vec![
LogEntry { level: "INFO", message: "Server started", timestamp: 1704067200 },
LogEntry { level: "ERROR", message: "Connection failed", timestamp: 1704067201 },
];
serde_json::to_writer(writer, &logs)?;
Ok(())
}
fn stream_read_example() -> std::io::Result<()> {
let file = File::open("/tmp/logs.json")?;
let reader = BufReader::new(file);
// 스트리밍 방식으로 읽기 - 파일 전체를 메모리에 로드하지 않음
let logs: Vec<LogEntry> = serde_json::from_reader(reader)?;
for log in logs {
println!("{:?}", log);
// log.level과 message는 제로 카피로 원본 참조
}
Ok(())
}
fn main() {
stream_write_example().unwrap();
stream_read_example().unwrap();
}
설명
이것이 하는 일: 스트리밍 API는 운영체제의 버퍼를 활용하여 데이터를 청크 단위로 읽고 쓰므로, 메모리 사용량을 일정하게 유지하면서 대용량 데이터를 처리할 수 있습니다. 첫 번째로, BufReader와 BufWriter는 시스템 콜 횟수를 줄이기 위해 내부 버퍼를 사용합니다.
왜 이렇게 하는지 이유는 파일 I/O는 느린 작업이므로, 한 번에 큰 청크를 읽어 버퍼에 저장한 후 조금씩 소비하는 것이 효율적이기 때문입니다. 예를 들어, 4KB씩 읽는다면 1MB 파일도 256번의 시스템 콜로 처리되지만, 버퍼 없이 바이트 단위로 읽으면 100만 번의 시스템 콜이 발생합니다.
그 다음으로, serde_json::to_writer(writer, &logs)는 데이터를 직렬화하면서 동시에 writer에 출력합니다. 내부에서는 중간 String을 생성하지 않고 직접 버퍼에 쓰므로 메모리 할당이 최소화됩니다.
로그 항목이 수천 개라도 메모리는 버퍼 크기(일반적으로 8KB) 정도만 사용합니다. 세 번째로, serde_json::from_reader(reader)는 파일에서 데이터를 읽으면서 동시에 파싱합니다.
LogEntry<'a>의 #[serde(borrow)] 필드는 파싱된 데이터가 내부 버퍼를 참조하도록 하여 String 할당을 피합니다. 하지만 주의할 점은 JSON 전체가 하나의 배열이므로 결국 메모리에 로드된다는 것입니다.
진정한 스트리밍을 위해서는 줄바꿈으로 구분된 JSON(NDJSON)을 사용해야 합니다. 여러분이 이 코드를 사용하면 대용량 파일 처리 시 메모리 사용량을 크게 줄일 수 있습니다.
10MB 파일을 처리하는데 전체를 로드하면 20MB 이상의 메모리가 필요하지만(문자열 복사 때문), 스트리밍과 제로 카피를 사용하면 수 MB로 충분합니다. 또한 첫 데이터 처리까지의 지연(latency)도 줄어들어 실시간 시스템에 적합합니다.
네트워크 소켓이나 파이프에서 읽을 때도 동일한 패턴을 사용할 수 있어 매우 범용적입니다.
실전 팁
💡 진정한 스트리밍 처리를 위해서는 NDJSON(줄바꿈으로 구분된 JSON) 포맷을 사용하고, 각 줄을 개별적으로 파싱하세요.
💡 제로 카피 역직렬화는 입력 버퍼의 라이프타임에 묶이므로, 데이터를 오래 보관하려면 to_owned()로 복사해야 합니다.
💡 serde_json은 편리하지만 simd-json이나 sonic-rs 같은 고성능 대안도 있습니다. 벤치마크 후 선택하세요.
💡 압축된 파일(gzip)을 읽을 때는 flate2 크레이트로 압축 해제 스트림을 만든 후 Serde에 넘기면 투명하게 처리됩니다.
💡 criterion 벤치마크를 작성하여 복사 버전과 제로 카피 버전의 성능을 측정하고, 실제 이득을 확인하세요.
8. Enum 직렬화 전략 - 태그 기반 표현으로 타입 안전하게 다루기
시작하며
여러분이 API 응답이 성공 또는 실패 두 가지 상태를 가지는데, 각각 다른 필드를 포함해야 한다면 어떻게 설계하시겠어요? 단순히 모든 필드를 Optional로 만들면 타입 안전성이 떨어지고, 잘못된 조합이 가능해집니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 다형성 데이터를 다룰 때, 즉 같은 필드명으로 서로 다른 타입의 데이터가 올 수 있는 경우가 많습니다.
예를 들어, 이벤트 로그에서 UserLogin, UserLogout, Purchase 등 여러 종류의 이벤트를 하나의 배열로 받아야 할 수 있습니다. JSON에서는 type 필드로 구분하지만, Rust에서는 타입 안전하게 표현하고 싶습니다.
바로 이럴 때 필요한 것이 Rust의 Enum과 Serde의 태그 전략입니다. #[serde(tag = "type")] 같은 속성으로 JSON의 구분 필드를 Enum variant에 자동으로 매핑할 수 있습니다.
개요
간단히 말해서, Serde의 Enum 직렬화는 여러 태그 전략(externally tagged, internally tagged, adjacently tagged, untagged)을 제공하여 다양한 JSON 형식을 타입 안전한 Rust Enum으로 표현할 수 있게 합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 실제 API는 다형성 데이터를 자주 사용합니다.
GitHub API의 이벤트, Stripe의 웹훅, AWS의 SNS 메시지 등 모두 type 필드로 메시지 종류를 구분합니다. 예를 들어, 결제 시스템에서 Payment 이벤트는 CreditCard, BankTransfer, PayPal 등 여러 결제 수단을 구분해야 하는데, 각 수단마다 필요한 필드가 다릅니다.
기존에는 모든 필드를 Optional로 만들거나 별도의 타입 체크 로직을 작성했다면, 이제는 Enum으로 컴파일 타임에 타입 안전성을 보장할 수 있습니다. Serde Enum의 핵심 특징은 패턴 매칭과의 완벽한 통합입니다.
Enum variant에 따라 match 표현식으로 타입 안전하게 처리할 수 있고, 컴파일러가 모든 경우를 처리했는지 검증합니다. 또한 다양한 태그 전략을 지원하여 기존 API 스펙에 맞출 수 있습니다.
이러한 특징들이 런타임 타입 체크 없이도 안전한 코드를 작성할 수 있게 합니다.
코드 예제
use serde::{Deserialize, Serialize};
// Internally tagged: type 필드로 variant 구분
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
enum Event {
#[serde(rename = "user_login")]
UserLogin { username: String, ip: String },
#[serde(rename = "purchase")]
Purchase { product_id: u64, amount: f64, currency: String },
#[serde(rename = "error")]
Error { code: u16, message: String },
}
// Adjacently tagged: type과 content 필드로 분리
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "status", content = "data")]
enum ApiResult {
Success(String),
Failure { error_code: u16, details: String },
}
fn main() {
// Internally tagged 역직렬화
let json1 = r#"{"type":"user_login","username":"alice","ip":"192.168.1.1"}"#;
let event: Event = serde_json::from_str(json1).unwrap();
match event {
Event::UserLogin { username, ip } => println!("Login: {} from {}", username, ip),
Event::Purchase { product_id, amount, currency } => println!("Purchase: {} {}", amount, currency),
Event::Error { code, message } => eprintln!("Error {}: {}", code, message),
}
// Adjacently tagged 직렬화
let result = ApiResult::Failure { error_code: 404, details: "Not found".to_string() };
let json2 = serde_json::to_string_pretty(&result).unwrap();
println!("\n{}", json2);
}
설명
이것이 하는 일: Serde는 JSON의 구분 필드(예: "type": "user_login")를 분석하여 적절한 Enum variant를 선택하고, 나머지 필드를 해당 variant의 데이터로 파싱합니다. 이를 통해 런타임 타입 체크 없이 컴파일 타임에 모든 경우를 안전하게 처리할 수 있습니다.
첫 번째로, #[serde(tag = "type")] 속성은 JSON의 "type" 필드를 보고 어떤 variant인지 결정하라고 Serde에 지시합니다. 왜 이렇게 하는지 이유는 이것이 가장 흔한 API 패턴이기 때문입니다.
많은 API가 {"type": "event_name", ...other_fields} 형식을 사용합니다. Serde는 "user_login" 값을 보고 Event::UserLogin variant를 선택한 후, 나머지 필드(username, ip)를 해당 variant의 필드에 매핑합니다.
그 다음으로, #[serde(tag = "status", content = "data")]로 adjacently tagged 전략을 사용하면 JSON이 {"status": "Success", "data": "OK"}처럼 태그와 내용이 분리된 형태로 직렬화됩니다. 내부에서는 variant 이름이 status 필드에, variant의 데이터가 data 필드에 들어갑니다.
이는 일부 API 스펙이 요구하는 형식입니다. 세 번째로, match 표현식에서 Enum을 패턴 매칭하면 컴파일러가 모든 variant를 처리했는지 검사합니다.
만약 Event::Error 케이스를 빼먹으면 컴파일 에러가 발생합니다. 이는 JavaScript 같은 동적 언어에서는 불가능한 강력한 안전성 보장입니다.
새로운 이벤트 타입이 추가되면 모든 match 표현식을 수정해야 하므로, 처리 빠뜨림을 방지합니다. 여러분이 이 코드를 사용하면 복잡한 타입 체크 로직 없이도 다형성 데이터를 안전하게 다룰 수 있습니다.
API에서 받은 이벤트를 처리할 때, 타입 필드를 문자열로 비교하는 대신 Enum 패턴 매칭을 사용하여 타입 오류를 컴파일 타임에 잡아낼 수 있습니다. 또한 각 variant는 필요한 필드만 가지므로 불필요한 Optional 필드가 없어 코드가 깔끔합니다.
리팩토링 시에도 컴파일러가 모든 사용처를 찾아주므로 안전합니다.
실전 팁
💡 #[serde(untagged)]를 사용하면 태그 필드 없이 내용만으로 variant를 추론하지만, 모호한 경우 에러가 발생할 수 있으니 주의하세요.
💡 Externally tagged(기본값)는 {"UserLogin": {"username": "alice"}}처럼 variant 이름이 키가 되는 형식으로, Rust 네이티브 표현입니다.
💡 복잡한 Enum은 serde_json::Value로 먼저 역직렬화한 후 디버깅하여 JSON 구조를 파악하세요.
💡 Enum의 variant가 많으면 매칭 성능이 걱정될 수 있지만, Serde는 해시맵을 사용하여 O(1) 시간에 variant를 찾으므로 성능 걱정은 불필요합니다.
💡 Unknown variant를 허용하려면 #[serde(other)]로 표시된 catch-all variant를 추가하여 예상치 못한 타입도 처리할 수 있습니다.