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

Follows the import procedure in README.md.

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

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

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

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

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

32 KiB

messaging-schema-api 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-schema-api SSOT owner: messaging-schema-api integration/family document: analysis/19-messaging-platform.md (secondary, INTEGRATION_ONLY)

성격. 읽기 기록이다. 이 leaf가 선언한 codec/schema 계약과, 그 중 무엇이 실제로 호출되는지를 source anchor와 함께 적는다.


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-schema-api
  • canonical state analysisFile: analysis/messaging/messaging-schema-api.md
  • source path: src/messaging/messaging-schema-api
  • leaf-owned subdocuments: 없음
  • registry allowed_dependencies: ["messaging-core-api"]
  • registry runtime_memberships: ["app-bootstrap"]

숫자

항목
production Java 파일 10
production LOC 630
패키지 1 (dev.caskeleton.messaging.schema)
test 파일 3
test 메서드(실행 확인) 19
외부(비프로젝트) 의존성 0

10개 타입의 성격:

타입 종류 역할
MessageCodec interface 한 wire 포맷의 인코딩/디코딩
MessageCodecRegistry interface content type → codec, 그리고 기본 codec
SchemaRegistry interface subject/version → schema, 그리고 compatibility mode
MessageContractKey record (MessageType, SchemaVersion) — registry 키
SchemaReference record subject + version + 선택적 URI
EncodedMessage record 바이트 + content type + schema reference
SchemaCompatibility enum(7) 진화 모드
SchemaCompatibilityValidator class 포맷 독립 진화 규칙
BoundedByteSink class 한도 초과 바이트를 쓰기 시점에 거절하는 OutputStream
RawBytesMessageCodec class 스키마 없는 M2 escape hatch

Coverage ledger

