# 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 이 스스로 선을 긋는다. ```java // InMemoryMessagingHarness.java:22-29 /** * A deterministic harness that exists only to prove the contract suite itself is sound. * *
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. 의존성과 런타임 배선
```groovy
// 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 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 에 있다.
```java
// InMemoryHarnessContractTest.java:5-11
/**
* Runs the shared adapter contract against the in-memory harness.
*
* 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개가 침묵으로 줄어드는 것을 막는 자물쇠가 하나 더 있다.
```java
// CompatibilityMatrixTest.java:13-21, 71-80
private static final List 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.
*
* The distinction across these scenarios is what evidence survives. …
*/
```
5개 시나리오, 그리고 각각이 `rationale` 을 **비어 있으면 생성 자체가 실패하도록** 강제한다.
```java
// 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` 은 알 수 없는 이름을 건너뛰지 않고 거절한다.
```java
// 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 이 **자기가 고친 결함을 이름 붙여** 남겼다.
```java
// CertifiedEvidence.java:9-27
/**
* The fault scenarios this repository's certification lane has actually exercised.
*
* 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.
*
* 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. …
*
* 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`.
```groovy
// messaging-kafka/build.gradle (doLast 발췌)
Closure 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 테스트 이름이라는 점이 좋다.
```java
// 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` — 파생된 인증, 선언된 나머지
```java
// CompatibilityMatrix.java:50-62
/**
* Reports whether a lane has run this adapter against a real broker.
*
* 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` 맵의 주석 두 개가 자기 이력을 남긴다.
```java
// 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` — 고정 시험 데이터
```java
// ContractMessage.java:17-26
/**
* 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. 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 은 성공이 아니다"** 라는 반대 규칙이 인증 레인에는 적용되어 있다.
```groovy
// 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` 의 두 메서드에 압축되어 있다.
```java
// 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` 의 자기 제한이 좋다.
```java
// MessagingDocumentationContractTest.java:18-20
* 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개 리프에만 부여된다.
```groovy
// 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` 를 **끈다**.
```groovy
// 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` 가 무엇을 재는지에 대한 판단도 적혀 있다.
```java
// EnvelopeCodecBenchmark.java:31-41
/**
* Measures the per-message cost the platform adds before any broker is involved.
*
* 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; …
*
* 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` 대조 테스트의 항등식.**
```java
// 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`)
```java
// BrokerFailureMatrix.java:18-20
* 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 을 가진 채 통과한다 — 그 사실이 같은 모듈에서 **명시적으로 단언되어 있다**.
```java
// 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 열거"로 바뀌었고 그 결정이 테스트 본문 주석에 남아 있다.
```java
// 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`).** `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
```