← all posts
DEV 2026.05.02 · 14 min read Intermediate

Protobuf은 왜 JSON보다 작고 빠른가

Tag-Length-Value 인코딩부터 스키마 진화의 호환성 규칙까지, Protobuf의 설계 결정이 만들어내는 성능과 안전성의 근거를 추적한다.


JSON으로 {"name":"Alice","age":30}을 전송하면 27바이트가 필요하다. Protobuf로 같은 데이터를 전송하면 7바이트다. 이 차이는 어디서 오는가? 그리고 이 선택이 스키마를 변경할 때 어떤 제약을 만들어내는가?

필드 이름이 사라지는 순간

Protobuf의 핵심은 하나다 — 필드 이름을 전송하지 않는다. 대신 .proto 파일에서 개발자가 선언한 필드 번호(field number)만 전송한다.

Tag = (field_number << 3) | wire_type
예: field=1, wire_type=2 → (1 << 3) | 2 = 0x0a

Person{name:"kim", age:25}의 직렬화 결과는 0a 03 6b 69 6d 10 19다. 총 7바이트. 이 바이트열에 "name"이나 "age"라는 문자열은 존재하지 않는다. 수신자는 0x0a를 보고 “field 1, length-delimited”라는 것을 알고, 0x10을 보고 “field 2, varint”라는 것을 안다. 이것이 가능한 이유는 양쪽이 같은 .proto 파일을 protoc로 컴파일해 코드를 생성했기 때문이다.

Wire Type은 데이터 종류에 따라 분기한다. 0은 Varint(int32, bool, enum), 1은 64-bit 고정 크기(double), 2는 Length-delimited(string, bytes, nested message, repeated), 5는 32-bit 고정 크기(float)다.

숫자를 최소 바이트로 — Varint와 ZigZag

필드 이름 생략만으로는 설명이 부족하다. Protobuf는 숫자 자체도 압축한다.

Varint는 7비트씩 저장하고 MSB(Most Significant Bit)를 continuation bit으로 사용한다. 25는 1바이트, 300은 2바이트, int64의 최댓값은 10바이트다. 자주 쓰는 작은 수는 1~2바이트로 전송된다.

def varint_encode(value):
    result = []
    while value > 127:
        result.append((value & 0x7f) | 0x80)  # 7비트 + MSB=1
        value >>= 7
    result.append(value & 0x7f)
    return bytes(result)

# varint_encode(300) → [0xac, 0x02]

문제는 음수다. -1을 2의 보수로 표현하면 64비트가 전부 1이 되어 Varint로 10바이트가 필요하다. 이 낭비를 막기 위해 sint32/sint64 타입은 ZigZag 인코딩을 사용한다.

n → (n << 1) ^ (n >> 31)   # int32

0 → 0,  -1 → 1,  1 → 2,  -2 → 3

음수와 양수를 번갈아 매핑해 절댓값이 작은 수는 작은 바이트로 전송된다. -1000은 ZigZag로 1999가 되고, Varint로 2바이트로 인코딩된다. int64 타입의 -1000은 10바이트였을 것이다.

타입 선택이 곧 성능

음수가 자주 나오는 필드에 int64 대신 sint64를 쓰는 것, 양수만 확실한 필드에 uint32를 쓰는 것 — 이런 선택 하나하나가 메시지 크기를 결정한다. proto 파일의 타입 선언은 단순한 문서가 아니라 인코딩 전략이다.

필드 번호는 변경 불가능한 계약

여기서 Protobuf의 두 번째 원칙이 등장한다. 필드 번호는 API 계약이다.

protoc가 생성한 코드에서 필드 이름은 언제든 바꿀 수 있다. namedisplay_name으로 바꿔도 직렬화는 번호 기반으로 동작하므로 호환성에 문제가 없다. 그러나 번호를 바꾸거나 삭제한 번호를 재사용하면 조용한 데이터 손상이 발생한다.

v1: message User { string name = 1; int32 age = 2; string phone = 3; }
v2: message User { string name = 1; int32 age = 2; string country = 3; }
                                                           ↑ 3번 재사용!

