🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Rust Serde 직렬화 역직렬화 완벽가이드 - 슬라이드 1/11
A

AI Generated

2025. 10. 30. · 15 Views

Rust Serde 직렬화 역직렬화 완벽 가이드

Rust에서 데이터를 JSON, YAML, TOML 등 다양한 형식으로 변환하는 Serde 라이브러리의 핵심 개념과 실무 활용법을 초급자 눈높이에서 상세히 설명합니다. 실전 예제와 함께 직렬화/역직렬화의 기본부터 고급 기능까지 단계별로 학습할 수 있습니다.


목차

  1. Serde_기본_개념
  2. Serialize_트레이트
  3. Deserialize_트레이트
  4. derive_매크로
  5. 필드_속성_활용
  6. 중첩_구조체_직렬화
  7. Enum_직렬화
  8. 에러_처리_패턴
  9. 커스텀_직렬화

1. Serde 기본 개념

시작하며

여러분이 Rust로 웹 API를 개발하거나 설정 파일을 읽어야 할 때, 이런 고민을 해본 적 있나요? "이 구조체를 어떻게 JSON으로 바꾸지?", "API 응답을 어떻게 Rust 타입으로 변환하지?" 같은 질문 말이죠.

다른 언어에서는 리플렉션을 사용하거나 수동으로 파싱 코드를 작성해야 합니다. 하지만 Rust에는 이런 문제를 우아하게 해결하는 Serde 라이브러리가 있습니다.

Serde는 "Serialization + Deserialization"의 줄임말로, Rust의 타입 시스템과 완벽하게 통합되어 안전하고 빠른 데이터 변환을 제공합니다. 이 가이드에서 여러분은 실무에서 바로 사용할 수 있는 Serde의 모든 것을 배우게 됩니다.

개요

간단히 말해서, Serde는 Rust의 데이터 구조를 다양한 형식(JSON, YAML, TOML, Bincode 등)으로 변환하고, 반대로 그 형식들을 Rust 타입으로 되돌리는 프레임워크입니다. 실무에서 이것이 왜 중요할까요?

웹 API를 만들 때 HTTP 응답을 JSON으로 보내야 하고, 데이터베이스나 파일에서 읽은 데이터를 구조체로 변환해야 하며, 마이크로서비스 간 통신에서 데이터를 주고받아야 합니다. Serde는 이 모든 상황에서 타입 안전성을 보장하면서도 최소한의 코드로 해결책을 제공합니다.

기존 방식과 비교해볼까요? 예전에는 수동으로 to_json() 메서드를 작성하고 각 필드를 일일이 매핑했다면, 이제는 #[derive(Serialize)] 한 줄로 모든 것이 자동으로 처리됩니다.

Serde의 핵심 특징은 세 가지입니다. 첫째, 제로 비용 추상화로 런타임 오버헤드가 거의 없습니다.

둘째, 컴파일 타임에 타입 검사가 이루어져 런타임 에러를 방지합니다. 셋째, 포맷에 독립적인 설계로 한 번 작성하면 여러 형식에 사용할 수 있습니다.

이러한 특징들은 Rust의 성능과 안전성 철학을 그대로 계승합니다.

코드 예제

use serde::{Serialize, Deserialize};

// 사용자 정보를 담는 구조체
#[derive(Serialize, Deserialize, Debug)]
struct User {
    id: u32,
    name: String,
    email: String,
    active: bool,
}

fn main() {
    // Rust 구조체 생성
    let user = User {
        id: 1,
        name: "김철수".to_string(),
        email: "chulsoo@example.com".to_string(),
        active: true,
    };

    // 직렬화: 구조체 -> JSON 문자열
    let json = serde_json::to_string(&user).unwrap();
    println!("JSON: {}", json);

    // 역직렬화: JSON 문자열 -> 구조체
    let user2: User = serde_json::from_str(&json).unwrap();
    println!("User: {:?}", user2);
}

설명

이것이 하는 일: 위 코드는 Rust의 구조체를 JSON으로 변환하고, 다시 JSON을 구조체로 복원하는 전체 과정을 보여줍니다. 이것이 바로 Serde의 핵심 기능인 직렬화와 역직렬화입니다.

첫 번째로, #[derive(Serialize, Deserialize)]를 구조체에 붙이면 컴파일러가 자동으로 필요한 코드를 생성합니다. 이 매크로는 각 필드의 타입을 분석하고, 그에 맞는 직렬화/역직렬화 로직을 만들어냅니다.

이 과정은 컴파일 타임에 일어나므로 런타임 성능에 영향을 주지 않습니다. 두 번째로, serde_json::to_string(&user)가 실행되면 User 구조체의 각 필드를 순회하면서 JSON 형식의 문자열로 변환합니다.

id는 숫자로, name과 email은 문자열로, active는 boolean으로 매핑됩니다. 이때 Rust의 소유권 시스템 덕분에 안전하게 데이터를 빌려올 수 있습니다.

세 번째로, serde_json::from_str(&json)은 반대 작업을 수행합니다. JSON 문자열을 파싱하여 User 타입의 새로운 인스턴스를 만들어냅니다.

만약 JSON 형식이 잘못되었거나 필드가 누락되면 컴파일러가 미리 알려주거나, 런타임에 명확한 에러 메시지를 반환합니다. 여러분이 이 코드를 사용하면 API 개발, 설정 파일 관리, 데이터 저장 등에서 타입 안전성을 유지하면서도 간결한 코드를 작성할 수 있습니다.

수동 파싱에 비해 버그가 줄어들고, 코드 유지보수가 쉬워지며, 새로운 필드를 추가할 때도 파싱 로직을 수정할 필요가 없습니다.

실전 팁

💡 Cargo.toml에 serde를 추가할 때는 features = ["derive"]를 꼭 포함하세요. 이것이 없으면 derive 매크로를 사용할 수 없습니다.

💡 JSON 외에도 serde_yaml, toml, bincode 등 다양한 포맷 라이브러리를 사용할 수 있으며, 코드는 거의 동일합니다. 필요에 따라 포맷만 바꾸면 됩니다.

💡 실무에서는 unwrap() 대신 Result 타입을 제대로 처리하세요. 직렬화는 거의 실패하지 않지만, 역직렬화는 잘못된 입력으로 실패할 수 있습니다.

💡 큰 데이터를 다룰 때는 to_string 대신 to_writer를 사용하여 파일이나 네트워크 스트림에 직접 쓰면 메모리 효율이 좋습니다.


2. Serialize 트레이트

시작하며

여러분이 복잡한 API 응답을 만들어야 할 때, 각 필드를 수동으로 JSON 문자열로 조합하는 것은 지루하고 오류가 발생하기 쉽습니다. "이 구조체를 자동으로 JSON으로 바꿀 수 있다면 얼마나 좋을까?" 하는 생각이 드실 겁니다.

Serialize 트레이트는 바로 이 문제를 해결합니다. Rust의 어떤 타입이든 Serialize를 구현하면 자동으로 직렬화 가능한 타입이 됩니다.

이 개념을 이해하면 여러분의 커스텀 타입을 네트워크로 전송하거나, 파일에 저장하거나, 캐시에 넣는 등 모든 상황에서 자유롭게 활용할 수 있게 됩니다.

