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>
512 lines
29 KiB
Markdown
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 | — |
|