LabHub
学习 学习路径 课程

改了字段编号,旧客户端悄悄读出了错误的值

在旧客户端面前修改 schema

在 LabHub 中继续学习

한국어 원문으로 표시합니다.

목표

v1 스키마를 코드에 박아 둔 "옛 클라이언트" 를 그대로 둔 채 스키마를 v2 로 바꿉니다. 새 필드를 새 번호로 더하고, 지운 필드를 reserved 로 봉인하고, 일부러 번호를 바꾼 바이트를 옛 클라이언트가 어떻게 오류 없이 틀리게 읽는지 재현합니다. 그 뒤 기본값과 presence 의 함정을 실측하고, optionalreserved 를 갖춘 최종 스키마와 호환성 표를 남깁니다.

왜 중요한가

클라이언트와 서버는 절대 같은 순간에 배포되지 않습니다. 롤백도 있고, 로그에 남은 옛 바이트도 있습니다. 그래서 스키마 변경의 안전은 "컴파일이 되는가" 가 아니라 "옛 바이너리가 새 바이트를, 새 바이너리가 옛 바이트를 옳게 읽는가" 로 판단해야 합니다. 와이어에는 이름이 없고 번호만 있으므로, 번호를 바꾸는 것은 필드를 지우고 새로 만드는 것과 같습니다 — 그런데 파서는 아무 오류도 내지 않고 바뀐 번호의 값을 옛 이름으로 읽습니다. 이 실습은 그 사건을 재현해서 기억에 새기는 것이 목적입니다.

단계

  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_pricestring currency새 번호로 더합니다.
  3. v2.proto 에서 note 를 지우고 reserved 3;reserved "note"; 를 남기세요. 나머지는 그대로입니다.
  4. /root/grpc/evolve/make_v2.py 에서 /opt/app/grpc/pbmini.pyid=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.txtimplicit=<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, 셋째 열은 이유 한 줄입니다.

참고

옛 클라이언트로 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_pricestring 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.txtimplicit= 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 하나만 쓰세요 — 채점기는 행에서 처음 나오는 판정 낱말을 봅니다.