5.7 데이터 직렬화(Serialization) 및 페이로드 최적화

5.7 데이터 직렬화(Serialization) 및 페이로드 최적화

네트워크 라이브러리가 아무리 데이터를 빛의 속도로 나른다 한들, 애플리케이션 계층에서 메모리 구조체(Struct)를 바이트(Byte) 배열로 뭉치고(Serialization) 다시 푸는(Deserialization) 과정이 굼뜨다면 말짱 도루묵이다.

Rust는 세상에서 가장 강력한 직렬화 프레임워크인 Serde를 보유하고 있다. Zenoh는 이 Serde의 위력과 결합하여, 개발자가 일일이 비트 엔디안(Endian)을 계산하지 않도록 돕는 것은 물론, ZBytes라는 고유의 스마트 버퍼 엔진을 통해 대규모 영상 스트리밍이나 자율주행 라이다 센서 데이터를 제로 카피(Zero-Copy) 로 넘기는 극한의 추상화를 제공한다. 이 장에서는 CPU 직렬화 바운드를 박살 내는 기법들을 런북 형태로 짚어본다.

1. Serde 크레이트와의 완벽한 통합 가이드

REST API를 다루던 웹 백엔드 개발자들이 Rust에 오면 환호하는 지점이 바로 매크로 한 줄로 완성되는 Serde의 마법이다. Zenoh는 페이로드를 바이트(&[u8]) 덩어리로만 취급하므로, 그 바이트를 어떻게 굽고 삶을지는 전적으로 Serde의 몫이다.

1.0.1 [Runbook] 데이터 객체의 이착륙(직렬화/역직렬화) 전술

1. Cargo.toml 의존성 투하
가장 무난한 JSON 포맷을 예제로 든다.

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

2. [Publisher 측] 매크로 기반 즉석 바이트 변환
복잡한 중첩 구조체라도 #[derive(Serialize)] 한 방이면 끝난다.

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
struct RobotStatus {
    id: u32,
    battery: f32,
    mode: String,
}

// 앨로케이션(Allocation) 한 번으로 즉석 직렬화
let status = RobotStatus { id: 1, battery: 88.5, mode: "Autonomous".into() };
let payload_bytes = serde_json::to_vec(&status).unwrap();

// Zenoh는 그저 만들어진 바이트 배열을 삼키기만 한다.
publisher.put(payload_bytes).res().await.unwrap();

3. [Subscriber 측] 수신 바이트 배열의 즉각적인 캐스팅
수신부에서는 sample.value.payload 구조체를 다시 우리가 아는 Rust의 구조체 뼈대에 맞춰 들이부어야 한다.

while let Ok(sample) = subscriber.recv_async().await {
    // payload.contiguous() 를 통해 불연속적인 청크 버퍼를 일체형 슬라이스로 치환
    let bytes = sample.value.payload.contiguous();
    
    // 이 순간 바이트 덩어리가 Type-Safe 한 Rust 구조체로 환생한다!
    match serde_json::from_slice::<RobotStatus>(&bytes) {
        Ok(status) => println!("로봇 상태 해석 완료: {:?}", status),
        Err(e) => eprintln!("버전이 맞지 않는 페이로드 공격 수신: {}", e),
    }
}

Serde는 런타임에 리플렉션(Reflection)을 써서 필드를 뒤지는 Java 등과 달리, 컴파일 타임에 완벽한 스태틱 파싱(Static Parsing) 루틴을 기계어(Assembly)로 박아넣기 때문에 속도 면에서 CPU를 거의 태우지 않는다.

2. JSON, CDR, Protobuf 포맷의 직렬화-역직렬화 성능 비교

초보자는 디버깅이 편하다는 이유로 전부 JSON으로 직렬화해서 보낸다.
하지만 하루에 1억 건의 메시지가 돌아다니는 분산 클러스터에서 { "battery": 95.5 } 처럼 불필요한 키(Key) 이름, 심지어 괄호와 공백문자까지 네트워크 대역폭을 낭비하는 것은 인프라 비용 청구서를 폭발시키는 주범이다.

