Rust

Serde를 활용한 다양한 JSON 열거형 처리 방법 및 주의점

드리프트2 2024. 5. 19. 14:21

JSON은 REST API 호출, 데이터 저장, 다국어 연동 등에 자주 사용된다.

 

하지만 JSON은 언어에서 지원하는 표현이 정수, 부동 소수점 수, 문자열, 배열, 객체, 그리고 null 정도로만 제한되며, 이보다 복잡한 표현은 이러한 기본 기능을 조합하여 표현해야 한다.

 

기능을 조합하는 방법에는 여러 가지가 있으며, 특히 여러 종류의 구조체나 클래스가 섞여 있을 때 표현 방식이 다양하다.

 

Rust에서는 표현하고자 하는 데이터 타입이 이미 알려져 있다면, 여러 종류 중 하나를 표현하기 위해 열거형을 사용할 수 있다.

 

그리고 Rust의 직렬화/역직렬화 라이브러리인 serde를 사용해 열거형과 JSON 간의 상호 변환이 가능하다.

열거형의 4가지 표현

serde에서 다룰 수 있는 열거형의 표현 방식은 4가지가 있다.

 

externally tagged, internally tagged, adjacently tagged, untagged의 4종류를 지정할 수 있다.

 

이 설명에서는 열거형의 변형(variant) 4가지를 각각 unit형 변형, newtype형 변형, tuple형 변형, struct형 변형으로 부른다.

 

이들은 Rust 코드에서 다음과 같이 작성된다.

enum Variants {
    Unit,
    Newtype(i64),
    Tuple(i64, i64),
    Struct { x: i64, y: i64 },
}

 

다음 샘플 코드의 전체 소스 코드는 아래 리포지토리에 있다.

Externally tagged (기본값)

코드 예시

externally tagged는 값의 외부에 객체의 키로 태그가 붙는 형식이다.

 

serde의 기본값이며, 별도의 지정이 없을 경우 이 형식이 사용된다.

 

이 형식은 변형을 읽고 나서 내용을 읽을 수 있다는 점, JSON 외에도 많은 포맷에서 이용 가능하다는 점, 모든 패턴의 변형을 잘 표현할 수 있다는 점 등으로 인해 기본 형식으로 선택된 것으로 보인다. (tuple 변형은 내용이 배열로 표현된다)

{ "Variant": { "field1": "value1", "field2": "value2" } }

 

열거형 선언 시 별도의 형식을 지정하지 않으면 이 형식을 사용할 수 있다.

#[derive(Debug, Serialize, Deserialize)]
enum Member {
    Permanent {
        id: u64,
        name: String,
        nickname: Option<String>,
    },
    SingleChannel {
        channel_id: u64,
        name: String,
        nickname: Option<String>,
    },
}

실제 동작 예시

Rust의 값:

[
    Permanent { id: 1, name: "Jebediah Kerman", nickname: Some("Jeb") },
    Permanent { id: 2, name: "Bob Kerman", nickname: None },
    SingleChannel { channel_id: 8, name: "Kamler Kerman", nickname: None }
]

 

JSON:

[
    {"Permanent": { "id": 1, "name": "Jebediah Kerman", "nickname": "Jeb" }},
    {"Permanent": { "id": 2, "name": "Bob Kerman", "nickname": null }},
    {"SingleChannel": { "channel_id": 8, "name": "Kamler Kerman", "nickname": null }}
]

Internally tagged

코드 예시

internally tagged는 객체 내부에 태그가 되는 키와 값 쌍이 포함된 형식이다.

{ "field1": "value1", "tag": "Variant", "field2": "value2" }

 

이 형식을 사용하려면 열거형 선언에 애트리뷰트로 #[serde(tag="tag")]와 같이 태그가 될 필드를 지정한다.

 

이 형식은 tuple 변형이 포함된 경우에는 사용할 수 없으며, unit 변형은 태그만 포함하는 객체가 된다.

 

newtype 변형은 내부에 구조체가 있는 경우에만 해당 구조체가 작성된 struct 변형과 동일하게 사용할 수 있다.

 

internally tagged 형식을 사용할 때는 열거형에 대응하는 변형이 없는 태그를 모두 other로 지정한 변형에 역직렬화하도록 할 수 있다(후술).

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Member {
    Permanent {
        id: u64,
        name: String,
        nickname: Option<String>,
    },
    SingleChannel {
        channel_id: u64,
        name: String,
        nickname: Option<String>,
    },
}

실제 동작 예시

Rust의 값:

[
    Permanent { id: 1, name: "Jebediah Kerman", nickname: Some("Jeb") },
    Permanent { id: 2, name: "Bob Kerman", nickname: None },
    SingleChannel { channel_id: 8, name: "Kamler Kerman", nickname: None }
]

 

JSON:

[
    { "type": "Permanent", "id": 1, "name": "Jebediah Kerman", "nickname": "Jeb" },
    { "type": "Permanent", "id": 2, "name": "Bob Kerman", "nickname": null },
    { "type": "SingleChannel", "channel_id": 8, "name": "Kamler Kerman", "nickname": null }
]

Adjacently tagged

코드 예시

adjacently tagged 형식은 객체 내부에 태그가 되는 키와 값 쌍, 그리고 내용이 되는 키와 값 쌍이 포함된 형식이다.

 

태그와 내용이 인접해 있으므로 adjacently라는 이름이 붙었다.

{ "t": "Variant", "c": { "field1": "value1", "field2": "value2" } }

 

이 형식을 사용하려면 열거형 선언에 애트리뷰트로 #[serde(tag="t", content="c")]와 같이 태그 필드와 내용 필드를 지정한다.

 

이 형식은 모든 종류의 변형에 사용할 수 있다.

 

adjacently tagged 형식에서도 열거형에 대응하는 변형이 없는 태그를 모두 other로 지정한 변형에 역직렬화할 수 있다(후술).

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
enum Member {
    Permanent {
        id: u64,
        name: String,
        nickname: Option<String>,
    },
    SingleChannel {
        channel_id: u64,
        name: String,
        nickname: Option<String>,
    },
}

실제 동작 예시

Rust의 값:

[
    Permanent { id: 1, name: "Jebediah Kerman", nickname: Some("Jeb") },
    Permanent { id: 2, name: "Bob Kerman", nickname: None },
    SingleChannel { channel_id: 8, name: "Kamler Kerman", nickname: None }
]

 

JSON:

[
    { "t": "Permanent", "c": { "id": 1, "name": "Jebediah Kerman", "nickname": "Jeb" }},
    { "t": "Permanent", "c": { "id": 2, "name": "Bob Kerman", "nickname": null }},
    { "t": "SingleChannel", "c": { "channel_id": 8, "name": "Kamler Kerman", "nickname": null }}
]

Untagged

코드 예시

untagged 형식은 JSON에 태그를 포함하지 않는 형식이다.

{ "field1": "value1", "field2": "value2" }

 

이 형식을 사용하려면 애트리뷰트로 #[serde(untagged)]라고 지정한다.

 

이 형식은 모든 종류의 변형에 사용할 수 있지만, 열거형 구조에 따라서는 유일하게 직렬화나 역직렬화할 수 없는 경우가 존재한다(후술).

#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum Member {
    Permanent {
        id: u64,
        name: String,
        nickname: Option<String>,
    },
    SingleChannel {
        channel_id: u64,
        name: String,
        nickname: Option<String>,
    },
}

실제 동작 예시

Rust의 값:

[
    Permanent { id: 1, name: "Jebediah Kerman", nickname: Some("Jeb") },
    Permanent { id: 2, name: "Bob Kerman", nickname: None },
    SingleChannel { channel_id: 8, name: "Kamler Kerman", nickname: None }
]

 

JSON:

[
    { "id": 1, "name": "Jebediah Kerman", "nickname": "Jeb" },
    { "id": 2, "name": "Bob Kerman", "nickname": null },
    { "channel_id": 8, "name": "Kamler Kerman", "nickname": null }
]

Untagged 형식의 주의점

untagged 형식에서는 직렬화 시 단순히 내부 내용을 출력하고, 역직렬화 시에는 열거형의 변형을 앞에서부터 순차적으로 시도해 역직렬화를 진행한다.

 

따라서 untagged 형식을 사용할 때는 순서에 따라 올바르게 역직렬화될 수 있도록 변형의 순서를 조정해야 한다.

 

예를 들어, 정수, 부동 소수점 수, 문자열 중 하나로 구성된 열거형을 정의하는 경우 다음과 같다.

use failure::Error;
use serde_derive::{Deserialize, Serialize};