개요

간단히 말해서, Serialize는 "이 타입은 직렬화 가능하다"는 것을 컴파일러에게 알려주는 트레이트입니다. 이것을 구현한 타입은 모든 Serde 포맷에서 사용할 수 있습니다.

실무에서 이것이 중요한 이유는 타입 안전성 때문입니다. 예를 들어, REST API에서 응답 객체를 만들 때 Serialize를 구현하면 컴파일 타임에 모든 필드가 직렬화 가능한지 검증됩니다.

잘못된 타입을 사용하면 컴파일 자체가 되지 않아서, 런타임 에러를 미연에 방지할 수 있습니다. 수동 구현과 비교하면 차이가 명확합니다.

기존에는 각 필드마다 format! 매크로로 문자열을 조합했다면, 이제는 derive 매크로 한 줄로 끝납니다. 코드량이 10분의 1로 줄어들면서도 더 안전합니다.

Serialize의 핵심 특징은 재귀적 구현입니다. 구조체의 필드들도 Serialize를 구현하고 있으면, 전체 구조체가 자동으로 직렬화됩니다.

또한 제네릭 타입과도 완벽하게 작동하여 Vec<T>, Option<T>, HashMap<K, V> 같은 컬렉션도 자연스럽게 처리됩니다. 이러한 조합성이 Serde의 강력함을 만들어냅니다.

코드 예제

use serde::Serialize;
use serde_json;

// 상품 정보 구조체
#[derive(Serialize, Debug)]
struct Product {
    id: u32,
    name: String,
    price: f64,
    in_stock: bool,
    tags: Vec<String>,
}

// API 응답 구조체 (중첩 구조)
#[derive(Serialize)]
struct ApiResponse {
    success: bool,
    message: String,
    data: Product,
}

fn main() {
    let product = Product {
        id: 101,
        name: "Rust Programming Book".to_string(),
        price: 39.99,
        in_stock: true,
        tags: vec!["programming".to_string(), "rust".to_string()],
    };

    let response = ApiResponse {
        success: true,
        message: "Product fetched successfully".to_string(),
        data: product,
    };

    // 예쁘게 포맷된 JSON 출력
    let json = serde_json::to_string_pretty(&response).unwrap();
    println!("{}", json);
}

설명

이것이 하는 일: 위 코드는 중첩된 구조체를 포함한 복잡한 API 응답을 JSON으로 변환하는 실제 시나리오를 보여줍니다. Serialize 트레이트가 어떻게 재귀적으로 작동하는지 이해할 수 있습니다.

첫 번째로, Product 구조체에 #[derive(Serialize)]를 붙이면 모든 필드가 직렬화 가능한지 컴파일러가 확인합니다. u32, String, f64, bool, Vec<String> 모두 이미 Serialize를 구현하고 있으므로 자동으로 전체 구조체가 직렬화 가능해집니다.

만약 직렬화 불가능한 타입이 필드에 있다면 컴파일 에러가 발생합니다. 두 번째로, ApiResponse는 Product를 필드로 포함합니다.

이것도 문제없이 작동하는 이유는 Product가 이미 Serialize를 구현했기 때문입니다. 이렇게 Serde는 타입의 계층 구조를 따라 자동으로 직렬화를 전파합니다.

여러분은 각 레벨에서 derive만 붙이면 됩니다. 세 번째로, to_string_pretty는 사람이 읽기 좋게 들여쓰기된 JSON을 생성합니다.

실제 프로덕션에서는 to_string으로 공백 없는 컴팩트한 JSON을 만들어 네트워크 대역폭을 절약하고, 개발 중에는 pretty 버전으로 디버깅을 쉽게 할 수 있습니다. 여러분이 이 패턴을 사용하면 API 개발이 획기적으로 간단해집니다.

구조체를 정의하고 derive만 붙이면 자동으로 JSON 응답이 생성되므로, 비즈니스 로직에만 집중할 수 있습니다. 타입을 변경해도 직렬화 코드를 수정할 필요가 없고, 새 필드를 추가하면 자동으로 JSON에 포함됩니다.

실전 팁

💡 to_string_pretty는 개발/디버깅용이고, 프로덕션에서는 to_string이나 to_vec를 사용하여 크기를 최소화하세요.

💡 큰 컬렉션을 직렬화할 때는 메모리 사용량을 고려하여 스트리밍 API(to_writer)를 사용하는 것이 좋습니다.

💡 Option<T> 필드는 None일 때 JSON에서 null로 표현됩니다. 이 동작을 변경하려면 skip_serializing_if 속성을 사용하세요.

💡 순환 참조가 있는 구조체는 직렬화할 수 없습니다. 이런 경우 Rc나 Arc 대신 ID를 사용하는 설계로 변경하세요.

💡 제네릭 구조체도 derive가 작동합니다. struct Wrapper<T: Serialize>처럼 트레이트 바운드만 명시하면 됩니다.


3. Deserialize 트레이트

시작하며

여러분이 외부 API에서 JSON 데이터를 받았을 때, 문자열 파싱 코드를 직접 작성하다 보면 에러 처리가 복잡해지고 코드가 지저분해집니다. "이 JSON을 안전하게 Rust 타입으로 변환할 수 있다면?" 하는 고민이 생깁니다.

Deserialize 트레이트는 외부 데이터를 Rust의 강타입 시스템으로 가져오는 다리 역할을 합니다. 잘못된 형식의 데이터는 자동으로 거부되고, 올바른 데이터만 타입 안전하게 변환됩니다.

이것을 마스터하면 API 클라이언트, 설정 파일 로더, 데이터 파서 등을 매우 안정적으로 구현할 수 있습니다.

개요

간단히 말해서, Deserialize는 "이 타입으로 외부 데이터를 안전하게 변환할 수 있다"는 것을 컴파일러에게 알려주는 트레이트입니다. 직렬화의 정확한 반대 작업을 수행합니다.

실무에서 이것이 중요한 이유는 데이터 검증 때문입니다. 예를 들어, HTTP 요청의 JSON 바디를 파싱할 때 Deserialize를 사용하면 필수 필드 누락, 타입 불일치, 잘못된 형식 등을 자동으로 감지합니다.

수동 파싱에서 발생하는 수많은 if-else 검증 코드가 필요 없어집니다. 기존 방식과 비교하면, 예전에는 JSON 파서로 값을 추출하고 각 필드의 타입을 확인하고 변환하는 코드를 일일이 작성했습니다.

이제는 대상 타입만 정의하면 모든 검증과 변환이 자동으로 처리됩니다. Deserialize의 핵심 특징은 강력한 타입 검증입니다.

JSON에서 숫자가 와야 할 곳에 문자열이 오면 즉시 에러를 반환합니다. 또한 누락된 필드, 추가 필드, 잘못된 enum 값 등도 명확한 에러 메시지와 함께 처리됩니다.

이러한 자동 검증이 런타임 버그를 크게 줄여줍니다.

코드 예제

use serde::Deserialize;
use serde_json;

// API에서 받을 데이터 구조
#[derive(Deserialize, Debug)]
struct ApiUser {
    id: u32,
    username: String,
    email: String,
    age: Option<u32>, // 선택적 필드
}