scope/file group count disposition reason
src/main/java/** (10) 10 FULL_READ 전 파일 본문 확인
src/test/java/** (3) 3 FULL_READ 전 파일 본문 확인
build.gradle 1 FULL_READ 5줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

이 leaf는 "바이트를 어떻게 만들고 읽는가"의 계약을 소유한다. 실제 포맷 구현은 갖지 않는다 — 단 하나의 예외가 RawBytesMessageCodec이고, 그것은 포맷이 아니라 포맷의 부재를 구현한다.

경계 규칙 하나가 모든 곳에 반복된다: codec은 닫힌 registry에 대해서만 동작한다.

// MessageCodec.java:10-12
 * <p>Implementations operate against a closed message-type registry. Accepting an unregistered type
 * would let a producer introduce a wire contract nothing has reviewed, which is the same class of
 * problem that makes Java serialization unsupported here.

build.gradleapi project(':messaging:messaging-core-api') 하나뿐이고 vendor 의존성이 없다. 포맷별 vendor(jackson, avro, protobuf)는 각자 leaf가 갖는다.


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api 노출).

나가는 것: messaging-schema-json, messaging-schema-avro, messaging-schema-protobuf, messaging-cloudevents, messaging-policy, messaging-transport-spi, messaging-runtime-core, messaging-kafka, messaging-rabbit, messaging-pulsar-experimental, messaging-nats-experimental, messaging-spring-boot-starter, messaging-testkit.

런타임 편입은 messaging-core-api와 같은 경로다 — app-bootstrapmessaging-spring-boot-starter를 선언하고 그 closure가 이 leaf를 끌어온다.

이 leaf는 bean을 만들지 않는다. Spring 주석 0개.


3. 패키지/컴포넌트 지도

패키지 하나에 10개 타입이 평평하게 있다. 관심사로 나누면 셋이다.

codec 축          MessageCodec ── MessageCodecRegistry
                       │
                       └── RawBytesMessageCodec (유일한 구현)

식별 축           MessageContractKey (type, version)
                  SchemaReference    (subject, version, uri?)
                  EncodedMessage     (bytes, contentType, schemaReference?)

진화 축           SchemaRegistry ── SchemaCompatibility(7)
                       │
                       └── SchemaCompatibilityValidator

경계 축           BoundedByteSink

4. 계약·불변식·상태 모델

4.1 MessageContractKey: 버전을 키에 넣는 이유

이 leaf에서 가장 밀도 높은 javadoc이다.

// MessageContractKey.java:10-17
 * <p>Keying on the message type alone is what let an unregistered version decode. The version
 * travels in the envelope and in {@link SchemaReference}, so a consumer receiving {@code
 * order.created v999} would look up {@code order.created}, find the v1 class or parser, decode
 * against it, and then keep the v999 label on the result. Nothing failed, and every downstream
 * compatibility gate and audit record then described a version that was never registered.

핵심은 "Nothing failed"다. 타입만으로 키를 잡으면 실패가 발생하지 않고 잘못된 성공이 발생한다. 그리고 그 결과에는 등록된 적 없는 버전 라벨이 붙어 하위 감사 기록까지 오염된다.

이 결정은 세 codec에 전부 반영돼 있다 — JacksonMessageCodec.requireRegistered, AvroMessageCodec.schemaFor, ProtobufMessageCodec.requireRegistered가 모두 "타입은 아는데 버전을 모른다"와 "타입 자체를 모른다"를 다른 에러 코드로 구분한다(SCHEMA_VERSION_NOT_REGISTERED vs UNKNOWN_MESSAGE_TYPE). 그 구분이 있어야 운영자가 "등록을 빠뜨렸다"와 "오타다"를 나눌 수 있다.

4.2 BoundedByteSink: 보고 임계값 → 할당 경계

// BoundedByteSink.java:11-15
 * <p>Every codec here used to serialize into an unbounded buffer and compare {@code bytes.length}
 * to the configured maximum afterwards. That makes the maximum a reporting threshold rather than an
 * allocation bound: a payload whose graph expands to hundreds of megabytes exhausts the heap while
 * being written, and the check that would have rejected it never runs. Under a broker consumer that
 * is a process-wide outage caused by one message.

세 가지 설계 결정이 붙어 있다.

  1. 버퍼를 한도로 미리 잡지 않는다. new ByteArrayOutputStream(Math.min(maxBytes, 8_192)) — 주석: "a 1 GiB bound must not pre-allocate 1 GiB."
  2. codec의 에러 코드를 그대로 던진다. errorCode가 생성자 인자다. 그래서 Avro는 AVRO_PAYLOAD_TOO_LARGE, JSON은 PAYLOAD_TOO_LARGE가 나온다. 테스트가 이 성질을 직접 단언한다(BoundedByteSinkTest.java:69-77, as("the sink reports the codec's own code, not a generic one")).
  3. requireFits(size)는 예산을 소비하지 않는다. Protobuf는 직렬화 크기를 미리 알므로 첫 바이트 전에 거절할 수 있다. 그리고 그 뒤의 쓰기도 여전히 경계 안이다 — 주석: "this is a cheaper refusal, not a replacement for the bound."

refuseIfBeyondLimitsize > maxBytes - written으로 비교하는 것도 의도적이다. written + size > maxBytes였다면 int 오버플로가 가능하다.

테스트가 실제 시나리오를 재현한다 — 10 MiB를 1 KiB씩 제공하고, written()이 한도(64) 이하로 유지되며 toByteArray()가 비어 있음을 확인한다(BoundedByteSinkTest.java:34-53).

4.3 EncodedMessage: 양방향 방어 복사

public EncodedMessage {
  ...
  bytes = bytes.clone();          // 생성 시
}

@Override
public byte[] bytes() {
  return bytes.clone();           // 접근 시
}

javadoc이 이유를 적는다 — "These bytes travel through retry, DLQ, and redrive paths where a shared mutable array would let one stage corrupt another's copy of the same logical message."

equals/hashCodeArrays.equals/Arrays.hashCode로 재정의된다(record 기본은 배열 참조 비교라 항상 불일치). toString은 바이트를 찍지 않고 크기만 찍는다 — payload가 로그에 새지 않는다.

size()가 복사 없이 길이를 반환하는 별도 메서드로 있는 것도 의도적이다. bytes().length는 전체 복사를 유발한다.

4.4 SchemaCompatibility: 7개 모드와 transitive의 의미

// SchemaCompatibility.java:6-8
 * <p>Transitive modes check every historical version, not just the immediate predecessor. That
 * matters for integration events, where a consumer may be several releases behind and a chain of
 * individually-compatible changes can still be collectively breaking.

NONE_EXPERIMENTAL은 "M2 raw bytes에만 허용"이라고 enum 상수 javadoc이 적는다.

4.5 SchemaRegistry: 포트이고, 순서가 계약이다

// SchemaRegistry.java:16-17
 * <p>{@link #history} returns oldest first. Transitive compatibility checks read the whole list, so
 * an ordering mistake here silently converts a transitive check into a pairwise one.

이것은 문서화된 함정이다. history가 newest-first로 구현되면 versionsToCheckreversed()한 뒤 history.get(0)을 취하므로 가장 오래된 버전 하나만 비교하게 된다 — transitive가 pairwise로 조용히 축소되는 것이 아니라 아예 엉뚱한 버전을 비교한다.

latest(subject)가 default 메서드로 versions.get(versions.size() - 1)인 것도 같은 순서 계약에 의존한다. 테스트가 이 성질을 직접 단언한다(SchemaCompatibilityValidatorTest.theLatestVersionIsTheNewestNotTheFirstListed).

port로 둔 이유도 적혀 있다 — "A hosted registry, a classpath directory of schema files, and a static in-process map are all legitimate sources … Binding to a vendor client here would make the rules untestable without that vendor running."

4.6 SchemaCompatibilityValidator: 포맷 독립 규칙

두 가지를 한다.

(a) 비교할 버전 목록

public List<SchemaVersion> versionsToCheck(String subject) {
  SchemaCompatibility mode = registry.compatibilityOf(subject);
  if (mode == SchemaCompatibility.NONE_EXPERIMENTAL) return List.of();
  List<SchemaVersion> history = registry.history(subject).reversed();
  if (history.isEmpty()) return List.of();
  return isTransitive(mode) ? history : List.of(history.get(0));
}

(b) production 목적지 게이트

public void requireProductionMode(String subject, String destination) {
  if (registry.compatibilityOf(subject) == SchemaCompatibility.NONE_EXPERIMENTAL) {
    throw new MessageSchemaIncompatibleException(
        "UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION", ...);
  }
}

javadoc이 이유를 적는다 — "A mode that checks nothing is useful while a message type is being designed and actively dangerous once a retained log exists, because the log outlives every consumer that could still read it."

그리고 분리 자체의 이유를 명시한다:

// SchemaCompatibilityValidator.java:12-14
 * <p>Split from the per-format gates on purpose. Whether v3 must be checked against v1 as well as
 * v2 is a property of the compatibility mode, not of Avro or Protobuf, and duplicating that
 * reasoning in each codec is how the two formats drift apart.

§12.1과 §12.3이 이 문장을 다시 다룬다.

4.7 RawBytesMessageCodec: 부재를 구현한다

// RawBytesMessageCodec.java:12-16
 * <p>It still enforces the byte limit, and it is deliberately excluded from default codec
 * selection: schema-free publishing has to be an explicit, auditable choice per destination, never
 * something a destination falls back to because its codec was misconfigured.

encodebyte[]가 아닌 payload를 MessageSerializationException("RAW_BYTES_PAYLOAD_REQUIRED")로 거절하고, decodebyte[].class가 아닌 대상을 RAW_BYTES_TARGET_REQUIRED로 거절한다. decodeencoded.clone()을 반환한다 — 호출자가 원본을 건드릴 수 없다.

DEFAULT_MAX_BYTES = 1_048_576(1 MiB)은 세 Stable codec이 공유하는 값이다.

주의: 이 codec은 BoundedByteSink를 쓰지 않는다. 이미 byte[]를 받으므로 스트리밍 경계가 의미 없고, bytes.length > maxBytes 비교로 충분하다. 다른 codec에서는 그 비교가 §4.2가 지적하는 "보고 임계값"이지만 여기서는 할당이 이미 끝난 입력이라 성격이 다르다.


5. 주요 실행 경로

세 개다.

  1. 경계 있는 인코딩 — codec이 BoundedByteSink.of(maxBytes, code)를 만들고 → 포맷 라이브러리가 sink에 쓰고 → 한도를 넘는 write에서 MessageTooLargeException → 아니면 sink.toByteArray()EncodedMessage 조립
  2. 계약 조회new MessageContractKey(type, version) → registry lookup → 미스면 "타입 미등록" vs "버전 미등록" 구분
  3. 진화 검사registry.compatibilityOf(subject)versionsToCheck → (포맷별 게이트가 실제 비교)

3번은 이 저장소에서 실행되지 않는다(§12.1).


6. 실패 경로와 복구/번역

이 leaf가 던지는 예외는 셋이고 전부 messaging-core-api 소유다.

예외 코드 조건
MessageTooLargeException codec별(PAYLOAD_TOO_LARGE, AVRO_PAYLOAD_TOO_LARGE, …) sink 한도 초과
MessageTooLargeException RAW_BYTES_TOO_LARGE raw codec 한도 초과
MessageSerializationException RAW_BYTES_PAYLOAD_REQUIRED / RAW_BYTES_TARGET_REQUIRED 타입 불일치
MessageSchemaIncompatibleException UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION NONE_EXPERIMENTAL이 production 목적지에

IllegalArgumentException도 던진다 — BoundedByteSink 생성자의 maxBytes < 1, requireFits의 음수, SchemaReference의 빈 subject. 이들은 호출자의 프로그래밍 오류이고 메시지 실패가 아니므로 MessagingException 계층 밖인 것이 일관적이다.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

동시성: BoundedByteSink의도적으로 thread-safe가 아니다. javadoc이 명시한다 — "Not thread-safe, and not meant to be: an instance belongs to a single encode call." 실제로 codec들이 매 encode 호출마다 새로 만든다.

EncodedMessage, MessageContractKey, SchemaReference는 불변이다. SchemaCompatibilityValidator는 registry 참조만 갖고 상태가 없다.

MessageCodecRegistry/SchemaRegistry 구현의 스레드 안전성은 이 leaf가 규정하지 않는다 — port javadoc에 그에 대한 요구가 없다. 이것은 §17의 P3 항목이다.


8. 설정·기능 플래그·환경 차이

설정 없음. 상수 하나:

상수 위치
RawBytesMessageCodec.DEFAULT_MAX_BYTES 1,048,576 RawBytesMessageCodec.java:21

BoundedByteSink의 초기 버퍼 상한 8,192는 private다.


9. 퍼시스턴스/외부 시스템 세부

없다. SchemaRegistry가 외부 registry를 가리킬 수 있는 port지만, 이 leaf에는 구현이 없다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-schema-api:test. BUILD SUCCESSFUL, 19 tests, 0 skipped, 0 failures (--rerun-tasks, revision 21234e38).

클래스 실제로 증명하는 것 증명하지 않는 것
BoundedByteSinkTest 4 한도 포함/초과 경계, 10 MiB 스트림이 한도에서 멈춤, pre-flight가 예산을 안 먹음, codec 에러 코드 전달 실제 codec들이 이 sink를 쓰는지(각 codec leaf가 소유)
RawBytesMessageCodecTest 6 round trip, content type, 비-byte[] 거절 양방향, 한도, EncodedMessage 방어 복사
SchemaCompatibilityValidatorTest 9 pairwise vs transitive 목록, NONE_EXPERIMENTAL 빈 목록, 빈 history, production 게이트 양방향, checksBackward/checksForward 조합, latest가 newest production 코드가 이 validator를 호출하는지

마지막 칸이 핵심이다. SchemaCompatibilityValidatorTest는 9개 단언으로 규칙을 정확히 고정하지만, §12.1이 보이듯 그 규칙을 실행 경로에서 부르는 코드가 없다. 테스트는 규칙이 옳다를 증명하고 규칙이 적용된다를 증명하지 않는다.

테스트가 쓰는 FixedRegistrySchemaRegistry의 유일한 구현이다(production 구현 0개, §12.1).


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
registry fail-closed 등록됨
verifyCleanArchitectureDependencies allowed_dependencies: ["messaging-core-api"]와 실제 project edge 대조
verifyRuntimeModuleMembership ["app-bootstrap"]
src/messaging/CLAUDE.md의 vendor api 규칙 이 leaf는 vendor 의존성이 없으므로 대상 없음
ArchUnit 이 leaf 전용 규칙 없음

src/messaging/CLAUDE.md:40-43이 기술하는 게이트 — "source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 build.gradleapi로 선언했는지 대조" — 는 이 leaf에서 확인할 것이 없다. 형제 leaf(schema-avro, schema-protobuf, cloudevents)는 이 규칙 때문에 vendor를 api로 선언했고 build.gradle 주석이 그 이유를 적는다.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/272-schema-family-reachability.txt.

12.1 Public surface reachability

leaf 밖 참조를 파일 수로 세면:

타입 leaf 밖 파일 수 판정
EncodedMessage 50 널리 쓰임 — 사실상 이 leaf의 주력 수출품
SchemaCompatibility 15 세 codec leaf + policy가 씀
MessageContractKey 7 세 codec leaf가 씀
MessageCodec 7 세 codec + runtime-core
MessageCodecRegistry 5 runtime-core가 구현
SchemaReference 4 codec들이 만듦
BoundedByteSink 3 JSON·Avro·Protobuf codec
RawBytesMessageCodec 0 자기 테스트만
SchemaCompatibilityValidator 0 자기 테스트만
SchemaRegistry 0 아래 참조

SchemaRegistry의 "0"은 확인이 필요했다. 단순 이름 검색은 2개 파일을 맞췄지만 둘 다 다른 타입이다:

src/adapter/outbound/messaging/.../LocalJsonSchemaRegistry.java:6:  import com.networknt.schema.SchemaRegistry;
src/adapter/outbound/notification/.../JsonSchemaVariableValidator.java:5: import com.networknt.schema.SchemaRegistry;

import dev.caskeleton.messaging.schema.SchemaRegistry 검색은 exit 1이다. 즉 이 플랫폼의 SchemaRegistry port를 import하는 파일이 저장소에 하나도 없다. 이름 충돌이 우연히 검색을 오염시킨 사례이고, -w 단어 매칭만으로 reachability를 판정하면 안 되는 이유이기도 하다.

SchemaCompatibilityValidator의 "0"이 이 leaf에서 가장 무거운 사실이다. 검색 결과 전체가 자기 선언과 자기 테스트다. 다시 말해:

  • 어떤 버전들을 비교해야 하는가 → 아무도 묻지 않는다
  • NONE_EXPERIMENTAL이 production 목적지를 뒷받침할 수 있는가 → 아무도 묻지 않는다

requireProductionMode는 "retained log outlives every consumer"라는 이유로 만들어졌고, 그 게이트가 호출되는 지점이 없다.

RawBytesMessageCodec의 "0"은 성격이 다르다. 이 클래스가 없어도 그 규칙은 살아 있다 — §12.2 참조.

12.2 Conditional sibling comparison

Spring 주석 0개이므로 bean 활성화 비대칭은 없다.

대신 이 leaf에는 다른 형태의 sibling 비대칭이 있고 결과가 좋다. RawBytesMessageCodec의 javadoc이 "deliberately excluded from default codec selection"이라고 선언하는 규칙을, 실제로 강제하는 코드는 다른 leaf에 있다:

// messaging-runtime-core/RegisteredMessageCodecs.java:52-56
if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) {
  throw new IllegalArgumentException(
      "the raw bytes codec must not be the default: every destination that has not declared an "
          + "encoding would silently skip schema validation");
}

클래스가 아니라 content type으로 판정한다. 그래서 RawBytesMessageCodec을 아무도 쓰지 않아도, 그리고 누가 ContentType.OCTET_STREAM을 내놓는 다른 codec을 새로 만들어도 규칙이 유지된다. 선언된 규칙과 강제하는 코드가 다른 leaf에 있으면서 강제 쪽이 더 넓은 드문 경우다. 결함이 아니라 확인된 설계로 기록한다.

12.3 Duplicate mechanism sweep

SchemaCompatibilityValidator가 막으려던 중복이 실제로 존재한다.

AvroCompatibilityGate(다른 leaf)가 같은 판단을 private static으로 다시 구현했다.

판단 schema-api (SchemaCompatibilityValidator) schema-avro (AvroCompatibilityGate)
transitive인가 mode == BACKWARD_TRANSITIVE || FORWARD_TRANSITIVE || FULL_TRANSITIVE (:107-112) 같은 식을 그대로 (:49-53)
후방 검사하나 mode == BACKWARD || BACKWARD_TRANSITIVE || FULL || FULL_TRANSITIVE허용목록 (:79-85) mode != FORWARD && mode != FORWARD_TRANSITIVE거부목록 (:55-57)
전방 검사하나 mode == FORWARD || FORWARD_TRANSITIVE || FULL || FULL_TRANSITIVE허용목록 (:93-99) mode != BACKWARD && mode != BACKWARD_TRANSITIVE거부목록 (:59-61)

isTransitive는 글자까지 동일한 복사본이다. 방향 판정 둘은 형태가 반대다.

현재 enum 7개 값에 대해 두 구현의 결과를 대조하면 일치한다. NONE_EXPERIMENTAL만 다른데(validator는 둘 다 false, gate는 둘 다 true) AvroCompatibilityGate.check:34가 그 모드에서 먼저 return하므로 가려진다.

문제는 오늘의 불일치가 아니라 형태다. 허용목록은 새 모드가 추가되면 "검사 안 함"으로 기본값이 잡히고, 거부목록은 "양방향 검사"로 잡힌다. SchemaCompatibility에 값이 하나 추가되는 순간 두 구현은 반대 방향으로 갈라진다. javadoc이 예고한 "how the two formats drift apart"가 바로 이 형태이고, 그것을 막으려고 만든 클래스는 §12.1에서 보듯 호출되지 않는다.

isTransitiveSchemaCompatibilityValidator에서 public static이다. Avro 게이트가 그것을 부를 수 있었고 부르지 않았다.

12.4 Documentation / measured-count drift

이 leaf를 직접 이름으로 언급하는 문서 주장을 재측정했다.

문서 주장 재측정 결과
계획 문서: codec은 닫힌 registry에 대해 동작 MessageCodec javadoc + 세 구현의 requireRegistered/schemaFor 일치
RawBytesMessageCodec javadoc: 기본 codec 선택에서 제외됨 RegisteredMessageCodecs.of 생성자 검사 일치(더 넓게 강제)
SchemaRegistry javadoc: history는 oldest-first 유일한 구현이 테스트 fixture이고 그 계약을 지킴 일치하나 production 구현 없음

§12.4의 family 전체 drift(support-matrix.md:23의 runtime membership 주장)는 analysis/messaging/messaging-core-api.md §12.4가 소유한다. 이 leaf도 그 18개 wired 목록에 포함된다.


13. Git/설계 문서에서 확인한 변화와 실패 기록

코드 주석이 보존한 이전 결함:

위치 이전 상태 그것이 만든 실패
BoundedByteSink javadoc 각 codec이 무제한 버퍼에 직렬화 후 길이 비교 한도가 보고 임계값일 뿐 할당 경계가 아님 → 팽창하는 payload 하나가 consumer 프로세스를 죽임
MessageContractKey javadoc 타입만으로 registry 키 v999가 v1 클래스로 디코딩되고 v999 라벨을 유지 → 하위 게이트·감사 기록이 등록된 적 없는 버전을 서술

두 사례 다 형태가 같다 — 검사가 없었던 게 아니라 검사의 위치/키가 틀렸다. messaging-core-api §13의 "문자 vs 바이트, 정확일치 vs 세그먼트" 목록과 같은 계열이다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-272 command evidence/raw/272-schema-family-reachability.txt SchemaCompatibilityValidator 호출자 전무, allowlist/denylist 두 형태 나란히, SchemaRegistry port import 0(exit=1)과 이름 충돌, codec별 소비자 정적 git grep
EVD-274 command ./gradlew :messaging:messaging-schema-api:test --rerun-tasks BUILD SUCCESSFUL, 19 / 0 / 0 순수 단위 레인

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 버전을 registry 키에 넣는 이유 — MessageContractKey javadoc
  • 할당 경계 vs 보고 임계값 — BoundedByteSink javadoc
  • codec 에러 코드를 sink에 넘기는 이유 — BoundedByteSink javadoc + 테스트 as(...)
  • 포맷 독립 규칙을 분리한 이유 — SchemaCompatibilityValidator javadoc
  • NONE_EXPERIMENTAL을 production에서 막는 이유 — 같은 javadoc
  • SchemaRegistry를 port로 둔 이유, history 순서가 계약인 이유 — SchemaRegistry javadoc
  • raw codec을 기본에서 제외하는 이유 — RawBytesMessageCodec javadoc + RegisteredMessageCodecs javadoc
  • EncodedMessage 양방향 복사 이유 — EncodedMessage javadoc

추론

  • SchemaCompatibilityValidator가 미호출인 것은 이 저장소에 schema registry를 실제로 운영하는 배포가 없기 때문이다 → 추론. SchemaRegistry production 구현이 0인 것은 관측이고, 인과는 추론이다.
  • Avro 게이트가 자기 복사본을 쓴 이유 → 미상. 커밋 메시지에 근거가 없다.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 10개 타입 전부의 계약과 불변식
  • 19개 테스트가 통과하고 무엇을 단언하는지
  • SchemaCompatibilityValidator·RawBytesMessageCodec·SchemaRegistry의 leaf 밖 참조 0 (SchemaRegistry는 이름 충돌을 배제한 뒤)
  • Avro 게이트의 중복 구현과 두 형태의 차이
  • raw-bytes 기본 금지 규칙이 content type 기준으로 더 넓게 강제된다는 것

확인하지 못한 것

  • SchemaCompatibility enum이 실제로 확장될 계획이 있는지. §12.3의 위험은 그때 실현된다.
  • port 구현의 스레드 안전성 요구. javadoc에 없고 이 저장소에 production 구현이 없어 관측할 대상이 없다.
  • BoundedByteSink의 경계가 실제 Jackson/Avro/Protobuf 인코더에서 기대대로 동작하는지 — 각 codec leaf의 테스트가 소유하고 이 문서 범위 밖이다.

17. 손볼 것

P2 — 포맷 독립 진화 규칙이 호출되지 않고, 그것이 막으려던 중복이 실제로 생겼다

  • 사실. SchemaCompatibilityValidator의 저장소 전체 참조가 자기 선언과 자기 테스트뿐이다. 동시에 AvroCompatibilityGateisTransitive를 글자 그대로 복사했고 방향 판정 둘은 허용목록/거부목록으로 형태가 반대다.
  • 근거. evidence/raw/272 §A, §B.
  • 왜 문제인가. 오늘은 7개 모드 전부에서 두 구현의 결과가 같다(NONE_EXPERIMENTAL은 gate의 early return이 가린다). 그러나 enum에 값이 하나 추가되면 허용목록은 "검사 안 함", 거부목록은 "양방향 검사"로 반대 방향 기본값을 갖는다. 그리고 requireProductionMode — 검사 없는 스키마가 보존 로그를 뒷받침하는 것을 막는 게이트 — 는 호출되는 곳이 없다.
  • 확인 방법. git grep -n -E 'requireProductionMode|versionsToCheck|SchemaCompatibilityValidator' -- 'src/**/*.java'
  • 후보. (a) Avro 게이트가 SchemaCompatibilityValidator의 public static을 부르게 한다. (b) validator를 CI 게이트에 배선한다. (c) 둘 다 쓰지 않을 거라면 validator를 제거하고 규칙 소유권을 게이트로 옮긴다.
  • 다음 단계. CASE 후보 + REFERENCE 후보. "중복을 막으려고 만든 추상이 호출되지 않으면 중복은 그대로 생긴다"는 형태가 재사용 가능하다. 그리고 "허용목록과 거부목록은 enum이 자라는 순간 반대로 갈라진다"도 별도 기준이다.

