LabHub
배우기 러닝패스 코스

I renumbered a field and the old client silently read the wrong value

Change the schema in front of an old client

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 하나만 쓰세요 — 채점기는 행에서 처음 나오는 판정 낱말을 봅니다.