Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-schema-protobuf.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

35 KiB
Raw Blame History

messaging-schema-protobuf 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-schema-protobuf SSOT owner: messaging-schema-protobuf integration/family document: analysis/19-messaging-platform.md (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-schema-protobuf
  • canonical state analysisFile: analysis/messaging/messaging-schema-protobuf.md
  • source path: src/messaging/messaging-schema-protobuf
  • registry allowed_dependencies: ["messaging-core-api", "messaging-schema-api"]
  • registry runtime_memberships: [] — build-only / incubating

숫자

항목
production Java 파일 2
production LOC 199
패키지 1 (dev.caskeleton.messaging.schema.protobuf)
test 파일 1
test 메서드(실행 확인) 12
test 리소스 src/test/proto/order_created_v1.proto (컴파일되지 않음)
외부 의존성 1 (com.google.protobuf:protobuf-java:4.29.3, api)

두 타입: ProtobufMessageCodec(codec), ProtobufMessageContract(record — 클래스와 parser의 검증된 짝).

Coverage ledger

scope/file group count disposition reason
.../protobuf/ProtobufMessageCodec.java 1 FULL_READ 146줄 전문
.../protobuf/ProtobufMessageContract.java 1 FULL_READ 53줄 전문
src/test/java/** 1 FULL_READ 255줄 전문
src/test/proto/order_created_v1.proto 1 FULL_READ 23줄 전문. 어느 빌드도 컴파일하지 않음(§12.4)
build.gradle 1 FULL_READ 주석 포함 11줄
gradle.lockfile 1 FULL_READ protobuf 좌표 2건 확인
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

선택적 Protobuf codec. runtime_memberships: []이고 starter의 codec registry에도 등록되지 않는다 — build-only / incubating.

protobuf를 api로 선언한 이유가 build.gradle 주석에 있다.

// api: ProtobufMessageContract is a public record over com.google.protobuf.Message and
// Parser, and registering a contract is the first thing a consumer of this codec does.
api 'com.google.protobuf:protobuf-java:4.29.3'

src/messaging/CLAUDE.md:40-43의 vendor api 게이트를 통과한다 — ProtobufMessageContract(Class<? extends Message>, Parser<? extends Message>)가 public record이므로 소비자가 그 타입을 이름 부르지 않고는 계약을 등록할 수 없다.

이 leaf의 핵심 문제 인식은 클래스 javadoc이 한 문장으로 적는다.

// ProtobufMessageCodec.java:25-27
 * <p>Bound to a closed registry of generated parsers. Protobuf's own wire format will happily
 * decode almost any bytes into almost any message, so without the registry a type confusion is
 * silent  the consumer gets a populated object built from the wrong schema rather than an error.

messaging-schema-avro의 "does not fail — it produces plausible garbage"와 같은 성질이다. JSON은 틀린 스키마로 디코딩하면 대개 실패하고, Avro와 Protobuf는 실패하지 않는다. 그래서 두 leaf 모두 registry를 계약의 중심에 둔다.


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api), messaging-schema-api(api), protobuf-java:4.29.3(api).

나가는 것: 없다. 어떤 leaf의 allowed_dependencies에도 없고 starter 목록에도 없다.

런타임 배선: 없음. bean 없음(Spring 주석 0개).

lockfile이 확인하는 실제 해석:

com.google.protobuf:protobuf-java:4.29.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor

컴파일/런타임은 4.29.3, annotation processor 경로만 4.33.2다. §12.4에서 저장소 전체의 protobuf 버전 지형을 다룬다.


3. 패키지/컴포넌트 지도

ProtobufMessageContract  (record)
├── payloadType : Class<? extends Message>
├── parser      : Parser<? extends Message>
└── compact 생성자가 빈 입력을 파싱해 짝을 증명

ProtobufMessageCodec  (MessageCodec 구현)
├── encode(type, version, Message)   → requireFits + writeTo(sink)
├── decode(type, version, byte[], Class) → parser.parseFrom
├── requireRegistered(type, version) → 2단 에러
└── registeredVersions(type)         → 에러 메시지용 정렬 목록

4. 계약·불변식·상태 모델

4.1 ProtobufMessageContract: 생성 시점에 짝을 증명한다

이 leaf에서 가장 밀도 높은 결정이다.

// ProtobufMessageContract.java:10-20
 * <p>They used to live in two parallel maps. Nothing checked that the two agreed, so a registry
 * that paired {@code OrderCreated.class} with {@code OrderCancelled}'s parser was accepted at
 * construction and produced a {@code ClassCastException} at decode time  on a broker thread, for
 * one message type, in production. Worse, a type present in one map and absent from the other made
 * {@code parsers.get(type)} return null and the decode fail with a {@code NullPointerException}
 * rather than the registry error the operator needed to read.
 *
 * <p>Binding them in one value makes the mismatch impossible to express, and the constructor proves
 * the pairing by parsing empty input: the parser's default instance must be an instance of the
 * declared class.

증명 방법이 영리하다.

public ProtobufMessageContract {
  Message defaultInstance;
  try {
    defaultInstance = parser.parseFrom(new byte[0]);
  } catch (Exception failure) {
    throw new MessagingConfigurationException("PROTOBUF_CONTRACT_UNUSABLE", ..., failure);
  }
  if (!payloadType.isInstance(defaultInstance)) {
    throw new MessagingConfigurationException("PROTOBUF_CONTRACT_MISMATCH", ...);
  }
}

proto3에서 모든 필드가 wire상 optional이므로 빈 바이트는 항상 유효한 메시지다. 그것을 파싱하면 default instance가 나오고 그 클래스가 곧 parser의 산출 타입이다. 별도 리플렉션 없이 짝을 확인한다.

에러 메시지가 실패 지점을 명시한다 — "a mismatched pairing fails at decode time on a broker thread, not here". 즉 여기서 실패하는 것이 목적임을 메시지가 스스로 말한다.

두 코드가 다르다: PROTOBUF_CONTRACT_UNUSABLE(파싱 자체 실패)과 PROTOBUF_CONTRACT_MISMATCH(파싱은 되는데 타입이 다름). 카테고리는 둘 다 CONFIGURATION이다.

테스트가 이 성질을 붙든다 — aParserThatDoesNotProduceTheDeclaredClassIsRejectedAtConstruction, as("the mismatch used to surface as a ClassCastException on a broker thread").

4.2 인코딩: 크기를 미리 알 수 있다

BoundedByteSink sink = BoundedByteSink.of(maxBytes, "PAYLOAD_TOO_LARGE");
sink.requireFits(message.getSerializedSize());
try {
  message.writeTo(sink);
}

주석이 이유를 적는다.

// :77-79
// Protobuf knows its serialized size exactly before writing a byte, so the limit is checked
// against that estimate first and enforced again by the sink. `toByteArray` allocated the whole
// encoding before anything could object.

세 codec 중 유일하게 사전 거절이 가능한 포맷이다. BoundedByteSink.requireFits가 이 leaf를 위해 존재하고, schema-api의 javadoc이 그것을 명시한다 — "Protobuf knows its serialized size exactly, so the whole encode can be refused before the first byte is written."

그리고 사전 검사가 사후 경계를 대체하지 않는다 — writeTo(sink)가 여전히 sink를 통과하므로 이중 방어다. schema-api javadoc: "this is a cheaper refusal, not a replacement for the bound."

4.3 인코딩 타입 검사: 이중 조건

if (!(payload instanceof Message message) || !contract.payloadType().isInstance(payload)) {
  throw new MessageValidationException("PAYLOAD_TYPE_MISMATCH", ...);
}

Message인지와 등록된 클래스의 인스턴스인지를 함께 본다. 후자만으로 충분해 보이지만 전자가 writeTo를 부를 수 있음을 보장한다.

4.4 디코딩: 정확 일치와 상한

if (!contract.payloadType().equals(payloadType)) { throw ... PAYLOAD_TYPE_MISMATCH ... }
if (encoded.length > maxBytes) { throw ... PAYLOAD_TOO_LARGE ... }
return payloadType.cast(contract.parser().parseFrom(encoded));

JSON codec과 같은 비대칭이다 — encode는 isInstance(하위 타입 허용), decode는 equals(정확 일치).

4.5 requireRegistered: 2단 에러, JSON과 같은 어휘

// :123-127
if (typeIsKnown) {
  // Protobuf will happily decode almost any bytes with almost any parser, so falling back to
  // another version's parser does not fail — it returns a populated object built from a schema
  // nobody registered for this version.
  throw new MessageValidationException("SCHEMA_VERSION_NOT_REGISTERED", ...);
}
throw new MessageValidationException("UNKNOWN_MESSAGE_TYPE", ...);

코드 문자열이 JacksonMessageCodec과 동일하다(SCHEMA_VERSION_NOT_REGISTERED, UNKNOWN_MESSAGE_TYPE). AvroMessageCodecAVRO_ 접두사를 붙여 어휘가 갈라진다 — analysis/messaging/messaging-schema-avro.md §12.3(d)가 소유한다.

에러 메시지에 registeredVersions(type)가 정렬되어 포함되는 것도 JSON과 같다.

4.6 unknown field 보존

// :29-31
 * <p>Unknown fields are preserved by the generated types, which is what makes forward compatibility
 * work: an old consumer round-tripping a message written by a newer producer does not silently drop
 * the fields it does not understand.

이것은 이 codec이 하는 일이 아니라 protobuf-java 생성 타입의 성질이다. 테스트가 그 성질을 직접 확인한다 — aNewWriterIsStillReadableByAnOldReaderasV1.getUnknownFields().hasField(5)를 단언하고 as("the unrecognised field is retained, not dropped, so a round trip does not lose it")라고 적는다.


5. 주요 실행 경로

계약 등록: new ProtobufMessageContract(class, parser) → 빈 입력 파싱 → 클래스 일치 확인 → 실패 시 MessagingConfigurationException

encode: requireRegisteredMessage이고 등록 클래스인지 → requireFits(getSerializedSize())writeTo(sink)EncodedMessage(bytes, PROTOBUF, SchemaReference)

decode: requireRegistered → 요청 클래스 정확 일치 → encoded.length 상한 → parser.parseFrom


6. 실패 경로와 복구/번역

코드 예외 카테고리 조건
PROTOBUF_CONTRACT_UNUSABLE MessagingConfigurationException CONFIGURATION parser가 빈 입력을 파싱하지 못함
PROTOBUF_CONTRACT_MISMATCH MessagingConfigurationException CONFIGURATION parser 산출 클래스 ≠ 선언 클래스
UNKNOWN_MESSAGE_TYPE MessageValidationException PERMANENT_BUSINESS 타입 미등록
SCHEMA_VERSION_NOT_REGISTERED MessageValidationException PERMANENT_BUSINESS 버전 미등록
PAYLOAD_TYPE_MISMATCH MessageValidationException PERMANENT_BUSINESS 타입 불일치(양방향)
PAYLOAD_TOO_LARGE MessageTooLargeException PERMANENT_BUSINESS 크기 초과
PROTOBUF_ENCODE_FAILED MessageSerializationException DESERIALIZATION IOException
PROTOBUF_DECODE_FAILED MessageSerializationException DESERIALIZATION InvalidProtocolBufferException

Avro와 다른 점 하나. Avro는 catch (IOException | RuntimeException) 안에서 MessageTooLargeExceptioninstanceof로 통과시킨다. Protobuf는 catch (IOException failure)만 잡으므로 sink가 던지는 MessageTooLargeException(RuntimeException)이 그대로 전파된다. 별도 통과 로직이 필요 없다 — protobuf-java가 예외를 감싸지 않기 때문이다. 세 codec이 같은 문제를 세 가지로 푸는데(JSON은 원인 사슬 탐색, Avro는 즉시 instanceof, Protobuf는 아무것도 안 함) 각각 라이브러리 동작에 맞는 최소 해법이다. 다만 그 이유가 코드에 적혀 있지 않다.

계약 위반은 CONFIGURATION이고 메시지 실패가 아니다. ProtobufMessageContract 생성 실패는 registry를 조립하는 시점, 즉 시작 시점에 난다. MessagingConfigurationException javadoc이 그 의도를 적는다 — "Raised at startup wherever possible."


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

ProtobufMessageCodec은 불변이다 — contractsMap.copyOf, maxBytes는 int. ProtobufMessageContract는 record이고 Class/Parser 둘 다 protobuf-java에서 스레드 안전하다.

BoundedByteSink는 매 encode마다 새로 만들어진다.

Map.copyOf가 여기서는 얕은 복사 문제가 없다Map<MessageContractKey, ProtobufMessageContract>가 이미 평탄한 한 레벨이다. AvroMessageCodec이 중첩 맵을 받아 flatten이 필요했던 것과 대비된다(§messaging-schema-avro §4.2). 두 codec이 같은 registry 개념을 다른 형태로 받았고, 평탄한 쪽이 결함을 만들지 않았다.


8. 설정·기능 플래그·환경 차이

설정 없음.

상수 가시성
ProtobufMessageCodec.DEFAULT_MAX_BYTES 1,048,576 private

protobuf-java 버전은 4.29.3으로 build.gradle에 직접 고정돼 있다 — §12.4.


9. 퍼시스턴스/외부 시스템 세부

없다. 외부 schema registry를 쓰지 않는다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-schema-protobuf:test. BUILD SUCCESSFUL, 12 tests, 0 skipped, 0 failures.

테스트 하나가 모든 것을 덮는다: ProtobufCompatibilityTest.

테스트 증명하는 것
aRoundTripPreservesEveryField 인코딩/디코딩 왕복, content type
renamingAFieldKeepsItsValueBecauseTheTagNumberIsTheContract 태그 4의 이름을 currencycurrency_code로 바꿔도 값 보존
anAddedFieldDecodesAsItsDefaultForAnOldWriter v1이 쓴 바이트를 v2로 읽으면 새 필드가 기본값 ""
aNewWriterIsStillReadableByAnOldReader v2가 쓴 것을 v1로 읽어도 태그 4 보존, 태그 5는 unknown field로 유지
reusingATagNumberCorruptsTheReadWhichIsWhyTagsAreNeverRecycled 태그 4를 string→int64로 재사용하면 값이 0L로 소실
anUnregisteredTypeIsRejectedRatherThanGuessed 타입 미등록 거절
anUnregisteredVersionIsRejectedRatherThanDecodedWithAnotherVersionsParser v2 요청이 v1 parser로 폴백하지 않음, 메시지에 order.created v2 포함
aParserThatDoesNotProduceTheDeclaredClassIsRejectedAtConstruction 짝 검증
anOversizedPayloadIsRefusedBeforeItIsSerialized 16바이트 상한에서 refused at byte
aPayloadAtExactlyTheLimitIsAccepted 정확히 상한인 payload 허용
aLengthPrefixNoPayloadOfThisSizeCouldHonourIsADecodeFailure 4억 바이트를 주장하는 6바이트 메시지가 할당이 아니라 디코딩 실패로 끝남
theEncodedMessageCarriesItsSchemaReference schema reference의 버전

테스트 설계의 핵심 결정이 클래스 javadoc에 있다.

// ProtobufCompatibilityTest.java:30-33
 * <p>Descriptors are built at runtime rather than generated by protoc. The properties under test 
 * that a reader keyed on tag numbers survives a rename, that an added field decodes as its default,
 * and that reusing a tag corrupts the read  are properties of the wire format, so proving them
 * without a code-generation step keeps the test honest and the build free of a protoc toolchain.

DescriptorProto/FileDescriptor/DynamicMessage로 런타임에 스키마를 만든다. 그래서 이 leaf의 빌드에 protoc 툴체인이 없다.

aLengthPrefixNoPayloadOfThisSizeCouldHonourIsADecodeFailure가 Avro와의 대비를 만든다. 같은 형태의 공격(작은 바이트로 큰 길이를 주장)이 Avro에서는 newArray 오버라이드가 필요했고 Protobuf에서는 라이브러리가 알아서 막는다.

// 테스트 주석 :238-240
// Tag 1, wire type 2 (length-delimited), then a varint claiming four hundred million bytes
// follow. The whole message is six bytes, so it passes the size limit; what must not happen is
// the parser reserving the claimed length before discovering there is nothing behind it.

결과가 MessageSerializationException이다 — 즉 protobuf-java는 길이 주장을 신뢰해 미리 할당하지 않는다. Avro의 GenericDatumReader.newArray는 신뢰한다. 같은 공격에 두 라이브러리의 기본 방어가 다르고, 이 저장소는 그 차이를 각 leaf에서 다르게 처리했다.

증명 공백. ProtobufMessageCodec.decode의 상한 검사(encoded.length > maxBytes)를 직접 겨냥한 테스트가 없다. 인코딩 상한은 두 테스트가 덮지만 디코딩 상한은 덮이지 않는다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api","messaging-schema-api"]
verifyRuntimeModuleMembership []
vendor api 규칙(src/messaging/CLAUDE.md:40-43) protobuf가 public record 시그니처에 등장 → api 필요. 통과
Gradle dependency locking gradle.lockfile이 4.29.3/4.33.2를 고정
ArchUnit 전용 규칙 없음
protoc 툴체인 없음 — 의도적(§10)

12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/272-schema-family-reachability.txt.

12.1 Public surface reachability

타입 leaf 밖 참조 판정
ProtobufMessageCodec 0 소비자 없음
ProtobufMessageContract 0 소비자 없음

git grep -l -w ProtobufMessageCodec -- src ':!src/messaging/messaging-schema-protobuf' exit 1.

정합적이다. runtime_memberships: [], starter 미등록, 소비자 0 — 세 축이 모두 "없음"이다. messaging-schema-avro와 같은 형태이고, 이것이 incubating leaf의 올바른 상태다.

한계. 이 저장소는 템플릿이므로 파생 프로젝트가 이 codec을 쓸 수 있다. 그것을 확인할 수단이 저장소 안에 없다. 다만 이 leaf는 그 경우를 위해 준비돼 있다 — vendor를 api로 노출했고, 계약 등록이 첫 단계임을 build.gradle 주석이 명시한다.

12.2 Conditional sibling comparison

Spring 주석 0개. bean 없음.

codec sibling 비교는 analysis/messaging/messaging-schema-avro.md §12.2의 표가 소유한다. 이 leaf는 Avro와 같은 행(구현 o / starter 등록 x / membership [] / 정합)이다.

12.3 Duplicate mechanism sweep

(a) registry 조회 로직이 세 codec에 복제돼 있다

requireRegistered(JSON), schemaFor(Avro), requireRegistered(Protobuf)가 같은 구조다.

key = (type, version)
if 등록됨 → 반환
typeIsKnown = 키들 중 type이 같은 것이 있는가
if typeIsKnown → "버전 미등록" + 등록 버전 목록
else           → "타입 미등록"

JSON과 Protobuf는 registeredVersions(type) 헬퍼까지 사실상 동일하다(스트림 필터 → 버전 추출 → 정렬 → 리스트). Avro는 등록 버전 목록을 메시지에 넣지 않는다.

이 중복은 messaging-schema-api가 흡수할 수 있었다 — MessageContractKey가 이미 그 leaf에 있고, "타입은 알고 버전을 모른다"는 판단은 키의 성질이지 포맷의 성질이 아니다. SchemaCompatibilityValidator가 진화 규칙에 대해 정확히 그 일을 하려 했던 것과 같은 구조이고, 그쪽은 호출되지 않았다(analysis/messaging/messaging-schema-api.md §12.1).

(b) 크기 예외 통과 방식이 세 codec에 셋

codec 방식 필요한 이유
JSON unwrapTooLarge 원인 사슬 탐색 Jackson이 스트림 예외를 감쌈
Avro catch 안 즉시 instanceof(3곳) Avro가 감싸지 않지만 IOException과 함께 잡힘
Protobuf 없음 catch (IOException)만 잡으므로 그대로 전파

셋 다 라이브러리 동작에 맞는 최소 해법이고 결과는 같다. 중복 경쟁이 아니라 불가피한 분기로 분류한다. 다만 세 코드 어디에도 "왜 우리는 다른가"가 적혀 있지 않아, 넷째 codec을 추가하는 사람이 어느 형태를 골라야 하는지 알 수 없다.

(c) 1 MiB 상한analysis/messaging/messaging-schema-json.md §12.3이 소유한다. 이 leaf의 DEFAULT_MAX_BYTES는 private이므로 외부에 값을 노출하지 않는다.

12.4 Documentation / measured-count drift

(a) .proto fixture를 컴파일하는 빌드가 없다

src/test/proto/order_created_v1.proto가 존재하고 v1 계약을 서술한다.

message OrderCreated {
  string order_id = 1;
  string customer_id = 2;
  int64 total_minor_units = 3;
  string currency = 4;
  // v2 adds `channel = 5`. ...
}

테스트는 이것을 읽지 않는다. DescriptorProto로 손수 만든 V1_DESCRIPTOR가 같은 네 필드를 같은 태그로 선언하고, V2_DESCRIPTOR가 태그 4를 currency_code로 개명하고 태그 5 channel을 추가한다.

오늘은 둘이 일치한다. 필드 이름·태그·타입을 전수 대조했고 .proto의 주석이 예고하는 v2 변경도 테스트의 V2_DESCRIPTOR와 맞는다. 그러나 일치를 강제하는 것이 아무것도 없다 — protoc 툴체인이 없고, 테스트가 파일을 읽지 않으며, 게이트도 없다. 테스트 javadoc이 .proto를 "the fixture documents"라고 부르는데, 문서와 테스트가 각자 진실을 갖고 있다.

이 판단은 신중해야 한다. protoc를 뺀 것은 명시적 설계 결정이고 그 이유(테스트를 정직하게, 빌드를 가볍게)가 적혀 있다. 문제는 protoc의 부재가 아니라 .proto가 남아 있으면서 아무도 검증하지 않는다는 것이다.

(b) protobuf-java 버전이 저장소에 셋 있다

위치 버전 성격
src/build.gradle:180 ext.protobufVersion 3.25.5 주석이 "the single SSOT"라 부름
messaging-schema-protobuf/build.gradle:9 4.29.3 이 leaf가 직접 고정
adapter/inbound/websocket/build.gradle:44,46 4.33.2 compileOnly / testImplementation
다수 lockfile의 annotationProcessor 경로 4.33.2 전이

src/build.gradle:174-180의 주석을 정확히 읽어야 한다.

// Inbound gRPC adapter (adapter:inbound:grpc) — the Spring Boot BOM does NOT manage io.grpc:* or
// protobuf versions, and this repo has no version catalog. Pin them here as the single SSOT so the
// grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as
// platforms at MODULE scope (not the shared dependencyManagement block below) — keeping the
// strict-locking blast radius to the grpc module alone.

"single SSOT"의 범위가 문장 안에서 grpc 모듈로 한정된다 — "keeping the strict-locking blast radius to the grpc module alone". 따라서 이 leaf가 4.29.3을 쓰는 것은 그 SSOT를 위반한 것이 아니다. 정확한 사실은 이렇다: 저장소에 protobuf 버전 정책이 전역으로 존재하지 않고, 세 곳이 독립적으로 고정한다. 그리고 "single SSOT"라는 표현이 전역 정책의 존재를 시사하는 반면 실제 범위는 한 모듈이다.

오늘 이것이 사고가 아닌 이유: 이 leaf의 runtime_memberships[]이므로 4.29.3이 4.33.2·3.25.5와 같은 classpath에 오르지 않는다. 채택 시점의 부채이지 지금의 결함이 아니다. 이 leaf를 런타임에 편입시키면 그때 버전 충돌 판정이 필요해진다.

(c) 일치하는 주장들

문서 주장 재측정 결과
build.gradle 주석: protobuf가 public 시그니처에 등장하므로 api ProtobufMessageContract가 public record over Message/Parser 일치
클래스 javadoc: unknown field가 보존됨 테스트가 getUnknownFields().hasField(5) 확인 일치
support-matrix.md: Protobuf가 Stable 아님 membership [], starter 미등록 일치
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 실제로 [] 이 leaf에 한해 참(family 전체로는 틀림)

13. Git/설계 문서에서 확인한 변화와 실패 기록

ProtobufMessageContract javadoc이 두 결함을 보존한다.

이전 상태 그것이 만든 실패
클래스와 parser를 두 개의 병렬 맵에 보관, 일치 검사 없음 OrderCreated.classOrderCancelled의 parser 짝이 생성 시 통과 → 디코딩 시점의 ClassCastException, 브로커 스레드에서, 한 메시지 타입에 대해, production에서
한쪽 맵에만 존재하는 타입 parsers.get(type)이 null → NullPointerException. 운영자가 읽어야 할 registry 에러 대신 NPE

두 번째가 특히 이 저장소의 반복 주제다 — 실패의 종류가 바뀌면 운영자가 읽을 정보가 사라진다. messaging-core-apiFailureDescriptor 설계, MessageContractKey의 2단 에러, JSON codec의 unwrapTooLarge가 전부 같은 관심사다.

.proto 파일의 주석도 설계 이유를 남긴다 — "Field numbers are the contract, not the field names ... Tags are never reused, and removed fields are reserved so that a later edit cannot take the number back." 이 규칙 셋 중 둘(개명 안전, 태그 재사용 위험)이 테스트로 증명되고 하나(reserved)는 증명되지 않는다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-272 command evidence/raw/272-schema-family-reachability.txt §D, §E 두 타입의 소비자 0, membership [] 정적 검색
EVD-277 command ./gradlew :messaging:messaging-schema-protobuf:test --rerun-tasks BUILD SUCCESSFUL, 12 / 0 / 0 protoc 없음. 런타임 descriptor

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 닫힌 registry가 없으면 타입 혼동이 조용하다 — 클래스 javadoc
  • 두 병렬 맵이 만든 두 결함과 짝 증명 방식 — ProtobufMessageContract javadoc
  • 크기를 미리 알 수 있어 사전 거절한다 — encode 주석
  • 다른 버전 parser로 폴백하지 않는 이유 — requireRegistered 주석
  • unknown field 보존이 forward compatibility의 기반 — 클래스 javadoc
  • descriptor를 런타임에 만드는 이유(protoc 툴체인 회피) — 테스트 javadoc
  • 태그 번호가 계약인 이유 — .proto 주석
  • protobuf를 api로 선언한 이유 — build.gradle 주석
  • ext.protobufVersion의 범위가 grpc 모듈로 한정된 이유 — src/build.gradle:174-178

추론

  • 크기 예외 통과 로직이 없는 것은 protobuf-java가 예외를 감싸지 않기 때문이다 → 추론. 코드 형태는 관측, 인과는 추론.
  • 4.29.3을 고른 이유 → 미상. 주석도 커밋 메시지도 없다.
  • .proto를 남겨 둔 이유 → 미상. 문서용으로 보이지만 명시되지 않았다.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 두 타입 199줄 전문의 계약
  • 12개 테스트가 통과하고 무엇을 단언하는지
  • 소비자 0 / starter 미등록 / membership []의 삼중 정합
  • .proto fixture와 테스트 descriptor가 오늘 일치한다는 것(전수 대조)과 그것을 강제하는 것이 없다는 것
  • 저장소에 protobuf 버전이 셋 있고 "single SSOT"의 범위가 한 모듈이라는 것
  • 길이 주장 공격에 대해 protobuf-java가 Avro와 달리 사전 할당하지 않는다는 것(테스트로 확인)

확인하지 못한 것

  • 디코딩 상한을 겨냥한 테스트가 없다. encoded.length > maxBytes 분기가 실행된 적이 없다.
  • .proto 주석이 말하는 reserved 규칙 — 테스트가 없다.
  • 파생 프로젝트가 이 codec을 쓰는지.
  • 4.29.3과 3.25.5·4.33.2가 한 classpath에 올랐을 때 무슨 일이 생기는지. 오늘은 그 조합이 존재하지 않는다.
  • 실제 protoc 생성 타입(GeneratedMessage 서브클래스)에서 ProtobufMessageContract의 빈 입력 파싱 증명이 동작하는지 — 테스트는 DynamicMessage만 쓴다.

17. 손볼 것

P3 — .proto fixture와 테스트 descriptor의 일치를 아무도 강제하지 않는다

  • 사실. src/test/proto/order_created_v1.proto가 v1 계약을 서술하고, 테스트는 그 파일을 읽지 않고 DescriptorProto로 같은 스키마를 손수 만든다. 오늘 둘은 일치한다(필드 4개, 태그 1–4, 타입 전수 대조).
  • 근거. .proto 전문 vs ProtobufCompatibilityTest.java:41-66.
  • 왜 문제인가. protoc를 뺀 것은 명시적 설계 결정이고 이유가 적혀 있다. 문제는 .proto가 남아 있으면서 검증되지 않는다는 것이다. 테스트 javadoc이 그것을 "the fixture documents"라 부르므로, 읽는 사람은 그 파일이 테스트의 근거라고 믿는다. 한쪽만 수정되면 조용히 갈라진다.
  • 확인 방법. 두 파일의 필드/태그/타입 대조. find src/messaging/messaging-schema-protobuf -name '*.proto'
  • 후보. (a) .proto를 읽어 descriptor를 만드는 테스트 헬퍼를 쓴다(protoc 없이 protobuf-java의 파서로는 불가하므로 실제로는 어렵다). (b) .proto를 삭제하고 규칙 주석을 테스트로 옮긴다. (c) .proto에 "이 파일은 문서이며 테스트는 descriptor를 손수 만든다"를 명시한다.
  • 다음 단계. REFERENCE 후보(검증되지 않는 스키마 파일은 문서임을 파일 안에 적는다).

P3 — 디코딩 상한 분기가 테스트되지 않는다

  • 사실. decodeif (encoded.length > maxBytes) 분기를 겨냥한 테스트가 없다. 인코딩 상한은 두 테스트가 덮는다.
  • 근거. ProtobufMessageCodec.java:104-108, ProtobufCompatibilityTest 12개 전수.
  • 왜 문제인가. 디코딩은 신뢰할 수 없는 입력을 받는 쪽이다. 브로커에서 온 바이트에 대한 방어가 자기 코드가 만든 바이트에 대한 방어보다 덜 검증됐다. 형제 leaf는 반대다 — AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound가 정확히 이 각도를 덮는다.
  • 확인 방법. 12개 테스트 중 decode에 큰 입력을 주는 것이 없음.
  • 후보. maxBytes보다 큰 byte[]decode를 부르는 테스트 추가.
  • 다음 단계. REFERENCE 후보(신뢰할 수 없는 입력 쪽 경계를 먼저 테스트한다).

P3 — protobuf-java 버전이 저장소에 셋이고 전역 정책이 없다

  • 사실. ext.protobufVersion = 3.25.5(grpc 모듈 범위로 한정), 이 leaf 4.29.3, websocket 4.33.2. lockfile들이 세 값을 모두 고정한다.
  • 근거. src/build.gradle:174-180 · messaging-schema-protobuf/build.gradle:9 · adapter/inbound/websocket/build.gradle:44,46 · 각 gradle.lockfile.
  • 왜 문제인가. 오늘은 사고가 아니다 — 이 leaf의 runtime_memberships[]이라 세 버전이 한 classpath를 공유하지 않는다. 채택 시점의 부채다. 이 leaf를 런타임에 편입시키는 순간 버전 판정이 필요해지고, 그때 참조할 전역 정책이 없다. 그리고 src/build.gradle의 "the single SSOT"라는 표현이 전역 정책의 존재를 시사하는데 실제 범위는 그 문장 안에서 grpc 모듈로 한정된다.
  • 확인 방법. git grep -n 'protobuf-java\|protobufVersion' -- src --include='*.gradle'
  • 후보. (a) 편입 전까지 현 상태 유지하되 src/messaging/CLAUDE.md에 "편입 시 버전 정합을 먼저 판정한다"를 적는다. (b) ext.protobufVersion의 범위를 넓히고 주석의 "single SSOT" 표현을 실제 범위에 맞춘다.
  • 다음 단계. OPEN QUESTION 후보. 판정이 "이 leaf를 런타임에 편입할 것인가"에 걸린다. 저장소 안에 답이 없다.

P3 — registry 조회 로직이 세 codec에 복제돼 있다

  • 사실. requireRegistered(JSON/Protobuf)와 schemaFor(Avro)가 같은 3단 판단을 각자 구현한다. JSON과 Protobuf는 registeredVersions 헬퍼까지 사실상 동일하다.
  • 근거. 세 codec의 해당 메서드.
  • 왜 문제인가. 판단은 MessageContractKey의 성질이지 포맷의 성질이 아니다. 그리고 실제로 갈라졌다 — Avro만 AVRO_ 접두 코드를 쓰고 등록 버전 목록을 메시지에 넣지 않는다. messaging-schema-api가 흡수할 수 있는 형태다.
  • 확인 방법. 세 메서드 대조.
  • 후보. messaging-schema-apiContractLookup류 헬퍼를 두고 세 codec이 부른다.
  • 다음 단계. messaging-schema-api §17의 "포맷 독립 규칙" 항목과 같은 계열이다. 그 leaf가 소유하고 여기서는 교차 참조만 남긴다.

확인된 설계(문제 아님)

  • 클래스와 parser를 한 값에 묶고 빈 입력 파싱으로 짝을 증명하는 것
  • 직렬화 크기를 미리 알아 사전 거절하고, sink 경계를 여전히 통과시키는 이중 방어
  • 다른 버전 parser로 폴백하지 않고 등록 버전 목록을 에러에 넣는 것
  • descriptor를 런타임에 만들어 protoc 툴체인 없이 wire 성질을 증명하는 것
  • 소비자 0 / starter 미등록 / membership []의 삼중 정합
  • 크기 예외 통과 로직이 없는 것(protobuf-java가 감싸지 않으므로 불필요)

Source anchors

id kind path revision what it proves limitations
MSP-001 registry src/config/architecture/modules.json 21234e38 deps, runtime_memberships: [] 선언
MSP-002 build messaging-schema-protobuf/build.gradle same protobuf api 선언과 이유, 버전 4.29.3
MSP-003 build messaging-schema-protobuf/gradle.lockfile:26-27 same 4.29.3(compile/runtime), 4.33.2(annotationProcessor) 이 leaf 범위
MSP-004 code .../protobuf/ProtobufMessageContract.java 전문 same §4.1 짝 증명과 두 이전 결함 DynamicMessage로만 검증됨
MSP-005 code .../protobuf/ProtobufMessageCodec.java 전문 same §4.24.6
MSP-006 test ProtobufCompatibilityTest (12) same §10 표 전부 protoc 없음. decode 상한 미검증
MSP-007 fixture src/test/proto/order_created_v1.proto same 태그 규칙 서술 컴파일되지 않음(§12.4a)
MSP-008 build policy src/build.gradle:174-180 same ext.protobufVersion = 3.25.5와 그 범위가 grpc 모듈로 한정됨
MSP-009 cross-leaf build adapter/inbound/websocket/build.gradle:44,46 same 세 번째 protobuf 버전 4.33.2 해당 leaf SSOT가 소유
MSP-010 cross-leaf code messaging-schema-api/.../BoundedByteSink.java:66-80 same requireFits가 이 codec을 위해 존재 해당 leaf SSOT가 소유
EVD-272 command evidence/raw/272-schema-family-reachability.txt same §12.1 정적 검색
EVD-277 command ./gradlew :messaging:messaging-schema-protobuf:test --rerun-tasks same 12 / 0 / 0