상태: 초안 (2026-10-17), 실측. 바이트는 모두 python3(3.9, 표준 라이브러리만)로 직접 인코딩한 값입니다. 공식 protobuf 라이브러리로 같은 메시지를 직렬화해 대조하는 일은 패키지를 내려받아야 해서 하지 않았습니다(미실측, 필요한 것:
protobuf파이썬 패키지와protoc). 규칙은 protobuf.dev 의 Encoding 문서와 proto3 언어 안내(2026-10-17 열람)로 확인했습니다.
목차
- 1. 직관: 칸 이름 대신 칸 번호
- 2. 그림: Rally 한 건의 21바이트
- 3. 규칙 네 가지
- 4. 직접 해 보기: 손 인코딩과 JSON 비교
- 5. 하위 호환: 모르는 필드를 건너뛰는 법
- 6. 한계
- 7. 작은 클러스터와의 연결
- 참고
1. 직관: 칸 이름 대신 칸 번호
JSON 은 {"rallyId":150} 처럼 칸 이름을 매번 적습니다. 읽기 쉽지만 같은 이름이 메시지마다 반복됩니다. protobuf 는 계약서(.proto)를
양쪽이 이미 갖고 있다고 보고, 바이트에는 칸 번호와 값만 적습니다. 「1번 칸: 150」. 이름은 계약서에 있으니 보낼 필요가 없습니다.
여기서 두 가지가 따라옵니다.
- 작다. 이름이 없고 정수는 작으면 작게 씁니다.
- 번호가 곧 약속이다. 칸 이름은 바꿔도 되지만(바이트에 없으니까) 번호를 바꾸거나 다시 쓰면 옛 데이터가 엉뚱한 칸으로 읽힙니다.
2. 그림: Rally 한 건의 21바이트
1편의 계약서에서 메시지 하나를 채웁니다. rally_id=150, server="티티", hits=[3,270,1], won=true, delta=-2.
- 색 하나가 필드 하나입니다. 각 필드는 태그 바이트로 시작합니다(그림의 「태그」 표시).
- 문자열
"티티"는 UTF-8 로 6바이트(ed 8b b0두 번)이고, 앞에 길이06이 붙습니다. hits의 270 은 두 바이트(8e 02)라서 묶음의 길이는 4입니다.
3. 규칙 네 가지
3.1 varint: 7비트씩, 작은 자리부터
정수를 7비트씩 잘라 낮은 자리부터 쓰고, 뒤에 바이트가 더 있으면 맨 위 비트(MSB)를 1로 켭니다. 150 은 이진수 1001 0110 이라
아래 7비트 001 0110 에 이어짐 비트를 켠 0x96, 나머지 1 이 0x01 이 되어 96 01 입니다. 직접 계산한 값입니다.
| 값 | 바이트 | 크기 |
|---|---|---|
| 1 | 01 |
1 |
| 127 | 7f |
1 |
| 128 | 80 01 |
2 |
| 150 | 96 01 |
2 |
| 300 | ac 02 |
2 |
| 16,384 | 80 80 01 |
3 |
| 2,147,483,647 | ff ff ff ff 07 |
5 |
| int32 -1 | ff ff ff ff ff ff ff ff ff 01 |
10 |
마지막 줄이 함정입니다. int32 의 음수는 64비트 2의 보수로 늘려서 쓰기 때문에 항상 10바이트입니다(Encoding 문서의 「Signed Integers」 절).
음수가 자주 나오는 필드는 sint32/sint64 를 씁니다. ZigZag 로 0, -1, 1, -2, 2 … 를 0, 1, 2, 3, 4 … 에 대응시킨 뒤 varint 로 씁니다.
| sint32 값 | ZigZag | 바이트 |
|---|---|---|
| -1 | 1 | 01 |
| 1 | 2 | 02 |
| -2 | 3 | 03 |
| -64 | 127 | 7f |
| 64 | 128 | 80 01 |
3.2 태그 = (필드 번호 << 3) | 와이어 타입
와이어 타입은 값의 모양만 알려 줍니다. 0 VARINT(int32·int64·uint·bool·enum·sint), 1 I64(fixed64·double), 2 LEN(string·bytes· 메시지·packed 묶음), 5 I32(fixed32·float)이고, 3·4 는 옛 group 용이라 쓰지 않습니다. 태그도 varint 이므로 번호가 커지면 길어집니다.
| 필드 번호 | 와이어 타입 | 태그 바이트 |
|---|---|---|
| 1 | 0 | 08 |
| 2 | 2 | 12 |
| 15 | 0 | 78 |
| 16 | 0 | 80 01 |
| 2047 | 0 | f8 7f |
| 2048 | 0 | 80 80 01 |
1~15 번은 태그가 1바이트, 16~2047 번은 2바이트입니다. 자주 채우는 필드에 작은 번호를 주라는 안내가 여기서 나옵니다. 번호는 1부터 536,870,911(2²⁹-1)까지 쓸 수 있고 19,000~19,999 는 구현용으로 막혀 있습니다(proto3 안내).
3.3 LEN: 길이를 앞에
문자열·바이트·하위 메시지·packed 묶음은 태그 + 길이(varint) + 내용 입니다. proto3 에서 숫자형 repeated 는 기본이 packed 라서
hits=[3,270,1] 은 태그 1a(3번, LEN) 하나 + 길이 04 + 03 8e 02 01 입니다. packed 가 아니었다면 값마다 태그를 붙여 7바이트가 됩니다(계산).
3.4 기본값은 보내지 않는다
proto3 의 일반 스칼라 필드(암묵적 존재)는 값이 0·빈 문자열·false 이면 직렬화하지 않습니다. 모든 필드가 기본값인 Rally 는 0바이트입니다.
받는 쪽은 「0을 보냈다」와 「안 보냈다」를 구분할 수 없습니다. 구분이 필요하면 optional 을 붙여 존재를 명시합니다(proto3 안내의 Field Presence).
5편의 헬스 체크 요청 HealthCheckRequest{service:""} 가 실제로 0바이트인 것도 이 규칙 때문입니다.
4. 직접 해 보기: 손 인코딩과 JSON 비교
- 환경: 노트북, Python 3.9.6, 표준 라이브러리만(
struct,json) - 코드와 원자료: 작업 디렉터리
measure/grpc/pb_by_hand.py, 출력out-pb_by_hand.txt - 방법: 3절의 규칙대로 필드를 하나씩 바이트로 만들어 이어 붙였습니다.
def varint(n):
if n < 0:
n &= (1 << 64) - 1 # int32 음수는 64비트로 늘려 10바이트
out = bytearray()
while True:
b, n = n & 0x7F, n >> 7
out.append(b | 0x80 if n else b)
if not n:
return bytes(out)
def tag(field, wire):
return varint((field << 3) | wire)
결과(실측):
| 필드 | 바이트 | 크기 |
|---|---|---|
1 rally_id=150 |
08 96 01 |
3 |
2 server="티티" |
12 06 ed 8b b0 ed 8b b0 |
8 |
3 hits=[3,270,1] |
1a 04 03 8e 02 01 |
6 |
4 won=true |
20 01 |
2 |
5 delta=-2 (sint32) |
28 03 |
2 |
| 합계 | 21 |
같은 내용을 JSON 으로 쓰면(필드 이름은 proto3 JSON 관례대로 lowerCamelCase) 공백 없는 한 줄이 72바이트, indent=2 로 들여 쓰면
111바이트였습니다. 이 예제에서 protobuf 는 JSON 한 줄의 29% 크기입니다. 메시지가 숫자 위주일수록 차이가 커지고 긴 문자열 위주면
줄어듭니다. 압축(gzip) 뒤의 비교는 하지 않았습니다(미실측).
delta 를 int32 로 선언했다면 -2 의 값만 10바이트라 합계가 30바이트가 됩니다(계산). 필드 하나의 타입 선택이 메시지를 43% 키우는 셈입니다.
5. 하위 호환: 모르는 필드를 건너뛰는 법
새 판이 6번 필드 string note = 6; 을 더해 "hello" 를 넣었다고 합시다. 바이트 끝에 32 05 68 65 6c 6c 6f 가 붙습니다.
6번을 모르는 옛 판 파서는 태그 32 에서 와이어 타입 2(LEN)를 읽고, 길이 5만큼 건너뜁니다. 실제로 스크립트의 「옛 판 파서」
흉내(skip_fields)는 (6, 2, 6) 을 내며 끝까지 읽었습니다. 값의 모양을 태그가 알려 주기 때문에 가능한 일입니다.
proto3 는 이렇게 모르는 필드를 버리지 않고 보관했다가 다시 직렬화할 때 그대로 씁니다(proto3 안내의 Unknown Fields). 단, JSON 으로 바꾸거나 필드를 하나씩 복사하면 사라질 수 있습니다.
지켜야 할 것을 정리하면 이렇습니다(proto3 안내 요약).
| 바꾸려는 것 | 안전한가 | 이유 |
|---|---|---|
| 필드 추가(새 번호) | 안전 | 옛 판은 건너뛰고, 새 판은 기본값으로 읽음 |
| 필드 이름 변경 | 바이너리는 안전 | 바이트에 이름이 없음. 단 JSON·TextFormat 은 이름을 씀 |
| 필드 삭제 | 번호와 이름을 reserved 로 막으면 안전 |
다시 쓰면 옛 데이터가 새 뜻으로 읽힘 |
| 번호 재사용·변경 | 위험 | 해석이 엇갈려 데이터 손상·유출까지 이어질 수 있음 |
| int32 ↔ int64 ↔ uint32 ↔ bool | 와이어 호환 | 모두 VARINT. 범위를 넘으면 잘림 |
| int32 ↔ sint32 | 비호환 | ZigZag 해석이 달라 값이 바뀜(-2 ↔ 3) |
마지막 줄은 4절 예제로 확인할 수 있습니다. 28 03 을 int32 로 읽으면 3, sint32 로 읽으면 -2 입니다.
6. 한계
- 메시지 크기 상한: 직렬화한 메시지는 2GiB 미만이어야 합니다(Encoding 문서). gRPC 는 그보다 훨씬 작은 기본 상한(수신 4MiB)을 따로 둡니다(3편).
- 이 글은 proto3 만 봤습니다. Editions(2023~)의 기능 설정, proto2 의 required·group,
map의 와이어 표현(반복 하위 메시지)은 다루지 않았습니다. - 손 인코딩은 규칙을 이해하려는 것이지 구현을 검증한 것이 아닙니다. 공식 라이브러리 출력과 바이트 단위 대조는 미실측입니다.
- 필드 순서: 위 예제는 번호 순으로 썼지만, 명세는 직렬화 순서를 보장하지 않습니다. 같은 메시지가 늘 같은 바이트라고 가정하면 안 됩니다(해시·서명에 쓸 때 주의).
7. 작은 클러스터와의 연결
kubelet 과 containerd 사이의 CRI 메시지도 이 형식입니다. 그래서 kubelet 이 containerd 보다 새 판이어도(CRI 에 새 필드가 더해져도) 옛 containerd 는 모르는 필드를 건너뛰며 일할 수 있습니다. 반대로 CRI 쪽이 필드 번호를 함부로 바꾸지 않는 이유도 같습니다. kubelet 이 CRI 응답을 받을 때 상한을 gRPC 기본 4MiB 대신 16MiB 로 올려 두는 것(6편)은, 컨테이너 수백 개의 목록이 한 메시지에 담기기 때문입니다.
티티 한마디. 점수판에 「1번 칸: 150」 이라고만 적어도 서로 같은 서식을 들고 있으면 충분해요. 단, 음수 점수를 int32 칸에 적으면 10칸을 차지하니 sint32 칸을 쓰세요!
참고
모든 자료는 2026-10-17 에 열람했습니다.
- protobuf.dev: Encoding(와이어 타입, varint, ZigZag, packed, 크기 상한), Language Guide (proto3)(필드 번호 범위, reserved, unknown fields, 타입 호환, field presence)
- 바이트 계산 코드와 출력: 작업 디렉터리
measure/grpc/pb_by_hand.py,out-pb_by_hand.txt