# 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 *
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. * *
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 *
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. * *
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 *
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 *
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 *
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. * *
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 *
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. * *
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 바인더 |