필드 번호를 바꿨더니 옛 클라이언트가 조용히 틀린 값을 읽었다 · 계약 검사는 리뷰어가 아니라 스크립트가 한다 · 이론
계약 검사는 리뷰어가 아니라 스크립트가 한다
한 줄 요약
번호 하나가 바뀐 diff 는 사람 눈에 안 띈다. 그래서 스키마 호환성 검사는 리뷰어가 아니라 스크립트가 해야 한다 — 두 .proto 를 파싱해 메시지별로 번호를 대조하고, 문서의 "안전하지 않은 변경" 목록을 기계가 읽게 옮긴다.
왜 이게 필요했나
앞 모듈에서 제목의 사건을 재현했다. 번호를 바꾸는 변경은 컴파일이 되고, 단위 테스트가 통과하고, 코드 리뷰에서는 숫자 두 개가 바뀐 줄로 보인다. 리뷰어가 매번 "이 번호가 예전에 뭐였지" 를 기억할 수는 없다. 반년 전 지운 필드의 번호를 기억하는 사람은 더더욱 없다. [모범 사례 문서](https://protobuf.dev/best-practices/dos-donts/)가 "절대 번호를 재사용하지 말라, 아무도 안 쓴다고 생각해도" 라고 쓰는 것은 그 기억이 믿을 만하지 않다는 뜻이다.
기계는 기억한다. 옛 .proto 와 새 .proto 를 나란히 놓고 번호를 대조하면 사람이 놓치는 것을 전부 잡는다. 이 검사는 문서의 [메시지 타입 갱신 규칙](https://protobuf.dev/programming-guides/proto3/#updating)을 옮겨 적은 것이라 판단이 들어갈 자리가 없고, CI 에 넣어 두면 병합 전에 돈다. 이 모듈에서 그 검사기를 표준 라이브러리로 직접 만든다.
어떻게 동작하나
무엇을 비교하나. 와이어 호환성은 메시지별로, 필드 번호를 키로 본다. 이름은 부차적이다. 그래서 검사기의 자료 구조는 {메시지: {번호: (이름, 타입)}} 에 예약 번호·예약 이름 집합을 더한 것이면 충분하다. 중첩 메시지는 Outer.Inner 로 이름을 붙여 따로 비교한다 — 바깥 메시지만 보면 안쪽의 삭제를 놓친다.
규칙은 문서에서 나온다. 아래 표가 검사기가 내는 판정이고, 오른쪽이 근거다.
| 판정 | 조건 | 근거 |
|---|---|---|
| REMOVED_NOT_RESERVED | 옛 번호가 새 파일에 없고 reserved 도 아님 | 지우는 것은 안전하나 번호를 다시 쓰면 안 된다 → reserved 로 막는다 |
| RENUMBERED | 같은 이름이 다른 번호로 | 번호 변경은 지우고 새로 만드는 것과 같다 — 안전하지 않다 |
| TYPE_CHANGED | 같은 번호·같은 이름, 다른 타입 | 타입은 거의 바꾸지 말라 (일부는 조건부 호환) |
| NUMBER_REUSED | 같은 번호에 다른 이름·다른 타입 | 번호 재사용은 해독을 모호하게 만든다 |
| REUSED_RESERVED | 옛 파일의 reserved 번호를 새 파일이 필드로 | 예약 목록에서 번호를 꺼내 쓰지 말라 |
| WARN RENAMED | 같은 번호·같은 타입, 이름만 다름 | 와이어에는 이름이 없다 — 바이너리는 무사, JSON·TextProto 는 영향 |
이름 변경을 경고로만 두는 이유는 인코딩 문서에 있다 — 바이트에는 번호와 와이어 타입뿐이고 이름은 읽는 쪽이 .proto 를 보고 붙인다. 이름만 바꾸면 바이트가 한 비트도 안 바뀐다. 다만 ProtoJSON 처럼 이름을 직렬화하는 형식을 쓰는 소비자에게는 깨지는 변경이므로, 검사기는 알리되 막지는 않는다. 이 구분을 못 하면 검사기가 매번 빨간불을 내고, 빨간불이 잦은 검사기는 무시당한다.
규칙의 우선순위. 하나의 원인에 하나의 줄이 나와야 한다. id 가 1 에서 2 로 옮겨 가면 "1 이 사라졌다(REMOVED)" 와 "2 에 다른 이름이 왔다(RENAMED)" 도 동시에 참이지만, 원인은 하나 — 번호를 바꿨다 — 이므로 RENUMBERED 만 내고 그 번호는 더 보지 않는다. 번호가 새 파일에 남아 있으면 REMOVED 가 아니다.
파서는 관대하게, 판정은 엄격하게. 실제 .proto 에는 주석, 여러 줄에 걸친 선언, [deprecated = true] 같은 옵션, reserved 9 to 11 같은 범위가 섞여 있다. 파서가 이런 것에 걸려 넘어지면 검사기를 끄게 된다. 반면 판정은 한 치도 양보하지 않는다 — 형식이 조금 달라도 읽어 주되, 번호 재사용은 반드시 떨어뜨린다. 파이썬으로는 주석을 정규식으로 지우고, message 이름 { 를 찾아 중괄호를 세어 짝을 맞추고, 안쪽을 타입 이름 = 번호; 정규식으로 읽으면 된다.
FIELD = re.compile(r"(?:\b(optional|repeated)\s+)?([A-Za-z_][\w.]*)\s+([A-Za-z_]\w*)\s*=\s*(\d+)\s*(?:\[[^\]]*\])?\s*;")출력 계약. 검사기는 도구다. 사람이 읽는 줄과 기계가 읽는 종료 코드를 둘 다 낸다 — 위반은 한 줄에 하나, 판정 이름으로 시작하고 메시지 이름과 번호를 담는다. 위반이 있으면 exit 1, 없으면 마지막 줄에 OK 를 찍고 exit 0. 경고는 exit 코드에 영향을 주지 않는다. 이 계약이 있어야 셸 스크립트로 디렉터리를 훑으며 하나라도 실패하면 CI 를 막을 수 있다.
검사기가 못 잡는 것. 문서의 조건부 호환 목록 — int32 를 int64 로 바꾸는 것 — 은 배포 순서를 통제할 때만 안전하다. 검사기는 그 순서를 모르므로 TYPE_CHANGED 로 막는 쪽이 옳다. 반대로 기본값의 의미 변경("0 이 이제 '미정' 이 아니라 '무료' 를 뜻한다")은 바이트도 스키마 텍스트도 안 바뀌어 어떤 검사기도 못 잡는다. 모범 사례 문서가 "필드의 기본값을 거의 바꾸지 말라" 고 따로 적는 이유다. 검사기는 리뷰를 대신하는 것이 아니라 리뷰어가 숫자 대조에 시간을 쓰지 않게 하는 것이다.
현장에서 만나는 모습
스키마 호환 검사를 CI 에 처음 넣는 날 대개 겪는 일이 있다 — 저장소의 옛 스키마 수십 개가 한꺼번에 빨간불이 된다. 예약 없이 지운 번호가 수년치 쌓여 있기 때문이다. 이때 규칙을 끄면 도구가 없는 것과 같아진다. 대신 옛 파일에 reserved 를 채워 넣는 정리 PR 을 먼저 낸다. 지운 번호를 예약하는 것은 언제 해도 안전한 변경이라 리스크가 없다.
두 번째는 "경고 피로" 다. 이름 변경을 오류로 두면, 오탈자를 고칠 때마다 검사가 막혀 사람들이 --no-verify 를 배운다. 그 뒤로는 진짜 위반도 같이 지나간다. 경고와 오류를 가르는 것은 사치가 아니라 검사기가 살아남기 위한 조건이다.
세 번째는 중첩 메시지다. 검사기가 최상위 메시지만 보고 있어서 Order.Item.count 의 타입 변경이 그대로 병합됐다. 파서를 짤 때 Outer.Inner 로 이름을 붙이는 한 줄이 이 사고를 막는다.
다음 실습에서 할 것
/root/grpc/compat/protocheck.py 를 만든다. 먼저 --dump 로 파서가 픽스처와 처음 보는 파일(주석·범위 예약·중첩·옵션)을 읽는지 확인하고, 규칙을 하나씩 더한다 — 예약 없는 삭제, 번호 변경, 타입 변경과 번호 재사용, 예약 번호 재사용, 그리고 이름 변경은 경고만. 픽스처 8쌍을 돌려 보고서를 만들고, 마지막에 디렉터리 단위로 도는 check-all.sh 를 만든다. 채점기는 픽스처 말고도 지시문에 없는 .proto 쌍을 만들어 여러분의 스크립트에 넣으므로, 픽스처 이름을 박아 둘 수 없다.