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

590 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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 | — |