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>
636 lines
37 KiB
Markdown
636 lines
37 KiB
Markdown
# 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 바인더 |
|