I renumbered a field and the old client silently read the wrong value
Change the schema in front of an old client
한국어 원문으로 표시합니다.
목표
v1 스키마를 코드에 박아 둔 "옛 클라이언트" 를 그대로 둔 채 스키마를 v2 로 바꿉니다.
새 필드를 새 번호로 더하고, 지운 필드를 reserved 로 봉인하고, 일부러 번호를
바꾼 바이트를 옛 클라이언트가 어떻게 오류 없이 틀리게 읽는지 재현합니다. 그 뒤
기본값과 presence 의 함정을 실측하고, optional 과 reserved 를 갖춘 최종
스키마와 호환성 표를 남깁니다.
왜 중요한가
클라이언트와 서버는 절대 같은 순간에 배포되지 않습니다. 롤백도 있고, 로그에 남은 옛 바이트도 있습니다. 그래서 스키마 변경의 안전은 "컴파일이 되는가" 가 아니라 "옛 바이너리가 새 바이트를, 새 바이너리가 옛 바이트를 옳게 읽는가" 로 판단해야 합니다. 와이어에는 이름이 없고 번호만 있으므로, 번호를 바꾸는 것은 필드를 지우고 새로 만드는 것과 같습니다 — 그런데 파서는 아무 오류도 내지 않고 바뀐 번호의 값을 옛 이름으로 읽습니다. 이 실습은 그 사건을 재현해서 기억에 새기는 것이 목적입니다.
단계
python3 /opt/app/grpc/old_client.py /opt/app/grpc/order_v1.bin을 돌려 출력 네 줄을/root/grpc/evolve/01-old.txt에 그대로 저장하세요./root/grpc/evolve/v2.proto를 쓰세요.syntax = "proto3";로 시작하고message Order에 v1 의int32 id = 1; int32 qty = 2; string note = 3;을 그대로 두고int64 unit_price와string currency를 새 번호로 더합니다.- v2.proto 에서
note를 지우고reserved 3;과reserved "note";를 남기세요. 나머지는 그대로입니다. /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에 저장하세요./opt/app/grpc/renumbered.bin은/opt/app/grpc/renumbered.proto(번호를 다시 매긴 스키마)로 만든 같은 주문입니다. 옛 클라이언트로 읽은 출력을/root/grpc/evolve/05-renumbered.txt에 저장하고, 그 아래에truth: id=<진짜 id> qty=<진짜 qty>한 줄과why: <왜 오류 없이 틀린 값이 나왔는지>한 줄을 적으세요.- 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>네 줄을 적으세요. - 최종
/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 = <새 번호>;가 들어갑니다. /root/grpc/evolve/compat.md에 마크다운 표를 쓰세요. 첫 열은add-new-numberremove-and-reservereuse-numberrenumberint32-to-stringrename-only여섯 키, 둘째 열은safe또는unsafe, 셋째 열은 이유 한 줄입니다.
참고
- 규칙의 원본: proto3 언어 안내 — 메시지 타입 갱신, 필드 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·텍스트 형식에서는 다릅니다).
옛 클라이언트로 v1 바이트를 읽는다
python3 /opt/app/grpc/old_client.py /opt/app/grpc/order_v1.bin 의 출력 네 줄을 /root/grpc/evolve/01-old.txt 에 그대로 저장하세요.
mkdir -p /root/grpc/evolve 뒤에 출력을 > 로 저장하면 됩니다. 옛 클라이언트는 필드 1 을 id, 2 를 qty, 3 을 note 라고 믿고 읽고, 그 밖의 번호는 unknown 에 적습니다. 손으로 적지 말고 출력을 저장하세요 — 채점기가 같은 명령을 다시 돌려 비교합니다.
새 필드는 새 번호로
/root/grpc/evolve/v2.proto 에 v1 의 세 필드를 그대로 두고 int64 unit_price 와 string currency 를 새 번호로 더하세요.
있던 필드의 번호·이름·타입은 한 글자도 바꾸지 않습니다. 새 필드는 지금까지 쓰인 적 없는 번호를 받습니다 — 4 와 5 가 자연스럽지만 6 과 7 이어도 됩니다. 19,000~19,999 는 구현이 예약한 구간이라 쓸 수 없고, 1~15 는 태그가 한 바이트라 자주 쓰는 필드에 좋습니다.
채점기는 syntax = "proto3"; 와 message Order { ... } 를 찾고, 필드 1·2 가 v1 과 같은지, unit_price 가 int64 이고 currency 가 string 인지, 그 번호가 1·2·3 이 아닌지 봅니다.
지운 번호를 봉인한다
v2.proto 에서 note 를 지우고 reserved 3; 과 reserved "note"; 를 남기세요.
지우는 것 자체는 와이어에 안전합니다. 위험은 나중에 누가 3 을 다시 쓰는 것이고, reserved 는 protoc 이 그것을 컴파일 오류로 막게 하는 장치입니다. 번호와 이름은 한 문장에 섞어 쓸 수 없어 두 줄로 씁니다.
reserved 3; 은 message 블록 안 어디에 있어도 됩니다.
v2 바이트를 옛 클라이언트가 읽는다
/root/grpc/evolve/make_v2.py 에서 pbmini 로 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 에 저장하세요.
pb.encode_message([(1, "int32", 9001), (2, "int32", 2), (4, "int64", 15000), (5, "string", "KRW")]) 처럼 v2.proto 에서 고른 번호를 그대로 씁니다. note(3) 는 지운 필드이니 넣지 않습니다.
옛 클라이언트는 4·5 를 모르지만 와이어 타입으로 길이를 알아 건너뛰고, id·qty 는 옳게 읽습니다. unknown= 줄에 그 두 번호가 보여야 합니다.
번호를 바꾼 바이트를 옛 클라이언트가 읽는다
옛 클라이언트로 /opt/app/grpc/renumbered.bin 을 읽은 출력을 /root/grpc/evolve/05-renumbered.txt 에 저장하고, truth: id=<진짜> qty=<진짜> 와 why: <이유> 두 줄을 덧붙이세요.
/opt/app/grpc/renumbered.proto 를 먼저 읽으세요 — 누군가 번호를 다시 매겼습니다. 같은 주문(order_v1.bin 과 같은 값)을 그 스키마로 직렬화한 것이 renumbered.bin 입니다.
옛 클라이언트는 번호 1 을 여전히 id 라고 믿습니다. 와이어 타입이 같으니(둘 다 VARINT) 오류도 경고도 없이 값이 바뀌어 나옵니다. truth 줄은 renumbered.proto 대로 읽은 진짜 값입니다.
qty=0 은 보낸 것인가 안 보낸 것인가
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= explicit= implicit_qty= explicit_qty= 네 줄을 적으세요.
proto3 의 라벨 없는 스칼라는 기본값(0, "")을 직렬화하지 않습니다. 그래서 암묵적 쪽은 필드 2 레코드를 아예 넣지 않고, 명시적(optional) 쪽은 (2, "int32", 0) 을 넣습니다 — 두 바이트 10 00 이 더 실립니다.
두 파일을 옛 클라이언트로 각각 읽혀 qty 를 적으세요. 둘 다 0 이 나옵니다 — 옛 클라이언트는 "0 을 받았다" 와 "아무것도 못 받았다" 를 구별할 수 없습니다. 이것이 optional 이 필요한 이유입니다.
optional 과 reserved 를 갖춘 최종 스키마
/root/grpc/evolve/Order.proto 를 쓰세요 — optional int32 qty = 2;, reserved 3; + reserved "note";, int64 unit_price·string currency, message Customer { string name = 1; int32 tier = 2; } 와 Order 의 Customer customer = <새 번호>;.
optional 라벨을 붙이면 qty 에 explicit presence 가 생겨 0 을 보낸 것과 안 보낸 것을 구별할 수 있습니다. 바이너리 와이어에서는 라벨을 붙이는 것 자체가 바이트를 바꾸지 않습니다(0 을 보내게 될 뿐입니다).
Customer 는 별도 message 로 두고 Order 에서 타입으로 씁니다. customer 의 번호는 지금까지 쓰인 적 없고 reserved 도 아닌 것이어야 합니다.
호환성 표를 쓴다
/root/grpc/evolve/compat.md 에 마크다운 표를 쓰세요. 첫 열은 add-new-number remove-and-reserve reuse-number renumber int32-to-string rename-only, 둘째 열은 safe/unsafe, 셋째 열은 이유입니다.
이 실습에서 직접 본 것을 정리하는 단계입니다. 4단계(새 번호로 추가), 3단계(지우고 reserved), 5단계(번호 바꿈)가 각각 한 행의 근거입니다. 타입 변경은 와이어 타입이 바뀌면(int32 VARINT → string LEN) 옛 파서가 값을 다른 뜻으로 읽습니다. 이름만 바꾸는 것은 바이너리 와이어에 이름이 없으니 바이트가 같습니다.
판정 낱말은 safe 또는 unsafe 하나만 쓰세요 — 채점기는 행에서 처음 나오는 판정 낱말을 봅니다.