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

Follows the import procedure in README.md.

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

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

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

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

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

66 KiB

messaging-testkit 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-testkit SSOT owner: messaging-testkit integration/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 이다" 를 주장하지 않는다. 그 주장은 여기에만 있고, 여기서만 검증된다.

세 개의 층으로 되어 있다.

  1. 공유 계약 (MessagingAdapterContract + MessagingAdapterHarness + ContractMessage/ContractAssertions/ObservedDelivery/HandleOutcome/FaultController) — 브로커가 무엇이든 똑같이 답해야 하는 7가지 행동.
  2. 결함 시나리오와 그 증거 (NetworkFaultScenario + BrokerCertificationEvidence + CertifiedEvidence + BrokerFailureMatrix) — 어떤 장애를 실제로 돌려 봤는가.
  3. 지원 등급 (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.publishPublishResult(core-api)를, ContractMessageEncodedMessage(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 결정 시점에 지연과 유실은 구별 불가

REJECTEDBEFORE_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/ 를 읽으면 깨끗한 체크아웃에서 답이 달라진다는 이유가 명시되어 있다. 확인: srcbuild/resources 사본이 diff 로 동일(EVD-300).

(b) 손으로 못 쓰게 하는 게이트. messaging-kafka/build.gradle:82verifyMessagingCertificationEvidence.

// 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: …"  }

gitCommitobservedAt 을 정규식으로 지우고 나머지 집합을 비교한다. 그 둘은 매 실행마다 달라지므로 비교 대상이 아니라는 주석이 붙어 있다. 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.RECORDEDstatic final 이며 클래스 초기화 시 1회 로드된다(:34). JVM 클래스 초기화 락이 스레드 안전을 보장하고, 반환되는 ListStream.toList() 결과라 불변이다. 테스트가 병렬로 돌아도 안전하다.

BrokerFailureMatrix 는 가변이고 동기화가 없다(LinkedHashMap, :23). 그러나 from(...) 이 매번 새 인스턴스를 만들고 그 안에서만 record(...) 를 호출한 뒤 반환하므로, 실제 사용 패턴에서 공유되는 인스턴스가 없다. CrossBrokerContractSuite 는 필드 하나(:34)를 갖지만 JUnit5 기본 생명주기가 메서드당 인스턴스라 매 테스트가 자기 행렬을 만든다. public BrokerFailureMatrix record(...) 가 노출되어 있어 원리상 외부에서 공유·변형할 수 있으나, 실제 그런 호출부는 0건이다(git grepmatrix.recordfrom(...) 내부 1건).

InMemoryMessagingHarness 는 전부 비동기화 컬렉션(ArrayDeque, ArrayList, LinkedHashSet)이고 CompletableFuture.completedFuture(...) 로 즉시 완료한다. 결정론이 목적이므로 옳다 — 실제 스레드 전환이 하나도 없다.

수명주기는 MessagingAdapterHarness 의 두 메서드에 압축되어 있다.

// MessagingAdapterHarness.java:61-72
/** Begins a graceful drain. */
void beginShutdown();
boolean isAcceptingWork();
@Override void close();

stopsAcceptingNewWorkDuringShutdownbeginShutdown()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 -PcertificationCommitGITHUB_SHAlocal 증거에 박히는 커밋
태그 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-tasks44 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']) {

그 아래에서 spotbugsJmhcheckstyleJmh끈다.

// 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 태스크는 JavaExecorg.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/mainDefaultFaultController 하나만 두면 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-testkitapi 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.gradle strictTestLanes 주석).
  • 계약을 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/main 13, src/test 6, src/jmh 1 — 20개 Java 파일 전부 본문 확인.
  • 테스트 레인 44건 전건 통과 (EVD-301).
  • build 그래프에 compileJmhJava 포함 (EVD-298).
  • 매니페스트 4줄 전문과 시나리오 모집단 5개 (EVD-300).
  • rejectPublish/reset 호출 0건, Faults 3중복 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). verifyMessagingCertificationEvidenceBUILD SUCCESSFUL 로 완료되고 build/reports/messaging-certification-evidence.txtscenarios=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 에서 rejectPublishrejected("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-kafkaconnection-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/mainDefaultFaultController (또는 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-testkitapi project(':messaging:messaging-policy') 를 이미 선언하고 있으므로 import 한 줄이면 된다. 지금은 messaging-policy 의존이 import 0건이라 선언만 남아 있는데(§12.4(b)), 이 자리가 그 의존이 실제로 쓰여야 할 곳이다.

ContractMessage.oversized()1_048_577PayloadPolicy.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. 문서의 부정 주장을 코드로 검증하는 드문 방향.
  • InMemoryMessagingHarnesssrc/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