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>
36 KiB
messaging-schema-avro 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-schema-avroSSOT owner:messaging-schema-avrointegration/family document:analysis/19-messaging-platform.md(secondary, INTEGRATION_ONLY)
0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id:
messaging-schema-avro - canonical state
analysisFile:analysis/messaging/messaging-schema-avro.md - source path:
src/messaging/messaging-schema-avro - registry
allowed_dependencies:["messaging-core-api", "messaging-schema-api"] - registry
runtime_memberships:[]— build-only / incubating
숫자
| 항목 | 수 |
|---|---|
| production Java 파일 | 2 |
| production LOC | 345 |
| 패키지 | 1 (dev.caskeleton.messaging.schema.avro) |
| test 파일 | 3 |
| test 메서드(실행 확인) | 16 |
| test resource | /schemas/order.created/v1.avsc |
| 외부 의존성 | 1 (org.apache.avro:avro:1.12.0, api) |
두 클래스: AvroMessageCodec(런타임 인코딩/디코딩), AvroCompatibilityGate(CI용 진화 검사).
Coverage ledger
| scope/file group | count | disposition | reason |
|---|---|---|---|
.../avro/AvroMessageCodec.java |
1 | FULL_READ |
272줄 전문 |
.../avro/AvroCompatibilityGate.java |
1 | FULL_READ |
73줄 전문 |
src/test/java/** |
3 | FULL_READ |
전문 |
src/test/resources/schemas/order.created/v1.avsc |
1 | STRUCTURAL_ONLY |
fixture 스키마; 필드 구성만 확인 |
build.gradle |
1 | FULL_READ |
주석 포함 11줄 |
gradle.lockfile |
1 | STRUCTURAL_ONLY |
잠금 파일 |
build/** |
— | EXCLUDED |
빌드 산출물 |
UNCLASSIFIED 0.
1. 모듈의 정체와 경계
선택적(optional) Avro codec. Stable이 아니고 registry membership이 비어 있다 — build-only / incubating이며, docs/messaging/support-matrix.md의 등급과는 다른 축이다.
Avro를 api로 선언한 이유가 build.gradle 주석에 있다.
// api: AvroMessageCodec's constructors take a registry of org.apache.avro.Schema and
// AvroCompatibilityGate.check takes and compares them. A consumer cannot build that
// registry without naming the type, so hiding the dependency only stops them compiling.
api 'org.apache.avro:avro:1.12.0'
src/messaging/CLAUDE.md:40-43이 기술하는 게이트 — public/protected 시그니처에 나오는 vendor 라이브러리가 api로 선언됐는지 대조 — 를 이 leaf가 통과한다. 형제 messaging-schema-json은 Jackson 타입이 시그니처에 없으므로 implementation이고, 그 판정 차이가 규칙이 실제로 작동한다는 증거다.
클래스 둘의 실행 시점이 다르다.
| 클래스 | 언제 도는가 | 근거 |
|---|---|---|
AvroMessageCodec |
런타임(메시지마다) | MessageCodec 구현 |
AvroCompatibilityGate |
CI | 클래스 javadoc: "Run in CI rather than at runtime" |
게이트의 javadoc이 그 이유를 적는다 — "By the time a producer has published one incompatible record, the damage is durable: the record sits in a retained log that every current and future consumer must be able to read."
2. 의존성과 런타임 배선
들어오는 것: messaging-core-api(api), messaging-schema-api(api), avro:1.12.0(api).
나가는 것: 없다. 어떤 leaf의 allowed_dependencies에도 messaging-schema-avro가 없다. messaging-spring-boot-starter의 17개 의존 목록에도 없다.
런타임 배선: 없음. runtime_memberships: []이므로 배포 아티팩트에 실리지 않는다. bean도 없다(Spring 주석 0개).
소비자 없음과 membership 없음이 일치한다. 이것이 정합적인 incubating 상태다 — messaging-cloudevents와 대비된다(그쪽은 membership이 있고 소비자가 없다).
3. 패키지/컴포넌트 지도
AvroMessageCodec (MessageCodec 구현)
├── encode(type, version, GenericRecord) → EncodedMessage
├── decode(type, version, byte[], Class) → GenericRecord (writer == reader)
├── decodeEvolved(type, writerV, readerV, byte[]) → GenericRecord (writer != reader)
├── schemaFor(type, version) → 등록 조회, 2단 에러
├── boundedReader(writer, reader) → newArray 오버라이드
└── flatten(nested registry) → (type, version) 평탄화 + 깊은 복사
AvroCompatibilityGate (CI)
└── check(candidate, history, mode)
├── isTransitive / readsBackward / readsForward (private, 자체 구현)
└── requireCompatible → org.apache.avro.SchemaCompatibility
4. 계약·불변식·상태 모델
4.1 Avro 바이너리에는 스키마가 없다 — 그래서 registry가 계약이다
// AvroMessageCodec.java:33-37
* <p>Decoding uses an explicit writer schema and reader schema pair. Avro binary carries no schema
* of its own, so decoding with the wrong schema does not fail — it produces plausible garbage. The
* registry is what makes the writer schema knowable, and passing both schemas to the reader is what
* makes evolution work: Avro resolves added, removed, and defaulted fields only when it can see
* both sides.
"does not fail — it produces plausible garbage"가 이 leaf의 모든 방어의 전제다. JSON이나 Protobuf와 달리 Avro는 잘못된 스키마로 디코딩해도 예외를 던지지 않는 경우가 있다.
single-object encoding에 헤더를 붙이지 않는 것도 명시적 결정이다 — "The framing that would carry a schema fingerprint belongs to the transport headers, where the platform already carries schema identity for every format, rather than being duplicated inside the Avro payload for this one format."
4.2 flatten: 얕은 복사가 만든 구멍
생성자가 받는 것은 중첩 맵 Map<MessageType, Map<SchemaVersion, Schema>>이고, Map.copyOf는 바깥 레벨만 복사한다.
// AvroMessageCodec.java:78-82
* <p>{@code Map.copyOf} on the outer map is a shallow copy: every inner {@code Map<SchemaVersion,
* Schema>} stayed the caller's own object, so a caller that kept a reference could add, replace,
* or remove a schema version after construction and the codec would silently start encoding
* against it. Flattening to {@code (type, version)} keys copies both levels and makes the version
* part of the identity the lookup uses rather than a second hop.
이 결함이 위험한 이유는 §4.1과 곱해진다 — 스키마가 바뀌어도 디코딩이 실패하지 않고 그럴듯한 쓰레기를 낸다.
AvroRegistryBoundsTest.mutatingTheCallersMapAfterConstructionChangesNothing이 세 가지를 한 번에 확인한다: 생성 후 추가한 버전은 미등록, 생성 후 추가한 타입도 미등록, 원래 등록한 스키마는 그대로.
평탄화가 MessageContractKey(schema-api)를 키로 쓰므로 §4.5의 2단 에러 구분도 자연히 따라온다.
4.3 인코딩: direct encoder를 쓰는 이유
// AvroMessageCodec.java:122-124
// A direct encoder, not the buffering one: the buffering encoder holds bytes back until flush,
// which would let a large record allocate freely before the sink ever sees a write. Direct
// encoding makes the bound apply to the record as it is written.
BinaryEncoder encoder = EncoderFactory.get().directBinaryEncoder(sink, null);
BoundedByteSink(schema-api)의 경계가 실제로 작동하려면 인코더가 증분적으로 써야 한다. EncoderFactory.get().binaryEncoder(...)는 버퍼링하므로 sink가 첫 write를 보기 전에 큰 레코드가 이미 할당된다. 즉 schema-api의 방어가 이 한 줄에 의존한다.
인코딩 전 검사 둘:
- payload가
GenericRecord인가 →AVRO_PAYLOAD_NOT_A_RECORD schema.equals(record.getSchema())인가 →AVRO_SCHEMA_MISMATCH
두 번째는 테스트가 이유를 적는다 — as("encoding v2 data under the v1 version would produce bytes nothing can decode").
4.4 boundedReader: 다섯 바이트 공격
이 leaf에서 가장 깊은 방어다.
// AvroMessageCodec.java:222-235
* <p>Avro writes an array as a declared element count followed by the elements. The count is a
* variable-length integer, so five bytes can claim four hundred million elements, and the generic
* reader allocates the backing array from that claim before reading a single element. Bounding
* the input length does not help: the whole hostile payload is five bytes, well under any limit,
* and the failure is an {@code OutOfMemoryError} rather than an exception the codec could report
* — on a consumer thread that is the process, not the message.
*
* <p>The ceiling is the byte limit itself. Every element costs at least one byte on the wire even
* when it is empty, so a payload of at most {@code maxBytes} bytes cannot honestly contain more
* than {@code maxBytes} elements, and any larger claim is a lie the reader should refuse rather
* than reserve memory for.
구현은 익명 서브클래스의 newArray 오버라이드다.
return new GenericDatumReader<>(writerSchema, readerSchema) {
@Override
protected Object newArray(Object old, int size, Schema schema) {
if (size > maxElements) {
throw new MessageTooLargeException("AVRO_COLLECTION_TOO_LARGE", ...);
}
return super.newArray(old, size, schema);
}
};
상한 선택의 논리가 정확하다. 원소 하나가 wire에서 최소 1바이트를 쓰므로, maxBytes 바이트짜리 payload가 정직하게 담을 수 있는 원소는 maxBytes개를 넘을 수 없다. 별도 튜닝 상수를 만들지 않고 이미 있는 경계에서 파생시켰다.
AvroHostileInputTest가 이 공격을 손으로 만든 zigzag varint로 재현한다.
// AvroHostileInputTest.java:118-123
* <p>Hand-written rather than taken from an encoder because the point is to write a count with no
* elements behind it, which no encoder will do.
그리고 공격의 크기를 직접 단언한다 — assertThat(hostile).as("the whole attack is five bytes, so no byte limit stands between it and the allocation").hasSizeLessThan(16).
테스트 클래스 javadoc이 왜 corpus가 좁은지까지 적는다.
// AvroHostileInputTest.java:30-33
* <p>Strings, byte arrays and maps were already safe: Avro validates those lengths against the
* bytes actually remaining. Arrays were the one shape that allocated on trust, which is why the
* corpus below is narrow rather than exhaustive — it pins the case that failed, and the two cases
* that must keep working around it.
이것은 "좁은 테스트"를 정당화한 드문 예다 — 다른 형태는 라이브러리가 이미 방어하므로 재확인이 아니라 잡음이 된다.
4.5 schemaFor: 2단 에러
AVRO_TYPE_NOT_REGISTERED(타입 미등록)와 AVRO_VERSION_NOT_REGISTERED(버전 미등록)를 구분한다. JSON codec의 UNKNOWN_MESSAGE_TYPE/SCHEMA_VERSION_NOT_REGISTERED와 같은 형태이지만 코드 문자열이 다르다. 두 codec이 같은 판단을 다른 어휘로 보고한다 — §12.3.
4.6 decodeEvolved: 나중에 붙은 경계
// AvroMessageCodec.java:199-201
// The same bound the ordinary decode applies. It was missing here, so the evolution path — the
// one a consumer takes for every message written by a newer producer — accepted input of any
// size.
requireWithinLimit(encoded.length);
테스트가 두 각도에서 붙든다 — AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound(as("decodeEvolved accepted input of any size"))와 AvroHostileInputTest.theEvolutionDecodeAppliesTheSameCollectionBound(as("a consumer reading a newer producer takes this path for every message")).
즉 decodeEvolved는 가장 흔한 경로인데 가장 늦게 보호됐다. 진화 경로는 producer가 앞서 나간 순간부터 모든 메시지가 지나는 길이다.
4.7 AvroCompatibilityGate
public void check(Schema candidate, List<Schema> history, SchemaCompatibility mode) {
if (mode == SchemaCompatibility.NONE_EXPERIMENTAL || history.isEmpty()) return;
List<Schema> checked = isTransitive(mode) ? history : history.subList(0, 1);
for (Schema previous : checked) {
if (readsBackward(mode)) requireCompatible(candidate, previous, "backward");
if (readsForward(mode)) requireCompatible(previous, candidate, "forward");
}
}
history는 newest first를 요구한다(javadoc @param history the previously registered schemas, newest first). 이것은 messaging-schema-api의 SchemaRegistry.history가 oldest first를 계약으로 삼는 것과 반대다. 두 계약을 잇는 코드가 없으므로 오늘은 충돌하지 않지만, 잇는 순간 reversed()를 빠뜨리면 조용히 잘못된 버전을 비교한다. SchemaCompatibilityValidator.versionsToCheck가 정확히 그 reversed()를 수행하고, 그 클래스는 호출되지 않는다(§12.3).
에러 코드는 방향에서 파생된다 — "AVRO_" + direction.toUpperCase(Locale.ROOT) + "_INCOMPATIBLE" → AVRO_BACKWARD_INCOMPATIBLE / AVRO_FORWARD_INCOMPATIBLE.
5. 주요 실행 경로
encode: schemaFor → GenericRecord 확인 → 스키마 동일성 확인 → BoundedByteSink + direct encoder → writer.write + flush → EncodedMessage(bytes, AVRO, SchemaReference)
decode(동일 버전): requireWithinLimit → schemaFor → 대상 타입이 GenericRecord 계열인지 → boundedReader(writer, writer) → reader.read
decodeEvolved: requireWithinLimit → schemaFor(writer) + schemaFor(reader) → boundedReader(writer, reader) → reader.read
CI 게이트: check(candidate, history, mode) → 모드에 따라 비교 대상 선정 → 방향별 checkReaderWriterCompatibility
6. 실패 경로와 복구/번역
| 코드 | 예외 | 조건 |
|---|---|---|
AVRO_TYPE_NOT_REGISTERED |
MessageValidationException |
타입 미등록 |
AVRO_VERSION_NOT_REGISTERED |
MessageValidationException |
버전 미등록 |
AVRO_PAYLOAD_NOT_A_RECORD |
MessageValidationException |
encode/decode 대상이 GenericRecord가 아님 |
AVRO_SCHEMA_MISMATCH |
MessageValidationException |
payload 스키마 ≠ 등록 스키마 |
AVRO_PAYLOAD_TOO_LARGE |
MessageTooLargeException |
인코딩 중 또는 디코딩 입력 상한 초과 |
AVRO_COLLECTION_TOO_LARGE |
MessageTooLargeException |
배열 원소 수 주장 > maxBytes |
AVRO_ENCODE_FAILED |
MessageSerializationException |
그 외 인코딩 실패 |
AVRO_DECODE_FAILED |
MessageSerializationException |
그 외 디코딩 실패 |
AVRO_EVOLUTION_FAILED |
MessageSerializationException |
진화 해석 실패 |
AVRO_BACKWARD_INCOMPATIBLE / AVRO_FORWARD_INCOMPATIBLE |
MessageSchemaIncompatibleException |
CI 게이트 |
예외 재던지기 패턴이 세 곳에 반복된다.
} catch (IOException | RuntimeException failure) {
if (failure instanceof MessageTooLargeException tooLarge) {
throw tooLarge;
}
throw new MessageSerializationException("AVRO_*_FAILED", ..., failure);
}
BoundedByteSink가 던지는 MessageTooLargeException은 RuntimeException이므로 catch에 걸린다. 그것을 그대로 통과시키지 않으면 크기 실패가 인코딩 실패로 접힌다 — JSON codec의 unwrapTooLarge와 같은 문제를 다른 방식(원인 사슬 탐색이 아니라 즉시 instanceof)으로 푼다. §12.3.
AvroHostileInputTest.aCountBeyondIntRangeFailsWhileReadingRatherThanWhileReserving가 흥미로운 경계를 잡는다 — 2³²을 주장하면 int로 잘려 무해한 값이 되고, 그 다음 읽기가 입력 부족으로 실패해 MessageSerializationException이 된다. 즉 newArray 방어를 우회하는 값이 존재하지만 그 우회는 할당이 아니라 읽기 실패로 끝난다.
7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
AvroMessageCodec은 불변이다 — schemas가 Map.copyOf된 평탄 맵, maxBytes는 int. BoundedByteSink·BinaryEncoder·DatumReader·BinaryDecoder는 전부 호출마다 새로 만들어진다.
EncoderFactory.get()/DecoderFactory.get()은 Avro의 싱글턴 팩토리이고 스레드 안전하다. 다만 binaryDecoder(encoded, null)의 두 번째 인자가 재사용 decoder 자리인데 항상 null을 넘긴다 — 재사용하지 않으므로 공유 상태가 없다. 성능을 버리고 안전을 택한 형태다.
AvroCompatibilityGate는 상태가 없다.
8. 설정·기능 플래그·환경 차이
설정 없음.
| 상수 | 값 | 가시성 |
|---|---|---|
AvroMessageCodec.DEFAULT_MAX_BYTES |
1,048,576 | private |
private이므로 §12.3의 "1 MiB가 다섯 곳에 복사됨" 문제에서 이 leaf는 외부에 값을 노출하지 않는다. 대신 공유 상수를 읽지도 않는다.
Avro 버전은 1.12.0으로 build.gradle에 고정돼 있다.
9. 퍼시스턴스/외부 시스템 세부
없다. 외부 schema registry를 쓰지 않는다 — 스키마는 생성자 인자로 받는다.
10. 테스트 레인과 실제 증명 범위
레인: ./gradlew :messaging:messaging-schema-avro:test. BUILD SUCCESSFUL, 16 tests, 0 skipped, 0 failures.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---|---|---|
AvroCompatibilityTest |
8 | round trip, schema reference, v1→v2 default를 통한 진화, defaulted 필드 추가는 backward 호환, default 없는 추가는 거절, payload 스키마 불일치 사전 거절, 미등록 버전/타입 거절 | transitive 모드 실제 동작(테스트가 BACKWARD만 씀) |
AvroHostileInputTest |
4 | 4억 원소 주장이 할당 전에 거절됨, 진화 경로도 같은 방어, int 범위 초과는 읽기 실패로 끝남, 정직한 배열은 정상 | 문자열·맵·바이트 배열(라이브러리가 이미 방어한다고 javadoc이 명시) |
AvroRegistryBoundsTest |
4 | 생성 후 맵 변경이 무효, 인코딩 중 거절(refused at byte), 진화 경로 상한, 정확히 상한인 payload 허용 |
— |
증명 공백 하나. AvroCompatibilityGate의 transitive 모드가 테스트되지 않는다. 8개 중 게이트를 부르는 것은 둘이고 둘 다 SchemaCompatibility.BACKWARD(pairwise)다. isTransitive가 true인 경로 — history 전체를 순회하는 분기 — 는 실행되지 않는다. 그 분기는 §12.3이 지적하는 중복 구현의 핵심이기도 하다.
세 테스트 클래스 중 둘이 클래스 javadoc으로 이전 결함을 서술한다(AvroHostileInputTest, AvroRegistryBoundsTest). 이 저장소의 일관된 습관이다.
11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
verifyCleanArchitectureDependencies |
["messaging-core-api","messaging-schema-api"] |
verifyRuntimeModuleMembership |
[] — 런타임 편입 없음이 강제됨 |
vendor api 규칙(src/messaging/CLAUDE.md:40-43) |
Avro가 public 시그니처에 등장 → api 선언 필요. 통과 |
| ArchUnit | 전용 규칙 없음 |
AvroCompatibilityGate가 "Run in CI"라고 선언하지만, 이 저장소의 CI에서 그것을 실행하는 task가 없다. src/build.gradle의 9개 verifyMessaging* task는 전부 app-bootstrap/build/messaging-evidence/**/manifest.json을 요구하는 자격 게이트이고 스키마 진화 검사를 부르지 않는다. §12.1.
12. 실제 사용 여부와 negative-space probes
원시 증거: evidence/raw/272-schema-family-reachability.txt.
12.1 Public surface reachability
| 타입 | leaf 밖 참조 | 판정 |
|---|---|---|
AvroMessageCodec |
0 | 소비자 없음 |
AvroCompatibilityGate |
0 | 소비자 없음 |
git grep -l -w AvroMessageCodec -- src ':!src/messaging/messaging-schema-avro' exit 1, AvroCompatibilityGate도 동일.
두 클래스의 "0"은 성격이 다르다.
AvroMessageCodec의 0은 정합적이다 — runtime_memberships: []이고 starter의 codec registry에도 등록되지 않는다(RegisteredMessageCodecs.of(JacksonMessageCodec.of(...)), varargs 비어 있음). 소비자 없음과 배포 없음이 일치한다.
AvroCompatibilityGate의 0은 다르다. 이 클래스는 런타임이 아니라 CI에서 도는 것을 전제로 설계됐다. javadoc이 그렇게 선언한다. 그런데 그것을 부르는 CI task가 없다. 즉 "런타임에 안 쓰이는 건 당연하다"가 이 클래스에는 적용되지 않는다 — 이 클래스는 애초에 런타임 소비자를 가질 계획이 없었고, 계획된 소비자(CI)도 없다.
이 구분이 중요한 이유: 배포 게이트가 생겨 messaging-schema-avro가 런타임에 편입되면 AvroMessageCodec은 자연히 배선되지만 AvroCompatibilityGate는 여전히 아무 데도 붙지 않는다. 두 문제는 함께 풀리지 않는다.
한계. 이 저장소는 템플릿이고, 파생 프로젝트가 AvroCompatibilityGate를 자기 CI에서 부를 수 있다. 그것을 확인할 수단이 저장소 안에 없다.
12.2 Conditional sibling comparison
Spring 주석 0개. bean 없음. 비교 대상 없음.
codec sibling 비교는 가능하고 결과가 유의미하다.
| codec | MessageCodec 구현 |
starter 등록 | membership | 정합성 |
|---|---|---|---|---|
JacksonMessageCodec |
o | o | ["app-bootstrap"] |
일치 |
AvroMessageCodec |
o | x | [] |
일치 |
ProtobufMessageCodec |
o | x | [] |
일치 |
RawBytesMessageCodec |
o | x | ["app-bootstrap"](schema-api 소속) |
불일치 |
Avro는 세 축이 전부 "없음"으로 정렬돼 있다. incubating leaf가 이래야 하는 형태다.
12.3 Duplicate mechanism sweep
(a) 진화 판단 중복 — 확인됨
AvroCompatibilityGate의 private isTransitive/readsBackward/readsForward가 messaging-schema-api의 SchemaCompatibilityValidator의 public static isTransitive/checksBackward/checksForward와 같은 판단을 다시 구현한다.
| 판단 | schema-api | 이 leaf |
|---|---|---|
isTransitive |
public static, 허용목록 | private static, 글자까지 동일한 복사본 |
| 후방 검사 | checksBackward, 허용목록 |
readsBackward, 거부목록 |
| 전방 검사 | checksForward, 허용목록 |
readsForward, 거부목록 |
현재 enum 7개 값에서 두 구현의 결과는 같다(NONE_EXPERIMENTAL은 check:34의 early return이 가린다). 형태가 반대이므로 enum이 자라면 갈라진다 — 허용목록은 새 모드를 "검사 안 함"으로, 거부목록은 "양방향 검사"로 기본 처리한다.
schema-api의 javadoc이 이 중복을 정확히 예고했다 — "duplicating that reasoning in each codec is how the two formats drift apart". 그리고 그것을 막을 클래스는 호출되지 않는다. 상세는 analysis/messaging/messaging-schema-api.md §12.3이 소유한다.
(b) history 순서 계약이 반대다
| 위치 | 요구 |
|---|---|
SchemaRegistry.history (schema-api) |
oldest first |
AvroCompatibilityGate.check의 history 파라미터 |
newest first |
둘을 잇는 코드가 없어 오늘은 충돌하지 않는다. 잇는 순간 reversed()를 빠뜨리면 history.subList(0, 1)이 가장 오래된 스키마를 "직전 버전"으로 비교한다. 실패하지 않고 엉뚱한 비교를 통과시킬 수 있다.
(c) 크기 예외 통과 패턴이 codec마다 다르다
| codec | 방식 |
|---|---|
JacksonMessageCodec |
unwrapTooLarge — 원인 사슬을 끝까지 훑음 |
AvroMessageCodec |
catch 안에서 즉시 instanceof (3곳 반복) |
ProtobufMessageCodec |
해당 없음 — requireFits로 사전 거절 |
같은 문제(BoundedByteSink의 MessageTooLargeException이 포맷 라이브러리 예외에 삼켜지는 것)를 세 가지로 푼다. Jackson은 예외를 감싸므로 사슬 탐색이 필요하고, Avro는 감싸지 않으므로 즉시 검사로 충분하다 — 즉 차이가 라이브러리 동작에서 나온 정당한 것이다. 다만 그 이유가 어디에도 적혀 있지 않다.
(d) 에러 코드 어휘가 codec마다 다르다
같은 판단에 다른 문자열:
| 판단 | JSON | Avro | Protobuf |
|---|---|---|---|
| 타입 미등록 | UNKNOWN_MESSAGE_TYPE |
AVRO_TYPE_NOT_REGISTERED |
UNKNOWN_MESSAGE_TYPE |
| 버전 미등록 | SCHEMA_VERSION_NOT_REGISTERED |
AVRO_VERSION_NOT_REGISTERED |
SCHEMA_VERSION_NOT_REGISTERED |
| 타입 불일치 | PAYLOAD_TYPE_MISMATCH |
AVRO_PAYLOAD_NOT_A_RECORD / AVRO_SCHEMA_MISMATCH |
PAYLOAD_TYPE_MISMATCH |
JSON과 Protobuf는 어휘를 공유하고 Avro만 접두사를 붙인다. 대시보드가 코드로 집계하면 Avro만 별도 계열이 된다.
12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
AvroCompatibilityGate javadoc: "Run in CI rather than at runtime" |
저장소 CI에 호출 지점 없음 | 미실현 — 진술이 틀린 게 아니라 계획이 실행되지 않음 |
build.gradle 주석: Avro가 public 시그니처에 등장하므로 api |
두 클래스의 public 시그니처에 org.apache.avro.Schema 등장 확인 |
일치 |
docs/messaging/support-matrix.md: Avro가 Stable이 아님 |
membership [], starter 미등록 |
일치 |
docs/messaging/support-matrix.md:23: 모든 messaging leaf가 unwired |
이 leaf는 실제로 [] — 이 leaf에 한해서는 맞다 |
family 전체로는 틀림(messaging-core-api §12.4) |
마지막 행이 흥미롭다. 잘못된 일반화가 우연히 이 leaf에서는 참이 된다. 그래서 이 문서만 읽으면 drift를 발견할 수 없다 — family 수준에서 세야 보인다.
13. Git/설계 문서에서 확인한 변화와 실패 기록
테스트 클래스 javadoc이 세 결함을 보존한다.
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
AvroRegistryBoundsTest javadoc |
중첩 맵에 Map.copyOf(얕은 복사) |
호출자가 생성 후 스키마 교체 가능 → Avro는 실패하지 않고 그럴듯한 쓰레기를 만듦 |
AvroRegistryBoundsTest javadoc |
decodeEvolved에 크기 검사 없음 |
producer가 앞서 나간 뒤 모든 메시지가 지나는 경로가 무제한 입력을 수용 |
AvroHostileInputTest javadoc |
배열 원소 수 주장을 신뢰하고 할당 | 5바이트로 4억 원소 배열 → OutOfMemoryError, codec이 분류할 수 없는 실패, consumer 스레드에서 프로세스 사망 |
AvroMessageCodec.decodeEvolved 주석 |
같은 내용 | — |
세 번째가 형태상 가장 흥미롭다 — 바이트 상한이라는 올바른 도구가 잘못된 공격에 적용되어 있었다. 테스트 javadoc이 그것을 한 문장으로 적는다: "The byte limit is the wrong instrument for this attack and was the only one in place."
14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-272 | command | evidence/raw/272-schema-family-reachability.txt §B, §D, §E |
두 형태의 진화 판단 나란히, codec별 소비자 0, membership [] |
정적 검색 |
| EVD-276 | command | ./gradlew :messaging:messaging-schema-avro:test --rerun-tasks |
BUILD SUCCESSFUL, 16 / 0 / 0 | 실제 Avro 브로커 없음 |
15. 명시적 설계 이유와 추론을 구분한 정리
명시적
- Avro 바이너리에 스키마가 없어 registry가 계약이 되는 이유 — 클래스 javadoc
- single-object encoding에 헤더를 안 붙이는 이유 — 클래스 javadoc
- 얕은 복사가 만든 구멍과 평탄화로 고친 이유 —
flattenjavadoc - direct encoder를 쓰는 이유 — encode 주석
- 배열 원소 상한을
maxBytes로 잡은 논리 —boundedReaderjavadoc - 적대적 입력 corpus가 좁은 이유 —
AvroHostileInputTestjavadoc - 게이트가 CI용인 이유 —
AvroCompatibilityGatejavadoc - Avro를
api로 선언한 이유 — build.gradle 주석
추론
- 크기 예외 통과 방식이 JSON과 다른 것은 Jackson이 예외를 감싸고 Avro는 감싸지 않기 때문이다 → 추론. 두 코드의 형태는 관측이고 인과는 추론이다.
- 에러 코드에
AVRO_접두사를 붙인 것이 의도인지 → 미상. - 게이트가
newest first를 요구하는 것과 port가oldest first인 것 중 어느 쪽이 나중인지 → 미상. 커밋이 4개뿐이고 둘 다 같은 커밋에 들어왔다.
16. 확인한 것 / 확인하지 못한 것
확인한 것
- 두 클래스 345줄 전문의 계약과 방어
- 16개 테스트가 통과하고 무엇을 단언하는지
- 소비자 0과 membership
[]이 정합적이라는 것 - 진화 판단이 schema-api와 중복이고 형태가 반대라는 것
history순서 계약이 schema-api와 반대라는 것- CI 실행을 전제한 게이트를 부르는 CI task가 없다는 것
확인하지 못한 것
- transitive 모드의 실제 동작. 테스트가
BACKWARD만 쓴다.history전체 순회 분기가 실행된 적이 없다. - 파생 프로젝트가
AvroCompatibilityGate를 자기 CI에서 부르는지. 저장소 안에 확인 수단이 없다. - 실제 Avro 스키마 진화 사례에서
checkReaderWriterCompatibility의 판정이 이 게이트의 방향 매핑과 맞는지 — 테스트는 defaulted 필드 추가/미추가 두 경우만 본다. decodeEvolved가 실제 다중 버전 배포에서 어떤 빈도로 쓰이는지. 소비자가 없어 관측할 수 없다.
17. 손볼 것
P2 — CI에서 돈다고 선언한 게이트를 부르는 CI가 없다
- 사실.
AvroCompatibilityGatejavadoc이 "Run in CI rather than at runtime"이라고 선언한다. 저장소 전체에서 이 클래스 참조는 자기 선언과 자기 테스트뿐이고,src/build.gradle의 9개verifyMessaging*task 중 스키마 진화를 검사하는 것이 없다. - 근거.
evidence/raw/272§D.src/build.gradle:65-110. - 왜 문제인가. 게이트의 존재 이유가 "한 번 발행되면 보존 로그에 영구히 남는다"인데, 그 보호가 어느 파이프라인에도 붙어 있지 않다.
AvroMessageCodec의 미사용과 달리 이것은 membership으로 설명되지 않는다 — 런타임 편입 여부와 무관하게 CI 게이트는 붙었어야 한다. - 확인 방법.
git grep -n -w AvroCompatibilityGate -- src·git grep -n 'verifyMessaging' -- src/build.gradle - 후보. (a) 스키마 디렉터리를 읽어 게이트를 돌리는 Gradle task를 만든다. (b) 파생 프로젝트가 붙이는 확장점이라면 javadoc이 그렇게 말하도록 고친다.
- 다음 단계. CASE 후보. "장치는 있고 회로가 닫히지 않았다"의 전형이고, 재현이 정적 검색으로 끝난다.
P2 — 진화 판단이 두 곳에 있고 형태가 반대다
- 사실.
isTransitive는SchemaCompatibilityValidator(public static)와 이 leaf(private static)에 글자까지 같은 복사본이 있다. 방향 판정은 전자가 허용목록, 후자가 거부목록이다. - 근거.
evidence/raw/272§B에 두 형태가 나란히 출력된다. - 왜 문제인가. 오늘 7개 모드에서 결과는 같지만 형태가 반대이므로
SchemaCompatibility에 값이 추가되는 순간 갈라진다 — 허용목록은 "검사 안 함", 거부목록은 "양방향 검사". 그리고 이 중복은 schema-api의 javadoc이 명시적으로 막으려던 것이다. - 확인 방법.
evidence/raw/272§B 재실행. - 후보.
AvroCompatibilityGate가SchemaCompatibilityValidator의 public static을 부르게 한다. 세 메서드 다 이미 public static이다. - 다음 단계.
messaging-schema-api§17의 같은 항목과 동일 사건이다. 그 leaf가 소유하고 여기서는 교차 참조만 남긴다.
P3 — history 순서 계약이 port와 게이트에서 반대다
- 사실.
SchemaRegistry.historyjavadoc은 oldest first,AvroCompatibilityGate.check의@param history는 newest first. - 근거. 두 javadoc.
- 왜 문제인가. 둘을 잇는 코드가 없어 지금은 무해하다. 이으면서
reversed()를 빠뜨리면 pairwise 모드가 가장 오래된 스키마를 직전 버전으로 비교한다. 실패하지 않고 통과할 수 있는 오류다. port javadoc이 이미 같은 위험을 경고한다 — "an ordering mistake here silently converts a transitive check into a pairwise one." - 확인 방법. 두 javadoc 대조.
- 후보. 게이트도 oldest-first를 받게 통일하고 내부에서 뒤집는다.
- 다음 단계. REFERENCE 후보(컬렉션 순서가 계약이면 양쪽에서 같은 방향으로 적는다).
P3 — transitive 분기가 테스트되지 않는다
- 사실.
AvroCompatibilityTest의 게이트 호출 2건이 모두SchemaCompatibility.BACKWARD다.isTransitive가 true인 경로가 실행되지 않는다. - 근거.
AvroCompatibilityTest.java:134-150. - 왜 문제인가. transitive 모드는 "여러 릴리스 뒤처진 consumer"를 위한 것이고 그것이 이 게이트의 존재 이유 중 절반이다. 그리고 그 분기가 §12.3의 중복 구현이 갈라질 지점이다.
- 확인 방법. 두 테스트의 모드 인자 확인.
- 후보. v1·v2·v3 세 스키마로
BACKWARD_TRANSITIVE케이스를 추가한다. - 다음 단계. REFERENCE 후보(모드 enum을 분기 조건으로 쓰면 각 분기에 테스트를 둔다).
P3 — 에러 코드 어휘가 형제 codec과 갈라진다
- 사실. 같은 판단에 JSON/Protobuf는
UNKNOWN_MESSAGE_TYPE·SCHEMA_VERSION_NOT_REGISTERED, Avro는AVRO_TYPE_NOT_REGISTERED·AVRO_VERSION_NOT_REGISTERED를 쓴다. - 근거. 세 codec의
requireRegistered/schemaFor. - 왜 문제인가.
FailureDescriptor.code는 "stable, machine-readable code"이고 대시보드·재시도 정책이 이것으로 집계한다. 같은 판단이 두 어휘로 나뉘면 Avro만 별도 계열이 된다. - 확인 방법.
git grep -n 'NOT_REGISTERED' -- 'src/messaging/**/*.java' - 후보. 공통 코드를 쓰고 포맷은
sanitizedMessage로 구분한다. - 다음 단계. REFERENCE 후보(안정 코드는 판단 단위로 정하고 구현 단위로 정하지 않는다).
확인된 설계(문제 아님)
- 중첩 registry를
(type, version)으로 평탄화해 양쪽 레벨을 복사하는 것 - direct encoder 선택 —
BoundedByteSink의 경계가 실제로 작동하기 위한 전제 - 배열 원소 상한을 별도 튜닝 값이 아니라
maxBytes에서 파생시킨 것 decodeEvolved에 같은 상한을 적용한 것과, 그것을 두 각도에서 붙드는 테스트- 적대적 입력 corpus를 좁게 두고 그 이유를 적은 것
- Avro를
api로 선언한 것(형제 JSON과 반대 판정이고, 그것이 맞다) - 소비자 0과 membership
[]이 정합적인 것
Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MSV-001 | registry | src/config/architecture/modules.json |
21234e38 |
deps, runtime_memberships: [] |
선언 |
| MSV-002 | build | messaging-schema-avro/build.gradle |
same | Avro api 선언과 그 이유, 버전 1.12.0 |
— |
| MSV-003 | code | .../avro/AvroMessageCodec.java 전문 |
same | §4.1–4.6 | — |
| MSV-004 | code | .../avro/AvroCompatibilityGate.java 전문 |
same | §4.7, §12.3(a) | — |
| MSV-005 | test | AvroCompatibilityTest (8) |
same | round trip·진화·게이트 pairwise | transitive 미검증 |
| MSV-006 | test | AvroHostileInputTest (4) |
same | 5바이트 4억 원소 공격과 방어, 진화 경로 동일 방어 | 문자열·맵은 범위 밖(javadoc이 이유를 적음) |
| MSV-007 | test | AvroRegistryBoundsTest (4) |
same | 생성 후 맵 변경 무효, 인코딩 중 거절, 진화 경로 상한 | — |
| MSV-008 | cross-leaf code | messaging-schema-api/.../SchemaCompatibilityValidator.java:79-112 |
same | 중복의 다른 쪽 | 해당 leaf SSOT가 소유 |
| MSV-009 | cross-leaf code | messaging-schema-api/.../SchemaRegistry.java:16-17 |
same | oldest-first 계약 | 해당 leaf SSOT가 소유 |
| MSV-010 | cross-leaf code | messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:360-366 |
same | codec registry에 Avro 미등록 | 해당 leaf SSOT가 소유 |
| MSV-011 | build policy | src/build.gradle:65-110, src/messaging/CLAUDE.md:40-43 |
same | verifyMessaging* 9개가 스키마 진화를 부르지 않음, vendor api 규칙 |
— |
| EVD-272 | command | evidence/raw/272-schema-family-reachability.txt |
same | §12.1·§12.3 | 정적 검색 |
| EVD-276 | command | ./gradlew :messaging:messaging-schema-avro:test --rerun-tasks |
same | 16 / 0 / 0 | 실제 브로커 없음 |