서버 간 통신에서 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. 바이너리 인코딩 실습
직접 계산해보면서 이해해보자.
정수 인코딩
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" |
음수 인코딩 (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바이트로 끝난다.
배열 (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는 필드를 추가하거나 제거해도 기존 클라이언트와의 호환을 유지할 수 있다.
필드 번호는 절대 변경하지 않는다
message User {
string name = 1;
int32 age = 2; // ← 이 번호를 3으로 바꾸면 기존 데이터가 깨진다
string email = 3; // 새 필드는 새 번호로
}삭제된 필드는 reserved로 표시
message User {
reserved 2; // 더 이상 age 번호 사용 금지
reserved "age"; // 필드명도 예약 가능
string name = 1;
string email = 3;
}나중에 누가 실수로 2번을 재사용하는 걸 막아준다.
이 글은 proto3 구문 기준으로 정리했다.
protoc 27부터는 proto2/proto3 구문을 에디션(Editions) 체계로 통합하는 방향으로 가고 있어서, 새로 스키마를 설계한다면 한 번 확인해볼 만하다.
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 |
차이가 나는 이유는 앞에서 본 구조 그대로다.
필드명을 보내지 않으니 페이로드가 작고, 문자열 파싱 대신 바이너리를 읽으니 역직렬화가 빠르다.
스키마 기반으로 미리 생성된 코드를 쓰기 때문에 리플렉션 오버헤드도 없다.
마무리
필드 번호 기반 인코딩과 Varint, ZigZag, 스키마 진화 규칙을 보면 Protobuf의 성능은 바이너리 레벨의 설계에서 나온다.
마이크로서비스 간 내부 통신이나 게임 서버처럼 호출량이 많은 환경이라면 도입을 검토할 만하다.
Protobuf 위에서 동작하는 RPC 프레임워크는 gRPC 정리에서 이어서 다룬다.