Skip to content

✍️ 필사 모드: 바이너리 직렬화 완전 가이드 2025: Protobuf, Thrift, Avro, MessagePack, FlatBuffers, Cap'n Proto — 성능 vs 유연성의 선택

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며: 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 지원.

이 글에서 비교할 것

  1. Protocol Buffers (Google): 가장 널리 쓰이는 표준.
  2. Thrift (Facebook/Apache): RPC 통합.
  3. Avro (Apache Hadoop): 빅데이터의 사실상 표준.
  4. MessagePack: JSON의 바이너리 교체.
  5. FlatBuffers (Google): Zero-copy 읽기.
  6. 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의 보수로 변환되기 때문):

-10xFFFFFFFF = 10 바이트!

해결: sint32, sint64 타입ZigZag 인코딩을 쓴다:

 00
-11
 12
-23
 24
...

공식: (n << 1) ^ (n >> 31)

음수와 양수가 교대로 매핑되어 절대값이 작을수록 인코딩이 짧다.

Schema Evolution

Protobuf는 forward/backward compatibility를 지원한다:

규칙:

  1. Tag number를 절대 바꾸지 마라: tag는 ID다.
  2. 필드 이름 변경 OK: wire format과 무관.
  3. 새 필드 추가 OK: 이전 클라이언트는 무시.
  4. 필드 삭제 OK, 단 tag를 reserved로: 재사용 방지.
  5. 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의 철학: 스키마가 데이터와 함께 전달되거나 중앙에서 관리된다.

두 가지 모드:

  1. Container File Mode: 파일 헤더에 schema 포함.
  2. 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개는 빅데이터 스트리밍의 골든 조합이다:

  1. Kafka: 메시지 브로커.
  2. Avro: 효율적 인코딩 + schema evolution.
  3. Schema Registry: 중앙 스키마 관리 + 호환성 검증.

Confluent Platform의 기본 아키텍처. Netflix, LinkedIn, Uber 등 모두 채택.

Protobuf vs Avro

항목ProtobufAvro
스키마 필요성코드 생성 시쓰기/읽기 시
Field identificationTag 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개 이상.

사용처

  1. Redis: Lua 스크립트 결과, 일부 내부 통신.
  2. Fluentd: 로그 수집 프로토콜.
  3. ZeroMQ: 메시지 바인딩.
  4. 게임: 실시간 네트워크 프로토콜.

Protobuf와의 차이

항목MessagePackProtobuf
스키마없음필수
크기중간작음
파싱 속도빠름매우 빠름
자기 설명아니오
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]...

읽을 때:

  1. 바이트 버퍼를 얻음 (memcpy 또는 mmap).
  2. 루트 offset을 따라감.
  3. 객체 할당 없이 바로 필드 접근.
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% 큼.

사용처

  1. 게임 엔진: Cocos2d, 여러 게임 데이터.
  2. 모바일 앱: 데이터 파일 포맷.
  3. Apache Arrow: 일부 포맷 공유.
  4. Facebook: 주요 사용자.
  5. 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.

ClientA: getUser(1)
ClientA: (promise of user)B.emailSummary(user)

A와 B 사이에서 직접 데이터가 흐름. Client가 중간에 안 걸림. 레이턴시 감소.

사용처

FlatBuffers보다 덜 쓰이지만 Cloudflare 같은 대기업이 내부에서 사용.

FlatBuffers vs Cap'n Proto

항목FlatBuffersCap'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 필드, 중첩 포함):

포맷크기직렬화역직렬화
JSON5.2 KB85 μs120 μs
BSON4.8 KB75 μs105 μs
MessagePack3.8 KB45 μs60 μs
Protobuf2.8 KB15 μs25 μs
Avro2.6 KB20 μs35 μs
Thrift (Compact)2.7 KB18 μs30 μs
Cap'n Proto3.2 KB10 μs1 μs
FlatBuffers3.5 KB12 μs1 μs

핵심 관찰:

  1. 크기: Avro > Thrift > Protobuf > FlatBuffers > MessagePack > JSON.
  2. 직렬화 속도: 모두 JSON보다 빠름.
  3. 역직렬화: Cap'n Proto/FlatBuffers가 압도적 (zero-copy).

실전 처리량

1 GB 데이터 처리 (100 GB 시스템 메모리):

포맷시간
JSON120 s
MessagePack60 s
Protobuf25 s
Avro (columnar compression)30 s
FlatBuffers2 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 규칙:

  1. 필드 매칭: 이름 기준 (aliases 지원).
  2. 없는 필드: default 값 사용.
  3. 타입 promotion: int → long, float → double.
  4. Union 진화: null 추가 가능.