fn main() {
    // 외부 API에서 받은 JSON 문자열 (시뮬레이션)
    let json_data = r#"
        {
            "id": 42,
            "username": "rustacean",
            "email": "rust@example.com",
            "age": 25
        }
    "#;

    // JSON -> Rust 구조체로 안전하게 변환
    match serde_json::from_str::<ApiUser>(json_data) {
        Ok(user) => {
            println!("User ID: {}", user.id);
            println!("Username: {}", user.username);
            println!("Age: {:?}", user.age);
        }
        Err(e) => {
            eprintln!("Failed to parse JSON: {}", e);
        }
    }

    // age 필드가 없는 경우도 처리 가능
    let json_without_age = r#"{"id": 43, "username": "newbie", "email": "new@example.com"}"#;
    let user2: ApiUser = serde_json::from_str(json_without_age).unwrap();
    println!("User without age: {:?}", user2);
}

설명

이것이 하는 일: 위 코드는 외부 소스(API, 파일 등)에서 받은 JSON 데이터를 Rust 구조체로 변환하면서, 다양한 에러 상황을 안전하게 처리하는 방법을 보여줍니다. 첫 번째로, ApiUser 구조체의 age: Option<u32> 필드는 선택적 데이터를 표현합니다.

JSON에 age가 없어도 에러가 발생하지 않고 None으로 설정됩니다. 반면 id, username, email은 필수 필드이므로 누락되면 역직렬화가 실패합니다.

이렇게 Rust의 타입 시스템이 데이터 계약을 강제합니다. 두 번째로, from_str 함수는 Result<ApiUser, Error>를 반환합니다.

이것은 파싱이 실패할 수 있다는 것을 타입 레벨에서 명시합니다. match 표현식으로 성공과 실패를 각각 처리하면서도, 에러 메시지는 어떤 필드에서 어떤 문제가 발생했는지 정확히 알려줍니다.

"expected u32 at line 3 column 20" 같은 구체적인 정보를 얻을 수 있습니다. 세 번째로, 두 번째 예제는 age 필드가 없는 JSON을 파싱합니다.

Option 타입 덕분에 이것도 정상적으로 처리되며, user.age는 None이 됩니다. 이렇게 API 버전 호환성 문제를 우아하게 해결할 수 있습니다.

여러분이 이 패턴을 사용하면 외부 데이터를 다룰 때 방어적 프로그래밍을 자연스럽게 하게 됩니다. 타입 시스템이 가능한 모든 에러 케이스를 컴파일 타임에 검토하도록 강제하므로, 예상치 못한 런타임 패닉이 사라집니다.

API 스펙이 변경되어도 컴파일러가 즉시 알려주므로 유지보수도 쉽습니다.

실전 팁

💡 외부 API 데이터는 항상 Result 타입으로 처리하세요. unwrap()은 테스트 코드에서만 사용하고, 실제 코드에서는 match나 ?로 에러를 전파하세요.

💡 API 응답이 null을 포함할 수 있다면 필드를 Option<T>로 만드세요. 그렇지 않으면 역직렬화가 실패합니다.

💡 JSON 스키마가 자주 변한다면 #[serde(deny_unknown_fields)] 속성으로 예상치 못한 필드를 감지할 수 있습니다.

💡 큰 JSON 파일을 읽을 때는 from_reader를 사용하여 스트리밍 방식으로 파싱하면 메모리 효율이 좋습니다.

💡 에러 메시지를 사용자에게 직접 보여주지 말고, 로그에 기록하고 일반적인 에러 응답을 반환하세요. 보안상 내부 구조를 노출하지 않는 것이 중요합니다.


4. derive 매크로

시작하며

여러분이 수십 개의 구조체를 직렬화해야 한다면, 각각에 대해 Serialize와 Deserialize를 수동으로 구현하는 것은 상상만 해도 끔찍합니다. "이 반복적인 작업을 자동화할 수 없을까?" 하는 생각이 자연스럽게 듭니다.

derive 매크로는 Rust의 메타프로그래밍 기능을 활용하여 컴파일 타임에 필요한 코드를 자동 생성합니다. 이것은 Serde를 실용적으로 만드는 핵심 기능입니다.

이 매크로를 이해하면 보일러플레이트 코드 없이 수백 개의 타입을 순식간에 직렬화 가능하게 만들 수 있습니다.

개요

간단히 말해서, #[derive(Serialize, Deserialize)]는 "이 타입에 대한 직렬화/역직렬화 코드를 자동으로 생성해줘"라고 컴파일러에게 요청하는 것입니다. 실무에서 이것이 중요한 이유는 생산성과 유지보수성 때문입니다.

예를 들어, 데이터베이스 모델을 50개 정의해야 할 때 derive를 사용하면 각 모델에 한 줄만 추가하면 됩니다. 수동 구현이라면 각 모델마다 수십 줄의 코드를 작성하고 테스트해야 합니다.

필드를 추가하거나 변경할 때도 derive는 자동으로 갱신되지만, 수동 구현은 일일이 수정해야 합니다. 기존 수동 구현과 비교하면 코드량이 100배 이상 차이납니다.

수동으로 Serialize를 구현하려면 Serializer 트레이트의 메서드들을 직접 호출하고 각 필드를 순회하는 복잡한 코드를 작성해야 합니다. derive는 이 모든 것을 자동화하면서도 최적화된 코드를 생성합니다.

derive 매크로의 핵심 특징은 컴파일 타임 실행입니다. 런타임 성능에 전혀 영향을 주지 않으면서도 타입 정보를 활용하여 최적의 코드를 생성합니다.

또한 조건부 컴파일과도 호환되어 #[cfg(feature = "...")] 같은 속성과 함께 사용할 수 있습니다. 이러한 유연성이 대규모 프로젝트에서도 Serde를 사용할 수 있게 합니다.

코드 예제

use serde::{Serialize, Deserialize};

