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>
35 KiB
messaging-schema-protobuf 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-schema-protobufSSOT owner:messaging-schema-protobufintegration/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). AvroMessageCodec만 AVRO_ 접두사를 붙여 어휘가 갈라진다 — 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 생성 타입의 성질이다. 테스트가 그 성질을 직접 확인한다 — aNewWriterIsStillReadableByAnOldReader가 asV1.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: requireRegistered → Message이고 등록 클래스인지 → 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) 안에서 MessageTooLargeException을 instanceof로 통과시킨다. 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은 불변이다 — contracts는 Map.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의 이름을 currency→currency_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.class와 OrderCancelled의 parser 짝이 생성 시 통과 → 디코딩 시점의 ClassCastException, 브로커 스레드에서, 한 메시지 타입에 대해, production에서 |
| 한쪽 맵에만 존재하는 타입 | parsers.get(type)이 null → NullPointerException. 운영자가 읽어야 할 registry 에러 대신 NPE |
두 번째가 특히 이 저장소의 반복 주제다 — 실패의 종류가 바뀌면 운영자가 읽을 정보가 사라진다. messaging-core-api의 FailureDescriptor 설계, 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
- 두 병렬 맵이 만든 두 결함과 짝 증명 방식 —
ProtobufMessageContractjavadoc - 크기를 미리 알 수 있어 사전 거절한다 — 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
[]의 삼중 정합 .protofixture와 테스트 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전문 vsProtobufCompatibilityTest.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 — 디코딩 상한 분기가 테스트되지 않는다
- 사실.
decode의if (encoded.length > maxBytes)분기를 겨냥한 테스트가 없다. 인코딩 상한은 두 테스트가 덮는다. - 근거.
ProtobufMessageCodec.java:104-108,ProtobufCompatibilityTest12개 전수. - 왜 문제인가. 디코딩은 신뢰할 수 없는 입력을 받는 쪽이다. 브로커에서 온 바이트에 대한 방어가 자기 코드가 만든 바이트에 대한 방어보다 덜 검증됐다. 형제 leaf는 반대다 —
AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound가 정확히 이 각도를 덮는다. - 확인 방법. 12개 테스트 중
decode에 큰 입력을 주는 것이 없음. - 후보.
maxBytes보다 큰byte[]로decode를 부르는 테스트 추가. - 다음 단계. REFERENCE 후보(신뢰할 수 없는 입력 쪽 경계를 먼저 테스트한다).
P3 — protobuf-java 버전이 저장소에 셋이고 전역 정책이 없다
- 사실.
ext.protobufVersion = 3.25.5(grpc 모듈 범위로 한정), 이 leaf4.29.3, websocket4.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-api에ContractLookup류 헬퍼를 두고 세 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.2–4.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 | — |