Avro는 가장 강력한 evolution 시스템. 복잡한 변경도 가능.

JSON

스키마 없음 → 평가가 어렵지만 사실상 아무렇게나 가능.

  • 새 필드 추가: 쉬움 (클라이언트가 무시).
  • 필드 삭제: 위험 (클라이언트가 의존할 수 있음).
  • 타입 변경: 위험.

JSON Schema를 쓰면 검증 가능하지만, 코드 레벨의 자동 호환성은 없다.

FlatBuffers / Cap'n Proto

Tag 기반. Protobuf와 유사한 규칙. 단, zero-copy 때문에 필드 재배치 금지.

실전 규칙

  1. Tag/필드 번호는 영원: 절대 재사용하지 마라.
  2. 필드 추가만: 삭제보다 추가가 안전.
  3. Optional을 기본: 새 필드는 optional로.
  4. Default 값: 명시적으로 설정.
  5. Deprecated 표시: 코드 주석으로 사용 중단 알림.
  6. 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 없음 (순서만).
  • 스키마 없으면 해석 불가능.
  • 두 가지 운영 모드:
    1. Container file: 파일 헤더에 스키마 포함.
    2. Schema Registry: 메시지에 schema ID만, 실제 스키마는 중앙에서.

왜 이런 차이?

Avro는 빅데이터와 스트리밍을 위해 설계되었다. 이 환경의 특성:

  • 데이터가 오래 살고 스키마가 진화.
  • 소비자가 미래에 누구인지 모름.
  • Writer schema ≠ Reader schema가 일상.

Avro의 이점:

  1. Writer vs Reader schema resolution:

    • Avro는 두 스키마를 모두 받아 자동 변환.
    • Protobuf는 tag 기반이라 한 쪽만 알면 됨.
    • 복잡한 마이그레이션에서 Avro가 더 유연.
  2. 자기 설명 파일:

    • Avro container file은 스키마를 포함해서 "어떤 도구든" 읽을 수 있음.
    • Protobuf는 .proto 파일이 없으면 못 읽음.
  3. 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 계산, 바로 메모리 읽기

작동 원리:

  1. 버퍼 시작에 root offset 저장.
  2. 각 객체 앞에 vtable offset (가변 구조 처리).
  3. Vtable: "필드 X는 offset Y에 있다"는 정보.
  4. 필드 접근은 offset 더하기 + 메모리 읽기. O(1).

이점:

  1. 역직렬화 시간: 0 (버퍼 매핑 시간만).
  2. 메모리 할당 없음: 기존 버퍼 재사용.
  3. 랜덤 액세스: 1 GB 파일에서 필드 하나만 필요하면 그 부분만 로드.
  4. mmap 친화적: 큰 파일도 게으르게 로드.
  5. Cache 효율: 한 번 읽으면 CPU 캐시에 남아 재사용.

비용:

  1. 크기: vtable 오버헤드로 Protobuf보다 20~50% 큼.
  2. 직렬화 복잡: builder로 단계적 구성.
  3. 유연성 낮음: 한 번 만든 버퍼를 수정하기 어려움.
  4. 디버깅: 바이너리 구조를 직접 보기 힘듦.

언제 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 계약 변경이다. 모든 사용처를 추적해야 한다.

안전한 필드 제거 절차:

  1. 표시 (deprecated): 코드에 주석, 문서에 명시.
  2. 사용 제거: 모든 writer가 해당 필드를 설정하지 않도록 코드 수정.
  3. 수집 기간: 몇 주~몇 달 동안 실제로 안 쓰이는지 확인.
  4. Reader 업데이트: reader 코드에서 이 필드 제거.
  5. Writer 업데이트: writer schema에서 제거.
  6. Reserved: Tag를 reserved로 표시 (재사용 방지).
  7. 모니터링: 이 tag 관련 에러 추적.

실전 조언:

  • 추가는 쉽고 삭제는 어렵다: "나중에 지우면 돼"라고 쓴 필드는 영원히 남는다.
  • 새 버전보다 필드 추가: 깨지는 변경보다 점진적 추가.
  • CI 검증: buf breaking, Schema Registry compatibility check.
  • Graceful deprecation: 오래된 필드는 "DEPRECATED" 표시 후 몇 분기 동안 유지.

결론: "삭제" 버튼은 쉽지만 그 결과는 분산 시스템의 여러 계층에 미친다. 스키마 설계 시 처음부터 확장 가능하도록 만드는 것이 최선이다. 삭제가 필요하면 신중한 프로세스로 진행해야 한다.

이것이 "스키마 설계는 한 번 결정하면 수정이 어렵다"는 말의 의미다. 대충 설계한 스키마는 영원한 기술 부채가 된다.

