Skip to content

Split View: Protobuf Edition 2026 — 조여지는 기본값 세 개와, protoc가 처음 강제하는 스키마 크기 한계

|

Protobuf Edition 2026 — 조여지는 기본값 세 개와, protoc가 처음 강제하는 스키마 크기 한계

들어가며 — 발표보다 나흘 먼저 나온 RC

2026년 7월 13일, protobuf.dev의 뉴스 페이지에 Edition 2026 발표가 올라왔습니다. 사흘 전 일입니다. 그런데 GitHub 릴리스 목록을 보면 v36.0-rc1은 그보다 나흘 앞선 7월 9일에 이미 published 상태였습니다. 발표문이 "36.x에서 2026년 3분기에 릴리스할 계획"이라고 쓰는 동안, 그 36.x의 첫 RC는 이미 나와 있었던 셈입니다.

이 글은 그 발표문을 요약하는 글이 아닙니다. 발표문은 짧고, 몇 군데는 실제 구현과 어긋나 있으며, 정작 가장 흥미로운 변화 하나를 통째로 빠뜨리고 있습니다. 그래서 v36.0-rc1 태그의 실제 소스 — descriptor.proto, descriptor.h, code_generator.h, command_line_interface.cc — 를 직접 읽고 확인한 내용을 정리합니다.

먼저 결론부터. Edition 2026은 문법을 하나도 추가하지 않습니다. 발표문의 마지막 줄이 그대로입니다 — "There is no new or removed grammar in Edition 2026." 대신 기본값 세 개를 조입니다. 그리고 그중 하나는 protoc가 15년 넘게 하지 않던 일입니다.

에디션이라는 장치를 한 문단으로

Protocol Buffers 자체바이너리 직렬화 포맷 비교는 이미 다뤘으니, 에디션만 짧게 짚습니다. 예전에는 파일 맨 위에 syntax = "proto2" 또는 syntax = "proto3"를 적었고, 그 한 글자가 필드 프레즌스·enum 개폐·기본 패킹 여부 등 서로 무관한 동작 여러 개를 한 덩어리로 결정했습니다. 에디션은 이 덩어리를 개별 기능(feature) 으로 분해하고, 파일 맨 위에는 edition = "2024" 같은 연도만 적게 합니다. 각 기능은 에디션마다 다른 기본값을 갖고, 필요하면 파일이나 특정 요소에서 개별적으로 덮어쓸 수 있습니다.

핵심은 이겁니다 — 에디션을 올리지 않으면 아무것도 바뀌지 않습니다. 기본값은 에디션에 묶여 있으므로, edition = "2023"으로 남아 있는 파일은 Edition 2026의 기본값 변경을 전혀 겪지 않습니다. 새 에디션은 "이제부터 이렇게 하는 게 낫다"를 기본값으로 선언하는 장치이지, 기존 파일을 깨는 장치가 아닙니다.

descriptor.protoEdition enum을 보면 릴리스된 에디션이 이렇게 나열돼 있습니다.

// Editions that have been released.  The specific values are arbitrary and
// should not be depended on, but they will always be time-ordered for easy
// comparison.
EDITION_2023 = 1000;
EDITION_2024 = 1001;
EDITION_2026 = 1002;

2025가 없습니다. 값은 1000, 1001, 1002로 연속이니 건너뛴 게 아니라 애초에 만들어지지 않았습니다. (주석이 "값 자체는 임의이며 의존하지 말 것, 다만 항상 시간순"이라고 못박고 있으니, 연도 사이 간격에 의미를 부여할 이유도 없습니다.)

protoc가 edition = "2026"을 받아주기 시작한 지점

"Edition 2026이 나왔다"가 정확히 어느 릴리스부터인지는 code_generator.h 한 줄로 확인됩니다.

// The maximum edition supported by protoc.
constexpr auto ProtocMaximumEdition() { return Edition::EDITION_2026; }

이 값을 릴리스 태그별로 훑어보면 경계가 선명합니다.

v32.0  (2025-08-14)  ProtocMaximumEdition() = EDITION_2024
v33.0  (2025-10-15)  ProtocMaximumEdition() = EDITION_2024
v34.0  (2026-02-25)  ProtocMaximumEdition() = EDITION_2024
v35.0  (2026-05-19)  ProtocMaximumEdition() = EDITION_2024
v35.1  (2026-06-11)  ProtocMaximumEdition() = EDITION_2024   <- 현재 stable
v36.0-rc1 (2026-07-09) ProtocMaximumEdition() = EDITION_2026  <- 여기서 처음

v36.0-rc1edition = "2026"을 파일 맨 위에 적을 수 있는 최초의 protoc입니다. 그 이전 protoc에 2026 파일을 물리면 최대 지원 에디션 초과로 거절당합니다. 그리고 오늘(2026-07-16) 기준으로 stable은 여전히 v35.1이고, v36.0은 GA가 아니라 RC입니다.

같은 방식으로 과거를 검증해 보면 트랙 레코드도 확인됩니다. Edition 2024 발표는 2025년 6월 27일에 "32.x에서 2025년 3분기에" 릴리스하겠다고 했고, v32.0은 2025년 8월 14일에 나왔으며 거기서 ProtocMaximumEdition()EDITION_2024로 올라갔습니다. 약속대로였습니다. 이번 발표문도 같은 형식으로 "36.x, 2026년 3분기"라고 말하고 있고, RC는 이미 나와 있습니다.

바뀌는 기본값 ①: enforce_naming_style이 STYLE2026으로

descriptor.proto의 기본값 테이블은 이렇습니다.

enum EnforceNamingStyle {
  ENFORCE_NAMING_STYLE_UNKNOWN = 0;
  STYLE2024 = 1;
  STYLE_LEGACY = 2;
  STYLE2026 = 3;
}
// edition_defaults:
//   EDITION_LEGACY -> STYLE_LEGACY
//   EDITION_2024   -> STYLE2024
//   EDITION_2026   -> STYLE2026