pub fn run() -> Result<(), Error> {
    println!("\n======== Untagged enum with multiple types ========\n");

    let string = r#"[1.0, 42, "foo"]"#;

    let values: Vec<Values> = serde_json::from_str(&string)?;

    println!("String: {}", string);
    println!("Decoded: {:?}", values);

    Ok(())
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum Values {
    Int(i64),
    Float(f64),
    Str(String),
}

 

위 코드를 실행하면 다음과 같이 정수, 부동 소수점 수, 문자열로 정확하게 역직렬화된다.

======== Untagged enum with multiple types ========

String: [1.0, 42, "foo"]
Decoded: [Float(1.0), Int(42), Str("foo")]

 

그러나 다음과 같이 부동 소수점 수를 앞에 두면, 정수는 항상 부동 소수점 수로도 역직렬화될 수 있기 때문에 정수도 부동 소수점 수로 역직렬화된다.

#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum Values {
    Float(f64),
    Int(i64),
    Str(String),
}
======== Untagged enum with multiple types ========

String: [1.0, 42, "foo"]
Decoded: [Float(1.0), Float(42.0), Str("foo")]

serde의 다른 기능과의 조합

other

internally tagged 형식이나 adjacently tagged 형식 중 하나를 사용할 때, unit 변형을 열거형의 마지막에 두고 #[serde(other)] 애트리뷰트를 붙이면 태그가 어떤 변형에도 해당하지 않을 때의 값을 지정할 수 있다.

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "education")]
enum Education {
    #[serde(rename = "high school")]
    HighSchool { name: String },
    #[serde(rename = "college")]
    College { name: String, speciality: String },
    #[serde(rename = "bachelor")]
    Bachelor { name: String, speciality: String },
    #[serde(rename = "master")]
    Master { name: String, speciality: String },
    #[serde(rename = "doctor")]
    Doctor { name: String, speciality: String },
    #[serde(other)]
    Other,
}
[
    { "education": "high school", "name": "Tokyo metropolitan Hibiya High School" },
    { "education": "college", "name": "Kyouritsu Women's Junior College", "speciality": "english literature" },
    { "education": "bachelor", "name": "Meiji University", "speciality": "laws" },
    { "education": "master", "name": "Tokyo Institute of Technology", "speciality": "engineering" },
    { "education": "doctor", "name": "The University of Tokyo", "speciality": "science" },
    { "education": "baka", "name": "バカ田大学" }
]
[
    HighSchool { name: "Tokyo metropolitan Hibiya High School" },
    College { name: "Kyouritsu Women's Junior College", speciality: "english literature" },
    Bachelor { name: "Meiji University", speciality: "laws" },
    Master { name: "Tokyo Institute of Technology", speciality: "engineering" },
    Doctor { name: "The University of Tokyo", speciality: "science" },
    Other
]

 

태그가 null일 경우 other 지정이 있어도 역직렬화 시 오류가 발생한다.

공통 부분을 별도의 구조체로 추출하기

serde에서는 구조체의 필드가 구조체일 경우 #[serde(flatten)] 애트리뷰트를 지정해 내부 구조체를 외부 구조체로 확장할 수 있다.

 

이는 struct 변형의 필드에도 적용 가능하기 때문에, 잘 활용하면 공통 부분을 별도 구조체로 다룰 수 있다.

 

공통 부분만 있을 때는 newtype 변형을 사용할 수도 있다. 이는 위임을 사용한 코드와의 친화성이 높다.

use failure::Error;
use serde_derive::{Deserialize, Serialize};

pub fn run() -> Result<(), Error> {
    println!("\n======== Extract fields to structs ========\n");

    let string = r#"[
        {"op": "Add",  "type": "Miscellaneous", "name": "clean up room"},
        {"op": "Add",  "type": "Technical",     "name": "fix Wi-Fi"},
        {"op": "Take", "type": "Technical"},
        {"op": "Take", "type": "Miscellaneous"}
    ]"#;

    let operations: Vec<Operation> = serde_json::from_str(&string)?;

    println!("String: {}", string);
    println!("Decoded: {:?}", operations);

    Ok(())
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "op")]
enum Operation {
    Add {
        #[serde(flatten)]
        task_type: TaskType,
        #[serde(flatten)]
        task: Task,
    },
    Take(TaskType),
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum TaskType {
    Technical,
    Miscellaneous,
}

#[derive(Debug, Serialize, Deserialize)]
struct Task {
    name: String,
}

마무리하며

serde는 Rust의 범용 직렬화/역직렬화 프레임워크로서, Rust의 타입과 범용 데이터 구조 간 변환 부분과, 범용 데이터 구조와 각 포맷 간 변환 부분을 잘 분리할 수 있도록 설계되었다.

 

serde의 기능을 잘 활용하면 여러 포맷과의 호환성을 유지하면서도 깔끔한 Rust 코드를 구현할 수 있을지도 모른다.

 

공식 문서에는 더 많은 기능의 해설과 예시가 나와 있으므로, 한 번 살펴보는 것이 좋을 것이다.