Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-schema-avro.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

598 lines
36 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-avro 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-schema-avro`
> SSOT owner: `messaging-schema-avro`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-schema-avro`
- canonical state `analysisFile`: `analysis/messaging/messaging-schema-avro.md`
- source path: `src/messaging/messaging-schema-avro`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]`
- registry `runtime_memberships`: **`[]`** — build-only / incubating
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 2 |
| production LOC | 345 |
| 패키지 | 1 (`dev.caskeleton.messaging.schema.avro`) |
| test 파일 | 3 |
| test 메서드(실행 확인) | 16 |
| test resource | `/schemas/order.created/v1.avsc` |
| 외부 의존성 | 1 (`org.apache.avro:avro:1.12.0`, **`api`**) |
두 클래스: `AvroMessageCodec`(런타임 인코딩/디코딩), `AvroCompatibilityGate`(CI용 진화 검사).
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `.../avro/AvroMessageCodec.java` | 1 | `FULL_READ` | 272줄 전문 |
| `.../avro/AvroCompatibilityGate.java` | 1 | `FULL_READ` | 73줄 전문 |
| `src/test/java/**` | 3 | `FULL_READ` | 전문 |
| `src/test/resources/schemas/order.created/v1.avsc` | 1 | `STRUCTURAL_ONLY` | fixture 스키마; 필드 구성만 확인 |
| `build.gradle` | 1 | `FULL_READ` | 주석 포함 11줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
선택적(optional) Avro codec. Stable이 아니고 registry membership이 비어 있다 — **build-only / incubating**이며, `docs/messaging/support-matrix.md`의 등급과는 다른 축이다.
Avro를 `api`로 선언한 이유가 build.gradle 주석에 있다.
```groovy
// api: AvroMessageCodec's constructors take a registry of org.apache.avro.Schema and
// AvroCompatibilityGate.check takes and compares them. A consumer cannot build that
// registry without naming the type, so hiding the dependency only stops them compiling.
api 'org.apache.avro:avro:1.12.0'
```
`src/messaging/CLAUDE.md:40-43`이 기술하는 게이트 — public/protected 시그니처에 나오는 vendor 라이브러리가 `api`로 선언됐는지 대조 — 를 이 leaf가 통과한다. 형제 `messaging-schema-json`은 Jackson 타입이 시그니처에 없으므로 `implementation`이고, 그 판정 차이가 규칙이 실제로 작동한다는 증거다.
**클래스 둘의 실행 시점이 다르다.**
| 클래스 | 언제 도는가 | 근거 |
|---|---|---|
| `AvroMessageCodec` | 런타임(메시지마다) | `MessageCodec` 구현 |
| `AvroCompatibilityGate` | **CI** | 클래스 javadoc: "Run in CI rather than at runtime" |
게이트의 javadoc이 그 이유를 적는다 — "By the time a producer has published one incompatible record, the damage is durable: the record sits in a retained log that every current and future consumer must be able to read."
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `avro:1.12.0`(api).
나가는 것: **없다.** 어떤 leaf의 `allowed_dependencies`에도 `messaging-schema-avro`가 없다. `messaging-spring-boot-starter`의 17개 의존 목록에도 없다.
런타임 배선: 없음. `runtime_memberships: []`이므로 배포 아티팩트에 실리지 않는다. bean도 없다(Spring 주석 0개).
**소비자 없음과 membership 없음이 일치한다.** 이것이 정합적인 incubating 상태다 — `messaging-cloudevents`와 대비된다(그쪽은 membership이 있고 소비자가 없다).
---
## 3. 패키지/컴포넌트 지도
```
AvroMessageCodec (MessageCodec 구현)
├── encode(type, version, GenericRecord) → EncodedMessage
├── decode(type, version, byte[], Class) → GenericRecord (writer == reader)
├── decodeEvolved(type, writerV, readerV, byte[]) → GenericRecord (writer != reader)
├── schemaFor(type, version) → 등록 조회, 2단 에러
├── boundedReader(writer, reader) → newArray 오버라이드
└── flatten(nested registry) → (type, version) 평탄화 + 깊은 복사
AvroCompatibilityGate (CI)
└── check(candidate, history, mode)
├── isTransitive / readsBackward / readsForward (private, 자체 구현)
└── requireCompatible → org.apache.avro.SchemaCompatibility
```
---
## 4. 계약·불변식·상태 모델
### 4.1 Avro 바이너리에는 스키마가 없다 — 그래서 registry가 계약이다
```java
// AvroMessageCodec.java:33-37
* <p>Decoding uses an explicit writer schema and reader schema pair. Avro binary carries no schema
* of its own, so decoding with the wrong schema does not fail it produces plausible garbage. The
* registry is what makes the writer schema knowable, and passing both schemas to the reader is what
* makes evolution work: Avro resolves added, removed, and defaulted fields only when it can see
* both sides.
```
"does not fail — it produces plausible garbage"가 이 leaf의 모든 방어의 전제다. JSON이나 Protobuf와 달리 Avro는 잘못된 스키마로 디코딩해도 예외를 던지지 않는 경우가 있다.
single-object encoding에 헤더를 붙이지 않는 것도 명시적 결정이다 — "The framing that would carry a schema fingerprint belongs to the transport headers, where the platform already carries schema identity for every format, rather than being duplicated inside the Avro payload for this one format."
### 4.2 `flatten`: 얕은 복사가 만든 구멍
생성자가 받는 것은 중첩 맵 `Map<MessageType, Map<SchemaVersion, Schema>>`이고, `Map.copyOf`**바깥 레벨만** 복사한다.
```java
// AvroMessageCodec.java:78-82
* <p>{@code Map.copyOf} on the outer map is a shallow copy: every inner {@code Map<SchemaVersion,
* Schema>} stayed the caller's own object, so a caller that kept a reference could add, replace,
* or remove a schema version after construction and the codec would silently start encoding
* against it. Flattening to {@code (type, version)} keys copies both levels and makes the version
* part of the identity the lookup uses rather than a second hop.
```
이 결함이 위험한 이유는 §4.1과 곱해진다 — 스키마가 바뀌어도 디코딩이 실패하지 않고 그럴듯한 쓰레기를 낸다.
`AvroRegistryBoundsTest.mutatingTheCallersMapAfterConstructionChangesNothing`이 세 가지를 한 번에 확인한다: 생성 후 추가한 버전은 미등록, 생성 후 추가한 타입도 미등록, 원래 등록한 스키마는 그대로.
평탄화가 `MessageContractKey`(schema-api)를 키로 쓰므로 §4.5의 2단 에러 구분도 자연히 따라온다.
### 4.3 인코딩: direct encoder를 쓰는 이유
```java
// AvroMessageCodec.java:122-124
// A direct encoder, not the buffering one: the buffering encoder holds bytes back until flush,
// which would let a large record allocate freely before the sink ever sees a write. Direct
// encoding makes the bound apply to the record as it is written.
BinaryEncoder encoder = EncoderFactory.get().directBinaryEncoder(sink, null);
```
`BoundedByteSink`(schema-api)의 경계가 실제로 작동하려면 인코더가 증분적으로 써야 한다. `EncoderFactory.get().binaryEncoder(...)`는 버퍼링하므로 sink가 첫 write를 보기 전에 큰 레코드가 이미 할당된다. 즉 **schema-api의 방어가 이 한 줄에 의존한다.**
인코딩 전 검사 둘:
- payload가 `GenericRecord`인가 → `AVRO_PAYLOAD_NOT_A_RECORD`
- `schema.equals(record.getSchema())`인가 → `AVRO_SCHEMA_MISMATCH`
두 번째는 테스트가 이유를 적는다 — `as("encoding v2 data under the v1 version would produce bytes nothing can decode")`.
### 4.4 `boundedReader`: 다섯 바이트 공격
이 leaf에서 가장 깊은 방어다.
```java
// AvroMessageCodec.java:222-235
* <p>Avro writes an array as a declared element count followed by the elements. The count is a
* variable-length integer, so five bytes can claim four hundred million elements, and the generic
* reader allocates the backing array from that claim before reading a single element. Bounding
* the input length does not help: the whole hostile payload is five bytes, well under any limit,
* and the failure is an {@code OutOfMemoryError} rather than an exception the codec could report
* on a consumer thread that is the process, not the message.
*
* <p>The ceiling is the byte limit itself. Every element costs at least one byte on the wire even
* when it is empty, so a payload of at most {@code maxBytes} bytes cannot honestly contain more
* than {@code maxBytes} elements, and any larger claim is a lie the reader should refuse rather
* than reserve memory for.
```
구현은 익명 서브클래스의 `newArray` 오버라이드다.
```java
return new GenericDatumReader<>(writerSchema, readerSchema) {
@Override
protected Object newArray(Object old, int size, Schema schema) {
if (size > maxElements) {
throw new MessageTooLargeException("AVRO_COLLECTION_TOO_LARGE", ...);
}
return super.newArray(old, size, schema);
}
};
```
**상한 선택의 논리가 정확하다.** 원소 하나가 wire에서 최소 1바이트를 쓰므로, `maxBytes` 바이트짜리 payload가 정직하게 담을 수 있는 원소는 `maxBytes`개를 넘을 수 없다. 별도 튜닝 상수를 만들지 않고 이미 있는 경계에서 파생시켰다.
`AvroHostileInputTest`가 이 공격을 손으로 만든 zigzag varint로 재현한다.
```java
// AvroHostileInputTest.java:118-123
* <p>Hand-written rather than taken from an encoder because the point is to write a count with no
* elements behind it, which no encoder will do.
```
그리고 공격의 크기를 직접 단언한다 — `assertThat(hostile).as("the whole attack is five bytes, so no byte limit stands between it and the allocation").hasSizeLessThan(16)`.
테스트 클래스 javadoc이 **왜 corpus가 좁은지**까지 적는다.
```java
// AvroHostileInputTest.java:30-33
* <p>Strings, byte arrays and maps were already safe: Avro validates those lengths against the
* bytes actually remaining. Arrays were the one shape that allocated on trust, which is why the
* corpus below is narrow rather than exhaustive it pins the case that failed, and the two cases
* that must keep working around it.
```
이것은 "좁은 테스트"를 정당화한 드문 예다 — 다른 형태는 라이브러리가 이미 방어하므로 재확인이 아니라 잡음이 된다.
### 4.5 `schemaFor`: 2단 에러
`AVRO_TYPE_NOT_REGISTERED`(타입 미등록)와 `AVRO_VERSION_NOT_REGISTERED`(버전 미등록)를 구분한다. JSON codec의 `UNKNOWN_MESSAGE_TYPE`/`SCHEMA_VERSION_NOT_REGISTERED`와 같은 형태이지만 **코드 문자열이 다르다.** 두 codec이 같은 판단을 다른 어휘로 보고한다 — §12.3.
### 4.6 `decodeEvolved`: 나중에 붙은 경계
```java
// AvroMessageCodec.java:199-201
// The same bound the ordinary decode applies. It was missing here, so the evolution path — the
// one a consumer takes for every message written by a newer producer — accepted input of any
// size.
requireWithinLimit(encoded.length);
```
테스트가 두 각도에서 붙든다 — `AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound`(`as("decodeEvolved accepted input of any size")`)와 `AvroHostileInputTest.theEvolutionDecodeAppliesTheSameCollectionBound`(`as("a consumer reading a newer producer takes this path for every message")`).
`decodeEvolved`**가장 흔한 경로인데 가장 늦게 보호됐다.** 진화 경로는 producer가 앞서 나간 순간부터 모든 메시지가 지나는 길이다.
### 4.7 `AvroCompatibilityGate`
```java
public void check(Schema candidate, List<Schema> history, SchemaCompatibility mode) {
if (mode == SchemaCompatibility.NONE_EXPERIMENTAL || history.isEmpty()) return;
List<Schema> checked = isTransitive(mode) ? history : history.subList(0, 1);
for (Schema previous : checked) {
if (readsBackward(mode)) requireCompatible(candidate, previous, "backward");
if (readsForward(mode)) requireCompatible(previous, candidate, "forward");
}
}
```
`history`는 **newest first**를 요구한다(javadoc `@param history the previously registered schemas, newest first`). 이것은 `messaging-schema-api``SchemaRegistry.history`가 **oldest first**를 계약으로 삼는 것과 반대다. 두 계약을 잇는 코드가 없으므로 오늘은 충돌하지 않지만, 잇는 순간 `reversed()`를 빠뜨리면 조용히 잘못된 버전을 비교한다. `SchemaCompatibilityValidator.versionsToCheck`가 정확히 그 `reversed()`를 수행하고, 그 클래스는 호출되지 않는다(§12.3).
에러 코드는 방향에서 파생된다 — `"AVRO_" + direction.toUpperCase(Locale.ROOT) + "_INCOMPATIBLE"``AVRO_BACKWARD_INCOMPATIBLE` / `AVRO_FORWARD_INCOMPATIBLE`.
---
## 5. 주요 실행 경로
**encode:** `schemaFor``GenericRecord` 확인 → 스키마 동일성 확인 → `BoundedByteSink` + direct encoder → `writer.write` + `flush``EncodedMessage(bytes, AVRO, SchemaReference)`
**decode(동일 버전):** `requireWithinLimit``schemaFor` → 대상 타입이 `GenericRecord` 계열인지 → `boundedReader(writer, writer)``reader.read`
**decodeEvolved:** `requireWithinLimit``schemaFor(writer)` + `schemaFor(reader)``boundedReader(writer, reader)``reader.read`
**CI 게이트:** `check(candidate, history, mode)` → 모드에 따라 비교 대상 선정 → 방향별 `checkReaderWriterCompatibility`
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 조건 |
|---|---|---|
| `AVRO_TYPE_NOT_REGISTERED` | `MessageValidationException` | 타입 미등록 |
| `AVRO_VERSION_NOT_REGISTERED` | `MessageValidationException` | 버전 미등록 |
| `AVRO_PAYLOAD_NOT_A_RECORD` | `MessageValidationException` | encode/decode 대상이 `GenericRecord`가 아님 |
| `AVRO_SCHEMA_MISMATCH` | `MessageValidationException` | payload 스키마 ≠ 등록 스키마 |
| `AVRO_PAYLOAD_TOO_LARGE` | `MessageTooLargeException` | 인코딩 중 또는 디코딩 입력 상한 초과 |
| `AVRO_COLLECTION_TOO_LARGE` | `MessageTooLargeException` | 배열 원소 수 주장 > `maxBytes` |
| `AVRO_ENCODE_FAILED` | `MessageSerializationException` | 그 외 인코딩 실패 |
| `AVRO_DECODE_FAILED` | `MessageSerializationException` | 그 외 디코딩 실패 |
| `AVRO_EVOLUTION_FAILED` | `MessageSerializationException` | 진화 해석 실패 |
| `AVRO_BACKWARD_INCOMPATIBLE` / `AVRO_FORWARD_INCOMPATIBLE` | `MessageSchemaIncompatibleException` | CI 게이트 |
**예외 재던지기 패턴이 세 곳에 반복된다.**
```java
} catch (IOException | RuntimeException failure) {
if (failure instanceof MessageTooLargeException tooLarge) {
throw tooLarge;
}
throw new MessageSerializationException("AVRO_*_FAILED", ..., failure);
}
```
`BoundedByteSink`가 던지는 `MessageTooLargeException``RuntimeException`이므로 catch에 걸린다. 그것을 그대로 통과시키지 않으면 크기 실패가 인코딩 실패로 접힌다 — JSON codec의 `unwrapTooLarge`와 같은 문제를 다른 방식(원인 사슬 탐색이 아니라 즉시 `instanceof`)으로 푼다. §12.3.
`AvroHostileInputTest.aCountBeyondIntRangeFailsWhileReadingRatherThanWhileReserving`가 흥미로운 경계를 잡는다 — 2³²을 주장하면 int로 잘려 무해한 값이 되고, 그 다음 읽기가 입력 부족으로 실패해 `MessageSerializationException`이 된다. 즉 `newArray` 방어를 우회하는 값이 존재하지만 그 우회는 할당이 아니라 읽기 실패로 끝난다.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
`AvroMessageCodec`은 불변이다 — `schemas``Map.copyOf`된 평탄 맵, `maxBytes`는 int. `BoundedByteSink`·`BinaryEncoder`·`DatumReader`·`BinaryDecoder`는 전부 호출마다 새로 만들어진다.
`EncoderFactory.get()`/`DecoderFactory.get()`은 Avro의 싱글턴 팩토리이고 스레드 안전하다. 다만 `binaryDecoder(encoded, null)`의 두 번째 인자가 재사용 decoder 자리인데 항상 `null`을 넘긴다 — 재사용하지 않으므로 공유 상태가 없다. 성능을 버리고 안전을 택한 형태다.
`AvroCompatibilityGate`는 상태가 없다.
---
## 8. 설정·기능 플래그·환경 차이
설정 없음.
| 상수 | 값 | 가시성 |
|---|---:|---|
| `AvroMessageCodec.DEFAULT_MAX_BYTES` | 1,048,576 | **private** |
private이므로 §12.3의 "1 MiB가 다섯 곳에 복사됨" 문제에서 이 leaf는 외부에 값을 노출하지 않는다. 대신 공유 상수를 읽지도 않는다.
Avro 버전은 `1.12.0`으로 build.gradle에 고정돼 있다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. 외부 schema registry를 쓰지 않는다 — 스키마는 생성자 인자로 받는다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-schema-avro:test`. **BUILD SUCCESSFUL, 16 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `AvroCompatibilityTest` | 8 | round trip, schema reference, v1→v2 default를 통한 진화, defaulted 필드 추가는 backward 호환, default 없는 추가는 거절, payload 스키마 불일치 사전 거절, 미등록 버전/타입 거절 | transitive 모드 실제 동작(테스트가 `BACKWARD`만 씀) |
| `AvroHostileInputTest` | 4 | 4억 원소 주장이 할당 전에 거절됨, 진화 경로도 같은 방어, int 범위 초과는 읽기 실패로 끝남, 정직한 배열은 정상 | 문자열·맵·바이트 배열(라이브러리가 이미 방어한다고 javadoc이 명시) |
| `AvroRegistryBoundsTest` | 4 | 생성 후 맵 변경이 무효, 인코딩 중 거절(`refused at byte`), 진화 경로 상한, 정확히 상한인 payload 허용 | — |
**증명 공백 하나.** `AvroCompatibilityGate`의 transitive 모드가 테스트되지 않는다. 8개 중 게이트를 부르는 것은 둘이고 둘 다 `SchemaCompatibility.BACKWARD`(pairwise)다. `isTransitive`가 true인 경로 — `history` 전체를 순회하는 분기 — 는 실행되지 않는다. 그 분기는 §12.3이 지적하는 중복 구현의 핵심이기도 하다.
세 테스트 클래스 중 둘이 클래스 javadoc으로 **이전 결함을 서술한다**(`AvroHostileInputTest`, `AvroRegistryBoundsTest`). 이 저장소의 일관된 습관이다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` |
| `verifyRuntimeModuleMembership` | `[]` — 런타임 편입 없음이 강제됨 |
| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | Avro가 public 시그니처에 등장 → `api` 선언 필요. **통과** |
| ArchUnit | 전용 규칙 없음 |
`AvroCompatibilityGate`가 "Run in CI"라고 선언하지만, **이 저장소의 CI에서 그것을 실행하는 task가 없다.** `src/build.gradle`의 9개 `verifyMessaging*` task는 전부 `app-bootstrap/build/messaging-evidence/**/manifest.json`을 요구하는 자격 게이트이고 스키마 진화 검사를 부르지 않는다. §12.1.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/272-schema-family-reachability.txt`.
### 12.1 Public surface reachability
| 타입 | leaf 밖 참조 | 판정 |
|---|---:|---|
| `AvroMessageCodec` | **0** | 소비자 없음 |
| `AvroCompatibilityGate` | **0** | 소비자 없음 |
`git grep -l -w AvroMessageCodec -- src ':!src/messaging/messaging-schema-avro'` exit 1, `AvroCompatibilityGate`도 동일.
**두 클래스의 "0"은 성격이 다르다.**
`AvroMessageCodec`의 0은 정합적이다 — `runtime_memberships: []`이고 starter의 codec registry에도 등록되지 않는다(`RegisteredMessageCodecs.of(JacksonMessageCodec.of(...))`, varargs 비어 있음). 소비자 없음과 배포 없음이 일치한다.
`AvroCompatibilityGate`의 0은 다르다. 이 클래스는 **런타임이 아니라 CI에서 도는 것을 전제로 설계됐다.** javadoc이 그렇게 선언한다. 그런데 그것을 부르는 CI task가 없다. 즉 "런타임에 안 쓰이는 건 당연하다"가 이 클래스에는 적용되지 않는다 — 이 클래스는 애초에 런타임 소비자를 가질 계획이 없었고, 계획된 소비자(CI)도 없다.
이 구분이 중요한 이유: 배포 게이트가 생겨 `messaging-schema-avro`가 런타임에 편입되면 `AvroMessageCodec`은 자연히 배선되지만 `AvroCompatibilityGate`는 여전히 아무 데도 붙지 않는다. 두 문제는 함께 풀리지 않는다.
**한계.** 이 저장소는 템플릿이고, 파생 프로젝트가 `AvroCompatibilityGate`를 자기 CI에서 부를 수 있다. 그것을 확인할 수단이 저장소 안에 없다.
### 12.2 Conditional sibling comparison
Spring 주석 0개. bean 없음. 비교 대상 없음.
**codec sibling 비교는 가능하고 결과가 유의미하다.**
| codec | `MessageCodec` 구현 | starter 등록 | membership | 정합성 |
|---|:---:|:---:|---|---|
| `JacksonMessageCodec` | o | o | `["app-bootstrap"]` | 일치 |
| `AvroMessageCodec` | o | x | `[]` | **일치** |
| `ProtobufMessageCodec` | o | x | `[]` | 일치 |
| `RawBytesMessageCodec` | o | x | `["app-bootstrap"]`(schema-api 소속) | 불일치 |
Avro는 세 축이 전부 "없음"으로 정렬돼 있다. incubating leaf가 이래야 하는 형태다.
### 12.3 Duplicate mechanism sweep
**(a) 진화 판단 중복 — 확인됨**
`AvroCompatibilityGate`의 private `isTransitive`/`readsBackward`/`readsForward``messaging-schema-api``SchemaCompatibilityValidator`의 public static `isTransitive`/`checksBackward`/`checksForward`와 같은 판단을 다시 구현한다.
| 판단 | schema-api | 이 leaf |
|---|---|---|
| `isTransitive` | public static, 허용목록 | private static, **글자까지 동일한 복사본** |
| 후방 검사 | `checksBackward`, 허용목록 | `readsBackward`, **거부목록** |
| 전방 검사 | `checksForward`, 허용목록 | `readsForward`, **거부목록** |
현재 enum 7개 값에서 두 구현의 결과는 같다(`NONE_EXPERIMENTAL``check:34`의 early return이 가린다). 형태가 반대이므로 enum이 자라면 갈라진다 — 허용목록은 새 모드를 "검사 안 함"으로, 거부목록은 "양방향 검사"로 기본 처리한다.
schema-api의 javadoc이 이 중복을 정확히 예고했다 — "duplicating that reasoning in each codec is how the two formats drift apart". 그리고 그것을 막을 클래스는 호출되지 않는다. 상세는 `analysis/messaging/messaging-schema-api.md` §12.3이 소유한다.
**(b) history 순서 계약이 반대다**
| 위치 | 요구 |
|---|---|
| `SchemaRegistry.history` (schema-api) | **oldest first** |
| `AvroCompatibilityGate.check``history` 파라미터 | **newest first** |
둘을 잇는 코드가 없어 오늘은 충돌하지 않는다. 잇는 순간 `reversed()`를 빠뜨리면 `history.subList(0, 1)`이 가장 오래된 스키마를 "직전 버전"으로 비교한다. 실패하지 않고 **엉뚱한 비교를 통과시킬 수 있다.**
**(c) 크기 예외 통과 패턴이 codec마다 다르다**
| codec | 방식 |
|---|---|
| `JacksonMessageCodec` | `unwrapTooLarge` — 원인 사슬을 끝까지 훑음 |
| `AvroMessageCodec` | `catch` 안에서 즉시 `instanceof` (3곳 반복) |
| `ProtobufMessageCodec` | 해당 없음 — `requireFits`로 사전 거절 |
같은 문제(`BoundedByteSink``MessageTooLargeException`이 포맷 라이브러리 예외에 삼켜지는 것)를 세 가지로 푼다. Jackson은 예외를 감싸므로 사슬 탐색이 필요하고, Avro는 감싸지 않으므로 즉시 검사로 충분하다 — 즉 차이가 라이브러리 동작에서 나온 정당한 것이다. 다만 그 이유가 어디에도 적혀 있지 않다.
**(d) 에러 코드 어휘가 codec마다 다르다**
같은 판단에 다른 문자열:
| 판단 | JSON | Avro | Protobuf |
|---|---|---|---|
| 타입 미등록 | `UNKNOWN_MESSAGE_TYPE` | `AVRO_TYPE_NOT_REGISTERED` | `UNKNOWN_MESSAGE_TYPE` |
| 버전 미등록 | `SCHEMA_VERSION_NOT_REGISTERED` | `AVRO_VERSION_NOT_REGISTERED` | `SCHEMA_VERSION_NOT_REGISTERED` |
| 타입 불일치 | `PAYLOAD_TYPE_MISMATCH` | `AVRO_PAYLOAD_NOT_A_RECORD` / `AVRO_SCHEMA_MISMATCH` | `PAYLOAD_TYPE_MISMATCH` |
JSON과 Protobuf는 어휘를 공유하고 Avro만 접두사를 붙인다. 대시보드가 코드로 집계하면 Avro만 별도 계열이 된다.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `AvroCompatibilityGate` javadoc: "Run in CI rather than at runtime" | 저장소 CI에 호출 지점 없음 | **미실현** — 진술이 틀린 게 아니라 계획이 실행되지 않음 |
| build.gradle 주석: Avro가 public 시그니처에 등장하므로 `api` | 두 클래스의 public 시그니처에 `org.apache.avro.Schema` 등장 확인 | **일치** |
| `docs/messaging/support-matrix.md`: Avro가 Stable이 아님 | membership `[]`, starter 미등록 | **일치** |
| `docs/messaging/support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 실제로 `[]`**이 leaf에 한해서는 맞다** | family 전체로는 틀림(`messaging-core-api` §12.4) |
마지막 행이 흥미롭다. 잘못된 일반화가 우연히 이 leaf에서는 참이 된다. 그래서 이 문서만 읽으면 drift를 발견할 수 없다 — family 수준에서 세야 보인다.
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
테스트 클래스 javadoc이 세 결함을 보존한다.
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `AvroRegistryBoundsTest` javadoc | 중첩 맵에 `Map.copyOf`(얕은 복사) | 호출자가 생성 후 스키마 교체 가능 → Avro는 실패하지 않고 그럴듯한 쓰레기를 만듦 |
| `AvroRegistryBoundsTest` javadoc | `decodeEvolved`에 크기 검사 없음 | producer가 앞서 나간 뒤 **모든 메시지**가 지나는 경로가 무제한 입력을 수용 |
| `AvroHostileInputTest` javadoc | 배열 원소 수 주장을 신뢰하고 할당 | 5바이트로 4억 원소 배열 → `OutOfMemoryError`, codec이 분류할 수 없는 실패, consumer 스레드에서 프로세스 사망 |
| `AvroMessageCodec.decodeEvolved` 주석 | 같은 내용 | — |
세 번째가 형태상 가장 흥미롭다 — **바이트 상한이라는 올바른 도구가 잘못된 공격에 적용되어 있었다.** 테스트 javadoc이 그것을 한 문장으로 적는다: "The byte limit is the wrong instrument for this attack and was the only one in place."
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §B, §D, §E | 두 형태의 진화 판단 나란히, codec별 소비자 0, membership `[]` | 정적 검색 |
| EVD-276 | command | `./gradlew :messaging:messaging-schema-avro:test --rerun-tasks` | BUILD SUCCESSFUL, 16 / 0 / 0 | 실제 Avro 브로커 없음 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- Avro 바이너리에 스키마가 없어 registry가 계약이 되는 이유 — 클래스 javadoc
- single-object encoding에 헤더를 안 붙이는 이유 — 클래스 javadoc
- 얕은 복사가 만든 구멍과 평탄화로 고친 이유 — `flatten` javadoc
- direct encoder를 쓰는 이유 — encode 주석
- 배열 원소 상한을 `maxBytes`로 잡은 논리 — `boundedReader` javadoc
- 적대적 입력 corpus가 좁은 이유 — `AvroHostileInputTest` javadoc
- 게이트가 CI용인 이유 — `AvroCompatibilityGate` javadoc
- Avro를 `api`로 선언한 이유 — build.gradle 주석
**추론**
- 크기 예외 통과 방식이 JSON과 다른 것은 Jackson이 예외를 감싸고 Avro는 감싸지 않기 때문이다 → **추론**. 두 코드의 형태는 관측이고 인과는 추론이다.
- 에러 코드에 `AVRO_` 접두사를 붙인 것이 의도인지 → **미상**.
- 게이트가 `newest first`를 요구하는 것과 port가 `oldest first`인 것 중 어느 쪽이 나중인지 → **미상**. 커밋이 4개뿐이고 둘 다 같은 커밋에 들어왔다.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 두 클래스 345줄 전문의 계약과 방어
- 16개 테스트가 통과하고 무엇을 단언하는지
- 소비자 0과 membership `[]`이 정합적이라는 것
- 진화 판단이 schema-api와 중복이고 형태가 반대라는 것
- `history` 순서 계약이 schema-api와 반대라는 것
- CI 실행을 전제한 게이트를 부르는 CI task가 없다는 것
**확인하지 못한 것**
- **transitive 모드의 실제 동작.** 테스트가 `BACKWARD`만 쓴다. `history` 전체 순회 분기가 실행된 적이 없다.
- 파생 프로젝트가 `AvroCompatibilityGate`를 자기 CI에서 부르는지. 저장소 안에 확인 수단이 없다.
- 실제 Avro 스키마 진화 사례에서 `checkReaderWriterCompatibility`의 판정이 이 게이트의 방향 매핑과 맞는지 — 테스트는 defaulted 필드 추가/미추가 두 경우만 본다.
- `decodeEvolved`가 실제 다중 버전 배포에서 어떤 빈도로 쓰이는지. 소비자가 없어 관측할 수 없다.
---
## 17. 손볼 것
### P2 — CI에서 돈다고 선언한 게이트를 부르는 CI가 없다
- **사실.** `AvroCompatibilityGate` javadoc이 "Run in CI rather than at runtime"이라고 선언한다. 저장소 전체에서 이 클래스 참조는 자기 선언과 자기 테스트뿐이고, `src/build.gradle`의 9개 `verifyMessaging*` task 중 스키마 진화를 검사하는 것이 없다.
- **근거.** `evidence/raw/272` §D. `src/build.gradle:65-110`.
- **왜 문제인가.** 게이트의 존재 이유가 "한 번 발행되면 보존 로그에 영구히 남는다"인데, 그 보호가 어느 파이프라인에도 붙어 있지 않다. `AvroMessageCodec`의 미사용과 달리 이것은 membership으로 설명되지 않는다 — 런타임 편입 여부와 무관하게 CI 게이트는 붙었어야 한다.
- **확인 방법.** `git grep -n -w AvroCompatibilityGate -- src` · `git grep -n 'verifyMessaging' -- src/build.gradle`
- **후보.** (a) 스키마 디렉터리를 읽어 게이트를 돌리는 Gradle task를 만든다. (b) 파생 프로젝트가 붙이는 확장점이라면 javadoc이 그렇게 말하도록 고친다.
- **다음 단계.** **CASE 후보.** "장치는 있고 회로가 닫히지 않았다"의 전형이고, 재현이 정적 검색으로 끝난다.
### P2 — 진화 판단이 두 곳에 있고 형태가 반대다
- **사실.** `isTransitive``SchemaCompatibilityValidator`(public static)와 이 leaf(private static)에 글자까지 같은 복사본이 있다. 방향 판정은 전자가 허용목록, 후자가 거부목록이다.
- **근거.** `evidence/raw/272` §B에 두 형태가 나란히 출력된다.
- **왜 문제인가.** 오늘 7개 모드에서 결과는 같지만 형태가 반대이므로 `SchemaCompatibility`에 값이 추가되는 순간 갈라진다 — 허용목록은 "검사 안 함", 거부목록은 "양방향 검사". 그리고 이 중복은 schema-api의 javadoc이 명시적으로 막으려던 것이다.
- **확인 방법.** `evidence/raw/272` §B 재실행.
- **후보.** `AvroCompatibilityGate``SchemaCompatibilityValidator`의 public static을 부르게 한다. 세 메서드 다 이미 public static이다.
- **다음 단계.** `messaging-schema-api` §17의 같은 항목과 **동일 사건**이다. 그 leaf가 소유하고 여기서는 교차 참조만 남긴다.
### P3 — `history` 순서 계약이 port와 게이트에서 반대다
- **사실.** `SchemaRegistry.history` javadoc은 oldest first, `AvroCompatibilityGate.check``@param history`는 newest first.
- **근거.** 두 javadoc.
- **왜 문제인가.** 둘을 잇는 코드가 없어 지금은 무해하다. 이으면서 `reversed()`를 빠뜨리면 pairwise 모드가 **가장 오래된** 스키마를 직전 버전으로 비교한다. 실패하지 않고 통과할 수 있는 오류다. port javadoc이 이미 같은 위험을 경고한다 — "an ordering mistake here silently converts a transitive check into a pairwise one."
- **확인 방법.** 두 javadoc 대조.
- **후보.** 게이트도 oldest-first를 받게 통일하고 내부에서 뒤집는다.
- **다음 단계.** **REFERENCE 후보**(컬렉션 순서가 계약이면 양쪽에서 같은 방향으로 적는다).
### P3 — transitive 분기가 테스트되지 않는다
- **사실.** `AvroCompatibilityTest`의 게이트 호출 2건이 모두 `SchemaCompatibility.BACKWARD`다. `isTransitive`가 true인 경로가 실행되지 않는다.
- **근거.** `AvroCompatibilityTest.java:134-150`.
- **왜 문제인가.** transitive 모드는 "여러 릴리스 뒤처진 consumer"를 위한 것이고 그것이 이 게이트의 존재 이유 중 절반이다. 그리고 그 분기가 §12.3의 중복 구현이 갈라질 지점이다.
- **확인 방법.** 두 테스트의 모드 인자 확인.
- **후보.** v1·v2·v3 세 스키마로 `BACKWARD_TRANSITIVE` 케이스를 추가한다.
- **다음 단계.** **REFERENCE 후보**(모드 enum을 분기 조건으로 쓰면 각 분기에 테스트를 둔다).
### P3 — 에러 코드 어휘가 형제 codec과 갈라진다
- **사실.** 같은 판단에 JSON/Protobuf는 `UNKNOWN_MESSAGE_TYPE`·`SCHEMA_VERSION_NOT_REGISTERED`, Avro는 `AVRO_TYPE_NOT_REGISTERED`·`AVRO_VERSION_NOT_REGISTERED`를 쓴다.
- **근거.** 세 codec의 `requireRegistered`/`schemaFor`.
- **왜 문제인가.** `FailureDescriptor.code`는 "stable, machine-readable code"이고 대시보드·재시도 정책이 이것으로 집계한다. 같은 판단이 두 어휘로 나뉘면 Avro만 별도 계열이 된다.
- **확인 방법.** `git grep -n 'NOT_REGISTERED' -- 'src/messaging/**/*.java'`
- **후보.** 공통 코드를 쓰고 포맷은 `sanitizedMessage`로 구분한다.
- **다음 단계.** **REFERENCE 후보**(안정 코드는 판단 단위로 정하고 구현 단위로 정하지 않는다).
### 확인된 설계(문제 아님)
- 중첩 registry를 `(type, version)`으로 평탄화해 양쪽 레벨을 복사하는 것
- direct encoder 선택 — `BoundedByteSink`의 경계가 실제로 작동하기 위한 전제
- 배열 원소 상한을 별도 튜닝 값이 아니라 `maxBytes`에서 파생시킨 것
- `decodeEvolved`에 같은 상한을 적용한 것과, 그것을 두 각도에서 붙드는 테스트
- 적대적 입력 corpus를 좁게 두고 그 이유를 적은 것
- Avro를 `api`로 선언한 것(형제 JSON과 반대 판정이고, 그것이 맞다)
- 소비자 0과 membership `[]`이 정합적인 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MSV-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, `runtime_memberships: []` | 선언 |
| MSV-002 | build | `messaging-schema-avro/build.gradle` | same | Avro `api` 선언과 그 이유, 버전 1.12.0 | — |
| MSV-003 | code | `.../avro/AvroMessageCodec.java` 전문 | same | §4.14.6 | — |
| MSV-004 | code | `.../avro/AvroCompatibilityGate.java` 전문 | same | §4.7, §12.3(a) | — |
| MSV-005 | test | `AvroCompatibilityTest` (8) | same | round trip·진화·게이트 pairwise | transitive 미검증 |
| MSV-006 | test | `AvroHostileInputTest` (4) | same | 5바이트 4억 원소 공격과 방어, 진화 경로 동일 방어 | 문자열·맵은 범위 밖(javadoc이 이유를 적음) |
| MSV-007 | test | `AvroRegistryBoundsTest` (4) | same | 생성 후 맵 변경 무효, 인코딩 중 거절, 진화 경로 상한 | — |
| MSV-008 | cross-leaf code | `messaging-schema-api/.../SchemaCompatibilityValidator.java:79-112` | same | 중복의 다른 쪽 | 해당 leaf SSOT가 소유 |
| MSV-009 | cross-leaf code | `messaging-schema-api/.../SchemaRegistry.java:16-17` | same | oldest-first 계약 | 해당 leaf SSOT가 소유 |
| MSV-010 | cross-leaf code | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:360-366` | same | codec registry에 Avro 미등록 | 해당 leaf SSOT가 소유 |
| MSV-011 | build policy | `src/build.gradle:65-110`, `src/messaging/CLAUDE.md:40-43` | same | `verifyMessaging*` 9개가 스키마 진화를 부르지 않음, vendor `api` 규칙 | — |
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | §12.1·§12.3 | 정적 검색 |
| EVD-276 | command | `./gradlew :messaging:messaging-schema-avro:test --rerun-tasks` | same | 16 / 0 / 0 | 실제 브로커 없음 |