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>
29 KiB
messaging-schema-json 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-schema-jsonSSOT owner:messaging-schema-jsonintegration/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으로 노출해도 안전한 이유" 셋을 명시한다.
// 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이다.
// 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는 등록되지 않는다.
두 번째 배선 지점은 상수 참조다.
// 같은 파일 :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
// 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 인코딩 — 스트리밍 경계
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가 필요한 이유도 명시돼 있다.
// :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 조회 — 세 갈래 결과
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 디코딩의 이중 상한
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
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/건 | 실제 처리량. 의도적으로 브로커 없음 |
성능 테스트의 자기 규정이 명확하다.
// 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가 조립되는 지점의 조건은 확인했다.
@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에서 만난 두 결함을 남겼다.
// 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주석 - 성능 예산이 느슨한 이유 —
PlatformOverheadPerformanceTestjavadoc - 이 codec에서 만난 두 결함 —
JsonContractRegistryTestjavadoc
추론
- encode는
isInstance, decode는equals로 비대칭인 이유 → 추론. 방향별 안전성으로 설명되지만 주석이 없다. - 파서 실패 여섯 갈래가 한 코드로 접힌 것이 의도인지 → 미상.
16. 확인한 것 / 확인하지 못한 것
확인한 것
- 226줄 전문의 계약과 파서 강화 설정 전수
- 18개 테스트가 통과하고 무엇을 단언하는지
- 이 codec이 유일하게 조립되는 codec이라는 것과 그 조립 코드의 정확한 형태
- payload 정책 상한이 이 codec의 public 상수에서 파생된다는 것
- 1 MiB 상한이 네 codec에 복사돼 있다는 것
확인하지 못한 것
- 실제 배포에서
MessageContractsbean이 채워지는지. 채워지지 않으면 codec은 모든 메시지를UNKNOWN_MESSAGE_TYPE으로 거절한다. 이 저장소에MessageContractsproduction 구현이 있는지는 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) 다섯 곳의 리터럴 중 하나만 바뀌면 조용히 갈라지고,RawBytesMessageCodecjavadoc이 이미 "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)이 기본값이므로MessageContractsbean이 없으면 빈 registry로 codec이 만들어진다. - 근거.
MessagingCoreAutoConfiguration:362-365. - 왜 문제인가. 그 codec은 시작에 성공하고 첫 publish에서
UNKNOWN_MESSAGE_TYPE으로 실패한다.messaging-core-api계열의 다른 leaf에서 관측된 것과 같은 형태다 — "시작은 하고 첫 쓰기에서 실패한다." - 확인 방법.
MessageContractsproduction 구현의 존재 여부를 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 | — |