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>
37 KiB
messaging-spring-cloud-stream-bridge 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-spring-cloud-stream-bridgeSSOT owner:messaging-spring-cloud-stream-bridgeintegration/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 | 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 — 의존하는 순간 거절
// :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-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 — 정산하지 않는다
// :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-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가 아닌 이유 —
MessagingBindingBridgejavadoc - 바인더 의미론을 승격하지 않는 이유 — 같은 javadoc
- 플랫폼 보장에 의존하는 목적지를 거절하는 이유 —
StreamBridgePolicyGuardjavadoc - 확장 속성을 병합하지 않고 거절하는 이유 —
BindingProfileValidatorjavadoc - 결여를 명시적 리포트로 만드는 이유 —
BindingCapabilityReportjavadoc AMBIGUOUS가 유일하게 정직한 답인 이유 —SpringCloudStreamPublisherBridgejavadoc- 정산이 바인더에 남는 이유, 예외를 재던지는 이유 —
SpringCloudStreamConsumerBridgejavadoc 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두 함수형 인터페이스가 바인더 접촉면이고, 그 구현이 저장소에 없다.MessagingBindingBridgejavadoc은 "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 바인더 |