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

Follows the import procedure in README.md.

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

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

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

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

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

37 KiB

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이다.

// 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 oDestinationName, MessagingConfigurationException, publish 6타입
messaging-policy api oDestinationProfile, 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.ChannelSendSpringCloudStreamConsumerBridge.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 — 의존하는 순간 거절

// :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 — 확장 속성을 병합하지 않는다

// :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 목적지는 무조건 거절한다.

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 — 부재를 값으로

// :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 — 가장 정직한 결과

// :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일 때의 결과:

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-apiPublishResult 14개 금지 조합을 전부 통과하도록 정확히 구성돼 있다AMBIGUOUSconfirmationLevel == NONE, brokerAccepted == false, transmission != NOT_TRANSMITTED, routingOutcome != ROUTED, failure.isPresent()를 요구하고 다섯 다 만족한다.

accepted == falseREJECTED + notTransmitted() + TRANSIENT_INFRASTRUCTURE — 채널이 거부했으므로 아무것도 나가지 않았고, 일시적 문제일 수 있으므로 재시도 가능하다.

두 결과가 core-api의 3상태를 정확히 쓴다. 이 저장소에서 AMBIGUOUS를 의도적으로 생성하는 몇 안 되는 지점이다.

Duration.ZERO를 elapsed로 넣는다 — 측정하지 않는다. PublishResult가 음수만 거절하므로 통과한다.

4.5 SpringCloudStreamConsumerBridge — 정산하지 않는다

// :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가 핸들러 예외를 잡지 않는다.

// 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-coreDefaultDeliveryProcessor가 조립되지 않았다는 사실과 정합한다(analysis/messaging/messaging-runtime-core.md §12.1a) — 이 leaf가 그 사실을 알고 쓰였다.

ConcurrentHashMap(handlers, destinations)이 바인딩 이름을 키로 한다. 두 맵이 함께 갱신되지만 원자적이지 않다registerhandlers.putdestinations.put을 한다. 그 사이에 dispatch가 들어오면 handler는 있고 destination은 없어 NO_BRIDGED_HANDLER가 난다. 안전한 방향이다(잘못된 목적지로 전달하지 않는다). §17.

4.6 MessagingBindingBridge — 구현이 한쪽뿐

인터페이스가 bindPublisherbindConsumer 둘을 선언한다. 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개

그 프로퍼티를 읽는 코드가 저장소에 없다. enabledvalidate(...)의 인자다. messaging-kafka-share-experimentalbackend.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 의존이 선언만 된 것은 바인더 통합 코드를 상정했기 때문이다 → 추론.
  • SpringCloudStreamConsumerBridgeMessagingBindingBridge를 구현하지 않는 것이 의도인지 → 미상.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 6개 타입 507줄 전문
  • 20개 테스트가 통과하고 여섯 타입 전부를 덮는다는 것
  • 여섯 타입 전부 참조 0이고 membership []과 정합한다는 것
  • messaging-transport-spispring-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.gradlespring-context를 선언한다. main 소스의 비-JDK import 9개는 전부 messaging-core-apimessaging-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만 구현하고 두 클래스가 같은 바인딩에 각자 상태를 갖는다

  • 사실. MessagingBindingBridgebindPublisher·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.registerhandlers.put(...)destinations.put(...)을 한다. 같은 형태가 publisher의 두 맵에도 있다(다만 각각 독립 키).
  • 근거. SpringCloudStreamConsumerBridge.java:38-39.
  • 왜 문제인가. 그 사이에 dispatch가 들어오면 destination == null이 되어 NO_BRIDGED_HANDLER가 난다. 안전한 방향이다 — 잘못된 목적지로 전달하지 않는다. 다만 에러 코드가 "핸들러가 없다"인데 실제로는 핸들러가 있고 목적지가 아직 없다.
  • 확인 방법.put 사이의 창.
  • 후보. 한 record로 묶어 한 번에 put한다.
  • 다음 단계. REFERENCE 후보(함께 읽히는 두 맵은 한 값으로 묶는다).

P3 — 등록 해제 경로가 없다

  • 사실. SpringCloudStreamConsumerBridgeunregisterclose가 없다. SpringCloudStreamPublisherBridge도 마찬가지다.
  • 근거. 두 클래스의 public 메서드 전수.
  • 왜 문제인가. 바인딩이 재구성되거나 컨텍스트가 종료될 때 맵이 비워지지 않는다. 오늘은 조립되지 않아 무해하다. messaging-transport-spiTransportConsumerRegistrationAutoCloseable인 것과 대비된다.
  • 확인 방법. public 메서드 목록.
  • 후보. unregister(bindingName) 또는 AutoCloseable 구현.
  • 다음 단계. REFERENCE 후보(등록을 받는 컴포넌트는 해제도 제공한다).

P3 — 활성화 프로퍼티 키가 에러 메시지에만 존재한다

  • 사실. backend.messaging.bridge.spring-cloud-stream=trueSTREAM_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 바인더