Split View: 바이너리 직렬화 완전 가이드 2025: Protobuf, Thrift, Avro, MessagePack, FlatBuffers, Cap'n Proto — 성능 vs 유연성의 선택
바이너리 직렬화 완전 가이드 2025: Protobuf, Thrift, Avro, MessagePack, FlatBuffers, Cap'n Proto — 성능 vs 유연성의 선택
들어가며: JSON은 진짜 최선인가?
흔한 시나리오
REST API를 만들 때 대부분의 개발자가 자연스럽게 선택하는 것:
{
"id": 12345,
"name": "Alice",
"email": "alice@example.com",
"age": 30
}
JSON은 사람이 읽을 수 있고, 언어 중립적이며, 어디서나 지원된다. 완벽해 보인다.
그런데 하루에 10억 건의 요청을 처리하는 서비스라면? 각 요청이 1 KB JSON이면 하루에 1 TB의 데이터. 파싱 비용, 네트워크 대역폭, 스토리지 — 모두 비싸진다.
같은 데이터를 Protobuf로 표현하면 ~100 바이트. 파싱 속도는 10배 이상 빠르다. 하루에 100 GB 대신 10 GB. 1년이면 수십 TB 차이.
왜 바이너리 직렬화인가?
JSON의 약점:
- 크기: 필드명을 매번 반복.
"email"이 수십 번. - 파싱 비용: 텍스트를 숫자/객체로 변환.
- 타입 정보 없음: 런타임 타입 체크 필요.
- Schema 없음: 문서화와 검증 별도.
바이너리 포맷의 강점:
- 작다: 필드명 대신 숫자 tag.
- 빠르다: 직접 메모리 매핑 또는 간단한 디코딩.
- 타입 안전: 스키마 기반.
- 진화 가능: schema evolution 지원.
이 글에서 비교할 것
- Protocol Buffers (Google): 가장 널리 쓰이는 표준.
- Thrift (Facebook/Apache): RPC 통합.
- Avro (Apache Hadoop): 빅데이터의 사실상 표준.
- MessagePack: JSON의 바이너리 교체.
- FlatBuffers (Google): Zero-copy 읽기.
- Cap'n Proto (Sandstorm): Zero-copy + RPC.
각 포맷의 내부 구조, 인코딩 방식, 언제 쓰는지를 720줄로 파고든다.
1. Protocol Buffers: 표준의 왕
역사
- 2001: Google 내부 개발 시작.
- 2008: 오픈소스로 공개 (proto2).
- 2016: proto3 출시. 더 단순한 문법.
- 2023: Edition 2023 출시. 점진적 기능 추가.
gRPC의 기본 포맷. Google 전체가 이를 쓰며, 외부에서도 가장 인기 있는 바이너리 직렬화.
IDL (Interface Definition Language)
Protobuf는 .proto 파일로 스키마를 정의한다:
syntax = "proto3";
message Person {
int32 id = 1;
string name = 2;
string email = 3;
optional int32 age = 4;
repeated string phone_numbers = 5;
}
- 각 필드는 tag number (1, 2, 3...)를 가진다.
- Tag number가 와이어 포맷의 키다. 필드명은 코드 생성에만 사용.
코드 생성
protoc 컴파일러가 타겟 언어의 코드를 생성:
protoc --java_out=. --python_out=. --go_out=. person.proto
결과: Java, Python, Go 클래스들. 각각 직렬화/역직렬화 메서드 포함.
Wire Format: 핵심 아이디어
Protobuf의 인코딩은 key-value 스트림이다. 각 필드는:
[tag + wire_type][value]
Wire types:
0: Varint (정수, boolean, enum).1: 64-bit (fixed64, double).2: Length-delimited (string, bytes, message, packed repeated).5: 32-bit (fixed32, float).
Varint: 가변 길이 정수
Protobuf의 핵심 최적화: varint 인코딩. 작은 숫자는 작은 바이트로:
150을 varint로:
150 = 0b10010110
Step 1: 7비트씩 쪼개기
→ [0b0000001, 0b0010110]
Step 2: 각 바이트의 최상위 비트를 "연속 표시"로 사용
→ [0b10010110, 0b00000001]
Step 3: Little-endian 순서
→ [0x96, 0x01]
결과:
0~127: 1 바이트.128~16383: 2 바이트.16384~2097151: 3 바이트.- 등등.
작은 숫자는 1 바이트, 큰 숫자는 여러 바이트. 대부분 필드가 작으니 평균적으로 효율적.
인코딩 예시
message Person {
int32 id = 1;
string name = 2;
}
id = 150, name = "Bob"
Wire:
Field 1 (id):
tag = (1 << 3) | 0 = 0x08 (field 1, wire type 0 = varint)
value = 150 varint = 0x96 0x01
Field 2 (name):
tag = (2 << 3) | 2 = 0x12 (field 2, wire type 2 = length-delimited)
length = 3
value = "Bob" = 0x42 0x6F 0x62
Total: 08 96 01 12 03 42 6F 62 (8 bytes)
8바이트! JSON은:
{"id":150,"name":"Bob"}
23바이트. 2.9배 작다.
ZigZag Encoding: 음수 최적화
Varint는 양수에 최적화되어 있다. 음수는 매우 큰 varint가 된다 (2의 보수로 변환되기 때문):
-1 → 0xFFFFFFFF = 10 바이트!
해결: sint32, sint64 타입은 ZigZag 인코딩을 쓴다:
0 → 0
-1 → 1
1 → 2
-2 → 3
2 → 4
...
공식: (n << 1) ^ (n >> 31)
음수와 양수가 교대로 매핑되어 절대값이 작을수록 인코딩이 짧다.
Schema Evolution
Protobuf는 forward/backward compatibility를 지원한다:
규칙:
- Tag number를 절대 바꾸지 마라: tag는 ID다.
- 필드 이름 변경 OK: wire format과 무관.
- 새 필드 추가 OK: 이전 클라이언트는 무시.
- 필드 삭제 OK, 단 tag를 reserved로: 재사용 방지.
- required는 쓰지 마라: proto3에서 제거됨.
예시:
// v1
message Person {
int32 id = 1;
string name = 2;
}
// v2 (호환성 유지)
message Person {
int32 id = 1;
string name = 2;
string email = 3; // 새 필드
reserved 4, 5; // 미래를 위한 예약
}
v1 서버가 v2 클라이언트의 메시지를 받아도 email 필드를 모르고 무시한다. 반대도 마찬가지. 이것이 분산 시스템에서 배포 시 매우 중요하다.
Proto3의 Default Values
Proto3는 모든 필드가 optional이다. 없으면 default 값:
- 숫자: 0
- 문자열: ""
- bool: false
- message: null
함정: "값이 0인가?"와 "값이 없는가?"를 구분할 수 없다. 이를 해결하려면 optional 키워드 (proto3 2.6+):
message Person {
optional int32 age = 1; // 명시적 존재 추적
}
성능
Protobuf는 매우 빠르다:
- JSON 대비 3~10배 작은 크기.
- 파싱 속도: 5~100배 (언어 구현에 따라).
- 메모리 할당 적음 (재사용 가능).
2. Apache Thrift: RPC 통합
탄생
- 2007: Facebook 공개.
- 2008: Apache Incubator.
- 현재: 많은 대기업의 내부 RPC 표준.
Protobuf와 비슷한 시기에 등장. Facebook 규모에서 검증되었다.
Protobuf와의 차이
Thrift는 직렬화 + RPC + 서버 프레임워크를 모두 제공한다:
service UserService {
Person getUser(1: i32 id) throws (1: NotFoundException e),
list<Person> listUsers(1: i32 limit, 2: i32 offset)
}
struct Person {
1: i32 id,
2: string name,
3: optional string email
}
한 파일에 데이터 구조와 서비스 정의가 함께. Protobuf는 gRPC와 분리되어 있었지만 Thrift는 통합.
Transport 계층
Thrift의 특이한 점: transport, protocol, server를 분리.
Transport: 데이터가 어떻게 이동하나.
- TSocket, TFramedTransport, TMemoryBuffer, ...
Protocol: 데이터가 어떻게 인코딩되나.
- TBinaryProtocol: 단순, 고정 크기.
- TCompactProtocol: 가변 길이 (varint 유사).
- TJSONProtocol: JSON 형식.
Server: 요청을 어떻게 처리하나.
- TSimpleServer, TThreadPoolServer, TNonblockingServer, ...
이 조합성으로 상황에 맞게 선택 가능.
TCompactProtocol
Thrift의 가장 효율적 인코딩. Protobuf와 비슷한 아이디어:
- Varint: 정수.
- ZigZag: 음수.
- Field ID delta: 이전 필드 ID와의 차이만 저장 (더 작음).
- Type + ID 병합: 1 바이트에 wire type과 field id 함께.
결과: Protobuf와 거의 동일한 크기. 어떤 경우엔 조금 더 작다.
장단점
장점:
- 통합 RPC: 별도 gRPC 설정 없이 완결.
- 언어 지원: 27개 이상.
- 성숙: Facebook, Uber, Twitter 내부 검증.
- 유연한 transport/protocol.
단점:
- 최근 업데이트 감소: Thrift 4 논의 중이지만 느림.
- 문서 분산: Protobuf보다 덜 정돈됨.
- gRPC에 밀림: Google의 마케팅 파워.
선택 지침:
- 새 프로젝트: gRPC (Protobuf).
- 기존 Thrift 시스템: 유지.
3. Apache Avro: 빅데이터의 친구
배경
Hadoop 생태계에서 나온 포맷. 현재 Kafka, Spark, Hive의 사실상 표준.
핵심 특징: Schema를 데이터와 함께
Protobuf/Thrift는 스키마가 코드 생성 시점에 사용된다. Avro는 다르다:
Avro의 철학: 스키마가 데이터와 함께 전달되거나 중앙에서 관리된다.
두 가지 모드:
- Container File Mode: 파일 헤더에 schema 포함.
- Schema Registry Mode: 중앙 registry에서 schema ID로 참조.
Schema 예시
Avro 스키마는 JSON으로 작성:
{
"type": "record",
"name": "Person",
"fields": [
{ "name": "id", "type": "int" },
{ "name": "name", "type": "string" },
{ "name": "email", "type": ["null", "string"], "default": null },
{ "name": "age", "type": "int", "default": 0 }
]
}
Wire Format
Avro의 인코딩은 매우 간단하다:
- 고정 크기 타입: 그대로 저장 (int, long, float, double).
- ZigZag varint: int와 long.
- 가변 크기: 길이 + 데이터 (string, bytes).
- Record: 필드를 스키마 순서대로 나열.
중요: Tag number 없음! 필드 순서가 곧 구조다.
결과: 매우 압축된 바이너리. 스키마 없이는 해석 불가능.
예시
같은 Person:
id=150, name="Bob", email=null, age=0
Avro wire:
150 (zigzag varint) = 0xAC 0x02 (2 bytes)
length 3 + "Bob" = 0x06 0x42 0x6F 0x62 (4 bytes)
null union tag = 0x00 (1 byte)
0 (zigzag varint) = 0x00 (1 byte)
Total: 8 bytes
Schema Evolution: Writer vs Reader Schema
Avro의 킬러 기능: Writer schema ≠ Reader schema.
- Writer schema: 데이터를 쓸 때 사용된 스키마.
- Reader schema: 데이터를 읽을 때 기대하는 스키마.
Avro는 두 스키마 간 차이를 자동 해결한다:
Writer: {id, name, age}
Reader: {id, name, email}
age는 reader에 없음 → 무시.email은 writer에 없음 → reader의 default 사용.
이 덕분에 호환성 검사와 마이그레이션이 수월.
Schema Registry
Confluent Schema Registry는 Avro 스키마를 중앙 관리:
Producer:
schema_id = registry.register(schema)
message = [magic byte][schema_id (4B)][avro payload]
Consumer:
schema_id = extract_from_message
schema = registry.get(schema_id)
data = decode(payload, schema)
각 메시지는 4바이트 schema ID만 포함. Payload는 순수 Avro. 수십~수백 GB의 Kafka 토픽에서 이 절약이 누적되면 엄청나다.
Kafka + Avro + Schema Registry
이 3개는 빅데이터 스트리밍의 골든 조합이다:
- Kafka: 메시지 브로커.
- Avro: 효율적 인코딩 + schema evolution.
- Schema Registry: 중앙 스키마 관리 + 호환성 검증.
Confluent Platform의 기본 아키텍처. Netflix, LinkedIn, Uber 등 모두 채택.
Protobuf vs Avro
| 항목 | Protobuf | Avro |
|---|---|---|
| 스키마 필요성 | 코드 생성 시 | 쓰기/읽기 시 |
| Field identification | Tag number | 순서 |
| Wire size | 비슷 | 비슷 (Avro 약간 작음) |
| Schema evolution | 양호 | 최고 |
| 빅데이터 친화 | 중간 | 최고 |
| RPC 지원 | gRPC | 없음 (별도) |
| 언어 지원 | 광범위 | 중간 |
선택 기준:
- RPC: Protobuf (gRPC).
- Kafka / 빅데이터 스트리밍: Avro.
- 파일 저장: Avro (자기 설명 파일).
4. MessagePack: JSON의 직접적 대체
철학
MessagePack의 슬로건: "It's like JSON. but fast and small."
- JSON과 1:1 매핑 가능.
- 스키마 불필요.
- 바이너리라 더 작고 빠름.
예시
JSON:
{"id": 150, "name": "Bob"}
MessagePack (hex):
82 A2 69 64 CC 96 A4 6E 61 6D 65 A3 42 6F 62
82: map with 2 items.A2: fixstr of length 2 ("id").CC 96: uint8 value 150.A4: fixstr of length 4 ("name").A3 42 6F 62: fixstr of length 3 "Bob".
총 15 바이트. JSON의 25 바이트에서 40% 감소.
특징
- 스키마 없음: 즉시 사용 가능.
- 타입 보존: int, string, array, map, binary, etc.
- Extension: 사용자 정의 타입 지원 (timestamp, UUID 등).
- 언어 지원: 50개 이상.
사용처
- Redis: Lua 스크립트 결과, 일부 내부 통신.
- Fluentd: 로그 수집 프로토콜.
- ZeroMQ: 메시지 바인딩.
- 게임: 실시간 네트워크 프로토콜.
Protobuf와의 차이
| 항목 | MessagePack | Protobuf |
|---|---|---|
| 스키마 | 없음 | 필수 |
| 크기 | 중간 | 작음 |
| 파싱 속도 | 빠름 | 매우 빠름 |
| 자기 설명 | 예 | 아니오 |
| Schema evolution | 약함 | 강함 |
| IDE 지원 | 적음 | 많음 |
MessagePack은 JSON 대체로 가장 적합하다. 스키마 시스템이 부담스럽지만 JSON보다 빠르고 작기를 원할 때.
5. FlatBuffers: Zero-Copy의 답
동기
Google이 게임과 모바일을 위해 개발. 목표: 역직렬화 시간 0.
기존 포맷의 문제:
- Protobuf/Avro/MessagePack: 읽으려면 전체 파싱이 필요. 새 객체 할당.
- 작은 메시지는 문제없지만 큰 메시지나 자주 접근하는 경우 오버헤드.
아이디어: 메모리에서 직접 사용
FlatBuffers는 메모리 레이아웃 자체를 직렬화 포맷으로 쓴다:
Byte stream:
[root offset][vtable 1][table 1][vtable 2][table 2]...
읽을 때:
- 바이트 버퍼를 얻음 (memcpy 또는 mmap).
- 루트 offset을 따라감.
- 객체 할당 없이 바로 필드 접근.
auto person = GetPerson(buffer);
int id = person->id(); // 실제 파싱 없이 바로 메모리 읽기
결과: 역직렬화 시간 거의 0. 메모리 할당 없음. 캐시 친화적.
IDL
Protobuf와 비슷한 스키마:
namespace Game;
table Person {
id: int;
name: string;
email: string;
}
root_type Person;
성능
- 역직렬화: Protobuf 대비 1000배 빠름 (0에 가까움).
- 메모리: 할당 없음.
- 직렬화: Protobuf보다 약간 느림 (vtable 생성).
- 크기: Protobuf보다 20~50% 큼.
사용처
- 게임 엔진: Cocos2d, 여러 게임 데이터.
- 모바일 앱: 데이터 파일 포맷.
- Apache Arrow: 일부 포맷 공유.
- Facebook: 주요 사용자.
- TensorFlow Lite: 모델 파일.
언제 쓸까
- 자주 접근하지만 쓰기 드묾.
- 큰 데이터 구조.
- 메모리 제약.
- 빠른 로드가 중요.
단점:
- 크기: Protobuf보다 큼.
- 유연성 낮음: 쓰기가 복잡.
- 디버깅 어려움: 바이너리 직접 보기 힘듦.
RandomAccess의 가치
FlatBuffers의 큰 강점: 랜덤 액세스. 1 GB 파일에서 특정 필드만 읽고 싶으면:
auto file = mmap(filename); // 실제 디스크 로딩은 lazy
auto root = GetRoot(file);
auto item_100 = root->items()->Get(100);
int value = item_100->value();
// 여기서 실제로 필요한 페이지만 디스크에서 로드
전체 파싱이 필요 없다. 큰 설정 파일, 게임 자원, ML 모델에 이상적.
6. Cap'n Proto: FlatBuffers의 사촌
탄생
Kenton Varda (Protobuf v2 주요 개발자)가 Google을 떠나 만든 차세대 포맷. 철학:
"Protocol Buffers, infinity times faster."
Zero-Copy + RPC
FlatBuffers의 zero-copy 장점 + 내장 RPC 시스템. Sandstorm 프로젝트에서 사용.
스키마
struct Person {
id @0 :Int32;
name @1 :Text;
email @2 :Text;
}
Protobuf와 비슷하지만 @N 문법으로 순서 표시.
인코딩 특성
- 정렬된 메모리: 8바이트 경계.
- Zero-copy 가능: FlatBuffers처럼.
- Packed 압축: 선택적으로 적용해 크기 줄이기.
- 랜덤 액세스: 큰 메시지의 일부만 접근.
Canonical form
Cap'n Proto는 같은 데이터는 항상 같은 바이트를 생성하려 한다 (hash 기반 비교에 유용).
RPC 특징
Time Traveling RPC: 서버가 응답하기 전에 그 결과를 다른 서비스에 미리 전달할 수 있다. Promise pipelining.
Client → A: getUser(1)
Client → A: (promise of user) → B.emailSummary(user)
A와 B 사이에서 직접 데이터가 흐름. Client가 중간에 안 걸림. 레이턴시 감소.
사용처
FlatBuffers보다 덜 쓰이지만 Cloudflare 같은 대기업이 내부에서 사용.
FlatBuffers vs Cap'n Proto
| 항목 | FlatBuffers | Cap'n Proto |
|---|---|---|
| Zero-copy | 예 | 예 |
| 크기 | 중간 | 비슷 |
| RPC | 없음 | 있음 |
| 성숙도 | 더 성숙 | 젊음 |
| 언어 지원 | 많음 | 적음 |
Cap'n Proto가 기술적으로 더 우수한 점이 있지만, FlatBuffers가 실전에서 더 널리 쓰인다.
7. 기타 주목할 만한 포맷
CBOR (Concise Binary Object Representation)
IETF 표준 (RFC 8949). JSON의 "진짜" 바이너리 버전. IoT와 CoAP에서 사용.
- 스키마 없음.
- JSON과 1:1 매핑.
- MessagePack과 유사하지만 표준화됨.
BSON (Binary JSON)
MongoDB의 저장 포맷. JSON 스타일이지만 추가 타입(ObjectId, Date, Binary).
- 스키마 없음.
- JSON보다 약간 크지만 파싱 빠름.
- 주로 MongoDB 내부 용도.
Apache Arrow
Columnar 메모리 포맷. 앞서 설명. 직렬화도 가능하지만 주 용도는 프로세스 간 zero-copy 교환.
Parquet
Columnar 디스크 포맷. 앞서 설명. 직렬화라기보다 저장 포맷이지만 언어 간 호환을 제공.
Bencode
BitTorrent의 프로토콜. 단순하지만 효율은 떨어짐.
Smile
JSON-like 바이너리. Jackson 라이브러리의 일부.
8. 성능 비교: 숫자로 보기
벤치마크 시나리오
전형적인 메시지 (Person 객체, 100 필드, 중첩 포함):
| 포맷 | 크기 | 직렬화 | 역직렬화 |
|---|---|---|---|
| JSON | 5.2 KB | 85 μs | 120 μs |
| BSON | 4.8 KB | 75 μs | 105 μs |
| MessagePack | 3.8 KB | 45 μs | 60 μs |
| Protobuf | 2.8 KB | 15 μs | 25 μs |
| Avro | 2.6 KB | 20 μs | 35 μs |
| Thrift (Compact) | 2.7 KB | 18 μs | 30 μs |
| Cap'n Proto | 3.2 KB | 10 μs | 1 μs |
| FlatBuffers | 3.5 KB | 12 μs | 1 μs |
핵심 관찰:
- 크기: Avro > Thrift > Protobuf > FlatBuffers > MessagePack > JSON.
- 직렬화 속도: 모두 JSON보다 빠름.
- 역직렬화: Cap'n Proto/FlatBuffers가 압도적 (zero-copy).
실전 처리량
1 GB 데이터 처리 (100 GB 시스템 메모리):
| 포맷 | 시간 |
|---|---|
| JSON | 120 s |
| MessagePack | 60 s |
| Protobuf | 25 s |
| Avro (columnar compression) | 30 s |
| FlatBuffers | 2 s (주로 mmap 시간) |
FlatBuffers의 이점은 대량 데이터 처리에서 극명하다.
9. Schema Evolution 비교
시간이 지나며 스키마가 변한다. 어떻게 다른 버전을 처리하는가?
Protobuf
- Tag number 기반: 변경 금지.
- 필드 추가: OK (새 tag).
- 필드 삭제: OK (tag를 reserved로).
- 타입 변경: 호환 타입끼리만 (int32 ↔ int64).
- Forward/Backward 호환성: 모두 가능.
안전한 변경 (v1 → v2):
- 새 필드 추가.
- 필드 이름 변경.
- required → optional.
위험한 변경:
- Tag number 변경.
- 타입 급변 (string → int).
- 필드 삭제 후 tag 재사용.
Thrift
Protobuf와 유사. Tag 기반이며 호환성 잘 지원.
Avro
Writer + Reader schema로 처리. Avro의 schema resolution 규칙:
- 필드 매칭: 이름 기준 (aliases 지원).
- 없는 필드: default 값 사용.
- 타입 promotion: int → long, float → double.
- Union 진화: null 추가 가능.
Avro는 가장 강력한 evolution 시스템. 복잡한 변경도 가능.
JSON
스키마 없음 → 평가가 어렵지만 사실상 아무렇게나 가능.
- 새 필드 추가: 쉬움 (클라이언트가 무시).
- 필드 삭제: 위험 (클라이언트가 의존할 수 있음).
- 타입 변경: 위험.
JSON Schema를 쓰면 검증 가능하지만, 코드 레벨의 자동 호환성은 없다.
FlatBuffers / Cap'n Proto
Tag 기반. Protobuf와 유사한 규칙. 단, zero-copy 때문에 필드 재배치 금지.
실전 규칙
- Tag/필드 번호는 영원: 절대 재사용하지 마라.
- 필드 추가만: 삭제보다 추가가 안전.
- Optional을 기본: 새 필드는 optional로.
- Default 값: 명시적으로 설정.
- Deprecated 표시: 코드 주석으로 사용 중단 알림.
- CI에서 호환성 검증: buf breaking (Protobuf), Schema Registry (Avro).
10. 선택 가이드
시나리오별 추천
REST API (공개 API):
- JSON: 여전히 기본. 누구나 파싱 가능.
- 내부 최적화: MessagePack 또는 Protobuf over HTTP.
gRPC 서비스:
- Protobuf: 기본. gRPC가 이를 전제로 설계됨.
Kafka / 스트리밍:
- Avro + Schema Registry: 표준 조합.
- 대안: Protobuf with Schema Registry.
게임 / 실시간 네트워킹:
- FlatBuffers: 게임 데이터.
- Cap'n Proto: 추가 RPC 필요 시.
- MessagePack: 유연함 필요 시.
설정 파일 / CLI:
- YAML/JSON/TOML: 사람이 읽을 수 있어야.
ML 모델:
- FlatBuffers: TensorFlow Lite 방식.
- Protobuf: ONNX 방식.
- SafeTensors: 최신 트렌드.
로그 / 이벤트:
- Avro: Big data 파이프라인.
- Protobuf: gRPC 로그.
- JSON: 사람이 파싱.
IoT / 임베디드:
- CBOR: 표준 IoT.
- Protobuf: 성능 중시.
- MessagePack: 유연함.
MongoDB 저장:
- BSON: 고정 (MongoDB 전용).
결정 트리
스키마를 원하는가?
├── 예
│ ├── RPC 통합 필요? → gRPC + Protobuf or Thrift
│ ├── 빅데이터 진화 중요? → Avro
│ ├── Zero-copy 필요? → FlatBuffers or Cap'n Proto
│ └── 일반 → Protobuf
└── 아니오
├── JSON 호환? → MessagePack or CBOR
├── MongoDB? → BSON
└── 사람이 읽어야? → JSON/YAML
11. 함정과 실전 교훈
함정 1: "JSON이 충분한데 Protobuf 쓰기"
문제: 소규모 API에 복잡한 코드 생성 파이프라인.
교훈: 도구가 문제를 정당화해야 한다. 트래픽이 낮으면 JSON의 개발 편의성이 Protobuf의 성능 이득을 능가한다.
함정 2: "Protobuf에서 오래된 필드 삭제하고 tag 재사용"
문제: v1 데이터를 v2 코드가 잘못 해석.
교훈: Tag number는 영원하다. 삭제 필요하면 reserved로 표시.
함정 3: "Avro에서 writer schema 잃어버림"
문제: Kafka 토픽의 오래된 메시지를 읽을 수 없음.
교훈: Schema Registry 사용. Writer schema는 반드시 보관.
함정 4: "FlatBuffers의 파일 크기 증가"
문제: Protobuf에서 FlatBuffers로 전환 후 파일 크기 증가.
교훈: FlatBuffers는 속도 우선. 크기가 중요하면 Protobuf가 낫다. 또는 FlatBuffers에 압축 층을 추가.
함정 5: "수십 개의 타겟 언어 코드 생성 유지"
문제: protoc 버전 관리, 각 언어별 빌드.
교훈: 모노레포 + buf 같은 도구 사용. CI에서 자동화.
함정 6: "Schema evolution 규칙 위반"
문제: 필드 추가인데 required로, 또는 타입 변경.
교훈: 자동 검증. buf breaking, Schema Registry compatibility check.
12. 실전 도구
buf: Protobuf의 현대 도구
Uber와 커뮤니티가 만든 buf는 Protobuf 개발을 획기적으로 개선:
# Lint
buf lint
# Breaking change detection
buf breaking --against .git#branch=main
# Generate code
buf generate
# Remote plugins (protoc 설치 불필요)
buf generate --template buf.gen.yaml
매 프로젝트에 권장.
Confluent Schema Registry
Avro/Protobuf/JSON Schema를 중앙 관리:
- REST API로 스키마 등록/조회.
- Compatibility 검증.
- Kafka와 통합.
// Kafka producer
properties.put("value.serializer", "io.confluent.kafka.serializers.KafkaAvroSerializer");
properties.put("schema.registry.url", "http://localhost:8081");
프로덕션 Kafka + Avro 환경의 필수.
protobuf-validator
Protobuf 메시지에 validation 규칙 추가:
message Person {
int32 age = 1 [(validate.rules).int32 = { gte: 0, lte: 150 }];
string email = 2 [(validate.rules).string.email = true];
}
생성된 코드에 validate 메서드 추가.
grpcurl / grpc_cli
gRPC 서비스를 curl처럼 테스트:
grpcurl -d '{"id": 1}' localhost:50051 myservice.UserService/GetUser
Server reflection을 통해 스키마 자동 획득.
퀴즈로 복습하기
Q1. Protobuf의 varint가 작은 숫자에 효과적인 이유는?
A. Varint는 7비트씩 값을 인코딩하고, 각 바이트의 최상위 비트(MSB)를 "다음 바이트가 있는가?" 플래그로 사용한다.
예시:
0~127: 1 바이트 (MSB=0).128~16383: 2 바이트.- 큰 숫자: 더 많은 바이트.
일반 int32의 경우: 항상 4 바이트. Varint:
0: 1 바이트100: 1 바이트10000: 2 바이트1000000: 3 바이트2^31-1: 5 바이트 (오히려 큼!)
실전 관찰: 대부분의 ID, 카운트, 비트마스크, 인덱스는 작은 값이다. 수백만 번의 메시지에서 대부분의 정수가 1~2 바이트로 인코딩되면 전체 크기가 크게 줄어든다.
예외: 음수
-1을 int32 varint로 하면? 2의 보수 때문에 0xFFFFFFFF가 되고, 5 바이트가 필요하다. 이는 재앙이다.
해결: ZigZag
sint32 타입은 ZigZag 인코딩을 쓴다:
0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4, ...- 절대값이 작을수록 짧게 인코딩.
따라서 음수가 자주 나오는 필드는 sint32/sint64를 써야 한다. Protobuf 튜토리얼의 중요한 교훈이지만 자주 간과된다.
교훈: 데이터의 분포를 알고 포맷을 선택하면 큰 차이를 만든다. Protobuf의 varint는 작은 숫자의 일반적 분포를 활용한 영리한 설계다.
Q2. Avro가 Protobuf와 달리 "스키마를 데이터와 함께 전달"하는 이유와 이점은?
A. Protobuf와 Avro는 근본적으로 다른 철학을 가진다.
Protobuf:
- 스키마는 코드 생성 시점에만 필요.
- Wire format에 tag number만 있음.
- 필드 이름이 없어도 tag로 식별.
- 런타임에는 스키마가 "이미 알려진" 상태를 가정.
Avro:
- 스키마가 쓰기/읽기 시점에 필요.
- Wire format에 필드 이름이나 tag 없음 (순서만).
- 스키마 없으면 해석 불가능.
- 두 가지 운영 모드:
- Container file: 파일 헤더에 스키마 포함.
- Schema Registry: 메시지에 schema ID만, 실제 스키마는 중앙에서.
왜 이런 차이?
Avro는 빅데이터와 스트리밍을 위해 설계되었다. 이 환경의 특성:
- 데이터가 오래 살고 스키마가 진화.
- 소비자가 미래에 누구인지 모름.
- Writer schema ≠ Reader schema가 일상.
Avro의 이점:
-
Writer vs Reader schema resolution:
- Avro는 두 스키마를 모두 받아 자동 변환.
- Protobuf는 tag 기반이라 한 쪽만 알면 됨.
- 복잡한 마이그레이션에서 Avro가 더 유연.
-
자기 설명 파일:
- Avro container file은 스키마를 포함해서 "어떤 도구든" 읽을 수 있음.
- Protobuf는
.proto파일이 없으면 못 읽음.
-
Schema Registry 패턴:
- 중앙에서 스키마를 관리하고 compatibility 검증.
- Kafka + Avro가 빅데이터의 표준이 된 이유.
Protobuf의 반론:
- Tag 기반은 더 효율적 (필드명 없음).
- 코드 생성이 타입 안전성 제공.
- gRPC와 통합.
결론: Avro는 "데이터가 오래 살고 스키마가 변한다" 는 가정에 최적화되었고, Protobuf는 "스키마가 코드와 함께 배포된다" 는 가정에 최적화되었다. 어느 것이 맞는지는 운영 환경에 달려 있다:
- RPC 시스템 (Protobuf가 유리)
- 빅데이터 스트림 (Avro가 유리)
- 저장 파일 (Avro가 유리)
둘 다 검증된 기술이고, 각자의 영역에서 최선의 선택이다.
Q3. FlatBuffers가 "역직렬화 시간 0"을 달성하는 방법은?
A. 메모리 레이아웃 자체를 직렬화 포맷으로 쓴다.
전통 직렬화의 문제: Protobuf, Avro, MessagePack 등은 직렬화된 바이트를 순차적으로 파싱한다:
// Protobuf 스타일
Person person;
person.ParseFromArray(buffer, size); // 전체 데이터를 파싱
int id = person.id(); // 이미 파싱된 필드 접근
- 전체 버퍼를 읽어야 한다.
- 새 객체를 할당해서 필드를 채운다.
- 크기에 비례한 시간과 메모리 소모.
FlatBuffers의 접근: 직렬화된 바이트는 이미 메모리에서 직접 사용할 수 있는 구조다:
// FlatBuffers
auto person = GetPerson(buffer); // 포인터만 캐스팅, 파싱 없음
int id = person->id(); // 내부에서 offset 계산, 바로 메모리 읽기
작동 원리:
- 버퍼 시작에 root offset 저장.
- 각 객체 앞에 vtable offset (가변 구조 처리).
- Vtable: "필드 X는 offset Y에 있다"는 정보.
- 필드 접근은 offset 더하기 + 메모리 읽기. O(1).
이점:
- 역직렬화 시간: 0 (버퍼 매핑 시간만).
- 메모리 할당 없음: 기존 버퍼 재사용.
- 랜덤 액세스: 1 GB 파일에서 필드 하나만 필요하면 그 부분만 로드.
- mmap 친화적: 큰 파일도 게으르게 로드.
- Cache 효율: 한 번 읽으면 CPU 캐시에 남아 재사용.
비용:
- 크기: vtable 오버헤드로 Protobuf보다 20~50% 큼.
- 직렬화 복잡: builder로 단계적 구성.
- 유연성 낮음: 한 번 만든 버퍼를 수정하기 어려움.
- 디버깅: 바이너리 구조를 직접 보기 힘듦.
언제 FlatBuffers를 쓰는가:
- 게임 데이터: 캐릭터 정보, 레벨 데이터. 자주 읽지만 거의 안 바뀜.
- ML 모델 파일: TensorFlow Lite가 대표.
- 모바일 앱 데이터: 빠른 로드가 중요.
- 임베디드: 메모리 제약 심함, 할당 비쌈.
언제 안 쓰는가:
- RPC: 매 요청마다 메시지가 다름 → zero-copy 이점 못 누림.
- 작은 메시지: 수 KB 이하면 파싱 시간이 무시할 정도.
- 자주 수정: 쓰기가 까다로움.
- 네트워크 크기 중요: Protobuf가 더 작음.
FlatBuffers vs 일반 파서:
- 1 MB 메시지 1000번 파싱:
- Protobuf: 1000 × 20 ms = 20 초.
- FlatBuffers: 1000 × 0.001 ms = 1 ms.
1000만 배는 아니지만, 대량 처리에선 극명한 차이. 이것이 FlatBuffers가 게임 엔진과 ML 런타임의 표준이 된 이유다.
교훈: "직렬화 포맷"이 반드시 "파싱 가능한 바이트 스트림"일 필요는 없다. FlatBuffers는 "메모리 레이아웃 = 와이어 포맷"이라는 과감한 선택으로 새로운 성능 영역을 열었다. 엔지니어링은 때때로 기본 가정을 뒤집을 때 도약한다.
Q4. Schema evolution에서 "필드 삭제"가 왜 위험한가?
A. 세 가지 수준의 위험이 있다.
1. Tag number 재사용 위험
Protobuf/Thrift에서 tag는 영원한 ID다:
// v1
message Person {
int32 id = 1;
string name = 2;
string deprecated_field = 3; // 이걸 지우면...
}
// v2 (위험!)
message Person {
int32 id = 1;
string name = 2;
int32 new_field = 3; // tag 3을 재사용
}
문제 시나리오:
- v1 데이터가 저장된 Kafka 토픽이나 DB에
deprecated_field = "hello"가 남아있음. - v2 코드가 이 데이터를 읽음 → tag 3이
int32로 해석됨. "hello"를 int32로 읽으려다 에러 또는 쓰레기 데이터.
해결: reserved 키워드로 tag 재사용 방지:
message Person {
int32 id = 1;
string name = 2;
reserved 3;
reserved "deprecated_field";
}
이제 v2에서 tag 3을 재사용하려 하면 컴파일 에러.
2. Reader가 필드를 기대하는 경우
Avro에서 필드 삭제는 reader schema에 해당 필드가 없으면 OK다:
- Writer v1:
{id, name, age} - Reader v2:
{id, name}(age 없음) - → age 무시. 안전.
문제: Reader에서 필드 삭제 후 다시 writer가 그 필드를 안 쓰면?
- Reader v2는 v1 데이터를 읽을 수 있음 (age 무시).
- Reader v2는 v2 데이터도 읽을 수 있음 (age 없음).
- 그러나 Reader v1은 v2 데이터를 읽을 때... v1은 age를 기대했는데 v2엔 없음 → default 또는 null. 비즈니스 로직이 깨질 수 있음.
교훈: 삭제는 모든 reader가 사라진 후에만.
3. 데이터 의존성
클라이언트 코드가 해당 필드를 사용하고 있다면 삭제는 재앙:
# Old client code
age = person.age # KeyError / AttributeError / 0 (default)
send_birthday_wishes(person.age) # 엉뚱한 결과
필드 삭제는 API 계약 변경이다. 모든 사용처를 추적해야 한다.
안전한 필드 제거 절차:
- 표시 (deprecated): 코드에 주석, 문서에 명시.
- 사용 제거: 모든 writer가 해당 필드를 설정하지 않도록 코드 수정.
- 수집 기간: 몇 주~몇 달 동안 실제로 안 쓰이는지 확인.
- Reader 업데이트: reader 코드에서 이 필드 제거.
- Writer 업데이트: writer schema에서 제거.
- Reserved: Tag를 reserved로 표시 (재사용 방지).
- 모니터링: 이 tag 관련 에러 추적.
실전 조언:
- 추가는 쉽고 삭제는 어렵다: "나중에 지우면 돼"라고 쓴 필드는 영원히 남는다.
- 새 버전보다 필드 추가: 깨지는 변경보다 점진적 추가.
- CI 검증:
buf breaking, Schema Registry compatibility check. - Graceful deprecation: 오래된 필드는 "DEPRECATED" 표시 후 몇 분기 동안 유지.
결론: "삭제" 버튼은 쉽지만 그 결과는 분산 시스템의 여러 계층에 미친다. 스키마 설계 시 처음부터 확장 가능하도록 만드는 것이 최선이다. 삭제가 필요하면 신중한 프로세스로 진행해야 한다.
이것이 "스키마 설계는 한 번 결정하면 수정이 어렵다"는 말의 의미다. 대충 설계한 스키마는 영원한 기술 부채가 된다.
Q5. 언제 JSON을 계속 써야 하고 언제 바이너리로 전환해야 하는가?
A. 단순한 답은 없다. 여러 요인을 고려해야 한다.
JSON을 계속 쓰는 이유:
- 공개 API: 외부 개발자가 사용. 표준 도구로 테스트 가능해야.
- 낮은 트래픽: 처리량이 적으면 최적화 이점 < 복잡성 비용.
- 빠른 개발: 스키마 생성 파이프라인 설정 부담.
- 디버깅 편의: 로그에서 바로 읽을 수 있음.
- 다양한 클라이언트: JavaScript 브라우저, 쉘 스크립트 등.
- 작은 메시지: 파싱 오버헤드가 네트워크 대비 미미.
바이너리로 전환해야 하는 신호:
- 트래픽 증가: 일 10억+ 요청, 대역폭 비용 무시 못 함.
- CPU 병목: 프로파일링에서 JSON 파싱이 10% 이상.
- 내부 서비스: 공개 API가 아니고 마이크로서비스 간 통신.
- 스키마 진화 필요: 버전 호환성이 중요해짐.
- 타입 안전성 부족으로 버그: "필드 이름 오타"로 인한 장애.
- 모바일/IoT: 대역폭과 배터리가 제한됨.
구체적 임계점:
JSON 유지:
- 하루 < 100만 요청.
- 평균 메시지 < 10 KB.
- API가 외부 공개.
- 개발팀이 소규모.
MessagePack 전환 (JSON의 부드러운 업그레이드):
- JSON 구조 유지하며 크기/속도 약간 개선.
- 스키마 없이도 OK.
- 소수의 내부 통신.
Protobuf 전환 (본격 바이너리):
- 마이크로서비스 아키텍처.
- gRPC 사용.
- 타입 안전성과 스키마 진화 필요.
- 팀이 tooling 운영 가능.
Avro 전환 (빅데이터):
- Kafka 중심.
- 장기 저장 데이터.
- 스키마 진화가 핵심.
FlatBuffers (특수 경우):
- 게임, 모바일, ML 모델.
- 자주 읽지만 드물게 쓰임.
혼합 전략 (가장 흔함):
대부분의 실전 시스템은 혼합을 쓴다:
- 외부 API: JSON (REST).
- 내부 서비스 간: Protobuf (gRPC).
- 메시지 큐: Avro (Kafka).
- DB 저장: 텍스트 또는 포맷에 따라.
- 설정 파일: YAML/JSON.
하나로 통일하려 하지 말고, 각 경계에 맞는 것을 선택하라.
의사 결정 프레임워크:
- 측정하라: 현재 시스템에서 JSON이 실제로 병목인가?
- 비교하라: 벤치마크를 자신의 워크로드로.
- 점진적 전환: 한 서비스부터 시작.
- 복잡성 대가 고려: 툴링, 디버깅, 채용.
- 되돌릴 수 있게: JSON 호환 유지 또는 gateway 레이어.
자주 하는 실수:
- "Protobuf가 빠르다는 글 읽고 무조건 도입": 문제가 없었는데 복잡성만 추가.
- "크기가 중요하다고 FlatBuffers": 크기는 FlatBuffers가 오히려 큼. Protobuf가 더 작음.
- "JSON 파싱이 느리다": 실제 병목이 아닌 경우 많음. 네트워크나 DB가 진짜 원인일 수 있음.
- "모든 곳에서 같은 포맷": 공개 API에 Protobuf 강요 → 개발자 이탈.
결론: 문제가 있을 때 해결책이 있어야 한다. JSON으로 시작하고, 실제 문제를 측정한 후, 적절한 경계에서 바이너리로 전환하라. "JSON이 느리다"는 신화지 사실이 아니다. 수백만 개의 성공적인 시스템이 JSON 위에서 돌아가고 있다.
이는 엔지니어링의 일반 원칙과 같다: 단순함을 기본으로, 복잡성은 증명된 필요에 의해서만. 바이너리 직렬화는 강력한 도구이지만, 도구를 선택하는 판단이 도구 자체보다 더 중요하다.
마치며: 바이트의 선택
핵심 정리
- Protobuf: 표준. RPC + 타입 안전성.
- Thrift: Protobuf의 사촌. 통합 RPC.
- Avro: 빅데이터의 표준. Schema evolution 최강.
- MessagePack: JSON의 부드러운 교체.
- FlatBuffers: Zero-copy. 게임/ML.
- Cap'n Proto: FlatBuffers + RPC.
- JSON: 여전히 공개 API의 기본.
선택의 핵심
"어떤 포맷이 최고인가?"는 잘못된 질문이다. "어떤 포맷이 이 상황에 최적인가?"가 올바른 질문이다.
- RPC: Protobuf
- Kafka: Avro
- 게임: FlatBuffers
- 공개 API: JSON
- 파일 저장: Avro (자기 설명)
- IoT: CBOR/MessagePack
마지막 교훈
직렬화 포맷은 데이터의 옷이다. 상황에 맞게 바꿔 입어야 한다. 잘못 선택하면 평생 고생하고, 잘 선택하면 보이지 않게 수많은 문제를 예방한다.
당신이 다음에 새 시스템을 설계할 때, 그냥 "JSON 써야지"라고 하지 말자. 질문하자:
- 얼마나 많은 데이터가 흐를까?
- 스키마가 변할까?
- 누가 소비자인가?
- 성능이 병목인가?
- 타입 안전성이 중요한가?
이 답에 따라 포맷을 선택하라. 그것이 엔지니어링의 기술이다.
참고 자료
- Protocol Buffers Language Guide (proto3)
- Protobuf Encoding Reference
- Apache Thrift Documentation
- Apache Avro Specification
- FlatBuffers Documentation
- Cap'n Proto
- MessagePack Specification
- buf: Modern Protobuf Tooling
- Confluent Schema Registry
- Designing Data-Intensive Applications, Ch.4 (Encoding)
- Martin Kleppmann: Thinking in Events
- Comparing Binary Formats: MessagePack, BSON, CBOR
Binary Serialization Complete Guide 2025: Protobuf, Thrift, Avro, MessagePack, FlatBuffers, Cap'n Proto — Choosing Between Performance and Flexibility
Introduction: Is JSON Really the Best Choice?
A Common Scenario
When building a REST API, most developers naturally pick this:
{
"id": 12345,
"name": "Alice",
"email": "alice@example.com",
"age": 30
}
JSON is human-readable, language-neutral, and supported everywhere. It looks perfect.
But what about a service handling 1 billion requests per day? If each request is 1 KB of JSON, that's 1 TB of data per day. Parsing cost, network bandwidth, storage — all get expensive.
The same data in Protobuf is ~100 bytes. Parsing is over 10x faster. 10 GB instead of 100 GB per day. Over a year, that's tens of TB of difference.
Why Binary Serialization?
JSON's weaknesses:
- Size: Field names repeat every time.
"email"dozens of times. - Parsing cost: Converting text to numbers/objects.
- No type information: Runtime type checking required.
- No schema: Documentation and validation separate.
Strengths of binary formats:
- Small: Numeric tags instead of field names.
- Fast: Direct memory mapping or simple decoding.
- Type-safe: Schema-based.
- Evolvable: Schema evolution support.
What This Post Compares
- Protocol Buffers (Google): The most widely used standard.
- Thrift (Facebook/Apache): RPC integration.
- Avro (Apache Hadoop): The de facto standard for big data.
- MessagePack: Binary replacement for JSON.
- FlatBuffers (Google): Zero-copy reads.
- Cap'n Proto (Sandstorm): Zero-copy plus RPC.
This post dives 720 lines into each format's internal structure, encoding scheme, and when to use it.
1. Protocol Buffers: King of the Standard
History
- 2001: Google starts internal development.
- 2008: Open-sourced (proto2).
- 2016: proto3 released. Simpler syntax.
- 2023: Edition 2023 released. Gradual feature additions.
The default format for gRPC. All of Google uses it, and externally it's the most popular binary serialization.
IDL (Interface Definition Language)
Protobuf defines schemas in .proto files:
syntax = "proto3";
message Person {
int32 id = 1;
string name = 2;
string email = 3;
optional int32 age = 4;
repeated string phone_numbers = 5;
}
- Each field has a tag number (1, 2, 3...).
- The tag number is the key in wire format. Field names are used only for code generation.
Code Generation
The protoc compiler generates code for the target language:
protoc --java_out=. --python_out=. --go_out=. person.proto
Result: Java, Python, and Go classes. Each includes serialize/deserialize methods.
Wire Format: The Core Idea
Protobuf encoding is a key-value stream. Each field is:
[tag + wire_type][value]
Wire types:
0: Varint (integers, boolean, enum).1: 64-bit (fixed64, double).2: Length-delimited (string, bytes, message, packed repeated).5: 32-bit (fixed32, float).
Varint: Variable-Length Integer
Protobuf's key optimization: varint encoding. Small numbers use fewer bytes:
150 as varint:
150 = 0b10010110
Step 1: Split into 7-bit groups
→ [0b0000001, 0b0010110]
Step 2: Use MSB of each byte as "continuation flag"
→ [0b10010110, 0b00000001]
Step 3: Little-endian order
→ [0x96, 0x01]
Result:
0~127: 1 byte.128~16383: 2 bytes.16384~2097151: 3 bytes.- And so on.
Small numbers take 1 byte, larger ones take more. Since most fields are small, this is efficient on average.
Encoding Example
message Person {
int32 id = 1;
string name = 2;
}
id = 150, name = "Bob"
Wire:
Field 1 (id):
tag = (1 << 3) | 0 = 0x08 (field 1, wire type 0 = varint)
value = 150 varint = 0x96 0x01
Field 2 (name):
tag = (2 << 3) | 2 = 0x12 (field 2, wire type 2 = length-delimited)
length = 3
value = "Bob" = 0x42 0x6F 0x62
Total: 08 96 01 12 03 42 6F 62 (8 bytes)
8 bytes! The JSON is:
{"id":150,"name":"Bob"}
23 bytes. 2.9x smaller.
ZigZag Encoding: Optimizing for Negative Numbers
Varint is optimized for positive numbers. Negative numbers become very large varints (due to two's complement):
-1 → 0xFFFFFFFF = 10 bytes!
Solution: sint32 and sint64 types use ZigZag encoding:
0 → 0
-1 → 1
1 → 2
-2 → 3
2 → 4
...
Formula: (n << 1) ^ (n >> 31)
Negative and positive numbers are mapped alternately, so smaller absolute values give shorter encodings.
Schema Evolution
Protobuf supports forward/backward compatibility:
Rules:
- Never change tag numbers: the tag is the ID.
- Renaming fields is OK: unrelated to wire format.
- Adding new fields is OK: old clients ignore them.
- Deleting fields is OK, but mark tag reserved: prevents reuse.
- Don't use required: removed in proto3.
Example:
// v1
message Person {
int32 id = 1;
string name = 2;
}
// v2 (compatible)
message Person {
int32 id = 1;
string name = 2;
string email = 3; // new field
reserved 4, 5; // reserved for future
}
A v1 server receiving a message from a v2 client will simply ignore the email field. Vice versa works too. This is critical in distributed systems during deployments.
Default Values in Proto3
In proto3, all fields are optional. Absent fields get default values:
- Numeric: 0
- String: ""
- bool: false
- message: null
Pitfall: You cannot distinguish "is the value 0?" from "is the value missing?" To solve this, use the optional keyword (proto3 2.6+):
message Person {
optional int32 age = 1; // explicit presence tracking
}
Performance
Protobuf is very fast:
- 3~10x smaller than JSON.
- Parsing speed: 5~100x (depending on language implementation).
- Low memory allocation (reusable).
2. Apache Thrift: RPC Integration
Origin
- 2007: Facebook releases it.
- 2008: Apache Incubator.
- Today: Internal RPC standard at many large companies.
Emerged around the same time as Protobuf. Battle-tested at Facebook scale.
Differences from Protobuf
Thrift provides serialization + RPC + server framework all together:
service UserService {
Person getUser(1: i32 id) throws (1: NotFoundException e),
list<Person> listUsers(1: i32 limit, 2: i32 offset)
}
struct Person {
1: i32 id,
2: string name,
3: optional string email
}
Data structures and service definitions live in one file. Protobuf was originally separate from gRPC, but Thrift was integrated.
Transport Layer
Thrift's unique aspect: separation of transport, protocol, and server.
Transport: how data moves.
- TSocket, TFramedTransport, TMemoryBuffer, ...
Protocol: how data is encoded.
- TBinaryProtocol: simple, fixed size.
- TCompactProtocol: variable length (varint-like).
- TJSONProtocol: JSON format.
Server: how requests are handled.
- TSimpleServer, TThreadPoolServer, TNonblockingServer, ...
This composability allows choosing the right combination per situation.
TCompactProtocol
Thrift's most efficient encoding. Similar ideas to Protobuf:
- Varint: integers.
- ZigZag: negatives.
- Field ID delta: stores only the difference from the previous field ID (smaller).
- Type + ID merge: wire type and field id in a single byte.
Result: Nearly identical size to Protobuf. In some cases slightly smaller.
Pros and Cons
Pros:
- Integrated RPC: self-contained without separate gRPC setup.
- Language support: 27+ languages.
- Mature: validated inside Facebook, Uber, Twitter.
- Flexible transport/protocol.
Cons:
- Fewer recent updates: Thrift 4 is in discussion but slow.
- Scattered docs: less organized than Protobuf.
- Pushed aside by gRPC: Google's marketing power.
Selection guide:
- New project: gRPC (Protobuf).
- Existing Thrift systems: keep as-is.
3. Apache Avro: Big Data's Friend
Background
A format born in the Hadoop ecosystem. Currently the de facto standard in Kafka, Spark, and Hive.
Core Feature: Schema Travels with Data
In Protobuf/Thrift, schemas are used at code generation time. Avro is different:
Avro's philosophy: Schema is delivered with the data or managed centrally.
Two modes:
- Container File Mode: schema embedded in file header.
- Schema Registry Mode: referenced by schema ID from a central registry.
Schema Example
Avro schemas are written in JSON:
{
"type": "record",
"name": "Person",
"fields": [
{ "name": "id", "type": "int" },
{ "name": "name", "type": "string" },
{ "name": "email", "type": ["null", "string"], "default": null },
{ "name": "age", "type": "int", "default": 0 }
]
}
Wire Format
Avro's encoding is extremely simple:
- Fixed-size types: stored as-is (int, long, float, double).
- ZigZag varint: for int and long.
- Variable size: length + data (string, bytes).
- Record: fields listed in schema order.
Important: No tag numbers! Field order is the structure.
Result: Very compact binary. Impossible to interpret without the schema.
Example
Same Person:
id=150, name="Bob", email=null, age=0
Avro wire:
150 (zigzag varint) = 0xAC 0x02 (2 bytes)
length 3 + "Bob" = 0x06 0x42 0x6F 0x62 (4 bytes)
null union tag = 0x00 (1 byte)
0 (zigzag varint) = 0x00 (1 byte)
Total: 8 bytes
Schema Evolution: Writer vs Reader Schema
Avro's killer feature: Writer schema is not equal to Reader schema.
- Writer schema: the schema used when writing the data.
- Reader schema: the schema expected when reading the data.
Avro automatically resolves differences between the two schemas:
Writer: {id, name, age}
Reader: {id, name, email}
ageis absent in reader → ignored.emailis absent in writer → reader's default is used.
This makes compatibility checks and migration smooth.
Schema Registry
Confluent Schema Registry centrally manages Avro schemas:
Producer:
schema_id = registry.register(schema)
message = [magic byte][schema_id (4B)][avro payload]
Consumer:
schema_id = extract_from_message
schema = registry.get(schema_id)
data = decode(payload, schema)
Each message contains only a 4-byte schema ID. Payload is pure Avro. When accumulated across tens to hundreds of GB of Kafka topics, this savings becomes enormous.
Kafka + Avro + Schema Registry
These three form the golden combination for big data streaming:
- Kafka: message broker.
- Avro: efficient encoding + schema evolution.
- Schema Registry: central schema management + compatibility validation.
The default architecture of Confluent Platform. Adopted by Netflix, LinkedIn, Uber, and more.
Protobuf vs Avro
| Item | Protobuf | Avro |
|---|---|---|
| When schema needed | At code gen | At write/read |
| Field identification | Tag number | Order |
| Wire size | Similar | Similar (Avro slightly smaller) |
| Schema evolution | Good | Best |
| Big data friendly | Medium | Best |
| RPC support | gRPC | None (separate) |
| Language support | Broad | Medium |
Selection criteria:
- RPC: Protobuf (gRPC).
- Kafka / big data streaming: Avro.
- File storage: Avro (self-describing files).
4. MessagePack: A Direct JSON Replacement
Philosophy
MessagePack's slogan: "It's like JSON. but fast and small."
- 1:1 mapping with JSON.
- No schema required.
- Binary, so smaller and faster.
Example
JSON:
{"id": 150, "name": "Bob"}
MessagePack (hex):
82 A2 69 64 CC 96 A4 6E 61 6D 65 A3 42 6F 62
82: map with 2 items.A2: fixstr of length 2 ("id").CC 96: uint8 value 150.A4: fixstr of length 4 ("name").A3 42 6F 62: fixstr of length 3 "Bob".
Total 15 bytes. Down from JSON's 25 bytes — a 40% reduction.
Characteristics
- No schema: works immediately.
- Type preservation: int, string, array, map, binary, etc.
- Extension: supports user-defined types (timestamp, UUID, etc.).
- Language support: 50+ languages.
Use Cases
- Redis: Lua script results, some internal communication.
- Fluentd: log collection protocol.
- ZeroMQ: message bindings.
- Games: real-time network protocols.
Differences from Protobuf
| Item | MessagePack | Protobuf |
|---|---|---|
| Schema | None | Required |
| Size | Medium | Small |
| Parse speed | Fast | Very fast |
| Self-describing | Yes | No |
| Schema evolution | Weak | Strong |
| IDE support | Little | Much |
MessagePack is the best fit as a JSON replacement when you want something faster and smaller than JSON without a schema system burden.
5. FlatBuffers: The Answer to Zero-Copy
Motivation
Developed by Google for games and mobile. Goal: zero deserialization time.
Problems with existing formats:
- Protobuf/Avro/MessagePack: reading requires full parsing. New object allocations.
- Small messages are fine, but large messages or frequently accessed data incurs overhead.
The Idea: Use Directly from Memory
FlatBuffers uses the memory layout itself as the serialization format:
Byte stream:
[root offset][vtable 1][table 1][vtable 2][table 2]...
When reading:
- Get the byte buffer (memcpy or mmap).
- Follow the root offset.
- Access fields directly without object allocation.
auto person = GetPerson(buffer);
int id = person->id(); // read memory directly without actual parsing
Result: Deserialization time approaches zero. No memory allocation. Cache-friendly.
IDL
A schema similar to Protobuf:
namespace Game;
table Person {
id: int;
name: string;
email: string;
}
root_type Person;
Performance
- Deserialization: 1000x faster than Protobuf (near zero).
- Memory: no allocation.
- Serialization: slightly slower than Protobuf (vtable construction).
- Size: 20~50% larger than Protobuf.
Use Cases
- Game engines: Cocos2d, various game data.
- Mobile apps: data file formats.
- Apache Arrow: some format sharing.
- Facebook: major user.
- TensorFlow Lite: model files.
When to Use
- Accessed often but rarely written.
- Large data structures.
- Memory-constrained.
- Fast loading matters.
Downsides:
- Size: larger than Protobuf.
- Lower flexibility: writing is complex.
- Hard to debug: difficult to inspect the binary directly.
The Value of Random Access
A major strength of FlatBuffers: random access. If you want to read a specific field in a 1 GB file:
auto file = mmap(filename); // actual disk loading is lazy
auto root = GetRoot(file);
auto item_100 = root->items()->Get(100);
int value = item_100->value();
// only the pages actually needed are loaded from disk
No full parsing required. Ideal for large config files, game assets, and ML models.
6. Cap'n Proto: FlatBuffers' Cousin
Origin
Kenton Varda (a main developer of Protobuf v2) left Google to build this next-generation format. Philosophy:
"Protocol Buffers, infinity times faster."
Zero-Copy Plus RPC
FlatBuffers' zero-copy advantage plus a built-in RPC system. Used in the Sandstorm project.
Schema
struct Person {
id @0 :Int32;
name @1 :Text;
email @2 :Text;
}
Similar to Protobuf but with @N syntax for ordering.
Encoding Characteristics
- Aligned memory: 8-byte boundaries.
- Zero-copy capable: like FlatBuffers.
- Packed compression: optionally applied for size reduction.
- Random access: access a portion of a large message.
Canonical Form
Cap'n Proto tries to produce the same bytes for the same data (useful for hash-based comparison).
RPC Features
Time Traveling RPC: the server can forward results to another service before responding. Promise pipelining.
Client → A: getUser(1)
Client → A: (promise of user) → B.emailSummary(user)
Data flows directly between A and B. The client is not in the middle. Reduces latency.
Use Cases
Less commonly used than FlatBuffers, but used internally by large companies like Cloudflare.
FlatBuffers vs Cap'n Proto
| Item | FlatBuffers | Cap'n Proto |
|---|---|---|
| Zero-copy | Yes | Yes |
| Size | Medium | Similar |
| RPC | None | Yes |
| Maturity | More mature | Younger |
| Language support | Many | Few |
Cap'n Proto has some technical advantages, but FlatBuffers is more widely used in practice.
7. Other Notable Formats
CBOR (Concise Binary Object Representation)
IETF standard (RFC 8949). The "real" binary version of JSON. Used in IoT and CoAP.
- No schema.
- 1:1 mapping with JSON.
- Similar to MessagePack but standardized.
BSON (Binary JSON)
MongoDB's storage format. JSON-style with additional types (ObjectId, Date, Binary).
- No schema.
- Slightly larger than JSON but faster to parse.
- Mainly internal use at MongoDB.
Apache Arrow
Columnar memory format. Described earlier. Can serialize, but its main purpose is zero-copy interchange between processes.
Parquet
Columnar disk format. Described earlier. Less a serialization format and more a storage format, but provides cross-language compatibility.
Bencode
BitTorrent's protocol. Simple but inefficient.
Smile
JSON-like binary. Part of the Jackson library.
8. Performance Comparison: By the Numbers
Benchmark Scenario
A typical message (Person object, 100 fields, with nesting):
| Format | Size | Serialize | Deserialize |
|---|---|---|---|
| JSON | 5.2 KB | 85 us | 120 us |
| BSON | 4.8 KB | 75 us | 105 us |
| MessagePack | 3.8 KB | 45 us | 60 us |
| Protobuf | 2.8 KB | 15 us | 25 us |
| Avro | 2.6 KB | 20 us | 35 us |
| Thrift (Compact) | 2.7 KB | 18 us | 30 us |
| Cap'n Proto | 3.2 KB | 10 us | 1 us |
| FlatBuffers | 3.5 KB | 12 us | 1 us |
Key observations:
- Size: Avro is greater than Thrift is greater than Protobuf is greater than FlatBuffers is greater than MessagePack is greater than JSON.
- Serialization speed: all are faster than JSON.
- Deserialization: Cap'n Proto/FlatBuffers are overwhelming (zero-copy).
Real-World Throughput
Processing 1 GB of data (100 GB system memory):
| Format | Time |
|---|---|
| JSON | 120 s |
| MessagePack | 60 s |
| Protobuf | 25 s |
| Avro (columnar compression) | 30 s |
| FlatBuffers | 2 s (mostly mmap time) |
FlatBuffers' advantage is stark for bulk data processing.
9. Schema Evolution Comparison
Schemas change over time. How does each format handle different versions?
Protobuf
- Tag-number based: never change.
- Adding fields: OK (new tag).
- Removing fields: OK (mark tag reserved).
- Type changes: only among compatible types (int32 is interchangeable with int64).
- Forward/backward compatibility: both possible.
Safe changes (v1 to v2):
- Adding new fields.
- Renaming fields.
- required to optional.
Dangerous changes:
- Changing tag numbers.
- Drastic type change (string to int).
- Reusing a tag after deleting the field.
Thrift
Similar to Protobuf. Tag-based and well-supports compatibility.
Avro
Handled via Writer plus Reader schema. Avro's schema resolution rules:
- Field matching: by name (aliases supported).
- Missing fields: default values used.
- Type promotion: int to long, float to double.
- Union evolution: can add null.
Avro has the most powerful evolution system. Complex changes are possible.
JSON
No schema, making evaluation difficult but effectively allowing anything.
- Adding new fields: easy (clients ignore).
- Removing fields: risky (clients may depend on them).
- Type changes: risky.
JSON Schema enables validation, but there's no code-level automatic compatibility.
FlatBuffers / Cap'n Proto
Tag-based. Similar rules to Protobuf. However, due to zero-copy, field rearrangement is forbidden.
Practical Rules
- Tag/field numbers are forever: never reuse them.
- Additions only: adding is safer than deleting.
- Optional by default: new fields should be optional.
- Default values: set them explicitly.
- Mark deprecated: note deprecation in code comments.
- Validate compatibility in CI: buf breaking (Protobuf), Schema Registry (Avro).
10. Selection Guide
Recommendations by Scenario
REST API (public API):
- JSON: still the default. Anyone can parse it.
- Internal optimization: MessagePack or Protobuf over HTTP.
gRPC services:
- Protobuf: the default. gRPC is designed around it.
Kafka / streaming:
- Avro plus Schema Registry: the standard combination.
- Alternative: Protobuf with Schema Registry.
Games / real-time networking:
- FlatBuffers: game data.
- Cap'n Proto: when RPC is also needed.
- MessagePack: when flexibility is needed.
Config files / CLIs:
- YAML/JSON/TOML: must be human-readable.
ML models:
- FlatBuffers: TensorFlow Lite style.
- Protobuf: ONNX style.
- SafeTensors: latest trend.
Logs / events:
- Avro: big data pipelines.
- Protobuf: gRPC logs.
- JSON: for human parsing.
IoT / embedded:
- CBOR: IoT standard.
- Protobuf: when performance matters.
- MessagePack: for flexibility.
MongoDB storage:
- BSON: fixed (MongoDB only).
Decision Tree
Do you want a schema?
|-- Yes
| |-- Need RPC integration? -> gRPC + Protobuf or Thrift
| |-- Big data evolution important? -> Avro
| |-- Need zero-copy? -> FlatBuffers or Cap'n Proto
| +-- General -> Protobuf
+-- No
|-- JSON compatibility? -> MessagePack or CBOR
|-- MongoDB? -> BSON
+-- Human-readable? -> JSON/YAML
11. Pitfalls and Real-World Lessons
Pitfall 1: "Using Protobuf When JSON Is Enough"
Problem: A complex code-generation pipeline for a small-scale API.
Lesson: The tool must justify the problem. At low traffic, JSON's developer convenience outweighs Protobuf's performance gains.
Pitfall 2: "Deleting an Old Field in Protobuf and Reusing the Tag"
Problem: v2 code misinterprets v1 data.
Lesson: Tag numbers are forever. If deletion is needed, mark as reserved.
Pitfall 3: "Losing the Writer Schema in Avro"
Problem: Old messages in a Kafka topic become unreadable.
Lesson: Use Schema Registry. Always preserve the writer schema.
Pitfall 4: "File Size Increase with FlatBuffers"
Problem: After migrating from Protobuf to FlatBuffers, file sizes increase.
Lesson: FlatBuffers is speed-first. If size matters, Protobuf is better. Or add a compression layer on top of FlatBuffers.
Pitfall 5: "Maintaining Code Generation for Dozens of Target Languages"
Problem: protoc version management, per-language builds.
Lesson: Use tools like monorepo plus buf. Automate in CI.
Pitfall 6: "Violating Schema Evolution Rules"
Problem: Adding a field as required, or changing types.
Lesson: Automated validation. buf breaking, Schema Registry compatibility check.
12. Practical Tools
buf: Modern Protobuf Tooling
Created by Uber and the community, buf dramatically improves Protobuf development:
# Lint
buf lint
# Breaking change detection
buf breaking --against .git#branch=main
# Generate code
buf generate
# Remote plugins (no need to install protoc)
buf generate --template buf.gen.yaml
Recommended for every project.
Confluent Schema Registry
Centrally manages Avro/Protobuf/JSON Schema:
- REST API for registering/retrieving schemas.
- Compatibility validation.
- Kafka integration.
// Kafka producer
properties.put("value.serializer", "io.confluent.kafka.serializers.KafkaAvroSerializer");
properties.put("schema.registry.url", "http://localhost:8081");
Essential in production Kafka plus Avro environments.
protobuf-validator
Adds validation rules to Protobuf messages:
message Person {
int32 age = 1 [(validate.rules).int32 = { gte: 0, lte: 150 }];
string email = 2 [(validate.rules).string.email = true];
}
Generated code includes validate methods.
grpcurl / grpc_cli
Test gRPC services like curl:
grpcurl -d '{"id": 1}' localhost:50051 myservice.UserService/GetUser
Automatically obtains schemas via server reflection.
Quiz Review
Q1. Why is Protobuf's varint effective for small numbers?
A. Varint encodes values 7 bits at a time and uses the most significant bit (MSB) of each byte as a "is there a next byte?" flag.
Example:
0~127: 1 byte (MSB=0).128~16383: 2 bytes.- Larger numbers: more bytes.
Regular int32 case: always 4 bytes. Varint:
0: 1 byte100: 1 byte10000: 2 bytes1000000: 3 bytes2^31-1: 5 bytes (larger!)
Real-world observation: Most IDs, counts, bitmasks, and indices are small values. Across millions of messages, if most integers encode as 1~2 bytes, the total size shrinks significantly.
Exception: Negative numbers
What if you encode -1 as an int32 varint? Due to two's complement, it becomes 0xFFFFFFFF, requiring 5 bytes. That's a disaster.
Solution: ZigZag
The sint32 type uses ZigZag encoding:
0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4, ...- The smaller the absolute value, the shorter the encoding.
So fields where negatives are common should use sint32/sint64. This is an important Protobuf tutorial lesson that's often overlooked.
Lesson: Knowing your data distribution and choosing the format accordingly makes a big difference. Protobuf's varint is a clever design leveraging the common distribution of small numbers.
Q2. Why does Avro "deliver schema with the data" unlike Protobuf, and what are the benefits?
A. Protobuf and Avro have fundamentally different philosophies.
Protobuf:
- Schema is only needed at code generation time.
- Wire format contains only tag numbers.
- Field names aren't needed; tags identify fields.
- At runtime, schemas are assumed to be "already known."
Avro:
- Schema is needed at write/read time.
- Wire format has no field names or tags (only order).
- Without the schema, interpretation is impossible.
- Two operational modes:
- Container file: schema embedded in file header.
- Schema Registry: only schema ID in messages; actual schema centrally stored.
Why this difference?
Avro was designed for big data and streaming. Characteristics of this environment:
- Data lives long and schemas evolve.
- Consumers may be unknown in the future.
- Writer schema is not equal to Reader schema is routine.
Avro's benefits:
-
Writer vs Reader schema resolution:
- Avro takes both schemas and converts automatically.
- Protobuf is tag-based, so only one side needs to be known.
- In complex migrations, Avro is more flexible.
-
Self-describing files:
- Avro container files contain the schema, so "any tool" can read them.
- Protobuf can't be read without the
.protofile.
-
Schema Registry pattern:
- Central schema management and compatibility validation.
- The reason Kafka plus Avro became the big-data standard.
Counterpoint for Protobuf:
- Tag-based is more efficient (no field names).
- Code generation provides type safety.
- Integrated with gRPC.
Conclusion: Avro is optimized for the assumption that "data lives long and schema changes", while Protobuf is optimized for the assumption that "schema is deployed alongside code". Which is right depends on your operational environment:
- RPC systems (Protobuf favored)
- Big data streams (Avro favored)
- Storage files (Avro favored)
Both are proven technologies and the best choice in their respective domains.
Q3. How does FlatBuffers achieve "zero deserialization time"?
A. By using the memory layout itself as the serialization format.
Problem with traditional serialization: Protobuf, Avro, MessagePack, and others sequentially parse the serialized bytes:
// Protobuf style
Person person;
person.ParseFromArray(buffer, size); // parse entire data
int id = person.id(); // access already-parsed field
- Must read the entire buffer.
- Must allocate new objects and populate fields.
- Time and memory proportional to size.
FlatBuffers' approach: The serialized bytes are already a structure usable directly from memory:
// FlatBuffers
auto person = GetPerson(buffer); // just cast a pointer, no parsing
int id = person->id(); // internally compute offset, read memory directly
How it works:
- Root offset stored at the start of the buffer.
- Before each object, a vtable offset (handles variable structure).
- Vtable: "field X is at offset Y" information.
- Field access is offset addition plus memory read. O(1).
Benefits:
- Deserialization time: 0 (only buffer-mapping time).
- No memory allocation: reuse the existing buffer.
- Random access: for a 1 GB file needing only one field, only that part is loaded.
- mmap-friendly: even large files load lazily.
- Cache efficient: once read, stays in CPU cache for reuse.
Costs:
- Size: 20~50% larger than Protobuf due to vtable overhead.
- Complex serialization: constructed stepwise with a builder.
- Low flexibility: hard to modify the buffer once built.
- Debugging: hard to inspect binary structure directly.
When to use FlatBuffers:
- Game data: character info, level data. Read often, rarely changed.
- ML model files: TensorFlow Lite is a prime example.
- Mobile app data: fast loading matters.
- Embedded: memory-constrained, allocation is expensive.
When not to use:
- RPC: every request has a different message; can't reap zero-copy benefits.
- Small messages: at under a few KB, parse time is negligible.
- Frequent modifications: writing is cumbersome.
- Network size matters: Protobuf is smaller.
FlatBuffers vs typical parser:
- Parsing a 1 MB message 1000 times:
- Protobuf: 1000 times 20 ms = 20 seconds.
- FlatBuffers: 1000 times 0.001 ms = 1 ms.
Not 10 million times, but the gap is stark in bulk processing. This is why FlatBuffers became the standard for game engines and ML runtimes.
Lesson: A "serialization format" doesn't have to be "a parseable byte stream." FlatBuffers opened new performance territory with the bold choice of "memory layout equals wire format." Engineering sometimes makes leaps by overturning default assumptions.
Q4. Why is "deleting a field" in schema evolution dangerous?
A. There are three levels of risk.
1. Tag number reuse risk
In Protobuf/Thrift, tags are eternal IDs:
// v1
message Person {
int32 id = 1;
string name = 2;
string deprecated_field = 3; // delete this and...
}
// v2 (dangerous!)
message Person {
int32 id = 1;
string name = 2;
int32 new_field = 3; // reusing tag 3
}
Problem scenario:
- v1 data stored in a Kafka topic or DB still has
deprecated_field = "hello". - v2 code reads this data; tag 3 is interpreted as
int32. - Trying to read
"hello"as int32 errors out or produces garbage data.
Solution: Use the reserved keyword to prevent reuse:
message Person {
int32 id = 1;
string name = 2;
reserved 3;
reserved "deprecated_field";
}
Now attempting to reuse tag 3 in v2 is a compile error.
2. When reader expects the field
In Avro, field deletion is OK if the field isn't in the reader schema:
- Writer v1:
{id, name, age} - Reader v2:
{id, name}(no age) - → age ignored. Safe.
Problem: If you delete the field in the reader and the writer stops writing it?
- Reader v2 can read v1 data (ignores age).
- Reader v2 can read v2 data (no age).
- But when Reader v1 reads v2 data... v1 expected age but v2 doesn't have it → default or null. Business logic may break.
Lesson: Delete only after all readers are gone.
3. Data dependency
If client code is using the field, deletion is catastrophic:
# Old client code
age = person.age # KeyError / AttributeError / 0 (default)
send_birthday_wishes(person.age) # wrong results
Field deletion is an API contract change. All usage sites must be tracked.
Safe field-removal procedure:
- Mark (deprecated): note in code comments and docs.
- Stop using: modify all writer code to not set the field.
- Soak period: confirm over weeks/months that it's really unused.
- Update readers: remove the field from reader code.
- Update writers: remove from writer schema.
- Reserved: mark the tag as reserved (prevent reuse).
- Monitor: track errors related to this tag.
Practical advice:
- Adding is easy, deleting is hard: a "we'll remove it later" field lives forever.
- Add fields rather than new versions: prefer incremental additions to breaking changes.
- CI validation:
buf breaking, Schema Registry compatibility check. - Graceful deprecation: mark old fields "DEPRECATED" and keep for several quarters.
Conclusion: The "delete" button is easy, but the consequences ripple across multiple layers of distributed systems. The best approach is designing schemas to be extensible from the start. If deletion is necessary, proceed with a careful process.
This is what "schema design is hard to change once decided" means. A sloppy schema becomes eternal technical debt.
Q5. When should you keep using JSON and when should you switch to binary?
A. There's no simple answer. Multiple factors must be considered.
Reasons to keep using JSON:
- Public API: external developers use it. Must be testable with standard tools.
- Low traffic: if throughput is low, optimization benefits are less than complexity costs.
- Fast development: schema-generation pipeline setup is overhead.
- Debugging convenience: readable directly in logs.
- Diverse clients: JavaScript browsers, shell scripts, etc.
- Small messages: parsing overhead is negligible relative to network.
Signs to switch to binary:
- Traffic growth: 1B+ requests/day, bandwidth costs unignorable.
- CPU bottleneck: JSON parsing accounts for 10%+ in profiling.
- Internal services: not a public API but microservice-to-microservice communication.
- Schema evolution needed: version compatibility becomes important.
- Bugs from lack of type safety: outages from "typo in field name."
- Mobile/IoT: bandwidth and battery constrained.
Concrete thresholds:
Keep JSON:
- Under 1M requests/day.
- Average message under 10 KB.
- API is public.
- Small dev team.
Switch to MessagePack (gentle JSON upgrade):
- Keep JSON structure with slight size/speed improvement.
- OK without a schema.
- Limited internal communication.
Switch to Protobuf (full binary):
- Microservice architecture.
- Using gRPC.
- Type safety and schema evolution needed.
- Team can operate the tooling.
Switch to Avro (big data):
- Kafka-centric.
- Long-lived storage data.
- Schema evolution is core.
FlatBuffers (special cases):
- Games, mobile, ML models.
- Read often, written rarely.
Mixed strategy (most common):
Most real-world systems use a mix:
- External APIs: JSON (REST).
- Internal services: Protobuf (gRPC).
- Message queues: Avro (Kafka).
- DB storage: text or format-specific.
- Config files: YAML/JSON.
Don't try to unify on one format. Choose the right one for each boundary.
Decision framework:
- Measure: is JSON actually the bottleneck in the current system?
- Compare: benchmark with your own workload.
- Gradual migration: start with one service.
- Consider complexity cost: tooling, debugging, hiring.
- Make it reversible: maintain JSON compatibility or a gateway layer.
Common mistakes:
- "Read that Protobuf is fast, so adopt unconditionally": adds complexity without a real problem.
- "Size matters, so FlatBuffers": FlatBuffers is actually larger. Protobuf is smaller.
- "JSON parsing is slow": often not the actual bottleneck. Network or DB may be the real cause.
- "Same format everywhere": forcing Protobuf on a public API → developer attrition.
Conclusion: Solutions must solve actual problems. Start with JSON, measure real problems, then switch to binary at the right boundary. "JSON is slow" is a myth, not a fact. Millions of successful systems run on JSON.
This follows the general engineering principle: simplicity by default, complexity only when proven necessary. Binary serialization is a powerful tool, but the judgment behind choosing it matters more than the tool itself.
Closing: The Choice of Bytes
Key Takeaways
- Protobuf: the standard. RPC plus type safety.
- Thrift: Protobuf's cousin. Integrated RPC.
- Avro: the big data standard. Strongest schema evolution.
- MessagePack: the gentle JSON replacement.
- FlatBuffers: zero-copy. Games/ML.
- Cap'n Proto: FlatBuffers plus RPC.
- JSON: still the default for public APIs.
Core of the Choice
"Which format is best?" is the wrong question. "Which format is optimal for this situation?" is the right question.
- RPC: Protobuf
- Kafka: Avro
- Games: FlatBuffers
- Public API: JSON
- File storage: Avro (self-describing)
- IoT: CBOR/MessagePack
Final Lesson
A serialization format is the clothing of data. Change it to fit the situation. Poor choice means lifelong pain; good choice invisibly prevents countless problems.
Next time you design a new system, don't just say "I'll use JSON." Ask:
- How much data will flow?
- Will the schema change?
- Who are the consumers?
- Is performance the bottleneck?
- Is type safety important?
Choose the format based on those answers. That is the art of engineering.
References
- Protocol Buffers Language Guide (proto3)
- Protobuf Encoding Reference
- Apache Thrift Documentation
- Apache Avro Specification
- FlatBuffers Documentation
- Cap'n Proto
- MessagePack Specification
- buf: Modern Protobuf Tooling
- Confluent Schema Registry
- Designing Data-Intensive Applications, Ch.4 (Encoding)
- Martin Kleppmann: Thinking in Events
- Comparing Binary Formats: MessagePack, BSON, CBOR