필드 번호를 바꿨더니 옛 클라이언트가 조용히 틀린 값을 읽었다 · 바이트 한 줄이 필드 번호를 품고 있다 · 이론
바이트 한 줄이 필드 번호를 품고 있다
한 줄 요약
protobuf 바이트에는 필드 이름이 없다. 태그 한 바이트 08 은 "필드 1, varint" 라는 뜻이고, 그 뒤의 96 01 이 150 이다. 이름과 선언 타입은 읽는 쪽의 .proto 가 붙인다 — 그래서 번호를 바꾸면 파서는 아무 오류 없이 다른 필드의 값을 읽는다.
왜 이게 필요했나
JSON 은 {"qty": 3} 처럼 이름을 같이 보낸다. 사람이 읽기 좋고, 필드 이름을 바꾸면 곧바로 티가 난다. 대신 매 메시지마다 이름을 문자열로 실어 보내야 하고, 숫자도 문자열로 적어야 한다. 트래픽이 많은 내부 서비스에서는 이 비용이 눈에 보인다.
Protocol Buffers 는 반대 선택을 했다. 이름을 보내지 않고 번호만 보낸다. 값도 텍스트가 아니라 가장 짧은 이진 표현으로 보낸다. 그 대가로 읽는 쪽은 반드시 스키마(.proto)를 알고 있어야 하고, 스키마의 번호가 곧 계약이 된다. 이 코스 전체가 그 대가에 관한 이야기다. 바이트를 한 번 손으로 읽어 보면 왜 그런지 몸으로 안다.
이 실습 이미지에는 protoc 도 protobuf 파이썬 패키지도 없다. 오히려 잘된 일이다. [인코딩 문서](https://protobuf.dev/programming-guides/encoding/)가 정한 규칙은 파이썬 표준 라이브러리로 백 줄이면 전부 옮길 수 있고, 라이브러리가 감춰 둔 것을 직접 열어 보는 것이 이 모듈의 목적이다.
어떻게 동작하나
문서의 첫 예시를 그대로 따라가 보자. message Test1 { int32 a = 1; } 에 a = 150 을 넣어 직렬화하면 세 바이트가 나온다.
08 96 01varint 부터. 정수를 7비트씩 잘라 낮은 자리부터 내보내고, 뒤에 더 있으면 그 바이트의 최상위 비트(MSB)를 세운다. 150 은 이진수로 10010110 이다. 아래 7비트 0010110 에 계속 표시를 붙이면 10010110 = 0x96, 남은 1 은 0x01. 그래서 96 01 이다. 1 은 한 바이트 01, 300 은 ac 02, 16384 는 80 80 01 — 값이 작을수록 짧다. 아래 값들은 이 글을 쓰며 파이썬으로 직접 계산한 것이다.
1 → 01 127 → 7f 128 → 80 01 150 → 96 01 300 → ac 0216384 → 80 80 01태그는 varint 로 인코딩된 (field_number << 3) | wire_type 이다. 아래 3비트가 와이어 타입, 나머지가 필드 번호. 08 은 0000 1000 이니 와이어 타입 0, 번호 1 이다. 와이어 타입은 여섯 가지가 있고 그중 넷을 실제로 만난다.
| 번호 | 이름 | 쓰는 타입 |
|---|---|---|
| 0 | VARINT | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
| 1 | I64 | fixed64, sfixed64, double |
| 2 | LEN | string, bytes, 서브메시지, packed repeated |
| 5 | I32 | fixed32, sfixed32, float |
(3 SGROUP · 4 EGROUP 은 폐기된 그룹용이다.) 필드 번호 1 부터 15 까지는 태그가 한 바이트, 16 부터는 두 바이트가 된다 — (16 << 3) | 0 = 128 이라 80 01 이다. [proto3 언어 안내](https://protobuf.dev/programming-guides/proto3/)가 "자주 쓰는 필드에 1 부터 15 까지를 주라" 고 하는 이유가 이 계산이다.
와이어 타입이 하는 일은 길이를 알려 주는 것이다. VARINT 는 MSB 가 0 인 바이트까지, I64 는 8바이트, I32 는 4바이트, LEN 은 바로 뒤의 varint 만큼. 이 덕분에 모르는 번호가 와도 파서는 얼마나 건너뛸지 안다. 옛 바이너리가 새 필드를 오류 없이 넘기는 원리가 이것이고, 다음 모듈에서 그 성질을 실측한다.
LEN 레코드는 태그 뒤에 길이 varint, 그다음 페이로드다. message Test2 { string b = 2; } 에 "testing" 을 넣으면 이렇다.
12 07 74 65 73 74 69 6e 67│ │ └── "testing" 의 UTF-8 7바이트│ └───── 길이 7└──────── 태그 (2 << 3) | 2 = 0x12길이는 글자 수가 아니라 바이트 수다. "한글 메모" 는 다섯 글자지만 UTF-8 로 13바이트라 길이 varint 도 13 이다. 이걸 글자 수로 세면 파서가 다음 태그를 엉뚱한 자리에서 읽는다.
서브메시지도 LEN 이다. message Test3 { Test1 c = 3; } 에서 c.a = 150 이면 1a 03 08 96 01 — 태그 1a(3:LEN), 길이 3, 그리고 아까 그 세 바이트가 그대로 들어 있다. 페이로드를 같은 함수로 한 번 더 해독하면 안쪽 메시지가 나온다.
음수가 함정이다. int32 와 int64 는 음수를 2의 보수로 다루는데, 문서는 이것을 "unsigned 64비트 정수로서 최상위 비트가 서 있으므로 열 바이트를 전부 쓴다" 고 설명한다. -1 은 ff ff ff ff ff ff ff ff ff 01, 열 바이트다. 음수가 잦은 필드는 sint32 를 쓴다. sint 는 ZigZag 로 먼저 바꾼다 — 양수 p 는 2p, 음수 n 은 2|n|-1. 0→0, -1→1, 1→2, -2→3 으로 번갈아 가며, 32비트 식은 (n << 1) ^ (n >> 31) 이다. 그러면 -1 은 varint 1, 즉 01 한 바이트가 된다. -150 은 ZigZag 299 → ab 02 두 바이트. 같은 값이 10바이트와 2바이트로 갈리는 것을 실습에서 직접 잰다.
빠진 필드는 그냥 레코드를 안 쓴다. 헤더도 필드 수도 없다. 메시지는 레코드를 이어 붙인 것이 전부라, 필드 순서도 보장되지 않고 파서는 어떤 순서로 와도 읽어야 한다. 문서는 "직렬화 결과 바이트가 안정적이라고 가정하지 말라" 고 못 박는다 — 같은 메시지를 두 번 직렬화해도 바이트가 같다는 보장이 없으니 해시나 CRC 를 그 위에 얹으면 안 된다.
packed repeated 는 Edition 2023 부터 기본이다. 스칼라 반복 필드는 원소마다 태그를 붙이는 대신 LEN 레코드 하나에 값을 이어 붙인다. repeated int32 e = 5 에 1, 2, 3 을 넣으면 2a 03 01 02 03 — 태그 하나에 세 값이다. 파서는 packed 로 왔든 원소별로 왔든 둘 다 읽어야 한다고 문서가 요구한다.
현장에서 만나는 모습
가장 흔한 사고는 "데이터가 깨졌는데 오류가 없다" 다. 어떤 팀이 스키마 정리를 하며 필드 번호를 보기 좋게 다시 매겼다. 컴파일이 되고 테스트가 통과했다. 그런데 아직 옛 바이너리를 쓰는 소비자 서비스가 주문 수량 자리에 주문 번호를 읽기 시작했다. 파서 입장에서는 번호 2 에 varint 가 왔으니 규칙대로 읽은 것뿐이다. 이름은 와이어에 없으므로 "이건 qty 가 아니다" 라고 말해 줄 정보 자체가 없다. 이런 사고는 로그에 예외가 없어서, 발견까지 며칠이 걸린다.
두 번째는 크기 착각이다. "정수 하나에 4바이트" 를 가정하고 용량을 계산했는데, 음수 int32 가 열 바이트씩 나가서 예상의 두 배가 넘게 나온 적이 있다. 반대로 sint32 로 바꾸고 나서 절반 이하로 줄기도 한다. 타입을 고를 때 값의 분포를 보는 습관이 여기서 나온다.
세 번째는 문자열 길이다. 직접 파서를 짜 본 사람이 처음 겪는 버그는 거의 예외 없이 UTF-8 바이트 수와 글자 수의 혼동이다. ASCII 만 테스트하면 절대 안 드러나고, 첫 한글 메모에서 터진다.
다음 실습에서 할 것
/root/grpc/pb.py 에 varint, 태그, ZigZag, 필드, 메시지 인코더·디코더를 차례로 만든다. 150 이 96 01 이 되는 것, int32 -1 이 열 바이트이고 sint32 -1 이 한 바이트인 것을 직접 잰다. 그다음 픽스처 바이트를 해독해 v1 스키마가 모르는 필드를 가려내고, 서브메시지와 packed repeated 를 두 번 해독한다. 채점기는 여러분의 pb.py 를 불러와 지시문에 없는 무작위 값으로 왕복시키므로, 값을 박아 둘 수는 없다. 규칙을 옮겨야 한다.