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

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>`이고, `Map.copyOf`는 **바깥 레벨만** 복사한다. ```java // AvroMessageCodec.java:78-82 *

{@code Map.copyOf} on the outer map is a shallow copy: every inner {@code Map} 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 *

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

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 *

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 *

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 history, SchemaCompatibility mode) { if (mode == SchemaCompatibility.NONE_EXPERIMENTAL || history.isEmpty()) return; List 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 | 실제 브로커 없음 |