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>
590 lines
35 KiB
Markdown
590 lines
35 KiB
Markdown
# 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 주석에 있다.
|
||
|
||
```groovy
|
||
// 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이 한 문장으로 적는다.
|
||
|
||
```java
|
||
// 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에서 가장 밀도 높은 결정이다.
|
||
|
||
```java
|
||
// 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.
|
||
```
|
||
|
||
증명 방법이 영리하다.
|
||
|
||
```java
|
||
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 인코딩: 크기를 미리 알 수 있다
|
||
|
||
```java
|
||
BoundedByteSink sink = BoundedByteSink.of(maxBytes, "PAYLOAD_TOO_LARGE");
|
||
sink.requireFits(message.getSerializedSize());
|
||
try {
|
||
message.writeTo(sink);
|
||
}
|
||
```
|
||
|
||
주석이 이유를 적는다.
|
||
|
||
```java
|
||
// :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 인코딩 타입 검사: 이중 조건
|
||
|
||
```java
|
||
if (!(payload instanceof Message message) || !contract.payloadType().isInstance(payload)) {
|
||
throw new MessageValidationException("PAYLOAD_TYPE_MISMATCH", ...);
|
||
}
|
||
```
|
||
|
||
`Message`인지와 등록된 클래스의 인스턴스인지를 함께 본다. 후자만으로 충분해 보이지만 전자가 `writeTo`를 부를 수 있음을 보장한다.
|
||
|
||
### 4.4 디코딩: 정확 일치와 상한
|
||
|
||
```java
|
||
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과 같은 어휘
|
||
|
||
```java
|
||
// :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 보존
|
||
|
||
```java
|
||
// :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에 있다.
|
||
|
||
```java
|
||
// 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에서는 라이브러리가 알아서 막는다.
|
||
|
||
```java
|
||
// 테스트 주석 :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 계약을 서술한다.
|
||
|
||
```proto
|
||
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
|
||
- 두 병렬 맵이 만든 두 결함과 짝 증명 방식 — `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 — 디코딩 상한 분기가 테스트되지 않는다
|
||
|
||
- **사실.** `decode`의 `if (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-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 | — |
|