10.8 데이터 직렬화(Serialization) 및 페이로드 관리

10.8 데이터 직렬화(Serialization) 및 페이로드 관리

“보낼 데이터는 12바이트인데, JSON 포장지가 100바이트다.”
마이크로컨트롤러 세계에서 이런 기상천외한 낭비는 배터리 광탈과 통신 장애(Timeout)로 직결된다.

이 장은 9.4장(C/Go 이기종 직렬화)의 극단적 버전이다. Protobuf 같은 화려한 코드 제너레이터(Code Generator)조차 플래시 메모리(Flash)가 모자라 쓰지 못하는 MCU 환경에서, 데이터를 극한으로 구겨서(Compaction) 한 땀 한 땀 네트워크로 우겨넣는 처절한 페이로드 매니지먼트 런북이다.

1. 경량화된 데이터 직렬화 기법: CBOR, MessagePack 연동

만약 JSON 처럼 Key-Value 형태의 유연함이 필요하긴 한데, 배열이나 객체를 바이너리 덩어리로 날리고 싶다면 어떻게 해야 할까?
이때 등장하는 것이 JSON 의 이진(Binary) 버전인 CBORMessagePack 이다.

1.0.1 [Runbook] 마이크로-패커(Micro-Packer) CBOR 전술

메모리를 1KB 도 쓰지 않는 순수 C 언어 라이브러리(예: tinycbor)를 Pico 펌웨어에 결합한다.

#include <tinycbor/cbor.h>
#include <zenoh-pico.h>

void publish_cbor_telemetry() {
    uint8_t buffer[64]; // 매우 작은 스택 메모리 배열
    CborEncoder encoder, mapEncoder;

    // 1. 인코더 초기화 (메모리 맵핑)
    cbor_encoder_init(&encoder, buffer, sizeof(buffer), 0);
    
    // 2. Map (JSON의 객체 {}) 생성 
    cbor_encoder_create_map(&encoder, &mapEncoder, 2); // 항목 2개 짜리
    
    // 3. {"temp": 28.5} 데이터 구겨넣기
    cbor_encode_text_stringz(&mapEncoder, "temp");
    cbor_encode_double(&mapEncoder, 28.5);
    
    // {"status": 1}
    cbor_encode_text_stringz(&mapEncoder, "status");
    cbor_encode_int(&mapEncoder, 1);
    
    cbor_encoder_close_container(&encoder, &mapEncoder);

    // 4. 완성된 CBOR 바이트 크기 (이 크기는 JSON의 절반도 안 된다!)
    size_t length = cbor_encoder_get_buffer_size(&encoder, buffer);

    // 5. Zenoh 로 발사! 백엔드의 Go/Rust 가 이 CBOR 을 똑같이 파싱해줄 것이다.
    z_publisher_put(&pub, buffer, length, NULL);
}

이 방식은 각 언어(C, Go, Rust, TS)에 모두 CBOR 라이브러리가 존재하기 때문에 호환성이 100% 보장되면서도, “문자열 파싱(String Parsing)” 이라는 최악의 CPU 낭비를 MCU 단에서 완전히 제거해 주는 IoT 의 정석이다.

2. 센서 데이터(JSON, Raw Bytes) 파싱 및 최적화

어쩔 수 없이 클라우드 서버 조직의 요구로 인해 거꾸로 MCU 에서 JSON 을 수신해서 읽어야 할 때가 있다.
cJSON 같은 유명한 C 라이브러리는 parse() 를 한 번 부를 때마다 내부적으로 십여 번의 malloc 트리 구조를 생성한다. 8KB RAM 환경에서 이 함수를 부르는 건 사실상 자살 버튼을 누르는 것과 같다.

2.0.1 [Runbook] 메모리 파괴 방어 토큰 파서(JSMN) 전술

오직 문자열의 인덱스(“이 글자는 몇 번째부터 몇 번째에 있다”) 정보만 뽑아주는 극도로 단순한 In-place 파서 jsmn (Jasmin) 을 사용해야 한다.

#include "jsmn.h"
#include <zenoh-pico.h>