// 복잡한 중첩 구조도 derive 한 줄로 해결
#[derive(Serialize, Deserialize, Debug, Clone)]
struct Company {
    name: String,
    employees: Vec<Employee>,
    metadata: Metadata,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct Employee {
    id: u32,
    name: String,
    position: Position,
    salary: f64,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
enum Position {
    Developer,
    Designer,
    Manager,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct Metadata {
    created_at: String,
    last_updated: String,
}

fn main() {
    let company = Company {
        name: "Tech Corp".to_string(),
        employees: vec![
            Employee {
                id: 1,
                name: "Alice".to_string(),
                position: Position::Developer,
                salary: 80000.0,
            },
            Employee {
                id: 2,
                name: "Bob".to_string(),
                position: Position::Manager,
                salary: 95000.0,
            },
        ],
        metadata: Metadata {
            created_at: "2024-01-01".to_string(),
            last_updated: "2024-06-15".to_string(),
        },
    };

    // 모든 중첩 구조가 자동으로 직렬화됨
    let json = serde_json::to_string_pretty(&company).unwrap();
    println!("{}", json);

    // 역직렬화도 자동
    let parsed: Company = serde_json::from_str(&json).unwrap();
    println!("Parsed company: {}", parsed.name);
}

설명

이것이 하는 일: 위 코드는 derive 매크로가 얼마나 강력한지 보여줍니다. 4개의 타입(구조체 3개, enum 1개)이 서로 중첩되어 있지만, 각각 derive 한 줄만으로 모두 직렬화 가능해집니다.

첫 번째로, Company 구조체는 Vec<Employee>와 Metadata를 포함합니다. derive 매크로는 이 관계를 분석하여 Employee와 Metadata도 Serialize를 구현하고 있는지 컴파일 타임에 확인합니다.

만약 하나라도 구현하지 않았다면 컴파일 에러가 발생합니다. 이 검증 덕분에 런타임 에러가 불가능합니다.

두 번째로, Position enum도 derive로 자동 처리됩니다. enum의 각 variant는 JSON에서 문자열로 표현됩니다.

"Developer", "Designer", "Manager"가 그대로 JSON에 나타납니다. 만약 수동으로 구현했다면 각 variant를 매칭하고 문자열로 변환하는 코드를 작성해야 했을 것입니다.

세 번째로, Debug와 Clone도 함께 derive했습니다. Rust에서는 여러 트레이트를 동시에 derive할 수 있으며, 이것들은 독립적으로 작동합니다.

실무에서는 보통 직렬화 가능한 타입에 Debug를 함께 붙여서 디버깅을 쉽게 합니다. 여러분이 이 패턴을 사용하면 도메인 모델을 정의하는 데만 집중할 수 있습니다.

직렬화 로직은 컴파일러가 알아서 생성하므로, 비즈니스 요구사항이 변경되어도 구조체 정의만 수정하면 됩니다. 팀원들도 코드를 이해하기 쉽고, 새로운 타입을 추가하는 것도 부담 없습니다.

실전 팁

💡 derive에 Debug, Clone, PartialEq 등을 함께 붙이면 편리합니다. 특히 Debug는 에러 디버깅에 필수적입니다.

💡 제네릭 타입에 derive를 사용할 때는 트레이트 바운드를 명시하세요. struct Wrapper<T: Serialize + Deserialize>처럼요.

💡 큰 프로젝트에서는 derive 컴파일 시간이 길어질 수 있습니다. 자주 변경되지 않는 타입은 별도 크레이트로 분리하면 빌드 시간을 단축할 수 있습니다.

💡 derive가 생성한 코드를 보고 싶다면 cargo expand 명령어를 사용하세요. 학습 목적으로 유용합니다.

💡 일부 필드만 커스터마이징이 필요하다면 derive를 사용하면서 #[serde(...)] 속성으로 개별 필드를 제어할 수 있습니다.


5. 필드 속성 활용

시작하며

여러분이 외부 API와 통신할 때 이런 문제를 겪어본 적 있나요? API는 "user_name"이라는 필드를 사용하는데 Rust 컨벤션은 "username"이라서 이름이 맞지 않거나, 일부 필드는 저장하고 싶지 않은데 자동으로 포함되는 상황 말이죠.

Serde는 이런 불일치를 우아하게 해결하는 필드 속성 시스템을 제공합니다. rename, skip, default 등의 속성으로 세밀하게 제어할 수 있습니다.

이것을 마스터하면 외부 시스템의 네이밍 규칙에 맞추면서도 Rust 코드는 관습을 따르는, 양쪽 모두를 만족시키는 코드를 작성할 수 있습니다.

개요

간단히 말해서, 필드 속성은 각 필드가 어떻게 직렬화/역직렬화될지 세밀하게 조정하는 메타데이터입니다. #[serde(...)] 형식으로 구조체 필드 위에 붙입니다.

실무에서 이것이 중요한 이유는 레거시 시스템과의 통합 때문입니다. 예를 들어, 오래된 API가 camelCase를 사용하는데 Rust는 snake_case를 쓰는 경우, rename 속성으로 자동 변환할 수 있습니다.

민감한 정보가 담긴 필드는 skip으로 직렬화에서 제외할 수 있습니다. API가 기본값을 제공하지 않는 선택적 필드는 default 속성으로 안전하게 처리합니다.

수동 처리와 비교하면 차이가 명확합니다. 기존에는 중간 구조체를 만들어 변환 로직을 작성했다면, 이제는 속성 한 줄로 끝납니다.

코드가 선언적이 되어 의도가 명확하고 유지보수도 쉽습니다. 필드 속성의 핵심 특징은 조합 가능성입니다.

하나의 필드에 여러 속성을 동시에 적용할 수 있고, 각 속성은 독립적으로 작동합니다. 또한 타입 레벨과 필드 레벨 속성을 모두 지원하여 전역 설정과 개별 설정을 함께 사용할 수 있습니다.

이러한 유연성이 복잡한 실무 요구사항을 충족시킵니다.

코드 예제

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct UserProfile {
    // API는 "userId"를 사용하지만 Rust는 snake_case 선호
    #[serde(rename = "userId")]
    user_id: u32,

    // 직렬화에서는 제외하지만 역직렬화는 가능
    #[serde(skip_serializing)]
    password_hash: String,

    // 완전히 무시 (직렬화/역직렬화 모두)
    #[serde(skip)]
    internal_cache: Option<String>,

    // JSON에 없으면 기본값 사용
    #[serde(default)]
    is_active: bool,

    // 커스텀 기본값 함수
    #[serde(default = "default_role")]
    role: String,

    // None이면 JSON에 필드 자체를 제외
    #[serde(skip_serializing_if = "Option::is_none")]
    nickname: Option<String>,
}

// 커스텀 기본값 제공 함수
fn default_role() -> String {
    "user".to_string()
}

fn main() {
    let user = UserProfile {
        user_id: 123,
        password_hash: "secret_hash".to_string(),
        internal_cache: Some("cached_data".to_string()),
        is_active: true,
        role: "admin".to_string(),
        nickname: None,
    };

    // 직렬화: password와 cache는 제외됨
    let json = serde_json::to_string_pretty(&user).unwrap();
    println!("Serialized:\n{}\n", json);

    // 역직렬화: 누락된 필드는 기본값 사용
    let json_input = r#"{"userId": 456}"#;
    let parsed: UserProfile = serde_json::from_str(json_input).unwrap();
    println!("Parsed user role: {}", parsed.role); // "user" (기본값)
}

설명

이것이 하는 일: 위 코드는 실무에서 자주 발생하는 다양한 필드 처리 시나리오를 하나의 구조체에서 보여줍니다. 각 속성이 어떻게 작동하는지 구체적으로 이해할 수 있습니다.

첫 번째로, rename 속성은 Rust 필드 이름과 JSON 키 이름을 분리합니다. user_id는 코드에서 snake_case로 사용되지만 JSON에서는 "userId"로 나타납니다.

이렇게 Rust 컨벤션을 지키면서도 외부 API 스펙을 따를 수 있습니다. 여러 필드에 일관된 규칙을 적용하려면 구조체 레벨에서 #[serde(rename_all = "camelCase")]를 사용할 수도 있습니다.

두 번째로, skip_serializingskip의 차이를 이해해야 합니다. password_hash는 역직렬화는 되지만 직렬화는 안 되므로, 데이터베이스에서 읽을 수는 있지만 API 응답에는 포함되지 않습니다.

internal_cache는 완전히 무시되어 Serde가 전혀 관여하지 않습니다. 이런 구분으로 보안과 성능을 동시에 관리합니다.

세 번째로, defaultskip_serializing_if는 선택적 필드를 우아하게 처리합니다. is_active는 JSON에 없으면 false가 되고, role은 커스텀 함수로 "user"가 됩니다.

nickname이 None이면 JSON 필드 자체가 생략되어 불필요한 null을 줄입니다. 이렇게 API 응답을 간결하게 유지하면서도 타입 안전성을 보장합니다.

여러분이 이 패턴을 사용하면 복잡한 데이터 변환 로직 없이도 다양한 외부 시스템과 통합할 수 있습니다. 코드는 Rust다우면서도 외부 인터페이스는 표준을 따르므로, 팀의 생산성과 시스템 호환성을 모두 높입니다.

실전 팁

💡 rename_all = "camelCase"를 구조체 레벨에 적용하면 모든 필드에 일괄 적용됩니다. 개별 필드는 추가로 재정의할 수 있습니다.

💡 보안에 민감한 필드(비밀번호, 토큰 등)는 반드시 skip_serializing으로 로그나 응답에 노출되지 않도록 하세요.

💡 skip_serializing_if로 빈 컬렉션이나 None을 제외하면 JSON 크기를 크게 줄일 수 있습니다. skip_serializing_if = "Vec::is_empty" 같은 패턴을 활용하세요.

💡 default 함수는 인자가 없고 해당 타입을 반환해야 합니다. 복잡한 초기화 로직이 필요하면 별도 함수로 분리하세요.

💡 여러 API 버전을 지원해야 한다면 alias 속성으로 여러 이름을 동시에 인식할 수 있습니다.


6. 중첩 구조체 직렬화

시작하며

여러분이 복잡한 도메인 모델을 설계할 때, 단순한 flat 구조로는 표현이 어렵습니다. 예를 들어, 주문 시스템에서 Order는 Customer를 포함하고, Customer는 Address를 포함하는 식의 계층 구조가 필요합니다.

Serde는 이런 중첩된 구조를 자연스럽게 처리합니다. 각 레벨에서 derive만 붙이면 전체 계층이 자동으로 직렬화 가능해집니다.

이것을 이해하면 실제 비즈니스 로직을 있는 그대로 모델링하면서도, JSON 변환에 대한 걱정 없이 설계에 집중할 수 있습니다.

개요

간단히 말해서, 중첩 구조체는 한 구조체가 다른 구조체를 필드로 포함하는 것이며, Serde는 이를 재귀적으로 처리합니다. 각 타입이 Serialize/Deserialize를 구현하면 자동으로 전체가 작동합니다.

실무에서 이것이 중요한 이유는 도메인 모델의 표현력 때문입니다. 예를 들어, 전자상거래 시스템에서 주문(Order)은 고객(Customer), 배송지(Address), 상품 목록(Vec<Item>)을 포함합니다.

이런 계층 구조를 flat하게 펼치면 의미가 불명확해지고 유지보수가 어려워집니다. Serde는 자연스러운 객체 모델을 그대로 JSON으로 변환할 수 있게 해줍니다.

수동 처리와 비교하면, 중첩 구조를 직접 직렬화하려면 각 레벨을 순회하는 복잡한 코드가 필요합니다. Serde는 컴파일러가 타입 관계를 분석하여 최적의 순회 코드를 자동 생성합니다.

중첩 직렬화의 핵심 특징은 타입 안전성의 전파입니다. 가장 깊은 레벨의 타입부터 최상위까지 모든 타입이 직렬화 가능한지 컴파일러가 검증합니다.

순환 참조는 컴파일 타임에 감지되고, 누락된 구현도 즉시 에러로 표시됩니다. 이러한 엄격함이 런타임 안정성을 보장합니다.

코드 예제

use serde::{Serialize, Deserialize};

// 가장 깊은 레벨: 주소
#[derive(Serialize, Deserialize, Debug)]
struct Address {
    street: String,
    city: String,
    zipcode: String,
}

// 중간 레벨: 고객 (Address 포함)
#[derive(Serialize, Deserialize, Debug)]
struct Customer {
    id: u32,
    name: String,
    email: String,
    address: Address, // 중첩된 구조체
}

// 상품 정보
#[derive(Serialize, Deserialize, Debug)]
struct Item {
    product_id: u32,
    name: String,
    quantity: u32,
    price: f64,
}

// 최상위 레벨: 주문 (Customer와 Vec<Item> 포함)
#[derive(Serialize, Deserialize, Debug)]
struct Order {
    order_id: u32,
    customer: Customer, // 중첩된 구조체
    items: Vec<Item>,   // 중첩된 구조체의 컬렉션
    total: f64,
    status: String,
}

fn main() {
    let order = Order {
        order_id: 1001,
        customer: Customer {
            id: 42,
            name: "김민수".to_string(),
            email: "minsu@example.com".to_string(),
            address: Address {
                street: "강남대로 123".to_string(),
                city: "서울".to_string(),
                zipcode: "06241".to_string(),
            },
        },
        items: vec![
            Item {
                product_id: 501,
                name: "Rust Book".to_string(),
                quantity: 2,
                price: 39.99,
            },
            Item {
                product_id: 502,
                name: "Programming Socks".to_string(),
                quantity: 3,
                price: 12.99,
            },
        ],
        total: 118.95,
        status: "Processing".to_string(),
    };

    // 전체 계층 구조가 자동으로 JSON으로 변환됨
    let json = serde_json::to_string_pretty(&order).unwrap();
    println!("{}", json);

    // 역직렬화도 전체 계층을 복원
    let parsed: Order = serde_json::from_str(&json).unwrap();
    println!("\nOrder ID: {}", parsed.order_id);
    println!("Customer: {}", parsed.customer.name);
    println!("City: {}", parsed.customer.address.city);
}

설명

이것이 하는 일: 위 코드는 3단계 깊이의 중첩 구조(Order -> Customer -> Address)와 컬렉션(Vec<Item>)을 포함하는 실제 전자상거래 모델을 보여줍니다. Serde가 이런 복잡한 구조를 어떻게 투명하게 처리하는지 알 수 있습니다.

첫 번째로, Address는 가장 기본적인 구조체로 다른 타입에 의존하지 않습니다. 이것에 derive를 붙이면 독립적으로 직렬화 가능해집니다.

Customer는 Address를 필드로 가지므로, Customer에 derive를 붙일 때 컴파일러는 Address도 Serialize를 구현했는지 확인합니다. 이 확인은 컴파일 타임에 일어나므로 런타임 에러가 불가능합니다.

두 번째로, Order는 Customer와 Vec<Item>을 포함합니다. Vec는 표준 라이브러리가 이미 Serialize를 구현했으므로, Item만 derive하면 됩니다.

이렇게 Serde는 컬렉션 타입도 자연스럽게 지원하며, HashMap, HashSet, BTreeMap 등도 동일하게 작동합니다. 세 번째로, 실제 직렬화 시점에는 Order부터 시작하여 깊이 우선으로 각 필드를 순회합니다.

customer 필드를 만나면 Customer를 직렬화하고, 그 안의 address를 또 직렬화합니다. items는 반복문으로 각 Item을 순차 처리합니다.

이 모든 과정이 자동이며 성능도 최적화되어 있습니다. 여러분이 이 패턴을 사용하면 도메인을 자연스럽게 모델링할 수 있습니다.

"JSON 때문에 구조를 평평하게 만들어야 하나?" 같은 고민 없이, 비즈니스 로직에 가장 적합한 구조를 선택하면 됩니다. 타입 시스템이 정합성을 보장하므로 리팩토링도 안전합니다.

실전 팁

💡 중첩이 깊어질수록 JSON 크기가 커지므로, 꼭 필요한 데이터만 포함하세요. 불필요한 필드는 skip 속성으로 제외하세요.

💡 순환 참조(A가 B를 포함하고 B가 A를 포함)는 직렬화할 수 없습니다. 이런 경우 ID 참조로 변경하거나 Rc<RefCell<>>을 고려하세요.

💡 매우 깊은 중첩(10단계 이상)은 스택 오버플로우를 유발할 수 있습니다. 합리적인 깊이로 설계하세요.

💡 중첩 구조의 일부만 업데이트해야 한다면 각 레벨을 별도 API로 분리하는 것이 RESTful합니다.

💡 성능이 중요하다면 serde_json::to_writer로 스트리밍 직렬화를 사용하여 큰 중첩 구조도 효율적으로 처리하세요.


7. Enum 직렬화

시작하며

여러분이 상태 머신이나 다형성 데이터를 다룰 때, 여러 가능한 타입 중 하나를 표현해야 하는 상황이 자주 발생합니다. 예를 들어, 결제 방법은 신용카드, 계좌이체, 암호화폐 중 하나일 수 있습니다.

Rust의 enum은 이런 상황에 완벽하며, Serde는 enum을 다양한 방식으로 JSON에 매핑할 수 있습니다. 간단한 문자열부터 복잡한 태그된 유니언까지 선택할 수 있습니다.

이것을 마스터하면 타입 안전한 상태 관리와 유연한 API 설계를 동시에 달성할 수 있습니다.

개요

간단히 말해서, enum 직렬화는 Rust의 sum 타입을 JSON으로 표현하는 방법이며, Serde는 externally tagged, internally tagged, adjacently tagged, untagged 등 여러 전략을 제공합니다. 실무에서 이것이 중요한 이유는 API 유연성과 타입 안전성 때문입니다.

예를 들어, 알림 시스템에서 이메일, SMS, 푸시 알림은 각각 다른 필드를 가지지만 "알림"이라는 공통 개념으로 다룰 수 있습니다. enum으로 이것을 모델링하면 컴파일러가 모든 케이스를 처리했는지 검증하며, Serde는 이것을 명확한 JSON으로 변환합니다.

전통적인 방법과 비교하면, 다른 언어에서는 타입 필드와 여러 개의 optional 필드로 표현했습니다. 이것은 런타임에 타입을 확인하고 적절한 필드에 접근해야 하므로 에러가 발생하기 쉽습니다.

Rust의 enum은 컴파일 타임에 모든 것을 보장합니다. enum 직렬화의 핵심 특징은 다양한 표현 전략입니다.

기본값(externally tagged)은 variant 이름을 키로 사용하여 명확합니다. internally tagged는 타입 필드를 데이터 안에 포함하여 flat한 구조를 만듭니다.

untagged는 구조만으로 타입을 추론하여 가장 간결합니다. 각 전략은 트레이드오프가 있으므로 상황에 맞게 선택해야 합니다.

코드 예제

use serde::{Serialize, Deserialize};

// 기본 enum (externally tagged)
#[derive(Serialize, Deserialize, Debug)]
enum Status {
    Pending,
    InProgress,
    Completed,
}

// 데이터를 포함하는 enum
#[derive(Serialize, Deserialize, Debug)]
enum PaymentMethod {
    CreditCard {
        card_number: String,
        expiry: String,
    },
    BankTransfer {
        account_number: String,
        bank_code: String,
    },
    Crypto {
        wallet_address: String,
        currency: String,
    },
}

// internally tagged enum (타입 필드가 데이터 안에)
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
enum Notification {
    Email {
        to: String,
        subject: String,
    },
    SMS {
        phone: String,
        message: String,
    },
    Push {
        device_id: String,
        title: String,
    },
}

fn main() {
    // 간단한 enum
    let status = Status::InProgress;
    println!("Status: {}", serde_json::to_string(&status).unwrap());

    // 데이터 포함 enum
    let payment = PaymentMethod::CreditCard {
        card_number: "1234-5678-9012-3456".to_string(),
        expiry: "12/25".to_string(),
    };
    println!("Payment: {}", serde_json::to_string_pretty(&payment).unwrap());

    // internally tagged enum
    let notif = Notification::Email {
        to: "user@example.com".to_string(),
        subject: "Welcome!".to_string(),
    };
    println!("Notification: {}", serde_json::to_string_pretty(&notif).unwrap());

    // 역직렬화도 자동으로 올바른 variant를 선택
    let json = r#"{"type":"SMS","phone":"+82-10-1234-5678","message":"Hello"}"#;
    let parsed: Notification = serde_json::from_str(json).unwrap();
    println!("Parsed: {:?}", parsed);
}

설명

이것이 하는 일: 위 코드는 enum의 세 가지 다른 스타일을 보여줍니다. 각각이 JSON에서 어떻게 표현되고 어떤 상황에 적합한지 이해할 수 있습니다.

첫 번째로, Status는 데이터가 없는 순수한 enum입니다. 이것은 JSON에서 단순 문자열로 표현됩니다: "Pending", "InProgress", "Completed".

이런 스타일은 상태 플래그나 카테고리처럼 제한된 선택지를 표현할 때 가장 간결합니다. 두 번째로, PaymentMethod는 각 variant가 다른 필드를 가집니다.

기본 externally tagged 방식에서는 {"CreditCard": {"card_number": "...", "expiry": "..."}}처럼 variant 이름이 최상위 키가 됩니다. 이것은 타입이 명확하지만 중첩이 한 단계 추가됩니다.

세 번째로, Notification은 #[serde(tag = "type")]로 internally tagged 방식을 사용합니다. JSON은 {"type": "Email", "to": "...", "subject": "..."}처럼 flat한 구조가 됩니다.

타입 필드가 데이터와 같은 레벨에 있어서 많은 API에서 선호하는 형태입니다. 역직렬화 시 "type" 필드를 보고 올바른 variant를 선택합니다.

여러분이 이 패턴을 사용하면 복잡한 도메인 로직을 타입 안전하게 표현할 수 있습니다. 결제 방법, 알림 타입, 이벤트 종류 등 다형성이 필요한 곳에서 런타임 타입 체크 없이도 안전한 코드를 작성할 수 있습니다.

새로운 variant를 추가하면 컴파일러가 모든 match 문을 검토하여 누락된 처리를 알려줍니다.

실전 팁

💡 외부 API와 통합할 때는 그들의 JSON 형식에 맞춰 tag, content 속성을 조정하세요. #[serde(tag = "kind", content = "data")]로 adjacently tagged도 가능합니다.

💡 untagged enum(#[serde(untagged)])은 구조만으로 타입을 추론하므로 가장 간결하지만, 역직렬화 실패 메시지가 불명확할 수 있습니다.

💡 variant 이름을 rename하려면 #[serde(rename = "in_progress")]를 각 variant에 붙이세요. rename_all로 일괄 변경도 가능합니다.

💡 unit variant(데이터 없는)와 struct variant(데이터 있는)를 섞어 쓸 수 있으며, Serde가 알아서 처리합니다.

💡 enum이 많은 variant를 가질 때는 역직렬화 성능을 고려하세요. internally tagged가 가장 빠릅니다.


8. 에러 처리 패턴

시작하며

여러분이 외부 데이터를 파싱할 때, 실패는 예외가 아니라 일상입니다. 클라이언트가 잘못된 JSON을 보내거나, API 스펙이 변경되거나, 네트워크 오류가 발생하는 등 수많은 이유로 역직렬화는 실패할 수 있습니다.

Rust의 Result 타입과 Serde의 에러 시스템을 제대로 이해하면, 이런 실패 상황을 우아하게 처리하고 사용자에게 명확한 피드백을 줄 수 있습니다. 이것을 마스터하면 안정적인 프로덕션 시스템을 구축하고, 디버깅 시간을 크게 단축할 수 있습니다.

개요

간단히 말해서, Serde의 모든 역직렬화 함수는 Result<T, Error>를 반환하며, Error는 무엇이 잘못되었는지 상세한 정보를 제공합니다. 실무에서 이것이 중요한 이유는 장애 대응 때문입니다.

예를 들어, REST API 서버에서 잘못된 요청을 받았을 때 적절한 HTTP 상태 코드와 에러 메시지를 반환해야 합니다. Serde 에러는 어떤 필드에서 어떤 타입 불일치가 발생했는지 정확히 알려주므로, 이것을 기반으로 사용자 친화적인 에러 응답을 만들 수 있습니다.

무시하는 것과 비교하면 차이가 극명합니다. unwrap()이나 expect()를 남발하면 프로그램이 패닉으로 종료되어 사용자 경험이 끔찍해집니다.

제대로 된 에러 처리는 시스템이 부분적 실패에서도 계속 작동하도록 하며, 로그를 통해 문제를 빠르게 파악할 수 있게 합니다. Serde 에러 처리의 핵심 특징은 정밀한 에러 정보입니다.

단순히 "파싱 실패"가 아니라 "line 3, column 15에서 String 타입을 기대했지만 Number를 받았음" 같은 구체적인 정보를 제공합니다. 또한 커스텀 에러 타입과 통합하기 쉬워서 애플리케이션 레벨 에러 처리와 자연스럽게 연결됩니다.

코드 예제

use serde::{Deserialize, Serialize};
use serde_json;
use std::fmt;

#[derive(Deserialize, Debug)]
struct Config {
    server_port: u16,
    database_url: String,
    max_connections: u32,
}

// 커스텀 에러 타입
#[derive(Debug)]
enum AppError {
    ParseError(String),
    ValidationError(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::ParseError(msg) => write!(f, "Parse error: {}", msg),
            AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
        }
    }
}

impl std::error::Error for AppError {}

// 안전한 파싱 함수
fn parse_config(json: &str) -> Result<Config, AppError> {
    // Serde 에러를 커스텀 에러로 변환
    let config: Config = serde_json::from_str(json)
        .map_err(|e| AppError::ParseError(format!("Failed to parse config: {}", e)))?;

    // 추가 검증 로직
    if config.server_port < 1024 {
        return Err(AppError::ValidationError(
            "Server port must be >= 1024".to_string()
        ));
    }

    if config.max_connections == 0 {
        return Err(AppError::ValidationError(
            "Max connections must be > 0".to_string()
        ));
    }

    Ok(config)
}

fn main() {
    // 올바른 설정
    let valid_json = r#"{
        "server_port": 8080,
        "database_url": "postgres://localhost/mydb",
        "max_connections": 100
    }"#;

    match parse_config(valid_json) {
        Ok(config) => println!("Config loaded: {:?}", config),
        Err(e) => eprintln!("Error: {}", e),
    }

    // 타입 오류
    let invalid_type = r#"{
        "server_port": "not a number",
        "database_url": "postgres://localhost/mydb",
        "max_connections": 100
    }"#;

    match parse_config(invalid_type) {
        Ok(_) => println!("Should not succeed"),
        Err(e) => eprintln!("Expected error: {}", e),
    }

    // 검증 실패
    let invalid_port = r#"{
        "server_port": 80,
        "database_url": "postgres://localhost/mydb",
        "max_connections": 100
    }"#;