v1 클라이언트가 phone="010-1234-5678"을 전송하면, v2 서버는 필드 3을 country로 해석한다. 에러는 발생하지 않는다. 그냥 전화번호가 국가 코드로 저장된다.

삭제된 필드 번호는 reserved로 영구 보호해야 한다.

message Event {
  string event_id = 1;
  int32 event_type = 2;
  // string description = 3;  ← 삭제됨

  reserved 3;  // 이 번호는 영구적으로 사용 금지
  
  bool is_critical = 4;  // 새 필드는 4번부터
}

reserved 없이 필드를 삭제하면, 미래의 개발자가 그 번호를 모르고 재사용해 과거 데이터를 손상시킬 수 있다. protoc는 reserved 번호를 사용하려 하면 컴파일 에러를 낸다.

proto3의 기본값 생략 — 0원과 미설정의 차이

proto3는 기본값인 필드를 직렬화하지 않는다. int32 타입의 0, bool 타입의 false, string 타입의 ""는 전송되지 않는다. 빈 메시지는 0바이트다.

이 최적화는 대부분 상황에서 좋지만, “0원 할인”과 “가격 미설정”을 구분해야 하는 경우 문제가 된다. optional 키워드가 이 경우를 위해 존재한다.

message Invoice {
  string invoice_id = 1;
  optional int32 discount_amount = 2;  // hasDiscountAmount() 사용 가능
}

optional 필드는 값이 0이더라도 명시적으로 설정되었으면 직렬화에 포함된다. hasDiscountAmount()로 설정 여부를 확인할 수 있다. oneof는 상호 배타적 필드를 표현할 때 사용한다 — 결제 수단이 신용카드이거나 무료이거나 기프트카드이거나 하나만 설정 가능하도록 컴파일러가 강제한다.

스키마 호환성 — 구와 신이 공존하는 방법

마이크로서비스에서 서버와 클라이언트는 동시에 배포되지 않는다. Protobuf은 이 상황을 두 방향으로 지원한다.

Backward compatible: 신 서버가 구 클라이언트 데이터를 처리할 수 있다. 구 클라이언트가 모르는 새 필드를 전송하지 않으면 신 서버는 해당 필드를 기본값으로 처리한다.

Forward compatible: 구 서버가 신 클라이언트 데이터를 처리할 수 있다. 신 클라이언트가 구 서버가 모르는 필드를 전송해도, 구 서버는 모르는 필드를 “unknown field”로 무시하고 파싱을 계속한다.

이 양방향 호환성은 필드 번호와 wire type이 유지되는 한 보장된다. 필드 추가는 안전하다. 필드 삭제는 reserved로 보호하면 안전하다. 필드 타입 변경과 필드 번호 재사용은 호환성을 파괴한다.

트레이드오프

Protobuf이 모든 면에서 우월한 것은 아니다.

트레이드오프

Protobuf은 크기와 속도에서 JSON 대비 2~3배 우위를 가진다. 대신 가독성이 없다 — 바이너리 형식이므로 tcpdump나 로그에서 데이터를 직접 읽을 수 없다. protoc를 통한 빌드 단계가 필요하고, .proto 파일 관리와 reserved 규칙을 팀 전체가 이해해야 한다. 공개 API, 5개 미만의 소규모 서비스, 개발 속도가 최우선인 환경에서는 JSON이 더 실용적이다.

정리

  • Protobuf의 크기 효율은 필드 이름 생략(Tag = 번호 + wire type)과 Varint 인코딩에서 나온다.
  • 필드 번호는 API 계약이다. 한 번 할당하면 변경 불가능하고, 삭제 시 reserved로 보호해야 한다.
  • proto3의 기본값 생략은 크기를 줄이지만, “0”과 “미설정”을 구분해야 하면 optional을 써야 한다.
  • Backward/Forward 호환성은 필드 추가와 unknown field 무시로 자동 보장된다. 번호 재사용이 이것을 파괴한다.

다음 글에서는 Protobuf 위에서 동작하는 gRPC의 HTTP/2 스트리밍이 어떻게 단방향/양방향 통신을 구현하는지 추적한다.