Q5. 언제 JSON을 계속 써야 하고 언제 바이너리로 전환해야 하는가?

A. 단순한 답은 없다. 여러 요인을 고려해야 한다.

JSON을 계속 쓰는 이유:

  1. 공개 API: 외부 개발자가 사용. 표준 도구로 테스트 가능해야.
  2. 낮은 트래픽: 처리량이 적으면 최적화 이점 < 복잡성 비용.
  3. 빠른 개발: 스키마 생성 파이프라인 설정 부담.
  4. 디버깅 편의: 로그에서 바로 읽을 수 있음.
  5. 다양한 클라이언트: JavaScript 브라우저, 쉘 스크립트 등.
  6. 작은 메시지: 파싱 오버헤드가 네트워크 대비 미미.

바이너리로 전환해야 하는 신호:

  1. 트래픽 증가: 일 10억+ 요청, 대역폭 비용 무시 못 함.
  2. CPU 병목: 프로파일링에서 JSON 파싱이 10% 이상.
  3. 내부 서비스: 공개 API가 아니고 마이크로서비스 간 통신.
  4. 스키마 진화 필요: 버전 호환성이 중요해짐.
  5. 타입 안전성 부족으로 버그: "필드 이름 오타"로 인한 장애.
  6. 모바일/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.

하나로 통일하려 하지 말고, 각 경계에 맞는 것을 선택하라.

의사 결정 프레임워크:

  1. 측정하라: 현재 시스템에서 JSON이 실제로 병목인가?
  2. 비교하라: 벤치마크를 자신의 워크로드로.
  3. 점진적 전환: 한 서비스부터 시작.
  4. 복잡성 대가 고려: 툴링, 디버깅, 채용.
  5. 되돌릴 수 있게: JSON 호환 유지 또는 gateway 레이어.

자주 하는 실수:

  • "Protobuf가 빠르다는 글 읽고 무조건 도입": 문제가 없었는데 복잡성만 추가.
  • "크기가 중요하다고 FlatBuffers": 크기는 FlatBuffers가 오히려 큼. Protobuf가 더 작음.
  • "JSON 파싱이 느리다": 실제 병목이 아닌 경우 많음. 네트워크나 DB가 진짜 원인일 수 있음.
  • "모든 곳에서 같은 포맷": 공개 API에 Protobuf 강요 → 개발자 이탈.

결론: 문제가 있을 때 해결책이 있어야 한다. JSON으로 시작하고, 실제 문제를 측정한 후, 적절한 경계에서 바이너리로 전환하라. "JSON이 느리다"는 신화지 사실이 아니다. 수백만 개의 성공적인 시스템이 JSON 위에서 돌아가고 있다.

이는 엔지니어링의 일반 원칙과 같다: 단순함을 기본으로, 복잡성은 증명된 필요에 의해서만. 바이너리 직렬화는 강력한 도구이지만, 도구를 선택하는 판단이 도구 자체보다 더 중요하다.


마치며: 바이트의 선택

핵심 정리

  1. Protobuf: 표준. RPC + 타입 안전성.
  2. Thrift: Protobuf의 사촌. 통합 RPC.
  3. Avro: 빅데이터의 표준. Schema evolution 최강.
  4. MessagePack: JSON의 부드러운 교체.
  5. FlatBuffers: Zero-copy. 게임/ML.
  6. Cap'n Proto: FlatBuffers + RPC.
  7. JSON: 여전히 공개 API의 기본.

선택의 핵심

"어떤 포맷이 최고인가?"는 잘못된 질문이다. "어떤 포맷이 이 상황에 최적인가?"가 올바른 질문이다.

  • RPC: Protobuf
  • Kafka: Avro
  • 게임: FlatBuffers
  • 공개 API: JSON
  • 파일 저장: Avro (자기 설명)
  • IoT: CBOR/MessagePack

마지막 교훈

직렬화 포맷은 데이터의 옷이다. 상황에 맞게 바꿔 입어야 한다. 잘못 선택하면 평생 고생하고, 잘 선택하면 보이지 않게 수많은 문제를 예방한다.

당신이 다음에 새 시스템을 설계할 때, 그냥 "JSON 써야지"라고 하지 말자. 질문하자:

  • 얼마나 많은 데이터가 흐를까?
  • 스키마가 변할까?
  • 누가 소비자인가?
  • 성능이 병목인가?
  • 타입 안전성이 중요한가?

이 답에 따라 포맷을 선택하라. 그것이 엔지니어링의 기술이다.


참고 자료

현재 단락 (1/756)

REST API를 만들 때 대부분의 개발자가 자연스럽게 선택하는 것:

작성 글자: 0원문 글자: 21,592작성 단락: 0/756