    match parse_config(invalid_port) {
        Ok(_) => println!("Should not succeed"),
        Err(e) => eprintln!("Validation error: {}", e),
    }
}

설명

이것이 하는 일: 위 코드는 실무에서 사용하는 안전한 파싱 패턴을 보여줍니다. Serde 에러를 애플리케이션 에러로 변환하고, 비즈니스 로직 검증까지 통합하는 방법을 이해할 수 있습니다.

첫 번째로, AppError라는 커스텀 에러 타입을 정의합니다. 이것은 파싱 에러와 검증 에러를 구분하여, 호출자가 에러 종류에 따라 다르게 대응할 수 있게 합니다.

Display와 Error 트레이트를 구현하여 표준 에러 처리 인프라와 호환됩니다. 두 번째로, parse_config 함수는 ?

연산자와 map_err를 사용하여 Serde의 에러를 AppError로 변환합니다. map_err는 원본 에러 메시지를 포함하므로 디버깅에 필요한 정보가 손실되지 않습니다.

이렇게 변환된 에러는 애플리케이션의 다른 에러들과 일관된 방식으로 처리됩니다. 세 번째로, 역직렬화 성공 후 추가 검증을 수행합니다.

server_port와 max_connections의 범위를 확인하여 논리적으로 유효한지 검증합니다. 이런 도메인 특정 검증은 타입 시스템만으로는 표현할 수 없지만, Result 체이닝으로 자연스럽게 통합됩니다.