void json_cmd_callback(const z_sample_t *sample, void *ctx) {
    // 1. 파서 및 토큰 저장소 스택(Stack) 메모리에 할당
    jsmn_parser p;
    // json 트리 구조가 아무리 깊어도 16번까지만 기억하겠다. (malloc 방지!)
    jsmntok_t t[16]; 

    jsmn_init(&p);
    char *js = (char *)sample->payload.start;
    
    // 2. 구문 분석 (동적 할당이 1도 일어나지 않는다!)
    int r = jsmn_parse(&p, js, sample->payload.len, t, 16);
    if (r < 0) {
        printf("JSON 포맷 에러 또는 크기 초과!\n");
        return;
    }

    // 3. 토큰 검색: {"command": "ON"} 찾기
    for (int i = 1; i < r; i++) {
        if (t[i].type == JSMN_STRING && 
            strncmp(js + t[i].start, "command", t[i].end - t[i].start) == 0) {
            
            // Value 포지션(i+1) 읽기
            if (strncmp(js + t[i+1].start, "ON", t[i+1].end - t[i+1].start) == 0) {
                // 진정한 0-바이트 오버헤드 LED 켜기 명령 실행!
                HW_LED_ON(); 
            }
        }
    }
}

MCU 네트워크에서 절대 잊지 마라. “읽고 버려라(Read and Forget).”
날아오는 통신 버퍼를 굳이 내 전역 구조체로 옮겨 닮으려고 sprintfstrcpy 를 남발하는 순간 시스템 지연 시간(Latency)은 겉잡을 수 없이 박살 난다.

3. 큰 페이로드(Payload) 분할 전송 및 조립 메커니즘

만약 센서가 찍은 50KB 짜리 저해상도 JPEG 사진을 클라우드로 쏴야 한다 치자.
Pico 는 1500바이트 짜리 TCP 윈도우 한두 장만 가지고 있다. 이걸 z_put 에 한 번에 때려박으면 즉시 LwIP 통신 스택이 OOM(Out of Memory) 단말마를 뱉으며 칩이 하얗게 멈춰버린다.

3.0.1 [Runbook] 청크 디바이드 앤 퀀커(Chunk Divide & Conquer) 전술

우리가 수동으로 쏘는 택배를 잘게 쪼개서(Split) 수동으로 발송해야 한다. 수신단의 클라우드 백엔드(Go, Rust) 가 이 플래그 번호들을 취합해 조립하도록 아키텍처를 강제해야 한다.

#include <zenoh-pico.h>

#define CHUNK_SIZE 1024 // 1KB 단위

// 커스텀 헤더 구조체 
typedef struct {
    uint32_t image_id;
    uint32_t total_chunks;
    uint32_t current_chunk;
    uint32_t data_len;
} __attribute__((packed)) ChunkHeader;

void transmit_large_image(z_publisher_t *pub, uint8_t *image_buffer, uint32_t total_size) {
    uint32_t chunks = (total_size / CHUNK_SIZE) + 1;
    uint8_t packet[CHUNK_SIZE + sizeof(ChunkHeader)];
    
    for (uint32_t i = 0; i < chunks; i++) {
        // 1. 헤더 조립
        ChunkHeader *hdr = (ChunkHeader*)packet;
        hdr->image_id = 999;
        hdr->total_chunks = chunks;
        hdr->current_chunk = i;
        
        // 남은 바이트 계산
        uint32_t offset = i * CHUNK_SIZE;
        hdr->data_len = (total_size - offset > CHUNK_SIZE) ? CHUNK_SIZE : (total_size - offset);
        
        // 2. 바이트 복사 (1KB 만 퍼나름)
        memcpy(packet + sizeof(ChunkHeader), image_buffer + offset, hdr->data_len);
        
        // 3. 발사!
        z_publisher_put(pub, packet, sizeof(ChunkHeader) + hdr->data_len, NULL);
        
        // 4. [극도로 중요함] 칩의 전송 버퍼가 쉴 시간을 주어야 한다!
        // 이 딜레이가 없으면 TCP 큐가 꽉 차버려 패킷 로스가 폭주한다.
        vTaskDelay(pdMS_TO_TICKS(10)); // FreeRTOS 기준 10ms 휴식
    }
}

이 코드는 단순해 보이지만 이기종 클라우드를 이어주는 “대용량 블랍(BLOB) 파이프라인” 의 심장이다.
마이크로컨트롤러에서는 무언가를 한꺼번에 집어삼키려 하지 마라. 가늘고 길게 베어 물어야 기계가 멈추지 않는다.