필드 번호를 바꿨더니 옛 클라이언트가 조용히 틀린 값을 읽었다 · 번호는 이름보다 오래 산다 · 실습
옛 클라이언트 앞에서 스키마를 바꾼다
목표
v1 스키마를 코드에 박아 둔 "옛 클라이언트" 를 그대로 둔 채 스키마를 v2 로 바꿉니다.
새 필드를 새 번호로 더하고, 지운 필드를 reserved 로 봉인하고, **일부러 번호를
바꾼 바이트**를 옛 클라이언트가 어떻게 오류 없이 틀리게 읽는지 재현합니다. 그 뒤
기본값과 presence 의 함정을 실측하고, optional 과 reserved 를 갖춘 최종
스키마와 호환성 표를 남깁니다.
왜 중요한가
클라이언트와 서버는 절대 같은 순간에 배포되지 않습니다. 롤백도 있고, 로그에
남은 옛 바이트도 있습니다. 그래서 스키마 변경의 안전은 "컴파일이 되는가" 가
아니라 "옛 바이너리가 새 바이트를, 새 바이너리가 옛 바이트를 옳게 읽는가" 로
판단해야 합니다. 와이어에는 이름이 없고 번호만 있으므로, 번호를 바꾸는 것은
필드를 지우고 새로 만드는 것과 같습니다 — 그런데 파서는 아무 오류도 내지 않고
바뀐 번호의 값을 옛 이름으로 읽습니다. 이 실습은 그 사건을 재현해서 기억에
새기는 것이 목적입니다.
단계
1. python3 /opt/app/grpc/old_client.py /opt/app/grpc/order_v1.bin 을 돌려 출력 네 줄을 /root/grpc/evolve/01-old.txt 에 그대로 저장하세요.
2. /root/grpc/evolve/v2.proto 를 쓰세요. syntax = "proto3"; 로 시작하고 message Order 에 v1 의 int32 id = 1; int32 qty = 2; string note = 3; 을 그대로 두고 int64 unit_price 와 string currency 를 새 번호로 더합니다.
3. v2.proto 에서 note 를 지우고 reserved 3; 과 reserved "note"; 를 남기세요. 나머지는 그대로입니다.
4. /root/grpc/evolve/make_v2.py 에서 /opt/app/grpc/pbmini.py 로 id=9001, qty=2, unit_price=15000, currency="KRW" 를 v2.proto 의 번호대로 인코딩해 /root/grpc/evolve/order_v2.bin 에 저장하고, 옛 클라이언트로 그 파일을 읽은 출력을 /root/grpc/evolve/04-old-reads-v2.txt 에 저장하세요.
5. /opt/app/grpc/renumbered.bin 은 /opt/app/grpc/renumbered.proto(번호를 다시 매긴 스키마)로 만든 같은 주문입니다. 옛 클라이언트로 읽은 출력을 /root/grpc/evolve/05-renumbered.txt 에 저장하고, 그 아래에 truth: id=<진짜 id> qty=<진짜 qty> 한 줄과 why: <왜 오류 없이 틀린 값이 나왔는지> 한 줄을 적으세요.
6. pbmini 로 id=9002, currency="KRW" 이고 qty=0 인 주문을 두 가지로 만드세요. /root/grpc/evolve/06-zero.bin 은 암묵적 presence(0 이면 보내지 않음), /root/grpc/evolve/06-zero-explicit.bin 은 명시적 presence(0 도 보냄). /root/grpc/evolve/06-presence.txt 에 implicit=<16진수> explicit=<16진수> implicit_qty=<옛 클라이언트가 읽은 qty> explicit_qty=<옛 클라이언트가 읽은 qty> 네 줄을 적으세요.
7. 최종 /root/grpc/evolve/Order.proto 를 쓰세요. optional int32 qty = 2;, reserved 3; + reserved "note";, unit_price·currency, 그리고 message Customer { string name = 1; int32 tier = 2; } 와 Order 의 Customer customer = <새 번호>; 가 들어갑니다.
8. /root/grpc/evolve/compat.md 에 마크다운 표를 쓰세요. 첫 열은 add-new-number remove-and-reserve reuse-number renumber int32-to-string rename-only 여섯 키, 둘째 열은 safe 또는 unsafe, 셋째 열은 이유 한 줄입니다.
참고
- 규칙의 원본: [proto3 언어 안내 — 메시지 타입 갱신](https://protobuf.dev/programming-guides/proto3/#updating), [필드 presence](https://protobuf.dev/programming-guides/field_presence/).
old_client.py는 v1 스키마를 코드에 박아 둔 프로그램입니다. 출력은id=qty=note=unknown=네 줄입니다. 6단계에서 두 파일을 각각 읽혀 보세요.- pbmini 사용:
import sys; sys.path.insert(0, "/opt/app/grpc"); import pbmini as pb; pb.encode_message([(1, "int32", 9001), (2, "int32", 2)]). - 채점기는 .proto 를 관대하게 읽습니다(주석·공백 자유). 그러나 번호 재사용, reserved 누락, 타입 변경은 반드시 떨어집니다.
- 8단계의
rename-only는 바이너리 와이어 기준입니다 — 와이어에 이름이 없으니 이름만 바꾸는 것은 바이트에 영향이 없습니다(JSON·텍스트 형식에서는 다릅니다).
단계 8개
- 옛 클라이언트로 v1 바이트를 읽는다
- 새 필드는 새 번호로
- 지운 번호를 봉인한다
- v2 바이트를 옛 클라이언트가 읽는다
- 번호를 바꾼 바이트를 옛 클라이언트가 읽는다
- qty=0 은 보낸 것인가 안 보낸 것인가
- optional 과 reserved 를 갖춘 최종 스키마
- 호환성 표를 쓴다