프레임이 경계와 무결성을 잡아준다면, 그 안에 담기는 payload는 어떻게 만들어질까요? Rust 구조체를 바이트 배열로 바꾸는 작업 — 직렬화(serialization) — 이 챕터의 주제입니다. 어떤 포맷을 쓸지, 왜 JSON은 아닌지, 그리고 "해시나 인덱스 키로 쓰기 위한 정규 바이트"는 왜 따로 필요한지까지 설명합니다.
구조체 → 바이트는 bincode로, 해시·인덱스 키용 정규 표현은 canonical_bytes()로 따로 관리한다. JSON처럼 생긴 값도 문자열로 감싸 저장하는 이유는 bincode의 비자기서술적(non-self-describing) 특성 때문이다.
당신이 아는 것: DB의 타입별 인코딩
관계형 DB에서 INSERT INTO logs (id, timestamp, message) VALUES (1, now(), 'hello')를 실행하면, DB 엔진은 INTEGER는 고정 바이트(4바이트)로, TIMESTAMP는 내부 시각 포맷으로, TEXT는 길이+데이터로 페이지에 직접 씁니다. 이 인코딩은 엔진 내부에 숨어 있어 우리가 신경 쓸 필요가 없었죠.
하지만 우리는 DB 엔진이 없습니다. 우리가 직접 "구조체 → 바이트"를 결정해야 합니다.
DB에서는 타입별 온디스크 인코딩이 엔진 내부에 박혀 있다(Postgres의 heap tuple 포맷, MySQL의 InnoDB row 포맷 등). Quipu-Log에서는 serde + bincode 조합으로 Rust 구조체를 바이트로 변환한다 — 이게 "엔진 없이 파일로" 할 때 우리가 직접 챙겨야 하는 부분이다.
왜 bincode인가 — JSON과의 비교
직렬화 포맷을 고를 때 가장 먼저 떠오르는 건 JSON입니다. 사람이 읽을 수 있고, 익숙하고, 도구도 많죠. 그런데 Quipu-Log는 bincode를 씁니다. 왜일까요?
| JSON | bincode | |
|---|---|---|
| 사람 가독성 | 좋음 | 없음 (바이너리) |
| 크기 | 큼 (필드명 반복, 따옴표, 쉼표) | 작음 (필드명 없음, 타입 추론) |
| 속도 | 느림 (파싱 비용) | 빠름 (메모리 레이아웃에 가까움) |
| 타입 정보 | 자기서술적 (key-value) | 비자기서술적 (순서 기반) |
| serde 연동 | 가능 | 가능 |
감사 로그는 수십만 건이 쌓입니다. 각 레코드가 100바이트씩 작아지면 디스크 사용량과 처리 속도 모두 유의미하게 달라집니다. bincode의 단점(비가독성)은 세그먼트 파일이 "사람이 직접 열어보는" 파일이 아니라는 점에서 큰 문제가 아닙니다 — 쿼리 API를 통해 읽으면 됩니다.
택배 박스를 생각해봅시다. 내용물을 일일이 라벨에 적는 방식(JSON)은 사람이 보기엔 좋지만 공간을 많이 씁니다. 내용물을 정해진 칸에 순서대로 넣는 방식(bincode)은 보내는 쪽과 받는 쪽이 "3번 칸엔 항상 타임스탬프"라고 약속했기 때문에 따로 라벨이 없어도 됩니다.
serde: 접착제 역할
bincode 자체는 어떤 구조체를 어떻게 직렬화할지 모릅니다. 그 역할은 serde가 합니다. #[derive(Serialize, Deserialize)]를 붙이면 Rust 컴파일러가 직렬화/역직렬화 코드를 자동 생성합니다. bincode는 그 인터페이스를 활용해 바이너리로 씁니다.
crates/quipu-core/src/model.rs#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditLog {
pub log_id: Uid,
pub timestamp: u64,
pub actor: Uid,
pub actor_type: String,
pub method: String,
pub url: String,
pub content: Content,
pub custom: BTreeMap<String, Value>,
}
derive 매크로 덕분에 bincode::serialize(&log) 한 줄로 바이트 배열을 얻습니다. 역으로 bincode::deserialize(&bytes)로 구조체를 되돌립니다. 저장 엔진이 직접 하는 일은 이것뿐입니다.
비자기서술적 포맷의 함정: Json은 String으로 감싼다
bincode는 필드 이름을 기록하지 않습니다 — 대신 구조체 정의의 순서를 믿습니다. 이게 크기와 속도의 원천이지만, 한 가지 함정이 있습니다.
serde_json::Value는 "어떤 JSON 타입이든 될 수 있는" 타입입니다. {"key": 1}일 수도, [1,2,3]일 수도 있죠. bincode는 비자기서술적 포맷이라 역직렬화 시 "다음에 오는 게 정수인지 배열인지"를 런타임에 판단하지 못합니다. 구조체 정의를 보고 결정하는데, serde_json::Value는 그 형태가 런타임에서야 결정됩니다.
Quipu-Log의 해결책이 흥미롭습니다.
crates/quipu-core/src/model.rs// 공개 타입: 호출자는 Value::Json(serde_json::Value)로 넘긴다
#[serde(try_from = "ValueRepr", into = "ValueRepr")]
pub enum Value {
Text(String),
Number(f64),
Json(serde_json::Value),
}
// 온디스크 표현: Json은 String으로 바꿔 저장
enum ValueRepr {
Text(String),
Number(f64),
Json(String), // <-- serde_json::Value 대신 String
}
serde(try_from, into)로 공개 타입(Value)과 온디스크 표현(ValueRepr)을 분리했습니다. JSON 값은 .to_string()으로 문자열화해서 저장하고, 읽을 때 다시 serde_json::from_str로 파싱합니다. bincode가 다루기 어려운 부분을 String으로 단순화한 것입니다.
같은 패턴이 Content(로그 본문)에도 적용됩니다.
canonical_bytes: 해시와 인덱스 키를 위한 정규 표현
직렬화와 별개로, 값을 "비교하거나 해시해야 할 때" 또 다른 바이트 표현이 필요합니다. 예를 들어 SHA-256으로 필드를 보호하면 쿼리 시 "probe 값의 SHA-256 == 저장된 SHA-256?"으로 검색합니다. 이때 probe 값도, 저장된 값도 동일한 방법으로 바이트로 변환해야 같은 해시가 나옵니다.
bincode 직렬화를 그대로 쓰면 안 될까요? 문제가 있습니다 — bincode는 구조체 전체를 직렬화하는데, 필드 하나의 값만 바이트로 표현하고 싶은 것이니까요. 또한 bincode는 구조체 레이아웃 변경에 민감합니다.
그래서 Value에는 별도 메서드가 있습니다.
crates/quipu-core/src/model.rsimpl Value {
/// 해시와 인덱스 키에 쓰는 정규 바이트 표현.
pub fn canonical_bytes(&self) -> Vec<u8> {
match self {
Value::Text(s) => s.as_bytes().to_vec(),
Value::Number(n) => format!("{n}").into_bytes(),
Value::Json(v) => v.to_string().into_bytes(),
}
}
}
단순하지만 중요한 계약입니다. "hello"라는 Text 값은 항상 UTF-8 바이트 5개, 42.0이라는 Number는 항상 "42"의 UTF-8 바이트입니다. 이 계약이 유지되는 한, SHA-256 보호 필드의 검색이 올바르게 동작합니다. 26장 블라인드 인덱스에서 이 canonical_bytes가 토큰 생성에 어떻게 쓰이는지 자세히 다룹니다.
Value에서 두 가지 바이트 표현이 나온다. 저장용(bincode)과 해시·인덱스용(canonical_bytes)은 목적이 다르기 때문에 분리한다.StoredValue: 보호가 적용된 온디스크 표현
레지스트리 필드는 스키마에 따라 평문, SHA-256 다이제스트, HMAC, RSA 암호문 중 하나로 저장될 수 있습니다. 이를 담는 타입이 StoredValue입니다.
crates/quipu-core/src/model.rspub enum StoredValue {
Plain(Value),
Sha256(String),
Hmac { key_version: u32, digest: String },
Rsa { key_version: u32, wrapped_key: String, nonce: String, ciphertext: String },
}
이것도 Serialize + Deserialize를 구현하므로 bincode로 세그먼트에 저장됩니다. 어떤 보호 방식이 쓰였는지 enum 배리언트에 담겨 있어 역직렬화 시 스키마를 따로 조회하지 않아도 됩니다. 파트 6 기밀성에서 각 변형의 암호학적 의미를 자세히 다룹니다.
포맷 안정성: 구조체를 바꾸면 어떻게 될까
bincode의 비자기서술적 특성은 강점이면서 주의사항이기도 합니다. 구조체에 필드를 추가하거나 순서를 바꾸면 기존 세그먼트를 역직렬화할 수 없게 됩니다. Quipu-Log가 FORMAT_VERSION을 세그먼트 헤더에 박아두는 이유 중 하나도 이것입니다 — 포맷이 바뀌면 버전을 올려 이전 파일을 "읽을 수 없음"으로 명확히 표시합니다. 18장 저장소 레이아웃과 포맷 버저닝
자기서술적 포맷(MessagePack, CBOR)은 필드 이름을 함께 저장해 구조체가 바뀌어도 부분 역직렬화가 가능합니다. bincode를 택한 것은 크기·속도 우선의 결정입니다. 감사 로그는 쓰기 집약적이고 레코드 수가 많으므로, 레코드당 수십 바이트 절감이 전체 디스크 사용량과 처리량에 의미 있게 영향을 줍니다. 포맷 안정성은 FORMAT_VERSION으로 관리하는 것으로 트레이드오프를 받아들입니다.
① bincode가 "비자기서술적"이라는 말의 뜻은 무엇인가요? JSON과 무엇이 다른가요?
② Value::Json(serde_json::Value)를 직접 bincode로 저장하지 않고 String으로 변환해 저장하는 이유는 무엇인가요?
③ canonical_bytes()와 bincode::serialize는 같은 값에 대해 항상 같은 바이트를 만들까요? 두 함수의 목적 차이를 설명해보세요.