# messaging-transport-spi 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-transport-spi` > SSOT owner: `messaging-transport-spi` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-transport-spi` - canonical state `analysisFile`: `analysis/messaging/messaging-transport-spi.md` - source path: `src/messaging/messaging-transport-spi` - registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api", "messaging-policy"]` - registry `runtime_memberships`: `["app-bootstrap"]` ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | 13 | | production LOC | 776 | | 패키지 | 1 (`dev.caskeleton.messaging.transport`) | | test 파일 | 4 | | test 메서드(실행 확인) | 24 | | 외부(비프로젝트) 의존성 | **0** | 13개 타입: | 타입 | 종류 | 역할 | |---|---|---| | `MessagingTransport` | interface | **브로커 어댑터가 구현하는 SPI** | | `TransportPublishRequest` | record | 이미 인코딩된 발행 요청 | | `TransportPublishResult` | record | `PublishResult` 래퍼 | | `TransportConsumerSpec` | record | 프로파일 + 콜백 | | `TransportConsumerRegistration` | interface | 살아 있는 구독 | | `TransportDelivery` | record | 아직 인코딩된 수신 | | `TransportSettlement` | interface | 어댑터 측 정산 핸들 | | `MessagingRuntime` | interface | 한 세대의 연결·자격증명·토폴로지 | | `MessagingRuntimeLease` | interface | 세대 참조 대여 | | `MessagingRuntimeRegistry` | interface | 브로커별 현재 세대 | | `DefaultMessagingRuntimeRegistry` | class | 참조 계수 + 원자 교체 구현 | | `GracefulShutdownCoordinator` | class | 드레인 조정자 | | `MessagingLifecycle` | interface | **8단계 종료 순서 계약 — 구현체 없음(§12.1)** | ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `src/main/java/**` (13) | 13 | `FULL_READ` | 전 파일 본문 확인 | | `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 확인 | | `build.gradle` | 1 | `FULL_READ` | 7줄 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 브로커 어댑터가 구현할 **SPI**와, 그 어댑터들의 **수명주기·세대 관리**를 소유한다. 벤더 의존성이 0이다. 가장 중요한 경계 규칙이 `MessagingTransport`의 javadoc에 있다. ```java // MessagingTransport.java:10-12 *
No method returns a native client object. Handing back a raw producer or channel would let an
* application bypass destination policy, payload limits, and the settlement ordering in one call,
* and the resulting code would silently stop working the moment the broker changed.
```
13개 타입 중 어느 것도 브로커 네이티브 타입을 시그니처에 노출하지 않는다. `BrokerPosition`(core-api)이 `Map Decoding happens above the transport so that a payload the consumer cannot parse is classified
* as a schema failure by the platform, and parked, rather than being turned into an
* adapter-specific exception each broker reports differently.
```
`TransportPublishRequest`도 대칭이다 — "The payload arrives already encoded and the profile arrives already validated, so an adapter never chooses a codec or a limit for itself. That is what keeps two adapters from disagreeing about what 'the same message' means."
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `messaging-policy`(api). 셋 다 `api`인 이유는 세 leaf의 타입이 이 leaf의 public 시그니처에 직접 등장하기 때문이다 — `TransportPublishRequest`가 `DestinationProfile`(policy)·`MessageEnvelope`(core-api)·`EncodedMessage`(schema-api)를 필드로 갖는다.
나가는 것: `messaging-runtime-core`, `messaging-kafka`, `messaging-kafka-share-experimental`, `messaging-rabbit`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-admin-runtime`, `messaging-spring-cloud-stream-bridge`, `messaging-spring-boot-starter`, `messaging-testkit`.
런타임 편입은 starter closure를 통해서다. 이 leaf 자체는 bean을 만들지 않는다.
---
## 3. 패키지/컴포넌트 지도
세 축이 한 패키지에 있다.
```
[SPI] MessagingTransport
├── publish(TransportPublishRequest) → TransportPublishResult
├── register(TransportConsumerSpec) → TransportConsumerRegistration
├── capabilities(DestinationName) → DestinationCapabilities
└── brokerName / generation / close
↑ 구현: Kafka · Rabbit · Pulsar · NATS (4)
[세대] MessagingRuntime ── MessagingRuntimeLease ── MessagingRuntimeRegistry
↑
DefaultMessagingRuntimeRegistry (구현)
[종료] GracefulShutdownCoordinator (사용됨: 11개 파일)
MessagingLifecycle.ShutdownPhase(8) (구현 없음, 소비자 0)
```
세 축이 **다른 정도로 살아 있다.** SPI는 4개 어댑터가 구현하고, 세대 관리는 구현이 하나 있고, 종료 계약은 절반만 실현됐다(§12.1).
---
## 4. 계약·불변식·상태 모델
### 4.1 세대 모델: 회전은 변경이 아니라 교체다
```java
// MessagingRuntime.java:5-8
* Credential rotation and topology reload replace a whole generation rather than mutating a live
* one. In-flight publishes keep the generation they started on, which is what makes a rotation
* invisible to callers instead of a burst of authentication failures.
```
세 타입이 그 모델을 이룬다.
| 타입 | 불변식 |
|---|---|
| `MessagingRuntime` | 불변. `close()`는 **멱등이어야 한다**(javadoc이 명시) |
| `MessagingRuntimeLease` | 참조를 pin. `close()`는 **멱등이어야 한다** |
| `MessagingRuntimeRegistry` | 브로커당 현재 세대 하나 |
### 4.2 `DefaultMessagingRuntimeRegistry`: 참조 계수와 원자 교체
이 leaf의 유일한 실질 구현이고 동시성 설계가 조밀하다.
**설치(교체)**
```java
Generation retired = current.put(runtime.brokerName(), new Generation(runtime));
if (retired == null) return;
retired.retire(now);
if (!retired.closeIfIdle()) {
synchronized (draining) { draining.add(retired); }
}
```
`ConcurrentHashMap.put`이 원자적이므로 호출자는 옛 세대 또는 새 세대만 본다 — javadoc: "never a half-rebuilt connection pool".
**대여**
```java
Generation generation = current.computeIfPresent(brokerName, (key, value) -> {
value.leases.incrementAndGet();
return value;
});
```
`computeIfPresent`의 리맵 함수가 **버킷 잠금 안에서** 실행되므로, 조회와 증가가 원자적이다. `get` 후 증가였다면 그 사이에 `install`이 세대를 교체해 이미 은퇴한 세대의 계수를 올릴 수 있다.
**해제**
```java
void release() {
if (leases.decrementAndGet() == 0) { closeIfIdle(); }
}
boolean closeIfIdle() {
if (retired.get() && leases.get() == 0) { return forceClose(); }
return false;
}
boolean forceClose() {
if (closed.compareAndSet(false, true)) { runtime.close(); return true; }
return false;
}
```
`closed`가 CAS로 보호되므로 **정확히 한 번만** `runtime.close()`가 불린다. 테스트가 그것을 직접 단언한다(`aRetiredGenerationIsClosedExactlyOnce`, `as("a second close on a real connection pool throws from a shutdown hook")`).
`Lease.close()`도 자체 `AtomicBoolean released`로 멱등이다 — 두 층의 멱등성이다.
**세대별 은퇴 시각**
```java
// Generation.retiredAt javadoc:180-183
* Each generation carries its own. The deadline check took one {@code retiredAt} from the
* caller and applied it to every draining generation, so a rotation during a drain either
* force-closed a generation that had just retired or gave an old one a fresh deadline —
* depending on which timestamp the caller happened to pass.
```
이전 결함의 기록이다. 하나의 타임스탬프를 전체 목록에 적용하면 회전이 겹칠 때 판정이 호출자가 우연히 넘긴 값에 좌우된다.
**닫힌 세대의 목록 제거**
```java
// closeExpiredDraining:112-113
// Anything already closed leaves the list too: it is not draining, and leaving it there is
// what made drainingCount report work that had finished.
draining.removeIf(Generation::isClosed);
```
`drainingCount()`가 관측 지표이므로, 이미 닫힌 세대가 목록에 남으면 지표가 영원히 0으로 안 떨어진다.
**`close()`가 현재 세대까지 닫는다**
```java
// close() javadoc:131-134
* Nothing closed the current generation. The registry only ever closed what a rotation had
* retired, so a process that shut down without rotating left its broker connections to the JVM's
* exit — which drops unflushed producer batches and leaves consumer sessions to time out on the
* broker instead of leaving the group.
```
이것도 이전 결함이다. 회전 없이 종료하는 프로세스(=대부분의 프로세스)가 연결을 정리하지 않았다.
**동시성 미세 결함 하나.** `close()`가 `draining`은 `synchronized`로 비우지만 `current`는 `List.copyOf(current.keySet())` 후 하나씩 `remove`한다. 그 사이에 `install`이 새 세대를 넣으면 그 세대는 닫히지 않는다. 종료 중 설치는 정상 시나리오가 아니므로 실질 위험은 낮다 — §17의 P3.
### 4.3 `GracefulShutdownCoordinator`: 세 단계와 그 이유
```java
// GracefulShutdownCoordinator.java:12-22
* Shutdown has three phases, in order: stop accepting new work, let what is running finish, then
* close. Skipping the middle phase is what produces the classic shutdown bug — a handler is
* interrupted between its side effect and its settlement, so the message is redelivered and the
* effect happens twice.
*
* The deadline exists because draining cannot be unbounded: a stuck handler would otherwise hold
* the process open forever. Work still running at the deadline is abandoned unsettled, so
* the broker redelivers it rather than the platform pretending it completed.
*
* No retry attempt is created once draining begins. Starting a fresh attempt during shutdown
* guarantees it will be abandoned at the deadline.
```
`tryBeginWork`가 **이중 검사**다.
```java
public boolean tryBeginWork() {
if (draining.get()) return false;
inFlight.incrementAndGet();
if (draining.get()) { inFlight.decrementAndGet(); return false; }
return true;
}
```
증가 후 다시 확인해서, 증가와 `beginDrain` 사이의 경합에서 계수를 되돌린다. 이 패턴이 없으면 드레인 시작 직후 시작된 작업이 계수에 남아 `isDrained`가 영원히 false가 된다.
`endWork`가 0에서 clamp한다.
```java
// :65-67
* Clamped at zero. A double release used to drive the count negative, and a negative in-flight
* count reports the drain as complete while work is still running — which is exactly when the
* process shuts down underneath it.
public void endWork() {
inFlight.updateAndGet(current -> current > 0 ? current - 1 : current);
}
```
`isDrained(now)`가 세 갈래다 — 드레인 전이면 false, 계수 0이면 true, 아니면 마감 경과 여부. `abandonedWorkAtDeadline`이 "마감으로 끝났는가"를 별도로 답해서, 완주한 드레인과 포기한 드레인을 구분할 수 있다.
### 4.4 `MessagingLifecycle`: 8단계 순서 계약
```java
// MessagingLifecycle.java:8-15
* The order in {@link ShutdownPhase} is the contract, not an implementation detail. Closing
* connections before settlements have been transmitted loses the settlements, and pausing consumers
* after draining lets fresh deliveries arrive into a runtime that is already shutting down. Each
* adapter implements the phases; none of them chooses the order.
*
* Implementations are driven by the Spring lifecycle rather than a JVM shutdown hook alone. A
* shutdown hook runs after the context has already begun disposing beans, so a handler mid-drain
* can find its datasource closed underneath it.
```
여덟 단계:
| # | 단계 | 뜻 |
|---:|---|---|
| 1 | `STOP_PUBLISH_ADMISSION` | 새 발행 거부 |
| 2 | `STOP_NEW_HANDLERS` | 새 핸들러 시작 거부 |
| 3 | `PAUSE_CONSUMERS` | 브로커에 전달 중단 요청 |
| 4 | `DRAIN_HANDLERS` | 실행 중 핸들러 완료 대기 |
| 5 | `FLUSH_SETTLEMENTS` | 그 핸들러들이 만든 정산 전송 |
| 6 | `AWAIT_PRODUCER_CONFIRMS` | 미확인 발행이 모호로 남지 않게 |
| 7 | `RELEASE_OUTBOX_LEASES` | 다른 relay가 즉시 claim 가능하게 |
| 8 | `CLOSE_CONNECTIONS` | 연결·채널 종료 |
`shutdown(Duration)`이 마감 시점에 실행 중이던 단계를 반환한다 — 완주하면 `CLOSE_CONNECTIONS`.
**이 인터페이스를 구현하는 것이 저장소에 없다.** §12.1.
### 4.5 `TransportConsumerRegistration`: 순서 단위별 pause
```java
// :8-10
* Pause and resume operate on an ordering unit rather than the whole consumer, because that is
* what makes {@code PAUSE_PARTITION} retry possible: one stuck key must not stall every other
* partition on the same connection.
```
`scope`가 빈 문자열이면 전체다. `core-api`의 `PauseResumeController`는 `"*"`를 전체로 쓴다 — 두 인터페이스가 같은 개념에 **다른 sentinel**을 쓴다. `PauseResumeController`는 소비자가 0이므로(`analysis/messaging/messaging-core-api.md` §12.1) 오늘 충돌하지 않지만, 그것을 배선하려는 사람이 두 규약을 이어야 한다.
### 4.6 `TransportSettlement`: 애플리케이션에 노출되지 않는다
```java
// :10-11
* Deliberately not exposed to application code. Handlers state an intent; the platform decides
* when and in what order the settlement happens, and this is the seam it uses to do that.
```
`acknowledge` / `requeue(delay)` / `discard` 셋이고, `core-api`의 `SettlementController`(`ack`/`retry`/`deadLetter`/`reject`)와 **이름도 개수도 다르다.** 전자는 어댑터 측 원시 연산, 후자는 M2 수동 정산 API다. `deadLetter`가 전자에 없는 것이 핵심이다 — DLQ 발행은 플랫폼(`DefaultDeliveryProcessor`)이 하고 어댑터는 `acknowledge`만 받는다.
---
## 5. 주요 실행 경로
**발행:** 상위(`DefaultMessagePublisher`)가 `TransportPublishRequest`를 만들어 `MessagingTransport.publish` → 어댑터가 `TransportPublishResult(PublishResult)` 반환
**수신:** 상위가 `TransportConsumerSpec(profile, sink)`로 `register` → 어댑터가 메시지마다 `sink.apply(TransportDelivery)` → 상위가 `TransportSettlement`으로 정산
**회전:** 새 `MessagingRuntime` 생성 → `registry.install(runtime, now)` → 옛 세대 `retire` → lease가 0이면 즉시 close, 아니면 `draining`에 적재 → 스케줄러가 `closeExpiredDraining(now)` 호출
**종료:** (실제 경로) `MessagingShutdownLifecycle.stop()` → `admission.stopAcceptingNewWork()` → `drain.beginDrain(now)` → 50 ms 폴링으로 `isDrained` 대기 → 마감 도달 시 중단
---
## 6. 실패 경로와 복구/번역
이 leaf가 직접 던지는 예외는 **하나**다.
| 코드 | 예외 | 조건 |
|---|---|---|
| `RUNTIME_NOT_INSTALLED` | `MessagingConfigurationException` | `acquire(brokerName)`인데 그 브로커의 세대가 없음 |
나머지는 `IllegalArgumentException`(생성자 인자 검증)과 `NullPointerException`(`Objects.requireNonNull`)이다. 이 leaf가 다루는 실패의 대부분은 **예외가 아니라 상태**다 — 드레인 마감 초과는 `abandonedWorkAtDeadline(now)`가 true를 반환하는 것이고, 세대 강제 종료는 `closeExpiredDraining`의 반환 계수다.
**포기가 조용하지 않다는 것이 설계다.** 마감에 도달한 작업은 정산되지 않은 채 버려지고, 브로커가 재전달한다. `GracefulShutdownCoordinator` javadoc: "rather than the platform pretending it completed."
---
## 7. 트랜잭션·동시성·수명주기
이 leaf는 messaging family에서 **동시성 밀도가 가장 높다.**
| 지점 | 도구 | 보호하는 것 |
|---|---|---|
| `current` 맵 | `ConcurrentHashMap` | 세대 교체의 원자성 |
| lease 증가 | `computeIfPresent` 리맵 | 조회-증가 사이의 교체 |
| `leases` | `AtomicInteger` | 참조 계수 |
| `retired`, `closed` | `AtomicBoolean` + CAS | 정확히 한 번 close |
| `Lease.released` | `AtomicBoolean` + CAS | 이중 close 방지 |
| `retiredAt` | `volatile Instant` | 세대별 마감 가시성 |
| `draining` 리스트 | `synchronized` 블록 | `ArrayList` 보호 |
| `inFlight` | `AtomicInteger` + 이중 검사 + clamp | 드레인 계수 |
| `draining`(coordinator) | `AtomicBoolean` CAS | 드레인 시작 한 번 |
| `drainStartedAt` | `volatile Instant` | 마감 가시성 |
**주목할 비대칭:** `DefaultMessagingRuntimeRegistry`가 `current`는 lock-free(`ConcurrentHashMap`)로, `draining`은 `synchronized ArrayList`로 다룬다. `draining`은 회전 때만 접근하므로 경합이 없다 — 합리적 선택이지만 주석이 없다.
수명주기는 §4.4의 8단계가 **선언**이고 §12.1이 실현 상태를 다룬다.
---
## 8. 설정·기능 플래그·환경 차이
설정 없음.
| 상수 | 값 | 위치 |
|---|---|---|
| `DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE` | 30초 | `:28` (private) |
| `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE` | 30초 | `:40` (public, 인터페이스 상수) |
**같은 값이 두 곳에 있다.** 그리고 `MessagingShutdownLifecycle`(starter)은 셋 중 어느 것도 참조하지 않고 생성자 인자로 받는다. 세 번째 값이 프로퍼티에서 올 수 있다는 뜻이다 — 그 배선은 starter leaf가 소유한다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. 이 leaf는 브로커를 만지지 않는다 — 만지는 방법의 **모양**만 정의한다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-transport-spi:test`. **BUILD SUCCESSFUL, 24 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `MessagingRuntimeRegistryTest` | 9 | 세대 설치·대여·은퇴·드레인 계수 | 실제 브로커 연결 |
| `ResourceLeakGateTest` | 4 | 20세대 연속 회전 후 현재 세대만 열림, 누수 lease가 마감에 강제 종료, 막힌 작업도 드레인 종료, 은퇴 세대가 **정확히 한 번** close | 며칠 단위 실행 |
| `GracefulShutdownTest` | 5 | 드레인이 새 작업만 막고 실행 중은 완료, 재시도 금지, 마감 경계(29초 false / 30초 true), 유휴 코디네이터, 이중 `endWork` clamp | — |
| `MessagingLifecycleTest` | 6 | **enum 선언 순서와 상수 값** | **아무 종료 동작도 증명하지 않는다** |
### 10.1 `ResourceLeakGateTest`의 자기 규정
```java
// :12-18
* Every resource the platform holds is bounded by something that must eventually release it: a
* runtime generation by its last lease, an in-flight slot by its handler finishing, a drain by its
* deadline. Each of those has a failure mode that is invisible in a short test and fatal over days
* — a retired generation whose credential never gets revoked, a partition that never accepts work
* again, a shutdown that never completes.
```
세 자원과 각각의 해제 조건을 명시하고, "짧은 테스트에서 안 보이고 며칠이면 치명적"이라는 실패 성격까지 적는다. 20세대 회전 루프가 그 형태를 압축한 것이다.
### 10.2 `MessagingLifecycleTest`가 실제로 단언하는 것
여섯 테스트 중 다섯이 이 형태다.
```java
List