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>
598 lines
36 KiB
Markdown
598 lines
36 KiB
Markdown
# 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.1–4.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 | 실제 브로커 없음 |
|