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

512 lines
29 KiB
Markdown

# messaging-schema-json 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-schema-json`
> SSOT owner: `messaging-schema-json`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-schema-json`
- canonical state `analysisFile`: `analysis/messaging/messaging-schema-json.md`
- source path: `src/messaging/messaging-schema-json`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]`
- registry `runtime_memberships`: `["app-bootstrap"]`
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | **1** |
| production LOC | 226 |
| 패키지 | 1 (`dev.caskeleton.messaging.schema.json`) |
| test 파일 | 3 |
| test 메서드(실행 확인) | 18 |
| 외부 의존성 | 1 (`tools.jackson.core:jackson-databind`, `implementation`) |
이 leaf는 클래스 하나다: `JacksonMessageCodec`. **그리고 messaging 플랫폼에서 production 소비자를 가진 유일한 codec이다**(§12.1).
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `.../json/JacksonMessageCodec.java` | 1 | `FULL_READ` | 226줄 전문 |
| `src/test/java/**` | 3 | `FULL_READ` | 전문 |
| `build.gradle` | 1 | `FULL_READ` | 8줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
Stable JSON codec 하나. `MessageCodec`(schema-api)을 구현하고 Jackson 3(`tools.jackson.*` 네임스페이스)을 쓴다.
javadoc이 "기본 codec으로 노출해도 안전한 이유" 셋을 명시한다.
```java
// JacksonMessageCodec.java:29-37
* <p>Three things make this safe to expose as the default. The message-type registry is closed, so
* a payload class only becomes reachable when someone registered it. The parser is constrained on
* depth, document length, and duplicate keys, so a hostile document cannot exhaust the consumer
* before the handler ever runs. And the encoded size is checked against the destination limit here
* rather than at the broker, so an oversized payload fails locally with {@code NOT_TRANSMITTED}
* evidence instead of ambiguously mid-flight.
*
* <p>Polymorphic default typing is never enabled. It is the mechanism behind most JSON
* deserialization gadget chains, and no legitimate message contract needs it.
```
세 번째가 `messaging-core-api`의 3상태 발행 결과와 직접 연결된다 — 크기 초과를 브로커가 아니라 여기서 잡으면 `NOT_TRANSMITTED` 증거가 붙은 `REJECTED`가 되고, 브로커에서 잡히면 `AMBIGUOUS`가 된다. 전자는 버려도 안전하고 후자는 아니다.
Jackson 의존성은 `implementation`이다 — public 시그니처에 Jackson 타입이 없기 때문이다. 형제 leaf(`schema-avro`, `schema-protobuf`, `cloudevents`)는 vendor 타입이 public 시그니처에 나오므로 `api`로 선언했고 build.gradle에 그 이유를 주석으로 적었다. `src/messaging/CLAUDE.md:40-43`의 게이트가 이 구분을 강제한다.
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `jackson-databind`(implementation).
나가는 것: `messaging-spring-boot-starter`(registry `allowed_dependencies`에 포함).
**실제 배선 지점이 하나 있다** — 이 플랫폼에서 유일하게 조립되는 codec이다.
```java
// messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:360-366
@ConditionalOnMissingBean(dev.caskeleton.messaging.schema.MessageCodecRegistry.class)
public dev.caskeleton.messaging.runtime.RegisteredMessageCodecs messagingCodecs(
ObjectProvider<MessageContracts> contracts) {
return dev.caskeleton.messaging.runtime.RegisteredMessageCodecs.of(
dev.caskeleton.messaging.schema.json.JacksonMessageCodec.of(
contracts.getIfAvailable(MessageContracts::none).byKey()));
}
```
`RegisteredMessageCodecs.of(defaultCodec, codecs...)`의 varargs 자리가 비어 있다. 즉 **출하 구성의 codec registry에는 JSON 하나만 들어간다.** Avro·Protobuf·raw bytes는 등록되지 않는다.
두 번째 배선 지점은 상수 참조다.
```java
// 같은 파일 :410-413
new dev.caskeleton.messaging.policy.PayloadPolicy(
JacksonMessageCodec.DEFAULT_MAX_BYTES,
JacksonMessageCodec.DEFAULT_MAX_BYTES / 2),
```
payload 정책의 상한이 **JSON codec의 상수에서 파생된다.** 포맷 중립이어야 할 admission 정책이 한 포맷의 클래스 상수를 참조한다 — §17에서 다룬다.
`contracts.getIfAvailable(MessageContracts::none)`이 기본값이므로, 애플리케이션이 `MessageContracts` bean을 내놓지 않으면 **빈 registry**로 codec이 만들어진다. 그 codec은 모든 `encode`/`decode``UNKNOWN_MESSAGE_TYPE`으로 거절한다.
---
## 3. 패키지/컴포넌트 지도
클래스 하나, 공개 표면 6개.
| 멤버 | 종류 | 용도 |
|---|---|---|
| `DEFAULT_MAX_BYTES` = 1,048,576 | public 상수 | starter의 payload 정책이 참조 |
| `MAX_NESTING_DEPTH` = 100 | public 상수 | 파서 깊이 상한 |
| `of(Map)` | factory | 기본 1 MiB |
| `of(Map, int)` | factory | 명시 상한 |
| `testingDefault(MessageType, Class)` | factory | 단일 계약, v1 |
| `testingDefault(MessageType, SchemaVersion, Class)` | factory | 단일 계약, 명시 버전 |
private 상수 둘: `MAX_STRING_CHARACTERS` = 5,000,000, `MAX_NUMBER_DIGITS` = 1,000.
---
## 4. 계약·불변식·상태 모델
### 4.1 파서 강화 — `strictMapper`
```java
// JacksonMessageCodec.java:207-225
JsonFactory factory =
JsonFactory.builder()
.streamReadConstraints(
StreamReadConstraints.builder()
.maxNestingDepth(MAX_NESTING_DEPTH) // 100
.maxDocumentLength(maxBytes) // = codec 상한
.maxNumberLength(MAX_NUMBER_DIGITS) // 1,000
.maxStringLength(MAX_STRING_CHARACTERS) // 5,000,000
.maxNameLength(MAX_STRING_CHARACTERS)
.build())
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
.build();
return JsonMapper.builder(factory)
.enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
```
여섯 가지 방어가 한 곳에 있다.
| 설정 | 막는 것 |
|---|---|
| `maxNestingDepth(100)` | 중첩 폭탄으로 파서 스택 소진 |
| `maxDocumentLength(maxBytes)` | 문서 길이 — codec 상한과 동일 |
| `maxNumberLength(1000)` | 초대형 `BigDecimal` 파싱 비용 |
| `maxStringLength`/`maxNameLength` | 단일 토큰 메모리 |
| `STRICT_DUPLICATE_DETECTION` + `FAIL_ON_READING_DUP_TREE_KEY` | 중복 키 — 파서마다 "먼저/나중 승리"가 달라 파싱 차이 공격이 됨 |
| `FAIL_ON_TRAILING_TOKENS` | 문서 뒤 추가 JSON — 두 번째 문서를 조용히 무시하는 것 |
| `FAIL_ON_UNKNOWN_PROPERTIES` | 미등록 필드 |
그리고 **polymorphic default typing을 켜지 않는다.** javadoc이 그것이 대부분의 JSON gadget chain의 기반이라고 적는다.
`maxDocumentLength``maxBytes`와 같다는 점이 중요하다 — 인코딩 상한과 디코딩 파서 상한이 하나의 값에서 나온다. 따로 두면 둘이 갈라진다.
### 4.2 인코딩 — 스트리밍 경계
```java
BoundedByteSink sink = BoundedByteSink.of(maxBytes, "PAYLOAD_TOO_LARGE");
try {
mapper.writeValue(sink, payload);
} catch (JacksonException exception) {
throw unwrapTooLarge(exception);
}
```
주석이 이유를 적는다 — "Jackson writes incrementally, so a payload whose serialized form is far larger than the limit stops at the limit instead of after the whole graph has been rendered into a buffer nobody bounded."
`unwrapTooLarge`가 필요한 이유도 명시돼 있다.
```java
// :190-196
* <p>Jackson wraps stream failures, so the size refusal would otherwise reach the caller as
* {@code JSON_ENCODE_FAILED} indistinguishable from a payload the mapper genuinely could not
* render, and the two need different operator responses.
```
`for (Throwable cause = exception; cause != null; cause = cause.getCause())` — 원인 사슬을 끝까지 훑어 `MessageTooLargeException`을 찾는다. 못 찾으면 `MessageSerializationException("JSON_ENCODE_FAILED")`.
### 4.3 registry 조회 — 세 갈래 결과
```java
private Class<?> requireRegistered(MessageType type, SchemaVersion version) {
MessageContractKey key = new MessageContractKey(type, version);
Class<?> registered = registry.get(key);
if (registered != null) return registered;
boolean typeIsKnown = registry.keySet().stream().anyMatch(known -> known.type().equals(type));
if (typeIsKnown) {
// Deliberately not falling back to another version's class: decoding v999 bytes with the v1
// class is exactly the silent type confusion the version-keyed registry exists to stop.
throw new MessageValidationException("SCHEMA_VERSION_NOT_REGISTERED", ...);
}
throw new MessageValidationException("UNKNOWN_MESSAGE_TYPE", ...);
}
```
`SCHEMA_VERSION_NOT_REGISTERED` 메시지에는 `registeredVersions(type)`가 정렬되어 포함된다. 테스트가 그 내용을 직접 단언한다 — `hasMessageContaining("order.created v999").hasMessageContaining("[1, 2]")`(`JsonContractRegistryTest.java:58-61`). 운영자가 "1과 2는 있고 999는 없다"를 에러 메시지만으로 알 수 있다.
### 4.4 인코딩·디코딩의 타입 검사 비대칭
| 방향 | 검사 |
|---|---|
| `encode` | `registered.isInstance(payload)`**하위 타입 허용** |
| `decode` | `registered.equals(payloadType)`**정확 일치 요구** |
비대칭이 합리적이다. 인코딩에서 `OrderCreated`의 하위 타입을 넘기면 Jackson이 등록된 형태로 직렬화한다. 디코딩에서 하위 타입을 허용하면 등록된 계약과 다른 클래스로 역직렬화되므로 정확 일치여야 한다. 다만 이 비대칭은 주석으로 설명되지 않았다 — §15의 추론 항목이다.
### 4.5 디코딩의 이중 상한
```java
if (encoded.length > maxBytes) { throw new MessageTooLargeException("PAYLOAD_TOO_LARGE", ...); }
...
return mapper.readValue(encoded, payloadType);
```
명시 검사 하나(`encoded.length`)와 파서 내부 검사 하나(`maxDocumentLength`)가 겹친다. 중복이지만 둘의 실패 형태가 다르다 — 전자는 `MessageTooLargeException`, 후자는 `JacksonException``MessageSerializationException`. 명시 검사가 있어야 크기 초과가 크기 초과로 보고된다.
### 4.6 `EncodedMessage`에 붙는 schema reference
```java
return new EncodedMessage(
sink.toByteArray(), ContentType.JSON, Optional.of(SchemaReference.of(type.value(), version)));
```
subject가 message type 값이고 URI는 없다. 즉 이 codec은 외부 schema registry를 쓰지 않고 "타입 이름 + 버전"을 스키마 신원으로 삼는다. 테스트가 확인한다(`JacksonMessageCodecTest.encodedMessageCarriesTheSchemaReference`).
---
## 5. 주요 실행 경로
**encode:** `requireRegistered(type, version)` → payload가 등록 타입의 인스턴스인지 → `BoundedByteSink` 생성 → `mapper.writeValue(sink, payload)` → 실패 시 `unwrapTooLarge``EncodedMessage(bytes, JSON, SchemaReference)`
**decode:** `requireRegistered(type, version)` → 요청 클래스가 등록 클래스와 정확히 같은지 → `encoded.length` 상한 → `mapper.readValue``JacksonException`이면 `JSON_DECODE_FAILED`
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 조건 | retryable |
|---|---|---|:---:|
| `UNKNOWN_MESSAGE_TYPE` | `MessageValidationException` | 타입 자체 미등록 | false |
| `SCHEMA_VERSION_NOT_REGISTERED` | `MessageValidationException` | 타입은 알고 버전 미등록 | false |
| `PAYLOAD_TYPE_MISMATCH` | `MessageValidationException` | encode: 인스턴스 아님 / decode: 클래스 불일치 | false |
| `PAYLOAD_TOO_LARGE` | `MessageTooLargeException` | 인코딩 중 한도 초과 또는 디코딩 입력 초과 | false |
| `JSON_ENCODE_FAILED` | `MessageSerializationException` | 그 외 Jackson 인코딩 실패 | false |
| `JSON_DECODE_FAILED` | `MessageSerializationException` | 파싱 실패(깊이·중복키·trailing·미지 필드 포함) | false |
전부 `retryable = false`다 — `PERMANENT_BUSINESS``DESERIALIZATION` 카테고리다. 같은 바이트를 다시 디코딩해도 같은 결과이므로 일관적이다.
**진단 손실 하나.** 파서 강화가 잡는 여섯 가지(깊이, 중복 키, trailing token, 미지 필드, 문서 길이, 토큰 길이)가 전부 하나의 코드 `JSON_DECODE_FAILED`로 접힌다. 운영자는 "JSON 디코딩 실패"만 보고 원인 여섯 갈래를 구분할 수 없다. 원인 예외가 `cause`로 붙지만 `FailureDescriptor``exceptionType``Optional.empty()`로 둔다(`MessageSerializationException`의 3인자 생성자 경로). §17 참조.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
동시성: `JacksonMessageCodec`은 불변이다 — `registry``Map.copyOf`, `maxBytes`는 int, `mapper`는 빌드 후 재구성되지 않는 Jackson `ObjectMapper`(스레드 안전). `BoundedByteSink`는 매 `encode`마다 새로 만들어지므로 공유되지 않는다.
`PlatformOverheadPerformanceTest.aRoundTripDoesNotAllocateAGrowingRetainedSet`이 codec이 메시지별 상태를 보유하지 않음을 간접 확인한다(메시지당 유지 메모리 64바이트 미만).
---
## 8. 설정·기능 플래그·환경 차이
설정 파일 없음. 상수:
| 상수 | 값 | 가시성 |
|---|---:|---|
| `DEFAULT_MAX_BYTES` | 1,048,576 | public — starter가 참조 |
| `MAX_NESTING_DEPTH` | 100 | public |
| `MAX_STRING_CHARACTERS` | 5,000,000 | private |
| `MAX_NUMBER_DIGITS` | 1,000 | private |
`maxBytes`는 생성자 인자로 재정의 가능하고 파서의 `maxDocumentLength`가 그 값을 따라간다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-schema-json:test`. **BUILD SUCCESSFUL, 18 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `JacksonMessageCodecTest` | 9 | round trip, 1 MiB 초과 거절, 미등록 타입, payload 타입 불일치, trailing token, 미지 필드, 중복 키, 깊이 200 거절, schema reference | 등록 registry가 실제 배포에서 채워지는지 |
| `JsonContractRegistryTest` | 6 | 버전별 클래스 분리, v999 거절 + 등록 버전 목록 노출, 클래스/버전 짝 검사, 타입 미등록과 버전 미등록 구분, 20 MiB payload가 1,024 상한에서 멈춤, 정확히 상한인 payload 허용 | — |
| `PlatformOverheadPerformanceTest` | 3 | 봉투 생성 < 20µs/건, JSON 인코딩 < 50µs/건, round trip 유지 메모리 < 64 B/건 | 실제 처리량. 의도적으로 브로커 없음 |
성능 테스트의 자기 규정이 명확하다.
```java
// PlatformOverheadPerformanceTest.java:22-30
* <p>This measures what the platform adds identity, validation, encoding and nothing else.
* There is no broker in the loop, deliberately: broker throughput is a property of the deployment
* and varies by an order of magnitude between a laptop and a cluster, so asserting on it produces a
* test that fails for reasons nobody can act on.
*
* <p>The budgets are generous on purpose. The regression worth catching here is structural an
* accidental per-message reflection call, a defensive copy that became a deep copy, a validator
* that started compiling a regex per invocation and those cost orders of magnitude, not
* percentages. A tight budget would instead catch a busy CI agent.
```
이것은 성능 테스트가 무엇을 잡으려는지 명시한 드문 예다 — 퍼센트가 아니라 자릿수 회귀. 다만 `aRoundTripDoesNotAllocateAGrowingRetainedSet``System.gc()``totalMemory() - freeMemory()`에 의존하므로 JVM이 GC 힌트를 무시하면 잡음이 낀다. 64 B/건이라는 여유가 그것을 흡수한다.
`JsonContractRegistryTest`의 클래스 javadoc이 이 codec에서 만난 두 결함을 기록한다 — 타입만으로 키를 잡았던 것과, 완성된 배열에 크기 제한을 적용했던 것.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | Jackson이 public 시그니처에 없으므로 `implementation`이 맞음 — 형제 leaf와 반대 판정 |
| ArchUnit | 전용 규칙 없음 |
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/272-schema-family-reachability.txt`.
### 12.1 Public surface reachability
`JacksonMessageCodec`의 leaf 밖 참조는 **1개 파일**이다 — `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java`.
이 하나가 messaging codec 전체에서 유일한 production 소비다. 형제 비교:
| codec | 소비자 | registry membership |
|---|---|---|
| `JacksonMessageCodec` | `MessagingCoreAutoConfiguration` | `["app-bootstrap"]` |
| `AvroMessageCodec` | **없음** | `[]` |
| `ProtobufMessageCodec` | **없음** | `[]` |
| `RawBytesMessageCodec` | **없음** | (schema-api 소속, `["app-bootstrap"]`) |
| `DefaultCloudEventMapper` | **없음** | `["app-bootstrap"]` |
Avro·Protobuf는 소비자 없음과 membership 없음이 **일치한다** — 정합적인 incubating 상태다. `RawBytesMessageCodec`과 CloudEvents는 어긋난다(각 leaf 문서 참조).
### 12.2 Conditional sibling comparison
이 leaf에는 bean이 없다. 그러나 이 leaf가 조립되는 지점의 조건은 확인했다.
```java
@ConditionalOnMissingBean(dev.caskeleton.messaging.schema.MessageCodecRegistry.class)
```
즉 애플리케이션이 자기 `MessageCodecRegistry`를 내놓으면 JSON codec 조립이 통째로 대체된다. 그 경우 `PayloadPolicy`가 참조하는 `JacksonMessageCodec.DEFAULT_MAX_BYTES`**그대로 남는다** — 정책 상한만 JSON codec의 값을 유지한다. §17 참조.
### 12.3 Duplicate mechanism sweep
JSON 인코딩/디코딩을 하는 다른 지점이 저장소에 여럿 있다(web adapter의 응답 직렬화, redis codec, fileserver 저널, mongo cursor 등). 그러나 그들은 **다른 책임**(HTTP 응답, 캐시 봉투, 로컬 저널)이고 messaging 계약을 구현하지 않는다. runtime eligibility가 겹치지 않으므로 중복 경쟁으로 분류하지 않는다.
같은 `messaging` family 안에서 `MessageCodec`을 구현하는 것은 넷이고(JSON·Avro·Protobuf·raw) content type이 서로 달라 `RegisteredMessageCodecs.register`가 충돌을 거절한다. 책임 분리가 명확하다.
**한 가지 실질 중복이 있다.** 1 MiB payload 상한이 messaging family의 production 코드 **다섯 곳**에서 독립적으로 선언된다.
| 위치 | 가시성 | 값 |
|---|---|---:|
| `messaging-policy/PayloadPolicy.DEFAULT_MAX_BYTES:17` | **public** | 1,048,576 |
| `messaging-schema-api/RawBytesMessageCodec.DEFAULT_MAX_BYTES:21` | public | 1,048,576 |
| `messaging-schema-json/JacksonMessageCodec.DEFAULT_MAX_BYTES:42` | public | 1,048,576 |
| `messaging-schema-avro/AvroMessageCodec.DEFAULT_MAX_BYTES:46` | private | 1,048,576 |
| `messaging-schema-protobuf/ProtobufMessageCodec.DEFAULT_MAX_BYTES:35` | private | 1,048,576 |
테스트에도 네 곳(`ClaimCheckRetentionValidatorTest:47`, `DestinationProfileValidatorTest:225`, `RabbitContractHarness:40`, `InMemoryMessagingHarness:31`)이 같은 리터럴을 갖는다.
`schema-api``RawBytesMessageCodec` javadoc은 이 값을 "The default encoded byte limit **shared with** the Stable codecs"라고 부르는데, 실제로는 공유되지 않고 복사돼 있다. 그리고 **정책 쪽에 이미 주인이 있다**`messaging-policy``PayloadPolicy.DEFAULT_MAX_BYTES`가 public 상수로 존재한다. 그런데 starter는 그것을 쓰지 않고 `JacksonMessageCodec.DEFAULT_MAX_BYTES`를 참조한다(§2). 같은 값의 후보가 둘 있고 배선이 덜 적절한 쪽을 골랐다.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `RawBytesMessageCodec` javadoc: 1 MiB가 "Stable codec들과 공유되는" 기본 상한 | 네 codec에 각자 리터럴 존재, 공유 상수 없음 | **표현 drift** — 값은 일치, "shared"는 사실이 아님 |
| `JacksonMessageCodec` javadoc: polymorphic default typing 미사용 | `strictMapper``activateDefaultTyping` 호출 없음 | **일치** |
| `docs/messaging/support-matrix.md`의 JSON Stable 등급 | 이 leaf가 유일하게 조립되는 codec인 것과 정합 | **일치** |
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
`JsonContractRegistryTest` 클래스 javadoc이 이 codec에서 만난 두 결함을 남겼다.
```java
// JsonContractRegistryTest.java:20-24
* <p>Two defects met in this codec. The registry was keyed on message type alone, so a message
* labelled v999 was decoded with the v1 class and kept its v999 label the compatibility gate and
* the audit record then both described a contract that was never registered. And the size limit was
* applied to the finished byte array, which reports an oversized payload rather than preventing
* one.
```
두 결함 다 `messaging-schema-api`가 소유하는 타입(`MessageContractKey`, `BoundedByteSink`)으로 고쳐졌다. 즉 **이 leaf에서 발견된 문제가 상위 leaf의 타입을 만들어냈다.**
`MessagingCoreAutoConfiguration:420-427`의 주석은 이 codec이 아니라 publisher 조립 결함(MSG-INT-003)을 기록하는데, 같은 configuration 안에 있으므로 조립 이력의 맥락으로 참조할 가치가 있다 — "no configuration produced one … the starter did not depend on that leaf."
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §D, §E | codec별 소비자와 registry membership | 정적 검색 |
| EVD-275 | command | `./gradlew :messaging:messaging-schema-json:test --rerun-tasks` | BUILD SUCCESSFUL, 18 / 0 / 0 | 브로커 없음 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 기본 codec으로 안전한 이유 셋 — 클래스 javadoc
- polymorphic default typing 금지 — 클래스 javadoc
- 크기 초과를 로컬에서 잡아야 `NOT_TRANSMITTED`가 된다 — 클래스 javadoc
- `unwrapTooLarge`가 필요한 이유 — 메서드 javadoc
- 다른 버전 클래스로 폴백하지 않는 이유 — `requireRegistered` 주석
- 성능 예산이 느슨한 이유 — `PlatformOverheadPerformanceTest` javadoc
- 이 codec에서 만난 두 결함 — `JsonContractRegistryTest` javadoc
**추론**
- encode는 `isInstance`, decode는 `equals`로 비대칭인 이유 → **추론**. 방향별 안전성으로 설명되지만 주석이 없다.
- 파서 실패 여섯 갈래가 한 코드로 접힌 것이 의도인지 → **미상**.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 226줄 전문의 계약과 파서 강화 설정 전수
- 18개 테스트가 통과하고 무엇을 단언하는지
- 이 codec이 유일하게 조립되는 codec이라는 것과 그 조립 코드의 정확한 형태
- payload 정책 상한이 이 codec의 public 상수에서 파생된다는 것
- 1 MiB 상한이 네 codec에 복사돼 있다는 것
**확인하지 못한 것**
- 실제 배포에서 `MessageContracts` bean이 채워지는지. 채워지지 않으면 codec은 모든 메시지를 `UNKNOWN_MESSAGE_TYPE`으로 거절한다. 이 저장소에 `MessageContracts` production 구현이 있는지는 starter leaf가 소유한다.
- Jackson 3의 `StreamReadConstraints`가 이 값들에서 실제로 어떻게 실패하는지 — 테스트는 깊이 200과 중복 키만 확인했고 `maxNumberLength`·`maxStringLength`는 검증하지 않았다.
- 성능 예산이 실제 CI 하드웨어에서 얼마나 여유 있는지 — 이번 실행은 통과했으나 측정값을 남기지 않았다.
---
## 17. 손볼 것
### P2 — 포맷 중립 payload 정책이, 자기 상수를 두고 JSON codec의 상수를 참조한다
- **사실.** `MessagingCoreAutoConfiguration:410-413``new PayloadPolicy(JacksonMessageCodec.DEFAULT_MAX_BYTES, JacksonMessageCodec.DEFAULT_MAX_BYTES / 2)`를 만든다. 그런데 `PayloadPolicy` 자신이 같은 값의 public 상수 `PayloadPolicy.DEFAULT_MAX_BYTES`(`messaging-policy/PayloadPolicy.java:17`)를 갖고 있다.
- **근거.** 두 라인, 그리고 `git grep -n '1_048_576' -- 'src/messaging/**/*.java'`의 production 5건.
- **왜 문제인가.** `MessagingAdmissionController`는 목적지의 codec이 무엇이든 지나는 관문이다. 그 상한이 **한 포맷 클래스**의 상수에서 나오면 두 가지가 깨진다. (1) `@ConditionalOnMissingBean`이 허용하는 대로 애플리케이션이 자기 `MessageCodecRegistry`를 내놓아 JSON codec을 대체해도, 정책은 여전히 JSON codec의 값을 읽는다. (2) 다섯 곳의 리터럴 중 하나만 바뀌면 조용히 갈라지고, `RawBytesMessageCodec` javadoc이 이미 "shared with the Stable codecs"라고 사실과 다르게 부르고 있다. 정책 소유자가 이미 존재하는데 배선이 그것을 지나쳤다.
- **확인 방법.** `git grep -n '1_048_576' -- 'src/messaging/**/*.java'` · `grep -n 'DEFAULT_MAX_BYTES' src/messaging/messaging-policy/src/main/java/dev/caskeleton/messaging/policy/PayloadPolicy.java`
- **후보.** starter가 `PayloadPolicy.DEFAULT_MAX_BYTES`를 참조하게 바꾸고, 네 codec의 기본값도 그 상수(또는 설정 프로퍼티)에서 파생시킨다.
- **다음 단계.** **CASE 후보.** 조립 지점이 한 줄이고 재현이 정적이며, "값은 맞는데 출처가 틀렸다"는 형태가 명확하다.
### P3 — 파서 방어 여섯 갈래가 하나의 실패 코드로 접힌다
- **사실.** 깊이 초과·중복 키·trailing token·미지 필드·문서 길이·토큰 길이가 전부 `JSON_DECODE_FAILED`가 된다.
- **근거.** `decode``catch (JacksonException)` 단일 분기(`JacksonMessageCodec.java:155-158`).
- **왜 문제인가.** 여섯 중 셋(중복 키, trailing token, 깊이)은 **적대적 입력의 신호**이고 나머지는 계약 불일치다. DLQ에 쌓인 메시지를 보는 운영자가 그 둘을 구분할 수 없다. `FailureDescriptor.exceptionType`도 비어 있다.
- **확인 방법.** `JacksonMessageCodecTest`의 네 케이스가 전부 같은 예외 타입을 기대하는 것으로 확인 가능.
- **후보.** `JacksonException` 하위 타입별로 코드를 나누거나, 최소한 `exceptionType`에 원인 클래스 단순명을 채운다.
- **다음 단계.** **REFERENCE 후보**(실패 코드는 운영자의 다음 행동이 갈리는 지점마다 나눈다).
### P3 — 빈 registry로 조립되면 모든 메시지가 거절된다
- **사실.** `contracts.getIfAvailable(MessageContracts::none)`이 기본값이므로 `MessageContracts` bean이 없으면 빈 registry로 codec이 만들어진다.
- **근거.** `MessagingCoreAutoConfiguration:362-365`.
- **왜 문제인가.** 그 codec은 시작에 성공하고 첫 publish에서 `UNKNOWN_MESSAGE_TYPE`으로 실패한다. `messaging-core-api` 계열의 다른 leaf에서 관측된 것과 같은 형태다 — "시작은 하고 첫 쓰기에서 실패한다."
- **확인 방법.** `MessageContracts` production 구현의 존재 여부를 starter leaf에서 확인해야 한다.
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 이 leaf 밖(`messaging-spring-boot-starter`)의 사실에 걸린다. 그 leaf SSOT가 답을 갖는다.
### 확인된 설계(문제 아님)
- 파서 상한 여섯 가지와 polymorphic typing 금지
- `maxDocumentLength`가 codec 상한과 같은 값에서 나오는 것
- `unwrapTooLarge`가 원인 사슬을 훑어 크기 실패를 크기 실패로 보고하는 것
- 미등록 버전 에러가 등록된 버전 목록을 포함하는 것
- Jackson을 `implementation`으로 선언한 것(형제 leaf와 반대이고, 그것이 맞다)
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MSJ-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, memberships `["app-bootstrap"]` | 선언 |
| MSJ-002 | build | `messaging-schema-json/build.gradle` | same | Jackson이 `implementation` | — |
| MSJ-003 | code | `.../json/JacksonMessageCodec.java` 전문 | same | §4 전체 | — |
| MSJ-004 | test | `JacksonMessageCodecTest` (9) | same | 파서 방어와 registry 거절 | 브로커 없음 |
| MSJ-005 | test | `JsonContractRegistryTest` (6) | same | 버전 키 동작, 20 MiB가 1 KiB 상한에서 멈춤 | — |
| MSJ-006 | test | `PlatformOverheadPerformanceTest` (3) | same | 구조적 회귀 예산 | 처리량 아님. `System.gc()` 의존 |
| MSJ-007 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:358-366, 408-417` | same | 유일한 codec 조립 지점, varargs 비어 있음, payload 정책의 상수 출처 | 해당 leaf SSOT가 소유 |
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | codec별 소비자와 membership | 정적 검색 |
| EVD-275 | command | `./gradlew :messaging:messaging-schema-json:test --rerun-tasks` | same | 18 / 0 / 0 | — |