Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-schema-api.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

548 lines
32 KiB
Markdown

# messaging-schema-api 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-schema-api`
> SSOT owner: `messaging-schema-api`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
> **성격.** 읽기 기록이다. 이 leaf가 선언한 codec/schema 계약과, 그 중 무엇이 실제로 호출되는지를 source anchor와 함께 적는다.
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-schema-api`
- canonical state `analysisFile`: `analysis/messaging/messaging-schema-api.md`
- source path: `src/messaging/messaging-schema-api`
- leaf-owned subdocuments: 없음
- registry `allowed_dependencies`: `["messaging-core-api"]`
- registry `runtime_memberships`: `["app-bootstrap"]`
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 10 |
| production LOC | 630 |
| 패키지 | 1 (`dev.caskeleton.messaging.schema`) |
| test 파일 | 3 |
| test 메서드(실행 확인) | 19 |
| 외부(비프로젝트) 의존성 | **0** |
10개 타입의 성격:
| 타입 | 종류 | 역할 |
|---|---|---|
| `MessageCodec` | interface | 한 wire 포맷의 인코딩/디코딩 |
| `MessageCodecRegistry` | interface | content type → codec, 그리고 기본 codec |
| `SchemaRegistry` | interface | subject/version → schema, 그리고 compatibility mode |
| `MessageContractKey` | record | `(MessageType, SchemaVersion)` — registry 키 |
| `SchemaReference` | record | subject + version + 선택적 URI |
| `EncodedMessage` | record | 바이트 + content type + schema reference |
| `SchemaCompatibility` | enum(7) | 진화 모드 |
| `SchemaCompatibilityValidator` | class | 포맷 독립 진화 규칙 |
| `BoundedByteSink` | class | 한도 초과 바이트를 **쓰기 시점에** 거절하는 OutputStream |
| `RawBytesMessageCodec` | class | 스키마 없는 M2 escape hatch |
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (10) | 10 | `FULL_READ` | 전 파일 본문 확인 |
| `src/test/java/**` (3) | 3 | `FULL_READ` | 전 파일 본문 확인 |
| `build.gradle` | 1 | `FULL_READ` | 5줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
이 leaf는 **"바이트를 어떻게 만들고 읽는가"의 계약**을 소유한다. 실제 포맷 구현은 갖지 않는다 — 단 하나의 예외가 `RawBytesMessageCodec`이고, 그것은 포맷이 아니라 포맷의 부재를 구현한다.
경계 규칙 하나가 모든 곳에 반복된다: **codec은 닫힌 registry에 대해서만 동작한다.**
```java
// MessageCodec.java:10-12
* <p>Implementations operate against a closed message-type registry. Accepting an unregistered type
* would let a producer introduce a wire contract nothing has reviewed, which is the same class of
* problem that makes Java serialization unsupported here.
```
`build.gradle``api project(':messaging:messaging-core-api')` 하나뿐이고 vendor 의존성이 없다. 포맷별 vendor(`jackson`, `avro`, `protobuf`)는 각자 leaf가 갖는다.
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api 노출).
나가는 것: `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`.
런타임 편입은 `messaging-core-api`와 같은 경로다 — `app-bootstrap``messaging-spring-boot-starter`를 선언하고 그 closure가 이 leaf를 끌어온다.
이 leaf는 bean을 만들지 않는다. Spring 주석 0개.
---
## 3. 패키지/컴포넌트 지도
패키지 하나에 10개 타입이 평평하게 있다. 관심사로 나누면 셋이다.
```
codec 축 MessageCodec ── MessageCodecRegistry
└── RawBytesMessageCodec (유일한 구현)
식별 축 MessageContractKey (type, version)
SchemaReference (subject, version, uri?)
EncodedMessage (bytes, contentType, schemaReference?)
진화 축 SchemaRegistry ── SchemaCompatibility(7)
└── SchemaCompatibilityValidator
경계 축 BoundedByteSink
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `MessageContractKey`: 버전을 키에 넣는 이유
이 leaf에서 가장 밀도 높은 javadoc이다.
```java
// MessageContractKey.java:10-17
* <p>Keying on the message type alone is what let an unregistered version decode. The version
* travels in the envelope and in {@link SchemaReference}, so a consumer receiving {@code
* order.created v999} would look up {@code order.created}, find the v1 class or parser, decode
* against it, and then keep the v999 label on the result. Nothing failed, and every downstream
* compatibility gate and audit record then described a version that was never registered.
```
핵심은 "Nothing failed"다. 타입만으로 키를 잡으면 실패가 발생하지 않고 **잘못된 성공**이 발생한다. 그리고 그 결과에는 등록된 적 없는 버전 라벨이 붙어 하위 감사 기록까지 오염된다.
이 결정은 세 codec에 전부 반영돼 있다 — `JacksonMessageCodec.requireRegistered`, `AvroMessageCodec.schemaFor`, `ProtobufMessageCodec.requireRegistered`가 모두 "타입은 아는데 버전을 모른다"와 "타입 자체를 모른다"를 **다른 에러 코드**로 구분한다(`SCHEMA_VERSION_NOT_REGISTERED` vs `UNKNOWN_MESSAGE_TYPE`). 그 구분이 있어야 운영자가 "등록을 빠뜨렸다"와 "오타다"를 나눌 수 있다.
### 4.2 `BoundedByteSink`: 보고 임계값 → 할당 경계
```java
// BoundedByteSink.java:11-15
* <p>Every codec here used to serialize into an unbounded buffer and compare {@code bytes.length}
* to the configured maximum afterwards. That makes the maximum a reporting threshold rather than an
* allocation bound: a payload whose graph expands to hundreds of megabytes exhausts the heap while
* being written, and the check that would have rejected it never runs. Under a broker consumer that
* is a process-wide outage caused by one message.
```
세 가지 설계 결정이 붙어 있다.
1. **버퍼를 한도로 미리 잡지 않는다.** `new ByteArrayOutputStream(Math.min(maxBytes, 8_192))` — 주석: "a 1 GiB bound must not pre-allocate 1 GiB."
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")`).
3. **`requireFits(size)`는 예산을 소비하지 않는다.** Protobuf는 직렬화 크기를 미리 알므로 첫 바이트 전에 거절할 수 있다. 그리고 그 뒤의 쓰기도 여전히 경계 안이다 — 주석: "this is a cheaper refusal, not a replacement for the bound."
`refuseIfBeyondLimit``size > maxBytes - written`으로 비교하는 것도 의도적이다. `written + size > maxBytes`였다면 `int` 오버플로가 가능하다.
테스트가 실제 시나리오를 재현한다 — 10 MiB를 1 KiB씩 제공하고, `written()`이 한도(64) 이하로 유지되며 `toByteArray()`가 비어 있음을 확인한다(`BoundedByteSinkTest.java:34-53`).
### 4.3 `EncodedMessage`: 양방향 방어 복사
```java
public EncodedMessage {
...
bytes = bytes.clone(); // 생성 시
}
@Override
public byte[] bytes() {
return bytes.clone(); // 접근 시
}
```
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."
`equals`/`hashCode``Arrays.equals`/`Arrays.hashCode`로 재정의된다(record 기본은 배열 참조 비교라 항상 불일치). `toString`은 바이트를 찍지 않고 크기만 찍는다 — payload가 로그에 새지 않는다.
`size()`가 복사 없이 길이를 반환하는 별도 메서드로 있는 것도 의도적이다. `bytes().length`는 전체 복사를 유발한다.
### 4.4 `SchemaCompatibility`: 7개 모드와 transitive의 의미
```java
// SchemaCompatibility.java:6-8
* <p>Transitive modes check every historical version, not just the immediate predecessor. That
* matters for integration events, where a consumer may be several releases behind and a chain of
* individually-compatible changes can still be collectively breaking.
```
`NONE_EXPERIMENTAL`은 "M2 raw bytes에만 허용"이라고 enum 상수 javadoc이 적는다.
### 4.5 `SchemaRegistry`: 포트이고, 순서가 계약이다
```java
// SchemaRegistry.java:16-17
* <p>{@link #history} returns oldest first. Transitive compatibility checks read the whole list, so
* an ordering mistake here silently converts a transitive check into a pairwise one.
```
이것은 문서화된 함정이다. `history`가 newest-first로 구현되면 `versionsToCheck``reversed()`한 뒤 `history.get(0)`을 취하므로 **가장 오래된 버전 하나**만 비교하게 된다 — transitive가 pairwise로 조용히 축소되는 것이 아니라 아예 엉뚱한 버전을 비교한다.
`latest(subject)`가 default 메서드로 `versions.get(versions.size() - 1)`인 것도 같은 순서 계약에 의존한다. 테스트가 이 성질을 직접 단언한다(`SchemaCompatibilityValidatorTest.theLatestVersionIsTheNewestNotTheFirstListed`).
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."
### 4.6 `SchemaCompatibilityValidator`: 포맷 독립 규칙
두 가지를 한다.
**(a) 비교할 버전 목록**
```java
public List<SchemaVersion> versionsToCheck(String subject) {
SchemaCompatibility mode = registry.compatibilityOf(subject);
if (mode == SchemaCompatibility.NONE_EXPERIMENTAL) return List.of();
List<SchemaVersion> history = registry.history(subject).reversed();
if (history.isEmpty()) return List.of();
return isTransitive(mode) ? history : List.of(history.get(0));
}
```
**(b) production 목적지 게이트**
```java
public void requireProductionMode(String subject, String destination) {
if (registry.compatibilityOf(subject) == SchemaCompatibility.NONE_EXPERIMENTAL) {
throw new MessageSchemaIncompatibleException(
"UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION", ...);
}
}
```
javadoc이 이유를 적는다 — "A mode that checks nothing is useful while a message type is being designed and actively dangerous once a retained log exists, because the log outlives every consumer that could still read it."
그리고 **분리 자체의 이유**를 명시한다:
```java
// SchemaCompatibilityValidator.java:12-14
* <p>Split from the per-format gates on purpose. Whether v3 must be checked against v1 as well as
* v2 is a property of the compatibility mode, not of Avro or Protobuf, and duplicating that
* reasoning in each codec is how the two formats drift apart.
```
§12.1과 §12.3이 이 문장을 다시 다룬다.
### 4.7 `RawBytesMessageCodec`: 부재를 구현한다
```java
// RawBytesMessageCodec.java:12-16
* <p>It still enforces the byte limit, and it is deliberately excluded from default codec
* selection: schema-free publishing has to be an explicit, auditable choice per destination, never
* something a destination falls back to because its codec was misconfigured.
```
`encode``byte[]`가 아닌 payload를 `MessageSerializationException("RAW_BYTES_PAYLOAD_REQUIRED")`로 거절하고, `decode``byte[].class`가 아닌 대상을 `RAW_BYTES_TARGET_REQUIRED`로 거절한다. `decode``encoded.clone()`을 반환한다 — 호출자가 원본을 건드릴 수 없다.
`DEFAULT_MAX_BYTES = 1_048_576`(1 MiB)은 세 Stable codec이 공유하는 값이다.
**주의:** 이 codec은 `BoundedByteSink`를 쓰지 않는다. 이미 `byte[]`를 받으므로 스트리밍 경계가 의미 없고, `bytes.length > maxBytes` 비교로 충분하다. 다른 codec에서는 그 비교가 §4.2가 지적하는 "보고 임계값"이지만 여기서는 할당이 이미 끝난 입력이라 성격이 다르다.
---
## 5. 주요 실행 경로
세 개다.
1. **경계 있는 인코딩** — codec이 `BoundedByteSink.of(maxBytes, code)`를 만들고 → 포맷 라이브러리가 sink에 쓰고 → 한도를 넘는 write에서 `MessageTooLargeException` → 아니면 `sink.toByteArray()``EncodedMessage` 조립
2. **계약 조회**`new MessageContractKey(type, version)` → registry lookup → 미스면 "타입 미등록" vs "버전 미등록" 구분
3. **진화 검사**`registry.compatibilityOf(subject)``versionsToCheck` → (포맷별 게이트가 실제 비교)
3번은 이 저장소에서 실행되지 않는다(§12.1).
---
## 6. 실패 경로와 복구/번역
이 leaf가 던지는 예외는 셋이고 전부 `messaging-core-api` 소유다.
| 예외 | 코드 | 조건 |
|---|---|---|
| `MessageTooLargeException` | codec별(`PAYLOAD_TOO_LARGE`, `AVRO_PAYLOAD_TOO_LARGE`, …) | sink 한도 초과 |
| `MessageTooLargeException` | `RAW_BYTES_TOO_LARGE` | raw codec 한도 초과 |
| `MessageSerializationException` | `RAW_BYTES_PAYLOAD_REQUIRED` / `RAW_BYTES_TARGET_REQUIRED` | 타입 불일치 |
| `MessageSchemaIncompatibleException` | `UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION` | `NONE_EXPERIMENTAL`이 production 목적지에 |
`IllegalArgumentException`도 던진다 — `BoundedByteSink` 생성자의 `maxBytes < 1`, `requireFits`의 음수, `SchemaReference`의 빈 subject. 이들은 **호출자의 프로그래밍 오류**이고 메시지 실패가 아니므로 `MessagingException` 계층 밖인 것이 일관적이다.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
동시성: `BoundedByteSink`**의도적으로 thread-safe가 아니다.** javadoc이 명시한다 — "Not thread-safe, and not meant to be: an instance belongs to a single encode call." 실제로 codec들이 매 `encode` 호출마다 새로 만든다.
`EncodedMessage`, `MessageContractKey`, `SchemaReference`는 불변이다. `SchemaCompatibilityValidator`는 registry 참조만 갖고 상태가 없다.
`MessageCodecRegistry`/`SchemaRegistry` 구현의 스레드 안전성은 이 leaf가 규정하지 않는다 — port javadoc에 그에 대한 요구가 없다. 이것은 §17의 P3 항목이다.
---
## 8. 설정·기능 플래그·환경 차이
설정 없음. 상수 하나:
| 상수 | 값 | 위치 |
|---|---:|---|
| `RawBytesMessageCodec.DEFAULT_MAX_BYTES` | 1,048,576 | `RawBytesMessageCodec.java:21` |
`BoundedByteSink`의 초기 버퍼 상한 8,192는 private다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. `SchemaRegistry`가 외부 registry를 가리킬 수 있는 port지만, 이 leaf에는 구현이 없다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-schema-api:test`. **BUILD SUCCESSFUL, 19 tests, 0 skipped, 0 failures** (`--rerun-tasks`, revision `21234e38`).
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `BoundedByteSinkTest` | 4 | 한도 포함/초과 경계, 10 MiB 스트림이 한도에서 멈춤, pre-flight가 예산을 안 먹음, codec 에러 코드 전달 | 실제 codec들이 이 sink를 쓰는지(각 codec leaf가 소유) |
| `RawBytesMessageCodecTest` | 6 | round trip, content type, 비-byte[] 거절 양방향, 한도, `EncodedMessage` 방어 복사 | — |
| `SchemaCompatibilityValidatorTest` | 9 | pairwise vs transitive 목록, `NONE_EXPERIMENTAL` 빈 목록, 빈 history, production 게이트 양방향, `checksBackward`/`checksForward` 조합, `latest`가 newest | **production 코드가 이 validator를 호출하는지** |
마지막 칸이 핵심이다. `SchemaCompatibilityValidatorTest`는 9개 단언으로 규칙을 정확히 고정하지만, §12.1이 보이듯 그 규칙을 실행 경로에서 부르는 코드가 없다. 테스트는 **규칙이 옳다**를 증명하고 **규칙이 적용된다**를 증명하지 않는다.
테스트가 쓰는 `FixedRegistry``SchemaRegistry`의 유일한 구현이다(production 구현 0개, §12.1).
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| registry fail-closed | 등록됨 |
| `verifyCleanArchitectureDependencies` | `allowed_dependencies: ["messaging-core-api"]`와 실제 project edge 대조 |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| `src/messaging/CLAUDE.md`의 vendor `api` 규칙 | 이 leaf는 vendor 의존성이 없으므로 대상 없음 |
| ArchUnit | 이 leaf 전용 규칙 없음 |
`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 주석이 그 이유를 적는다.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/272-schema-family-reachability.txt`.
### 12.1 Public surface reachability
leaf 밖 참조를 파일 수로 세면:
| 타입 | leaf 밖 파일 수 | 판정 |
|---|---:|---|
| `EncodedMessage` | 50 | 널리 쓰임 — 사실상 이 leaf의 주력 수출품 |
| `SchemaCompatibility` | 15 | 세 codec leaf + policy가 씀 |
| `MessageContractKey` | 7 | 세 codec leaf가 씀 |
| `MessageCodec` | 7 | 세 codec + runtime-core |
| `MessageCodecRegistry` | 5 | runtime-core가 구현 |
| `SchemaReference` | 4 | codec들이 만듦 |
| `BoundedByteSink` | 3 | JSON·Avro·Protobuf codec |
| `RawBytesMessageCodec` | **0** | 자기 테스트만 |
| `SchemaCompatibilityValidator` | **0** | 자기 테스트만 |
| `SchemaRegistry` | **0** | 아래 참조 |
**`SchemaRegistry`의 "0"은 확인이 필요했다.** 단순 이름 검색은 2개 파일을 맞췄지만 둘 다 다른 타입이다:
```
src/adapter/outbound/messaging/.../LocalJsonSchemaRegistry.java:6: import com.networknt.schema.SchemaRegistry;
src/adapter/outbound/notification/.../JsonSchemaVariableValidator.java:5: import com.networknt.schema.SchemaRegistry;
```
`import dev.caskeleton.messaging.schema.SchemaRegistry` 검색은 exit 1이다. 즉 **이 플랫폼의 `SchemaRegistry` port를 import하는 파일이 저장소에 하나도 없다.** 이름 충돌이 우연히 검색을 오염시킨 사례이고, `-w` 단어 매칭만으로 reachability를 판정하면 안 되는 이유이기도 하다.
**`SchemaCompatibilityValidator`의 "0"이 이 leaf에서 가장 무거운 사실이다.** 검색 결과 전체가 자기 선언과 자기 테스트다. 다시 말해:
- 어떤 버전들을 비교해야 하는가 → 아무도 묻지 않는다
- `NONE_EXPERIMENTAL`이 production 목적지를 뒷받침할 수 있는가 → 아무도 묻지 않는다
`requireProductionMode`는 "retained log outlives every consumer"라는 이유로 만들어졌고, 그 게이트가 호출되는 지점이 없다.
`RawBytesMessageCodec`의 "0"은 성격이 다르다. 이 클래스가 없어도 그 **규칙**은 살아 있다 — §12.2 참조.
### 12.2 Conditional sibling comparison
Spring 주석 0개이므로 bean 활성화 비대칭은 없다.
대신 이 leaf에는 **다른 형태의 sibling 비대칭**이 있고 결과가 좋다. `RawBytesMessageCodec`의 javadoc이 "deliberately excluded from default codec selection"이라고 선언하는 규칙을, 실제로 강제하는 코드는 다른 leaf에 있다:
```java
// messaging-runtime-core/RegisteredMessageCodecs.java:52-56
if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) {
throw new IllegalArgumentException(
"the raw bytes codec must not be the default: every destination that has not declared an "
+ "encoding would silently skip schema validation");
}
```
**클래스가 아니라 content type으로 판정한다.** 그래서 `RawBytesMessageCodec`을 아무도 쓰지 않아도, 그리고 누가 `ContentType.OCTET_STREAM`을 내놓는 다른 codec을 새로 만들어도 규칙이 유지된다. 선언된 규칙과 강제하는 코드가 다른 leaf에 있으면서 **강제 쪽이 더 넓은** 드문 경우다. 결함이 아니라 확인된 설계로 기록한다.
### 12.3 Duplicate mechanism sweep
**`SchemaCompatibilityValidator`가 막으려던 중복이 실제로 존재한다.**
`AvroCompatibilityGate`(다른 leaf)가 같은 판단을 private static으로 다시 구현했다.
| 판단 | schema-api (`SchemaCompatibilityValidator`) | schema-avro (`AvroCompatibilityGate`) |
|---|---|---|
| transitive인가 | `mode == BACKWARD_TRANSITIVE \|\| FORWARD_TRANSITIVE \|\| FULL_TRANSITIVE` (:107-112) | **같은 식을 그대로** (:49-53) |
| 후방 검사하나 | `mode == BACKWARD \|\| BACKWARD_TRANSITIVE \|\| FULL \|\| FULL_TRANSITIVE`**허용목록** (:79-85) | `mode != FORWARD && mode != FORWARD_TRANSITIVE`**거부목록** (:55-57) |
| 전방 검사하나 | `mode == FORWARD \|\| FORWARD_TRANSITIVE \|\| FULL \|\| FULL_TRANSITIVE`**허용목록** (:93-99) | `mode != BACKWARD && mode != BACKWARD_TRANSITIVE`**거부목록** (:59-61) |
`isTransitive`는 글자까지 동일한 복사본이다. 방향 판정 둘은 **형태가 반대**다.
현재 enum 7개 값에 대해 두 구현의 결과를 대조하면 일치한다. `NONE_EXPERIMENTAL`만 다른데(validator는 둘 다 false, gate는 둘 다 true) `AvroCompatibilityGate.check:34`가 그 모드에서 먼저 return하므로 가려진다.
**문제는 오늘의 불일치가 아니라 형태다.** 허용목록은 새 모드가 추가되면 "검사 안 함"으로 기본값이 잡히고, 거부목록은 "양방향 검사"로 잡힌다. `SchemaCompatibility`에 값이 하나 추가되는 순간 두 구현은 **반대 방향으로** 갈라진다. javadoc이 예고한 "how the two formats drift apart"가 바로 이 형태이고, 그것을 막으려고 만든 클래스는 §12.1에서 보듯 호출되지 않는다.
`isTransitive``SchemaCompatibilityValidator`에서 **public static**이다. Avro 게이트가 그것을 부를 수 있었고 부르지 않았다.
### 12.4 Documentation / measured-count drift
이 leaf를 직접 이름으로 언급하는 문서 주장을 재측정했다.
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| 계획 문서: codec은 닫힌 registry에 대해 동작 | `MessageCodec` javadoc + 세 구현의 `requireRegistered`/`schemaFor` | **일치** |
| `RawBytesMessageCodec` javadoc: 기본 codec 선택에서 제외됨 | `RegisteredMessageCodecs.of` 생성자 검사 | **일치**(더 넓게 강제) |
| `SchemaRegistry` javadoc: history는 oldest-first | 유일한 구현이 테스트 fixture이고 그 계약을 지킴 | 일치하나 production 구현 없음 |
§12.4의 family 전체 drift(`support-matrix.md:23`의 runtime membership 주장)는 `analysis/messaging/messaging-core-api.md` §12.4가 소유한다. 이 leaf도 그 18개 wired 목록에 포함된다.
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
코드 주석이 보존한 이전 결함:
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `BoundedByteSink` javadoc | 각 codec이 무제한 버퍼에 직렬화 후 길이 비교 | 한도가 **보고 임계값**일 뿐 할당 경계가 아님 → 팽창하는 payload 하나가 consumer 프로세스를 죽임 |
| `MessageContractKey` javadoc | 타입만으로 registry 키 | v999가 v1 클래스로 디코딩되고 v999 라벨을 유지 → 하위 게이트·감사 기록이 등록된 적 없는 버전을 서술 |
두 사례 다 형태가 같다 — **검사가 없었던 게 아니라 검사의 위치/키가 틀렸다.** `messaging-core-api` §13의 "문자 vs 바이트, 정확일치 vs 세그먼트" 목록과 같은 계열이다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | `SchemaCompatibilityValidator` 호출자 전무, allowlist/denylist 두 형태 나란히, `SchemaRegistry` port import 0(exit=1)과 이름 충돌, codec별 소비자 | 정적 `git grep` |
| EVD-274 | command | `./gradlew :messaging:messaging-schema-api:test --rerun-tasks` | BUILD SUCCESSFUL, 19 / 0 / 0 | 순수 단위 레인 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 버전을 registry 키에 넣는 이유 — `MessageContractKey` javadoc
- 할당 경계 vs 보고 임계값 — `BoundedByteSink` javadoc
- codec 에러 코드를 sink에 넘기는 이유 — `BoundedByteSink` javadoc + 테스트 `as(...)`
- 포맷 독립 규칙을 분리한 이유 — `SchemaCompatibilityValidator` javadoc
- `NONE_EXPERIMENTAL`을 production에서 막는 이유 — 같은 javadoc
- `SchemaRegistry`를 port로 둔 이유, history 순서가 계약인 이유 — `SchemaRegistry` javadoc
- raw codec을 기본에서 제외하는 이유 — `RawBytesMessageCodec` javadoc + `RegisteredMessageCodecs` javadoc
- `EncodedMessage` 양방향 복사 이유 — `EncodedMessage` javadoc
**추론**
- `SchemaCompatibilityValidator`가 미호출인 것은 이 저장소에 schema registry를 실제로 운영하는 배포가 없기 때문이다 → **추론**. `SchemaRegistry` production 구현이 0인 것은 관측이고, 인과는 추론이다.
- Avro 게이트가 자기 복사본을 쓴 이유 → **미상**. 커밋 메시지에 근거가 없다.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 10개 타입 전부의 계약과 불변식
- 19개 테스트가 통과하고 무엇을 단언하는지
- `SchemaCompatibilityValidator`·`RawBytesMessageCodec`·`SchemaRegistry`의 leaf 밖 참조 0 (`SchemaRegistry`는 이름 충돌을 배제한 뒤)
- Avro 게이트의 중복 구현과 두 형태의 차이
- raw-bytes 기본 금지 규칙이 content type 기준으로 더 넓게 강제된다는 것
**확인하지 못한 것**
- `SchemaCompatibility` enum이 실제로 확장될 계획이 있는지. §12.3의 위험은 그때 실현된다.
- port 구현의 스레드 안전성 요구. javadoc에 없고 이 저장소에 production 구현이 없어 관측할 대상이 없다.
- `BoundedByteSink`의 경계가 실제 Jackson/Avro/Protobuf 인코더에서 기대대로 동작하는지 — 각 codec leaf의 테스트가 소유하고 이 문서 범위 밖이다.
---
## 17. 손볼 것
### P2 — 포맷 독립 진화 규칙이 호출되지 않고, 그것이 막으려던 중복이 실제로 생겼다
- **사실.** `SchemaCompatibilityValidator`의 저장소 전체 참조가 자기 선언과 자기 테스트뿐이다. 동시에 `AvroCompatibilityGate``isTransitive`를 글자 그대로 복사했고 방향 판정 둘은 허용목록/거부목록으로 형태가 반대다.
- **근거.** `evidence/raw/272` §A, §B.
- **왜 문제인가.** 오늘은 7개 모드 전부에서 두 구현의 결과가 같다(`NONE_EXPERIMENTAL`은 gate의 early return이 가린다). 그러나 enum에 값이 하나 추가되면 허용목록은 "검사 안 함", 거부목록은 "양방향 검사"로 **반대 방향** 기본값을 갖는다. 그리고 `requireProductionMode` — 검사 없는 스키마가 보존 로그를 뒷받침하는 것을 막는 게이트 — 는 호출되는 곳이 없다.
- **확인 방법.** `git grep -n -E 'requireProductionMode|versionsToCheck|SchemaCompatibilityValidator' -- 'src/**/*.java'`
- **후보.** (a) Avro 게이트가 `SchemaCompatibilityValidator`의 public static을 부르게 한다. (b) validator를 CI 게이트에 배선한다. (c) 둘 다 쓰지 않을 거라면 validator를 제거하고 규칙 소유권을 게이트로 옮긴다.
- **다음 단계.** **CASE 후보 + REFERENCE 후보**. "중복을 막으려고 만든 추상이 호출되지 않으면 중복은 그대로 생긴다"는 형태가 재사용 가능하다. 그리고 "허용목록과 거부목록은 enum이 자라는 순간 반대로 갈라진다"도 별도 기준이다.
### P3 — port 구현의 스레드 안전성 요구가 문서화되어 있지 않다
- **사실.** `SchemaRegistry``MessageCodecRegistry` javadoc에 동시성 요구가 없다. `BoundedByteSink`만 "not thread-safe"를 명시한다.
- **근거.** 세 타입의 javadoc 전문.
- **왜 문제인가.** `MessageCodecRegistry`의 유일한 구현 `RegisteredMessageCodecs``Map.copyOf`로 불변이라 안전하지만, 그것은 구현의 성질이지 계약이 아니다. 외부 registry를 감싸는 `SchemaRegistry` 구현은 브로커 소비자 스레드들에서 동시에 호출된다.
- **확인 방법.** 세 인터페이스의 javadoc 확인.
- **후보.** port javadoc에 "구현은 스레드 안전해야 한다"를 명시.
- **다음 단계.** **REFERENCE 후보**(port 계약은 동시성 요구를 적는다).
### P3 — `SchemaRegistry`라는 이름이 저장소에서 두 가지를 가리킨다
- **사실.** `dev.caskeleton.messaging.schema.SchemaRegistry`(이 leaf의 port)와 `com.networknt.schema.SchemaRegistry`(JSON Schema 라이브러리)가 공존하고, 후자만 실제로 import된다.
- **근거.** `evidence/raw/272` §C.
- **왜 문제인가.** 지금 깨지는 것은 없다. 다만 reachability 판정에서 실제로 오탐을 만들었다 — 단어 검색이 2건을 맞췄고 둘 다 다른 타입이었다. 사람이 같은 실수를 한다.
- **확인 방법.** `git grep -n 'import .*\.SchemaRegistry;' -- src`
- **후보.** 이름 변경 없이 두는 것이 합리적일 수 있다. 기록만 남긴다.
- **다음 단계.** **REFERENCE 후보**(도달성 판정은 단어가 아니라 import로 확인한다).
### 확인된 설계(문제 아님)
- `BoundedByteSink`가 codec의 에러 코드를 전달하고, pre-flight가 예산을 소비하지 않는 것 — 테스트가 양쪽을 고정
- `EncodedMessage`의 양방향 방어 복사와 payload를 찍지 않는 `toString`
- 버전을 registry 키에 포함하고 "타입 미등록"과 "버전 미등록"을 다른 코드로 구분하는 것
- raw-bytes 기본 금지가 클래스가 아니라 content type으로 강제되는 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MSA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps `["messaging-core-api"]`, memberships `["app-bootstrap"]` | 선언 |
| MSA-002 | build | `messaging-schema-api/build.gradle` | same | vendor 의존성 0 | — |
| MSA-003 | code | `.../schema/MessageContractKey.java` | same | 버전 키 결정과 그 이유 | — |
| MSA-004 | code | `.../schema/BoundedByteSink.java` | same | 할당 경계, 에러 코드 전달, pre-flight | 실제 인코더 동작은 각 codec leaf |
| MSA-005 | code | `.../schema/EncodedMessage.java` | same | 양방향 복사, 배열 equals, 안전한 toString | — |
| MSA-006 | code | `.../schema/SchemaCompatibilityValidator.java` | same | 포맷 독립 규칙과 분리 이유 | 호출자 없음(§12.1) |
| MSA-007 | code | `.../schema/SchemaRegistry.java` | same | port 계약, history oldest-first | production 구현 없음 |
| MSA-008 | code | `.../schema/RawBytesMessageCodec.java` | same | escape hatch 계약 | 외부 사용 0 |
| MSA-009 | code | `.../schema/{MessageCodec,MessageCodecRegistry,SchemaReference,SchemaCompatibility}.java` | same | codec/식별/모드 계약 | — |
| MSA-010 | test | `src/test/java/**` (3 클래스 / 19 테스트) | same | §10 표 | 순수 단위 |
| MSA-011 | cross-leaf code | `messaging-runtime-core/.../RegisteredMessageCodecs.java:29-77` | same | raw-bytes 기본 금지의 실제 강제 지점, 중복 content type 거절 | 해당 leaf SSOT가 소유 |
| MSA-012 | cross-leaf code | `messaging-schema-avro/.../AvroCompatibilityGate.java:34-61` | same | 중복 구현과 두 형태의 차이 | 해당 leaf SSOT가 소유 |
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | §12.1·§12.3 전부 | 정적 검색 |
| EVD-274 | command | `./gradlew :messaging:messaging-schema-api:test --rerun-tasks` | same | 19 / 0 skipped / 0 failures | 순수 단위 |