2.0.1 [Runbook] 프로덕션 대역폭 다이어트 전술 (포맷 벤치마크)

Zenoh 위에서 직렬화를 돌릴 때, 데이터 특성에 따라 엔진을 교체해야 한다.

1. JSON (serde_json)

  • 특징: 인간이 읽기 편하다. REST API 레거시 봇 연동에 쓰기 좋다.
  • 성능 단점: 파싱이 끔찍하게 느리다. 바이너리 대비 용량이 3~5배 이상 부풀어 오른다.
  • Runbook: 외부 연동(Frontend 등) 관제 대시보드 API용으로만 제한적으로 사용하라.

2. CDR (Common Data Representation) / Bincode

  • 특징: DDS 진영과 뼈를 맞대고 진화해 온 로보틱스 성능의 근본. 키(Key) 이름을 싹 빼어버리고 “4바이트는 int, 다음 8바이트는 텍스트” 식으로 메모리 주소를 때려 박는다.
  • Runbook: 폐쇄망 안에서 군집 로봇들끼리 초고속 제어 데이터를 주고받을 때는 무조건 bincode 크레이트를 쓴다. 용량이 JSON의 절반 이하로 줄고 인코딩 속도는 10배 차이 난다.
    // bincode 크레이트 활용
    let payload = bincode::serialize(&status).unwrap();
    

**3. Protobuf / FlatBuffers (스키마 기반)**
- **특징:** 서버와 클라이언트가 명시적인 `.proto` 계약서를 들고 통신한다.
- **Runbook:** 글로벌 자율주행 서버처럼, 버전(Version) 업그레이드 시 하위 호환성(Backward Compatibility)을 보장해야 하는 대형 서비스 백본에는 필수다. 다만 Rust 진영에서는 Protobuf(`prost`) 세팅이 번거로운 편이므로, 양단이 모두 Rust로 통일된 환경이면 굳이 쓸 필요 없이 `bincode`로 가는 것이 빠르다.


## 3.  ZBytes(Zenoh Bytes)의 구조와 메모리 안전성


Zenoh API를 쓰다 보면 데이터를 쏠 때는 편하게 `put("...")` 으로 넘기지만, 받을 때는 내 데이터가 이상한 `sample.value.payload` 즉, **`ZBytes`** 라는 독자적인 버퍼 컨테이너에 담겨오는 것을 깨닫게 된다.

왜 표준의 `Vec<u8>`을 쓰지 않고 굳이 `ZBytes`라는 귀찮은 객체를 씌워놨을까?

#### 3.0.1 [Runbook] ZBytes 인스펙션: 조각난 네트워크 버퍼의 봉합


TCP/IP 네트워크에서 10MB짜리 덩어리를 쏘면 한 번에 도착하지 않는다. 1.5KB씩 잘게 쪼개져서(MTU) 도달한다. 과거의 무식한 라이브러리들은 이 조각들을 `Vec<u8>` 에 `push`하며 거대한 메모리를 재할당(Re-allocation)했다.

**[ZBytes의 파편화 관리의 미학]**
Zenoh의 `ZBytes`는 파편화된 메모리 조각들의 헤더 포인터만 들고 있는 **논리적인 묶음 배열(Scattered Array)** 이다.

