TT Lab
요금제

한국어

시작하기
블로그

블로그

protobuf 와이어 형식: 21바이트를 한 칸씩

상태: 초안 (2026-10-17), 실측. 바이트는 모두 python3(3.9, 표준 라이브러리만)로 직접 인코딩한 값입니다. 공식 protobuf 라이브러리로 같은 메시지를 직렬화해 대조하는 일은 패키지를 내려받아야 해서 하지 않았습니다(미실측, 필요한 것: protobuf 파이썬 패키지와 protoc). 규칙은 protobuf.dev 의 Encoding 문서와 proto3 언어 안내(2026-10-17 열람)로 확인했습니다.

목차

1. 직관: 칸 이름 대신 칸 번호

JSON 은 {"rallyId":150} 처럼 칸 이름을 매번 적습니다. 읽기 쉽지만 같은 이름이 메시지마다 반복됩니다. protobuf 는 계약서(.proto)를 양쪽이 이미 갖고 있다고 보고, 바이트에는 칸 번호와 값만 적습니다. 「1번 칸: 150」. 이름은 계약서에 있으니 보낼 필요가 없습니다.

여기서 두 가지가 따라옵니다.

2. 그림: Rally 한 건의 21바이트

1편의 계약서에서 메시지 하나를 채웁니다. rally_id=150, server="티티", hits=[3,270,1], won=true, delta=-2.

Rally 메시지 21바이트를 한 칸씩 08 태그 96 01 12 태그 06 ed 8b b0 ed 8b b0 1a 태그 04 03 8e 02 01 20 태그 01 28 태그 03 읽는 법 필드 1 rally_id 태그 08 · 150=96 01 필드 2 server 태그 12 · 길이 06 · UTF-8 필드 3 hits 태그 1a · 길이 04 · 3,270,1 필드 4 won 태그 20 · 1 필드 5 delta 태그 28 · -2→3 태그 = (필드 번호 << 3) | 와이어 타입. 08 = 1번, varint 같은 값을 다른 형식으로 (실측, python3 표준 라이브러리) protobuf 21바이트 JSON 한 줄 72바이트 JSON 들여쓰기 111바이트
그림: Rally 메시지 21바이트를 필드별로 나눈 그림과 JSON 과의 크기 비교

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 비교

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. 한계

7. 작은 클러스터와의 연결

kubelet 과 containerd 사이의 CRI 메시지도 이 형식입니다. 그래서 kubelet 이 containerd 보다 새 판이어도(CRI 에 새 필드가 더해져도) 옛 containerd 는 모르는 필드를 건너뛰며 일할 수 있습니다. 반대로 CRI 쪽이 필드 번호를 함부로 바꾸지 않는 이유도 같습니다. kubelet 이 CRI 응답을 받을 때 상한을 gRPC 기본 4MiB 대신 16MiB 로 올려 두는 것(6편)은, 컨테이너 수백 개의 목록이 한 메시지에 담기기 때문입니다.

티티 한마디. 점수판에 「1번 칸: 150」 이라고만 적어도 서로 같은 서식을 들고 있으면 충분해요. 단, 음수 점수를 int32 칸에 적으면 10칸을 차지하니 sint32 칸을 쓰세요!

참고

모든 자료는 2026-10-17 에 열람했습니다.

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다