LabHub
배우기 러닝패스 코스

필드 번호를 바꿨더니 옛 클라이언트가 조용히 틀린 값을 읽었다 · 번호는 이름보다 오래 산다 · 이론

번호는 이름보다 오래 산다

LabHub 에서 이어서 보기

한 줄 요약

필드 번호는 이름보다 오래 산다. 새 필드는 새 번호로 더하고, 지운 번호는 reserved 로 봉인하고, 번호를 바꾸는 일은 절대 하지 않는다. 그리고 proto3 의 스칼라는 기본값(0, 빈 문자열)을 보내지 않으므로, "0 을 보냈다" 와 "아무것도 안 보냈다" 를 구별하려면 optional 이 필요하다.

왜 이게 필요했나

[모범 사례 문서](https://protobuf.dev/best-practices/dos-donts/)의 첫 문장이 이 모듈의 전제다 — 클라이언트와 서버는 절대 정확히 같은 순간에 갱신되지 않는다. 같이 배포하려 해도 한쪽이 롤백될 수 있고, 로그 어딘가에는 옛 스키마로 직렬화된 바이트가 남아 있다. 그러니 "지금 양쪽이 같은 스키마니까 괜찮다" 는 가정은 성립한 적이 없다.

앞 모듈에서 본 대로 와이어에는 번호만 있다. 그래서 스키마 변경의 안전성은 컴파일이 되는가가 아니라, 옛 바이너리가 새 바이트를 읽고 새 바이너리가 옛 바이트를 읽을 때 뜻이 보존되는가로 판단해야 한다. [proto3 언어 안내](https://protobuf.dev/programming-guides/proto3/)는 이 기준으로 변경을 세 부류로 나눈다 — 와이어에 안전한 것, 안전하지 않은 것, 조건부로 호환되는 것.

어떻게 동작하나

안전한 변경. 새 필드를 더하는 것은 안전하다. 옛 코드가 만든 바이트는 새 코드가 그대로 읽고(새 필드는 기본값), 새 코드가 만든 바이트는 옛 코드가 읽되 모르는 번호는 알 수 없는 필드(unknown field)로 넘긴다. proto3 는 알 수 없는 필드를 보존해서 다시 직렬화할 때 포함한다 — 중간에 낀 옛 서비스가 새 필드를 지워 버리지 않는다는 뜻이다. 다만 JSON 으로 바꾸거나 필드를 하나씩 옮겨 담으면 그 보존은 깨진다.

필드를 지우는 것도 안전하다. 조건이 하나 있다 — 그 번호를 다시 쓰지 않는 것. 문서는 지운 번호를 reserved 목록에 넣으라고 한다. 번호와 이름을 함께 예약할 수 있고, 한 문장에 둘을 섞을 수는 없다.

message Order {  reserved 3;          // 지운 note 의 번호. 9 to 11 처럼 범위도 된다  reserved "note";     // 이름 예약은 별도 문장으로  int32 id = 1;  int32 qty = 2;  int64 unit_price = 4;  string currency = 5;}

reserved 는 컴파일러가 지키는 약속이다. 나중에 누가 string memo = 3; 을 쓰면 protoc 이 거절한다. 이름 예약은 바이너리에는 영향이 없고 TextProto·JSON 처럼 이름이 직렬화되는 형식을 위한 것이다.

안전하지 않은 변경. 있는 필드의 번호를 바꾸는 것. 문서는 이를 "그 필드를 지우고 같은 타입의 새 필드를 만드는 것과 같다" 고 정의한다. 문제는 파서가 그 사실을 알 길이 없다는 것이다. 문서가 나열한 결과는 이렇다 — 디버깅에 날아가는 시간, 파싱·병합 오류(이게 최선의 경우다), 개인정보 유출, 데이터 손상. 번호 재사용의 흔한 원인 두 가지도 적혀 있다: 보기 좋게 번호를 다시 매기는 것, 지운 번호를 예약하지 않는 것.

조건부 호환. int32·uint32·int64·uint64·bool 은 서로 읽히지만 값이 잘릴 수 있다(64비트 값을 int32 로 읽으면 32비트로 잘린다). sint32sint64 는 서로만 호환되고 다른 정수 타입과는 아니다 — ZigZag 를 거치기 때문이다. stringbytes 는 바이트가 유효한 UTF-8 일 때만. fixed32sfixed32, fixed64sfixed64 는 짝끼리. 이 부류는 배포 순서를 통제할 수 있을 때만 쓰라고 문서가 못 박고, 모범 사례 문서는 아예 "타입을 거의 바꾸지 말라" 고 한다. int32string 으로 바꾸는 것은 와이어 타입이 VARINT 에서 LEN 으로 바뀌므로 이 부류에도 들지 않는다.

기본값과 presence. proto3 에서 라벨 없는 스칼라는 [암묵적 presence](https://protobuf.dev/programming-guides/field_presence/)를 따른다 — 기본값이면 직렬화하지 않는다. 숫자는 0, 문자열과 bytes 는 빈 값, bool 은 false. 그래서 qty = 0 을 보내면 와이어에 필드 2 레코드가 아예 없고, 받는 쪽은 "0 을 보냈다" 와 "보내지 않았다" 를 구별할 수 없다. 문서는 이 상태에서는 has_ 메서드도 없다고 적는다.

optional 라벨을 붙이면 명시적 presence 가 된다. 명시적으로 설정한 값은 기본값이어도 직렬화되고(10 00 두 바이트), 설정 여부를 물어볼 수 있다. 문서는 proto3 기본 타입에 항상 optional 을 붙이기를 권한다 — Editions 로 가는 길이 부드럽고, 부분 갱신(patch)에서 "0 으로 바꿔라" 를 표현할 수 있기 때문이다. 암묵적 presence 에서는 기본값이 병합되지 않아 FieldMask 같은 외부 장치가 필요해진다. 라벨을 바꾸는 것 자체는 바이너리 호환이지만, 한쪽이 has_ 를 믿고 있다면 다른 쪽을 거친 왕복에서 그 정보가 사라질 수 있다고 문서가 예시로 보여 준다.

번호의 범위. 1 부터 536,870,911 까지이고 19,000~19,999 는 구현 예약이라 컴파일러가 거절한다. 태그에서 3비트를 와이어 타입이 쓰기 때문에 32비트가 아니라 29비트다.

현장에서 만나는 모습

제목의 사건은 이렇게 난다. 주문 서비스 팀이 스키마를 정리하며 qty 를 1 번, id 를 2 번으로 바꿨다. 새 서버는 새 스키마로 직렬화하고, 배포는 무사히 끝났다. 그런데 정산 배치는 석 달 전 빌드다. 그 배치는 번호 1 을 여전히 id 라고 믿고 읽었고, 주문 7788 개의 수량이 3 개가 아니라 7788 개로 집계됐다. 와이어 타입이 둘 다 VARINT 라 오류가 날 자리조차 없었다. 실습의 renumbered.bin 이 정확히 이 바이트다.

두 번째 유형은 "0 이 사라지는" 사고다. 재고 서비스가 qty = 0 으로 갱신을 보냈는데, 암묵적 presence 라 필드가 아예 실리지 않았고, 받는 쪽의 병합 로직은 "온 것만 덮어쓴다" 였다. 결과적으로 수량이 이전 값에서 바뀌지 않았다. 문서가 "부분 갱신에서 기본값을 표현할 수 없다" 고 경고하는 바로 그 경우다. optional 을 붙였다면 10 00 두 바이트가 실려 0 이 전달됐다.

세 번째는 삭제 뒤의 재사용이다. 어떤 팀이 note 를 지우면서 번호 3 을 예약하지 않았고, 반년 뒤 다른 사람이 3 번에 string memo 를 넣었다. 로그 재처리 도중 옛 주문의 note 가 memo 로 읽혔다. 이름은 달라도 타입이 같아서 이번에도 오류가 없었다. 모범 사례 문서가 "그 변경이 한 번이라도 라이브였다면 어딘가 로그에 직렬화된 버전이 있다" 고 쓰는 이유다.

다음 실습에서 할 것

v1 스키마를 코드에 박아 둔 옛 클라이언트를 그대로 둔 채 스키마를 v2 로 바꾼다. 새 필드를 새 번호로 더해 옛 클라이언트가 건너뛰는 것을 보고, note 를 지우며 reserved 를 남긴다. 그다음 번호를 뒤바꾼 renumbered.bin 을 옛 클라이언트에 읽혀 id 와 qty 가 바뀌어 나오는 것을 기록한다 — 제목의 사건이다. qty = 0 을 암묵적·명시적 presence 로 각각 만들어 바이트를 비교하고, optionalreserved 를 갖춘 최종 스키마와 호환성 표를 남긴다.