1. **`payload.contiguous()` 란 무엇인가?**
조각난 배열들을 억지로 물리적 1열(`&[u8]`)로 이어 붙여 달라는(복사가 일어날 수도 있는) 명령이다. 초보자는 무지성으로 이걸 써서 역직렬화 도구(Serde)에 넘긴다.
   ```rust
   let bytes_ref = sample.value.payload.contiguous(); // 안전하지만 메모리 오버헤드 유발 가능
  1. [고급 전술] 청크 반복자(Chunk Iterator)를 활용한 인-플레이스(In-place) 처리
    로봇 비전(Vision)에서 4K 해상도의 영상 바이트 덩어리를 받는 상황.
    조각난 채로 들어왔으면, 그냥 조각된 상태 그대로 파일 시스템이나 디스플레이 파이프에 밀어 넣는 것이 진정한 튜닝 고수다.
    use std::io::Write;
    
    // 1.5KB씩 쪼개져 들어온 패킷들을 contiguous()로 합치지 않고, 
    // 파편 그 자체를 하나씩 순회하며 꺼내 쓴다! (Zero-Allocation)
    for chunk in sample.value.payload.chunks() {
        file.write_all(chunk).unwrap(); 
    }
    
이 `ZBytes` 철학을 이해하면, 당신의 노드는 1GB짜리 파일을 Zenoh로 전송받더라도 RAM 메모리 사용량이 고작 수십 MB에서 요동치지 않는 기적을 보일 것이다.


## 4.  제로 카피(Zero-copy) 원칙에 입각한 대용량 페이로드 처리 기법


하나의 리눅스 서버 안에 띄워진 비전 AI 모듈(프로세스 A)과 통신 릴레이 모듈(프로세스 B)이 있다고 하자.
비전 모듈이 1초에 60번씩 20MB짜리 카메라 원본 배열을 쏠 때, 일반적인 로컬 호스트 통신(TCP 127.0.0.1)으로 쏘면 커널 단과 유저 공간 사이에서 미친 듯한 `memcpy(가비지 복사)`가 일어나며 CPU가 불타오른다.

#### 4.0.1 [Runbook] 프로세스 간 메모리 마법 (Shared Memory)


Zenoh는 이 로컬 IPC(Inter-Process Communication)의 악몽을 **Shared Memory(공유 메모리)** 튜닝을 통해 극한의 제로 카피로 돌파한다.
(`Cargo.toml`에서 `shared-memory` Feature 명시적 활성화 必)

**1. 공유 메모리 버퍼 사전 할당 (Publisher 측)**
미친 듯한 배포 루프 안에서 매번 버퍼를 `Vec::new()`로 파지 마라.
미리 RAM 한쪽에 공용 영토(Shared Memory)를 잡아두고 거기다 그림을 그린 뒤, 포인터만 휙 던지는 형태다.
```rust
use zenoh::shm::ShmProvider;

let session = zenoh::open(Config::default()).res().await.unwrap();

// 1. 라우터와 세션에 종속되는 커널 공유 메모리 공급자 객체 활성화
let mut shm = zenoh::shm::ShmProvider::new(session.clone()).res().await.unwrap();

// 2. 20MB 짜리 빈 버퍼를 공유 메모리에 '미리' 뚫어둔다.
let mut shared_buffer = shm.alloc(20 * 1024 * 1024).res().await.unwrap();

// 3. 센서에서 읽은 값을 저 공간에 직접 덮어쓴다 (복사 없음)
fill_camera_data_to(&mut shared_buffer);

// 4. [핵심] 20MB 버퍼를 넘기는 게 아니라, "공유 메모리 15번 주소 번지"라는 참조표(Ticket)만 넣어서 발사!
publisher.put(shared_buffer).res().await.unwrap();

2. 수신 측의 환희 (Subscriber 측)
Subscriber는 도착한 패킷을 보고, 이 패킷이 원격지(TCP)에서 날아온 진짜 바이트인지, 아니면 옆 동네(Shared Memory)에서 포인터만 날아온 건지 구분할 필요조차 없다. Zenoh 파서가 알아서 추상화해주기 때문이다.
로컬 환경이라면 수신 측 프로세스는 자기 메모리에 할당된 값을 쓰는 것처럼 0.001ms 만에 거대 버퍼를 그대로 읽어 들인다.

이 제로 카피 아키텍처는 NVIDIA Jetson 같은 임베디드 AI 플랫폼에서 ROS2의 고질적 병목인 RMW(미들웨어) 데이터 카피 오버헤드를 완벽히 대체하는 킬러 전술이다.