여러분이 이 패턴을 사용하면 프로덕션 시스템의 안정성이 크게 향상됩니다. 모든 외부 입력을 Result로 처리하여 패닉을 방지하고, 상세한 에러 정보로 문제를 빠르게 파악할 수 있습니다.

사용자에게는 친절한 에러 메시지를 보여주고, 로그에는 상세한 디버깅 정보를 남길 수 있습니다.

실전 팁

💡 프로덕션에서는 절대 unwrap()을 사용하지 마세요. 모든 역직렬화는 Result로 처리하고 match나 ?로 에러를 전파하세요.

💡 Serde 에러는 매우 상세하므로 사용자에게 직접 노출하지 말고, 내부 로그에만 기록하세요. 사용자에게는 일반화된 메시지를 보여주세요.

💡 serde_path_to_error 크레이트를 사용하면 중첩 구조에서 정확히 어느 경로의 필드가 실패했는지 알 수 있습니다.

💡 비동기 환경에서는 anyhowthiserror 크레이트로 에러 처리를 단순화할 수 있습니다.

💡 테스트에서는 다양한 잘못된 입력으로 에러 처리 로직을 검증하세요. 예상한 에러가 올바르게 반환되는지 확인하세요.


9. 커스텀 직렬화

시작하며

여러분이 특수한 데이터 형식을 다룰 때, derive 매크로만으로는 충분하지 않은 경우가 있습니다. 예를 들어, 날짜를 특정 포맷으로 표현하거나, 숫자를 문자열로 변환하거나, 암호화된 형태로 저장해야 할 때 말이죠.