P3 — port 구현의 스레드 안전성 요구가 문서화되어 있지 않다

  • 사실. SchemaRegistryMessageCodecRegistry javadoc에 동시성 요구가 없다. BoundedByteSink만 "not thread-safe"를 명시한다.
  • 근거. 세 타입의 javadoc 전문.
  • 왜 문제인가. MessageCodecRegistry의 유일한 구현 RegisteredMessageCodecsMap.copyOf로 불변이라 안전하지만, 그것은 구현의 성질이지 계약이 아니다. 외부 registry를 감싸는 SchemaRegistry 구현은 브로커 소비자 스레드들에서 동시에 호출된다.
  • 확인 방법. 세 인터페이스의 javadoc 확인.
  • 후보. port javadoc에 "구현은 스레드 안전해야 한다"를 명시.
  • 다음 단계. REFERENCE 후보(port 계약은 동시성 요구를 적는다).

P3 — SchemaRegistry라는 이름이 저장소에서 두 가지를 가리킨다

  • 사실. dev.caskeleton.messaging.schema.SchemaRegistry(이 leaf의 port)와 com.networknt.schema.SchemaRegistry(JSON Schema 라이브러리)가 공존하고, 후자만 실제로 import된다.
  • 근거. evidence/raw/272 §C.
  • 왜 문제인가. 지금 깨지는 것은 없다. 다만 reachability 판정에서 실제로 오탐을 만들었다 — 단어 검색이 2건을 맞췄고 둘 다 다른 타입이었다. 사람이 같은 실수를 한다.
  • 확인 방법. git grep -n 'import .*\.SchemaRegistry;' -- src
  • 후보. 이름 변경 없이 두는 것이 합리적일 수 있다. 기록만 남긴다.
  • 다음 단계. REFERENCE 후보(도달성 판정은 단어가 아니라 import로 확인한다).