발표문은 규칙을 이렇게 설명합니다 — x라는 필드가 있는 메시지 안에서 has_x, set_x, get_x, clear_x, x_value는 금지되고, 필드 이름으로 descriptor를 쓸 수 없다.

그런데 실제 구현(descriptor.cc, 2026-04-07 커밋)을 읽으면 발표문보다 정교합니다. 금지 접두사는 has_, get_, set_, clear_ 네 개이고 금지 접미사는 _value 하나인데, 무조건 금지가 아니라 조건부입니다.

if (absl::StartsWith(name, prefix)) {
  absl::string_view without_prefix = name;
  without_prefix.remove_prefix(prefix.size());
  if ((message->FindFieldByName(without_prefix) != nullptr ||
       message->FindOneofByName(without_prefix) != nullptr)) {
    *error = absl::StrCat("should not begin with ", prefix,
                          " if a field named ", without_prefix,
                          " exists. This can cause collisions in "
                          "generated code.");
    return false;
  }
}

같은 메시지 안에 짝이 되는 x실제로 존재할 때만 has_x가 에러입니다. x가 없다면 has_x라는 이름은 그냥 통과합니다. 즉 이건 명명 규칙이라기보다 충돌 검사입니다 — 생성 코드에서 has_x() 접근자와 has_x 필드가 같은 심볼로 부딪히는 상황만 정확히 겨냥합니다. 그리고 짝을 찾을 때 필드뿐 아니라 oneof 이름까지 뒤지고(FindOneofByName), 검사 자체도 필드와 oneof 양쪽에 적용됩니다. 반면 descriptor는 조건 없이 금지입니다.

옵트아웃이 2단이라는 점도 발표문에는 없습니다. 소스에 에러 메시지 상수가 두 개 있습니다.

constexpr absl::string_view kNamingStyleOptOutMessage =
    " (features.enforce_naming_style = STYLE_LEGACY can be used to opt out of "
    "this check)";

constexpr absl::string_view kNamingStyleCollisionsOptOutMessage =
    " (features.enforce_naming_style = STYLE2024 can be used to opt out of "
    "this check)";

STYLE2024로 내리면 2026에서 새로 생긴 충돌 검사만 끄고 2024의 스타일 검사는 유지합니다. 전부 끄려면 STYLE_LEGACY로 내려야 합니다. 마이그레이션할 때 실제로 쓸 손잡이는 대개 앞쪽입니다.

마지막으로 사소하지만 재미있는 흠 하나. 위 enum에서 STYLE_LEGACY가 2이고 STYLE2024가 1이라 숫자 순서와 엄격함 순서가 어긋납니다. 그래서 "이 스타일 이상인가"를 판정하는 헬퍼에 특수 케이스가 박혀 있습니다.

bool IsStyleOrGreater(const DescriptorT* descriptor,
                      FeatureSet::EnforceNamingStyle style) {
  return internal::InternalFeatureHelper::GetFeatures(*descriptor)
                 .enforce_naming_style() >= style &&
         // Required because STYLE_LEGACY comes after STYLE2024 in enum
         // definition.
         internal::InternalFeatureHelper::GetFeatures(*descriptor)
                 .enforce_naming_style() != FeatureSet::STYLE_LEGACY;
}

enum 값 순서를 나중에 바꿀 수 없으니 코드로 우회한 자국입니다. 프로토콜 진화를 다루는 프로젝트가 자기 enum의 진화에 걸린 셈인데, 이런 게 남아 있는 게 오히려 정상입니다.

바뀌는 기본값 ②: default_symbol_visibility가 STRICT로

Edition 2024가 export / local 키워드와 default_symbol_visibility 기능을 도입했고, 그때 기본값은 EXPORT_TOP_LEVEL — 최상위 심볼은 export, 중첩 심볼은 local — 이었습니다. Edition 2026은 이걸 STRICT로 올립니다.

descriptor.proto의 주석이 값의 의미를 그대로 설명합니다.

// Default pre-EDITION_2024, all UNSET visibility are export.
EXPORT_ALL = 1;
// All top-level symbols default to export, nested default to local.
EXPORT_TOP_LEVEL = 2;
// All symbols default to local.
LOCAL_ALL = 3;
// All symbols local by default. Nested types cannot be exported.
// With special case caveat for message { enum {} reserved 1 to max; }
// This is the recommended setting for new protos.
STRICT = 4;

STRICT에서 달라지는 건 세 가지입니다 — 모든 심볼이 기본 local이고, export/local 키워드는 최상위 message/enum 선언에만 쓸 수 있으며, 중첩 심볼에 이 키워드를 붙이면 문법 에러입니다. LOCAL_ALL과의 차이가 여기 있습니다. LOCAL_ALL은 기본값만 local로 바꿀 뿐 중첩 타입을 export로 되살릴 수 있지만, STRICT는 그 탈출구 자체를 없앱니다.

다만 탈출구가 완전히 없지는 않고, C++ 네임스페이스 오염을 피하려고 쓰던 관용구 하나에 카브아웃이 있습니다. 최상위 message가 local이고 모든 필드가 reserved일 때에 한해 중첩 export enum이 허용됩니다.

local message MyNamespaceMessage {
  export enum Enum {
    MY_VAL = 1;
  }

  // Ensure no fields are added to the message.
  reserved 1 to max;
}

메시지를 순수한 네임스페이스 껍데기로만 쓰는 경우 — 필드를 하나도 담지 않겠다고 reserved 1 to max;로 못박은 경우 — 만 봐주겠다는 뜻입니다. 조건이 이렇게 빡빡한 건 이게 일반적인 우회로로 쓰이는 걸 막으려는 의도로 읽힙니다.

이 변경의 명분은 1-1-1 베스트 프랙티스입니다. .proto 파일 하나에 메시지 하나, 그리고 그 파일만 임포트하게 해서 의존성 비대화를 막자는 것. 이게 왜 크기 문제냐면, 심볼 가시성 문서가 설명하듯 protobuf는 디스크립터 데이터를 FileDescriptorSet/FileDescriptor 단위로 묶게 돼 있어서, 한 파일 안의 모든 정의와 그 파일이 임포트한 것들이 통째로 딸려오기 때문입니다.

