# 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 *
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 *
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. * *
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 *
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 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 | — |