확인된 설계(문제 아님)

  • BoundedByteSink가 codec의 에러 코드를 전달하고, pre-flight가 예산을 소비하지 않는 것 — 테스트가 양쪽을 고정
  • EncodedMessage의 양방향 방어 복사와 payload를 찍지 않는 toString
  • 버전을 registry 키에 포함하고 "타입 미등록"과 "버전 미등록"을 다른 코드로 구분하는 것
  • raw-bytes 기본 금지가 클래스가 아니라 content type으로 강제되는 것

Source anchors

id kind path revision what it proves limitations
MSA-001 registry src/config/architecture/modules.json 21234e38 deps ["messaging-core-api"], memberships ["app-bootstrap"] 선언
MSA-002 build messaging-schema-api/build.gradle same vendor 의존성 0
MSA-003 code .../schema/MessageContractKey.java same 버전 키 결정과 그 이유
MSA-004 code .../schema/BoundedByteSink.java same 할당 경계, 에러 코드 전달, pre-flight 실제 인코더 동작은 각 codec leaf
MSA-005 code .../schema/EncodedMessage.java same 양방향 복사, 배열 equals, 안전한 toString
MSA-006 code .../schema/SchemaCompatibilityValidator.java same 포맷 독립 규칙과 분리 이유 호출자 없음(§12.1)
MSA-007 code .../schema/SchemaRegistry.java same port 계약, history oldest-first production 구현 없음
MSA-008 code .../schema/RawBytesMessageCodec.java same escape hatch 계약 외부 사용 0
MSA-009 code .../schema/{MessageCodec,MessageCodecRegistry,SchemaReference,SchemaCompatibility}.java same codec/식별/모드 계약
MSA-010 test src/test/java/** (3 클래스 / 19 테스트) same §10 표 순수 단위
MSA-011 cross-leaf code messaging-runtime-core/.../RegisteredMessageCodecs.java:29-77 same raw-bytes 기본 금지의 실제 강제 지점, 중복 content type 거절 해당 leaf SSOT가 소유
MSA-012 cross-leaf code messaging-schema-avro/.../AvroCompatibilityGate.java:34-61 same 중복 구현과 두 형태의 차이 해당 leaf SSOT가 소유
EVD-272 command evidence/raw/272-schema-family-reachability.txt same §12.1·§12.3 전부 정적 검색
EVD-274 command ./gradlew :messaging:messaging-schema-api:test --rerun-tasks same 19 / 0 skipped / 0 failures 순수 단위