바뀌는 기본값 ③: enforce_proto_limits — 여기가 진짜 변화입니다

그리고 이게 7월 13일 발표문에 한 글자도 없는 기능입니다. 발표문의 "Changes to Existing Features"에는 앞의 둘만 있고, "New Features"에는 JSON enum 값 커스텀 문자열, C++ namespace 옵션, C++ 커스텀 옵션 분리, C# nullable 참조 타입만 있습니다. 그런데 v36.0-rc1descriptor.proto에는 이게 들어 있습니다.

message ProtoLimitsFeature {
  enum EnforceProtoLimits {
    PROTO_LIMITS_UNKNOWN = 0;

    // Default pre-EDITION_2026: there are no limit enforcement at the protoc
    // level. Practical limits still exist, but they will tend to fail while
    // compiling protoc-generated code, and these limits tend to be language
    // or toolchain specific.
    LEGACY_NO_EXPLICIT_LIMITS = 1;

    // A set of limits enforced by Edition 2026 by default. For a detailed
    // list of all the limits please consult the Edition 2026 documentation.
    PROTO_LIMITS2026 = 2;
  }
}

저 주석의 두 번째 문장이 이 기능의 존재 이유 전부입니다. 한계는 원래부터 있었습니다. 다만 protoc는 그걸 몰랐고, 그래서 실패가 엉뚱한 데서 터졌습니다. descriptor.h의 주석이 더 직설적입니다.

// Enforce protobuf limits at the descriptor level. Pre Edition 2026, there
// is no limit enforcement in the protobuf compiler when parsing proto files.
// As a result, it is possible to write a protobuf file that compiles in
// protoc, and code generation works as intended, but the code generation
// output is uncompilable because it runs into language-specific or
// compiler-specific limits.
//
// Starting in Edition 2026, certain limits will start to be enforced. The
// goal of this enforcement is to help ensure that any file that is accepted
// by the protobuf compiler will be compilable on standard tool chains.
void EnforceProtoLimits(bool enforce) { enforce_proto_limits_ = enforce; }

"protoc에서는 컴파일되고 코드 생성도 의도대로 되는데, 생성된 코드가 컴파일이 안 되는" 상황. protobuf를 크게 굴려 본 사람은 이 문장이 무슨 뜻인지 압니다. enum에 값을 계속 추가하다가 어느 날 javac가 상수 관련 에러를 뱉고, 그게 내 .proto 때문이라는 걸 깨닫기까지 한참 걸리는 그 경험입니다.

지금까지 이 한계들이 어떻게 관리됐는지 보면 더 재미있습니다. protobuf.dev에는 Proto Limits라는 문서가 있는데, 그 문서가 스스로를 이렇게 소개합니다.

This information is a collection of discovered limitations by many engineers, but is not exhaustive and may be incorrect/outdated in some areas.

여러 엔지니어가 얻어맞으면서 발견한 것들을 모아 둔 문서이고, 완전하지도 않고 일부는 틀렸거나 낡았을 수 있다고 인정합니다. 실제로 그 안의 숫자들은 이런 식입니다 — 메시지당 필드는 65,535개가 상한이지만 Boolean 같은 단일 필드만 있는 메시지는 "~2100개(proto2)", "~3100개(proto3, optional 미사용)"에서 깨지고, enum은 "가장 낮은 한계가 Java에서 ~1700개"입니다. 물결표가 붙은 민속지식입니다.

Edition 2026은 이 민속지식을 protoc가 강제하는 숫자로 바꿉니다. descriptor.h의 상수가 이렇습니다.

// Edition 2026 introduces default limits for proto files as the descriptor gets
// built from protoc. To opt out of these limits for edition 2026, you may use
// features.enforce_proto_limits = LEGACY_NO_EXPLICIT_LIMITS.
inline constexpr int kLimit2026FieldsPerMessage = 1500;
inline constexpr int kLimit2026OneofsPerMessage = 1000;
inline constexpr int kLimit2026FieldsPerOneof = 1200;
inline constexpr int kLimit2026ValuesPerEnum = 1700;

네 숫자는 세 군데에서 일치합니다 — 위 헤더 상수, 이 기능을 넣은 커밋 메시지(2026-05-14), 그리고 Feature Settings for Editions 문서enforce_proto_limits 절. 셋 다 같은 값입니다.

                        Edition 2024 이하        Edition 2026
메시지당 필드            제한 없음(protoc 기준)    1500
메시지당 oneof           제한 없음                1000
oneof당 필드             제한 없음                1200
enum당 값                제한 없음                1700

여기서 눈에 띄는 건 enum의 1700입니다. Proto Limits 문서가 말한 "Java에서 ~1700"과 정확히 같은 숫자입니다. 필드의 1500도 문서의 "~2100(proto2)"보다 낮게 잡혀 있습니다. 즉 새 한계는 임의로 정한 라운드 넘버가 아니라, 실제로 깨지는 지점보다 안쪽에 그어진 선으로 보입니다. (다만 protobuf 측이 이 선정 근거를 공개 문서로 밝힌 건 제가 확인한 범위에는 없습니다. 숫자가 일치한다는 사실만 확인된 것이고, 인과는 추정입니다.)

그리고 실무에서 아플 지점 하나. 이 옵트아웃은 파일 단위로 못 겁니다. descriptor.proto에서 이 기능의 targets를 보면 ENUM, MESSAGE, FIELD, ONEOF만 있고 FILE이 없습니다.

optional ProtoLimitsFeature.EnforceProtoLimits enforce_proto_limits = 9 [
  retention = RETENTION_SOURCE,
  targets = TARGET_TYPE_ENUM,
  targets = TARGET_TYPE_MESSAGE,
  targets = TARGET_TYPE_FIELD,
  targets = TARGET_TYPE_ONEOF,
  feature_support = {
    edition_introduced: EDITION_2026
  },
  ...
];