Serde는 이런 특수한 요구사항을 위해 커스텀 직렬화 로직을 작성할 수 있는 강력한 메커니즘을 제공합니다. serialize_with와 deserialize_with 속성이 핵심입니다.

이것을 마스터하면 외부 시스템의 특이한 형식 요구사항도 우아하게 처리하면서, Rust 코드는 깔끔하게 유지할 수 있습니다.

개요

간단히 말해서, 커스텀 직렬화는 특정 필드에 대해 derive가 생성한 기본 로직 대신 여러분이 작성한 함수를 사용하는 것입니다. 실무에서 이것이 중요한 이유는 레거시 시스템 통합 때문입니다.

예를 들어, 오래된 API가 날짜를 "YYYY-MM-DD" 형식의 문자열로 요구하는데 Rust에서는 DateTime<Utc>를 사용하고 싶을 때, 커스텀 함수로 변환을 처리할 수 있습니다. 또한 민감한 데이터를 암호화하거나, 큰 숫자를 문자열로 저장하여 JavaScript의 정밀도 문제를 피하는 등 다양한 상황에서 필수적입니다.

기본 직렬화와 비교하면, derive는 타입의 구조를 그대로 JSON에 매핑합니다. 커스텀 직렬화는 중간에 변환 로직을 삽입하여 외부 표현과 내부 표현을 분리합니다.

