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>
32 KiB
messaging-schema-api 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-schema-apiSSOT owner:messaging-schema-apiintegration/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.gradle는 api 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-bootstrap이 messaging-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.
세 가지 설계 결정이 붙어 있다.
- 버퍼를 한도로 미리 잡지 않는다.
new ByteArrayOutputStream(Math.min(maxBytes, 8_192))— 주석: "a 1 GiB bound must not pre-allocate 1 GiB." - 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")). requireFits(size)는 예산을 소비하지 않는다. Protobuf는 직렬화 크기를 미리 알므로 첫 바이트 전에 거절할 수 있다. 그리고 그 뒤의 쓰기도 여전히 경계 안이다 — 주석: "this is a cheaper refusal, not a replacement for the bound."
refuseIfBeyondLimit가 size > 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/hashCode는 Arrays.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로 구현되면 versionsToCheck가 reversed()한 뒤 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.
encode는 byte[]가 아닌 payload를 MessageSerializationException("RAW_BYTES_PAYLOAD_REQUIRED")로 거절하고, decode는 byte[].class가 아닌 대상을 RAW_BYTES_TARGET_REQUIRED로 거절한다. decode는 encoded.clone()을 반환한다 — 호출자가 원본을 건드릴 수 없다.
DEFAULT_MAX_BYTES = 1_048_576(1 MiB)은 세 Stable codec이 공유하는 값이다.
주의: 이 codec은 BoundedByteSink를 쓰지 않는다. 이미 byte[]를 받으므로 스트리밍 경계가 의미 없고, bytes.length > maxBytes 비교로 충분하다. 다른 codec에서는 그 비교가 §4.2가 지적하는 "보고 임계값"이지만 여기서는 할당이 이미 끝난 입력이라 성격이 다르다.
5. 주요 실행 경로
세 개다.
- 경계 있는 인코딩 — codec이
BoundedByteSink.of(maxBytes, code)를 만들고 → 포맷 라이브러리가 sink에 쓰고 → 한도를 넘는 write에서MessageTooLargeException→ 아니면sink.toByteArray()로EncodedMessage조립 - 계약 조회 —
new MessageContractKey(type, version)→ registry lookup → 미스면 "타입 미등록" vs "버전 미등록" 구분 - 진화 검사 —
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이 보이듯 그 규칙을 실행 경로에서 부르는 코드가 없다. 테스트는 규칙이 옳다를 증명하고 규칙이 적용된다를 증명하지 않는다.
테스트가 쓰는 FixedRegistry는 SchemaRegistry의 유일한 구현이다(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.gradle이 api로 선언했는지 대조" — 는 이 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에서 보듯 호출되지 않는다.
isTransitive는 SchemaCompatibilityValidator에서 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 키에 넣는 이유 —
MessageContractKeyjavadoc - 할당 경계 vs 보고 임계값 —
BoundedByteSinkjavadoc - codec 에러 코드를 sink에 넘기는 이유 —
BoundedByteSinkjavadoc + 테스트as(...) - 포맷 독립 규칙을 분리한 이유 —
SchemaCompatibilityValidatorjavadoc NONE_EXPERIMENTAL을 production에서 막는 이유 — 같은 javadocSchemaRegistry를 port로 둔 이유, history 순서가 계약인 이유 —SchemaRegistryjavadoc- raw codec을 기본에서 제외하는 이유 —
RawBytesMessageCodecjavadoc +RegisteredMessageCodecsjavadoc EncodedMessage양방향 복사 이유 —EncodedMessagejavadoc
추론
SchemaCompatibilityValidator가 미호출인 것은 이 저장소에 schema registry를 실제로 운영하는 배포가 없기 때문이다 → 추론.SchemaRegistryproduction 구현이 0인 것은 관측이고, 인과는 추론이다.- Avro 게이트가 자기 복사본을 쓴 이유 → 미상. 커밋 메시지에 근거가 없다.
16. 확인한 것 / 확인하지 못한 것
확인한 것
- 10개 타입 전부의 계약과 불변식
- 19개 테스트가 통과하고 무엇을 단언하는지
SchemaCompatibilityValidator·RawBytesMessageCodec·SchemaRegistry의 leaf 밖 참조 0 (SchemaRegistry는 이름 충돌을 배제한 뒤)- Avro 게이트의 중복 구현과 두 형태의 차이
- raw-bytes 기본 금지 규칙이 content type 기준으로 더 넓게 강제된다는 것
확인하지 못한 것
SchemaCompatibilityenum이 실제로 확장될 계획이 있는지. §12.3의 위험은 그때 실현된다.- port 구현의 스레드 안전성 요구. javadoc에 없고 이 저장소에 production 구현이 없어 관측할 대상이 없다.
BoundedByteSink의 경계가 실제 Jackson/Avro/Protobuf 인코더에서 기대대로 동작하는지 — 각 codec leaf의 테스트가 소유하고 이 문서 범위 밖이다.
17. 손볼 것
P2 — 포맷 독립 진화 규칙이 호출되지 않고, 그것이 막으려던 중복이 실제로 생겼다
- 사실.
SchemaCompatibilityValidator의 저장소 전체 참조가 자기 선언과 자기 테스트뿐이다. 동시에AvroCompatibilityGate가isTransitive를 글자 그대로 복사했고 방향 판정 둘은 허용목록/거부목록으로 형태가 반대다. - 근거.
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 구현의 스레드 안전성 요구가 문서화되어 있지 않다
- 사실.
SchemaRegistry와MessageCodecRegistryjavadoc에 동시성 요구가 없다.BoundedByteSink만 "not thread-safe"를 명시한다. - 근거. 세 타입의 javadoc 전문.
- 왜 문제인가.
MessageCodecRegistry의 유일한 구현RegisteredMessageCodecs는Map.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 | 순수 단위 |