Feature Settings 문서도 같은 말을 코드 주석으로 적어 뒀습니다 — "Must be set at the level of the oversized message, enum, or oneof as this feature is not allowed at the file level." 파일 맨 위에 한 줄 적어서 통째로 끄는 게 불가능하고, 비대한 메시지·enum·oneof마다 찾아가서 개별로 붙여야 합니다. 거대 레거시 스키마를 Edition 2026으로 올릴 때 이게 그대로 작업량이 됩니다.

또 하나. 위 feature_supportedition_introduced: EDITION_2026은 이 기능이 Edition 2026 이전에는 설정 자체가 불가능하다는 뜻입니다. 앞의 두 기능은 edition_introduced: EDITION_2024라서 Edition 2024 파일에서도 손댈 수 있지만, 크기 한계 검사는 2026으로 올라가야만 만날 수 있습니다. 미리 켜 보고 준비하는 길이 없습니다.

검사는 protoc가 켜고, 런타임은 끕니다

이 세 검사가 항상 도는 건 아닙니다. command_line_interface.cc를 보면 protoc CLI가 명시적으로 켭니다.

descriptor_pool->EnforceSymbolVisibility(true);
descriptor_pool->EnforceNamingStyle(true);
descriptor_pool->EnforceProtoLimits(true);

반면 DescriptorPool의 멤버 기본값은 꺼져 있습니다 — descriptor.henforce_proto_limits_ = false;, enforce_symbol_visibility_ = false;로 초기화돼 있습니다. 그러니까 런타임에 디스크립터 풀을 직접 만들어 쓰는 코드(동적 스키마 로딩 같은)는 이 검사를 자동으로 받지 않습니다. 이건 합리적입니다 — 이미 protoc를 통과해 존재하는 디스크립터를 런타임에 또 거부해 봐야 얻을 게 없으니까요.

그리고 세 기능 모두 retention = RETENTION_SOURCE입니다. 소스에서만 의미가 있고 생성된 디스크립터에는 실려 나가지 않습니다. 컴파일 타임 린트지 런타임 동작이 아닙니다.

여기서 반드시 짚어야 할 것 — 와이어 포맷은 아무것도 바뀌지 않습니다. 에디션은 인코딩을 건드린 적이 없고 Edition 2026도 마찬가지입니다. 필드 번호와 와이어 타입으로 굴러가는 바이트열은 그대로입니다. 이 글의 어떤 것도 이미 배포된 서비스 간의 호환성에 영향을 주지 않습니다.

새로 들어오는 것들 (짧게)

발표문이 "New Features"로 든 것들은 대체로 각 언어 바인딩 사정입니다.

  • enum 값의 JSON 커스텀 문자열. 기존 json_name이 키 이름을 바꿨다면, 이제 값도 바꿉니다 — FOO_BAR = 5 [(pb.enumvalue.json).string = "custom_string_here"];. 발표문이 든 이유는 충돌 회피, 마이그레이션 보조, 외부 요구사항 충족입니다.
  • C++ namespace 옵션. option (pb.file.cpp).namespace = "clock_time";처럼 생성 바인딩의 네임스페이스를 .proto 패키지와 독립적으로 지정합니다.
  • C++ 커스텀 옵션이 descriptor.proto 밖으로. C++ 전용 옵션이 별도 파일로 빠지고, import option "google/protobuf/cpp_features.proto";로 가져다 씁니다. 이 절에는 repeated_type = PROXYRepeatedFieldProxy 이야기도 섞여 있는데, 발표문 마크다운의 코드 펜스가 깨져 있어 어느 절에 속한 내용인지 원문만으로는 애매합니다.
  • C# nullable 참조 타입. opt-in이고, v36.0-rc1 릴리스 노트에도 "Advertise that C# supports Edition 2026, and move C# Nullable Reference Type support into that edition"으로 대응 항목이 있습니다.

정직한 트레이드오프와 한계

아직 GA가 아닙니다. 오늘 기준 stable은 v35.1이고 v36.0은 rc1입니다. 발표문 자신이 이렇게 못박습니다.

These describe changes as we anticipate them being implemented, but due to the flexible nature of software some of these changes may not land or may vary from how they are described in this topic.

"착륙하지 않을 수도 있고 설명과 다를 수도 있다." 이 글의 숫자와 동작은 전부 v36.0-rc1 태그 기준이고, GA에서 바뀔 수 있습니다.

발표문과 코드가 어긋납니다. enforce_proto_limits는 코드에 있고 문서(Feature Settings)에도 있는데 7월 13일 발표문에는 없습니다. 발표문만 읽고 Edition 2026을 판단하면 가장 파급력 큰 변화를 통째로 놓칩니다. 반대로 Feature Settings 문서의 enforce_proto_limits 예제는 "비대한 메시지를 유지하려면 오버라이드가 필요하다"고 써 놓고 정작 PROTO_LIMITS2026을 설정하는(끄는 게 아니라 켜는) 코드를 보여 줍니다. 문서가 갓 쓰여서 거친 상태입니다. 규범적 사실은 descriptor.prototargets/edition_defaults에서 확인하는 게 안전합니다.

