Protocol Buffers 정리 - JSON보다 10배 빠른 직렬화의 원리
서버 간 통신에서 JSON을 사용하다 보면 한 번쯤 이런 생각이 든다. "이거 너무 느린 거 아닌가?" 매번 문자열 파싱하고, 필드명까지 다 보내야 하고... 마이크로서비스 환경에서 초당 수천 건씩 주고받는데 이게 맞나 싶을 때가 있다.
그래서 찾아본 게 Protocol Buffers(이하 Protobuf)다. Google에서 만든 이진 직렬화 포맷인데, JSON 대비 속도는 수십 배 빠르고, 용량은 절반 이하라고 한다. 실제로 적용해보니 체감이 확실했다.
Protobuf가 어떻게 이런 성능을 내는지, 바이너리 레벨까지 파고들어 정리한다.
1. Protobuf란?
Protobuf는 Google에서 개발한 언어 중립적, 플랫폼 중립적인 직렬화 포맷이다.
핵심 특징을 정리하면:
- 바이너리 인코딩: 텍스트(JSON, XML)가 아닌 바이너리로 직렬화
- 스키마 기반:
.proto파일로 데이터 구조를 명시적으로 정의 - 다양한 언어 지원: C++, Java, Python, C#, Go, Kotlin 등
- 하위 호환성: 필드 추가/제거 시에도 기존 클라이언트와 호환 유지 가능
2. IDL (Interface Definition Language)
Protobuf는 .proto 파일을 통해 데이터 구조를 정의한다.
syntax = "proto3";
option csharp_namespace = "GameServer";
message EquipmentGachaReq {
GachaType gachaType = 1;
ItemType itemType = 2;
int32 gachaCount = 3;
}
enum ItemType {
ItemType_None = 0;
ItemType_Diamond = 1;
ItemType_PaidDiamond = 2;
ItemType_Gold = 3;
}여기서 중요한 건 각 필드 뒤의 숫자다. = 1, = 2 이런 것들. 이게 필드 번호인데, Protobuf 직렬화의 핵심이다.
3. 직렬화 원리 - 왜 JSON보다 빠를까?
JSON vs Protobuf
JSON으로 데이터를 보내면 이렇게 된다:
{"id": 150, "name": "Alice"}필드명 id, name이 매번 문자열로 전송된다. 파싱할 때도 문자열 비교를 해야 한다.
Protobuf는 다르다. 필드명 대신 필드 번호만 전송한다:
08 96 01 12 05 41 6C 69 63 65
10바이트. JSON은 32바이트였는데.
Wire Type 시스템
Protobuf는 데이터 타입을 4가지 Wire Type으로 분류한다:
| Wire Type | 이름 | 데이터 타입 |
|---|---|---|
| 0 | VARINT | int32, int64, uint32, uint64, bool, enum |
| 1 | I64 | fixed64, double |
| 2 | LEN | string, bytes, nested message, repeated |
| 5 | I32 | fixed32, float |
각 필드는 Tag = (field_number << 3) | wire_type 공식으로 인코딩된다.
4. 바이너리 인코딩 실습
직접 계산해보면서 이해해보자.
예시 1: 정수 인코딩
message User {
int32 Id = 1;
string Name = 2;
}new User { Id = 150, Name = "Alice" }Id 필드 인코딩 과정:
- Tag 계산:
(1 << 3) | 0 = 8 = 0x08 - 값 150을 Varint로 인코딩:
- 150 =
10010110(2진수) - 7비트 단위로 분할:
0010110|0000001 - MSB 플래그 추가:
1001011000000001 - 결과:
96 01
- 150 =
Name 필드 인코딩 과정:
- Tag 계산:
(2 << 3) | 2 = 18 = 0x12 - 길이: 5바이트 =
0x05 - ASCII 변환: A(41) l(6C) i(69) c(63) e(65)
최종 바이너리:
08 96 01 12 05 41 6C 69 63 65
| 바이트 | 의미 |
|---|---|
| 08 | Tag (field=1, VARINT) |
| 96 01 | 150 |
| 12 | Tag (field=2, LEN) |
| 05 | 길이 5 |
| 41 6C 69 63 65 | "Alice" |
예시 2: 음수 인코딩 (ZigZag)
음수는 2의 보수 표현 때문에 Varint로 인코딩하면 비효율적이다. -1이 FF FF FF FF FF FF FF FF FF 01 (10바이트)가 돼버린다.
그래서 sint32, sint64 타입은 ZigZag 인코딩을 사용한다:
n >= 0: ZZ = n * 2
n < 0: ZZ = (-n) * 2 - 1
-300을 인코딩하면:
- ZZ = 300 * 2 - 1 = 599
- Varint 인코딩:
D7 04
3바이트로 끝난다.
예시 3: 배열 (repeated)
message Data {
repeated int32 Nums = 1;
}new List<int> { 3, 270 }- Tag:
0x0A(field=1, wire=2) - Packed 모드로 하나의 LEN 필드에 담김
- 결과:
0A 03 03 8E 02
| 바이트 | 의미 |
|---|---|
| 0A | Tag |
| 03 | 총 길이 3바이트 |
| 03 8E 02 | [3, 270] |
5. 필드 동작 방식
optional
message CheckReceivableLuckyBagRes {
optional int64 SharedLuckyBagId = 1;
}값이 없으면 필드 자체가 직렬화되지 않는다. C#에서는 nullable 타입으로 생성된다.
oneof
message Item {
int32 ItemId = 1;
string Name = 2;
oneof IType {
Weapon weapon = 3;
Armor armor = 4;
}
}여러 필드 중 하나만 존재할 수 있다. 메모리 효율적이고, 상호 배타적인 데이터 표현에 적합하다.
var item = new Item();
item.Weapon = new Weapon(); // weapon 설정
item.Armor = new Armor(); // armor 설정 → weapon은 자동으로 null
Console.WriteLine(item.Weapon is null); // True6. 스키마 진화 (Schema Evolution)
서비스가 운영 중일 때 스키마가 바뀌면 어떻게 될까? Protobuf는 이걸 잘 처리한다.
규칙 1: 필드 번호는 절대 변경하지 않는다
message User {
string name = 1;
int32 age = 2; // ← 이 번호를 3으로 바꾸면 기존 데이터가 깨진다
string email = 3; // 새 필드는 새 번호로
}규칙 2: 삭제된 필드는 reserved로 표시
message User {
reserved 2; // 더 이상 age 번호 사용 금지
reserved "age"; // 필드명도 예약 가능
string name = 1;
string email = 3;
}나중에 누가 실수로 2번을 재사용하는 걸 막아준다.
7. .NET에서 코드 생성
Grpc.Tools 패키지를 사용하면 빌드 시 자동으로 C# 코드가 생성된다.
<ItemGroup>
<Protobuf Include="..\ProtoFiles\**\*.proto" GrpcServices="Both" ProtoRoot="..\ProtoFiles\">
<Link>Protos\%(RecursiveDir)%(Filename)%(Extension)</Link>
</Protobuf>
</ItemGroup>생성된 코드:
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
public sealed partial class TrainingStatLevelReq : pb::IMessage<TrainingStatLevelReq>
{
public const int StatTypeFieldNumber = 1;
public global::GameServer.StatType StatType { get; set; }
public const int LevelUpAmountFieldNumber = 2;
public int LevelUpAmount { get; set; }
}partial 클래스로 생성되어 확장 메서드 추가도 가능하다.
8. JSON과의 성능 비교
실제 측정 결과 (1만 건 기준):
| 항목 | JSON | Protobuf |
|---|---|---|
| 직렬화 속도 | 150ms | 8ms |
| 역직렬화 속도 | 180ms | 12ms |
| 메시지 크기 | 1.2MB | 0.4MB |
Protobuf가 압도적이다. 특히:
- 필드명 미포함: 네트워크 대역폭 절약
- 바이너리 파싱: 문자열 파싱 대비 훨씬 빠름
- 사전 컴파일된 코드: 리플렉션 오버헤드 없음
마무리
Protobuf는 빠른 JSON 대체제 그 이상이다. 바이너리 레벨에서 설계된 효율적인 직렬화 포맷이다.
핵심 포인트를 정리하면:
- 필드 번호 기반 인코딩으로 필드명 전송 불필요
- Varint 인코딩으로 작은 숫자는 적은 바이트로
- ZigZag 인코딩으로 음수도 효율적으로
- 스키마 진화로 하위 호환성 유지
마이크로서비스 간 통신, 게임 서버, 고성능이 필요한 모든 곳에서 고려해볼 만하다.
다음 글에서는 Protobuf 위에서 동작하는 gRPC 프레임워크를 다룬다.
Loading comments...