{ "schema_version": "1.0", "document": "/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md", "document_sha256": "8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d", "line_count": 47035, "line_number_space": "canonical-source-with-managed-blocks-collapsed", "anchor": { "kind": "line", "value": 35416, "line": 35416 }, "current_section": { "heading": { "line": 35416, "level": 3, "text": "messaging-schema-api 완전 해부" }, "start_line": 35416, "end_line": 35965, "text": "### messaging-schema-api 완전 해부\n\n> 상태: COMPLETE\n> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n> 분석 범위: `src/messaging/messaging-schema-api`\n> SSOT owner: `messaging-schema-api`\n> integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n\n> **성격.** 읽기 기록이다. 이 leaf가 선언한 codec/schema 계약과, 그 중 무엇이 실제로 호출되는지를 source anchor와 함께 적는다.\n\n---\n\n#### 0. SSOT identity / 커버리지와 숫자 지도\n\n- registered leaf id: `messaging-schema-api`\n- canonical state `analysisFile`: §A19-MESSAGING-SCHEMA-API\n- source path: `src/messaging/messaging-schema-api`\n- leaf-owned subdocuments: 없음\n- registry `allowed_dependencies`: `[\"messaging-core-api\"]`\n- registry `runtime_memberships`: `[\"app-bootstrap\"]`\n\n##### 숫자\n\n| 항목 | 수 |\n|---|---:|\n| production Java 파일 | 10 |\n| production LOC | 630 |\n| 패키지 | 1 (`dev.caskeleton.messaging.schema`) |\n| test 파일 | 3 |\n| test 메서드(실행 확인) | 19 |\n| 외부(비프로젝트) 의존성 | **0** |\n\n10개 타입의 성격:\n\n| 타입 | 종류 | 역할 |\n|---|---|---|\n| `MessageCodec` | interface | 한 wire 포맷의 인코딩/디코딩 |\n| `MessageCodecRegistry` | interface | content type → codec, 그리고 기본 codec |\n| `SchemaRegistry` | interface | subject/version → schema, 그리고 compatibility mode |\n| `MessageContractKey` | record | `(MessageType, SchemaVersion)` — registry 키 |\n| `SchemaReference` | record | subject + version + 선택적 URI |\n| `EncodedMessage` | record | 바이트 + content type + schema reference |\n| `SchemaCompatibility` | enum(7) | 진화 모드 |\n| `SchemaCompatibilityValidator` | class | 포맷 독립 진화 규칙 |\n| `BoundedByteSink` | class | 한도 초과 바이트를 **쓰기 시점에** 거절하는 OutputStream |\n| `RawBytesMessageCodec` | class | 스키마 없는 M2 escape hatch |\n\n##### Coverage ledger\n\n| scope/file group | count | disposition | reason |\n|---|---:|---|---|\n| `src/main/java/**` (10) | 10 | `FULL_READ` | 전 파일 본문 확인 |\n| `src/test/java/**` (3) | 3 | `FULL_READ` | 전 파일 본문 확인 |\n| `build.gradle` | 1 | `FULL_READ` | 5줄 |\n| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |\n| `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n\n`UNCLASSIFIED` 0.\n\n---\n\n#### 1. 모듈의 정체와 경계\n\n이 leaf는 **\"바이트를 어떻게 만들고 읽는가\"의 계약**을 소유한다. 실제 포맷 구현은 갖지 않는다 — 단 하나의 예외가 `RawBytesMessageCodec`이고, 그것은 포맷이 아니라 포맷의 부재를 구현한다.\n\n경계 규칙 하나가 모든 곳에 반복된다: **codec은 닫힌 registry에 대해서만 동작한다.**\n\n```java\n// MessageCodec.java:10-12\n *
Implementations operate against a closed message-type registry. Accepting an unregistered type\n * would let a producer introduce a wire contract nothing has reviewed, which is the same class of\n * problem that makes Java serialization unsupported here.\n```\n\n`build.gradle`는 `api project(':messaging:messaging-core-api')` 하나뿐이고 vendor 의존성이 없다. 포맷별 vendor(`jackson`, `avro`, `protobuf`)는 각자 leaf가 갖는다.\n\n---\n\n#### 2. 의존성과 런타임 배선\n\n들어오는 것: `messaging-core-api`(api 노출).\n\n나가는 것: `messaging-schema-json`, `messaging-schema-avro`, `messaging-schema-protobuf`, `messaging-cloudevents`, `messaging-policy`, `messaging-transport-spi`, `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`, `messaging-testkit`.\n\n런타임 편입은 `messaging-core-api`와 같은 경로다 — `app-bootstrap`이 `messaging-spring-boot-starter`를 선언하고 그 closure가 이 leaf를 끌어온다.\n\n이 leaf는 bean을 만들지 않는다. Spring 주석 0개.\n\n---\n\n#### 3. 패키지/컴포넌트 지도\n\n패키지 하나에 10개 타입이 평평하게 있다. 관심사로 나누면 셋이다.\n\n```\ncodec 축 MessageCodec ── MessageCodecRegistry\n │\n └── RawBytesMessageCodec (유일한 구현)\n\n식별 축 MessageContractKey (type, version)\n SchemaReference (subject, version, uri?)\n EncodedMessage (bytes, contentType, schemaReference?)\n\n진화 축 SchemaRegistry ── SchemaCompatibility(7)\n │\n └── SchemaCompatibilityValidator\n\n경계 축 BoundedByteSink\n```\n\n---\n\n#### 4. 계약·불변식·상태 모델\n\n##### 4.1 `MessageContractKey`: 버전을 키에 넣는 이유\n\n이 leaf에서 가장 밀도 높은 javadoc이다.\n\n```java\n// MessageContractKey.java:10-17\n *
Keying on the message type alone is what let an unregistered version decode. The version\n * travels in the envelope and in {@link SchemaReference}, so a consumer receiving {@code\n * order.created v999} would look up {@code order.created}, find the v1 class or parser, decode\n * against it, and then keep the v999 label on the result. Nothing failed, and every downstream\n * compatibility gate and audit record then described a version that was never registered.\n```\n\n핵심은 \"Nothing failed\"다. 타입만으로 키를 잡으면 실패가 발생하지 않고 **잘못된 성공**이 발생한다. 그리고 그 결과에는 등록된 적 없는 버전 라벨이 붙어 하위 감사 기록까지 오염된다.\n\n이 결정은 세 codec에 전부 반영돼 있다 — `JacksonMessageCodec.requireRegistered`, `AvroMessageCodec.schemaFor`, `ProtobufMessageCodec.requireRegistered`가 모두 \"타입은 아는데 버전을 모른다\"와 \"타입 자체를 모른다\"를 **다른 에러 코드**로 구분한다(`SCHEMA_VERSION_NOT_REGISTERED` vs `UNKNOWN_MESSAGE_TYPE`). 그 구분이 있어야 운영자가 \"등록을 빠뜨렸다\"와 \"오타다\"를 나눌 수 있다.\n\n##### 4.2 `BoundedByteSink`: 보고 임계값 → 할당 경계\n\n```java\n// BoundedByteSink.java:11-15\n *
Every codec here used to serialize into an unbounded buffer and compare {@code bytes.length}\n * to the configured maximum afterwards. That makes the maximum a reporting threshold rather than an\n * allocation bound: a payload whose graph expands to hundreds of megabytes exhausts the heap while\n * being written, and the check that would have rejected it never runs. Under a broker consumer that\n * is a process-wide outage caused by one message.\n```\n\n세 가지 설계 결정이 붙어 있다.\n\n1. **버퍼를 한도로 미리 잡지 않는다.** `new ByteArrayOutputStream(Math.min(maxBytes, 8_192))` — 주석: \"a 1 GiB bound must not pre-allocate 1 GiB.\"\n2. **codec의 에러 코드를 그대로 던진다.** `errorCode`가 생성자 인자다. 그래서 Avro는 `AVRO_PAYLOAD_TOO_LARGE`, JSON은 `PAYLOAD_TOO_LARGE`가 나온다. 테스트가 이 성질을 직접 단언한다(`BoundedByteSinkTest.java:69-77`, `as(\"the sink reports the codec's own code, not a generic one\")`).\n3. **`requireFits(size)`는 예산을 소비하지 않는다.** Protobuf는 직렬화 크기를 미리 알므로 첫 바이트 전에 거절할 수 있다. 그리고 그 뒤의 쓰기도 여전히 경계 안이다 — 주석: \"this is a cheaper refusal, not a replacement for the bound.\"\n\n`refuseIfBeyondLimit`가 `size > maxBytes - written`으로 비교하는 것도 의도적이다. `written + size > maxBytes`였다면 `int` 오버플로가 가능하다.\n\n테스트가 실제 시나리오를 재현한다 — 10 MiB를 1 KiB씩 제공하고, `written()`이 한도(64) 이하로 유지되며 `toByteArray()`가 비어 있음을 확인한다(`BoundedByteSinkTest.java:34-53`).\n\n##### 4.3 `EncodedMessage`: 양방향 방어 복사\n\n```java\npublic EncodedMessage {\n ...\n bytes = bytes.clone(); // 생성 시\n}\n\n@Override\npublic byte[] bytes() {\n return bytes.clone(); // 접근 시\n}\n```\n\njavadoc이 이유를 적는다 — \"These bytes travel through retry, DLQ, and redrive paths where a shared mutable array would let one stage corrupt another's copy of the same logical message.\"\n\n`equals`/`hashCode`는 `Arrays.equals`/`Arrays.hashCode`로 재정의된다(record 기본은 배열 참조 비교라 항상 불일치). `toString`은 바이트를 찍지 않고 크기만 찍는다 — payload가 로그에 새지 않는다.\n\n`size()`가 복사 없이 길이를 반환하는 별도 메서드로 있는 것도 의도적이다. `bytes().length`는 전체 복사를 유발한다.\n\n##### 4.4 `SchemaCompatibility`: 7개 모드와 transitive의 의미\n\n```java\n// SchemaCompatibility.java:6-8\n *
Transitive modes check every historical version, not just the immediate predecessor. That\n * matters for integration events, where a consumer may be several releases behind and a chain of\n * individually-compatible changes can still be collectively breaking.\n```\n\n`NONE_EXPERIMENTAL`은 \"M2 raw bytes에만 허용\"이라고 enum 상수 javadoc이 적는다.\n\n##### 4.5 `SchemaRegistry`: 포트이고, 순서가 계약이다\n\n```java\n// SchemaRegistry.java:16-17\n *
{@link #history} returns oldest first. Transitive compatibility checks read the whole list, so\n * an ordering mistake here silently converts a transitive check into a pairwise one.\n```\n\n이것은 문서화된 함정이다. `history`가 newest-first로 구현되면 `versionsToCheck`가 `reversed()`한 뒤 `history.get(0)`을 취하므로 **가장 오래된 버전 하나**만 비교하게 된다 — transitive가 pairwise로 조용히 축소되는 것이 아니라 아예 엉뚱한 버전을 비교한다.\n\n`latest(subject)`가 default 메서드로 `versions.get(versions.size() - 1)`인 것도 같은 순서 계약에 의존한다. 테스트가 이 성질을 직접 단언한다(`SchemaCompatibilityValidatorTest.theLatestVersionIsTheNewestNotTheFirstListed`).\n\nport로 둔 이유도 적혀 있다 — \"A hosted registry, a classpath directory of schema files, and a static in-process map are all legitimate sources … Binding to a vendor client here would make the rules untestable without that vendor running.\"\n\n##### 4.6 `SchemaCompatibilityValidator`: 포맷 독립 규칙\n\n두 가지를 한다.\n\n**(a) 비교할 버전 목록**\n\n```java\npublic List Split from the per-format gates on purpose. Whether v3 must be checked against v1 as well as\n * v2 is a property of the compatibility mode, not of Avro or Protobuf, and duplicating that\n * reasoning in each codec is how the two formats drift apart.\n```\n\n§12.1과 §12.3이 이 문장을 다시 다룬다.\n\n##### 4.7 `RawBytesMessageCodec`: 부재를 구현한다\n\n```java\n// RawBytesMessageCodec.java:12-16\n * It still enforces the byte limit, and it is deliberately excluded from default codec\n * selection: schema-free publishing has to be an explicit, auditable choice per destination, never\n * something a destination falls back to because its codec was misconfigured.\n```\n\n`encode`는 `byte[]`가 아닌 payload를 `MessageSerializationException(\"RAW_BYTES_PAYLOAD_REQUIRED\")`로 거절하고, `decode`는 `byte[].class`가 아닌 대상을 `RAW_BYTES_TARGET_REQUIRED`로 거절한다. `decode`는 `encoded.clone()`을 반환한다 — 호출자가 원본을 건드릴 수 없다.\n\n`DEFAULT_MAX_BYTES = 1_048_576`(1 MiB)은 세 Stable codec이 공유하는 값이다.\n\n**주의:** 이 codec은 `BoundedByteSink`를 쓰지 않는다. 이미 `byte[]`를 받으므로 스트리밍 경계가 의미 없고, `bytes.length > maxBytes` 비교로 충분하다. 다른 codec에서는 그 비교가 §4.2가 지적하는 \"보고 임계값\"이지만 여기서는 할당이 이미 끝난 입력이라 성격이 다르다.\n\n---\n\n#### 5. 주요 실행 경로\n\n세 개다.\n\n1. **경계 있는 인코딩** — codec이 `BoundedByteSink.of(maxBytes, code)`를 만들고 → 포맷 라이브러리가 sink에 쓰고 → 한도를 넘는 write에서 `MessageTooLargeException` → 아니면 `sink.toByteArray()`로 `EncodedMessage` 조립\n2. **계약 조회** — `new MessageContractKey(type, version)` → registry lookup → 미스면 \"타입 미등록\" vs \"버전 미등록\" 구분\n3. **진화 검사** — `registry.compatibilityOf(subject)` → `versionsToCheck` → (포맷별 게이트가 실제 비교)\n\n3번은 이 저장소에서 실행되지 않는다(§12.1).\n\n---\n\n#### 6. 실패 경로와 복구/번역\n\n이 leaf가 던지는 예외는 셋이고 전부 `messaging-core-api` 소유다.\n\n| 예외 | 코드 | 조건 |\n|---|---|---|\n| `MessageTooLargeException` | codec별(`PAYLOAD_TOO_LARGE`, `AVRO_PAYLOAD_TOO_LARGE`, …) | sink 한도 초과 |\n| `MessageTooLargeException` | `RAW_BYTES_TOO_LARGE` | raw codec 한도 초과 |\n| `MessageSerializationException` | `RAW_BYTES_PAYLOAD_REQUIRED` / `RAW_BYTES_TARGET_REQUIRED` | 타입 불일치 |\n| `MessageSchemaIncompatibleException` | `UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION` | `NONE_EXPERIMENTAL`이 production 목적지에 |\n\n`IllegalArgumentException`도 던진다 — `BoundedByteSink` 생성자의 `maxBytes < 1`, `requireFits`의 음수, `SchemaReference`의 빈 subject. 이들은 **호출자의 프로그래밍 오류**이고 메시지 실패가 아니므로 `MessagingException` 계층 밖인 것이 일관적이다.\n\n---\n\n#### 7. 트랜잭션·동시성·수명주기\n\n트랜잭션 없음.\n\n동시성: `BoundedByteSink`가 **의도적으로 thread-safe가 아니다.** javadoc이 명시한다 — \"Not thread-safe, and not meant to be: an instance belongs to a single encode call.\" 실제로 codec들이 매 `encode` 호출마다 새로 만든다.\n\n`EncodedMessage`, `MessageContractKey`, `SchemaReference`는 불변이다. `SchemaCompatibilityValidator`는 registry 참조만 갖고 상태가 없다.\n\n`MessageCodecRegistry`/`SchemaRegistry` 구현의 스레드 안전성은 이 leaf가 규정하지 않는다 — port javadoc에 그에 대한 요구가 없다. 이것은 §17의 P3 항목이다.\n\n---\n\n#### 8. 설정·기능 플래그·환경 차이\n\n설정 없음. 상수 하나:\n\n| 상수 | 값 | 위치 |\n|---|---:|---|\n| `RawBytesMessageCodec.DEFAULT_MAX_BYTES` | 1,048,576 | `RawBytesMessageCodec.java:21` |\n\n`BoundedByteSink`의 초기 버퍼 상한 8,192는 private다.\n\n---\n\n#### 9. 퍼시스턴스/외부 시스템 세부\n\n없다. `SchemaRegistry`가 외부 registry를 가리킬 수 있는 port지만, 이 leaf에는 구현이 없다.\n\n---\n\n#### 10. 테스트 레인과 실제 증명 범위\n\n레인: `./gradlew :messaging:messaging-schema-api:test`. **BUILD SUCCESSFUL, 19 tests, 0 skipped, 0 failures** (`--rerun-tasks`, revision `21234e38`).\n\n| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |\n|---|---:|---|---|\n| `BoundedByteSinkTest` | 4 | 한도 포함/초과 경계, 10 MiB 스트림이 한도에서 멈춤, pre-flight가 예산을 안 먹음, codec 에러 코드 전달 | 실제 codec들이 이 sink를 쓰는지(각 codec leaf가 소유) |\n| `RawBytesMessageCodecTest` | 6 | round trip, content type, 비-byte[] 거절 양방향, 한도, `EncodedMessage` 방어 복사 | — |\n| `SchemaCompatibilityValidatorTest` | 9 | pairwise vs transitive 목록, `NONE_EXPERIMENTAL` 빈 목록, 빈 history, production 게이트 양방향, `checksBackward`/`checksForward` 조합, `latest`가 newest | **production 코드가 이 validator를 호출하는지** |\n\n마지막 칸이 핵심이다. `SchemaCompatibilityValidatorTest`는 9개 단언으로 규칙을 정확히 고정하지만, §12.1이 보이듯 그 규칙을 실행 경로에서 부르는 코드가 없다. 테스트는 **규칙이 옳다**를 증명하고 **규칙이 적용된다**를 증명하지 않는다.\n\n테스트가 쓰는 `FixedRegistry`는 `SchemaRegistry`의 유일한 구현이다(production 구현 0개, §12.1).\n\n---\n\n#### 11. 빌드/ArchUnit/CI 강제 지점\n\n| 게이트 | 이 leaf에 대해 |\n|---|---|\n| registry fail-closed | 등록됨 |\n| `verifyCleanArchitectureDependencies` | `allowed_dependencies: [\"messaging-core-api\"]`와 실제 project edge 대조 |\n| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |\n| `src/messaging/CLAUDE.md`의 vendor `api` 규칙 | 이 leaf는 vendor 의존성이 없으므로 대상 없음 |\n| ArchUnit | 이 leaf 전용 규칙 없음 |\n\n`src/messaging/CLAUDE.md:40-43`이 기술하는 게이트 — \"source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 `build.gradle`이 `api`로 선언했는지 대조\" — 는 이 leaf에서 확인할 것이 없다. 형제 leaf(`schema-avro`, `schema-protobuf`, `cloudevents`)는 이 규칙 때문에 vendor를 `api`로 선언했고 build.gradle 주석이 그 이유를 적는다.\n\n---\n\n#### 12. 실제 사용 여부와 negative-space probes\n\n원시 증거: `evidence/raw/272-schema-family-reachability.txt`.\n\n##### 12.1 Public surface reachability\n\nleaf 밖 참조를 파일 수로 세면:\n\n| 타입 | leaf 밖 파일 수 | 판정 |\n|---|---:|---|\n| `EncodedMessage` | 50 | 널리 쓰임 — 사실상 이 leaf의 주력 수출품 |\n| `SchemaCompatibility` | 15 | 세 codec leaf + policy가 씀 |\n| `MessageContractKey` | 7 | 세 codec leaf가 씀 |\n| `MessageCodec` | 7 | 세 codec + runtime-core |\n| `MessageCodecRegistry` | 5 | runtime-core가 구현 |\n| `SchemaReference` | 4 | codec들이 만듦 |\n| `BoundedByteSink` | 3 | JSON·Avro·Protobuf codec |\n| `RawBytesMessageCodec` | **0** | 자기 테스트만 |\n| `SchemaCompatibilityValidator` | **0** | 자기 테스트만 |\n| `SchemaRegistry` | **0** | 아래 참조 |\n\n**`SchemaRegistry`의 \"0\"은 확인이 필요했다.** 단순 이름 검색은 2개 파일을 맞췄지만 둘 다 다른 타입이다:\n\n```\nsrc/adapter/outbound/messaging/.../LocalJsonSchemaRegistry.java:6: import com.networknt.schema.SchemaRegistry;\nsrc/adapter/outbound/notification/.../JsonSchemaVariableValidator.java:5: import com.networknt.schema.SchemaRegistry;\n```\n\n`import dev.caskeleton.messaging.schema.SchemaRegistry` 검색은 exit 1이다. 즉 **이 플랫폼의 `SchemaRegistry` port를 import하는 파일이 저장소에 하나도 없다.** 이름 충돌이 우연히 검색을 오염시킨 사례이고, `-w` 단어 매칭만으로 reachability를 판정하면 안 되는 이유이기도 하다.\n\n**`SchemaCompatibilityValidator`의 \"0\"이 이 leaf에서 가장 무거운 사실이다.** 검색 결과 전체가 자기 선언과 자기 테스트다. 다시 말해:\n\n- 어떤 버전들을 비교해야 하는가 → 아무도 묻지 않는다\n- `NONE_EXPERIMENTAL`이 production 목적지를 뒷받침할 수 있는가 → 아무도 묻지 않는다\n\n`requireProductionMode`는 \"retained log outlives every consumer\"라는 이유로 만들어졌고, 그 게이트가 호출되는 지점이 없다.\n\n`RawBytesMessageCodec`의 \"0\"은 성격이 다르다. 이 클래스가 없어도 그 **규칙**은 살아 있다 — §12.2 참조.\n\n##### 12.2 Conditional sibling comparison\n\nSpring 주석 0개이므로 bean 활성화 비대칭은 없다.\n\n대신 이 leaf에는 **다른 형태의 sibling 비대칭**이 있고 결과가 좋다. `RawBytesMessageCodec`의 javadoc이 \"deliberately excluded from default codec selection\"이라고 선언하는 규칙을, 실제로 강제하는 코드는 다른 leaf에 있다:\n\n```java\n// messaging-runtime-core/RegisteredMessageCodecs.java:52-56\nif (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) {\n throw new IllegalArgumentException(\n \"the raw bytes codec must not be the default: every destination that has not declared an \"\n + \"encoding would silently skip schema validation\");\n}\n```\n\n**클래스가 아니라 content type으로 판정한다.** 그래서 `RawBytesMessageCodec`을 아무도 쓰지 않아도, 그리고 누가 `ContentType.OCTET_STREAM`을 내놓는 다른 codec을 새로 만들어도 규칙이 유지된다. 선언된 규칙과 강제하는 코드가 다른 leaf에 있으면서 **강제 쪽이 더 넓은** 드문 경우다. 결함이 아니라 확인된 설계로 기록한다.\n\n##### 12.3 Duplicate mechanism sweep\n\n**`SchemaCompatibilityValidator`가 막으려던 중복이 실제로 존재한다.**\n\n`AvroCompatibilityGate`(다른 leaf)가 같은 판단을 private static으로 다시 구현했다.\n\n| 판단 | schema-api (`SchemaCompatibilityValidator`) | schema-avro (`AvroCompatibilityGate`) |\n|---|---|---|\n| transitive인가 | `mode == BACKWARD_TRANSITIVE \\|\\| FORWARD_TRANSITIVE \\|\\| FULL_TRANSITIVE` (:107-112) | **같은 식을 그대로** (:49-53) |\n| 후방 검사하나 | `mode == BACKWARD \\|\\| BACKWARD_TRANSITIVE \\|\\| FULL \\|\\| FULL_TRANSITIVE` — **허용목록** (:79-85) | `mode != FORWARD && mode != FORWARD_TRANSITIVE` — **거부목록** (:55-57) |\n| 전방 검사하나 | `mode == FORWARD \\|\\| FORWARD_TRANSITIVE \\|\\| FULL \\|\\| FULL_TRANSITIVE` — **허용목록** (:93-99) | `mode != BACKWARD && mode != BACKWARD_TRANSITIVE` — **거부목록** (:59-61) |\n\n`isTransitive`는 글자까지 동일한 복사본이다. 방향 판정 둘은 **형태가 반대**다.\n\n현재 enum 7개 값에 대해 두 구현의 결과를 대조하면 일치한다. `NONE_EXPERIMENTAL`만 다른데(validator는 둘 다 false, gate는 둘 다 true) `AvroCompatibilityGate.check:34`가 그 모드에서 먼저 return하므로 가려진다.\n\n**문제는 오늘의 불일치가 아니라 형태다.** 허용목록은 새 모드가 추가되면 \"검사 안 함\"으로 기본값이 잡히고, 거부목록은 \"양방향 검사\"로 잡힌다. `SchemaCompatibility`에 값이 하나 추가되는 순간 두 구현은 **반대 방향으로** 갈라진다. javadoc이 예고한 \"how the two formats drift apart\"가 바로 이 형태이고, 그것을 막으려고 만든 클래스는 §12.1에서 보듯 호출되지 않는다.\n\n`isTransitive`는 `SchemaCompatibilityValidator`에서 **public static**이다. Avro 게이트가 그것을 부를 수 있었고 부르지 않았다.\n\n##### 12.4 Documentation / measured-count drift\n\n이 leaf를 직접 이름으로 언급하는 문서 주장을 재측정했다.\n\n| 문서 주장 | 재측정 | 결과 |\n|---|---|---|\n| 계획 문서: codec은 닫힌 registry에 대해 동작 | `MessageCodec` javadoc + 세 구현의 `requireRegistered`/`schemaFor` | **일치** |\n| `RawBytesMessageCodec` javadoc: 기본 codec 선택에서 제외됨 | `RegisteredMessageCodecs.of` 생성자 검사 | **일치**(더 넓게 강제) |\n| `SchemaRegistry` javadoc: history는 oldest-first | 유일한 구현이 테스트 fixture이고 그 계약을 지킴 | 일치하나 production 구현 없음 |\n\n§12.4의 family 전체 drift(`support-matrix.md:23`의 runtime membership 주장)는 §A19-MESSAGING-CORE-API §12.4가 소유한다. 이 leaf도 그 18개 wired 목록에 포함된다.\n\n---\n\n#### 13. Git/설계 문서에서 확인한 변화와 실패 기록\n\n코드 주석이 보존한 이전 결함:\n\n| 위치 | 이전 상태 | 그것이 만든 실패 |\n|---|---|---|\n| `BoundedByteSink` javadoc | 각 codec이 무제한 버퍼에 직렬화 후 길이 비교 | 한도가 **보고 임계값**일 뿐 할당 경계가 아님 → 팽창하는 payload 하나가 consumer 프로세스를 죽임 |\n| `MessageContractKey` javadoc | 타입만으로 registry 키 | v999가 v1 클래스로 디코딩되고 v999 라벨을 유지 → 하위 게이트·감사 기록이 등록된 적 없는 버전을 서술 |\n\n두 사례 다 형태가 같다 — **검사가 없었던 게 아니라 검사의 위치/키가 틀렸다.** `messaging-core-api` §13의 \"문자 vs 바이트, 정확일치 vs 세그먼트\" 목록과 같은 계열이다.\n\n---\n\n#### 14. 런타임·터미널 Evidence\n\n| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |\n|---|---|---|---|---|\n| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | `SchemaCompatibilityValidator` 호출자 전무, allowlist/denylist 두 형태 나란히, `SchemaRegistry` port import 0(exit=1)과 이름 충돌, codec별 소비자 | 정적 `git grep` |\n| EVD-274 | command | `./gradlew :messaging:messaging-schema-api:test --rerun-tasks` | BUILD SUCCESSFUL, 19 / 0 / 0 | 순수 단위 레인 |\n\n---\n\n#### 15. 명시적 설계 이유와 추론을 구분한 정리\n\n**명시적**\n\n- 버전을 registry 키에 넣는 이유 — `MessageContractKey` javadoc\n- 할당 경계 vs 보고 임계값 — `BoundedByteSink` javadoc\n- codec 에러 코드를 sink에 넘기는 이유 — `BoundedByteSink` javadoc + 테스트 `as(...)`\n- 포맷 독립 규칙을 분리한 이유 — `SchemaCompatibilityValidator` javadoc\n- `NONE_EXPERIMENTAL`을 production에서 막는 이유 — 같은 javadoc\n- `SchemaRegistry`를 port로 둔 이유, history 순서가 계약인 이유 — `SchemaRegistry` javadoc\n- raw codec을 기본에서 제외하는 이유 — `RawBytesMessageCodec` javadoc + `RegisteredMessageCodecs` javadoc\n- `EncodedMessage` 양방향 복사 이유 — `EncodedMessage` javadoc\n\n**추론**\n\n- `SchemaCompatibilityValidator`가 미호출인 것은 이 저장소에 schema registry를 실제로 운영하는 배포가 없기 때문이다 → **추론**. `SchemaRegistry` production 구현이 0인 것은 관측이고, 인과는 추론이다.\n- Avro 게이트가 자기 복사본을 쓴 이유 → **미상**. 커밋 메시지에 근거가 없다.\n\n---\n\n#### 16. 확인한 것 / 확인하지 못한 것\n\n**확인한 것**\n\n- 10개 타입 전부의 계약과 불변식\n- 19개 테스트가 통과하고 무엇을 단언하는지\n- `SchemaCompatibilityValidator`·`RawBytesMessageCodec`·`SchemaRegistry`의 leaf 밖 참조 0 (`SchemaRegistry`는 이름 충돌을 배제한 뒤)\n- Avro 게이트의 중복 구현과 두 형태의 차이\n- raw-bytes 기본 금지 규칙이 content type 기준으로 더 넓게 강제된다는 것\n\n**확인하지 못한 것**\n\n- `SchemaCompatibility` enum이 실제로 확장될 계획이 있는지. §12.3의 위험은 그때 실현된다.\n- port 구현의 스레드 안전성 요구. javadoc에 없고 이 저장소에 production 구현이 없어 관측할 대상이 없다.\n- `BoundedByteSink`의 경계가 실제 Jackson/Avro/Protobuf 인코더에서 기대대로 동작하는지 — 각 codec leaf의 테스트가 소유하고 이 문서 범위 밖이다.\n\n---\n\n#### 17. 손볼 것\n\n##### P2 — 포맷 독립 진화 규칙이 호출되지 않고, 그것이 막으려던 중복이 실제로 생겼다\n\n- **사실.** `SchemaCompatibilityValidator`의 저장소 전체 참조가 자기 선언과 자기 테스트뿐이다. 동시에 `AvroCompatibilityGate`가 `isTransitive`를 글자 그대로 복사했고 방향 판정 둘은 허용목록/거부목록으로 형태가 반대다.\n- **근거.** `evidence/raw/272` §A, §B.\n- **왜 문제인가.** 오늘은 7개 모드 전부에서 두 구현의 결과가 같다(`NONE_EXPERIMENTAL`은 gate의 early return이 가린다). 그러나 enum에 값이 하나 추가되면 허용목록은 \"검사 안 함\", 거부목록은 \"양방향 검사\"로 **반대 방향** 기본값을 갖는다. 그리고 `requireProductionMode` — 검사 없는 스키마가 보존 로그를 뒷받침하는 것을 막는 게이트 — 는 호출되는 곳이 없다.\n- **확인 방법.** `git grep -n -E 'requireProductionMode|versionsToCheck|SchemaCompatibilityValidator' -- 'src/**/*.java'`\n- **후보.** (a) Avro 게이트가 `SchemaCompatibilityValidator`의 public static을 부르게 한다. (b) validator를 CI 게이트에 배선한다. (c) 둘 다 쓰지 않을 거라면 validator를 제거하고 규칙 소유권을 게이트로 옮긴다.\n- **다음 단계.** **CASE 후보 + REFERENCE 후보**. \"중복을 막으려고 만든 추상이 호출되지 않으면 중복은 그대로 생긴다\"는 형태가 재사용 가능하다. 그리고 \"허용목록과 거부목록은 enum이 자라는 순간 반대로 갈라진다\"도 별도 기준이다.\n\n##### P3 — port 구현의 스레드 안전성 요구가 문서화되어 있지 않다\n\n- **사실.** `SchemaRegistry`와 `MessageCodecRegistry` javadoc에 동시성 요구가 없다. `BoundedByteSink`만 \"not thread-safe\"를 명시한다.\n- **근거.** 세 타입의 javadoc 전문.\n- **왜 문제인가.** `MessageCodecRegistry`의 유일한 구현 `RegisteredMessageCodecs`는 `Map.copyOf`로 불변이라 안전하지만, 그것은 구현의 성질이지 계약이 아니다. 외부 registry를 감싸는 `SchemaRegistry` 구현은 브로커 소비자 스레드들에서 동시에 호출된다.\n- **확인 방법.** 세 인터페이스의 javadoc 확인.\n- **후보.** port javadoc에 \"구현은 스레드 안전해야 한다\"를 명시.\n- **다음 단계.** **REFERENCE 후보**(port 계약은 동시성 요구를 적는다).\n\n##### P3 — `SchemaRegistry`라는 이름이 저장소에서 두 가지를 가리킨다\n\n- **사실.** `dev.caskeleton.messaging.schema.SchemaRegistry`(이 leaf의 port)와 `com.networknt.schema.SchemaRegistry`(JSON Schema 라이브러리)가 공존하고, 후자만 실제로 import된다.\n- **근거.** `evidence/raw/272` §C.\n- **왜 문제인가.** 지금 깨지는 것은 없다. 다만 reachability 판정에서 실제로 오탐을 만들었다 — 단어 검색이 2건을 맞췄고 둘 다 다른 타입이었다. 사람이 같은 실수를 한다.\n- **확인 방법.** `git grep -n 'import .*\\.SchemaRegistry;' -- src`\n- **후보.** 이름 변경 없이 두는 것이 합리적일 수 있다. 기록만 남긴다.\n- **다음 단계.** **REFERENCE 후보**(도달성 판정은 단어가 아니라 import로 확인한다).\n\n##### 확인된 설계(문제 아님)\n\n- `BoundedByteSink`가 codec의 에러 코드를 전달하고, pre-flight가 예산을 소비하지 않는 것 — 테스트가 양쪽을 고정\n- `EncodedMessage`의 양방향 방어 복사와 payload를 찍지 않는 `toString`\n- 버전을 registry 키에 포함하고 \"타입 미등록\"과 \"버전 미등록\"을 다른 코드로 구분하는 것\n- raw-bytes 기본 금지가 클래스가 아니라 content type으로 강제되는 것\n\n---\n\n#### Source anchors\n\n| id | kind | path | revision | what it proves | limitations |\n|---|---|---|---|---|---|\n| MSA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps `[\"messaging-core-api\"]`, memberships `[\"app-bootstrap\"]` | 선언 |\n| MSA-002 | build | `messaging-schema-api/build.gradle` | same | vendor 의존성 0 | — |\n| MSA-003 | code | `.../schema/MessageContractKey.java` | same | 버전 키 결정과 그 이유 | — |\n| MSA-004 | code | `.../schema/BoundedByteSink.java` | same | 할당 경계, 에러 코드 전달, pre-flight | 실제 인코더 동작은 각 codec leaf |\n| MSA-005 | code | `.../schema/EncodedMessage.java` | same | 양방향 복사, 배열 equals, 안전한 toString | — |\n| MSA-006 | code | `.../schema/SchemaCompatibilityValidator.java` | same | 포맷 독립 규칙과 분리 이유 | 호출자 없음(§12.1) |\n| MSA-007 | code | `.../schema/SchemaRegistry.java` | same | port 계약, history oldest-first | production 구현 없음 |\n| MSA-008 | code | `.../schema/RawBytesMessageCodec.java` | same | escape hatch 계약 | 외부 사용 0 |\n| MSA-009 | code | `.../schema/{MessageCodec,MessageCodecRegistry,SchemaReference,SchemaCompatibility}.java` | same | codec/식별/모드 계약 | — |\n| MSA-010 | test | `src/test/java/**` (3 클래스 / 19 테스트) | same | §10 표 | 순수 단위 |\n| MSA-011 | cross-leaf code | `messaging-runtime-core/.../RegisteredMessageCodecs.java:29-77` | same | raw-bytes 기본 금지의 실제 강제 지점, 중복 content type 거절 | 해당 leaf SSOT가 소유 |\n| MSA-012 | cross-leaf code | `messaging-schema-avro/.../AvroCompatibilityGate.java:34-61` | same | 중복 구현과 두 형태의 차이 | 해당 leaf SSOT가 소유 |\n| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | §12.1·§12.3 전부 | 정적 검색 |\n| EVD-274 | command | `./gradlew :messaging:messaging-schema-api:test --rerun-tasks` | same | 19 / 0 skipped / 0 failures | 순수 단위 |\n\n---\n"
},
"previous_section": {
"heading": {
"line": 35412,
"level": 2,
"text": "A19-MESSAGING-SCHEMA-API. messaging-schema-api"
},
"start_line": 35412,
"end_line": 35415,
"text": "## A19-MESSAGING-SCHEMA-API. messaging-schema-api\n\n> 분석 중에는 `messaging/MESSAGING-SCHEMA-API.md` 파일이었다. 547줄.\n"
},
"next_section": {
"heading": {
"line": 35966,
"level": 2,
"text": "A19-MESSAGING-SCHEMA-AVRO. messaging-schema-avro"
},
"start_line": 35966,
"end_line": 36569,
"text": "## A19-MESSAGING-SCHEMA-AVRO. messaging-schema-avro\n\n> 분석 중에는 `messaging/MESSAGING-SCHEMA-AVRO.md` 파일이었다. 597줄.\n\n### messaging-schema-avro 완전 해부\n\n> 상태: COMPLETE\n> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n> 분석 범위: `src/messaging/messaging-schema-avro`\n> SSOT owner: `messaging-schema-avro`\n> integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n\n---\n\n#### 0. SSOT identity / 커버리지와 숫자 지도\n\n- registered leaf id: `messaging-schema-avro`\n- canonical state `analysisFile`: §A19-MESSAGING-SCHEMA-AVRO\n- source path: `src/messaging/messaging-schema-avro`\n- registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-schema-api\"]`\n- registry `runtime_memberships`: **`[]`** — build-only / incubating\n\n##### 숫자\n\n| 항목 | 수 |\n|---|---:|\n| production Java 파일 | 2 |\n| production LOC | 345 |\n| 패키지 | 1 (`dev.caskeleton.messaging.schema.avro`) |\n| test 파일 | 3 |\n| test 메서드(실행 확인) | 16 |\n| test resource | `/schemas/order.created/v1.avsc` |\n| 외부 의존성 | 1 (`org.apache.avro:avro:1.12.0`, **`api`**) |\n\n두 클래스: `AvroMessageCodec`(런타임 인코딩/디코딩), `AvroCompatibilityGate`(CI용 진화 검사).\n\n##### Coverage ledger\n\n| scope/file group | count | disposition | reason |\n|---|---:|---|---|\n| `.../avro/AvroMessageCodec.java` | 1 | `FULL_READ` | 272줄 전문 |\n| `.../avro/AvroCompatibilityGate.java` | 1 | `FULL_READ` | 73줄 전문 |\n| `src/test/java/**` | 3 | `FULL_READ` | 전문 |\n| `src/test/resources/schemas/order.created/v1.avsc` | 1 | `STRUCTURAL_ONLY` | fixture 스키마; 필드 구성만 확인 |\n| `build.gradle` | 1 | `FULL_READ` | 주석 포함 11줄 |\n| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |\n| `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n\n`UNCLASSIFIED` 0.\n\n---\n\n#### 1. 모듈의 정체와 경계\n\n선택적(optional) Avro codec. Stable이 아니고 registry membership이 비어 있다 — **build-only / incubating**이며, `docs/messaging/support-matrix.md`의 등급과는 다른 축이다.\n\nAvro를 `api`로 선언한 이유가 build.gradle 주석에 있다.\n\n```groovy\n// api: AvroMessageCodec's constructors take a registry of org.apache.avro.Schema and\n// AvroCompatibilityGate.check takes and compares them. A consumer cannot build that\n// registry without naming the type, so hiding the dependency only stops them compiling.\napi 'org.apache.avro:avro:1.12.0'\n```\n\n`src/messaging/CLAUDE.md:40-43`이 기술하는 게이트 — public/protected 시그니처에 나오는 vendor 라이브러리가 `api`로 선언됐는지 대조 — 를 이 leaf가 통과한다. 형제 `messaging-schema-json`은 Jackson 타입이 시그니처에 없으므로 `implementation`이고, 그 판정 차이가 규칙이 실제로 작동한다는 증거다.\n\n**클래스 둘의 실행 시점이 다르다.**\n\n| 클래스 | 언제 도는가 | 근거 |\n|---|---|---|\n| `AvroMessageCodec` | 런타임(메시지마다) | `MessageCodec` 구현 |\n| `AvroCompatibilityGate` | **CI** | 클래스 javadoc: \"Run in CI rather than at runtime\" |\n\n게이트의 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.\"\n\n---\n\n#### 2. 의존성과 런타임 배선\n\n들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `avro:1.12.0`(api).\n\n나가는 것: **없다.** 어떤 leaf의 `allowed_dependencies`에도 `messaging-schema-avro`가 없다. `messaging-spring-boot-starter`의 17개 의존 목록에도 없다.\n\n런타임 배선: 없음. `runtime_memberships: []`이므로 배포 아티팩트에 실리지 않는다. bean도 없다(Spring 주석 0개).\n\n**소비자 없음과 membership 없음이 일치한다.** 이것이 정합적인 incubating 상태다 — `messaging-cloudevents`와 대비된다(그쪽은 membership이 있고 소비자가 없다).\n\n---\n\n#### 3. 패키지/컴포넌트 지도\n\n```\nAvroMessageCodec (MessageCodec 구현)\n├── encode(type, version, GenericRecord) → EncodedMessage\n├── decode(type, version, byte[], Class) → GenericRecord (writer == reader)\n├── decodeEvolved(type, writerV, readerV, byte[]) → GenericRecord (writer != reader)\n├── schemaFor(type, version) → 등록 조회, 2단 에러\n├── boundedReader(writer, reader) → newArray 오버라이드\n└── flatten(nested registry) → (type, version) 평탄화 + 깊은 복사\n\nAvroCompatibilityGate (CI)\n└── check(candidate, history, mode)\n ├── isTransitive / readsBackward / readsForward (private, 자체 구현)\n └── requireCompatible → org.apache.avro.SchemaCompatibility\n```\n\n---\n\n#### 4. 계약·불변식·상태 모델\n\n##### 4.1 Avro 바이너리에는 스키마가 없다 — 그래서 registry가 계약이다\n\n```java\n// AvroMessageCodec.java:33-37\n * Decoding uses an explicit writer schema and reader schema pair. Avro binary carries no schema\n * of its own, so decoding with the wrong schema does not fail — it produces plausible garbage. The\n * registry is what makes the writer schema knowable, and passing both schemas to the reader is what\n * makes evolution work: Avro resolves added, removed, and defaulted fields only when it can see\n * both sides.\n```\n\n\"does not fail — it produces plausible garbage\"가 이 leaf의 모든 방어의 전제다. JSON이나 Protobuf와 달리 Avro는 잘못된 스키마로 디코딩해도 예외를 던지지 않는 경우가 있다.\n\nsingle-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.\"\n\n##### 4.2 `flatten`: 얕은 복사가 만든 구멍\n\n생성자가 받는 것은 중첩 맵 `Map {@code Map.copyOf} on the outer map is a shallow copy: every inner {@code Map Avro writes an array as a declared element count followed by the elements. The count is a\n * variable-length integer, so five bytes can claim four hundred million elements, and the generic\n * reader allocates the backing array from that claim before reading a single element. Bounding\n * the input length does not help: the whole hostile payload is five bytes, well under any limit,\n * and the failure is an {@code OutOfMemoryError} rather than an exception the codec could report\n * — on a consumer thread that is the process, not the message.\n *\n * The ceiling is the byte limit itself. Every element costs at least one byte on the wire even\n * when it is empty, so a payload of at most {@code maxBytes} bytes cannot honestly contain more\n * than {@code maxBytes} elements, and any larger claim is a lie the reader should refuse rather\n * than reserve memory for.\n```\n\n구현은 익명 서브클래스의 `newArray` 오버라이드다.\n\n```java\nreturn new GenericDatumReader<>(writerSchema, readerSchema) {\n @Override\n protected Object newArray(Object old, int size, Schema schema) {\n if (size > maxElements) {\n throw new MessageTooLargeException(\"AVRO_COLLECTION_TOO_LARGE\", ...);\n }\n return super.newArray(old, size, schema);\n }\n};\n```\n\n**상한 선택의 논리가 정확하다.** 원소 하나가 wire에서 최소 1바이트를 쓰므로, `maxBytes` 바이트짜리 payload가 정직하게 담을 수 있는 원소는 `maxBytes`개를 넘을 수 없다. 별도 튜닝 상수를 만들지 않고 이미 있는 경계에서 파생시켰다.\n\n`AvroHostileInputTest`가 이 공격을 손으로 만든 zigzag varint로 재현한다.\n\n```java\n// AvroHostileInputTest.java:118-123\n * Hand-written rather than taken from an encoder because the point is to write a count with no\n * elements behind it, which no encoder will do.\n```\n\n그리고 공격의 크기를 직접 단언한다 — `assertThat(hostile).as(\"the whole attack is five bytes, so no byte limit stands between it and the allocation\").hasSizeLessThan(16)`.\n\n테스트 클래스 javadoc이 **왜 corpus가 좁은지**까지 적는다.\n\n```java\n// AvroHostileInputTest.java:30-33\n * Strings, byte arrays and maps were already safe: Avro validates those lengths against the\n * bytes actually remaining. Arrays were the one shape that allocated on trust, which is why the\n * corpus below is narrow rather than exhaustive — it pins the case that failed, and the two cases\n * that must keep working around it.\n```\n\n이것은 \"좁은 테스트\"를 정당화한 드문 예다 — 다른 형태는 라이브러리가 이미 방어하므로 재확인이 아니라 잡음이 된다.\n\n##### 4.5 `schemaFor`: 2단 에러\n\n`AVRO_TYPE_NOT_REGISTERED`(타입 미등록)와 `AVRO_VERSION_NOT_REGISTERED`(버전 미등록)를 구분한다. JSON codec의 `UNKNOWN_MESSAGE_TYPE`/`SCHEMA_VERSION_NOT_REGISTERED`와 같은 형태이지만 **코드 문자열이 다르다.** 두 codec이 같은 판단을 다른 어휘로 보고한다 — §12.3.\n\n##### 4.6 `decodeEvolved`: 나중에 붙은 경계\n\n```java\n// AvroMessageCodec.java:199-201\n// The same bound the ordinary decode applies. It was missing here, so the evolution path — the\n// one a consumer takes for every message written by a newer producer — accepted input of any\n// size.\nrequireWithinLimit(encoded.length);\n```\n\n테스트가 두 각도에서 붙든다 — `AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound`(`as(\"decodeEvolved accepted input of any size\")`)와 `AvroHostileInputTest.theEvolutionDecodeAppliesTheSameCollectionBound`(`as(\"a consumer reading a newer producer takes this path for every message\")`).\n\n즉 `decodeEvolved`는 **가장 흔한 경로인데 가장 늦게 보호됐다.** 진화 경로는 producer가 앞서 나간 순간부터 모든 메시지가 지나는 길이다.\n\n##### 4.7 `AvroCompatibilityGate`\n\n```java\npublic void check(Schema candidate, List Implementations operate against a closed message-type registry. Accepting an unregistered type"
},
{
"line": 35486,
"text": " * would let a producer introduce a wire contract nothing has reviewed, which is the same class of"
},
{
"line": 35487,
"text": " * problem that makes Java serialization unsupported here."
},
{
"line": 35488,
"text": "```"
},
{
"line": 35489,
"text": ""
},
{
"line": 35490,
"text": "`build.gradle`는 `api project(':messaging:messaging-core-api')` 하나뿐이고 vendor 의존성이 없다. 포맷별 vendor(`jackson`, `avro`, `protobuf`)는 각자 leaf가 갖는다."
},
{
"line": 35491,
"text": ""
},
{
"line": 35492,
"text": "---"
},
{
"line": 35493,
"text": ""
},
{
"line": 35494,
"text": "#### 2. 의존성과 런타임 배선"
},
{
"line": 35495,
"text": ""
},
{
"line": 35496,
"text": "들어오는 것: `messaging-core-api`(api 노출)."
},
{
"line": 35497,
"text": ""
},
{
"line": 35498,
"text": "나가는 것: `messaging-schema-json`, `messaging-schema-avro`, `messaging-schema-protobuf`, `messaging-cloudevents`, `messaging-policy`, `messaging-transport-spi`, `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`, `messaging-testkit`."
},
{
"line": 35499,
"text": ""
},
{
"line": 35500,
"text": "런타임 편입은 `messaging-core-api`와 같은 경로다 — `app-bootstrap`이 `messaging-spring-boot-starter`를 선언하고 그 closure가 이 leaf를 끌어온다."
},
{
"line": 35501,
"text": ""
},
{
"line": 35502,
"text": "이 leaf는 bean을 만들지 않는다. Spring 주석 0개."
},
{
"line": 35503,
"text": ""
},
{
"line": 35504,
"text": "---"
},
{
"line": 35505,
"text": ""
},
{
"line": 35506,
"text": "#### 3. 패키지/컴포넌트 지도"
},
{
"line": 35507,
"text": ""
},
{
"line": 35508,
"text": "패키지 하나에 10개 타입이 평평하게 있다. 관심사로 나누면 셋이다."
},
{
"line": 35509,
"text": ""
},
{
"line": 35510,
"text": "```"
},
{
"line": 35511,
"text": "codec 축 MessageCodec ── MessageCodecRegistry"
},
{
"line": 35512,
"text": " │"
},
{
"line": 35513,
"text": " └── RawBytesMessageCodec (유일한 구현)"
},
{
"line": 35514,
"text": ""
},
{
"line": 35515,
"text": "식별 축 MessageContractKey (type, version)"
},
{
"line": 35516,
"text": " SchemaReference (subject, version, uri?)"
},
{
"line": 35517,
"text": " EncodedMessage (bytes, contentType, schemaReference?)"
},
{
"line": 35518,
"text": ""
},
{
"line": 35519,
"text": "진화 축 SchemaRegistry ── SchemaCompatibility(7)"
},
{
"line": 35520,
"text": " │"
},
{
"line": 35521,
"text": " └── SchemaCompatibilityValidator"
},
{
"line": 35522,
"text": ""
},
{
"line": 35523,
"text": "경계 축 BoundedByteSink"
},
{
"line": 35524,
"text": "```"
},
{
"line": 35525,
"text": ""
},
{
"line": 35526,
"text": "---"
},
{
"line": 35527,
"text": ""
},
{
"line": 35528,
"text": "#### 4. 계약·불변식·상태 모델"
},
{
"line": 35529,
"text": ""
},
{
"line": 35530,
"text": "##### 4.1 `MessageContractKey`: 버전을 키에 넣는 이유"
},
{
"line": 35531,
"text": ""
},
{
"line": 35532,
"text": "이 leaf에서 가장 밀도 높은 javadoc이다."
},
{
"line": 35533,
"text": ""
},
{
"line": 35534,
"text": "```java"
},
{
"line": 35535,
"text": "// MessageContractKey.java:10-17"
},
{
"line": 35536,
"text": " * Keying on the message type alone is what let an unregistered version decode. The version"
},
{
"line": 35537,
"text": " * travels in the envelope and in {@link SchemaReference}, so a consumer receiving {@code"
},
{
"line": 35538,
"text": " * order.created v999} would look up {@code order.created}, find the v1 class or parser, decode"
},
{
"line": 35539,
"text": " * against it, and then keep the v999 label on the result. Nothing failed, and every downstream"
},
{
"line": 35540,
"text": " * compatibility gate and audit record then described a version that was never registered."
},
{
"line": 35541,
"text": "```"
},
{
"line": 35542,
"text": ""
},
{
"line": 35543,
"text": "핵심은 \"Nothing failed\"다. 타입만으로 키를 잡으면 실패가 발생하지 않고 **잘못된 성공**이 발생한다. 그리고 그 결과에는 등록된 적 없는 버전 라벨이 붙어 하위 감사 기록까지 오염된다."
},
{
"line": 35544,
"text": ""
},
{
"line": 35545,
"text": "이 결정은 세 codec에 전부 반영돼 있다 — `JacksonMessageCodec.requireRegistered`, `AvroMessageCodec.schemaFor`, `ProtobufMessageCodec.requireRegistered`가 모두 \"타입은 아는데 버전을 모른다\"와 \"타입 자체를 모른다\"를 **다른 에러 코드**로 구분한다(`SCHEMA_VERSION_NOT_REGISTERED` vs `UNKNOWN_MESSAGE_TYPE`). 그 구분이 있어야 운영자가 \"등록을 빠뜨렸다\"와 \"오타다\"를 나눌 수 있다."
},
{
"line": 35546,
"text": ""
},
{
"line": 35547,
"text": "##### 4.2 `BoundedByteSink`: 보고 임계값 → 할당 경계"
},
{
"line": 35548,
"text": ""
},
{
"line": 35549,
"text": "```java"
},
{
"line": 35550,
"text": "// BoundedByteSink.java:11-15"
},
{
"line": 35551,
"text": " * Every codec here used to serialize into an unbounded buffer and compare {@code bytes.length}"
},
{
"line": 35552,
"text": " * to the configured maximum afterwards. That makes the maximum a reporting threshold rather than an"
},
{
"line": 35553,
"text": " * allocation bound: a payload whose graph expands to hundreds of megabytes exhausts the heap while"
},
{
"line": 35554,
"text": " * being written, and the check that would have rejected it never runs. Under a broker consumer that"
},
{
"line": 35555,
"text": " * is a process-wide outage caused by one message."
},
{
"line": 35556,
"text": "```"
},
{
"line": 35557,
"text": ""
},
{
"line": 35558,
"text": "세 가지 설계 결정이 붙어 있다."
},
{
"line": 35559,
"text": ""
},
{
"line": 35560,
"text": "1. **버퍼를 한도로 미리 잡지 않는다.** `new ByteArrayOutputStream(Math.min(maxBytes, 8_192))` — 주석: \"a 1 GiB bound must not pre-allocate 1 GiB.\""
},
{
"line": 35561,
"text": "2. **codec의 에러 코드를 그대로 던진다.** `errorCode`가 생성자 인자다. 그래서 Avro는 `AVRO_PAYLOAD_TOO_LARGE`, JSON은 `PAYLOAD_TOO_LARGE`가 나온다. 테스트가 이 성질을 직접 단언한다(`BoundedByteSinkTest.java:69-77`, `as(\"the sink reports the codec's own code, not a generic one\")`)."
},
{
"line": 35562,
"text": "3. **`requireFits(size)`는 예산을 소비하지 않는다.** Protobuf는 직렬화 크기를 미리 알므로 첫 바이트 전에 거절할 수 있다. 그리고 그 뒤의 쓰기도 여전히 경계 안이다 — 주석: \"this is a cheaper refusal, not a replacement for the bound.\""
},
{
"line": 35563,
"text": ""
},
{
"line": 35564,
"text": "`refuseIfBeyondLimit`가 `size > maxBytes - written`으로 비교하는 것도 의도적이다. `written + size > maxBytes`였다면 `int` 오버플로가 가능하다."
},
{
"line": 35565,
"text": ""
},
{
"line": 35566,
"text": "테스트가 실제 시나리오를 재현한다 — 10 MiB를 1 KiB씩 제공하고, `written()`이 한도(64) 이하로 유지되며 `toByteArray()`가 비어 있음을 확인한다(`BoundedByteSinkTest.java:34-53`)."
},
{
"line": 35567,
"text": ""
},
{
"line": 35568,
"text": "##### 4.3 `EncodedMessage`: 양방향 방어 복사"
},
{
"line": 35569,
"text": ""
},
{
"line": 35570,
"text": "```java"
},
{
"line": 35571,
"text": "public EncodedMessage {"
},
{
"line": 35572,
"text": " ..."
},
{
"line": 35573,
"text": " bytes = bytes.clone(); // 생성 시"
},
{
"line": 35574,
"text": "}"
},
{
"line": 35575,
"text": ""
},
{
"line": 35576,
"text": "@Override"
},
{
"line": 35577,
"text": "public byte[] bytes() {"
},
{
"line": 35578,
"text": " return bytes.clone(); // 접근 시"
},
{
"line": 35579,
"text": "}"
},
{
"line": 35580,
"text": "```"
},
{
"line": 35581,
"text": ""
},
{
"line": 35582,
"text": "javadoc이 이유를 적는다 — \"These bytes travel through retry, DLQ, and redrive paths where a shared mutable array would let one stage corrupt another's copy of the same logical message.\""
},
{
"line": 35583,
"text": ""
},
{
"line": 35584,
"text": "`equals`/`hashCode`는 `Arrays.equals`/`Arrays.hashCode`로 재정의된다(record 기본은 배열 참조 비교라 항상 불일치). `toString`은 바이트를 찍지 않고 크기만 찍는다 — payload가 로그에 새지 않는다."
},
{
"line": 35585,
"text": ""
},
{
"line": 35586,
"text": "`size()`가 복사 없이 길이를 반환하는 별도 메서드로 있는 것도 의도적이다. `bytes().length`는 전체 복사를 유발한다."
},
{
"line": 35587,
"text": ""
},
{
"line": 35588,
"text": "##### 4.4 `SchemaCompatibility`: 7개 모드와 transitive의 의미"
},
{
"line": 35589,
"text": ""
},
{
"line": 35590,
"text": "```java"
},
{
"line": 35591,
"text": "// SchemaCompatibility.java:6-8"
},
{
"line": 35592,
"text": " * Transitive modes check every historical version, not just the immediate predecessor. That"
},
{
"line": 35593,
"text": " * matters for integration events, where a consumer may be several releases behind and a chain of"
},
{
"line": 35594,
"text": " * individually-compatible changes can still be collectively breaking."
},
{
"line": 35595,
"text": "```"
},
{
"line": 35596,
"text": ""
},
{
"line": 35597,
"text": "`NONE_EXPERIMENTAL`은 \"M2 raw bytes에만 허용\"이라고 enum 상수 javadoc이 적는다."
},
{
"line": 35598,
"text": ""
},
{
"line": 35599,
"text": "##### 4.5 `SchemaRegistry`: 포트이고, 순서가 계약이다"
},
{
"line": 35600,
"text": ""
},
{
"line": 35601,
"text": "```java"
},
{
"line": 35602,
"text": "// SchemaRegistry.java:16-17"
},
{
"line": 35603,
"text": " * {@link #history} returns oldest first. Transitive compatibility checks read the whole list, so"
},
{
"line": 35604,
"text": " * an ordering mistake here silently converts a transitive check into a pairwise one."
},
{
"line": 35605,
"text": "```"
},
{
"line": 35606,
"text": ""
},
{
"line": 35607,
"text": "이것은 문서화된 함정이다. `history`가 newest-first로 구현되면 `versionsToCheck`가 `reversed()`한 뒤 `history.get(0)`을 취하므로 **가장 오래된 버전 하나**만 비교하게 된다 — transitive가 pairwise로 조용히 축소되는 것이 아니라 아예 엉뚱한 버전을 비교한다."
},
{
"line": 35608,
"text": ""
},
{
"line": 35609,
"text": "`latest(subject)`가 default 메서드로 `versions.get(versions.size() - 1)`인 것도 같은 순서 계약에 의존한다. 테스트가 이 성질을 직접 단언한다(`SchemaCompatibilityValidatorTest.theLatestVersionIsTheNewestNotTheFirstListed`)."
},
{
"line": 35610,
"text": ""
},
{
"line": 35611,
"text": "port로 둔 이유도 적혀 있다 — \"A hosted registry, a classpath directory of schema files, and a static in-process map are all legitimate sources … Binding to a vendor client here would make the rules untestable without that vendor running.\""
},
{
"line": 35612,
"text": ""
},
{
"line": 35613,
"text": "##### 4.6 `SchemaCompatibilityValidator`: 포맷 독립 규칙"
},
{
"line": 35614,
"text": ""
},
{
"line": 35615,
"text": "두 가지를 한다."
},
{
"line": 35616,
"text": ""
},
{
"line": 35617,
"text": "**(a) 비교할 버전 목록**"
},
{
"line": 35618,
"text": ""
},
{
"line": 35619,
"text": "```java"
},
{
"line": 35620,
"text": "public List Split from the per-format gates on purpose. Whether v3 must be checked against v1 as well as"
},
{
"line": 35647,
"text": " * v2 is a property of the compatibility mode, not of Avro or Protobuf, and duplicating that"
},
{
"line": 35648,
"text": " * reasoning in each codec is how the two formats drift apart."
},
{
"line": 35649,
"text": "```"
},
{
"line": 35650,
"text": ""
},
{
"line": 35651,
"text": "§12.1과 §12.3이 이 문장을 다시 다룬다."
},
{
"line": 35652,
"text": ""
},
{
"line": 35653,
"text": "##### 4.7 `RawBytesMessageCodec`: 부재를 구현한다"
},
{
"line": 35654,
"text": ""
},
{
"line": 35655,
"text": "```java"
},
{
"line": 35656,
"text": "// RawBytesMessageCodec.java:12-16"
},
{
"line": 35657,
"text": " * It still enforces the byte limit, and it is deliberately excluded from default codec"
},
{
"line": 35658,
"text": " * selection: schema-free publishing has to be an explicit, auditable choice per destination, never"
},
{
"line": 35659,
"text": " * something a destination falls back to because its codec was misconfigured."
},
{
"line": 35660,
"text": "```"
},
{
"line": 35661,
"text": ""
},
{
"line": 35662,
"text": "`encode`는 `byte[]`가 아닌 payload를 `MessageSerializationException(\"RAW_BYTES_PAYLOAD_REQUIRED\")`로 거절하고, `decode`는 `byte[].class`가 아닌 대상을 `RAW_BYTES_TARGET_REQUIRED`로 거절한다. `decode`는 `encoded.clone()`을 반환한다 — 호출자가 원본을 건드릴 수 없다."
},
{
"line": 35663,
"text": ""
},
{
"line": 35664,
"text": "`DEFAULT_MAX_BYTES = 1_048_576`(1 MiB)은 세 Stable codec이 공유하는 값이다."
},
{
"line": 35665,
"text": ""
},
{
"line": 35666,
"text": "**주의:** 이 codec은 `BoundedByteSink`를 쓰지 않는다. 이미 `byte[]`를 받으므로 스트리밍 경계가 의미 없고, `bytes.length > maxBytes` 비교로 충분하다. 다른 codec에서는 그 비교가 §4.2가 지적하는 \"보고 임계값\"이지만 여기서는 할당이 이미 끝난 입력이라 성격이 다르다."
},
{
"line": 35667,
"text": ""
},
{
"line": 35668,
"text": "---"
},
{
"line": 35669,
"text": ""
},
{
"line": 35670,
"text": "#### 5. 주요 실행 경로"
},
{
"line": 35671,
"text": ""
},
{
"line": 35672,
"text": "세 개다."
},
{
"line": 35673,
"text": ""
},
{
"line": 35674,
"text": "1. **경계 있는 인코딩** — codec이 `BoundedByteSink.of(maxBytes, code)`를 만들고 → 포맷 라이브러리가 sink에 쓰고 → 한도를 넘는 write에서 `MessageTooLargeException` → 아니면 `sink.toByteArray()`로 `EncodedMessage` 조립"
},
{
"line": 35675,
"text": "2. **계약 조회** — `new MessageContractKey(type, version)` → registry lookup → 미스면 \"타입 미등록\" vs \"버전 미등록\" 구분"
},
{
"line": 35676,
"text": "3. **진화 검사** — `registry.compatibilityOf(subject)` → `versionsToCheck` → (포맷별 게이트가 실제 비교)"
},
{
"line": 35677,
"text": ""
},
{
"line": 35678,
"text": "3번은 이 저장소에서 실행되지 않는다(§12.1)."
},
{
"line": 35679,
"text": ""
},
{
"line": 35680,
"text": "---"
},
{
"line": 35681,
"text": ""
},
{
"line": 35682,
"text": "#### 6. 실패 경로와 복구/번역"
},
{
"line": 35683,
"text": ""
},
{
"line": 35684,
"text": "이 leaf가 던지는 예외는 셋이고 전부 `messaging-core-api` 소유다."
},
{
"line": 35685,
"text": ""
},
{
"line": 35686,
"text": "| 예외 | 코드 | 조건 |"
},
{
"line": 35687,
"text": "|---|---|---|"
},
{
"line": 35688,
"text": "| `MessageTooLargeException` | codec별(`PAYLOAD_TOO_LARGE`, `AVRO_PAYLOAD_TOO_LARGE`, …) | sink 한도 초과 |"
},
{
"line": 35689,
"text": "| `MessageTooLargeException` | `RAW_BYTES_TOO_LARGE` | raw codec 한도 초과 |"
},
{
"line": 35690,
"text": "| `MessageSerializationException` | `RAW_BYTES_PAYLOAD_REQUIRED` / `RAW_BYTES_TARGET_REQUIRED` | 타입 불일치 |"
},
{
"line": 35691,
"text": "| `MessageSchemaIncompatibleException` | `UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION` | `NONE_EXPERIMENTAL`이 production 목적지에 |"
},
{
"line": 35692,
"text": ""
},
{
"line": 35693,
"text": "`IllegalArgumentException`도 던진다 — `BoundedByteSink` 생성자의 `maxBytes < 1`, `requireFits`의 음수, `SchemaReference`의 빈 subject. 이들은 **호출자의 프로그래밍 오류**이고 메시지 실패가 아니므로 `MessagingException` 계층 밖인 것이 일관적이다."
},
{
"line": 35694,
"text": ""
},
{
"line": 35695,
"text": "---"
},
{
"line": 35696,
"text": ""
},
{
"line": 35697,
"text": "#### 7. 트랜잭션·동시성·수명주기"
},
{
"line": 35698,
"text": ""
},
{
"line": 35699,
"text": "트랜잭션 없음."
},
{
"line": 35700,
"text": ""
},
{
"line": 35701,
"text": "동시성: `BoundedByteSink`가 **의도적으로 thread-safe가 아니다.** javadoc이 명시한다 — \"Not thread-safe, and not meant to be: an instance belongs to a single encode call.\" 실제로 codec들이 매 `encode` 호출마다 새로 만든다."
},
{
"line": 35702,
"text": ""
},
{
"line": 35703,
"text": "`EncodedMessage`, `MessageContractKey`, `SchemaReference`는 불변이다. `SchemaCompatibilityValidator`는 registry 참조만 갖고 상태가 없다."
},
{
"line": 35704,
"text": ""
},
{
"line": 35705,
"text": "`MessageCodecRegistry`/`SchemaRegistry` 구현의 스레드 안전성은 이 leaf가 규정하지 않는다 — port javadoc에 그에 대한 요구가 없다. 이것은 §17의 P3 항목이다."
},
{
"line": 35706,
"text": ""
},
{
"line": 35707,
"text": "---"
},
{
"line": 35708,
"text": ""
},
{
"line": 35709,
"text": "#### 8. 설정·기능 플래그·환경 차이"
},
{
"line": 35710,
"text": ""
},
{
"line": 35711,
"text": "설정 없음. 상수 하나:"
},
{
"line": 35712,
"text": ""
},
{
"line": 35713,
"text": "| 상수 | 값 | 위치 |"
},
{
"line": 35714,
"text": "|---|---:|---|"
},
{
"line": 35715,
"text": "| `RawBytesMessageCodec.DEFAULT_MAX_BYTES` | 1,048,576 | `RawBytesMessageCodec.java:21` |"
},
{
"line": 35716,
"text": ""
},
{
"line": 35717,
"text": "`BoundedByteSink`의 초기 버퍼 상한 8,192는 private다."
},
{
"line": 35718,
"text": ""
},
{
"line": 35719,
"text": "---"
},
{
"line": 35720,
"text": ""
},
{
"line": 35721,
"text": "#### 9. 퍼시스턴스/외부 시스템 세부"
},
{
"line": 35722,
"text": ""
},
{
"line": 35723,
"text": "없다. `SchemaRegistry`가 외부 registry를 가리킬 수 있는 port지만, 이 leaf에는 구현이 없다."
},
{
"line": 35724,
"text": ""
},
{
"line": 35725,
"text": "---"
},
{
"line": 35726,
"text": ""
},
{
"line": 35727,
"text": "#### 10. 테스트 레인과 실제 증명 범위"
},
{
"line": 35728,
"text": ""
},
{
"line": 35729,
"text": "레인: `./gradlew :messaging:messaging-schema-api:test`. **BUILD SUCCESSFUL, 19 tests, 0 skipped, 0 failures** (`--rerun-tasks`, revision `21234e38`)."
},
{
"line": 35730,
"text": ""
},
{
"line": 35731,
"text": "| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |"
},
{
"line": 35732,
"text": "|---|---:|---|---|"
},
{
"line": 35733,
"text": "| `BoundedByteSinkTest` | 4 | 한도 포함/초과 경계, 10 MiB 스트림이 한도에서 멈춤, pre-flight가 예산을 안 먹음, codec 에러 코드 전달 | 실제 codec들이 이 sink를 쓰는지(각 codec leaf가 소유) |"
},
{
"line": 35734,
"text": "| `RawBytesMessageCodecTest` | 6 | round trip, content type, 비-byte[] 거절 양방향, 한도, `EncodedMessage` 방어 복사 | — |"
},
{
"line": 35735,
"text": "| `SchemaCompatibilityValidatorTest` | 9 | pairwise vs transitive 목록, `NONE_EXPERIMENTAL` 빈 목록, 빈 history, production 게이트 양방향, `checksBackward`/`checksForward` 조합, `latest`가 newest | **production 코드가 이 validator를 호출하는지** |"
},
{
"line": 35736,
"text": ""
},
{
"line": 35737,
"text": "마지막 칸이 핵심이다. `SchemaCompatibilityValidatorTest`는 9개 단언으로 규칙을 정확히 고정하지만, §12.1이 보이듯 그 규칙을 실행 경로에서 부르는 코드가 없다. 테스트는 **규칙이 옳다**를 증명하고 **규칙이 적용된다**를 증명하지 않는다."
},
{
"line": 35738,
"text": ""
},
{
"line": 35739,
"text": "테스트가 쓰는 `FixedRegistry`는 `SchemaRegistry`의 유일한 구현이다(production 구현 0개, §12.1)."
},
{
"line": 35740,
"text": ""
},
{
"line": 35741,
"text": "---"
},
{
"line": 35742,
"text": ""
},
{
"line": 35743,
"text": "#### 11. 빌드/ArchUnit/CI 강제 지점"
},
{
"line": 35744,
"text": ""
},
{
"line": 35745,
"text": "| 게이트 | 이 leaf에 대해 |"
},
{
"line": 35746,
"text": "|---|---|"
},
{
"line": 35747,
"text": "| registry fail-closed | 등록됨 |"
},
{
"line": 35748,
"text": "| `verifyCleanArchitectureDependencies` | `allowed_dependencies: [\"messaging-core-api\"]`와 실제 project edge 대조 |"
},
{
"line": 35749,
"text": "| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |"
},
{
"line": 35750,
"text": "| `src/messaging/CLAUDE.md`의 vendor `api` 규칙 | 이 leaf는 vendor 의존성이 없으므로 대상 없음 |"
},
{
"line": 35751,
"text": "| ArchUnit | 이 leaf 전용 규칙 없음 |"
},
{
"line": 35752,
"text": ""
},
{
"line": 35753,
"text": "`src/messaging/CLAUDE.md:40-43`이 기술하는 게이트 — \"source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 `build.gradle`이 `api`로 선언했는지 대조\" — 는 이 leaf에서 확인할 것이 없다. 형제 leaf(`schema-avro`, `schema-protobuf`, `cloudevents`)는 이 규칙 때문에 vendor를 `api`로 선언했고 build.gradle 주석이 그 이유를 적는다."
},
{
"line": 35754,
"text": ""
},
{
"line": 35755,
"text": "---"
},
{
"line": 35756,
"text": ""
},
{
"line": 35757,
"text": "#### 12. 실제 사용 여부와 negative-space probes"
},
{
"line": 35758,
"text": ""
},
{
"line": 35759,
"text": "원시 증거: `evidence/raw/272-schema-family-reachability.txt`."
},
{
"line": 35760,
"text": ""
},
{
"line": 35761,
"text": "##### 12.1 Public surface reachability"
},
{
"line": 35762,
"text": ""
},
{
"line": 35763,
"text": "leaf 밖 참조를 파일 수로 세면:"
},
{
"line": 35764,
"text": ""
},
{
"line": 35765,
"text": "| 타입 | leaf 밖 파일 수 | 판정 |"
},
{
"line": 35766,
"text": "|---|---:|---|"
},
{
"line": 35767,
"text": "| `EncodedMessage` | 50 | 널리 쓰임 — 사실상 이 leaf의 주력 수출품 |"
},
{
"line": 35768,
"text": "| `SchemaCompatibility` | 15 | 세 codec leaf + policy가 씀 |"
},
{
"line": 35769,
"text": "| `MessageContractKey` | 7 | 세 codec leaf가 씀 |"
},
{
"line": 35770,
"text": "| `MessageCodec` | 7 | 세 codec + runtime-core |"
},
{
"line": 35771,
"text": "| `MessageCodecRegistry` | 5 | runtime-core가 구현 |"
},
{
"line": 35772,
"text": "| `SchemaReference` | 4 | codec들이 만듦 |"
},
{
"line": 35773,
"text": "| `BoundedByteSink` | 3 | JSON·Avro·Protobuf codec |"
},
{
"line": 35774,
"text": "| `RawBytesMessageCodec` | **0** | 자기 테스트만 |"
},
{
"line": 35775,
"text": "| `SchemaCompatibilityValidator` | **0** | 자기 테스트만 |"
},
{
"line": 35776,
"text": "| `SchemaRegistry` | **0** | 아래 참조 |"
},
{
"line": 35777,
"text": ""
},
{
"line": 35778,
"text": "**`SchemaRegistry`의 \"0\"은 확인이 필요했다.** 단순 이름 검색은 2개 파일을 맞췄지만 둘 다 다른 타입이다:"
},
{
"line": 35779,
"text": ""
},
{
"line": 35780,
"text": "```"
},
{
"line": 35781,
"text": "src/adapter/outbound/messaging/.../LocalJsonSchemaRegistry.java:6: import com.networknt.schema.SchemaRegistry;"
},
{
"line": 35782,
"text": "src/adapter/outbound/notification/.../JsonSchemaVariableValidator.java:5: import com.networknt.schema.SchemaRegistry;"
},
{
"line": 35783,
"text": "```"
},
{
"line": 35784,
"text": ""
},
{
"line": 35785,
"text": "`import dev.caskeleton.messaging.schema.SchemaRegistry` 검색은 exit 1이다. 즉 **이 플랫폼의 `SchemaRegistry` port를 import하는 파일이 저장소에 하나도 없다.** 이름 충돌이 우연히 검색을 오염시킨 사례이고, `-w` 단어 매칭만으로 reachability를 판정하면 안 되는 이유이기도 하다."
},
{
"line": 35786,
"text": ""
},
{
"line": 35787,
"text": "**`SchemaCompatibilityValidator`의 \"0\"이 이 leaf에서 가장 무거운 사실이다.** 검색 결과 전체가 자기 선언과 자기 테스트다. 다시 말해:"
},
{
"line": 35788,
"text": ""
},
{
"line": 35789,
"text": "- 어떤 버전들을 비교해야 하는가 → 아무도 묻지 않는다"
},
{
"line": 35790,
"text": "- `NONE_EXPERIMENTAL`이 production 목적지를 뒷받침할 수 있는가 → 아무도 묻지 않는다"
},
{
"line": 35791,
"text": ""
},
{
"line": 35792,
"text": "`requireProductionMode`는 \"retained log outlives every consumer\"라는 이유로 만들어졌고, 그 게이트가 호출되는 지점이 없다."
},
{
"line": 35793,
"text": ""
},
{
"line": 35794,
"text": "`RawBytesMessageCodec`의 \"0\"은 성격이 다르다. 이 클래스가 없어도 그 **규칙**은 살아 있다 — §12.2 참조."
},
{
"line": 35795,
"text": ""
},
{
"line": 35796,
"text": "##### 12.2 Conditional sibling comparison"
},
{
"line": 35797,
"text": ""
},
{
"line": 35798,
"text": "Spring 주석 0개이므로 bean 활성화 비대칭은 없다."
},
{
"line": 35799,
"text": ""
},
{
"line": 35800,
"text": "대신 이 leaf에는 **다른 형태의 sibling 비대칭**이 있고 결과가 좋다. `RawBytesMessageCodec`의 javadoc이 \"deliberately excluded from default codec selection\"이라고 선언하는 규칙을, 실제로 강제하는 코드는 다른 leaf에 있다:"
},
{
"line": 35801,
"text": ""
},
{
"line": 35802,
"text": "```java"
},
{
"line": 35803,
"text": "// messaging-runtime-core/RegisteredMessageCodecs.java:52-56"
},
{
"line": 35804,
"text": "if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) {"
},
{
"line": 35805,
"text": " throw new IllegalArgumentException("
},
{
"line": 35806,
"text": " \"the raw bytes codec must not be the default: every destination that has not declared an \""
},
{
"line": 35807,
"text": " + \"encoding would silently skip schema validation\");"
},
{
"line": 35808,
"text": "}"
},
{
"line": 35809,
"text": "```"
},
{
"line": 35810,
"text": ""
},
{
"line": 35811,
"text": "**클래스가 아니라 content type으로 판정한다.** 그래서 `RawBytesMessageCodec`을 아무도 쓰지 않아도, 그리고 누가 `ContentType.OCTET_STREAM`을 내놓는 다른 codec을 새로 만들어도 규칙이 유지된다. 선언된 규칙과 강제하는 코드가 다른 leaf에 있으면서 **강제 쪽이 더 넓은** 드문 경우다. 결함이 아니라 확인된 설계로 기록한다."
},
{
"line": 35812,
"text": ""
},
{
"line": 35813,
"text": "##### 12.3 Duplicate mechanism sweep"
},
{
"line": 35814,
"text": ""
},
{
"line": 35815,
"text": "**`SchemaCompatibilityValidator`가 막으려던 중복이 실제로 존재한다.**"
},
{
"line": 35816,
"text": ""
},
{
"line": 35817,
"text": "`AvroCompatibilityGate`(다른 leaf)가 같은 판단을 private static으로 다시 구현했다."
},
{
"line": 35818,
"text": ""
},
{
"line": 35819,
"text": "| 판단 | schema-api (`SchemaCompatibilityValidator`) | schema-avro (`AvroCompatibilityGate`) |"
},
{
"line": 35820,
"text": "|---|---|---|"
},
{
"line": 35821,
"text": "| transitive인가 | `mode == BACKWARD_TRANSITIVE \\|\\| FORWARD_TRANSITIVE \\|\\| FULL_TRANSITIVE` (:107-112) | **같은 식을 그대로** (:49-53) |"
},
{
"line": 35822,
"text": "| 후방 검사하나 | `mode == BACKWARD \\|\\| BACKWARD_TRANSITIVE \\|\\| FULL \\|\\| FULL_TRANSITIVE` — **허용목록** (:79-85) | `mode != FORWARD && mode != FORWARD_TRANSITIVE` — **거부목록** (:55-57) |"
},
{
"line": 35823,
"text": "| 전방 검사하나 | `mode == FORWARD \\|\\| FORWARD_TRANSITIVE \\|\\| FULL \\|\\| FULL_TRANSITIVE` — **허용목록** (:93-99) | `mode != BACKWARD && mode != BACKWARD_TRANSITIVE` — **거부목록** (:59-61) |"
},
{
"line": 35824,
"text": ""
},
{
"line": 35825,
"text": "`isTransitive`는 글자까지 동일한 복사본이다. 방향 판정 둘은 **형태가 반대**다."
},
{
"line": 35826,
"text": ""
},
{
"line": 35827,
"text": "현재 enum 7개 값에 대해 두 구현의 결과를 대조하면 일치한다. `NONE_EXPERIMENTAL`만 다른데(validator는 둘 다 false, gate는 둘 다 true) `AvroCompatibilityGate.check:34`가 그 모드에서 먼저 return하므로 가려진다."
},
{
"line": 35828,
"text": ""
},
{
"line": 35829,
"text": "**문제는 오늘의 불일치가 아니라 형태다.** 허용목록은 새 모드가 추가되면 \"검사 안 함\"으로 기본값이 잡히고, 거부목록은 \"양방향 검사\"로 잡힌다. `SchemaCompatibility`에 값이 하나 추가되는 순간 두 구현은 **반대 방향으로** 갈라진다. javadoc이 예고한 \"how the two formats drift apart\"가 바로 이 형태이고, 그것을 막으려고 만든 클래스는 §12.1에서 보듯 호출되지 않는다."
},
{
"line": 35830,
"text": ""
},
{
"line": 35831,
"text": "`isTransitive`는 `SchemaCompatibilityValidator`에서 **public static**이다. Avro 게이트가 그것을 부를 수 있었고 부르지 않았다."
},
{
"line": 35832,
"text": ""
},
{
"line": 35833,
"text": "##### 12.4 Documentation / measured-count drift"
},
{
"line": 35834,
"text": ""
},
{
"line": 35835,
"text": "이 leaf를 직접 이름으로 언급하는 문서 주장을 재측정했다."
},
{
"line": 35836,
"text": ""
},
{
"line": 35837,
"text": "| 문서 주장 | 재측정 | 결과 |"
},
{
"line": 35838,
"text": "|---|---|---|"
},
{
"line": 35839,
"text": "| 계획 문서: codec은 닫힌 registry에 대해 동작 | `MessageCodec` javadoc + 세 구현의 `requireRegistered`/`schemaFor` | **일치** |"
},
{
"line": 35840,
"text": "| `RawBytesMessageCodec` javadoc: 기본 codec 선택에서 제외됨 | `RegisteredMessageCodecs.of` 생성자 검사 | **일치**(더 넓게 강제) |"
},
{
"line": 35841,
"text": "| `SchemaRegistry` javadoc: history는 oldest-first | 유일한 구현이 테스트 fixture이고 그 계약을 지킴 | 일치하나 production 구현 없음 |"
},
{
"line": 35842,
"text": ""
},
{
"line": 35843,
"text": "§12.4의 family 전체 drift(`support-matrix.md:23`의 runtime membership 주장)는 §A19-MESSAGING-CORE-API §12.4가 소유한다. 이 leaf도 그 18개 wired 목록에 포함된다."
},
{
"line": 35844,
"text": ""
},
{
"line": 35845,
"text": "---"
},
{
"line": 35846,
"text": ""
},
{
"line": 35847,
"text": "#### 13. Git/설계 문서에서 확인한 변화와 실패 기록"
},
{
"line": 35848,
"text": ""
},
{
"line": 35849,
"text": "코드 주석이 보존한 이전 결함:"
},
{
"line": 35850,
"text": ""
},
{
"line": 35851,
"text": "| 위치 | 이전 상태 | 그것이 만든 실패 |"
},
{
"line": 35852,
"text": "|---|---|---|"
},
{
"line": 35853,
"text": "| `BoundedByteSink` javadoc | 각 codec이 무제한 버퍼에 직렬화 후 길이 비교 | 한도가 **보고 임계값**일 뿐 할당 경계가 아님 → 팽창하는 payload 하나가 consumer 프로세스를 죽임 |"
},
{
"line": 35854,
"text": "| `MessageContractKey` javadoc | 타입만으로 registry 키 | v999가 v1 클래스로 디코딩되고 v999 라벨을 유지 → 하위 게이트·감사 기록이 등록된 적 없는 버전을 서술 |"
},
{
"line": 35855,
"text": ""
},
{
"line": 35856,
"text": "두 사례 다 형태가 같다 — **검사가 없었던 게 아니라 검사의 위치/키가 틀렸다.** `messaging-core-api` §13의 \"문자 vs 바이트, 정확일치 vs 세그먼트\" 목록과 같은 계열이다."
},
{
"line": 35857,
"text": ""
},
{
"line": 35858,
"text": "---"
},
{
"line": 35859,
"text": ""
},
{
"line": 35860,
"text": "#### 14. 런타임·터미널 Evidence"
},
{
"line": 35861,
"text": ""
},
{
"line": 35862,
"text": "| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |"
},
{
"line": 35863,
"text": "|---|---|---|---|---|"
},
{
"line": 35864,
"text": "| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | `SchemaCompatibilityValidator` 호출자 전무, allowlist/denylist 두 형태 나란히, `SchemaRegistry` port import 0(exit=1)과 이름 충돌, codec별 소비자 | 정적 `git grep` |"
},
{
"line": 35865,
"text": "| EVD-274 | command | `./gradlew :messaging:messaging-schema-api:test --rerun-tasks` | BUILD SUCCESSFUL, 19 / 0 / 0 | 순수 단위 레인 |"
},
{
"line": 35866,
"text": ""
},
{
"line": 35867,
"text": "---"
},
{
"line": 35868,
"text": ""
},
{
"line": 35869,
"text": "#### 15. 명시적 설계 이유와 추론을 구분한 정리"
},
{
"line": 35870,
"text": ""
},
{
"line": 35871,
"text": "**명시적**"
},
{
"line": 35872,
"text": ""
},
{
"line": 35873,
"text": "- 버전을 registry 키에 넣는 이유 — `MessageContractKey` javadoc"
},
{
"line": 35874,
"text": "- 할당 경계 vs 보고 임계값 — `BoundedByteSink` javadoc"
},
{
"line": 35875,
"text": "- codec 에러 코드를 sink에 넘기는 이유 — `BoundedByteSink` javadoc + 테스트 `as(...)`"
},
{
"line": 35876,
"text": "- 포맷 독립 규칙을 분리한 이유 — `SchemaCompatibilityValidator` javadoc"
},
{
"line": 35877,
"text": "- `NONE_EXPERIMENTAL`을 production에서 막는 이유 — 같은 javadoc"
},
{
"line": 35878,
"text": "- `SchemaRegistry`를 port로 둔 이유, history 순서가 계약인 이유 — `SchemaRegistry` javadoc"
},
{
"line": 35879,
"text": "- raw codec을 기본에서 제외하는 이유 — `RawBytesMessageCodec` javadoc + `RegisteredMessageCodecs` javadoc"
},
{
"line": 35880,
"text": "- `EncodedMessage` 양방향 복사 이유 — `EncodedMessage` javadoc"
},
{
"line": 35881,
"text": ""
},
{
"line": 35882,
"text": "**추론**"
},
{
"line": 35883,
"text": ""
},
{
"line": 35884,
"text": "- `SchemaCompatibilityValidator`가 미호출인 것은 이 저장소에 schema registry를 실제로 운영하는 배포가 없기 때문이다 → **추론**. `SchemaRegistry` production 구현이 0인 것은 관측이고, 인과는 추론이다."
},
{
"line": 35885,
"text": "- Avro 게이트가 자기 복사본을 쓴 이유 → **미상**. 커밋 메시지에 근거가 없다."
},
{
"line": 35886,
"text": ""
},
{
"line": 35887,
"text": "---"
},
{
"line": 35888,
"text": ""
},
{
"line": 35889,
"text": "#### 16. 확인한 것 / 확인하지 못한 것"
},
{
"line": 35890,
"text": ""
},
{
"line": 35891,
"text": "**확인한 것**"
},
{
"line": 35892,
"text": ""
},
{
"line": 35893,
"text": "- 10개 타입 전부의 계약과 불변식"
},
{
"line": 35894,
"text": "- 19개 테스트가 통과하고 무엇을 단언하는지"
},
{
"line": 35895,
"text": "- `SchemaCompatibilityValidator`·`RawBytesMessageCodec`·`SchemaRegistry`의 leaf 밖 참조 0 (`SchemaRegistry`는 이름 충돌을 배제한 뒤)"
},
{
"line": 35896,
"text": "- Avro 게이트의 중복 구현과 두 형태의 차이"
},
{
"line": 35897,
"text": "- raw-bytes 기본 금지 규칙이 content type 기준으로 더 넓게 강제된다는 것"
},
{
"line": 35898,
"text": ""
},
{
"line": 35899,
"text": "**확인하지 못한 것**"
},
{
"line": 35900,
"text": ""
},
{
"line": 35901,
"text": "- `SchemaCompatibility` enum이 실제로 확장될 계획이 있는지. §12.3의 위험은 그때 실현된다."
},
{
"line": 35902,
"text": "- port 구현의 스레드 안전성 요구. javadoc에 없고 이 저장소에 production 구현이 없어 관측할 대상이 없다."
},
{
"line": 35903,
"text": "- `BoundedByteSink`의 경계가 실제 Jackson/Avro/Protobuf 인코더에서 기대대로 동작하는지 — 각 codec leaf의 테스트가 소유하고 이 문서 범위 밖이다."
},
{
"line": 35904,
"text": ""
},
{
"line": 35905,
"text": "---"
},
{
"line": 35906,
"text": ""
},
{
"line": 35907,
"text": "#### 17. 손볼 것"
},
{
"line": 35908,
"text": ""
},
{
"line": 35909,
"text": "##### P2 — 포맷 독립 진화 규칙이 호출되지 않고, 그것이 막으려던 중복이 실제로 생겼다"
},
{
"line": 35910,
"text": ""
},
{
"line": 35911,
"text": "- **사실.** `SchemaCompatibilityValidator`의 저장소 전체 참조가 자기 선언과 자기 테스트뿐이다. 동시에 `AvroCompatibilityGate`가 `isTransitive`를 글자 그대로 복사했고 방향 판정 둘은 허용목록/거부목록으로 형태가 반대다."
},
{
"line": 35912,
"text": "- **근거.** `evidence/raw/272` §A, §B."
},
{
"line": 35913,
"text": "- **왜 문제인가.** 오늘은 7개 모드 전부에서 두 구현의 결과가 같다(`NONE_EXPERIMENTAL`은 gate의 early return이 가린다). 그러나 enum에 값이 하나 추가되면 허용목록은 \"검사 안 함\", 거부목록은 \"양방향 검사\"로 **반대 방향** 기본값을 갖는다. 그리고 `requireProductionMode` — 검사 없는 스키마가 보존 로그를 뒷받침하는 것을 막는 게이트 — 는 호출되는 곳이 없다."
},
{
"line": 35914,
"text": "- **확인 방법.** `git grep -n -E 'requireProductionMode|versionsToCheck|SchemaCompatibilityValidator' -- 'src/**/*.java'`"
},
{
"line": 35915,
"text": "- **후보.** (a) Avro 게이트가 `SchemaCompatibilityValidator`의 public static을 부르게 한다. (b) validator를 CI 게이트에 배선한다. (c) 둘 다 쓰지 않을 거라면 validator를 제거하고 규칙 소유권을 게이트로 옮긴다."
},
{
"line": 35916,
"text": "- **다음 단계.** **CASE 후보 + REFERENCE 후보**. \"중복을 막으려고 만든 추상이 호출되지 않으면 중복은 그대로 생긴다\"는 형태가 재사용 가능하다. 그리고 \"허용목록과 거부목록은 enum이 자라는 순간 반대로 갈라진다\"도 별도 기준이다."
},
{
"line": 35917,
"text": ""
},
{
"line": 35918,
"text": "##### P3 — port 구현의 스레드 안전성 요구가 문서화되어 있지 않다"
},
{
"line": 35919,
"text": ""
},
{
"line": 35920,
"text": "- **사실.** `SchemaRegistry`와 `MessageCodecRegistry` javadoc에 동시성 요구가 없다. `BoundedByteSink`만 \"not thread-safe\"를 명시한다."
},
{
"line": 35921,
"text": "- **근거.** 세 타입의 javadoc 전문."
},
{
"line": 35922,
"text": "- **왜 문제인가.** `MessageCodecRegistry`의 유일한 구현 `RegisteredMessageCodecs`는 `Map.copyOf`로 불변이라 안전하지만, 그것은 구현의 성질이지 계약이 아니다. 외부 registry를 감싸는 `SchemaRegistry` 구현은 브로커 소비자 스레드들에서 동시에 호출된다."
},
{
"line": 35923,
"text": "- **확인 방법.** 세 인터페이스의 javadoc 확인."
},
{
"line": 35924,
"text": "- **후보.** port javadoc에 \"구현은 스레드 안전해야 한다\"를 명시."
},
{
"line": 35925,
"text": "- **다음 단계.** **REFERENCE 후보**(port 계약은 동시성 요구를 적는다)."
},
{
"line": 35926,
"text": ""
},
{
"line": 35927,
"text": "##### P3 — `SchemaRegistry`라는 이름이 저장소에서 두 가지를 가리킨다"
},
{
"line": 35928,
"text": ""
},
{
"line": 35929,
"text": "- **사실.** `dev.caskeleton.messaging.schema.SchemaRegistry`(이 leaf의 port)와 `com.networknt.schema.SchemaRegistry`(JSON Schema 라이브러리)가 공존하고, 후자만 실제로 import된다."
},
{
"line": 35930,
"text": "- **근거.** `evidence/raw/272` §C."
},
{
"line": 35931,
"text": "- **왜 문제인가.** 지금 깨지는 것은 없다. 다만 reachability 판정에서 실제로 오탐을 만들었다 — 단어 검색이 2건을 맞췄고 둘 다 다른 타입이었다. 사람이 같은 실수를 한다."
},
{
"line": 35932,
"text": "- **확인 방법.** `git grep -n 'import .*\\.SchemaRegistry;' -- src`"
},
{
"line": 35933,
"text": "- **후보.** 이름 변경 없이 두는 것이 합리적일 수 있다. 기록만 남긴다."
},
{
"line": 35934,
"text": "- **다음 단계.** **REFERENCE 후보**(도달성 판정은 단어가 아니라 import로 확인한다)."
},
{
"line": 35935,
"text": ""
},
{
"line": 35936,
"text": "##### 확인된 설계(문제 아님)"
},
{
"line": 35937,
"text": ""
},
{
"line": 35938,
"text": "- `BoundedByteSink`가 codec의 에러 코드를 전달하고, pre-flight가 예산을 소비하지 않는 것 — 테스트가 양쪽을 고정"
},
{
"line": 35939,
"text": "- `EncodedMessage`의 양방향 방어 복사와 payload를 찍지 않는 `toString`"
},
{
"line": 35940,
"text": "- 버전을 registry 키에 포함하고 \"타입 미등록\"과 \"버전 미등록\"을 다른 코드로 구분하는 것"
},
{
"line": 35941,
"text": "- raw-bytes 기본 금지가 클래스가 아니라 content type으로 강제되는 것"
},
{
"line": 35942,
"text": ""
},
{
"line": 35943,
"text": "---"
},
{
"line": 35944,
"text": ""
},
{
"line": 35945,
"text": "#### Source anchors"
},
{
"line": 35946,
"text": ""
},
{
"line": 35947,
"text": "| id | kind | path | revision | what it proves | limitations |"
},
{
"line": 35948,
"text": "|---|---|---|---|---|---|"
},
{
"line": 35949,
"text": "| MSA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps `[\"messaging-core-api\"]`, memberships `[\"app-bootstrap\"]` | 선언 |"
},
{
"line": 35950,
"text": "| MSA-002 | build | `messaging-schema-api/build.gradle` | same | vendor 의존성 0 | — |"
},
{
"line": 35951,
"text": "| MSA-003 | code | `.../schema/MessageContractKey.java` | same | 버전 키 결정과 그 이유 | — |"
},
{
"line": 35952,
"text": "| MSA-004 | code | `.../schema/BoundedByteSink.java` | same | 할당 경계, 에러 코드 전달, pre-flight | 실제 인코더 동작은 각 codec leaf |"
},
{
"line": 35953,
"text": "| MSA-005 | code | `.../schema/EncodedMessage.java` | same | 양방향 복사, 배열 equals, 안전한 toString | — |"
},
{
"line": 35954,
"text": "| MSA-006 | code | `.../schema/SchemaCompatibilityValidator.java` | same | 포맷 독립 규칙과 분리 이유 | 호출자 없음(§12.1) |"
},
{
"line": 35955,
"text": "| MSA-007 | code | `.../schema/SchemaRegistry.java` | same | port 계약, history oldest-first | production 구현 없음 |"
},
{
"line": 35956,
"text": "| MSA-008 | code | `.../schema/RawBytesMessageCodec.java` | same | escape hatch 계약 | 외부 사용 0 |"
},
{
"line": 35957,
"text": "| MSA-009 | code | `.../schema/{MessageCodec,MessageCodecRegistry,SchemaReference,SchemaCompatibility}.java` | same | codec/식별/모드 계약 | — |"
},
{
"line": 35958,
"text": "| MSA-010 | test | `src/test/java/**` (3 클래스 / 19 테스트) | same | §10 표 | 순수 단위 |"
},
{
"line": 35959,
"text": "| MSA-011 | cross-leaf code | `messaging-runtime-core/.../RegisteredMessageCodecs.java:29-77` | same | raw-bytes 기본 금지의 실제 강제 지점, 중복 content type 거절 | 해당 leaf SSOT가 소유 |"
},
{
"line": 35960,
"text": "| MSA-012 | cross-leaf code | `messaging-schema-avro/.../AvroCompatibilityGate.java:34-61` | same | 중복 구현과 두 형태의 차이 | 해당 leaf SSOT가 소유 |"
},
{
"line": 35961,
"text": "| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | §12.1·§12.3 전부 | 정적 검색 |"
},
{
"line": 35962,
"text": "| EVD-274 | command | `./gradlew :messaging:messaging-schema-api:test --rerun-tasks` | same | 19 / 0 skipped / 0 failures | 순수 단위 |"
},
{
"line": 35963,
"text": ""
},
{
"line": 35964,
"text": "---"
},
{
"line": 35965,
"text": ""
},
{
"line": 35966,
"text": "## A19-MESSAGING-SCHEMA-AVRO. messaging-schema-avro"
},
{
"line": 35967,
"text": ""
},
{
"line": 35968,
"text": "> 분석 중에는 `messaging/MESSAGING-SCHEMA-AVRO.md` 파일이었다. 597줄."
},
{
"line": 35969,
"text": ""
},
{
"line": 35970,
"text": "### messaging-schema-avro 완전 해부"
},
{
"line": 35971,
"text": ""
},
{
"line": 35972,
"text": "> 상태: COMPLETE"
},
{
"line": 35973,
"text": "> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`"
},
{
"line": 35974,
"text": "> 분석 범위: `src/messaging/messaging-schema-avro`"
},
{
"line": 35975,
"text": "> SSOT owner: `messaging-schema-avro`"
},
{
"line": 35976,
"text": "> integration/family document: §A19 (secondary, INTEGRATION_ONLY)"
},
{
"line": 35977,
"text": ""
},
{
"line": 35978,
"text": "---"
},
{
"line": 35979,
"text": ""
},
{
"line": 35980,
"text": "#### 0. SSOT identity / 커버리지와 숫자 지도"
},
{
"line": 35981,
"text": ""
},
{
"line": 35982,
"text": "- registered leaf id: `messaging-schema-avro`"
},
{
"line": 35983,
"text": "- canonical state `analysisFile`: §A19-MESSAGING-SCHEMA-AVRO"
},
{
"line": 35984,
"text": "- source path: `src/messaging/messaging-schema-avro`"
},
{
"line": 35985,
"text": "- registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-schema-api\"]`"
},
{
"line": 35986,
"text": "- registry `runtime_memberships`: **`[]`** — build-only / incubating"
},
{
"line": 35987,
"text": ""
},
{
"line": 35988,
"text": "##### 숫자"
},
{
"line": 35989,
"text": ""
},
{
"line": 35990,
"text": "| 항목 | 수 |"
},
{
"line": 35991,
"text": "|---|---:|"
},
{
"line": 35992,
"text": "| production Java 파일 | 2 |"
},
{
"line": 35993,
"text": "| production LOC | 345 |"
},
{
"line": 35994,
"text": "| 패키지 | 1 (`dev.caskeleton.messaging.schema.avro`) |"
},
{
"line": 35995,
"text": "| test 파일 | 3 |"
},
{
"line": 35996,
"text": "| test 메서드(실행 확인) | 16 |"
},
{
"line": 35997,
"text": "| test resource | `/schemas/order.created/v1.avsc` |"
},
{
"line": 35998,
"text": "| 외부 의존성 | 1 (`org.apache.avro:avro:1.12.0`, **`api`**) |"
},
{
"line": 35999,
"text": ""
},
{
"line": 36000,
"text": "두 클래스: `AvroMessageCodec`(런타임 인코딩/디코딩), `AvroCompatibilityGate`(CI용 진화 검사)."
},
{
"line": 36001,
"text": ""
},
{
"line": 36002,
"text": "##### Coverage ledger"
},
{
"line": 36003,
"text": ""
},
{
"line": 36004,
"text": "| scope/file group | count | disposition | reason |"
},
{
"line": 36005,
"text": "|---|---:|---|---|"
},
{
"line": 36006,
"text": "| `.../avro/AvroMessageCodec.java` | 1 | `FULL_READ` | 272줄 전문 |"
},
{
"line": 36007,
"text": "| `.../avro/AvroCompatibilityGate.java` | 1 | `FULL_READ` | 73줄 전문 |"
},
{
"line": 36008,
"text": "| `src/test/java/**` | 3 | `FULL_READ` | 전문 |"
},
{
"line": 36009,
"text": "| `src/test/resources/schemas/order.created/v1.avsc` | 1 | `STRUCTURAL_ONLY` | fixture 스키마; 필드 구성만 확인 |"
},
{
"line": 36010,
"text": "| `build.gradle` | 1 | `FULL_READ` | 주석 포함 11줄 |"
},
{
"line": 36011,
"text": "| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |"
},
{
"line": 36012,
"text": "| `build/**` | — | `EXCLUDED` | 빌드 산출물 |"
},
{
"line": 36013,
"text": ""
},
{
"line": 36014,
"text": "`UNCLASSIFIED` 0."
},
{
"line": 36015,
"text": ""
},
{
"line": 36016,
"text": "---"
},
{
"line": 36017,
"text": ""
},
{
"line": 36018,
"text": "#### 1. 모듈의 정체와 경계"
},
{
"line": 36019,
"text": ""
},
{
"line": 36020,
"text": "선택적(optional) Avro codec. Stable이 아니고 registry membership이 비어 있다 — **build-only / incubating**이며, `docs/messaging/support-matrix.md`의 등급과는 다른 축이다."
},
{
"line": 36021,
"text": ""
},
{
"line": 36022,
"text": "Avro를 `api`로 선언한 이유가 build.gradle 주석에 있다."
},
{
"line": 36023,
"text": ""
},
{
"line": 36024,
"text": "```groovy"
},
{
"line": 36025,
"text": "// api: AvroMessageCodec's constructors take a registry of org.apache.avro.Schema and"
},
{
"line": 36026,
"text": "// AvroCompatibilityGate.check takes and compares them. A consumer cannot build that"
},
{
"line": 36027,
"text": "// registry without naming the type, so hiding the dependency only stops them compiling."
},
{
"line": 36028,
"text": "api 'org.apache.avro:avro:1.12.0'"
},
{
"line": 36029,
"text": "```"
},
{
"line": 36030,
"text": ""
},
{
"line": 36031,
"text": "`src/messaging/CLAUDE.md:40-43`이 기술하는 게이트 — public/protected 시그니처에 나오는 vendor 라이브러리가 `api`로 선언됐는지 대조 — 를 이 leaf가 통과한다. 형제 `messaging-schema-json`은 Jackson 타입이 시그니처에 없으므로 `implementation`이고, 그 판정 차이가 규칙이 실제로 작동한다는 증거다."
},
{
"line": 36032,
"text": ""
},
{
"line": 36033,
"text": "**클래스 둘의 실행 시점이 다르다.**"
},
{
"line": 36034,
"text": ""
},
{
"line": 36035,
"text": "| 클래스 | 언제 도는가 | 근거 |"
},
{
"line": 36036,
"text": "|---|---|---|"
},
{
"line": 36037,
"text": "| `AvroMessageCodec` | 런타임(메시지마다) | `MessageCodec` 구현 |"
},
{
"line": 36038,
"text": "| `AvroCompatibilityGate` | **CI** | 클래스 javadoc: \"Run in CI rather than at runtime\" |"
},
{
"line": 36039,
"text": ""
},
{
"line": 36040,
"text": "게이트의 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.\""
},
{
"line": 36041,
"text": ""
},
{
"line": 36042,
"text": "---"
},
{
"line": 36043,
"text": ""
},
{
"line": 36044,
"text": "#### 2. 의존성과 런타임 배선"
},
{
"line": 36045,
"text": ""
},
{
"line": 36046,
"text": "들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `avro:1.12.0`(api)."
},
{
"line": 36047,
"text": ""
},
{
"line": 36048,
"text": "나가는 것: **없다.** 어떤 leaf의 `allowed_dependencies`에도 `messaging-schema-avro`가 없다. `messaging-spring-boot-starter`의 17개 의존 목록에도 없다."
},
{
"line": 36049,
"text": ""
},
{
"line": 36050,
"text": "런타임 배선: 없음. `runtime_memberships: []`이므로 배포 아티팩트에 실리지 않는다. bean도 없다(Spring 주석 0개)."
},
{
"line": 36051,
"text": ""
},
{
"line": 36052,
"text": "**소비자 없음과 membership 없음이 일치한다.** 이것이 정합적인 incubating 상태다 — `messaging-cloudevents`와 대비된다(그쪽은 membership이 있고 소비자가 없다)."
},
{
"line": 36053,
"text": ""
},
{
"line": 36054,
"text": "---"
},
{
"line": 36055,
"text": ""
},
{
"line": 36056,
"text": "#### 3. 패키지/컴포넌트 지도"
},
{
"line": 36057,
"text": ""
},
{
"line": 36058,
"text": "```"
},
{
"line": 36059,
"text": "AvroMessageCodec (MessageCodec 구현)"
},
{
"line": 36060,
"text": "├── encode(type, version, GenericRecord) → EncodedMessage"
},
{
"line": 36061,
"text": "├── decode(type, version, byte[], Class) → GenericRecord (writer == reader)"
},
{
"line": 36062,
"text": "├── decodeEvolved(type, writerV, readerV, byte[]) → GenericRecord (writer != reader)"
},
{
"line": 36063,
"text": "├── schemaFor(type, version) → 등록 조회, 2단 에러"
},
{
"line": 36064,
"text": "├── boundedReader(writer, reader) → newArray 오버라이드"
},
{
"line": 36065,
"text": "└── flatten(nested registry) → (type, version) 평탄화 + 깊은 복사"
},
{
"line": 36066,
"text": ""
},
{
"line": 36067,
"text": "AvroCompatibilityGate (CI)"
},
{
"line": 36068,
"text": "└── check(candidate, history, mode)"
},
{
"line": 36069,
"text": " ├── isTransitive / readsBackward / readsForward (private, 자체 구현)"
},
{
"line": 36070,
"text": " └── requireCompatible → org.apache.avro.SchemaCompatibility"
},
{
"line": 36071,
"text": "```"
},
{
"line": 36072,
"text": ""
},
{
"line": 36073,
"text": "---"
},
{
"line": 36074,
"text": ""
},
{
"line": 36075,
"text": "#### 4. 계약·불변식·상태 모델"
},
{
"line": 36076,
"text": ""
},
{
"line": 36077,
"text": "##### 4.1 Avro 바이너리에는 스키마가 없다 — 그래서 registry가 계약이다"
},
{
"line": 36078,
"text": ""
},
{
"line": 36079,
"text": "```java"
},
{
"line": 36080,
"text": "// AvroMessageCodec.java:33-37"
},
{
"line": 36081,
"text": " * Decoding uses an explicit writer schema and reader schema pair. Avro binary carries no schema"
},
{
"line": 36082,
"text": " * of its own, so decoding with the wrong schema does not fail — it produces plausible garbage. The"
},
{
"line": 36083,
"text": " * registry is what makes the writer schema knowable, and passing both schemas to the reader is what"
},
{
"line": 36084,
"text": " * makes evolution work: Avro resolves added, removed, and defaulted fields only when it can see"
},
{
"line": 36085,
"text": " * both sides."
},
{
"line": 36086,
"text": "```"
},
{
"line": 36087,
"text": ""
},
{
"line": 36088,
"text": "\"does not fail — it produces plausible garbage\"가 이 leaf의 모든 방어의 전제다. JSON이나 Protobuf와 달리 Avro는 잘못된 스키마로 디코딩해도 예외를 던지지 않는 경우가 있다."
},
{
"line": 36089,
"text": ""
},
{
"line": 36090,
"text": "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.\""
},
{
"line": 36091,
"text": ""
},
{
"line": 36092,
"text": "##### 4.2 `flatten`: 얕은 복사가 만든 구멍"
},
{
"line": 36093,
"text": ""
},
{
"line": 36094,
"text": "생성자가 받는 것은 중첩 맵 `Map {@code Map.copyOf} on the outer map is a shallow copy: every inner {@code Map Avro writes an array as a declared element count followed by the elements. The count is a"
},
{
"line": 36136,
"text": " * variable-length integer, so five bytes can claim four hundred million elements, and the generic"
},
{
"line": 36137,
"text": " * reader allocates the backing array from that claim before reading a single element. Bounding"
},
{
"line": 36138,
"text": " * the input length does not help: the whole hostile payload is five bytes, well under any limit,"
},
{
"line": 36139,
"text": " * and the failure is an {@code OutOfMemoryError} rather than an exception the codec could report"
},
{
"line": 36140,
"text": " * — on a consumer thread that is the process, not the message."
},
{
"line": 36141,
"text": " *"
},
{
"line": 36142,
"text": " * The ceiling is the byte limit itself. Every element costs at least one byte on the wire even"
},
{
"line": 36143,
"text": " * when it is empty, so a payload of at most {@code maxBytes} bytes cannot honestly contain more"
},
{
"line": 36144,
"text": " * than {@code maxBytes} elements, and any larger claim is a lie the reader should refuse rather"
},
{
"line": 36145,
"text": " * than reserve memory for."
},
{
"line": 36146,
"text": "```"
},
{
"line": 36147,
"text": ""
},
{
"line": 36148,
"text": "구현은 익명 서브클래스의 `newArray` 오버라이드다."
},
{
"line": 36149,
"text": ""
},
{
"line": 36150,
"text": "```java"
},
{
"line": 36151,
"text": "return new GenericDatumReader<>(writerSchema, readerSchema) {"
},
{
"line": 36152,
"text": " @Override"
},
{
"line": 36153,
"text": " protected Object newArray(Object old, int size, Schema schema) {"
},
{
"line": 36154,
"text": " if (size > maxElements) {"
},
{
"line": 36155,
"text": " throw new MessageTooLargeException(\"AVRO_COLLECTION_TOO_LARGE\", ...);"
},
{
"line": 36156,
"text": " }"
},
{
"line": 36157,
"text": " return super.newArray(old, size, schema);"
},
{
"line": 36158,
"text": " }"
},
{
"line": 36159,
"text": "};"
},
{
"line": 36160,
"text": "```"
},
{
"line": 36161,
"text": ""
},
{
"line": 36162,
"text": "**상한 선택의 논리가 정확하다.** 원소 하나가 wire에서 최소 1바이트를 쓰므로, `maxBytes` 바이트짜리 payload가 정직하게 담을 수 있는 원소는 `maxBytes`개를 넘을 수 없다. 별도 튜닝 상수를 만들지 않고 이미 있는 경계에서 파생시켰다."
},
{
"line": 36163,
"text": ""
},
{
"line": 36164,
"text": "`AvroHostileInputTest`가 이 공격을 손으로 만든 zigzag varint로 재현한다."
},
{
"line": 36165,
"text": ""
},
{
"line": 36166,
"text": "```java"
},
{
"line": 36167,
"text": "// AvroHostileInputTest.java:118-123"
},
{
"line": 36168,
"text": " * Hand-written rather than taken from an encoder because the point is to write a count with no"
},
{
"line": 36169,
"text": " * elements behind it, which no encoder will do."
},
{
"line": 36170,
"text": "```"
},
{
"line": 36171,
"text": ""
},
{
"line": 36172,
"text": "그리고 공격의 크기를 직접 단언한다 — `assertThat(hostile).as(\"the whole attack is five bytes, so no byte limit stands between it and the allocation\").hasSizeLessThan(16)`."
},
{
"line": 36173,
"text": ""
},
{
"line": 36174,
"text": "테스트 클래스 javadoc이 **왜 corpus가 좁은지**까지 적는다."
},
{
"line": 36175,
"text": ""
},
{
"line": 36176,
"text": "```java"
},
{
"line": 36177,
"text": "// AvroHostileInputTest.java:30-33"
},
{
"line": 36178,
"text": " * Strings, byte arrays and maps were already safe: Avro validates those lengths against the"
},
{
"line": 36179,
"text": " * bytes actually remaining. Arrays were the one shape that allocated on trust, which is why the"
},
{
"line": 36180,
"text": " * corpus below is narrow rather than exhaustive — it pins the case that failed, and the two cases"
},
{
"line": 36181,
"text": " * that must keep working around it."
},
{
"line": 36182,
"text": "```"
},
{
"line": 36183,
"text": ""
},
{
"line": 36184,
"text": "이것은 \"좁은 테스트\"를 정당화한 드문 예다 — 다른 형태는 라이브러리가 이미 방어하므로 재확인이 아니라 잡음이 된다."
},
{
"line": 36185,
"text": ""
},
{
"line": 36186,
"text": "##### 4.5 `schemaFor`: 2단 에러"
},
{
"line": 36187,
"text": ""
},
{
"line": 36188,
"text": "`AVRO_TYPE_NOT_REGISTERED`(타입 미등록)와 `AVRO_VERSION_NOT_REGISTERED`(버전 미등록)를 구분한다. JSON codec의 `UNKNOWN_MESSAGE_TYPE`/`SCHEMA_VERSION_NOT_REGISTERED`와 같은 형태이지만 **코드 문자열이 다르다.** 두 codec이 같은 판단을 다른 어휘로 보고한다 — §12.3."
},
{
"line": 36189,
"text": ""
},
{
"line": 36190,
"text": "##### 4.6 `decodeEvolved`: 나중에 붙은 경계"
},
{
"line": 36191,
"text": ""
},
{
"line": 36192,
"text": "```java"
},
{
"line": 36193,
"text": "// AvroMessageCodec.java:199-201"
},
{
"line": 36194,
"text": "// The same bound the ordinary decode applies. It was missing here, so the evolution path — the"
},
{
"line": 36195,
"text": "// one a consumer takes for every message written by a newer producer — accepted input of any"
},
{
"line": 36196,
"text": "// size."
},
{
"line": 36197,
"text": "requireWithinLimit(encoded.length);"
},
{
"line": 36198,
"text": "```"
},
{
"line": 36199,
"text": ""
},
{
"line": 36200,
"text": "테스트가 두 각도에서 붙든다 — `AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound`(`as(\"decodeEvolved accepted input of any size\")`)와 `AvroHostileInputTest.theEvolutionDecodeAppliesTheSameCollectionBound`(`as(\"a consumer reading a newer producer takes this path for every message\")`)."
},
{
"line": 36201,
"text": ""
},
{
"line": 36202,
"text": "즉 `decodeEvolved`는 **가장 흔한 경로인데 가장 늦게 보호됐다.** 진화 경로는 producer가 앞서 나간 순간부터 모든 메시지가 지나는 길이다."
},
{
"line": 36203,
"text": ""
},
{
"line": 36204,
"text": "##### 4.7 `AvroCompatibilityGate`"
},
{
"line": 36205,
"text": ""
},
{
"line": 36206,
"text": "```java"
},
{
"line": 36207,
"text": "public void check(Schema candidate, List Implementations operate against a closed message-type registry. Accepting an unregistered type\n35486 | * would let a producer introduce a wire contract nothing has reviewed, which is the same class of\n35487 | * problem that makes Java serialization unsupported here.\n35488 | ```\n35489 | \n35490 | `build.gradle`는 `api project(':messaging:messaging-core-api')` 하나뿐이고 vendor 의존성이 없다. 포맷별 vendor(`jackson`, `avro`, `protobuf`)는 각자 leaf가 갖는다.\n35491 | \n35492 | ---\n35493 | \n35494 | #### 2. 의존성과 런타임 배선\n35495 | \n35496 | 들어오는 것: `messaging-core-api`(api 노출).\n35497 | \n35498 | 나가는 것: `messaging-schema-json`, `messaging-schema-avro`, `messaging-schema-protobuf`, `messaging-cloudevents`, `messaging-policy`, `messaging-transport-spi`, `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`, `messaging-testkit`.\n35499 | \n35500 | 런타임 편입은 `messaging-core-api`와 같은 경로다 — `app-bootstrap`이 `messaging-spring-boot-starter`를 선언하고 그 closure가 이 leaf를 끌어온다.\n35501 | \n35502 | 이 leaf는 bean을 만들지 않는다. Spring 주석 0개.\n35503 | \n35504 | ---\n35505 | \n35506 | #### 3. 패키지/컴포넌트 지도\n35507 | \n35508 | 패키지 하나에 10개 타입이 평평하게 있다. 관심사로 나누면 셋이다.\n35509 | \n35510 | ```\n35511 | codec 축 MessageCodec ── MessageCodecRegistry\n35512 | │\n35513 | └── RawBytesMessageCodec (유일한 구현)\n35514 | \n35515 | 식별 축 MessageContractKey (type, version)\n35516 | SchemaReference (subject, version, uri?)\n35517 | EncodedMessage (bytes, contentType, schemaReference?)\n35518 | \n35519 | 진화 축 SchemaRegistry ── SchemaCompatibility(7)\n35520 | │\n35521 | └── SchemaCompatibilityValidator\n35522 | \n35523 | 경계 축 BoundedByteSink\n35524 | ```\n35525 | \n35526 | ---\n35527 | \n35528 | #### 4. 계약·불변식·상태 모델\n35529 | \n35530 | ##### 4.1 `MessageContractKey`: 버전을 키에 넣는 이유\n35531 | \n35532 | 이 leaf에서 가장 밀도 높은 javadoc이다.\n35533 | \n35534 | ```java\n35535 | // MessageContractKey.java:10-17\n35536 | * Keying on the message type alone is what let an unregistered version decode. The version\n35537 | * travels in the envelope and in {@link SchemaReference}, so a consumer receiving {@code\n35538 | * order.created v999} would look up {@code order.created}, find the v1 class or parser, decode\n35539 | * against it, and then keep the v999 label on the result. Nothing failed, and every downstream\n35540 | * compatibility gate and audit record then described a version that was never registered.\n35541 | ```\n35542 | \n35543 | 핵심은 \"Nothing failed\"다. 타입만으로 키를 잡으면 실패가 발생하지 않고 **잘못된 성공**이 발생한다. 그리고 그 결과에는 등록된 적 없는 버전 라벨이 붙어 하위 감사 기록까지 오염된다.\n35544 | \n35545 | 이 결정은 세 codec에 전부 반영돼 있다 — `JacksonMessageCodec.requireRegistered`, `AvroMessageCodec.schemaFor`, `ProtobufMessageCodec.requireRegistered`가 모두 \"타입은 아는데 버전을 모른다\"와 \"타입 자체를 모른다\"를 **다른 에러 코드**로 구분한다(`SCHEMA_VERSION_NOT_REGISTERED` vs `UNKNOWN_MESSAGE_TYPE`). 그 구분이 있어야 운영자가 \"등록을 빠뜨렸다\"와 \"오타다\"를 나눌 수 있다.\n35546 | \n35547 | ##### 4.2 `BoundedByteSink`: 보고 임계값 → 할당 경계\n35548 | \n35549 | ```java\n35550 | // BoundedByteSink.java:11-15\n35551 | * Every codec here used to serialize into an unbounded buffer and compare {@code bytes.length}\n35552 | * to the configured maximum afterwards. That makes the maximum a reporting threshold rather than an\n35553 | * allocation bound: a payload whose graph expands to hundreds of megabytes exhausts the heap while\n35554 | * being written, and the check that would have rejected it never runs. Under a broker consumer that\n35555 | * is a process-wide outage caused by one message.\n35556 | ```\n35557 | \n35558 | 세 가지 설계 결정이 붙어 있다.\n35559 | \n35560 | 1. **버퍼를 한도로 미리 잡지 않는다.** `new ByteArrayOutputStream(Math.min(maxBytes, 8_192))` — 주석: \"a 1 GiB bound must not pre-allocate 1 GiB.\"\n35561 | 2. **codec의 에러 코드를 그대로 던진다.** `errorCode`가 생성자 인자다. 그래서 Avro는 `AVRO_PAYLOAD_TOO_LARGE`, JSON은 `PAYLOAD_TOO_LARGE`가 나온다. 테스트가 이 성질을 직접 단언한다(`BoundedByteSinkTest.java:69-77`, `as(\"the sink reports the codec's own code, not a generic one\")`).\n35562 | 3. **`requireFits(size)`는 예산을 소비하지 않는다.** Protobuf는 직렬화 크기를 미리 알므로 첫 바이트 전에 거절할 수 있다. 그리고 그 뒤의 쓰기도 여전히 경계 안이다 — 주석: \"this is a cheaper refusal, not a replacement for the bound.\"\n35563 | \n35564 | `refuseIfBeyondLimit`가 `size > maxBytes - written`으로 비교하는 것도 의도적이다. `written + size > maxBytes`였다면 `int` 오버플로가 가능하다.\n35565 | \n35566 | 테스트가 실제 시나리오를 재현한다 — 10 MiB를 1 KiB씩 제공하고, `written()`이 한도(64) 이하로 유지되며 `toByteArray()`가 비어 있음을 확인한다(`BoundedByteSinkTest.java:34-53`).\n35567 | \n35568 | ##### 4.3 `EncodedMessage`: 양방향 방어 복사\n35569 | \n35570 | ```java\n35571 | public EncodedMessage {\n35572 | ...\n35573 | bytes = bytes.clone(); // 생성 시\n35574 | }\n35575 | \n35576 | @Override\n35577 | public byte[] bytes() {\n35578 | return bytes.clone(); // 접근 시\n35579 | }\n35580 | ```\n35581 | \n35582 | javadoc이 이유를 적는다 — \"These bytes travel through retry, DLQ, and redrive paths where a shared mutable array would let one stage corrupt another's copy of the same logical message.\"\n35583 | \n35584 | `equals`/`hashCode`는 `Arrays.equals`/`Arrays.hashCode`로 재정의된다(record 기본은 배열 참조 비교라 항상 불일치). `toString`은 바이트를 찍지 않고 크기만 찍는다 — payload가 로그에 새지 않는다.\n35585 | \n35586 | `size()`가 복사 없이 길이를 반환하는 별도 메서드로 있는 것도 의도적이다. `bytes().length`는 전체 복사를 유발한다.\n35587 | \n35588 | ##### 4.4 `SchemaCompatibility`: 7개 모드와 transitive의 의미\n35589 | \n35590 | ```java\n35591 | // SchemaCompatibility.java:6-8\n35592 | * Transitive modes check every historical version, not just the immediate predecessor. That\n35593 | * matters for integration events, where a consumer may be several releases behind and a chain of\n35594 | * individually-compatible changes can still be collectively breaking.\n35595 | ```\n35596 | \n35597 | `NONE_EXPERIMENTAL`은 \"M2 raw bytes에만 허용\"이라고 enum 상수 javadoc이 적는다.\n35598 | \n35599 | ##### 4.5 `SchemaRegistry`: 포트이고, 순서가 계약이다\n35600 | \n35601 | ```java\n35602 | // SchemaRegistry.java:16-17\n35603 | * {@link #history} returns oldest first. Transitive compatibility checks read the whole list, so\n35604 | * an ordering mistake here silently converts a transitive check into a pairwise one.\n35605 | ```\n35606 | \n35607 | 이것은 문서화된 함정이다. `history`가 newest-first로 구현되면 `versionsToCheck`가 `reversed()`한 뒤 `history.get(0)`을 취하므로 **가장 오래된 버전 하나**만 비교하게 된다 — transitive가 pairwise로 조용히 축소되는 것이 아니라 아예 엉뚱한 버전을 비교한다.\n35608 | \n35609 | `latest(subject)`가 default 메서드로 `versions.get(versions.size() - 1)`인 것도 같은 순서 계약에 의존한다. 테스트가 이 성질을 직접 단언한다(`SchemaCompatibilityValidatorTest.theLatestVersionIsTheNewestNotTheFirstListed`).\n35610 | \n35611 | port로 둔 이유도 적혀 있다 — \"A hosted registry, a classpath directory of schema files, and a static in-process map are all legitimate sources … Binding to a vendor client here would make the rules untestable without that vendor running.\"\n35612 | \n35613 | ##### 4.6 `SchemaCompatibilityValidator`: 포맷 독립 규칙\n35614 | \n35615 | 두 가지를 한다.\n35616 | \n35617 | **(a) 비교할 버전 목록**\n35618 | \n35619 | ```java\n35620 | public List Split from the per-format gates on purpose. Whether v3 must be checked against v1 as well as\n35647 | * v2 is a property of the compatibility mode, not of Avro or Protobuf, and duplicating that\n35648 | * reasoning in each codec is how the two formats drift apart.\n35649 | ```\n35650 | \n35651 | §12.1과 §12.3이 이 문장을 다시 다룬다.\n35652 | \n35653 | ##### 4.7 `RawBytesMessageCodec`: 부재를 구현한다\n35654 | \n35655 | ```java\n35656 | // RawBytesMessageCodec.java:12-16\n35657 | * It still enforces the byte limit, and it is deliberately excluded from default codec\n35658 | * selection: schema-free publishing has to be an explicit, auditable choice per destination, never\n35659 | * something a destination falls back to because its codec was misconfigured.\n35660 | ```\n35661 | \n35662 | `encode`는 `byte[]`가 아닌 payload를 `MessageSerializationException(\"RAW_BYTES_PAYLOAD_REQUIRED\")`로 거절하고, `decode`는 `byte[].class`가 아닌 대상을 `RAW_BYTES_TARGET_REQUIRED`로 거절한다. `decode`는 `encoded.clone()`을 반환한다 — 호출자가 원본을 건드릴 수 없다.\n35663 | \n35664 | `DEFAULT_MAX_BYTES = 1_048_576`(1 MiB)은 세 Stable codec이 공유하는 값이다.\n35665 | \n35666 | **주의:** 이 codec은 `BoundedByteSink`를 쓰지 않는다. 이미 `byte[]`를 받으므로 스트리밍 경계가 의미 없고, `bytes.length > maxBytes` 비교로 충분하다. 다른 codec에서는 그 비교가 §4.2가 지적하는 \"보고 임계값\"이지만 여기서는 할당이 이미 끝난 입력이라 성격이 다르다.\n35667 | \n35668 | ---\n35669 | \n35670 | #### 5. 주요 실행 경로\n35671 | \n35672 | 세 개다.\n35673 | \n35674 | 1. **경계 있는 인코딩** — codec이 `BoundedByteSink.of(maxBytes, code)`를 만들고 → 포맷 라이브러리가 sink에 쓰고 → 한도를 넘는 write에서 `MessageTooLargeException` → 아니면 `sink.toByteArray()`로 `EncodedMessage` 조립\n35675 | 2. **계약 조회** — `new MessageContractKey(type, version)` → registry lookup → 미스면 \"타입 미등록\" vs \"버전 미등록\" 구분\n35676 | 3. **진화 검사** — `registry.compatibilityOf(subject)` → `versionsToCheck` → (포맷별 게이트가 실제 비교)\n35677 | \n35678 | 3번은 이 저장소에서 실행되지 않는다(§12.1).\n35679 | \n35680 | ---\n35681 | \n35682 | #### 6. 실패 경로와 복구/번역\n35683 | \n35684 | 이 leaf가 던지는 예외는 셋이고 전부 `messaging-core-api` 소유다.\n35685 | \n35686 | | 예외 | 코드 | 조건 |\n35687 | |---|---|---|\n35688 | | `MessageTooLargeException` | codec별(`PAYLOAD_TOO_LARGE`, `AVRO_PAYLOAD_TOO_LARGE`, …) | sink 한도 초과 |\n35689 | | `MessageTooLargeException` | `RAW_BYTES_TOO_LARGE` | raw codec 한도 초과 |\n35690 | | `MessageSerializationException` | `RAW_BYTES_PAYLOAD_REQUIRED` / `RAW_BYTES_TARGET_REQUIRED` | 타입 불일치 |\n35691 | | `MessageSchemaIncompatibleException` | `UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION` | `NONE_EXPERIMENTAL`이 production 목적지에 |\n35692 | \n35693 | `IllegalArgumentException`도 던진다 — `BoundedByteSink` 생성자의 `maxBytes < 1`, `requireFits`의 음수, `SchemaReference`의 빈 subject. 이들은 **호출자의 프로그래밍 오류**이고 메시지 실패가 아니므로 `MessagingException` 계층 밖인 것이 일관적이다.\n35694 | \n35695 | ---\n35696 | \n35697 | #### 7. 트랜잭션·동시성·수명주기\n35698 | \n35699 | 트랜잭션 없음.\n35700 | \n35701 | 동시성: `BoundedByteSink`가 **의도적으로 thread-safe가 아니다.** javadoc이 명시한다 — \"Not thread-safe, and not meant to be: an instance belongs to a single encode call.\" 실제로 codec들이 매 `encode` 호출마다 새로 만든다.\n35702 | \n35703 | `EncodedMessage`, `MessageContractKey`, `SchemaReference`는 불변이다. `SchemaCompatibilityValidator`는 registry 참조만 갖고 상태가 없다.\n35704 | \n35705 | `MessageCodecRegistry`/`SchemaRegistry` 구현의 스레드 안전성은 이 leaf가 규정하지 않는다 — port javadoc에 그에 대한 요구가 없다. 이것은 §17의 P3 항목이다.\n35706 | \n35707 | ---\n35708 | \n35709 | #### 8. 설정·기능 플래그·환경 차이\n35710 | \n35711 | 설정 없음. 상수 하나:\n35712 | \n35713 | | 상수 | 값 | 위치 |\n35714 | |---|---:|---|\n35715 | | `RawBytesMessageCodec.DEFAULT_MAX_BYTES` | 1,048,576 | `RawBytesMessageCodec.java:21` |\n35716 | \n35717 | `BoundedByteSink`의 초기 버퍼 상한 8,192는 private다.\n35718 | \n35719 | ---\n35720 | \n35721 | #### 9. 퍼시스턴스/외부 시스템 세부\n35722 | \n35723 | 없다. `SchemaRegistry`가 외부 registry를 가리킬 수 있는 port지만, 이 leaf에는 구현이 없다.\n35724 | \n35725 | ---\n35726 | \n35727 | #### 10. 테스트 레인과 실제 증명 범위\n35728 | \n35729 | 레인: `./gradlew :messaging:messaging-schema-api:test`. **BUILD SUCCESSFUL, 19 tests, 0 skipped, 0 failures** (`--rerun-tasks`, revision `21234e38`).\n35730 | \n35731 | | 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |\n35732 | |---|---:|---|---|\n35733 | | `BoundedByteSinkTest` | 4 | 한도 포함/초과 경계, 10 MiB 스트림이 한도에서 멈춤, pre-flight가 예산을 안 먹음, codec 에러 코드 전달 | 실제 codec들이 이 sink를 쓰는지(각 codec leaf가 소유) |\n35734 | | `RawBytesMessageCodecTest` | 6 | round trip, content type, 비-byte[] 거절 양방향, 한도, `EncodedMessage` 방어 복사 | — |\n35735 | | `SchemaCompatibilityValidatorTest` | 9 | pairwise vs transitive 목록, `NONE_EXPERIMENTAL` 빈 목록, 빈 history, production 게이트 양방향, `checksBackward`/`checksForward` 조합, `latest`가 newest | **production 코드가 이 validator를 호출하는지** |\n35736 | \n35737 | 마지막 칸이 핵심이다. `SchemaCompatibilityValidatorTest`는 9개 단언으로 규칙을 정확히 고정하지만, §12.1이 보이듯 그 규칙을 실행 경로에서 부르는 코드가 없다. 테스트는 **규칙이 옳다**를 증명하고 **규칙이 적용된다**를 증명하지 않는다.\n35738 | \n35739 | 테스트가 쓰는 `FixedRegistry`는 `SchemaRegistry`의 유일한 구현이다(production 구현 0개, §12.1).\n35740 | \n35741 | ---\n35742 | \n35743 | #### 11. 빌드/ArchUnit/CI 강제 지점\n35744 | \n35745 | | 게이트 | 이 leaf에 대해 |\n35746 | |---|---|\n35747 | | registry fail-closed | 등록됨 |\n35748 | | `verifyCleanArchitectureDependencies` | `allowed_dependencies: [\"messaging-core-api\"]`와 실제 project edge 대조 |\n35749 | | `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |\n35750 | | `src/messaging/CLAUDE.md`의 vendor `api` 규칙 | 이 leaf는 vendor 의존성이 없으므로 대상 없음 |\n35751 | | ArchUnit | 이 leaf 전용 규칙 없음 |\n35752 | \n35753 | `src/messaging/CLAUDE.md:40-43`이 기술하는 게이트 — \"source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 `build.gradle`이 `api`로 선언했는지 대조\" — 는 이 leaf에서 확인할 것이 없다. 형제 leaf(`schema-avro`, `schema-protobuf`, `cloudevents`)는 이 규칙 때문에 vendor를 `api`로 선언했고 build.gradle 주석이 그 이유를 적는다.\n35754 | \n35755 | ---\n35756 | \n35757 | #### 12. 실제 사용 여부와 negative-space probes\n35758 | \n35759 | 원시 증거: `evidence/raw/272-schema-family-reachability.txt`.\n35760 | \n35761 | ##### 12.1 Public surface reachability\n35762 | \n35763 | leaf 밖 참조를 파일 수로 세면:\n35764 | \n35765 | | 타입 | leaf 밖 파일 수 | 판정 |\n35766 | |---|---:|---|\n35767 | | `EncodedMessage` | 50 | 널리 쓰임 — 사실상 이 leaf의 주력 수출품 |\n35768 | | `SchemaCompatibility` | 15 | 세 codec leaf + policy가 씀 |\n35769 | | `MessageContractKey` | 7 | 세 codec leaf가 씀 |\n35770 | | `MessageCodec` | 7 | 세 codec + runtime-core |\n35771 | | `MessageCodecRegistry` | 5 | runtime-core가 구현 |\n35772 | | `SchemaReference` | 4 | codec들이 만듦 |\n35773 | | `BoundedByteSink` | 3 | JSON·Avro·Protobuf codec |\n35774 | | `RawBytesMessageCodec` | **0** | 자기 테스트만 |\n35775 | | `SchemaCompatibilityValidator` | **0** | 자기 테스트만 |\n35776 | | `SchemaRegistry` | **0** | 아래 참조 |\n35777 | \n35778 | **`SchemaRegistry`의 \"0\"은 확인이 필요했다.** 단순 이름 검색은 2개 파일을 맞췄지만 둘 다 다른 타입이다:\n35779 | \n35780 | ```\n35781 | src/adapter/outbound/messaging/.../LocalJsonSchemaRegistry.java:6: import com.networknt.schema.SchemaRegistry;\n35782 | src/adapter/outbound/notification/.../JsonSchemaVariableValidator.java:5: import com.networknt.schema.SchemaRegistry;\n35783 | ```\n35784 | \n35785 | `import dev.caskeleton.messaging.schema.SchemaRegistry` 검색은 exit 1이다. 즉 **이 플랫폼의 `SchemaRegistry` port를 import하는 파일이 저장소에 하나도 없다.** 이름 충돌이 우연히 검색을 오염시킨 사례이고, `-w` 단어 매칭만으로 reachability를 판정하면 안 되는 이유이기도 하다.\n35786 | \n35787 | **`SchemaCompatibilityValidator`의 \"0\"이 이 leaf에서 가장 무거운 사실이다.** 검색 결과 전체가 자기 선언과 자기 테스트다. 다시 말해:\n35788 | \n35789 | - 어떤 버전들을 비교해야 하는가 → 아무도 묻지 않는다\n35790 | - `NONE_EXPERIMENTAL`이 production 목적지를 뒷받침할 수 있는가 → 아무도 묻지 않는다\n35791 | \n35792 | `requireProductionMode`는 \"retained log outlives every consumer\"라는 이유로 만들어졌고, 그 게이트가 호출되는 지점이 없다.\n35793 | \n35794 | `RawBytesMessageCodec`의 \"0\"은 성격이 다르다. 이 클래스가 없어도 그 **규칙**은 살아 있다 — §12.2 참조.\n35795 | \n35796 | ##### 12.2 Conditional sibling comparison\n35797 | \n35798 | Spring 주석 0개이므로 bean 활성화 비대칭은 없다.\n35799 | \n35800 | 대신 이 leaf에는 **다른 형태의 sibling 비대칭**이 있고 결과가 좋다. `RawBytesMessageCodec`의 javadoc이 \"deliberately excluded from default codec selection\"이라고 선언하는 규칙을, 실제로 강제하는 코드는 다른 leaf에 있다:\n35801 | \n35802 | ```java\n35803 | // messaging-runtime-core/RegisteredMessageCodecs.java:52-56\n35804 | if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) {\n35805 | throw new IllegalArgumentException(\n35806 | \"the raw bytes codec must not be the default: every destination that has not declared an \"\n35807 | + \"encoding would silently skip schema validation\");\n35808 | }\n35809 | ```\n35810 | \n35811 | **클래스가 아니라 content type으로 판정한다.** 그래서 `RawBytesMessageCodec`을 아무도 쓰지 않아도, 그리고 누가 `ContentType.OCTET_STREAM`을 내놓는 다른 codec을 새로 만들어도 규칙이 유지된다. 선언된 규칙과 강제하는 코드가 다른 leaf에 있으면서 **강제 쪽이 더 넓은** 드문 경우다. 결함이 아니라 확인된 설계로 기록한다.\n35812 | \n35813 | ##### 12.3 Duplicate mechanism sweep\n35814 | \n35815 | **`SchemaCompatibilityValidator`가 막으려던 중복이 실제로 존재한다.**\n35816 | \n35817 | `AvroCompatibilityGate`(다른 leaf)가 같은 판단을 private static으로 다시 구현했다.\n35818 | \n35819 | | 판단 | schema-api (`SchemaCompatibilityValidator`) | schema-avro (`AvroCompatibilityGate`) |\n35820 | |---|---|---|\n35821 | | transitive인가 | `mode == BACKWARD_TRANSITIVE \\|\\| FORWARD_TRANSITIVE \\|\\| FULL_TRANSITIVE` (:107-112) | **같은 식을 그대로** (:49-53) |\n35822 | | 후방 검사하나 | `mode == BACKWARD \\|\\| BACKWARD_TRANSITIVE \\|\\| FULL \\|\\| FULL_TRANSITIVE` — **허용목록** (:79-85) | `mode != FORWARD && mode != FORWARD_TRANSITIVE` — **거부목록** (:55-57) |\n35823 | | 전방 검사하나 | `mode == FORWARD \\|\\| FORWARD_TRANSITIVE \\|\\| FULL \\|\\| FULL_TRANSITIVE` — **허용목록** (:93-99) | `mode != BACKWARD && mode != BACKWARD_TRANSITIVE` — **거부목록** (:59-61) |\n35824 | \n35825 | `isTransitive`는 글자까지 동일한 복사본이다. 방향 판정 둘은 **형태가 반대**다.\n35826 | \n35827 | 현재 enum 7개 값에 대해 두 구현의 결과를 대조하면 일치한다. `NONE_EXPERIMENTAL`만 다른데(validator는 둘 다 false, gate는 둘 다 true) `AvroCompatibilityGate.check:34`가 그 모드에서 먼저 return하므로 가려진다.\n35828 | \n35829 | **문제는 오늘의 불일치가 아니라 형태다.** 허용목록은 새 모드가 추가되면 \"검사 안 함\"으로 기본값이 잡히고, 거부목록은 \"양방향 검사\"로 잡힌다. `SchemaCompatibility`에 값이 하나 추가되는 순간 두 구현은 **반대 방향으로** 갈라진다. javadoc이 예고한 \"how the two formats drift apart\"가 바로 이 형태이고, 그것을 막으려고 만든 클래스는 §12.1에서 보듯 호출되지 않는다.\n35830 | \n35831 | `isTransitive`는 `SchemaCompatibilityValidator`에서 **public static**이다. Avro 게이트가 그것을 부를 수 있었고 부르지 않았다.\n35832 | \n35833 | ##### 12.4 Documentation / measured-count drift\n35834 | \n35835 | 이 leaf를 직접 이름으로 언급하는 문서 주장을 재측정했다.\n35836 | \n35837 | | 문서 주장 | 재측정 | 결과 |\n35838 | |---|---|---|\n35839 | | 계획 문서: codec은 닫힌 registry에 대해 동작 | `MessageCodec` javadoc + 세 구현의 `requireRegistered`/`schemaFor` | **일치** |\n35840 | | `RawBytesMessageCodec` javadoc: 기본 codec 선택에서 제외됨 | `RegisteredMessageCodecs.of` 생성자 검사 | **일치**(더 넓게 강제) |\n35841 | | `SchemaRegistry` javadoc: history는 oldest-first | 유일한 구현이 테스트 fixture이고 그 계약을 지킴 | 일치하나 production 구현 없음 |\n35842 | \n35843 | §12.4의 family 전체 drift(`support-matrix.md:23`의 runtime membership 주장)는 §A19-MESSAGING-CORE-API §12.4가 소유한다. 이 leaf도 그 18개 wired 목록에 포함된다.\n35844 | \n35845 | ---\n35846 | \n35847 | #### 13. Git/설계 문서에서 확인한 변화와 실패 기록\n35848 | \n35849 | 코드 주석이 보존한 이전 결함:\n35850 | \n35851 | | 위치 | 이전 상태 | 그것이 만든 실패 |\n35852 | |---|---|---|\n35853 | | `BoundedByteSink` javadoc | 각 codec이 무제한 버퍼에 직렬화 후 길이 비교 | 한도가 **보고 임계값**일 뿐 할당 경계가 아님 → 팽창하는 payload 하나가 consumer 프로세스를 죽임 |\n35854 | | `MessageContractKey` javadoc | 타입만으로 registry 키 | v999가 v1 클래스로 디코딩되고 v999 라벨을 유지 → 하위 게이트·감사 기록이 등록된 적 없는 버전을 서술 |\n35855 | \n35856 | 두 사례 다 형태가 같다 — **검사가 없었던 게 아니라 검사의 위치/키가 틀렸다.** `messaging-core-api` §13의 \"문자 vs 바이트, 정확일치 vs 세그먼트\" 목록과 같은 계열이다.\n35857 | \n35858 | ---\n35859 | \n35860 | #### 14. 런타임·터미널 Evidence\n35861 | \n35862 | | id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |\n35863 | |---|---|---|---|---|\n35864 | | EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | `SchemaCompatibilityValidator` 호출자 전무, allowlist/denylist 두 형태 나란히, `SchemaRegistry` port import 0(exit=1)과 이름 충돌, codec별 소비자 | 정적 `git grep` |\n35865 | | EVD-274 | command | `./gradlew :messaging:messaging-schema-api:test --rerun-tasks` | BUILD SUCCESSFUL, 19 / 0 / 0 | 순수 단위 레인 |\n35866 | \n35867 | ---\n35868 | \n35869 | #### 15. 명시적 설계 이유와 추론을 구분한 정리\n35870 | \n35871 | **명시적**\n35872 | \n35873 | - 버전을 registry 키에 넣는 이유 — `MessageContractKey` javadoc\n35874 | - 할당 경계 vs 보고 임계값 — `BoundedByteSink` javadoc\n35875 | - codec 에러 코드를 sink에 넘기는 이유 — `BoundedByteSink` javadoc + 테스트 `as(...)`\n35876 | - 포맷 독립 규칙을 분리한 이유 — `SchemaCompatibilityValidator` javadoc\n35877 | - `NONE_EXPERIMENTAL`을 production에서 막는 이유 — 같은 javadoc\n35878 | - `SchemaRegistry`를 port로 둔 이유, history 순서가 계약인 이유 — `SchemaRegistry` javadoc\n35879 | - raw codec을 기본에서 제외하는 이유 — `RawBytesMessageCodec` javadoc + `RegisteredMessageCodecs` javadoc\n35880 | - `EncodedMessage` 양방향 복사 이유 — `EncodedMessage` javadoc\n35881 | \n35882 | **추론**\n35883 | \n35884 | - `SchemaCompatibilityValidator`가 미호출인 것은 이 저장소에 schema registry를 실제로 운영하는 배포가 없기 때문이다 → **추론**. `SchemaRegistry` production 구현이 0인 것은 관측이고, 인과는 추론이다.\n35885 | - Avro 게이트가 자기 복사본을 쓴 이유 → **미상**. 커밋 메시지에 근거가 없다.\n35886 | \n35887 | ---\n35888 | \n35889 | #### 16. 확인한 것 / 확인하지 못한 것\n35890 | \n35891 | **확인한 것**\n35892 | \n35893 | - 10개 타입 전부의 계약과 불변식\n35894 | - 19개 테스트가 통과하고 무엇을 단언하는지\n35895 | - `SchemaCompatibilityValidator`·`RawBytesMessageCodec`·`SchemaRegistry`의 leaf 밖 참조 0 (`SchemaRegistry`는 이름 충돌을 배제한 뒤)\n35896 | - Avro 게이트의 중복 구현과 두 형태의 차이\n35897 | - raw-bytes 기본 금지 규칙이 content type 기준으로 더 넓게 강제된다는 것\n35898 | \n35899 | **확인하지 못한 것**\n35900 | \n35901 | - `SchemaCompatibility` enum이 실제로 확장될 계획이 있는지. §12.3의 위험은 그때 실현된다.\n35902 | - port 구현의 스레드 안전성 요구. javadoc에 없고 이 저장소에 production 구현이 없어 관측할 대상이 없다.\n35903 | - `BoundedByteSink`의 경계가 실제 Jackson/Avro/Protobuf 인코더에서 기대대로 동작하는지 — 각 codec leaf의 테스트가 소유하고 이 문서 범위 밖이다.\n35904 | \n35905 | ---\n35906 | \n35907 | #### 17. 손볼 것\n35908 | \n35909 | ##### P2 — 포맷 독립 진화 규칙이 호출되지 않고, 그것이 막으려던 중복이 실제로 생겼다\n35910 | \n35911 | - **사실.** `SchemaCompatibilityValidator`의 저장소 전체 참조가 자기 선언과 자기 테스트뿐이다. 동시에 `AvroCompatibilityGate`가 `isTransitive`를 글자 그대로 복사했고 방향 판정 둘은 허용목록/거부목록으로 형태가 반대다.\n35912 | - **근거.** `evidence/raw/272` §A, §B.\n35913 | - **왜 문제인가.** 오늘은 7개 모드 전부에서 두 구현의 결과가 같다(`NONE_EXPERIMENTAL`은 gate의 early return이 가린다). 그러나 enum에 값이 하나 추가되면 허용목록은 \"검사 안 함\", 거부목록은 \"양방향 검사\"로 **반대 방향** 기본값을 갖는다. 그리고 `requireProductionMode` — 검사 없는 스키마가 보존 로그를 뒷받침하는 것을 막는 게이트 — 는 호출되는 곳이 없다.\n35914 | - **확인 방법.** `git grep -n -E 'requireProductionMode|versionsToCheck|SchemaCompatibilityValidator' -- 'src/**/*.java'`\n35915 | - **후보.** (a) Avro 게이트가 `SchemaCompatibilityValidator`의 public static을 부르게 한다. (b) validator를 CI 게이트에 배선한다. (c) 둘 다 쓰지 않을 거라면 validator를 제거하고 규칙 소유권을 게이트로 옮긴다.\n35916 | - **다음 단계.** **CASE 후보 + REFERENCE 후보**. \"중복을 막으려고 만든 추상이 호출되지 않으면 중복은 그대로 생긴다\"는 형태가 재사용 가능하다. 그리고 \"허용목록과 거부목록은 enum이 자라는 순간 반대로 갈라진다\"도 별도 기준이다.\n35917 | \n35918 | ##### P3 — port 구현의 스레드 안전성 요구가 문서화되어 있지 않다\n35919 | \n35920 | - **사실.** `SchemaRegistry`와 `MessageCodecRegistry` javadoc에 동시성 요구가 없다. `BoundedByteSink`만 \"not thread-safe\"를 명시한다.\n35921 | - **근거.** 세 타입의 javadoc 전문.\n35922 | - **왜 문제인가.** `MessageCodecRegistry`의 유일한 구현 `RegisteredMessageCodecs`는 `Map.copyOf`로 불변이라 안전하지만, 그것은 구현의 성질이지 계약이 아니다. 외부 registry를 감싸는 `SchemaRegistry` 구현은 브로커 소비자 스레드들에서 동시에 호출된다.\n35923 | - **확인 방법.** 세 인터페이스의 javadoc 확인.\n35924 | - **후보.** port javadoc에 \"구현은 스레드 안전해야 한다\"를 명시.\n35925 | - **다음 단계.** **REFERENCE 후보**(port 계약은 동시성 요구를 적는다).\n35926 | \n35927 | ##### P3 — `SchemaRegistry`라는 이름이 저장소에서 두 가지를 가리킨다\n35928 | \n35929 | - **사실.** `dev.caskeleton.messaging.schema.SchemaRegistry`(이 leaf의 port)와 `com.networknt.schema.SchemaRegistry`(JSON Schema 라이브러리)가 공존하고, 후자만 실제로 import된다.\n35930 | - **근거.** `evidence/raw/272` §C.\n35931 | - **왜 문제인가.** 지금 깨지는 것은 없다. 다만 reachability 판정에서 실제로 오탐을 만들었다 — 단어 검색이 2건을 맞췄고 둘 다 다른 타입이었다. 사람이 같은 실수를 한다.\n35932 | - **확인 방법.** `git grep -n 'import .*\\.SchemaRegistry;' -- src`\n35933 | - **후보.** 이름 변경 없이 두는 것이 합리적일 수 있다. 기록만 남긴다.\n35934 | - **다음 단계.** **REFERENCE 후보**(도달성 판정은 단어가 아니라 import로 확인한다).\n35935 | \n35936 | ##### 확인된 설계(문제 아님)\n35937 | \n35938 | - `BoundedByteSink`가 codec의 에러 코드를 전달하고, pre-flight가 예산을 소비하지 않는 것 — 테스트가 양쪽을 고정\n35939 | - `EncodedMessage`의 양방향 방어 복사와 payload를 찍지 않는 `toString`\n35940 | - 버전을 registry 키에 포함하고 \"타입 미등록\"과 \"버전 미등록\"을 다른 코드로 구분하는 것\n35941 | - raw-bytes 기본 금지가 클래스가 아니라 content type으로 강제되는 것\n35942 | \n35943 | ---\n35944 | \n35945 | #### Source anchors\n35946 | \n35947 | | id | kind | path | revision | what it proves | limitations |\n35948 | |---|---|---|---|---|---|\n35949 | | MSA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps `[\"messaging-core-api\"]`, memberships `[\"app-bootstrap\"]` | 선언 |\n35950 | | MSA-002 | build | `messaging-schema-api/build.gradle` | same | vendor 의존성 0 | — |\n35951 | | MSA-003 | code | `.../schema/MessageContractKey.java` | same | 버전 키 결정과 그 이유 | — |\n35952 | | MSA-004 | code | `.../schema/BoundedByteSink.java` | same | 할당 경계, 에러 코드 전달, pre-flight | 실제 인코더 동작은 각 codec leaf |\n35953 | | MSA-005 | code | `.../schema/EncodedMessage.java` | same | 양방향 복사, 배열 equals, 안전한 toString | — |\n35954 | | MSA-006 | code | `.../schema/SchemaCompatibilityValidator.java` | same | 포맷 독립 규칙과 분리 이유 | 호출자 없음(§12.1) |\n35955 | | MSA-007 | code | `.../schema/SchemaRegistry.java` | same | port 계약, history oldest-first | production 구현 없음 |\n35956 | | MSA-008 | code | `.../schema/RawBytesMessageCodec.java` | same | escape hatch 계약 | 외부 사용 0 |\n35957 | | MSA-009 | code | `.../schema/{MessageCodec,MessageCodecRegistry,SchemaReference,SchemaCompatibility}.java` | same | codec/식별/모드 계약 | — |\n35958 | | MSA-010 | test | `src/test/java/**` (3 클래스 / 19 테스트) | same | §10 표 | 순수 단위 |\n35959 | | MSA-011 | cross-leaf code | `messaging-runtime-core/.../RegisteredMessageCodecs.java:29-77` | same | raw-bytes 기본 금지의 실제 강제 지점, 중복 content type 거절 | 해당 leaf SSOT가 소유 |\n35960 | | MSA-012 | cross-leaf code | `messaging-schema-avro/.../AvroCompatibilityGate.java:34-61` | same | 중복 구현과 두 형태의 차이 | 해당 leaf SSOT가 소유 |\n35961 | | EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | §12.1·§12.3 전부 | 정적 검색 |\n35962 | | EVD-274 | command | `./gradlew :messaging:messaging-schema-api:test --rerun-tasks` | same | 19 / 0 skipped / 0 failures | 순수 단위 |\n35963 | \n35964 | ---\n35965 | \n35966 | ## A19-MESSAGING-SCHEMA-AVRO. messaging-schema-avro\n35967 | \n35968 | > 분석 중에는 `messaging/MESSAGING-SCHEMA-AVRO.md` 파일이었다. 597줄.\n35969 | \n35970 | ### messaging-schema-avro 완전 해부\n35971 | \n35972 | > 상태: COMPLETE\n35973 | > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n35974 | > 분석 범위: `src/messaging/messaging-schema-avro`\n35975 | > SSOT owner: `messaging-schema-avro`\n35976 | > integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n35977 | \n35978 | ---\n35979 | \n35980 | #### 0. SSOT identity / 커버리지와 숫자 지도\n35981 | \n35982 | - registered leaf id: `messaging-schema-avro`\n35983 | - canonical state `analysisFile`: §A19-MESSAGING-SCHEMA-AVRO\n35984 | - source path: `src/messaging/messaging-schema-avro`\n35985 | - registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-schema-api\"]`\n35986 | - registry `runtime_memberships`: **`[]`** — build-only / incubating\n35987 | \n35988 | ##### 숫자\n35989 | \n35990 | | 항목 | 수 |\n35991 | |---|---:|\n35992 | | production Java 파일 | 2 |\n35993 | | production LOC | 345 |\n35994 | | 패키지 | 1 (`dev.caskeleton.messaging.schema.avro`) |\n35995 | | test 파일 | 3 |\n35996 | | test 메서드(실행 확인) | 16 |\n35997 | | test resource | `/schemas/order.created/v1.avsc` |\n35998 | | 외부 의존성 | 1 (`org.apache.avro:avro:1.12.0`, **`api`**) |\n35999 | \n36000 | 두 클래스: `AvroMessageCodec`(런타임 인코딩/디코딩), `AvroCompatibilityGate`(CI용 진화 검사).\n36001 | \n36002 | ##### Coverage ledger\n36003 | \n36004 | | scope/file group | count | disposition | reason |\n36005 | |---|---:|---|---|\n36006 | | `.../avro/AvroMessageCodec.java` | 1 | `FULL_READ` | 272줄 전문 |\n36007 | | `.../avro/AvroCompatibilityGate.java` | 1 | `FULL_READ` | 73줄 전문 |\n36008 | | `src/test/java/**` | 3 | `FULL_READ` | 전문 |\n36009 | | `src/test/resources/schemas/order.created/v1.avsc` | 1 | `STRUCTURAL_ONLY` | fixture 스키마; 필드 구성만 확인 |\n36010 | | `build.gradle` | 1 | `FULL_READ` | 주석 포함 11줄 |\n36011 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |\n36012 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n36013 | \n36014 | `UNCLASSIFIED` 0.\n36015 | \n36016 | ---\n36017 | \n36018 | #### 1. 모듈의 정체와 경계\n36019 | \n36020 | 선택적(optional) Avro codec. Stable이 아니고 registry membership이 비어 있다 — **build-only / incubating**이며, `docs/messaging/support-matrix.md`의 등급과는 다른 축이다.\n36021 | \n36022 | Avro를 `api`로 선언한 이유가 build.gradle 주석에 있다.\n36023 | \n36024 | ```groovy\n36025 | // api: AvroMessageCodec's constructors take a registry of org.apache.avro.Schema and\n36026 | // AvroCompatibilityGate.check takes and compares them. A consumer cannot build that\n36027 | // registry without naming the type, so hiding the dependency only stops them compiling.\n36028 | api 'org.apache.avro:avro:1.12.0'\n36029 | ```\n36030 | \n36031 | `src/messaging/CLAUDE.md:40-43`이 기술하는 게이트 — public/protected 시그니처에 나오는 vendor 라이브러리가 `api`로 선언됐는지 대조 — 를 이 leaf가 통과한다. 형제 `messaging-schema-json`은 Jackson 타입이 시그니처에 없으므로 `implementation`이고, 그 판정 차이가 규칙이 실제로 작동한다는 증거다.\n36032 | \n36033 | **클래스 둘의 실행 시점이 다르다.**\n36034 | \n36035 | | 클래스 | 언제 도는가 | 근거 |\n36036 | |---|---|---|\n36037 | | `AvroMessageCodec` | 런타임(메시지마다) | `MessageCodec` 구현 |\n36038 | | `AvroCompatibilityGate` | **CI** | 클래스 javadoc: \"Run in CI rather than at runtime\" |\n36039 | \n36040 | 게이트의 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.\"\n36041 | \n36042 | ---\n36043 | \n36044 | #### 2. 의존성과 런타임 배선\n36045 | \n36046 | 들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `avro:1.12.0`(api).\n36047 | \n36048 | 나가는 것: **없다.** 어떤 leaf의 `allowed_dependencies`에도 `messaging-schema-avro`가 없다. `messaging-spring-boot-starter`의 17개 의존 목록에도 없다.\n36049 | \n36050 | 런타임 배선: 없음. `runtime_memberships: []`이므로 배포 아티팩트에 실리지 않는다. bean도 없다(Spring 주석 0개).\n36051 | \n36052 | **소비자 없음과 membership 없음이 일치한다.** 이것이 정합적인 incubating 상태다 — `messaging-cloudevents`와 대비된다(그쪽은 membership이 있고 소비자가 없다).\n36053 | \n36054 | ---\n36055 | \n36056 | #### 3. 패키지/컴포넌트 지도\n36057 | \n36058 | ```\n36059 | AvroMessageCodec (MessageCodec 구현)\n36060 | ├── encode(type, version, GenericRecord) → EncodedMessage\n36061 | ├── decode(type, version, byte[], Class) → GenericRecord (writer == reader)\n36062 | ├── decodeEvolved(type, writerV, readerV, byte[]) → GenericRecord (writer != reader)\n36063 | ├── schemaFor(type, version) → 등록 조회, 2단 에러\n36064 | ├── boundedReader(writer, reader) → newArray 오버라이드\n36065 | └── flatten(nested registry) → (type, version) 평탄화 + 깊은 복사\n36066 | \n36067 | AvroCompatibilityGate (CI)\n36068 | └── check(candidate, history, mode)\n36069 | ├── isTransitive / readsBackward / readsForward (private, 자체 구현)\n36070 | └── requireCompatible → org.apache.avro.SchemaCompatibility\n36071 | ```\n36072 | \n36073 | ---\n36074 | \n36075 | #### 4. 계약·불변식·상태 모델\n36076 | \n36077 | ##### 4.1 Avro 바이너리에는 스키마가 없다 — 그래서 registry가 계약이다\n36078 | \n36079 | ```java\n36080 | // AvroMessageCodec.java:33-37\n36081 | * Decoding uses an explicit writer schema and reader schema pair. Avro binary carries no schema\n36082 | * of its own, so decoding with the wrong schema does not fail — it produces plausible garbage. The\n36083 | * registry is what makes the writer schema knowable, and passing both schemas to the reader is what\n36084 | * makes evolution work: Avro resolves added, removed, and defaulted fields only when it can see\n36085 | * both sides.\n36086 | ```\n36087 | \n36088 | \"does not fail — it produces plausible garbage\"가 이 leaf의 모든 방어의 전제다. JSON이나 Protobuf와 달리 Avro는 잘못된 스키마로 디코딩해도 예외를 던지지 않는 경우가 있다.\n36089 | \n36090 | 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.\"\n36091 | \n36092 | ##### 4.2 `flatten`: 얕은 복사가 만든 구멍\n36093 | \n36094 | 생성자가 받는 것은 중첩 맵 `Map {@code Map.copyOf} on the outer map is a shallow copy: every inner {@code Map Avro writes an array as a declared element count followed by the elements. The count is a\n36136 | * variable-length integer, so five bytes can claim four hundred million elements, and the generic\n36137 | * reader allocates the backing array from that claim before reading a single element. Bounding\n36138 | * the input length does not help: the whole hostile payload is five bytes, well under any limit,\n36139 | * and the failure is an {@code OutOfMemoryError} rather than an exception the codec could report\n36140 | * — on a consumer thread that is the process, not the message.\n36141 | *\n36142 | * The ceiling is the byte limit itself. Every element costs at least one byte on the wire even\n36143 | * when it is empty, so a payload of at most {@code maxBytes} bytes cannot honestly contain more\n36144 | * than {@code maxBytes} elements, and any larger claim is a lie the reader should refuse rather\n36145 | * than reserve memory for.\n36146 | ```\n36147 | \n36148 | 구현은 익명 서브클래스의 `newArray` 오버라이드다.\n36149 | \n36150 | ```java\n36151 | return new GenericDatumReader<>(writerSchema, readerSchema) {\n36152 | @Override\n36153 | protected Object newArray(Object old, int size, Schema schema) {\n36154 | if (size > maxElements) {\n36155 | throw new MessageTooLargeException(\"AVRO_COLLECTION_TOO_LARGE\", ...);\n36156 | }\n36157 | return super.newArray(old, size, schema);\n36158 | }\n36159 | };\n36160 | ```\n36161 | \n36162 | **상한 선택의 논리가 정확하다.** 원소 하나가 wire에서 최소 1바이트를 쓰므로, `maxBytes` 바이트짜리 payload가 정직하게 담을 수 있는 원소는 `maxBytes`개를 넘을 수 없다. 별도 튜닝 상수를 만들지 않고 이미 있는 경계에서 파생시켰다.\n36163 | \n36164 | `AvroHostileInputTest`가 이 공격을 손으로 만든 zigzag varint로 재현한다.\n36165 | \n36166 | ```java\n36167 | // AvroHostileInputTest.java:118-123\n36168 | * Hand-written rather than taken from an encoder because the point is to write a count with no\n36169 | * elements behind it, which no encoder will do.\n36170 | ```\n36171 | \n36172 | 그리고 공격의 크기를 직접 단언한다 — `assertThat(hostile).as(\"the whole attack is five bytes, so no byte limit stands between it and the allocation\").hasSizeLessThan(16)`.\n36173 | \n36174 | 테스트 클래스 javadoc이 **왜 corpus가 좁은지**까지 적는다.\n36175 | \n36176 | ```java\n36177 | // AvroHostileInputTest.java:30-33\n36178 | * Strings, byte arrays and maps were already safe: Avro validates those lengths against the\n36179 | * bytes actually remaining. Arrays were the one shape that allocated on trust, which is why the\n36180 | * corpus below is narrow rather than exhaustive — it pins the case that failed, and the two cases\n36181 | * that must keep working around it.\n36182 | ```\n36183 | \n36184 | 이것은 \"좁은 테스트\"를 정당화한 드문 예다 — 다른 형태는 라이브러리가 이미 방어하므로 재확인이 아니라 잡음이 된다.\n36185 | \n36186 | ##### 4.5 `schemaFor`: 2단 에러\n36187 | \n36188 | `AVRO_TYPE_NOT_REGISTERED`(타입 미등록)와 `AVRO_VERSION_NOT_REGISTERED`(버전 미등록)를 구분한다. JSON codec의 `UNKNOWN_MESSAGE_TYPE`/`SCHEMA_VERSION_NOT_REGISTERED`와 같은 형태이지만 **코드 문자열이 다르다.** 두 codec이 같은 판단을 다른 어휘로 보고한다 — §12.3.\n36189 | \n36190 | ##### 4.6 `decodeEvolved`: 나중에 붙은 경계\n36191 | \n36192 | ```java\n36193 | // AvroMessageCodec.java:199-201\n36194 | // The same bound the ordinary decode applies. It was missing here, so the evolution path — the\n36195 | // one a consumer takes for every message written by a newer producer — accepted input of any\n36196 | // size.\n36197 | requireWithinLimit(encoded.length);\n36198 | ```\n36199 | \n36200 | 테스트가 두 각도에서 붙든다 — `AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound`(`as(\"decodeEvolved accepted input of any size\")`)와 `AvroHostileInputTest.theEvolutionDecodeAppliesTheSameCollectionBound`(`as(\"a consumer reading a newer producer takes this path for every message\")`).\n36201 | \n36202 | 즉 `decodeEvolved`는 **가장 흔한 경로인데 가장 늦게 보호됐다.** 진화 경로는 producer가 앞서 나간 순간부터 모든 메시지가 지나는 길이다.\n36203 | \n36204 | ##### 4.7 `AvroCompatibilityGate`\n36205 | \n36206 | ```java\n36207 | public void check(Schema candidate, List