언어 지원은 따로 붙습니다. 여기 나온 건 대부분 protoc와 C++ 런타임 이야기입니다. 각 언어 런타임이 Edition 2026을 지원한다고 광고해야 실제로 쓸 수 있고(C# 항목이 릴리스 노트에 따로 있는 이유가 그것), 서드파티 구현이나 protoc를 쓰지 않는 툴체인은 별도 문제입니다.

이득이 눈에 보이는 종류가 아닙니다. 이 세 기능 중 무엇도 메시지를 작게 만들거나 파싱을 빠르게 하지 않습니다. 전부 컴파일 타임 린트입니다. 얻는 건 "나중에 이상한 데서 터질 일이 지금 명확한 에러로 터진다"이고, 그게 값이 나가는 팀이 있고 아닌 팀이 있습니다.

그래서, 지금 뭘 해야 하나

당장 할 게 없는 경우가 대부분입니다. syntax = "proto2" / syntax = "proto3"를 쓰고 있다면 Edition 2026은 오늘 당신과 아무 상관이 없습니다. descriptor.proto의 기본값 테이블상 proto2/proto3/Edition 2023은 세 기능 모두 레거시 값(STYLE_LEGACY, EXPORT_ALL, LEGACY_NO_EXPLICIT_LIMITS)이고, 에디션을 올리지 않는 한 그대로입니다. 서두를 이유가 없습니다.

Edition 2024를 이미 쓰고 있고 2026을 볼 생각이라면, 순서는 이렇게 잡는 게 맞습니다. 이름 충돌 검사는 STYLE2024로, 가시성은 명시적 export/local 표기로 미리 정리할 수 있습니다. 반면 크기 한계는 앞서 본 대로 2026 이전에 켜 볼 방법이 없으니, 스키마에 1500 필드를 넘는 메시지나 1700 값을 넘는 enum이 있는지는 그냥 세어 보는 수밖에 없습니다. 있다면 그게 마이그레이션 비용의 대부분이 될 겁니다 — 파일 단위로 못 끄니까요.

Edition 2026으로 올릴 이유가 없는데 검사만 갖고 싶다면 대체로 방법이 없거나, 있어도 부분적입니다. 이건 에디션 설계상 의도된 결과입니다 — 기본값의 묶음을 연도로 파는 게 에디션이니까요.

반대로 값을 하는 경우는 명확합니다. 스키마가 크고, 여러 언어로 생성하고, "왜 Java 빌드만 깨지지" 같은 일을 겪어 본 조직. 그런 곳에서 protoc가 스키마를 받아준 순간 표준 툴체인에서 컴파일된다는 보장은 꽤 값이 나갑니다. descriptor.h 주석이 내건 목표가 정확히 그것입니다 — "any file that is accepted by the protobuf compiler will be compilable on standard tool chains."

마치며

Edition 2026은 화려한 릴리스가 아닙니다. 문법 추가가 0이고, 와이어 포맷 변경도 0이며, 성능 개선도 0입니다. 대신 기본값 세 개를 조입니다 — 이름 충돌을 금지하고, 심볼을 기본 local로 만들고, 스키마 크기에 처음으로 선을 긋습니다.

그리고 그게 에디션이라는 장치가 원래 하려던 일입니다. proto2/proto3 시절에는 "이 기본값은 틀렸다"는 걸 알아도 바꿀 방법이 없었습니다. 바꾸는 순간 전 세계의 .proto가 깨지니까요. 에디션은 기본값을 연도에 묶어서, 옛 파일은 옛 기본값으로 두고 새 파일부터 나은 기본값을 주는 길을 냈습니다. Edition 2026은 그 기계가 실제로 도는 첫 사례에 가깝습니다 — 한 번에 세 개를, 각각 옵트아웃을 달아서, 기존 파일은 건드리지 않고.

가장 마음에 드는 건 크기 한계 쪽입니다. "여러 엔지니어가 발견한, 완전하지 않고 틀렸을 수도 있는" 위키 문서에 물결표로 적혀 있던 숫자가, 컴파일러가 강제하는 상수 네 개가 됐습니다. 민속지식이 사양이 되는 순간은 대체로 조용하고, 대체로 이렇게 생겼습니다.

다만 오늘 기준으로 이건 아직 RC이고, 발표문은 자기 기능 하나를 빠뜨렸으며, 문서 예제에는 버그가 있습니다. 확인은 descriptor.proto에서 하십시오. 발표문이 아니라.

참고 자료

Protobuf Edition 2026: Three Tightened Defaults, and the Schema Size Limits protoc Finally Enforces

Introduction — An RC That Shipped Four Days Before the Announcement

On July 13, 2026, the Edition 2026 announcement went up on protobuf.dev's news page. That was three days ago. But looking at the GitHub release list, v36.0-rc1 had already been published four days before that — on July 9. While the announcement was saying it plans to release "in 36.x, in Q3 2026," the first RC of that very 36.x was already out.

This post is not a summary of that announcement. The announcement is short, some of it is out of step with the actual implementation, and it leaves out entirely what is arguably the single most interesting change. So this post is what I found reading the actual source of the v36.0-rc1 tag directly — descriptor.proto, descriptor.h, code_generator.h, command_line_interface.cc.

The conclusion first. Edition 2026 adds no new syntax at all. The announcement's last line stands as written — "There is no new or removed grammar in Edition 2026." Instead, it tightens three defaults. And one of them is something protoc has not done in more than fifteen years.

Editions, in One Paragraph

Protocol Buffers itself and a comparison of binary serialization formats have already been covered, so here is just a quick note on Editions. In the old scheme you wrote syntax = "proto2" or syntax = "proto3" at the top of the file, and that single token decided a bundle of otherwise-unrelated behaviors at once — field presence, whether enums are open or closed, default packing, and more. Editions break that bundle apart into individual features, and the top of the file now just carries a year, like edition = "2024". Each feature has a different default per edition, and can be overridden individually at the file level or on a specific element if needed.

The key point is this — nothing changes unless you bump the edition. Defaults are tied to the edition, so a file that stays at edition = "2023" never experiences Edition 2026's default changes at all. A new edition is a device for declaring "this is the better default from now on" — not a device for breaking existing files.

Looking at the Edition enum in descriptor.proto, the released editions are listed like this.

// Editions that have been released.  The specific values are arbitrary and
// should not be depended on, but they will always be time-ordered for easy
// comparison.
EDITION_2023 = 1000;
EDITION_2024 = 1001;
EDITION_2026 = 1002;

There is no 2025. The values are consecutive — 1000, 1001, 1002 — so nothing was skipped; a 2025 edition was simply never made. (The comment itself insists the values are arbitrary and not to be depended on, only time-ordered, so there is no reason to read meaning into the gap between years either.)

Where protoc Started Accepting edition = "2026"

Exactly which release "Edition 2026 shipped" from is confirmed by a single line in code_generator.h.

// The maximum edition supported by protoc.
constexpr auto ProtocMaximumEdition() { return Edition::EDITION_2026; }

Tracking this value across release tags makes the boundary clear.

v32.0  (2025-08-14)  ProtocMaximumEdition() = EDITION_2024
v33.0  (2025-10-15)  ProtocMaximumEdition() = EDITION_2024
v34.0  (2026-02-25)  ProtocMaximumEdition() = EDITION_2024
v35.0  (2026-05-19)  ProtocMaximumEdition() = EDITION_2024
v35.1  (2026-06-11)  ProtocMaximumEdition() = EDITION_2024   <- current stable
v36.0-rc1 (2026-07-09) ProtocMaximumEdition() = EDITION_2026  <- first here

In other words, v36.0-rc1 is the first protoc that can accept edition = "2026" at the top of a file. Feed a 2026 file to any earlier protoc and it is rejected for exceeding the maximum supported edition. And as of today (2026-07-16), stable is still v35.1, and v36.0 is an RC, not GA.

Checking the past the same way confirms the track record too. The Edition 2024 announcement said on June 27, 2025 that it would ship "in 32.x, in Q3 2025," and v32.0 came out on August 14, 2025, with ProtocMaximumEdition() bumped to EDITION_2024 right there. As promised. This announcement uses the same format — "36.x, Q3 2026" — and the RC is already out.

Default Change 1: enforce_naming_style Moves to STYLE2026

The default table in descriptor.proto looks like this.

enum EnforceNamingStyle {
  ENFORCE_NAMING_STYLE_UNKNOWN = 0;
  STYLE2024 = 1;
  STYLE_LEGACY = 2;
  STYLE2026 = 3;
}
// edition_defaults:
//   EDITION_LEGACY -> STYLE_LEGACY
//   EDITION_2024   -> STYLE2024
//   EDITION_2026   -> STYLE2026

The announcement describes the rule like this — inside a message that has a field x, has_x, set_x, get_x, clear_x, and x_value are banned, and a field cannot be named descriptor.

But reading the actual implementation (descriptor.cc, commit dated 2026-04-07), it turns out to be more precise than the announcement. The banned prefixes are the four has_, get_, set_, clear_, and the banned suffix is a single _value — but it is not an unconditional ban, it is conditional.

if (absl::StartsWith(name, prefix)) {
  absl::string_view without_prefix = name;
  without_prefix.remove_prefix(prefix.size());
  if ((message->FindFieldByName(without_prefix) != nullptr ||
       message->FindOneofByName(without_prefix) != nullptr)) {
    *error = absl::StrCat("should not begin with ", prefix,
                          " if a field named ", without_prefix,
                          " exists. This can cause collisions in "
                          "generated code.");
    return false;
  }
}

has_x is only an error when a matching x actually exists in the same message. If there is no x, a field literally named has_x passes fine. So this is less a naming convention than a collision check — it targets precisely the situation where the generated has_x() accessor and a has_x field would collide on the same symbol in generated code. And when looking for the match, it searches not just fields but oneof names too (FindOneofByName), and the check itself applies to both fields and oneofs. descriptor, by contrast, is banned unconditionally.

The two-tier opt-out is also missing from the announcement. There are two error-message constants in the source.

constexpr absl::string_view kNamingStyleOptOutMessage =
    " (features.enforce_naming_style = STYLE_LEGACY can be used to opt out of "
    "this check)";

constexpr absl::string_view kNamingStyleCollisionsOptOutMessage =
    " (features.enforce_naming_style = STYLE2024 can be used to opt out of "
    "this check)";

Dropping to STYLE2024 turns off only the collision check that is new in 2026, while keeping the 2024 style check. To turn everything off, you have to drop to STYLE_LEGACY. In practice, the lever you actually reach for during migration is usually the former.

One last, minor but amusing wart. In the enum above, STYLE_LEGACY is 2 and STYLE2024 is 1, so numeric order and strictness order disagree. That is why the helper that decides "is this style at least this strict" has a special case baked in.

bool IsStyleOrGreater(const DescriptorT* descriptor,
                      FeatureSet::EnforceNamingStyle style) {
  return internal::InternalFeatureHelper::GetFeatures(*descriptor)
                 .enforce_naming_style() >= style &&
         // Required because STYLE_LEGACY comes after STYLE2024 in enum
         // definition.
         internal::InternalFeatureHelper::GetFeatures(*descriptor)
                 .enforce_naming_style() != FeatureSet::STYLE_LEGACY;
}

Enum value ordering cannot be changed after the fact, so this is a scar left by working around it in code. A project all about managing protocol evolution getting tripped up by the evolution of its own enum — and honestly, it is normal for a trace like this to survive.

Default Change 2: default_symbol_visibility Moves to STRICT

Edition 2024 introduced the export / local keywords and the default_symbol_visibility feature, with a default of EXPORT_TOP_LEVEL — top-level symbols export, nested symbols are local. Edition 2026 bumps this to STRICT.

The comment in descriptor.proto spells out exactly what each value means.

// Default pre-EDITION_2024, all UNSET visibility are export.
EXPORT_ALL = 1;
// All top-level symbols default to export, nested default to local.
EXPORT_TOP_LEVEL = 2;
// All symbols default to local.
LOCAL_ALL = 3;
// All symbols local by default. Nested types cannot be exported.
// With special case caveat for message { enum {} reserved 1 to max; }
// This is the recommended setting for new protos.
STRICT = 4;

Three things change under STRICT — every symbol defaults to local, the export/local keywords can only be used on top-level message/enum declarations, and attaching either keyword to a nested symbol is a syntax error. That is where it differs from LOCAL_ALL. LOCAL_ALL only flips the default to local but still lets you resurrect a nested type as export; STRICT removes that escape hatch entirely.

The escape hatch is not entirely gone, though — there is one carve-out for an idiom people used to avoid polluting the C++ namespace. When a top-level message is local and every field is reserved, a nested export enum is permitted.

local message MyNamespaceMessage {
  export enum Enum {
    MY_VAL = 1;
  }

  // Ensure no fields are added to the message.
  reserved 1 to max;
}

The intent is to allow this only when the message is used purely as a namespace shell — one that has pinned down reserved 1 to max; to guarantee it will never hold a single field. The condition being this tight reads as a deliberate attempt to keep this from becoming a general-purpose workaround.

The stated rationale for this change is the 1-1-1 best practice — one message per .proto file, so that importing that file only pulls in that one message, keeping dependency bloat down. Why this is a size problem in the first place: as the symbol visibility docs explain, protobuf bundles descriptor data at the granularity of a FileDescriptorSet/FileDescriptor, so every definition in a file — plus everything that file imports — comes along as one bundle.

Default Change 3: enforce_proto_limits — This Is the Real Change

And this is the feature that is not mentioned even once in the July 13 announcement. The announcement's "Changes to Existing Features" section covers only the two above, and its "New Features" section lists only a JSON custom string for enum values, a C++ namespace option, splitting out C++ custom options, and C# nullable reference types. Yet descriptor.proto in v36.0-rc1 has this.

message ProtoLimitsFeature {
  enum EnforceProtoLimits {
    PROTO_LIMITS_UNKNOWN = 0;

    // Default pre-EDITION_2026: there are no limit enforcement at the protoc
    // level. Practical limits still exist, but they will tend to fail while
    // compiling protoc-generated code, and these limits tend to be language
    // or toolchain specific.
    LEGACY_NO_EXPLICIT_LIMITS = 1;

    // A set of limits enforced by Edition 2026 by default. For a detailed
    // list of all the limits please consult the Edition 2026 documentation.
    PROTO_LIMITS2026 = 2;
  }
}

The second sentence of that comment is the entire reason this feature exists. The limits were always there. protoc just did not know about them, so failures surfaced in the wrong place. The comment in descriptor.h is more blunt about it.

// Enforce protobuf limits at the descriptor level. Pre Edition 2026, there
// is no limit enforcement in the protobuf compiler when parsing proto files.
// As a result, it is possible to write a protobuf file that compiles in
// protoc, and code generation works as intended, but the code generation
// output is uncompilable because it runs into language-specific or
// compiler-specific limits.
//
// Starting in Edition 2026, certain limits will start to be enforced. The
// goal of this enforcement is to help ensure that any file that is accepted
// by the protobuf compiler will be compilable on standard tool chains.
void EnforceProtoLimits(bool enforce) { enforce_proto_limits_ = enforce; }

The situation where "it compiles in protoc, code generation runs as intended, but the generated code itself will not compile." Anyone who has run protobuf at any real scale knows exactly what this sentence means — you keep adding values to an enum, and one day javac starts throwing constant-related errors, and it takes a while before you realize your .proto file is the cause.

It gets even more interesting when you look at how these limits have been managed up to now. protobuf.dev has a document called Proto Limits, and that document introduces itself like this.

This information is a collection of discovered limitations by many engineers, but is not exhaustive and may be incorrect/outdated in some areas.

In other words, it is a collection of things a lot of engineers discovered the hard way, and it admits up front that it is neither exhaustive nor guaranteed accurate. The actual numbers inside read like this — the hard cap on fields per message is 65,535, but a message that is all single fields (like Booleans) breaks around "~2100 (proto2)" or "~3100 (proto3, without optional)," and for enums "the lowest limit is ~1700 in Java." Folklore with tildes attached.

Edition 2026 turns this folklore into numbers protoc enforces. The constants in descriptor.h are these.

// Edition 2026 introduces default limits for proto files as the descriptor gets
// built from protoc. To opt out of these limits for edition 2026, you may use
// features.enforce_proto_limits = LEGACY_NO_EXPLICIT_LIMITS.
inline constexpr int kLimit2026FieldsPerMessage = 1500;
inline constexpr int kLimit2026OneofsPerMessage = 1000;
inline constexpr int kLimit2026FieldsPerOneof = 1200;
inline constexpr int kLimit2026ValuesPerEnum = 1700;

The four numbers agree across three places — the header constants above, the commit message that introduced this feature (dated 2026-05-14), and the enforce_proto_limits section of the Feature Settings for Editions doc. All three give the same values.

                        Edition 2024 and below      Edition 2026
Fields per message      No limit (at protoc level)  1500
Oneofs per message      No limit                    1000
Fields per oneof        No limit                    1200
Values per enum         No limit                    1700

What stands out here is the enum's 1700. It is exactly the number the Proto Limits doc gave as "~1700 in Java." The field limit of 1500 is also set lower than the doc's "~2100 (proto2)." So the new limits do not look like arbitrary round numbers — they look like lines drawn inside where things actually break. (That said, I have not found any public document from the protobuf team stating the rationale behind these exact figures. All I have confirmed is that the numbers line up; the causal link is my inference.)

And here is a spot that will sting in practice. This opt-out cannot be set at the file level. Looking at this feature's targets in descriptor.proto, there is ENUM, MESSAGE, FIELD, ONEOF — and no FILE.

optional ProtoLimitsFeature.EnforceProtoLimits enforce_proto_limits = 9 [
  retention = RETENTION_SOURCE,
  targets = TARGET_TYPE_ENUM,
  targets = TARGET_TYPE_MESSAGE,
  targets = TARGET_TYPE_FIELD,
  targets = TARGET_TYPE_ONEOF,
  feature_support = {
    edition_introduced: EDITION_2026
  },
  ...
];

The Feature Settings doc says the same thing in a code comment — "Must be set at the level of the oversized message, enum, or oneof as this feature is not allowed at the file level." You cannot write one line at the top of the file to switch it off wholesale; you have to go find every oversized message, enum, and oneof and attach the override individually. When you move a large legacy schema to Edition 2026, this is exactly the work that shows up on the ticket.

One more thing. That edition_introduced: EDITION_2026 in feature_support means this feature cannot be configured at all before Edition 2026. The two previous features carry edition_introduced: EDITION_2024, so you can touch them even on an Edition 2024 file — but the size-limit check is only reachable once you move to 2026. There is no way to flip it on ahead of time to prepare.

protoc Turns the Checks On; the Runtime Turns Them Off

These three checks do not run all the time. Looking at command_line_interface.cc, the protoc CLI turns them on explicitly.

descriptor_pool->EnforceSymbolVisibility(true);
descriptor_pool->EnforceNamingStyle(true);
descriptor_pool->EnforceProtoLimits(true);

DescriptorPool's member defaults, on the other hand, are off — descriptor.h initializes them as enforce_proto_limits_ = false; and enforce_symbol_visibility_ = false;. So code that builds a descriptor pool directly at runtime (dynamic schema loading, for example) does not automatically get these checks. That is reasonable — there is nothing to gain from rejecting, at runtime, a descriptor that already made it through protoc once.

And all three features are retention = RETENTION_SOURCE. They only carry meaning in the source and are not carried into the generated descriptor. This is a compile-time lint, not runtime behavior.

One thing worth stressing here — nothing about the wire format changes. Editions have never touched encoding, and Edition 2026 is no exception. The byte stream, driven by field numbers and wire types, stays exactly as it was. Nothing in this post affects compatibility between services that are already deployed.

What's New (Briefly)

What the announcement lists under "New Features" is mostly a matter of individual language bindings.

  • JSON custom strings for enum values. Where the existing json_name renamed the key, this now renames the value too — FOO_BAR = 5 [(pb.enumvalue.json).string = "custom_string_here"];. The announcement cites collision avoidance, migration support, and satisfying external requirements as the reasons.
  • The C++ namespace option. option (pb.file.cpp).namespace = "clock_time"; lets you set the generated binding's namespace independently of the .proto package.
  • C++ custom options moving out of descriptor.proto. C++-only options move into a separate file, pulled in via import option "google/protobuf/cpp_features.proto";. This section also mixes in mentions of repeated_type = PROXY and RepeatedFieldProxy, but the announcement's markdown has a broken code fence here, so which section that content actually belongs to is ambiguous from the source text alone.
  • C# nullable reference types. Opt-in, and there is a matching entry in the v36.0-rc1 release notes too — "Advertise that C# supports Edition 2026, and move C# Nullable Reference Type support into that edition."

Honest Tradeoffs and Limitations

This is not GA yet. As of today, stable is v35.1 and v36.0 is rc1. The announcement itself says as much.

These describe changes as we anticipate them being implemented, but due to the flexible nature of software some of these changes may not land or may vary from how they are described in this topic.

"May not land, may differ from what is described." Every number and every behavior in this post is as of the v36.0-rc1 tag, and any of it can change by GA.

The announcement and the code disagree. enforce_proto_limits is in the code, and it is in the docs (Feature Settings), but it is not in the July 13 announcement. Judge Edition 2026 by the announcement alone and you miss its single highest-impact change entirely. And the other way around, the Feature Settings doc's own example for enforce_proto_limits writes that "an override is required to keep an oversized message" and then shows code that sets PROTO_LIMITS2026 (turning it on, not off). The docs are freshly written and still rough. The normative facts are safer to confirm directly in descriptor.proto's targets/edition_defaults.

Language support ships separately. Most of what is covered here concerns protoc and the C++ runtime. Each language runtime has to advertise Edition 2026 support before you can actually use it (which is exactly why the C# item gets its own release-note entry), and third-party implementations or toolchains that do not go through protoc are a separate question entirely.

The payoff is not the visible kind. None of these three features makes messages smaller or parsing faster. All three are compile-time lint. What you get is "the thing that would have broken somewhere weird later now breaks as a clear error now" — and whether that is worth something to your team depends entirely on the team.

So, What Should You Actually Do Now

For most people, there is nothing to do right now. If you are on syntax = "proto2" / syntax = "proto3", Edition 2026 has nothing to do with you today. In descriptor.proto's default table, proto2/proto3/Edition 2023 all sit at the legacy value for all three features (STYLE_LEGACY, EXPORT_ALL, LEGACY_NO_EXPLICIT_LIMITS), and stay there unless you bump the edition. No reason to rush.

If you are already on Edition 2024 and thinking about looking at 2026, this is the right order to work in. The naming-collision check can be cleared out ahead of time with STYLE2024, and visibility can be prepared with explicit export/local annotations. The size limits, though, as covered above, have no way to be turned on before 2026 — so the only option is to just go count whether your schema has any message over 1500 fields or any enum over 1700 values. If it does, that will be most of your migration cost — because you cannot flip it off at the file level.

If you have no reason to move to Edition 2026 but just want the checks, there is mostly no way, or only a partial one. This is the intended outcome of how editions are designed — an edition is exactly a bundle of defaults sold by year.

Where it is worth it, on the other hand, is clear. Organizations with large schemas, generating for multiple languages, who have lived through "why does only the Java build break." For teams like that, having protoc's acceptance of a schema actually guarantee it compiles on the standard toolchain is worth quite a lot. That is precisely the goal the descriptor.h comment states — "any file that is accepted by the protobuf compiler will be compilable on standard tool chains."

Closing

Edition 2026 is not a flashy release. Zero new syntax, zero wire-format changes, zero performance improvements. Instead it tightens three defaults — banning name collisions, defaulting symbols to local, and drawing a line on schema size for the first time.

And that is exactly what the Editions mechanism was built to do. In the proto2/proto3 era, even knowing "this default is wrong" gave you no way to fix it — changing it would break .proto files across the entire world the moment you did. Editions tie defaults to a year, leaving old files on old defaults while letting new files start with better ones. Edition 2026 looks close to the first real case of that machine actually turning — three at once, each with its own opt-out, without touching a single existing file.

The part I like best is the size limits. A number that used to sit in a wiki-style doc with a tilde in front of it — "discovered by many engineers, not exhaustive, may be wrong" — has become four constants the compiler enforces. The moment folklore turns into a spec is usually quiet, and usually looks exactly like this.

That said, as of today this is still an RC, the announcement leaves out one of its own features, and the docs' example has a bug in it. Check descriptor.proto for the truth. Not the announcement.

References