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>
66 KiB
messaging-testkit 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-testkitSSOT owner:messaging-testkitintegration/family document:analysis/19-messaging-platform.md(secondary, INTEGRATION_ONLY)
0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id:
messaging-testkit - canonical state
analysisFile:analysis/messaging/messaging-testkit.md - source path:
src/messaging/messaging-testkit - registry
allowed_dependencies:["messaging-core-api", "messaging-schema-api", "messaging-policy", "messaging-transport-spi"] - registry
runtime_memberships:[]— 런타임 배포 대상이 아니다. 그러나 §2에서 보듯 이 리프는 지금까지 본[]리프들과 성격이 다르다: 소비자가 4개 모듈, 12개 파일로 실재한다.
숫자
| 항목 | 수 |
|---|---|
| Java 파일 총계 | 20 |
src/main 파일 |
13 |
src/test 파일 |
6 |
src/jmh 파일 |
1 |
| 전체 LOC (Java) | 2,063 |
| 소스셋 | 3 (main / test / jmh) — messaging 리프 중 jmh 를 가진 3개 중 하나 |
| 비-Java 리소스 | 1 (src/main/resources/messaging/broker-certification-evidence.jsonl) |
| test 메서드(실행 확인) | 44 (EVD-301) |
| 선언된 의존 | project 4 + vendor 2 (junit-jupiter, assertj) — 전부 api |
| leaf 밖에서 이 리프를 import 하는 파일 | 12 (4개 모듈) |
src/main 13타입 — 이 리프에서 main 은 "프로덕션 코드"가 아니라 다른 리프의 test 컴파일 클래스패스에 올라가는 계약 자산이다.
| 타입 | 종류 | 역할 | leaf 밖 참조 |
|---|---|---|---|
MessagingAdapterContract |
abstract class | 모든 어댑터가 통과해야 할 7개 계약 테스트 | 2 (kafka, rabbit) |
MessagingAdapterHarness |
interface | 어댑터가 계약을 돌리기 위해 구현하는 seam | 2 |
FaultController |
interface | 주입 가능한 5개 결함 | (하니스 경유) 2 |
ContractMessage |
record | 고정 시험 메시지 (정상 / 1 MiB 초과) | 2 |
ContractAssertions |
final class | confirmed / ambiguous / rejected 단언 3종 | 2 |
ObservedDelivery |
record | 배달 1건에 대해 하니스가 관측한 것 | 2 |
HandleOutcome |
enum | 핸들러 결정 (SUCCESS/RETRY/DEAD_LETTER) | 2 |
NetworkFaultScenario |
record + 5 상수 | 네트워크 결함 5종과 기대 결과 | 1 (kafka) |
BrokerCertificationEvidence |
record | 실제로 실행된 1건의 증거 (JSONL 1줄) | 1 (kafka) |
CertifiedEvidence |
final class | 매니페스트 리더 | 1 (kafka) |
BrokerFailureMatrix |
class | 증거 → 커버리지 행렬 | 0 |
CompatibilityMatrix |
final class | 어댑터별 지원 등급 | 0 |
DockerAvailability |
final class | 컨테이너 런타임 가용성 | 4 |
Coverage ledger
| scope/file group | count | disposition | reason |
|---|---|---|---|
src/main/java/** (13) |
13 | FULL_READ |
전 파일 본문 확인 |
src/test/java/** (6) |
6 | FULL_READ |
44개 테스트 메서드·단언 확인 |
src/jmh/java/** (1) |
1 | FULL_READ |
EnvelopeCodecBenchmark 126줄 |
src/main/resources/** (1) |
1 | FULL_READ |
매니페스트 4줄 전문 (EVD-300) |
build.gradle |
1 | FULL_READ |
11줄 |
src/build.gradle jmh 블록 |
— | FULL_READ |
루트 500-542줄 (§11) |
build/** |
— | EXCLUDED |
빌드 산출물 — 단 build/resources 매니페스트는 src 와 동일함을 diff 로 확인 |
UNCLASSIFIED 0.
1. 모듈의 정체와 경계
이 리프는 "지원한다(supported)"라는 단어의 정의를 코드로 못 박는 곳이다. 플랫폼의 다른 어떤 리프도 "Kafka 는 Stable 이다" 를 주장하지 않는다. 그 주장은 여기에만 있고, 여기서만 검증된다.
세 개의 층으로 되어 있다.
- 공유 계약 (
MessagingAdapterContract+MessagingAdapterHarness+ContractMessage/ContractAssertions/ObservedDelivery/HandleOutcome/FaultController) — 브로커가 무엇이든 똑같이 답해야 하는 7가지 행동. - 결함 시나리오와 그 증거 (
NetworkFaultScenario+BrokerCertificationEvidence+CertifiedEvidence+BrokerFailureMatrix) — 어떤 장애를 실제로 돌려 봤는가. - 지원 등급 (
CompatibilityMatrix) — 위 두 층의 결과로 어댑터가 얻는 등급.
경계는 명확하다. 이 리프는 어댑터를 구현하지 않고, 어댑터를 실행하지도 않는다. 하니스 구현은 각 어댑터 리프의 src/test 에 있다(KafkaContractHarness, RabbitContractHarness). 이 리프가 가진 유일한 하니스는 InMemoryMessagingHarness 이며 src/test 에 있고, 그 javadoc 이 스스로 선을 긋는다.
// InMemoryMessagingHarness.java:22-29
/**
* A deterministic harness that exists only to prove the contract suite itself is sound.
*
* <p>It is not a production adapter and must never become one. Its value is that it has no broker
* to hide behind: if a contract test passes here and fails on Kafka, the difference is in the
* adapter, not in the test.
*/
final class InMemoryMessagingHarness implements MessagingAdapterHarness {
final + package-private + private 생성자 + 정적 팩토리. "프로덕션 어댑터가 되어서는 안 된다" 는 문장이 접근 제어자로도 강제되어 있다. src/main 이 아니라 src/test 에 둔 것도 같은 결정이다 — 다른 리프의 test 클래스패스에 올라가는 것은 src/main 뿐이므로, 이 하니스는 물리적으로 이 리프 밖으로 나갈 수 없다.
2. 의존성과 런타임 배선
// messaging-testkit/build.gradle 전문 (11줄)
apply plugin: 'java-library'
dependencies {
api project(':messaging:messaging-core-api')
api project(':messaging:messaging-schema-api')
api project(':messaging:messaging-policy')
api project(':messaging:messaging-transport-spi')
api 'org.junit.jupiter:junit-jupiter'
api 'org.assertj:assertj-core'
}
여섯 개가 전부 api 다. implementation 이 하나도 없다. 이것은 이 리프에서 옳은 선택이다: MessagingAdapterContract 는 @Test 를 자기 시그니처에 달고 있고(MessagingAdapterContract.java:28), ContractAssertions 는 AssertJ 를 반환 타입 없이 쓰지만 상속받는 쪽이 같은 AssertJ 를 봐야 하며, MessagingAdapterHarness.publish 는 PublishResult(core-api)를, ContractMessage 는 EncodedMessage(schema-api)를 공개 시그니처에 노출한다. 상속하는 쪽이 컴파일되려면 전부 전이되어야 한다.
runtime_memberships: [] 이지만 §12.1 의 판정은 다른 [] 리프들과 정반대다.
| 소비 모듈 | build.gradle | import 하는 파일 |
|---|---|---|
messaging-kafka |
testImplementation project(':messaging:messaging-testkit') |
6 |
messaging-rabbit |
testImplementation … |
3 |
messaging-outbox-jdbc-postgresql |
testImplementation … |
2 |
messaging-inbox-jdbc-postgresql |
testImplementation … |
1 |
추가로 messaging-kafka/build.gradle:91 이 이 리프의 리소스 파일 경로를 문자열로 참조한다(§4.3).
allowed_dependencies 4개 중 실제 import 여부:
| 선언 | 실제 사용 | 근거 |
|---|---|---|
messaging-core-api |
O | MessageId, MessageEnvelope, PublishResult, PublishEvidence, FailureCategory, TraceContext, MessageHeaders, UuidV7 … |
messaging-schema-api |
O | EncodedMessage (ContractMessage.java:11) |
messaging-policy |
X (§12.4) | import dev.caskeleton.messaging.policy.* 0건 |
messaging-transport-spi |
X (§12.4) | import dev.caskeleton.messaging.transport.* 0건 |
3. 패키지/컴포넌트 지도
단일 패키지 dev.caskeleton.messaging.testkit. 세 소스셋이 같은 패키지를 공유하므로 InMemoryMessagingHarness(test)가 FaultController(main)를 package-private 없이 구현할 수 있고, EnvelopeCodecBenchmark(jmh)도 같은 패키지에 있다.
데이터 흐름은 한 방향이다.
[인증 레인 실행] (messaging-kafka: messagingCertificationTest)
| BrokerCertificationEvidence.toJson() 한 시나리오당 한 줄
v
broker-certification-evidence.jsonl (messaging-testkit/src/main/resources)
| CertifiedEvidence.load() — static 초기화 1회
v
List<BrokerCertificationEvidence>
| BrokerFailureMatrix.from(...) — 기대치 대조 후 LIVE_BROKER 로 기록
v
BrokerFailureMatrix
| hasLiveBrokerCoverage(adapter)
v
CompatibilityMatrix.Entry.hasLiveBrokerCertification() <- 선언이 아니라 파생
|
v
docs/messaging/support-matrix.md 대조 (MessagingDocumentationContractTest)
핵심은 화살표 방향이 한 번도 역전되지 않는다는 것이다. 등급이 증거를 만들지 않고 증거가 등급을 만든다.
4. 계약·불변식·상태 모델
4.1 MessagingAdapterContract — 7개가 "지원한다"의 정의
// MessagingAdapterContract.java:10-19
/**
* The behaviour every adapter must exhibit, regardless of broker.
*
* <p>This suite is the platform's actual definition of "supported". A broker is Stable when it
* passes these unchanged — not when it has an adapter that compiles. …
*/
public abstract class MessagingAdapterContract {
protected abstract MessagingAdapterHarness harness();
7개 테스트와 각각이 못 박는 것:
| 테스트 | 못 박는 불변식 |
|---|---|
publishesAndConfirms |
확인은 CONFIRMED + brokerAccepted + confirmationLevel != NONE + TRANSMITTED 넷이 동시에 |
returnsAmbiguousWhenConfirmIsLost |
확인 유실은 추측이 아니라 AMBIGUOUS + mayHaveBeenStored() |
redeliversWhenSettlementIsLost |
정산 미확인 → settled=false → 2회차 attempt=2, redelivered=true |
preservesMessageIdAcrossRetryAndDlq |
재시도·DLQ 를 거쳐도 MessageId 동일 |
keepsSourceUnsettledWhenDlqPublishFails |
DLQ 발행 실패 시 원본을 정산하지 않는다 |
rejectsOversizedPayloadBeforeTransport |
크기 초과는 브로커가 아니라 로컬에서 거절 |
stopsAcceptingNewWorkDuringShutdown |
종료 시작 후 발행은 로컬 거절 |
계약을 abstract class + @Test 로 만든 결정의 효과는 InMemoryHarnessContractTest 의 javadoc 에 있다.
// InMemoryHarnessContractTest.java:5-11
/**
* Runs the shared adapter contract against the in-memory harness.
*
* <p>This is the contract testing itself. Every broker adapter adds the same nested class over its
* own harness, so a guarantee can only be weakened by editing the contract, where the change is
* visible, rather than by an adapter quietly not implementing it.
*/
즉 어댑터가 @Test 를 삭제하는 방법이 없다. 상속받는 순간 7개가 전부 실행된다. 어댑터 쪽에서 하나를 빼려면 이 파일을 고쳐야 하고, 그것은 리뷰에 보인다.
그리고 그 7개가 침묵으로 줄어드는 것을 막는 자물쇠가 하나 더 있다.
// CompatibilityMatrixTest.java:13-21, 71-80
private static final List<String> REQUIRED_CONTRACT_TESTS = List.of(
"publishesAndConfirms", "returnsAmbiguousWhenConfirmIsLost", "redeliversWhenSettlementIsLost",
"preservesMessageIdAcrossRetryAndDlq", "keepsSourceUnsettledWhenDlqPublishFails",
"rejectsOversizedPayloadBeforeTransport", "stopsAcceptingNewWorkDuringShutdown");
…
List<String> declared = Arrays.stream(MessagingAdapterContract.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(org.junit.jupiter.api.Test.class))
.map(Method::getName).toList();
assertThat(declared).containsExactlyInAnyOrderElementsOf(REQUIRED_CONTRACT_TESTS);
리플렉션으로 @Test 가 붙은 메서드 이름 집합을 상수와 정확히 대조한다. 계약에서 테스트 하나를 지우면 이 테스트가 깨진다. 추가해도 깨진다. 계약의 크기 자체가 잠겨 있다.
4.2 NetworkFaultScenario — 기대 결과를 시나리오가 소유한다
// NetworkFaultScenario.java:7-17
/**
* <p>The expected outcome is part of the scenario rather than left to each test, because the whole
* value of a fault suite is that every adapter answers the same way. A scenario that let each
* adapter declare its own expectation would pass while the adapters disagreed — which is exactly
* the situation the shared contract exists to catch.
*
* <p>The distinction across these scenarios is what evidence survives. …
*/
5개 시나리오, 그리고 각각이 rationale 을 비어 있으면 생성 자체가 실패하도록 강제한다.
// NetworkFaultScenario.java:59-62
if (rationale == null || rationale.isBlank()) {
throw new IllegalArgumentException(
"a scenario without a rationale cannot be reviewed; state why this outcome is correct");
}
| 시나리오 | Phase | Expectation | rationale 요지 |
|---|---|---|---|
connection-refused |
BEFORE_TRANSMISSION |
REJECTED |
바이트가 안 나갔으니 브로커가 가질 수 없다 |
connection-cut-after-write |
AFTER_TRANSMISSION |
AMBIGUOUS |
여기서 거절이라 답하는 게 "잃어버린 확인 하나를 주문 둘로" 만든다 |
confirm-timeout |
AFTER_TRANSMISSION |
AMBIGUOUS |
"타임아웃은 부재의 증거가 아니라 증거의 부재" |
settlement-lost |
DURING_SETTLEMENT |
REDELIVERED |
재배달은 설계이며, 핸들러가 멱등이어야지 플랫폼이 정산된 척하면 안 된다 |
high-latency |
AFTER_TRANSMISSION |
AMBIGUOUS |
결정 시점에 지연과 유실은 구별 불가 |
REJECTED 가 BEFORE_TRANSMISSION 하나뿐이라는 사실이 테스트로 잠겨 있다(CrossBrokerContractSuite.aFailureBeforeTransmissionIsTheOnlyOneReportedAsRejected).
byName 은 알 수 없는 이름을 건너뛰지 않고 거절한다.
// NetworkFaultScenario.java:125-136
.orElseThrow(() -> new IllegalArgumentException(
"no fault scenario is named '" + name + "'; evidence for a scenario this release does not"
+ " define is coverage of nothing"));
4.3 CertifiedEvidence / BrokerCertificationEvidence — 증거는 실행이 쓴다
이 리프에서 가장 밀도 높은 설계다. 두 javadoc 이 자기가 고친 결함을 이름 붙여 남겼다.
// CertifiedEvidence.java:9-27
/**
* The fault scenarios this repository's certification lane has actually exercised.
*
* <p>Read from a manifest a run produced, not from a list somebody typed. It was the list: an
* author added a line and the compatibility matrix, the cross-broker suite and the support matrix
* all believed it, on a machine that may never have started a container. "Certified against a live
* broker" then meant "a developer wrote that down", which is the one thing a certification claim
* must not mean.
*
* <p>The manifest is committed because a release claim has to survive the build that produced it —
* a matrix that reads {@code build/} answers differently on a clean checkout. What stops it from
* being hand-written again is the gate: {@code verifyMessagingCertificationEvidence} runs the lane
* against a real broker and refuses to pass when this file claims a scenario the run did not
* produce. …
*
* <p>An absent or empty manifest is not an error here. It means no scenario has evidence, so every
* scenario is {@link BrokerFailureMatrix.Coverage#NOT_COVERED} and every Stable claim that depends
* on evidence fails — which is the correct reading of "nothing has been run".
*/
네 가지 결정이 한 문단에 압축되어 있고, 넷 다 코드에서 확인된다.
(a) 커밋한다. src/main/resources/messaging/broker-certification-evidence.jsonl. build/ 를 읽으면 깨끗한 체크아웃에서 답이 달라진다는 이유가 명시되어 있다. 확인: src 와 build/resources 사본이 diff 로 동일(EVD-300).
(b) 손으로 못 쓰게 하는 게이트. messaging-kafka/build.gradle:82 의 verifyMessagingCertificationEvidence.
// messaging-kafka/build.gradle (doLast 발췌)
Closure<Set<String>> claims = { File file ->
file.readLines('UTF-8').findAll { !it.trim().isEmpty() }
.collect { line -> line.replaceAll(/,"gitCommit":"[^"]*"/, '')
.replaceAll(/,"observedAt":"[^"]*"/, '') }.toSet()
}
Set<String> ran = claims(produced.get().asFile)
Set<String> shipped = claims(committed)
if (ran != shipped) { … "claimed but not produced: …" … }
gitCommit 과 observedAt 을 정규식으로 지우고 나머지 집합을 비교한다. 그 둘은 매 실행마다 달라지므로 비교 대상이 아니라는 주석이 붙어 있다. outputs.upToDateWhen { false } — "이전 실행 결과를 재사용할 수 있는 게이트는 그 이전 실행에 대한 증거일 뿐" 이라는 주석과 함께.
(c) 부재는 오류가 아니고, 읽기 실패는 오류다.
// CertifiedEvidence.java:66-83
if (manifest == null) { return List.of(); }
…
} catch (IOException failure) {
// Not swallowed into "no evidence": a manifest that exists and cannot be read is a broken
// build, and reporting it as an absence would quietly demote every adapter instead.
throw new IllegalStateException(…);
}
없음 → 빈 목록(모두 NOT_COVERED). 있는데 못 읽음 → 예외. 이 구분이 "조용한 강등"을 막는다.
(d) 직렬화 형식이 스스로를 보호한다. JSON 라이브러리를 쓰지 않는 이유가 적혀 있다("this module is the test support for a platform whose *-api leaves carry no dependencies at all"). 대신 손으로 쓴 파서가 세 가지를 거절한다.
// BrokerCertificationEvidence.java:96-105
for (String field : FIELDS) { // 7개 필드 중 하나라도 없으면 거절
if (!fields.containsKey(field)) { throw new IllegalArgumentException(
"certification evidence is missing '" + field + "': " + json); } }
if (fields.size() != FIELDS.length) { // 모르는 필드가 있어도 거절
throw new IllegalArgumentException(
"certification evidence carries fields this release does not define: " + fields.keySet()); }
그리고 쓰기 쪽에서 이스케이프가 필요한 값을 아예 막는다.
// BrokerCertificationEvidence.java:47-55
// The serialised form is a flat object written by hand, so a quote or a backslash in any field
// would produce a manifest line that the reader silently truncates instead of rejecting. …
requireUnescaped(adapter, "adapter"); … requireUnescaped(gitCommit, "gitCommit");
"조용히 잘라 읽는 대신 거절한다" 가 이 파일 전체를 관통하는 규칙이다. 이 규칙은 테스트로도 잠겨 있다(CertifiedEvidenceTest.aValueThatWouldBreakTheManifestIsRefusedWhenTheEvidenceIsBuilt).
한 줄 = 한 객체(JSONL)인 이유도 명시적이다: "appending a scenario is an append and a diff of two runs is a diff of two sets of lines"(BrokerCertificationEvidence.java:22-24).
4.4 BrokerFailureMatrix.requireOutcomeMatchesExpectation — 틀린 증거는 증거가 아니다
이 리프에서 가장 날카로운 단 하나의 판단이다.
// BrokerFailureMatrix.java:129-146
/**
* Refuses evidence whose observed outcome contradicts the scenario it claims to cover.
*
* <p>Nothing compared the two, so a run that reported a definite rejection where the scenario
* says the outcome is unknowable counted as coverage of that scenario. That is worse than no
* evidence: it certifies the adapter for exactly the behaviour it got wrong, and the support
* matrix then tells an adopter the case is handled.
*/
private static void requireOutcomeMatchesExpectation(BrokerCertificationEvidence observed) {
String expected = observed.scenario().expectation().name();
if (!expected.equals(observed.outcome())) { throw new IllegalArgumentException(…); }
}
"틀린 증거는 무증거보다 나쁘다" — 실패한 인증을 커버리지로 세면, 어댑터가 정확히 틀린 그 행동에 대해 인증된다. 이 판단이 테스트로 재현되어 있고(CrossBrokerContractSuite.evidenceThatDisagreesWithItsScenarioIsNotCoverage), 그 테스트가 쓰는 반례가 실재하는 Rabbit 테스트 이름이라는 점이 좋다.
// CrossBrokerContractSuite.java:58-72
new BrokerCertificationEvidence("messaging-rabbit", "rabbitmq:4.3-management",
NetworkFaultScenario.CONNECTION_CUT_AFTER_WRITE,
"RabbitBrokerIT#anUnroutablePublishIsRejectedEvenThoughTheExchangeConfirmedIt",
"REJECTED", "repository-local", …);
…assertThatThrownBy(() -> BrokerFailureMatrix.from(List.of(contradicting)))
.as("certifying an adapter for the behaviour it got wrong is worse than no evidence")
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("AMBIGUOUS");
4.5 CompatibilityMatrix — 파생된 인증, 선언된 나머지
// CompatibilityMatrix.java:50-62
/**
* Reports whether a lane has run this adapter against a real broker.
*
* <p>Read from the evidence rather than declared. As a field it was a boolean an author set
* next to the tier, and RabbitMQ carried {@code true} while no fault scenario had ever been
* executed against it — the same shape as the coverage matrix that used to assert its own
* constants.
*/
public boolean hasLiveBrokerCertification() {
return BrokerFailureMatrix.from(CertifiedEvidence.recorded()).hasLiveBrokerCoverage(adapter);
}
Entry 는 5개 필드 중 4개가 선언값이고 1개(hasLiveBrokerCertification)만 파생값이다. 그리고 그 하나가 정확히 거짓말이 실제로 발생했던 자리다.
ENTRIES 맵의 주석 두 개가 자기 이력을 남긴다.
// CompatibilityMatrix.java:79-92
"messaging-kafka",
// 4.1 only. The declared 4.2/4.3 were never run: the fixtures pin apache/kafka:4.1.0 and
// the locked client is 4.1.1, so the matrix and the support document agreed with each
// other and with nothing that had executed. A version returns here when a lane runs
// against its image.
new Entry("messaging-kafka", List.of("4.1"), Tier.STABLE, true, true),
"messaging-rabbit",
// Experimental until a lane runs a fault scenario against a RabbitMQ container. The
// adapter passes the shared contract and its broker IT exercises the routable and
// unroutable happy paths, but Stable in this repository means fault evidence, and
// there is none: connection-refused, cut-after-write, confirm-timeout,
// settlement-lost and high-latency have never been executed here.
new Entry("messaging-rabbit", List.of("4.3"), Tier.EXPERIMENTAL, true, false),
(rabbit 의 인자는 runsSharedContract=true, enabledByDefault=false — 계약은 통과하지만 기본 비활성.)
"문서와 행렬이 서로 동의했고, 실행된 것과는 아무것도 동의하지 않았다" 는 문장이 이 리프 전체의 요약이다.
runsSharedContract 는 여전히 선언 불리언이지만 §12.1 에서 두 Stable/준-Stable 어댑터에 대해 실측 검증했다: kafka·rabbit 둘 다 실제로 extends MessagingAdapterContract 를 한다. 나머지 셋은 false 로 선언되어 있고 실제로도 상속하지 않는다. 현재는 일치한다.
4.6 ContractMessage — 고정 시험 데이터
// ContractMessage.java:17-26
/**
* <p>Fixed rather than random so that a failure in one adapter can be compared byte for byte
* against another. The oversized variant exists to prove the payload limit is enforced locally: an
* adapter that lets it reach the broker turns a deterministic rejection into a broker-specific
* error.
*/
FIXED_TIME = 2026-08-10T09:15:00Z 고정, oversized() 는 new byte[1_048_577] = 1 MiB + 1 바이트. 이 값은 PayloadPolicy.DEFAULT_MAX_BYTES = 1_048_576 과 정확히 맞는다 — 그러나 상수를 참조하지 않고 리터럴을 다시 쓴다(§12.3).
5. 주요 실행 경로
경로 A — 어댑터 계약 실행 (컨테이너 불필요, 항상 실행)
InMemoryHarnessContractTest.Contract (또는 KafkaProducerContractTest.Contract 등)
-> MessagingAdapterContract.<7개 @Test>
-> harness() 로 새 하니스 1개
-> harness.faults().drop…() 로 결함 주입 (3/5 만 사용, §12.1)
-> harness.publish(ContractMessage.…)
-> harness.drain(HandleOutcome.…)
-> ContractAssertions.assert…(result)
-> try-with-resources 로 close()
MessagingAdapterHarness extends AutoCloseable 이고 close() 가 checked exception 을 던지지 않도록 재선언되어 있다(MessagingAdapterHarness.java:71-72). 7개 테스트 전부 try (…) 로 감싸므로 하니스 누수 경로가 없다.
경로 B — 인증 증거 생산 (컨테이너 필요, test 에서 제외)
:messaging:messaging-kafka:messagingCertificationTest (tag = 'messaging-certification')
-> KafkaBrokerCertificationIT (toxiproxy 로 결함 주입)
-> BrokerCertificationEvidence.toJson() 한 줄씩
-> build/messaging-certification/broker-certification-evidence.jsonl
:messaging:messaging-kafka:verifyMessagingCertificationEvidence
-> 생산물 vs 커밋본 집합 비교 (gitCommit/observedAt 제외)
경로 C — 등급 판정 (컨테이너 불필요, 매 빌드)
CertifiedEvidence.<clinit> -> 클래스패스 리소스 1회 로드
CompatibilityMatrixTest / CrossBrokerContractSuite / MessagingDocumentationContractTest
-> BrokerFailureMatrix.from(recorded()) -> 등급 단언 -> docs/messaging/*.md 대조
경로 C 가 경로 B 없이도 돌고, 경로 B 가 없으면 매니페스트가 비어 등급 주장이 무너진다는 것이 설계의 핵심이다.
6. 실패 경로와 복구/번역
이 리프의 실패 처리 원칙은 하나다: 모르는 것을 아는 척하지 않는다.
| 상황 | 처리 | 근거 |
|---|---|---|
| 매니페스트 없음 | 빈 목록 → 전부 NOT_COVERED |
CertifiedEvidence.java:68-70 + javadoc 25-27 |
| 매니페스트 읽기 실패 | IllegalStateException |
CertifiedEvidence.java:77-82 |
| 필드 누락 | IllegalArgumentException("missing '…'") |
BrokerCertificationEvidence.java:96-101 |
| 모르는 필드 존재 | IllegalArgumentException("fields this release does not define") |
:102-105 |
| JSON 아님 / 잘림 | IllegalArgumentException |
:117-118, 131-133 |
| 모르는 시나리오 이름 | IllegalArgumentException("coverage of nothing") |
NetworkFaultScenario.java:129-135 |
| 결과가 기대와 불일치 | IllegalArgumentException — 커버리지로 세지 않음 |
BrokerFailureMatrix.java:137-145 |
| 등록되지 않은 어댑터 조회 | IllegalArgumentException("not in the compatibility matrix") |
CompatibilityMatrix.java:126-132 |
| 기록 없는 어댑터의 커버리지 조회 | NOT_COVERED (예외 아님) |
BrokerFailureMatrix.java:60-64 |
마지막 두 줄의 구분이 의도적이다. CompatibilityMatrix.of("messaging-artemis") 는 던지고(anUnknownAdapterIsNotSilentlyTreatedAsSupported), matrix.coverageOf("messaging-artemis", …) 는 NOT_COVERED 를 돌려준다(aFaultThatWasNeverRecordedReadsAsUncoveredRatherThanPassing). 전자는 "지원 목록에 없는 것을 지원인 척"을 막고, 후자는 "기록 없음"이 곧 "커버 안 됨"이라는 자연스러운 읽기다.
DockerAvailability 는 반대 방향의 실패 처리다.
// DockerAvailability.java:3-9
/**
* <p>Live-broker suites are guarded on this rather than assumed. A developer machine or a build
* agent without Docker should skip them with a stated reason, not fail with a connection error that
* looks like a product defect — and the release gate checks separately that the suites actually ran
* where they were supposed to.
*/
Class.forName("org.testcontainers.DockerClientFactory") 를 리플렉션으로 부르고 어떤 예외든 false 로 삼킨다(:26-34). 이 리프가 testcontainers 에 의존하지 않으면서 그 존재를 물어볼 수 있게 하는 유일한 방법이고, 결과를 static final 로 1회만 캐시한다.
주목할 점: "skip 은 성공이 아니다" 라는 반대 규칙이 인증 레인에는 적용되어 있다.
// messaging-kafka/build.gradle
// The certification tag is excluded from `test` because the lane deliberately carries no Docker
// guard. Every other container suite here skips with a stated reason when Docker is absent; a lane
// that skipped would report success for a broker nobody started, which is the whole failure the
// evidence exists to rule out. Keeping it out of `test` is what lets it fail closed without
// breaking a laptop build.
일반 컨테이너 스위트는 DockerAvailability 로 skip 하고, 인증 레인만 가드 없이 실패한다. 대신 test 태그에서 빼서 노트북 빌드를 깨지 않는다. 두 규칙이 충돌하지 않게 배치되어 있다.
7. 트랜잭션·동시성·수명주기
트랜잭션 없음. 동시성 관련해서 세 가지를 확인했다.
CertifiedEvidence.RECORDED 는 static final 이며 클래스 초기화 시 1회 로드된다(:34). JVM 클래스 초기화 락이 스레드 안전을 보장하고, 반환되는 List 는 Stream.toList() 결과라 불변이다. 테스트가 병렬로 돌아도 안전하다.
BrokerFailureMatrix 는 가변이고 동기화가 없다(LinkedHashMap, :23). 그러나 from(...) 이 매번 새 인스턴스를 만들고 그 안에서만 record(...) 를 호출한 뒤 반환하므로, 실제 사용 패턴에서 공유되는 인스턴스가 없다. CrossBrokerContractSuite 는 필드 하나(:34)를 갖지만 JUnit5 기본 생명주기가 메서드당 인스턴스라 매 테스트가 자기 행렬을 만든다. public BrokerFailureMatrix record(...) 가 노출되어 있어 원리상 외부에서 공유·변형할 수 있으나, 실제 그런 호출부는 0건이다(git grep 상 matrix.record 는 from(...) 내부 1건).
InMemoryMessagingHarness 는 전부 비동기화 컬렉션(ArrayDeque, ArrayList, LinkedHashSet)이고 CompletableFuture.completedFuture(...) 로 즉시 완료한다. 결정론이 목적이므로 옳다 — 실제 스레드 전환이 하나도 없다.
수명주기는 MessagingAdapterHarness 의 두 메서드에 압축되어 있다.
// MessagingAdapterHarness.java:61-72
/** Begins a graceful drain. */
void beginShutdown();
boolean isAcceptingWork();
@Override void close();
stopsAcceptingNewWorkDuringShutdown 이 beginShutdown() 후 isAcceptingWork()==false 와 발행 로컬 거절 둘 다를 요구한다. 플래그만 바꾸고 발행을 계속 받는 구현은 통과하지 못한다.
8. 설정·기능 플래그·환경 차이
이 리프 자체에는 설정이 없다. 환경 차이는 전부 소비 측 Gradle 에 있다.
| 스위치 | 위치 | 기본값 | 의미 |
|---|---|---|---|
-PmessagingKafkaImage |
messaging-kafka/build.gradle | apache/kafka:4.1.0 |
인증 대상 브로커 이미지 |
-PmessagingToxiproxyImage |
〃 | ghcr.io/shopify/toxiproxy:2.12.0 |
결함 주입 프록시 |
messaging.certification.manifest |
〃 (systemProperty) | build/messaging-certification/…jsonl |
레인이 쓸 경로 |
messaging.certification.commit |
〃 | -PcertificationCommit → GITHUB_SHA → local |
증거에 박히는 커밋 |
태그 messaging-certification |
〃 | test 에서 제외 |
노트북 빌드 보호 |
이미지를 고정한 이유가 주석에 있다: "A certification claim names the build it was made against, so a floating tag would make a red run unattributable and a green one unrepeatable." 그리고 observedAt·gitCommit 을 게이트 비교에서 제외하는 이유도 함께 있다. 커밋을 -P 가 아니라 환경변수로 읽는 이유까지 적혀 있다("so the CI job's command line stays the literal grammar the gate matrix lint accepts").
CertifiedEvidenceTest.everyClaimNamesTheImageItRanAgainst 가 :latest 를 금지한다 — "a floating tag makes a certification unrepeatable".
9. 퍼시스턴스/외부 시스템 세부
DB 없음. 외부 시스템 접점은 두 개다.
클래스패스 리소스 1개. /messaging/broker-certification-evidence.jsonl. 현재 4줄이며 전부 messaging-kafka 다(EVD-300).
| adapter | scenario | outcome | testId |
|---|---|---|---|
| messaging-kafka | confirm-timeout | AMBIGUOUS | KafkaBrokerCertificationIT#aConfirmThatNeverArrivesIsReportedAsAmbiguousAndTheRecordIsThere |
| messaging-kafka | connection-cut-after-write | AMBIGUOUS | KafkaBrokerCertificationIT#aConnectionCutAfterTheWriteIsReportedAsAmbiguous |
| messaging-kafka | high-latency | AMBIGUOUS | KafkaBrokerCertificationIT#latencyBeyondThePublishDeadlineIsReportedAsAmbiguous |
| messaging-kafka | settlement-lost | REDELIVERED | KafkaBrokerCertificationIT#aDeliveryLostBeforeSettlementIsRedelivered |
브로커 이미지는 4줄 모두 apache/kafka:4.1.0, gitCommit 은 4줄 모두 e98b56eb….
즉 모집단 5 시나리오 × 5 어댑터 = 25 칸 중 4칸에 증거가 있다. connection-refused 는 어느 어댑터에도 증거가 없고, messaging-rabbit 은 5칸 전부 비어 있다.
Docker 데몬. DockerAvailability 가 리플렉션으로만 접촉한다.
10. 테스트 레인과 실제 증명 범위
EVD-301: ./gradlew :messaging:messaging-testkit:test --rerun-tasks → 44 tests, 0 failures, 0 errors, 0 skipped.
| 클래스 | 수 | 증명 대상 |
|---|---|---|
CompatibilityMatrixTest |
11 | 등급 규칙, 계약 크기 잠금, 미등록 어댑터 거절 |
CrossBrokerContractSuite |
10 | 증거→커버리지 변환, 기대 불일치 거절, 시나리오 불변식 |
CertifiedEvidenceTest |
8 | 매니페스트 원본성, 이미지/테스트ID 형식, gap 명명, 직렬화 왕복·거절 |
MessagingDocumentationContractTest |
8 | docs/messaging/*.md 9개 존재·내용·등급 일치 |
InMemoryHarnessContractTest$Contract |
7 | 공유 계약 7개 |
이 레인이 증명하는 것과 증명하지 않는 것의 경계가 이 리프의 핵심이다.
증명한다: 매니페스트를 읽는 코드가 옳다. 등급이 매니페스트에서 파생된다. 문서가 등급과 일치한다. 계약이 7개다. 계약 7개가 결정론적 하니스에서 통과한다.
증명하지 않는다: 매니페스트에 든 4줄이 진짜 실행에서 나왔다는 것. 그것은 messaging-kafka:verifyMessagingCertificationEvidence 만 증명하고, 그 레인은 test 에서 제외되어 있으며 Docker 를 요구한다. 이 세션에서 실행하지 않았다(§16).
MessagingDocumentationContractTest 의 자기 제한이 좋다.
// MessagingDocumentationContractTest.java:18-20
* <p>The assertions are deliberately narrow: they check the claims a reader would act on, not
* prose. Asserting on wording would make every edit a test failure and the check would be deleted.
문서 검사가 삭제당하지 않도록 검사 범위를 스스로 좁혔다. 그리고 noEnumConstantTheDocsDenyActuallyExists 는 방향이 반대다 — 문서가 "없다"고 한 것(EXACTLY_ONCE, GLOBAL)이 실제로 enum 에 없는지를 확인한다. 문서의 부정 주장을 코드로 검증하는 것은 드문 패턴이다.
11. 빌드/ArchUnit/CI 강제 지점
jmh 소스셋이 루트에서 정확히 3개 리프에만 부여된다.
// src/build.gradle:500-508
// The three messaging leaves that carry JMH benchmarks get a `jmh` source set. It is a source
// set rather than a plugin because the benchmarks are compiled and reviewed on every build but
// only *run* on demand: a benchmark that stops compiling is a defect, while a benchmark that
// runs in CI is a flaky test measuring the build agent.
if (project.path in [':messaging:messaging-kafka',
':messaging:messaging-rabbit',
':messaging:messaging-testkit']) {
그 아래에서 spotbugsJmh 와 checkstyleJmh 를 끈다.
// src/build.gradle:528-535
// JMH's annotation processor emits the generated harness into this source set, and its
// generated code trips DLS_DEAD_LOCAL_STORE by design (the dead stores are how it defeats
// dead-code elimination). … The benchmarks themselves are still compiled, which is what
// catches a real breakage.
tasks.named('spotbugsJmh') { enabled = false }
tasks.named('checkstyleJmh') { enabled = false }
마지막 문장("still compiled")이 참인지가 갈림길이다. 그 두 태스크가 check → compileJmhJava 로 가는 유일한 경로이기 때문이다. 실측했다(EVD-298):
./gradlew :messaging:messaging-testkit:build --dry-run
:messaging:messaging-testkit:compileJmhJava SKIPPED
:messaging:messaging-testkit:jmhClasses SKIPPED
:messaging:messaging-testkit:checkstyleJmh SKIPPED
:messaging:messaging-testkit:spotbugsJmh SKIPPED
참이다. Gradle 의 enabled = false 는 태스크 액션만 건너뛰고 의존성 그래프는 유지하므로, 꺼진 checkstyleJmh/spotbugsJmh 가 여전히 compileJmhJava 를 끌고 들어온다. 벤치마크는 매 빌드에서 컴파일되고 실행만 온디맨드다. 반직관적이라 증거로 남겼다.
compileJmhJava 에서 ErrorProne 을 끄고 -Werror 를 제거하는 이유도 명시적이다: "ErrorProne's -Werror would reject JMH's generated sources, which the platform does not own and cannot fix."
jmh 태스크는 JavaExec 로 org.openjdk.jmh.Main 을 부른다(:536-541).
EnvelopeCodecBenchmark 가 무엇을 재는지에 대한 판단도 적혀 있다.
// EnvelopeCodecBenchmark.java:31-41
/**
* Measures the per-message cost the platform adds before any broker is involved.
*
* <p>This is the number the platform is accountable for. Broker latency dominates any real publish
* and varies with the network, so measuring it would tell you about the test environment; …
*
* <p>Header validation is benchmarked separately from envelope construction because they scale
* differently: construction is constant, while validation is linear in the header count …
*/
4개 벤치마크: generateMessageId(UuidV7), validateFewHeaders(3개), validateManyHeaders(32개), buildEnvelope. 헤더 맵을 @Setup 에서 미리 만들어 "맵 생성이 아니라 검증을 잰다"는 것을 보장한다.
12. 실제 사용 여부와 negative-space probes
12.1 Public surface reachability
방법 주의. 참조 계수는 단어 검색이 아니라 import dev.caskeleton.messaging.testkit 및 타입별 git grep 으로 셌다. 이 리프의 타입 이름(CompatibilityMatrix, BrokerFailureMatrix 등)은 저장소 내 동명 클래스가 없어 충돌은 없었으나, isComplete/reset 같은 메서드 이름은 충돌이 심하다 — git grep "isComplete" 는 10건을 내지만 9건이 fileserver/websocket 의 무관한 클래스다(EVD-299, EVD-300). 메서드 단위 판정은 전부 소유 타입을 확인한 뒤 세었다.
| 타입/멤버 | leaf 밖 참조 | 판정 |
|---|---|---|
MessagingAdapterContract |
2 (kafka, rabbit extends) |
사용됨 |
MessagingAdapterHarness |
2 (implements) |
사용됨 |
ContractMessage / ContractAssertions / ObservedDelivery / HandleOutcome |
2씩 | 사용됨 |
DockerAvailability |
4 모듈 | 사용됨 |
NetworkFaultScenario, BrokerCertificationEvidence |
1 (kafka 인증 IT) | 사용됨 |
CertifiedEvidence |
1 | 사용됨 |
CompatibilityMatrix |
0 (leaf 내부 테스트만) | leaf-local |
BrokerFailureMatrix |
0 (leaf 내부 테스트만) | leaf-local |
FaultController.rejectPublish() |
호출 0건 | §17 P2 |
FaultController.reset() |
호출 0건 | §17 P2 |
BrokerFailureMatrix.adapters() |
호출 0건 | §17 P3 |
BrokerFailureMatrix.isComplete(...) |
호출 1건, Experimental 에만 | §12.4 |
CompatibilityMatrix/BrokerFailureMatrix 가 leaf 밖 참조 0인 것은 결함이 아니다. 이 둘의 소비자는 문서와 릴리스 판정이고, 그 판정은 이 리프의 테스트에서 이뤄지도록 설계되어 있다.
FaultController 의 두 미사용 메서드는 다르다(EVD-299).
dropPublishConfirmation() : 호출 1건 (MessagingAdapterContract:40)
dropSettlementConfirmation(): 호출 1건 (MessagingAdapterContract:53)
failDeadLetterPublish() : 호출 1건 (MessagingAdapterContract:104)
rejectPublish() : 호출 0건
reset() : 호출 0건
인터페이스 5개 중 3개만 계약이 쓴다. 나머지 2개는 구현이 3벌 강제되면서 아무도 부르지 않는다.
12.2 Conditional sibling comparison
같은 저장소에 "증거 기반 등급" 을 하는 형제가 하나 더 있다.
src/adapter/outbound/persistence-mongo/src/test/java/.../performance/MongoReleaseEvidenceTest.java
MongoFailoverScenario.all().forEach(scenario -> gate.record(scenario, true));
Mongo 쪽은 gate.record(scenario, true) 를 테스트가 직접 호출한다 — 즉 테스트가 증거를 선언한다. messaging 쪽은 매니페스트 파일이 증거를 나르고 테스트는 읽기만 한다. CertifiedEvidence 의 javadoc 이 고쳤다고 말하는 바로 그 형태가 Mongo 쪽에는 아직 남아 있다. 이는 이 리프의 결함이 아니라 같은 교훈이 아직 전파되지 않은 곳이며, family 문서에서 다룰 대비다.
messaging 내부에서 DockerAvailability 를 쓰는 4개 모듈과 인증 레인의 관계도 대비된다: 전자는 없으면 skip, 후자는 가드 없이 실패 — §6 에 근거 인용.
12.3 Duplicate mechanism sweep
(a) Faults 내부클래스 3중복 — 바이트 동일. (EVD-299)
KafkaContractHarness.java : 57줄 sha256[0:16]=3028b4591144fe86
RabbitContractHarness.java : 57줄 sha256[0:16]=3028b4591144fe86
InMemoryMessagingHarness.java: 57줄 sha256[0:16]=3028b4591144fe86
diff kafka vs rabbit -> IDENTICAL
diff kafka vs inmemory -> IDENTICAL
private static final class Faults implements FaultController 57줄이 3개 모듈에 완전히 동일하게 존재한다. 총 171줄. 4개 불리언 필드 + 5개 오버라이드 + 4개 consume/query 메서드. 이 리프의 src/main 에 DefaultFaultController 하나만 두면 3벌이 1벌이 된다. 세 하니스가 faults 필드 타입만 공유하면 되므로 API 변경도 필요 없다.
(b) 1 MiB 한도 리터럴 8중복. PayloadPolicy.DEFAULT_MAX_BYTES = 1_048_576 이 있는데도 같은 값이 리터럴로 다시 선언된다.
messaging-policy/PayloadPolicy.java:17 DEFAULT_MAX_BYTES = 1_048_576 <- 정본
messaging-schema-api/RawBytesMessageCodec.java:21 DEFAULT_MAX_BYTES = 1_048_576
messaging-schema-json/JacksonMessageCodec.java:42 DEFAULT_MAX_BYTES = 1_048_576
messaging-schema-avro/AvroMessageCodec.java:46 DEFAULT_MAX_BYTES = 1_048_576
messaging-schema-protobuf/ProtobufMessageCodec.java:35 DEFAULT_MAX_BYTES = 1_048_576
messaging-claim-check/…RetentionValidatorTest.java:47 PORTABLE_PAYLOAD_LIMIT_BYTES
messaging-rabbit/RabbitContractHarness.java:40 MAX_PAYLOAD_BYTES
messaging-testkit/InMemoryMessagingHarness.java:31 MAX_PAYLOAD_BYTES <- 이 리프
messaging-testkit/ContractMessage.java:50 new byte[1_048_577] <- 이 리프
messaging-spring-boot-starter/DestinationSettings.java:175 @DefaultValue("1048576")
messaging-testkit 은 api project(':messaging:messaging-policy') 를 선언하고 있으므로 PayloadPolicy.DEFAULT_MAX_BYTES 를 그냥 참조할 수 있다. §12.4 의 "policy 미사용" 과 합치면, 유일하게 policy 를 써야 할 자리에서 쓰지 않고 있는 셈이다.
(c) hasLiveBrokerCertification 대조 테스트의 항등식.
// CompatibilityMatrixTest.java:48-60 aCertificationClaimCannotBeMadeWithoutEvidence
assertThat(entry.hasLiveBrokerCertification())
.isEqualTo(BrokerFailureMatrix.from(CertifiedEvidence.recorded()).hasLiveBrokerCoverage(entry.adapter()));
Entry.hasLiveBrokerCertification() 의 본문이 정확히 우변과 같다(CompatibilityMatrix.java:60-62). 이 단언은 항상 참인 항등식이며, 어떤 회귀도 잡지 못한다. 같은 파일의 everyStableAdapterIsCertifiedAgainstALiveBroker(:97-109)와 noExperimentalAdapterClaimsLiveBrokerCertification(:111-116)이 실질 검사를 하고 있어 커버리지 손실은 없지만, 이름이 약속하는 것("증거 없이 인증 주장 불가")을 이 테스트 자체는 검사하지 않는다.
12.4 Documentation / measured-count drift
(a) BrokerFailureMatrix 클래스 javadoc 이 강제되지 않는 규칙을 선언한다. (EVD-300)
// BrokerFailureMatrix.java:18-20
* <p>A Stable adapter must cover every scenario. That rule is enforced by a test rather than
* documented, because a promotion to Stable is exactly the moment the gap would otherwise be
* overlooked.
측정:
git grep -n "isComplete" -- src (messaging-testkit 범위)
BrokerFailureMatrix.java:95 public boolean isComplete(String adapter) {
CrossBrokerContractSuite.java:110 assertThat(matrix.isComplete("messaging-pulsar-experimental"))
isComplete 의 호출부는 1곳이고 그것은 Experimental 어댑터가 불완전함을 단언한다. Stable 어댑터에 isComplete 를 거는 테스트는 없다.
그리고 실제로 Stable 인 messaging-kafka 는 gap 을 가진 채 통과한다 — 그 사실이 같은 모듈에서 명시적으로 단언되어 있다.
// CertifiedEvidenceTest.java:52-55
assertThat(CertifiedEvidence.knownGaps("messaging-kafka"))
.as("a Kafka producer buffers before it learns a connection exists, so this stays unproven")
.contains(NetworkFaultScenario.CONNECTION_REFUSED);
코드는 "정직한 gap 열거"로 바뀌었고 그 결정이 테스트 본문 주석에 남아 있다.
// CrossBrokerContractSuite.java:44-47
void everyStableAdapterCoversEveryFaultScenario() {
// The gaps are named rather than asserted empty. A Stable adapter with unrun scenarios is the
// current, honest state; asserting emptiness here would only reinstate the self-declaration.
바뀌지 않은 것은 두 가지다: BrokerFailureMatrix 의 클래스 javadoc 과, 저 테스트 메서드 이름(everyStableAdapterCoversEveryFaultScenario — 본문은 covers 를 검사하지 않는다). 이 리프의 나머지 javadoc 들이 자기 이력을 정확히 갱신해 온 것과 대비되어 눈에 띈다.
(b) 선언된 project 의존 4개 중 2개가 import 0건.
messaging-core-api -> 사용 O
messaging-schema-api -> 사용 O (EncodedMessage)
messaging-policy -> import 0건
messaging-transport-spi -> import 0건
messaging-spring-cloud-stream-bridge, messaging-kafka-share-experimental 에서 이미 본 것과 같은 형태다. 다만 여기는 §12.3(b) 때문에 성격이 다르다 — policy 를 안 쓰는 게 아니라 써야 하는데 리터럴로 우회하고 있다.
(c) 증거의 커밋이 현재 트리가 아니다.
현재 HEAD : 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
매니페스트의 gitCommit : e98b56eb03ecab588c21fd1e7dbcaa493c1d8645 (히스토리에 존재)
이는 결함이 아니다. 게이트가 gitCommit/observedAt 을 비교에서 제외하는 것이 명시적 설계이며 그 이유가 주석에 있다. 다만 BrokerCertificationEvidence javadoc 이 "the commit are here because 'certified' is a claim about … a specific source tree; without them the evidence cannot be checked against anything later" 라고 쓴 것에 비해, 실제로 그 필드를 읽어서 무언가를 판정하는 코드는 없다. 기록은 되고 활용은 되지 않는다.
(d) 지원 문서 9개 존재·내용 검사는 통과. MessagingDocumentationContractTest 8건 전부 통과(EVD-301). 단, 이 검사는 docs/messaging/support-matrix.md 의 등급 표기만 본다. 같은 문서 23줄의 runtime_memberships 관련 서술 드리프트는 이 검사의 사정권 밖이며 analysis/19-messaging-platform.md 에서 다룬다.
13. Git/설계 문서에서 확인한 변화와 실패 기록
a24ece9c feat: web, websocket 어댑터 추가 구현
01372634 refactor: 각 어댑터터별 리펙토링 진행
2f5d2fc2 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가
d646c2f1 feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가
커밋 메시지는 정보가 거의 없다. 그러나 이 리프는 코드 주석이 커밋 로그를 대신하는 드문 사례다. 세 개의 javadoc 이 각각 "무엇이 틀렸었고 왜 지금 형태인가" 를 남겼다.
| 위치 | 기록된 과거 결함 |
|---|---|
BrokerCertificationEvidence.java:11-15 |
"Coverage used to be a constant: every scenario marked live-broker for both brokers, with the suite that 'verified' the matrix asserting the contents of that same map." |
CertifiedEvidence.java:12-16 |
"It was the list: an author added a line and the compatibility matrix, the cross-broker suite and the support matrix all believed it, on a machine that may never have started a container." |
CompatibilityMatrix.Entry.hasLiveBrokerCertification javadoc :52-57 |
"As a field it was a boolean an author set next to the tier, and RabbitMQ carried true while no fault scenario had ever been executed against it." |
BrokerFailureMatrix.from javadoc :111-114 |
"A hard-coded matrix marked every scenario as live-broker coverage for both brokers, and the suite that checked it asserted the same constants, so the whole claim passed on a machine with no Docker." |
CompatibilityMatrix ENTRIES 주석 :80-84 |
"the matrix and the support document agreed with each other and with nothing that had executed." |
CrossBrokerContractSuite 필드 javadoc :22-32 |
"A hard-coded map … would leave the assertions below comparing that map to itself" |
여섯 곳이 같은 결함의 여섯 얼굴이다: 자기 자신을 검증하는 상수. 그리고 여섯 곳 모두 지금은 매니페스트를 가리킨다.
messaging-rabbit 이 Stable 에서 Experimental 로 강등된 흔적도 남아 있다: CompatibilityMatrixTest.theStableSetIsExactlyWhatALaneHasCertified 의 .as("RabbitMQ passes the shared contract, but no fault scenario has been run against it"). 강등의 근거가 "계약은 통과하지만 결함 증거가 없다" 로 정확히 적혀 있다.
14. 런타임·터미널 Evidence
| ID | 파일 | 내용 |
|---|---|---|
| EVD-298 | evidence/raw/298-testkit-jmh-compile-graph.txt |
build --dry-run 태스크 그래프 — compileJmhJava 포함 확인, 꺼진 태스크가 의존성을 유지함 |
| EVD-299 | evidence/raw/299-faultcontroller-dead-fault-triplicate.txt |
rejectPublish/reset 호출 0건, Faults 57줄 3중복 sha256 동일 |
| EVD-300 | evidence/raw/300-certification-manifest-and-stable-rule-drift.txt |
매니페스트 4줄 전문, 5시나리오 모집단, isComplete 호출 1건(Experimental 전용), javadoc 드리프트 |
| EVD-301 | evidence/raw/301-messaging-testkit-test-lane.txt |
테스트 레인 44건 전건 통과 |
15. 명시적 설계 이유와 추론을 구분한 정리
코드/주석에 명시된 것
- 지원 행렬을 문서가 아니라 코드로 들고 테스트를 거는 이유 — "a support matrix that lives only in prose drifts the first time an adapter is added" (
CompatibilityMatrix.java:10-13). - 인증 주장을 등급 옆 불리언이 아니라 증거에서 파생하는 이유 — "so an adapter cannot be promoted by editing a boolean" (
:15-16). - 매니페스트를 커밋하는 이유 — 깨끗한 체크아웃에서 답이 달라지면 안 된다 (
CertifiedEvidence.java:17-19). - 빈 매니페스트가 오류가 아닌 이유 / 읽기 실패가 오류인 이유 (
:25-27,:78-79). - JSONL 한 줄 = 한 객체인 이유 — append 가 append 이고 diff 가 diff 이도록 (
BrokerCertificationEvidence.java:22-24). - JSON 라이브러리를 안 쓰는 이유 —
*-api리프가 의존을 안 갖는 플랫폼의 테스트 지원이므로 (:83-86). - 기대 결과를 시나리오가 소유하는 이유 — 어댑터별 기대치는 불일치를 통과시킨다 (
NetworkFaultScenario.java:10-13). - 틀린 증거를 거절하는 이유 — 틀린 그 행동에 대해 인증하게 된다 (
BrokerFailureMatrix.java:131-135). - 인메모리 하니스가 프로덕션이 되면 안 되는 이유 (
InMemoryMessagingHarness.java:24-27). - 문서 검사를 좁게 하는 이유 — 넓으면 삭제당한다 (
MessagingDocumentationContractTest.java:18-20). jmh를 플러그인이 아니라 소스셋으로 두는 이유 — 컴파일은 매 빌드, 실행은 온디맨드 (src/build.gradle:501-504).- 인증 레인에 Docker 가드를 두지 않는 이유 — skip 이 성공으로 보고되면 안 된다 (
messaging-kafka/build.gradle). - 이미지를 고정하는 이유 / 커밋을 환경변수로 읽는 이유 (
messaging-kafka/build.gradlestrictTestLanes 주석). - 계약을 abstract class 로 두는 이유 — 어댑터가 조용히 빼지 못하도록 (
InMemoryHarnessContractTest.java:8-10). - 고정 시험 데이터의 이유 — 어댑터 간 바이트 비교 (
ContractMessage.java:19-23). DockerAvailability가드의 이유 — 없는 Docker 가 제품 결함처럼 보이면 안 된다 (DockerAvailability.java:5-9).
추론 (근거는 있으나 문서에 없음)
- 여섯 개 의존이 전부
api인 것은 계약 상속 요건 때문이다 —@Test, AssertJ,PublishResult,EncodedMessage가 전부 공개 시그니처에 노출된다. 주석은 없다. CompatibilityMatrix/BrokerFailureMatrix의 leaf 밖 참조 0은 설계된 경계로 보인다(소비자가 문서 검사와 릴리스 판정이므로). 명시된 문장은 없다.messaging-policy/messaging-transport-spi의존이 남아 있는 이유는 알 수 없다. 이전에 쓰였다가 빠졌을 가능성이 있으나 git 로그 입도가 커밋 4개뿐이라 확인 불가.reset()/rejectPublish()가 계약에 없는 이유는 알 수 없다. 5개 시나리오 중connection-refused(=REJECTED)만 증거가 없다는 사실과rejectPublish가 미사용이라는 사실이 같은 빈칸을 가리키는 것은 우연으로 보이지 않으나, 이를 잇는 문장은 코드에 없다.
16. 확인한 것 / 확인하지 못한 것
확인한 것
src/main13,src/test6,src/jmh1 — 20개 Java 파일 전부 본문 확인.- 테스트 레인 44건 전건 통과 (
EVD-301). build그래프에compileJmhJava포함 (EVD-298).- 매니페스트 4줄 전문과 시나리오 모집단 5개 (
EVD-300). rejectPublish/reset호출 0건,Faults3중복 sha256 동일 (EVD-299).- kafka·rabbit 이 실제로
extends MessagingAdapterContract함 —runsSharedContract선언값과 일치. isComplete호출부 1건이며 Stable 을 검사하지 않음.
확인하지 못한 것
매니페스트 4줄이 실제 실행에서 나왔는지→ 해소됨 (EVD-318).messaging-kafka분석 시./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence를 실행했다. 레인이 실제apache/kafka:4.1.0+ toxiproxy 컨테이너에 대해 돌아 4줄을 생산했고, 게이트가 커밋본과 비교해 통과했다 — adapter·brokerImage·scenario·testId·outcome 이 4줄 모두 일치한다. 커밋된 매니페스트는 이 revision 에서 레인이 실제로 생산하는 것과 같다.게이트가 실제로 도는지→ 해소됨 (EVD-318).verifyMessagingCertificationEvidence가BUILD SUCCESSFUL로 완료되고build/reports/messaging-certification-evidence.txt에scenarios=4를 남겼다.verifyMessagingCertificationEvidence가 실패하는 것은 확인하지 않았다. 손으로 매니페스트에 한 줄을 더하고 레인을 다시 도는 것은 애플리케이션 소스 수정에 해당해 하지 않았다.jmh태스크 실제 실행 결과 — 벤치마크 수치는 이 문서의 관심사가 아니고, 컴파일 여부만 확인했다.messaging-policy/messaging-transport-spi의존이 언제 왜 추가되었는지 — git 이력 입도 부족.- Rabbit 강등이 언제 일어났는지 — 마찬가지.
17. 손볼 것
P2 — FaultController 의 5개 중 2개가 구현만 3벌 있고 호출부가 0건이다
rejectPublish() 와 reset() 은 인터페이스에 선언되어 세 하니스가 전부 구현하지만, 계약 스위트를 포함해 어디에서도 호출되지 않는다(EVD-299). rejectPublish 는 심지어 세 하니스의 publish() 경로에 완전히 배선되어 있다(KafkaContractHarness:119, RabbitContractHarness:85, InMemoryMessagingHarness:66) — 켜는 스위치만 아무도 누르지 않는다.
이것이 단순한 미사용 코드가 아닌 이유: 미사용 경로가 틀린 값을 인코딩하고 있다. InMemoryMessagingHarness 에서 rejectPublish 는 rejected("BROKER_REJECTED", …) 를 돌려주고, 그 헬퍼는 PublishEvidence.notTransmitted() 를 쓴다(:184-193). TransmissionEvidence.NOT_TRANSMITTED 의 javadoc 은 "Nothing was written to the broker connection." 이다. 그런데 FaultController.rejectPublish 의 javadoc 은 "refused outright by the broker" 다 — 브로커가 거절하려면 바이트가 나갔어야 하므로 TRANSMITTED 여야 한다. 이 플랫폼은 전송 증거를 세 값으로 구분하는 것을 핵심 가치로 삼는데, 유일하게 실행되지 않는 경로에 그 구분의 오류가 들어 있다.
connection-refused 시나리오(유일하게 증거가 없는 시나리오, Expectation.REJECTED)와 이 미사용 결함이 같은 빈칸을 가리킨다. 둘 중 하나를 택해야 한다: 계약에 rejectsWhenBrokerRefusesBeforeTransmission 를 추가하고 전송 증거를 바로잡거나, rejectPublish 를 인터페이스에서 제거해 세 하니스의 구현 부담을 없애거나.
reset() 은 별개다. 세 구현 모두 결함 플래그를 one-shot 으로 소비하므로(consumeXxx 가 읽고 즉시 false) 리셋이 필요 없는 구조다. 계약이 테스트마다 새 하니스를 만드는 것도 같은 이유다. 제거 후보다.
P2 — 클래스 javadoc 이 강제되지 않는 규칙을 강제된다고 말한다
BrokerFailureMatrix.java:18-20 이 "A Stable adapter must cover every scenario. That rule is enforced by a test rather than documented" 라고 쓰고 있으나, isComplete 를 Stable 어댑터에 거는 테스트는 없다(EVD-300). 유일한 호출부는 Experimental 어댑터가 불완전함을 단언한다. 실제 Stable 인 messaging-kafka 는 connection-refused gap 을 가진 채 통과하며, 그 gap 은 같은 모듈이 명시적으로 단언한다.
코드 쪽 결정("gap 을 열거하되 비어 있음을 단언하지 않는다")은 옳고, 그 이유도 CrossBrokerContractSuite.java:45-47 에 적혀 있다. 문제는 javadoc 이 갱신되지 않은 것이다. 이 리프의 다른 javadoc 여섯 곳이 자기 이력을 정확히 남긴 것과 대비되어 더 눈에 띈다.
같은 이유로 테스트 메서드 이름 everyStableAdapterCoversEveryFaultScenario 도 본문과 맞지 않는다. everyStableAdaptersGapsAreExactlyWhatTheEvidenceShows 같은 이름이 본문을 정확히 기술한다.
수정 방향: javadoc 을 현재 규칙("Stable 은 live-broker 증거를 하나 이상 요구한다. 전 시나리오 커버리지는 목표이지 게이트가 아니며, gap 은 knownGaps 로 명명된다")으로 바꾸고, 테스트 이름을 본문에 맞춘다.
P3 — Faults 내부클래스 57줄이 3개 모듈에 바이트 단위로 복제되어 있다
sha256 이 세 곳 모두 3028b459… 로 동일하다(EVD-299). 총 171줄. messaging-testkit/src/main 에 DefaultFaultController (또는 RecordingFaultController) 하나를 두고 세 하니스가 그것을 필드로 갖게 하면 된다. MessagingAdapterHarness.faults() 의 반환 타입은 FaultController 그대로이므로 외부 API 변경이 없다.
이 복제가 위험한 이유는 P2 와 겹친다: rejectPublish 의 전송 증거를 고치려면 지금은 세 파일을 고쳐야 하고, 세 파일이 어긋나면 어댑터마다 다른 결함 의미를 갖게 된다 — 이 리프가 존재하는 이유 자체를 무너뜨린다.
P3 — 1 MiB 한도가 PayloadPolicy 를 두고 리터럴로 재선언된다
PayloadPolicy.DEFAULT_MAX_BYTES = 1_048_576 이 정본인데 같은 값이 최소 8곳에 다시 있고(§12.3(b)), 그중 둘이 이 리프다(InMemoryMessagingHarness:31, ContractMessage:50). messaging-testkit 은 api project(':messaging:messaging-policy') 를 이미 선언하고 있으므로 import 한 줄이면 된다. 지금은 messaging-policy 의존이 import 0건이라 선언만 남아 있는데(§12.4(b)), 이 자리가 그 의존이 실제로 쓰여야 할 곳이다.
ContractMessage.oversized() 의 1_048_577 은 PayloadPolicy.DEFAULT_MAX_BYTES + 1 로 쓰면 "한도 바로 위 한 바이트" 라는 의도가 코드에 드러난다.
P3 — messaging-transport-spi 의존이 import 0건이다
policy 와 달리 transport-spi 는 쓸 자리가 보이지 않는다. 제거 후보.
P3 — BrokerFailureMatrix.adapters() 는 호출부가 0건이다
public 메서드이나 아무도 쓰지 않는다. 이 리프의 다른 public 표면은 전부 소비자가 있다. 제거하거나, 진단용이라면 그렇게 적는다.
P3 — 항등식을 단언하는 테스트가 하나 있다
CompatibilityMatrixTest.aCertificationClaimCannotBeMadeWithoutEvidence(:48-60)의 좌변과 우변이 같은 식이다(§12.3(c)). 이름이 약속하는 것을 검사하지 않는다. 실질 검사는 같은 파일의 다른 두 테스트가 하고 있으므로 커버리지 손실은 없다. 이 테스트를 지우거나, "증거를 비우면 Stable 주장이 무너진다" 를 실제로 검사하도록 바꾼다 — 후자가 이름에 맞는다.
P3 — gitCommit 은 기록되지만 읽혀 판정되지 않는다
BrokerCertificationEvidence javadoc 이 "without them the evidence cannot be checked against anything later" 라고 쓰지만, 실제로 gitCommit 을 읽어 무언가를 결정하는 코드는 없고 게이트는 오히려 그 필드를 비교에서 제외한다(§12.4(c)). 현재 매니페스트의 커밋은 HEAD 가 아니다(e98b56eb vs 21234e38). "증거가 얼마나 오래된 트리에서 나왔는가" 를 보고하는 것은 유용한 진단이 될 수 있다 — 게이트로 만들 필요는 없고, knownGaps 처럼 사실로 노출하면 이 리프의 나머지 설계와 결이 맞는다.
확인된 설계(문제 아님)
- 매니페스트를 커밋하고, 게이트가 손편집을 막는 구조. 재현 가능성과 깨끗한 체크아웃 두 요구를 동시에 만족한다.
requireOutcomeMatchesExpectation. "틀린 증거는 무증거보다 나쁘다" 는 판단과 그 근거가 코드·주석·테스트 셋 다에 있다.- 부재는
NOT_COVERED, 읽기 실패는 예외. 조용한 강등을 막는 구분. - 파싱이 누락 필드와 미지 필드를 둘 다 거절한다. 조용한 절단 대신 실패.
- 인증 레인에만 Docker 가드를 두지 않는 결정. skip 이 성공으로 보고되는 것을 막으면서 노트북 빌드를 지킨다.
jmh를 소스셋으로 두어 매 빌드 컴파일 / 온디맨드 실행.EVD-298로 그래프에서 실측 확인했다. 꺼진spotbugsJmh/checkstyleJmh가 여전히compileJmhJava를 끌고 온다.- 계약 크기를 리플렉션으로 잠근 것. 테스트를 조용히 지울 수 없다.
- 문서 검사를 좁게 유지한 것. 삭제당하지 않을 만큼만 검사한다.
noEnumConstantTheDocsDenyActuallyExists. 문서의 부정 주장을 코드로 검증하는 드문 방향.InMemoryMessagingHarness를src/test에 두고 package-private + final 로 막은 것. 물리적으로 리프 밖으로 못 나간다.
Source anchors
src/messaging/messaging-testkit/build.gradle:1-11
src/build.gradle:500-542 (jmh 소스셋 3리프 한정 + 게이트 제외 사유)
src/config/architecture/modules.json (messaging-testkit 항목)
main/…/MessagingAdapterContract.java:10-19,26,28-134
main/…/MessagingAdapterHarness.java:8-15,61-72
main/…/FaultController.java:3-25
main/…/ContractMessage.java:17-26,29,40-51
main/…/ContractAssertions.java:20-50
main/…/ObservedDelivery.java:14-22
main/…/HandleOutcome.java:4-13
main/…/NetworkFaultScenario.java:7-17,52-63,65-112,114-136,143-150
main/…/BrokerCertificationEvidence.java:8-25,35-56,58-114,116-145
main/…/CertifiedEvidence.java:9-27,31-34,43-64,66-83
main/…/BrokerFailureMatrix.java:9-20,21-33,43-51,60-64,72-76,84-87,95-97,104-106,108-127,129-146
main/…/CompatibilityMatrix.java:7-17,22-32,34-75,50-62,77-109,116-132
main/…/DockerAvailability.java:3-34
main/resources/messaging/broker-certification-evidence.jsonl (4줄)
test/…/InMemoryMessagingHarness.java:22-29,31,57-96,98-120,122-153,184-197,199-259
test/…/InMemoryHarnessContractTest.java:5-21
test/…/CompatibilityMatrixTest.java:13-21,23-46,48-60,62-69,71-80,82-95,97-127
test/…/CertifiedEvidenceTest.java:12-18,21-32,34-49,51-59,61-74,76-96,98-112,114-126
test/…/CrossBrokerContractSuite.java:9-19,22-34,36-41,43-54,56-73,75-88,90-106,108-114,116-120,122-145
test/…/MessagingDocumentationContractTest.java:11-20,23-40,42-58,60-89,91-99,101-112,114-133,135-144
jmh/…/EnvelopeCodecBenchmark.java:31-41,43-49,55-69,71-125
src/messaging/messaging-kafka/build.gradle:34-120 (strictTestLanes + verifyMessagingCertificationEvidence)
src/messaging/messaging-kafka/src/test/java/.../KafkaContractHarness.java:69,102,119,312-368
src/messaging/messaging-rabbit/src/test/java/.../RabbitContractHarness.java:38,40,85,246-300
src/messaging/messaging-policy/src/main/java/.../PayloadPolicy.java:17
src/messaging/messaging-core-api/src/main/java/.../publish/TransmissionEvidence.java:4-13
src/messaging/messaging-core-api/src/main/java/.../publish/PublishEvidence.java:29,39-64