Protobuf 브레이킹 체인지: 와이어에서 살아남는 것
5분 분량
REST API는 JSON의 형태가 바뀔 때 바뀌고, 그 형태의 대부분은 브라우저에서 읽을 수 있는 응답 안에
보인다. gRPC API는 .proto 파일이 바뀔 때 바뀌며, Protocol Buffers의 바이너리 와이어 포맷은
필드 이름이 뭐라고 말하든 상관없이 클라이언트가 견딜 수 있는 것에 대해 자기만의 규칙을 갖는다.
diff에서 똑같이 사소해 보이는 두 변경, 필드 번호를 다시 매기는 것과 새 필드를 추가하는 것은,
breaking changes가 일반적으로 긋는 선의 반대편에 떨어진다. 하나는
기존 클라이언트 모두에게 보이지 않고, 다른 하나는 그것들 전부를 한꺼번에 망가뜨린다. protobuf
브레이킹 체인지를 안전한 변경과 구별한다는 것은 .proto diff가 어떻게 읽히는지 짐작하는 게
아니라 와이어 포맷 자체의 규칙을 읽는 것을 뜻한다.
Protobuf에서 필드 이름보다 번호가 중요한 이유는 무엇인가
와이어 포맷이 이름이 아니라 번호로 필드를 인코딩하기 때문이다. 각 언어의 생성된 코드는 이
번호를 읽고 쓴다. .proto 파일의 email이라는 필드 이름은 사람을 위한 편의일 뿐,
네트워크로 전송되는 바이너리 바이트에는 전혀 닿지 않는다. 필드 이름을 바꾸는 것, email을
email_address로 바꾸는 것은 번호가 그대로인 한 바이너리 와이어에서 안전하며, 이것은 리네임된
JSON 키가 정확히 클라이언트를 망가뜨리는 종류의 변경인 REST에 익숙한 엔지니어를 놀라게 한다.
예외도 바로 그 REST와 같은 경우다. ProtoJSON과 텍스트 형식은
이름을 직렬화하므로, 이름 변경은 JSON 트랜스코딩(예: grpc-gateway), 텍스트 형식 파일, 필드 마스크를
망가뜨린다. 같은 필드의 번호를 다시 매기는 것, 이름은 유지하되 1을 7로 바꾸는 것은 정반대다. 이름만
보여주는 코드 리뷰에서는 보이지 않으면서, 그 순간부터 클라이언트가 보내거나 받는 모든 메시지를
망가뜨린다.
| 변경 | 와이어에서 안전한가 | 이유 |
|---|---|---|
| 필드 이름 변경, 번호 유지 | 바이너리는 예, JSON과 텍스트는 아니오 | 바이너리 인코딩은 번호를 사용한다. ProtoJSON과 텍스트 형식은 이름을 사용한다 |
| 필드 번호 변경 | 아니오 | 모든 기존 메시지가 이제 다른 필드로 읽힌다 |
| 새 번호로 새 필드 추가 | 예 | 오래된 클라이언트는 모르는 필드를 무시한다 |
| 필드를 삭제하고 그 옛 번호를 다른 용도로 재사용 | 아니오 | 오래된 데이터가 잘못된 새 필드로 디코딩된다 |
필드 타입을 비호환적으로 변경 (예: int32를 string으로) | 아니오 | 와이어 인코딩은 타입마다 다르다 |
필드 삭제가 REST JSON 응답에서의 같은 일과 다른 이유는 무엇인가
번호가 방사성을 띠게 되기 때문이다. Protobuf 자체 가이드는 삭제된 필드의 번호를
reserved로 표시하고 재사용을 허용하지 말 것을 권장하는데, 실제 피해가 발생하는 곳이 바로
그 재사용이기 때문이다. 몇 달 전 생성된 코드로 여전히 실행되는 클라이언트가 오래된 필드
번호를 오래된 값을 위해 보내면, 그 번호가 이제 다른 것을 의미한다고 기대하는 서버는 데이터를
바로 거부하는 대신 조용히 잘못 해석한다. REST에는 이에 상응하는 함정이 없다. 삭제된 JSON
키는 그냥 더 이상 도착하지 않을 뿐이고, 오래된 클라이언트의 요청이 조용히 다른 것으로
재해석되는 방법은 존재하지 않는다. 메시지 앞에 reserved 4, 9, 12;를 가진 .proto 파일은
영구적인 흉터이며, 그것이 핵심이다. 그 번호가 자신의 이력을 모르는 누군가에 의해 새 필드에
넘어가는 것을 막는다.
message Invoice {
reserved 4; // 예전에는 `legacy_customer_id`, 2026-06-01에 삭제
reserved "legacy_customer_id"; // JSON/텍스트용으로 이름도 예약
string customer_id = 5;
string status = 6;
}
필드 추가는 애초에 체인지로그 항목을 필요로 하는가
보통 breaking change 항목은 아니지만 흔히 일반 항목은 필요하다. “와이어에서 안전함”과 “신경 쓰는 독자에게 보임”은 서로 다른 주장이기 때문이다. 응답 메시지에 필드를 추가하는 것은 구조적으로 공짜이며, 오래된 클라이언트는 메시지를 디코딩하고 새 필드를 자동으로 무시한다. 하지만 이 서비스에 대해 새 통합을 구축하는 사람은 누군가 말해주지 않는 한 그 필드가 존재한다는 것을 알 방법이 없다. 성공한 빌드나 통과한 테스트 중 어느 것도 새로운 옵션 필드를 보이게 만들지 않기 때문이다. 체인지로그 API는 추가적 항목이 독자에게 빚지고 있는 것을 일반적으로 다룬다. 그럼에도 gRPC에 특유한 이유로 그것을 써야 하는 것은, 디버거에서 REST 응답을 훑어보다가 새 키가 나타난 것을 알아차리는 것에 상응하는 것이 존재하지 않기 때문이다.
GraphQL 호출자가 마주하는 것과는 어떻게 다른가
추가에 관한 규칙은 같지만, 노출되는 방식이 다르다. GraphQL 스키마 폐기는 클라이언트가 명시적으로 요청한 필드만 받는 모델을 다루는데, 이는 추가적 변경을 본질적으로 무위험으로 만들고 삭제만을 진짜 위험으로 만든다. 반면 gRPC 클라이언트는 서버가 보내는 모든 것을 받고, 자신의 컴파일된 스키마 사본에 대해 전부 디코딩한다. 클라이언트의 노출은 요청한 것이 아니라 그 생성된 코드가 읽을 수 있는 것만으로 제한된다. 이 차이는 체인지로그를 쓰는 데 중요하다. GraphQL 항목은 클라이언트가 요청하지 않은 필드로부터 보호받는다고 합리적으로 가정할 수 있지만, gRPC 항목은 그것을 전혀 가정할 수 없다.
gRPC 서비스의 버전 관리는 REST의 /v1/, /v2/와 같은 방식으로 작동하는가
의도는 같아도 메커니즘은 다르다. REST API에서 v1과 v2란 무엇인가는
버전 관리를 서로 다른 계약을 제공하는 병렬 URL 경로로 다룬다. gRPC 서비스는 보통 .proto
파일 자체 안의 패키지 이름을 통해 버전이 관리되며, payments.v1.InvoiceService가
payments.v2.InvoiceService가 된다. 이것은 클라이언트가 요청하는 URL 세그먼트가 아니라
클라이언트가 다이얼하는 완전한 자격을 갖춘 서비스 이름을 바꾼다. 두 접근 방식 모두 같은
문제를 해결한다. 새 계약이 존재하는 동안 오래된 계약이 계속 작동하게 하는 것이다. 하지만
REST 배경을 가진 팀은 흔히 잘못된 곳에서 버전 번호를 찾다가 그 작업을 패키지 선언이 하고
있다는 것을 놓친다.
gRPC 체인지로그 항목은 실제로 무엇을 지칭해야 하는가
메시지, 필드 번호, 그리고 그 변경이 추가적인지 아니면 마이그레이션이 필요한 삭제인지, 이
순서가 행동할지 결정하는 독자에게 중요한 순서다. “Order에 shipping_address(필드 8)
추가”는 통합자에게 생성된 코드를 업데이트하고 사용을 시작하는 데 필요한 모든 것을 알려준다.
“Invoice의 필드 4 예약, legacy_customer_id 사라짐”은 자신의 코드베이스 안의 무언가가
여전히 그 필드를 읽고 있지 않은지 확인하라고 알려주는데, 이것은 REST 스타일의 “응답에서
필드 삭제됨”이라는 메모가 같은 긴급함으로 전달하지 못하는 것이다. REST 삭제는 단지 더
적은 데이터를 반환할 뿐이지만, Protobuf 필드 재사용은 그것을 적극적으로 망가뜨리기
때문이다.
FAQ
필드 타입이 와이어 포맷을 망가뜨리지 않고 변경될 수 있는 경우가 있는가?
Protobuf가 문서화하는 특정 호환 그룹 내에서만, 예를 들어 어떤 경우에는 int32를 int64로
확장하는 것이다. Protobuf 자체의 호환성 표에 비추어 확인하지 않는 한 어떤 타입 변경도
breaking으로 취급하라. 언어의 타입 시스템과의 유추로 호환성을 가정하는 것이 잘못되는
방식이다.
Protobuf의 필드 폐기는 GraphQL의 @deprecated 지시어처럼 작동하는가?
비슷하다. Protobuf는 도구가 표시할 수 있는 필드 옵션 [deprecated = true]를 지원한다.
어느 쪽도 강제되지 않는다. GraphQL 서버는 폐기된 필드에 대한 쿼리에도 여전히 응답하고,
protobuf 클라이언트도 여전히 그것을 인코딩한다. 둘 다 권고적이며 같은 체인지로그 지원을 필요로 한다.
모든 클라이언트를 통제한다면 번호를 다시 매기는 것이 안전한가? 완전히 폐쇄된 시스템에서는 원칙적으로 그렇지만, 그것은 필드 번호가 존재하는 이유인 안전성이라는 속성 전체를 제거한다. “우리는 모든 클라이언트를 통제한다”는 빌드가 캐시되거나, 배포가 지연되거나, 아무도 기억하지 못한 클라이언트가 추가되는 순간 더 이상 참이 아니게 되는 주장이다. 사내에서도 번호를 재사용하는 대신 예약하라.
gRPC 서비스는 공개 REST API처럼 체인지로그 페이지가 필요한가?
.proto diff를 직접 읽지 않는 외부 팀이 그것을 소비할 때만, 이것은 내부 API
체인지로그가 일반적으로 적용하는 “반대편에 누가
있는가”라는 테스트와 같다. 같은 팀의 다른 서비스만 소비하는 gRPC 서비스는 공식
체인지로그 없이 커밋 히스토리에 의존하는 것으로 충분한 경우가 많다. 그것을 읽는 사람은
누구나 이미 스키마를 열어두고 있기 때문이다.
이 글의 기술적 내용은 독립적으로 검토되지 않았습니다. 잘못된 부분이 있으면 알려 주세요. 바로잡겠습니다.