이 분리 덕분에 코드는 도메인 로직에 집중하고, 직렬화는 인터페이스 계층에서 처리됩니다. 커스텀 직렬화의 핵심 특징은 세밀한 제어입니다.

필드 단위로 로직을 적용할 수 있고, 직렬화와 역직렬화를 독립적으로 커스터마이징할 수 있습니다. 또한 serde의 with 모듈 패턴을 사용하면 재사용 가능한 변환 로직을 작성하여 여러 구조체에 적용할 수 있습니다.

코드 예제

use serde::{Serialize, Deserialize, Serializer, Deserializer};
use serde::de::{self, Visitor};
use std::fmt;

// 커스텀 직렬화 모듈
mod timestamp {
    use super::*;

    pub fn serialize<S>(timestamp: &i64, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Unix 타임스탬프를 ISO 8601 문자열로 변환
        let datetime = format!("2024-01-01T{}:00:00Z", timestamp % 24);
        serializer.serialize_str(&datetime)
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<i64, D::Error>
    where
        D: Deserializer<'de>,
    {
        // 간단한 예제: 시간 부분만 추출
        let s: String = String::deserialize(deserializer)?;
        let hour: i64 = s.split('T')
            .nth(1)
            .and_then(|t| t.split(':').next())
            .and_then(|h| h.parse().ok())
            .ok_or_else(|| de::Error::custom("Invalid timestamp format"))?;
        Ok(hour)
    }
}

// 큰 숫자를 문자열로 직렬화 (JavaScript 호환)
mod string_number {
    use super::*;

    pub fn serialize<S>(num: &u64, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_str(&num.to_string())
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<u64, D::Error>
    where
        D: Deserializer<'de>,
    {
        let s: String = String::deserialize(deserializer)?;
        s.parse::<u64>().map_err(de::Error::custom)
    }
}

#[derive(Serialize, Deserialize, Debug)]
struct Event {
    id: u32,

    // 커스텀 직렬화 함수 사용
    #[serde(with = "timestamp")]
    created_at: i64,

    // 큰 숫자를 문자열로 변환
    #[serde(with = "string_number")]
    user_id: u64,

    description: String,
}

fn main() {
    let event = Event {
        id: 1,
        created_at: 14,
        user_id: 9007199254740992, // JavaScript Number.MAX_SAFE_INTEGER 초과
        description: "User logged in".to_string(),
    };

    // 직렬화: created_at은 ISO 형식, user_id는 문자열로
    let json = serde_json::to_string_pretty(&event).unwrap();
    println!("Serialized:\n{}\n", json);

    // 역직렬화: 자동으로 변환 함수 사용
    let parsed: Event = serde_json::from_str(&json).unwrap();
    println!("Parsed timestamp: {}", parsed.created_at);
    println!("Parsed user_id: {}", parsed.user_id);
}

설명

이것이 하는 일: 위 코드는 두 가지 실무 시나리오를 보여줍니다. 타임스탬프를 사람이 읽기 좋은 형식으로 변환하고, JavaScript의 Number 제한을 피하기 위해 큰 숫자를 문자열로 처리합니다.

첫 번째로, timestamp 모듈은 serialize와 deserialize 함수 쌍을 제공합니다. serialize 함수는 i64를 받아서 ISO 8601 문자열로 변환하여 Serializer에 전달합니다.

deserialize는 그 반대 작업을 수행합니다. 이 함수들은 Serde의 Serializer와 Deserializer 트레이트를 사용하므로 모든 포맷(JSON, YAML 등)에서 작동합니다.

두 번째로, #[serde(with = "timestamp")] 속성은 created_at 필드가 기본 직렬화 대신 timestamp 모듈의 함수를 사용하도록 지정합니다. 이렇게 필드 단위로 커스터마이징이 가능하며, 구조체의 다른 필드는 영향을 받지 않습니다.

세 번째로, string_number 모듈은 u64를 문자열로 변환합니다. JavaScript는 2^53 이상의 정수를 정확히 표현하지 못하므로, 백엔드에서 큰 ID를 문자열로 보내는 것이 일반적입니다.

이런 변환 로직을 커스텀 함수로 캡슐화하면 재사용이 쉽고 테스트도 간단합니다. 여러분이 이 패턴을 사용하면 외부 시스템과의 인터페이스 문제를 우아하게 해결할 수 있습니다.

도메인 모델은 Rust다운 타입을 사용하고, 직렬화 레이어에서만 변환을 처리하므로 관심사가 분리됩니다. 변환 로직이 모듈로 독립되어 있어서 단위 테스트도 쉽고, 여러 구조체에서 재사용할 수 있습니다.

실전 팁

💡 커스텀 함수는 별도 모듈로 분리하여 재사용하세요. 여러 프로젝트에서 공통으로 쓰는 변환은 별도 크레이트로 만드는 것도 좋습니다.

💡 chrono 크레이트의 DateTime 같은 일반적인 타입은 이미 Serde 지원이 있으므로, 직접 구현하기 전에 확인하세요.

💡 에러 처리를 잊지 마세요. deserialize에서 잘못된 형식을 만나면 de::Error::custom으로 명확한 에러를 반환하세요.

💡 성능이 중요하다면 커스텀 함수에서 불필요한 할당을 피하고, 가능하면 zero-copy 방식을 사용하세요.

💡 복잡한 변환은 별도 함수로 분리하고, serialize/deserialize 함수는 얇은 래퍼로 유지하세요. 테스트와 유지보수가 쉬워집니다.


#Rust#Serde#Serialization#Deserialization#JSON

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스