# 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 *

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. * *

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 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 *

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 *

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. * *

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 *

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 | — |