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>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,635 @@
# messaging-spring-cloud-stream-bridge 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-spring-cloud-stream-bridge`
> SSOT owner: `messaging-spring-cloud-stream-bridge`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-spring-cloud-stream-bridge`
- canonical state `analysisFile`: `analysis/messaging/messaging-spring-cloud-stream-bridge.md`
- source path: `src/messaging/messaging-spring-cloud-stream-bridge`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-policy", "messaging-transport-spi"]`
- registry `runtime_memberships`: **`[]`** — build-only
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 6 |
| production LOC | 507 |
| 패키지 | 1 (`dev.caskeleton.messaging.streambridge`) |
| test 파일 | 2 |
| test 메서드(실행 확인) | **20** |
| 선언된 의존 | project 3 + vendor 1 |
| **실제 import되는 의존** | **project 2** (§12.4) |
여섯 타입:
| 타입 | 종류 | 역할 | leaf 밖 참조 |
|---|---|---|---:|
| `MessagingBindingBridge` | interface | 논리 목적지 ↔ Stream 바인딩 | 0 |
| `SpringCloudStreamPublisherBridge` | class | 발행 측 + 위 인터페이스 구현 | 0 |
| `SpringCloudStreamConsumerBridge` | class | 수신 측 | 0 |
| `StreamBridgePolicyGuard` | class | 목적지가 브리지 대상인가 | 0 |
| `BindingProfileValidator` | class | 바인딩 구성이 일관적인가 | 0 |
| `BindingCapabilityReport` | record | 무엇을 보장하지 **않는가** | 0 |
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |
| `src/test/java/**` (2) | 2 | `FULL_READ` | 20개 테스트명·단언 확인 |
| `build.gradle` | 1 | `FULL_READ` | 9줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
Spring Cloud Stream 바인딩을 이미 쓰는 서비스가 같은 논리 목적지에 닿게 하는 **상호운용 seam**이다.
```java
// MessagingBindingBridge.java:8-15
* <p>The bridge is an interoperability seam, not a second messaging API. Its whole reason to exist
* is that a service already has Stream bindings and needs to reach the same destinations without a
* rewrite.
*
* <p>Binder semantics are never promoted to platform guarantees. Stream's binder has its own retry,
* its own dead-letter, and its own acknowledgement mode, and they look enough like the platform's
* to be mistaken for them so a destination that actually relies on the platform's versions is
* refused by {@link StreamBridgePolicyGuard} rather than served with the binder's.
```
**"look enough like the platform's to be mistaken for them"**이 이 leaf 전체의 위협 모델이다. 브리지는 기능을 추가하지 않고 **차이를 드러낸다.**
세 층으로 그것을 한다.
| 층 | 무엇을 |
|---|---|
| `StreamBridgePolicyGuard` | 플랫폼 보장에 의존하는 목적지를 아예 거절 |
| `BindingProfileValidator` | 바인더 확장 속성이 프로파일 결정을 덮는 것을 거절 |
| `BindingCapabilityReport` | 남은 차이를 **문장으로** 기록 |
세 번째가 특이하다 — 거절할 수 없는 차이를 문서화 가능한 값으로 만든다.
---
## 2. 의존성과 런타임 배선
**선언된 것과 쓰이는 것이 다르다.**
| 선언 | scope | 실제 import |
|---|---|---|
| `messaging-core-api` | api | **o**`DestinationName`, `MessagingConfigurationException`, publish 6타입 |
| `messaging-policy` | api | **o**`DestinationProfile`, `RetryMode` |
| `messaging-transport-spi` | api | **x** |
| `org.springframework:spring-context` | implementation | **x** |
`grep -rn 'import dev.caskeleton.messaging.transport\|import org.springframework'` → exit 1.
**Spring Cloud Stream 브리지가 Spring을 import하지 않는다.** 바인더 접촉면 전체가 두 함수형 인터페이스로 추상화돼 있다 — `SpringCloudStreamPublisherBridge.ChannelSend``SpringCloudStreamConsumerBridge.BridgedHandler`. javadoc이 그 목적을 적는다 — "isolated so the bridge is testable without a binder".
**`spring-context` 의존은 실제 통합 코드가 있어야 필요했을 것**인데 그 코드가 없다. §12.4.
나가는 것: 없다. 어떤 leaf의 `allowed_dependencies`에도 이 leaf가 없고 starter 목록에도 없다.
런타임 배선: 없음. `runtime_memberships: []`. bean 없음.
**소비자 0 · membership `[]` · 조립 0의 삼중 정합**`messaging-kafka-share-experimental`·`messaging-schema-avro`와 같은 상태다.
---
## 3. 패키지/컴포넌트 지도
```
게이트 (2단)
StreamBridgePolicyGuard.validate(profile, enabled)
├── !enabled → STREAM_BRIDGE_DISABLED
├── isOrdered() → STREAM_BRIDGE_ORDERING_UNSUPPORTED
├── retry != NONE → STREAM_BRIDGE_RETRY_UNSUPPORTED
└── deadLetter on → STREAM_BRIDGE_DLQ_UNSUPPORTED
↓ (통과 후)
BindingProfileValidator.validate(profile, bindingName, extendedProperties, enabled)
├── guard.validate(...) ← 위임
├── 바인딩 이름 패턴 → INVALID_BINDING_NAME
├── 충돌 확장 속성 8개 → BINDING_OVERRIDES_PLATFORM_POLICY
├── profile.production() → BRIDGE_ON_PRODUCTION_DESTINATION
└── → BindingCapabilityReport.bridged(...) ← 네 보장 전부 false
발행
SpringCloudStreamPublisherBridge(ChannelSend) implements MessagingBindingBridge
├── bindPublisher / bindConsumer ← 두 맵
└── publish(dest, payload, headers)
├── 바인딩 없음 → NO_OUTPUT_BINDING
├── send == true → AMBIGUOUS (STREAM_BRIDGE_NO_BROKER_EVIDENCE)
└── send == false → REJECTED (STREAM_BRIDGE_SEND_REFUSED)
수신
SpringCloudStreamConsumerBridge ← MessagingBindingBridge를 구현하지 않음
├── register(dest, binding, BridgedHandler)
└── dispatch(binding, payload, headers)
├── 미등록 → NO_BRIDGED_HANDLER
└── handler.handle(...) ← 예외를 잡지 않음
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `StreamBridgePolicyGuard` — 의존하는 순간 거절
```java
// :10-17
* <p>The bridge exists for interoperability with existing Spring Cloud Stream bindings, and its
* risk is specific: Stream owns its own binder configuration, so a binding can quietly acquire its
* own serializer, its own error handling, and its own acknowledgement mode none of which the
* destination profile knows about.
*
* <p>So the bridge is only permitted where the platform's guarantees are not the thing being relied
* on: a destination that declares an ordering scope, a retry policy, or a dead letter destination
* must go through the native adapter, where those are actually enforced.
```
**세 거절이 `DestinationProfile`의 세 필드를 직접 본다.**
| 조건 | 코드 |
|---|---|
| `profile.isOrdered()``orderingScope != NONE` | `STREAM_BRIDGE_ORDERING_UNSUPPORTED` |
| `profile.retry().mode() != RetryMode.NONE` | `STREAM_BRIDGE_RETRY_UNSUPPORTED` |
| `profile.deadLetter().enabled()` | `STREAM_BRIDGE_DLQ_UNSUPPORTED` |
**`messaging-policy`가 정의한 세 보장 각각에 대해 "이것을 선언했으면 브리지를 쓸 수 없다"**를 강제한다. 세 코드 전부 `MessagingConfigurationException`이고 안정 코드를 갖는다 — `messaging-kafka-share-experimental`이 두 거절에 다른 예외 타입을 쓴 것(그쪽 §17)과 대비된다.
`!enabled`도 같은 예외 타입이다 — 일관적이다.
### 4.2 `BindingProfileValidator` — 확장 속성을 병합하지 않는다
```java
// :16-20
* <p>The binder's extended properties are the sharp edge. Stream lets a binding override the
* serializer, the acknowledgement mode, and the concurrency, and each of those silently replaces
* something the destination profile already decided. Rather than merging the two which produces a
* configuration nobody can read a conflicting extended property is rejected and the operator is
* told which side to remove.
```
거절 목록 8개:
| 속성 | 무엇을 덮는가 |
|---|---|
| `autoBindDlq`, `republishToDlq` | DLQ 정책 |
| `maxAttempts`, `backOffInitialInterval` | 재시도 정책 |
| `autoCommitOffset`, `ackMode` | 정산 |
| `useNativeEncoding`, `contentType` | codec |
에러 메시지가 **두 선택지를 명시한다** — "remove it from the binding or move the destination to the native adapter". 무엇을 하라고만 하지 않고 어느 쪽을 포기할지를 준다.
**production 목적지는 무조건 거절한다.**
```java
if (profile.production()) {
throw new MessagingConfigurationException(
"BRIDGE_ON_PRODUCTION_DESTINATION",
"destination %s is marked production; the bridge does not carry the platform's publish "
+ "evidence, retry, or confirmed dead lettering");
}
```
guard의 세 조건을 통과한 목적지(순서 없음·재시도 없음·DLQ 없음)라도 production이면 막는다. **네 번째 게이트**다.
바인딩 이름 패턴 `[a-zA-Z][a-zA-Z0-9-]{0,63}` — 언더스코어와 점을 배제한다.
### 4.3 `BindingCapabilityReport` — 부재를 값으로
```java
// :8-14
* <p>An explicit report rather than silence. The binder does provide retry and dead-lettering of
* its own, so a binding looks like it has them; what it does not have is the platform's versions
* bounded attempts under the destination's retry policy, and a dead-letter publish confirmed before
* the source is settled. An operator comparing a bridged binding to a native one needs that
* difference written down, because nothing at runtime will show it.
```
**"nothing at runtime will show it"**이 이 record가 존재하는 이유다.
네 boolean과 두 factory:
| factory | 네 값 |
|---|---|
| `bridged(destination, bindingName)` | 전부 `false` |
| `nativeAdapter(destination, bindingName)` (`BindingProfileValidator`의 static) | 전부 `true` |
`gaps()`가 각 `false`마다 **문장 하나**를 만든다.
| 결여 | 문장 |
|---|---|
| publish evidence | "the binder reports a send, not a broker confirmation, so an ambiguous publish is indistinguishable from a confirmed one" |
| retry | "the binder's own retry runs instead of the destination's retry policy, with its own attempt budget and backoff" |
| dead letter | "the binder settles the source without waiting for the dead-letter publish to confirm, so a dead-letter outage loses the message" |
| ordering | "the binder's concurrency settings decide ordering, not the profile" |
**각 문장이 결과까지 적는다** — "indistinguishable", "loses the message". 상태 플래그가 아니라 운영자가 읽는 진술이다.
`isFullyGuaranteed()``gaps().isEmpty()`다 — 매 호출마다 네 문장을 다시 만든다. 성능 문제는 아니지만 순수 조회가 문자열을 할당한다.
### 4.4 `SpringCloudStreamPublisherBridge` — 가장 정직한 결과
```java
// :20-27
* <p>The result is deliberately {@code AMBIGUOUS} rather than {@code CONFIRMED}. A Stream {@code
* send} returns a boolean from the message channel it says the binder accepted the message, not
* that a broker did. Reporting that as confirmed would put the platform's strongest word on the
* binder's weakest evidence, and a caller reading {@code CONFIRMED} would stop worrying about a
* message that may never have left the process.
*
* <p>A caller that needs real publish evidence has to use the native adapter. That is the honest
* trade the bridge exists to make visible.
```
`accepted == true`일 때의 결과:
```java
PublishCompletion.AMBIGUOUS,
new PublishEvidence(true, TransmissionEvidence.MAY_HAVE_BEEN_TRANSMITTED, false, ConfirmationLevel.NONE),
RoutingOutcome.UNKNOWN,
...
FailureDescriptor.of(FailureCategory.AMBIGUOUS, "STREAM_BRIDGE_NO_BROKER_EVIDENCE", ...)
```
**`messaging-core-api``PublishResult` 14개 금지 조합을 전부 통과하도록 정확히 구성돼 있다** — `AMBIGUOUS``confirmationLevel == NONE`, `brokerAccepted == false`, `transmission != NOT_TRANSMITTED`, `routingOutcome != ROUTED`, `failure.isPresent()`를 요구하고 다섯 다 만족한다.
`accepted == false``REJECTED` + `notTransmitted()` + `TRANSIENT_INFRASTRUCTURE` — 채널이 거부했으므로 아무것도 나가지 않았고, 일시적 문제일 수 있으므로 재시도 가능하다.
**두 결과가 core-api의 3상태를 정확히 쓴다.** 이 저장소에서 `AMBIGUOUS`를 의도적으로 생성하는 몇 안 되는 지점이다.
`Duration.ZERO`를 elapsed로 넣는다 — 측정하지 않는다. `PublishResult`가 음수만 거절하므로 통과한다.
### 4.5 `SpringCloudStreamConsumerBridge` — 정산하지 않는다
```java
// :12-18
* <p>Settlement stays with the binder. The bridge cannot acknowledge, retry, or dead-letter a
* message itself, because Stream's binder already owns the acknowledgement for that binding and two
* things settling one message is worse than either doing it alone.
*
* <p>What the bridge does own is the translation and the honesty about it: a handler failure is
* rethrown so the binder's error channel sees it, rather than being converted into a platform
* {@code HandleResult} that nothing downstream would act on.
```
`dispatch`가 핸들러 예외를 잡지 않는다.
```java
// Not caught. The binder's error channel is what retries and dead-letters this binding, and
// swallowing the failure here would acknowledge a message nothing handled.
handler.handle(destination, payload, headers);
```
**`HandleResult`를 만들지 않는 것이 결정이다.** javadoc이 "nothing downstream would act on"이라고 적는데, 이것은 `messaging-runtime-core``DefaultDeliveryProcessor`가 조립되지 않았다는 사실과 정합한다(`analysis/messaging/messaging-runtime-core.md` §12.1a) — 이 leaf가 그 사실을 알고 쓰였다.
`ConcurrentHashMap`(handlers, destinations)이 바인딩 이름을 키로 한다. **두 맵이 함께 갱신되지만 원자적이지 않다**`register``handlers.put``destinations.put`을 한다. 그 사이에 `dispatch`가 들어오면 handler는 있고 destination은 없어 `NO_BRIDGED_HANDLER`가 난다. 안전한 방향이다(잘못된 목적지로 전달하지 않는다). §17.
### 4.6 `MessagingBindingBridge` — 구현이 한쪽뿐
인터페이스가 `bindPublisher``bindConsumer` 둘을 선언한다. **`SpringCloudStreamPublisherBridge`가 둘 다 구현하고, `SpringCloudStreamConsumerBridge`는 이 인터페이스를 구현하지 않는다.**
결과: `bindConsumer`가 publisher 쪽 `inputBindings` 맵에 기록되고, 실제 수신 등록(`register`)은 consumer 쪽에서 따로 일어난다. 두 클래스가 같은 바인딩에 대해 각자 상태를 갖는다. §17.
---
## 5. 주요 실행 경로
**검증:** `validator.validate(profile, bindingName, extendedProperties, enabled)` → guard 4검사 → 이름 → 속성 8개 → production → `BindingCapabilityReport.bridged(...)`
**발행:** `bridge.bindPublisher(dest, binding)``bridge.publish(dest, payload, headers)``send.send(...)` → true면 `AMBIGUOUS`, false면 `REJECTED`
**수신:** `consumerBridge.register(dest, binding, handler)` → 바인더가 `dispatch(binding, payload, headers)``handler.handle(...)` (예외 그대로 전파)
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 위치 |
|---|---|---|
| `STREAM_BRIDGE_DISABLED` | `MessagingConfigurationException` | guard |
| `STREAM_BRIDGE_ORDERING_UNSUPPORTED` | 같음 | guard |
| `STREAM_BRIDGE_RETRY_UNSUPPORTED` | 같음 | guard |
| `STREAM_BRIDGE_DLQ_UNSUPPORTED` | 같음 | guard |
| `INVALID_BINDING_NAME` | 같음 | validator |
| `BINDING_OVERRIDES_PLATFORM_POLICY` | 같음 | validator |
| `BRIDGE_ON_PRODUCTION_DESTINATION` | 같음 | validator |
| `NO_OUTPUT_BINDING` | 같음 | publisher bridge |
| `NO_BRIDGED_HANDLER` | 같음 | consumer bridge |
| `STREAM_BRIDGE_NO_BROKER_EVIDENCE` | (예외 아님) `PublishResult` `AMBIGUOUS` | publisher bridge |
| `STREAM_BRIDGE_SEND_REFUSED` | (예외 아님) `PublishResult` `REJECTED` | publisher bridge |
**아홉 개의 구성 실패가 전부 `MessagingConfigurationException` + 안정 코드다.** 이 저장소 messaging family에서 예외 어휘가 가장 일관된 leaf다 — `messaging-security`(두 계층 혼용)·`messaging-kafka-share-experimental`(두 계층 혼용)·`messaging-policy`(검증기가 `IllegalArgumentException`)와 대비된다.
발행 결과 둘은 예외가 아니라 값이다 — `messaging-core-api`의 설계를 그대로 따른다.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
| 지점 | 도구 |
|---|---|
| `SpringCloudStreamPublisherBridge.outputBindings`/`inputBindings` | `ConcurrentHashMap` |
| `SpringCloudStreamConsumerBridge.handlers`/`destinations` | `ConcurrentHashMap` |
각 맵은 스레드 안전하지만 **두 맵의 갱신이 원자적이지 않다**(§4.5). 정산이나 자원 해제가 없으므로 다른 동시성 지점은 없다.
`StreamBridgePolicyGuard`·`BindingProfileValidator`는 상태가 없다(`BindingProfileValidator`가 guard 인스턴스를 필드로 하나 갖지만 그것도 무상태).
수명주기 참여 없음 — `close()``stop()`이 없다. 등록된 핸들러를 해제하는 방법이 없다. §17.
---
## 8. 설정·기능 플래그·환경 차이
| 항목 | 값 |
|---|---|
| 프로퍼티 키(에러 메시지에만) | `backend.messaging.bridge.spring-cloud-stream` |
| 바인딩 이름 패턴 | `[a-zA-Z][a-zA-Z0-9-]{0,63}` |
| 충돌 확장 속성 | 8개 |
**그 프로퍼티를 읽는 코드가 저장소에 없다.** `enabled``validate(...)`의 인자다. `messaging-kafka-share-experimental``backend.messaging.experimental.kafka-share`와 같은 형태다(그쪽 §17).
상수 없음 — 두 패턴과 한 집합이 전부 private.
---
## 9. 퍼시스턴스/외부 시스템 세부
**없다.** Spring Cloud Stream 자체를 만지지 않는다 — 바인더 접촉면이 두 함수형 인터페이스(`ChannelSend`, `BridgedHandler`)로 추상화돼 있고 구현은 이 leaf 밖의 책임이다.
그래서 이 leaf는 **바인더 없이 전부 테스트 가능하다** — 20개 테스트가 실제로 그렇게 한다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-spring-cloud-stream-bridge:test`. **BUILD SUCCESSFUL, 20 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 무엇을 증명하는가 |
|---|---:|---|
| `BindingProfileValidatorTest` | 10 | 허용 목적지, 비활성 거절, 순서/DLQ/production 거절, 충돌 속성 거절, 무해한 속성 통과, 이름 거절, **브리지 리포트가 네 결여를 전부 보고**, native 리포트는 결여 없음 |
| `BridgePublishEvidenceTest` | 10 | accepted → `AMBIGUOUS`, transmission unknown, descriptor가 결여를 이름, refused → `REJECTED`, 미바인딩 목적지 거절, payload 도달, 양방향 조회, **핸들러 실패가 바인더 error channel로 재던져짐**, 미등록 바인딩 거절, 핸들러가 바인딩된 목적지를 받음 |
**여섯 타입 전부가 테스트에 등장한다.** 이 leaf는 messaging family에서 **타입 대비 테스트 커버리지가 가장 고른** 축이다 — `messaging-kafka-share-experimental`(4타입 중 1개만)·`messaging-claim-check`(publisher 미검증)·`messaging-security`(12 중 5개 미검증)와 대비된다.
`aHarmlessBinderPropertyIsAllowedThrough`가 특히 중요하다 — 거절 목록이 **과잉 차단하지 않는다**는 반대 방향 확인이다. `messaging-core-api`의 자격증명 세그먼트 매칭 테스트(`aNameThatMerelyContainsTheLettersIsAccepted`)와 같은 규율이다.
**증명하지 않는 것:** 실제 Spring Cloud Stream 바인더와의 통합. `ChannelSend`·`BridgedHandler`가 fake이므로 바인더가 실제로 이 계약대로 동작하는지는 이 레인 밖이다. 그리고 그 통합 코드 자체가 이 저장소에 없다(§12.1).
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | 세 project 의존 — **미사용 하나를 포함해 통과**(허용 목록은 상한) |
| `verifyRuntimeModuleMembership` | `[]` |
| vendor `api` 규칙 | Spring 타입이 public 시그니처에 없음 → `implementation`이 맞다. **다만 아예 쓰이지 않는다** |
| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |
| ArchUnit | 전용 규칙 없음 |
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/296-stream-bridge-unconsumed-and-unused-deps.txt`.
### 12.1 Public surface reachability
**여섯 타입 전부 leaf 밖 참조 0이다.**
`runtime_memberships: []`, starter 미포함, 조립 0건 — **삼중 정합**이다. incubating leaf가 이래야 하는 형태이고, `messaging-claim-check`·`messaging-cloudevents`가 어긋난 것과 대비된다.
**다만 이 leaf는 미완의 성격이 다르다.** 바인더 접촉면이 두 함수형 인터페이스로 추상화돼 있고 그 구현이 없다 — 즉 **Spring Cloud Stream과 실제로 연결하는 코드가 존재하지 않는다.** 이 leaf는 "브리지의 정책과 정직성"을 완성했고 "브리지 자체"는 없다.
그 사실이 `spring-context` 의존과 맞물린다(§12.4).
### 12.2 Conditional sibling comparison
Spring 주석 0개, bean 없음.
**`MessagingTransport` 구현 sibling과의 비교:**
| leaf | 브로커 접촉 | membership |
|---|---|---|
| `messaging-kafka`·`messaging-rabbit` | `MessagingTransport` 구현 | `["app-bootstrap"]` |
| `messaging-pulsar-experimental`·`messaging-nats-experimental` | `MessagingTransport` 구현 | `[]` |
| `messaging-kafka-share-experimental` | 부분 구현(`TransportConsumerRegistration`) | `[]` |
| **이 leaf** | **구현 없음 — 자체 인터페이스** | `[]` |
이 leaf는 `MessagingTransport`를 구현하지 **않는** 것이 의도다. 브리지는 transport가 아니라 **다른 프레임워크로의 seam**이고, 그래서 `MessagingBindingBridge`라는 자기 인터페이스를 갖는다. `messaging-transport-spi` 의존이 선언만 되고 쓰이지 않는 것이 그 판단과 정합한다 — 처음에 transport로 만들려다 방향을 바꾼 흔적으로 보인다(**추론**).
### 12.3 Duplicate mechanism sweep
**(a) 활성화 플래그 패턴이 세 leaf에 있다**
| leaf | 키 | 전달 방식 |
|---|---|---|
| 이 leaf | `backend.messaging.bridge.spring-cloud-stream` | `validate(..., boolean enabled)` |
| `messaging-kafka-share-experimental` | `backend.messaging.experimental.kafka-share` | `KafkaShareProfile.enabled` 필드 |
| (pulsar·nats) | — | 각 leaf SSOT가 답함 |
두 키 모두 **에러 메시지에만 존재**하고 읽는 코드가 없다. 같은 형태의 미완이다.
**(b) capability 보고가 두 형태**
| 위치 | 형태 |
|---|---|
| `messaging-core-api` `MessagingCapabilities` | boolean 12개, 브로커가 **할 수 있는 것** |
| 이 leaf `BindingCapabilityReport` | boolean 4개 + 문장, 브리지가 **하지 않는 것** |
**방향이 반대다.** 전자는 능력 선언이고 후자는 결여 진술이다. 그리고 후자만 사람이 읽는 문장을 만든다. 중복이 아니라 서로 다른 질문에 답한다 — 다만 `BindingCapabilityReport`의 네 boolean이 `MessagingCapabilities`의 어느 필드와도 대응하지 않아, 두 모델을 잇는 코드가 생기면 매핑을 새로 정해야 한다.
**(c) 순서·재시도·DLQ 거절이 여러 곳에**
| 위치 | 무엇을 거절 |
|---|---|
| `messaging-policy` `DestinationProfileValidator` | 프로파일 **내부** 모순(순서 + 재정렬 재시도 등) |
| `messaging-kafka-share-experimental` `KafkaShareProfileValidator` | 순서 목적지를 share group에 |
| 이 leaf `StreamBridgePolicyGuard` | 순서·재시도·DLQ를 **선언한** 목적지를 브리지에 |
셋이 다른 질문에 답한다 — 내부 일관성 / 어댑터 능력 / seam 적격성. 중복 아니다. 다만 셋 다 `DestinationProfile`의 같은 필드를 읽고 **서로를 참조하지 않는다.**
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| build.gradle: `messaging-transport-spi` 의존 | import 0건 | **미사용 의존** |
| build.gradle: `spring-context` 의존 | `org.springframework` import 0건 | **미사용 의존** |
| `MessagingBindingBridge` javadoc: "an interoperability seam" | 바인더 연결 코드 없음 | **미실현** |
| `StreamBridgePolicyGuard` 에러 메시지: `backend.messaging.bridge.spring-cloud-stream=true` | 그 키를 읽는 코드 0건 | **미실현** |
| `BindingCapabilityReport` javadoc: 운영자가 native와 비교할 수 있어야 함 | `nativeAdapter(...)` 호출자가 테스트뿐 | **부분 미실현** |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 실제로 `[]` | **이 leaf에 한해 참** |
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
이 leaf의 javadoc에 **이전 결함 서술이 없다.** 대신 막으려는 것을 다섯 적는다.
| 위치 | 막으려는 것 |
|---|---|
| `MessagingBindingBridge` | 바인더 의미론이 플랫폼 보장으로 승격되는 것 |
| `StreamBridgePolicyGuard` | 바인딩이 자기 serializer·error handling·ack mode를 조용히 획득하는 것 |
| `BindingProfileValidator` | 확장 속성과 프로파일을 병합해 "아무도 읽을 수 없는 구성"을 만드는 것 |
| `BindingCapabilityReport` | 차이를 침묵으로 두는 것 — "nothing at runtime will show it" |
| `SpringCloudStreamPublisherBridge` | 바인더의 가장 약한 증거에 플랫폼의 가장 강한 단어를 붙이는 것 |
| `SpringCloudStreamConsumerBridge` | 두 주체가 한 메시지를 정산하는 것 |
**여섯 파일 중 여섯이 "하지 않는 것"을 서술한다.** 이 leaf는 기능이 아니라 **경계**로 구성돼 있다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-296 | command | `evidence/raw/296-stream-bridge-unconsumed-and-unused-deps.txt` | 여섯 타입 참조 0, membership `[]`, 선언 의존 4개와 실제 import 목록, transport-spi·spring-context import 0(exit=1), 인터페이스 구현이 publisher뿐, `nativeAdapter` 호출자가 테스트뿐 | 정적 검색 |
| EVD-297 | command | `./gradlew :messaging:messaging-spring-cloud-stream-bridge:test --rerun-tasks` | BUILD SUCCESSFUL, 20 / 0 / 0 | 바인더 없이 fake로 검증 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 브리지가 두 번째 messaging API가 아닌 이유 — `MessagingBindingBridge` javadoc
- 바인더 의미론을 승격하지 않는 이유 — 같은 javadoc
- 플랫폼 보장에 의존하는 목적지를 거절하는 이유 — `StreamBridgePolicyGuard` javadoc
- 확장 속성을 병합하지 않고 거절하는 이유 — `BindingProfileValidator` javadoc
- 결여를 명시적 리포트로 만드는 이유 — `BindingCapabilityReport` javadoc
- `AMBIGUOUS`가 유일하게 정직한 답인 이유 — `SpringCloudStreamPublisherBridge` javadoc
- 정산이 바인더에 남는 이유, 예외를 재던지는 이유 — `SpringCloudStreamConsumerBridge` javadoc
- `ChannelSend`를 분리한 이유("testable without a binder") — 그 인터페이스 javadoc
**추론**
- `messaging-transport-spi` 의존이 선언만 된 것은 처음에 transport로 만들려다 방향을 바꿨기 때문이다 → **추론**. 의존 선언과 미사용은 관측이고 인과는 추론이다.
- `spring-context` 의존이 선언만 된 것은 바인더 통합 코드를 상정했기 때문이다 → **추론**.
- `SpringCloudStreamConsumerBridge``MessagingBindingBridge`를 구현하지 않는 것이 의도인지 → **미상**.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 6개 타입 507줄 전문
- 20개 테스트가 통과하고 **여섯 타입 전부를 덮는다**는 것
- 여섯 타입 전부 참조 0이고 membership `[]`과 정합한다는 것
- `messaging-transport-spi``spring-context`가 선언되고 import 0건이라는 것
- 바인더 접촉면이 두 함수형 인터페이스로 추상화돼 있고 그 구현이 저장소에 없다는 것
- 아홉 구성 실패가 전부 같은 예외 타입과 안정 코드를 쓴다는 것
-`PublishResult`가 core-api의 14개 금지 조합을 정확히 만족한다는 것
**확인하지 못한 것**
- 실제 Spring Cloud Stream 바인더가 `ChannelSend`의 boolean 계약대로 동작하는지 — 바인더가 저장소에 없다.
- `backend.messaging.bridge.spring-cloud-stream` 키가 어딘가 문서화돼 있는지.
- `SpringCloudStreamConsumerBridge`에 해제 경로가 필요한지 — 바인더 수명주기를 모른다.
- 이 leaf를 완성할 계획이 있는지.
---
## 17. 손볼 것
### P3 — 선언된 의존 둘이 사용되지 않는다
- **사실.** registry가 `messaging-transport-spi`를 허용하고 `build.gradle``spring-context`를 선언한다. main 소스의 비-JDK import 9개는 전부 `messaging-core-api``messaging-policy`에서 온다. `import dev.caskeleton.messaging.transport` · `import org.springframework` 검색이 exit 1이다.
- **근거.** `evidence/raw/296` §B.
- **왜 문제인가.** `verifyCleanArchitectureDependencies`가 허용 목록을 **상한**으로 검사하므로 잡히지 않는다. 그리고 `spring-context` 선언이 "이 leaf가 Spring과 통합돼 있다"는 인상을 주는데 실제로는 Spring 타입을 한 번도 이름 부르지 않는다 — 바인더 접촉면 전체가 자체 함수형 인터페이스다.
- **확인 방법.** `evidence/raw/296` §B 재실행.
- **후보.** 두 의존을 제거하거나, 완성 시 필요함을 build.gradle 주석에 적는다.
- **다음 단계.** `messaging-kafka-share-experimental` §17의 같은 항목과 **동일 형태**다. 두 incubating leaf가 같은 방식으로 미사용 의존을 선언한다 → **REFERENCE 후보**(허용 의존 목록은 상한이므로 미사용을 잡지 않는다).
### P3 — 브리지의 바인더 쪽 절반이 없다
- **사실.** `ChannelSend`·`BridgedHandler` 두 함수형 인터페이스가 바인더 접촉면이고, 그 구현이 저장소에 없다. `MessagingBindingBridge` javadoc은 "a service already has Stream bindings and needs to reach the same destinations without a rewrite"를 존재 이유로 든다.
- **근거.** `evidence/raw/296` §A·§B.
- **왜 문제인가.** 정책·검증·정직성 세 층이 완성돼 있고 그것들을 실제 바인딩에 연결하는 코드가 없다. `runtime_memberships: []`와 정합하므로 오늘의 결함은 아니지만, 이 leaf의 이름이 약속하는 것("spring-cloud-stream-bridge")이 절반만 존재한다.
- **확인 방법.** `git grep -n 'ChannelSend\|BridgedHandler' -- src` → 이 leaf와 그 테스트만.
- **후보.** 바인더 어댑터를 만들거나, 두 인터페이스가 파생 프로젝트의 구현점임을 javadoc에 명시한다.
- **다음 단계.** **OPEN QUESTION 후보.** `messaging-kafka-share-experimental` §17 첫 항목과 같은 질문("완성할 것인가")이다.
### P3 — 인터페이스를 publisher만 구현하고 두 클래스가 같은 바인딩에 각자 상태를 갖는다
- **사실.** `MessagingBindingBridge``bindPublisher`·`bindConsumer` 둘을 선언한다. `SpringCloudStreamPublisherBridge`가 둘 다 구현하고 `inputBindings` 맵에 기록한다. `SpringCloudStreamConsumerBridge`는 이 인터페이스를 구현하지 않고 자기 `handlers`·`destinations` 맵에 기록한다.
- **근거.** `evidence/raw/296` §C.
- **왜 문제인가.** 한 바인딩에 대해 두 객체가 각자 등록을 갖고 서로를 모른다. `bindConsumer`를 부르고 `register`를 부르지 않으면 publisher 쪽은 바인딩이 있다고 보고하고 실제 전달은 `NO_BRIDGED_HANDLER`로 실패한다. `consumerBinding(dest)`가 그 불일치를 드러내지 않는다.
- **확인 방법.** 두 클래스의 필드와 인터페이스 구현 확인.
- **후보.** consumer bridge가 `MessagingBindingBridge`를 구현하고 publisher가 `bindConsumer`를 위임하거나, 인터페이스를 발행·수신으로 나눈다.
- **다음 단계.** **REFERENCE 후보**(한 개념의 등록 상태를 두 객체가 나눠 갖지 않는다).
### P3 — 두 맵 갱신이 원자적이지 않다
- **사실.** `SpringCloudStreamConsumerBridge.register``handlers.put(...)``destinations.put(...)`을 한다. 같은 형태가 publisher의 두 맵에도 있다(다만 각각 독립 키).
- **근거.** `SpringCloudStreamConsumerBridge.java:38-39`.
- **왜 문제인가.** 그 사이에 `dispatch`가 들어오면 `destination == null`이 되어 `NO_BRIDGED_HANDLER`가 난다. **안전한 방향**이다 — 잘못된 목적지로 전달하지 않는다. 다만 에러 코드가 "핸들러가 없다"인데 실제로는 핸들러가 있고 목적지가 아직 없다.
- **확인 방법.** 두 `put` 사이의 창.
- **후보.** 한 record로 묶어 한 번에 put한다.
- **다음 단계.** **REFERENCE 후보**(함께 읽히는 두 맵은 한 값으로 묶는다).
### P3 — 등록 해제 경로가 없다
- **사실.** `SpringCloudStreamConsumerBridge``unregister``close`가 없다. `SpringCloudStreamPublisherBridge`도 마찬가지다.
- **근거.** 두 클래스의 public 메서드 전수.
- **왜 문제인가.** 바인딩이 재구성되거나 컨텍스트가 종료될 때 맵이 비워지지 않는다. 오늘은 조립되지 않아 무해하다. `messaging-transport-spi``TransportConsumerRegistration``AutoCloseable`인 것과 대비된다.
- **확인 방법.** public 메서드 목록.
- **후보.** `unregister(bindingName)` 또는 `AutoCloseable` 구현.
- **다음 단계.** **REFERENCE 후보**(등록을 받는 컴포넌트는 해제도 제공한다).
### P3 — 활성화 프로퍼티 키가 에러 메시지에만 존재한다
- **사실.** `backend.messaging.bridge.spring-cloud-stream=true``STREAM_BRIDGE_DISABLED` 메시지에 적혀 있다. 그 키를 읽는 코드가 없다.
- **근거.** `git grep -n 'spring-cloud-stream=true' -- src` → 이 leaf의 문자열 하나.
- **왜 문제인가.** `messaging-kafka-share-experimental`·`messaging-claim-check`와 같은 형태다 — 메시지가 지시하는 설정에 대응 코드가 없다.
- **다음 단계.** 그 두 leaf의 같은 항목과 함께 **REFERENCE 후보**(에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다).
### 확인된 설계(문제 아님)
- 플랫폼 보장에 의존하는 목적지를 브리지에서 아예 거절하는 4단 게이트
- 확장 속성을 병합하지 않고 거절하며 어느 쪽을 지울지 알려 주는 것
- 무해한 확장 속성은 통과시키고 그것을 테스트로 고정한 것
- 결여를 boolean이 아니라 **결과가 적힌 문장**으로 만드는 것
- 바인더의 boolean send를 `AMBIGUOUS`로 보고하고 그 이유를 적은 것
-`PublishResult`가 core-api의 금지 조합을 정확히 만족하는 것
- 정산을 바인더에 남기고 핸들러 예외를 재던지는 것
- 바인더 접촉면을 함수형 인터페이스로 분리해 바인더 없이 전부 테스트 가능하게 한 것
- 아홉 구성 실패가 한 예외 타입과 안정 코드를 쓰는 것
- 소비자 0 · membership `[]` · 조립 0의 삼중 정합
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MSB-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 3개, `runtime_memberships: []` | 선언 |
| MSB-002 | build | `messaging-spring-cloud-stream-bridge/build.gradle` | same | 네 의존 선언 | 둘은 미사용(§12.4) |
| MSB-003 | code | `.../streambridge/StreamBridgePolicyGuard.java` | same | §4.1 네 거절 | — |
| MSB-004 | code | `.../streambridge/BindingProfileValidator.java` | same | §4.2 8속성 거절, production 거절 | — |
| MSB-005 | code | `.../streambridge/BindingCapabilityReport.java` | same | §4.3 결여를 문장으로 | `nativeAdapter` 호출자 테스트뿐 |
| MSB-006 | code | `.../streambridge/SpringCloudStreamPublisherBridge.java` | same | §4.4 AMBIGUOUS 결정과 두 결과 | — |
| MSB-007 | code | `.../streambridge/SpringCloudStreamConsumerBridge.java` | same | §4.5 정산 미소유, 예외 재던짐 | 두 맵 비원자(§17) |
| MSB-008 | code | `.../streambridge/MessagingBindingBridge.java` | same | seam 선언과 위협 모델 | 구현이 publisher뿐 |
| MSB-009 | test | `BindingProfileValidatorTest` (10), `BridgePublishEvidenceTest` (10) | same | §10 표, 여섯 타입 전부 | 실제 바인더 없음 |
| MSB-010 | cross-leaf code | `messaging-core-api/.../PublishResult.java:39-101` | same | 두 결과가 만족하는 금지 조합 | 해당 leaf SSOT가 소유 |
| MSB-011 | cross-leaf code | `messaging-policy/.../DestinationProfile.java`, `RetryMode.java` | same | 게이트가 읽는 세 필드 | 해당 leaf SSOT가 소유 |
| EVD-296 | command | `evidence/raw/296-stream-bridge-unconsumed-and-unused-deps.txt` | same | §12.1·§12.4 | 정적 검색 |
| EVD-297 | command | `./gradlew :messaging:messaging-spring-cloud-stream-bridge:test --rerun-tasks` | same | 20 / 0 / 0 | fake 바인더 |