Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-transport-spi.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

42 KiB

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에 있다.

// MessagingTransport.java:10-12
 * <p>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<String,String> diagnosticAttributes()로 좌표를 문자열로만 내보내는 것과 같은 규율이다.

두 번째 경계는 인코딩 위치다.

// TransportDelivery.java:11-13
 * <p>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 시그니처에 직접 등장하기 때문이다 — TransportPublishRequestDestinationProfile(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 세대 모델: 회전은 변경이 아니라 교체다

// MessagingRuntime.java:5-8
 * <p>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의 유일한 실질 구현이고 동시성 설계가 조밀하다.

설치(교체)

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".

대여

Generation generation = current.computeIfPresent(brokerName, (key, value) -> {
  value.leases.incrementAndGet();
  return value;
});

computeIfPresent의 리맵 함수가 버킷 잠금 안에서 실행되므로, 조회와 증가가 원자적이다. get 후 증가였다면 그 사이에 install이 세대를 교체해 이미 은퇴한 세대의 계수를 올릴 수 있다.

해제

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로 멱등이다 — 두 층의 멱등성이다.

세대별 은퇴 시각

// Generation.retiredAt javadoc:180-183
 * <p>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.

이전 결함의 기록이다. 하나의 타임스탬프를 전체 목록에 적용하면 회전이 겹칠 때 판정이 호출자가 우연히 넘긴 값에 좌우된다.

닫힌 세대의 목록 제거

// 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()가 현재 세대까지 닫는다

// close() javadoc:131-134
 * <p>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()drainingsynchronized로 비우지만 currentList.copyOf(current.keySet()) 후 하나씩 remove한다. 그 사이에 install이 새 세대를 넣으면 그 세대는 닫히지 않는다. 종료 중 설치는 정상 시나리오가 아니므로 실질 위험은 낮다 — §17의 P3.

4.3 GracefulShutdownCoordinator: 세 단계와 그 이유

// GracefulShutdownCoordinator.java:12-22
 * <p>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.
 *
 * <p>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 <em>unsettled</em>, so
 * the broker redelivers it rather than the platform pretending it completed.
 *
 * <p>No retry attempt is created once draining begins. Starting a fresh attempt during shutdown
 * guarantees it will be abandoned at the deadline.

tryBeginWork이중 검사다.

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

// :65-67
 * <p>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단계 순서 계약

// MessagingLifecycle.java:8-15
 * <p>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.
 *
 * <p>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

// :8-10
 * <p>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-apiPauseResumeController"*"를 전체로 쓴다 — 두 인터페이스가 같은 개념에 다른 sentinel을 쓴다. PauseResumeController는 소비자가 0이므로(analysis/messaging/messaging-core-api.md §12.1) 오늘 충돌하지 않지만, 그것을 배선하려는 사람이 두 규약을 이어야 한다.

4.6 TransportSettlement: 애플리케이션에 노출되지 않는다

// :10-11
 * <p>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-apiSettlementController(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 마감 가시성

주목할 비대칭: DefaultMessagingRuntimeRegistrycurrent는 lock-free(ConcurrentHashMap)로, drainingsynchronized 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의 자기 규정

// :12-18
 * <p>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가 실제로 단언하는 것

여섯 테스트 중 다섯이 이 형태다.

List<ShutdownPhase> order = List.of(ShutdownPhase.values());
assertThat(order.indexOf(ShutdownPhase.DRAIN_HANDLERS))
    .as("flushing before the handlers finish would lose the settlements they produce")
    .isLessThan(order.indexOf(ShutdownPhase.FLUSH_SETTLEMENTS));

ShutdownPhase.values()소스에 상수가 적힌 순서를 반환한다. 이 단언이 검증하는 것은 "누군가 enum 상수를 이 순서로 타이핑했다"이다. 여섯 번째는 상수 값 비교(DEFAULT_DRAIN_DEADLINE == 30초)다.

as(...) 문구들은 실제 시스템 동작을 서술한다 — "flushing before the handlers finish would lose the settlements", "a confirm that arrives after close cannot be observed". 그러나 그 동작을 수행하는 코드가 없다(§12.1). 테스트 이름(handlersDrainBeforeTheirSettlementsAreFlushed)과 실제 단언(enum 인덱스 비교) 사이의 거리가 이 레인에서 가장 큰 항목이다.

이 여섯 테스트는 enum 상수 순서를 바꾸면 실패한다. 그리고 순서를 바꿔도 시스템 동작은 바뀌지 않는다 — 아무도 그 순서를 읽지 않기 때문이다. 게이트가 지키는 것과 게이트가 지킨다고 이름 붙인 것이 다르다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies 세 project 의존
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙 벤더 의존성 0이므로 대상 없음. 세 project 의존은 전부 api이고 시그니처에 실제로 등장
ArchUnit 전용 규칙 없음
MessagingLifecycle 구현 강제 없음 — 인터페이스는 컴파일 타임 강제를 만들지 않는다

마지막 행이 §12.1의 구조적 이유다. MessagingTransport는 어댑터가 구현하지 않으면 TransportMessagingRuntime이 컴파일되지 않는다. MessagingLifecycle은 아무도 받지 않으므로 구현하지 않아도 아무것도 깨지지 않는다.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/280-transport-spi-lifecycle-unimplemented.txt.

12.1 Public surface reachability

타입 leaf 밖 파일 수 판정
TransportPublishRequest 18 활발
TransportConsumerSpec 14 활발
MessagingTransport 12 4개 어댑터가 구현
GracefulShutdownCoordinator 11 활발
TransportPublishResult 10 활발
TransportDelivery 9 활발
TransportConsumerRegistration 8 활발
TransportSettlement 4 활발
MessagingRuntime 3 TransportMessagingRuntime이 구현
MessagingRuntimeRegistry 3
MessagingRuntimeLease 2
DefaultMessagingRuntimeRegistry 2
MessagingLifecycle 0 구현 없음, 소비자 없음

이 leaf는 messaging family에서 가장 잘 쓰이는 leaf 중 하나다. 13개 중 12개가 실제 소비자를 갖는다. 그래서 나머지 하나가 두드러진다.

MessagingLifecycle의 세 겹 부재

  1. git grep -E 'implements .*MessagingLifecycle' → exit 1. 구현체 없음.
  2. git grep -w ShutdownPhase -- src ':!src/messaging/messaging-transport-spi' → exit 1. 8단계 enum의 외부 소비자 없음.
  3. MessagingLifecycle의 저장소 전체 언급이 자기 선언과 자기 테스트 두 줄뿐.

한편 MessagingTransport는 넷이 구현한다 — KafkaMessagingTransport, RabbitMessagingTransport, PulsarMessagingTransport, NatsJetStreamTransport. 네 어댑터 중 어느 것도 MessagingLifecycle을 구현하지 않는다. javadoc이 "Each adapter implements the phases"라고 적은 그 어댑터들이다.

실제 종료 경로는 존재하고 다른 타입으로 되어 있다.

messaging-spring-boot-starterMessagingShutdownLifecycle implements SmartLifecycle이 종료를 수행한다.

public void stop() {
  if (!running.compareAndSet(true, false)) return;
  admission.stopAcceptingNewWork();          // ≈ phase 1
  Instant startedAt = clock.get();
  drain.beginDrain(startedAt);               // ≈ phase 2
  Instant deadline = startedAt.plus(drainDeadline);
  while (!drain.isDrained(clock.get()) && clock.get().isBefore(deadline)) { ... }  // ≈ phase 4
}

선언된 8단계와 대조:

# 선언 단계 실제 수행
1 STOP_PUBLISH_ADMISSION 수행admission.stopAcceptingNewWork()
2 STOP_NEW_HANDLERS 수행beginDrain 이후 tryBeginWork()가 false
3 PAUSE_CONSUMERS 명시적 호출 없음. 어댑터의 registrar가 자체 처리
4 DRAIN_HANDLERS 수행 — 폴링 루프
5 FLUSH_SETTLEMENTS 명시적 단계 없음
6 AWAIT_PRODUCER_CONFIRMS 명시적 단계 없음
7 RELEASE_OUTBOX_LEASES 명시적 단계 없음 — getPhase() javadoc이 outbox relay와의 상대 순서만 언급
8 CLOSE_CONNECTIONS Spring bean 소멸에 위임 — getPhase()Integer.MAX_VALUE - 1024로 transport보다 먼저 멈춤

8단계 중 셋이 명시적으로 수행되고, 하나는 Spring 단계 순서에 위임되며, 넷은 명시적 단계가 없다. 그리고 순서를 결정하는 것은 ShutdownPhase enum이 아니라 Spring의 getPhase() 정수다.

MessagingShutdownLifecycle의 javadoc이 자기 순서를 스스로 설명한다 — "The order is admission first, drain second. Reversed, the drain waits for a count that new work keeps topping up." 두 단계에 대해서만 순서를 논한다.

한계. PAUSE_CONSUMERS·FLUSH_SETTLEMENTS·AWAIT_PRODUCER_CONFIRMS가 어댑터 내부에서 다른 이름으로 수행될 수 있다. KafkaConsumerRegistrarRabbitConsumerRegistrarGracefulShutdownCoordinator를 쓰므로 그 leaf들이 답을 갖는다. 이 문서는 ShutdownPhase가 그 순서를 결정하지 않는다만 주장한다.

12.2 Conditional sibling comparison

Spring 주석 0개, bean 없음.

MessagingTransport 구현 sibling 넷의 비대칭이 관측된다.

어댑터 MessagingTransport registry membership
KafkaMessagingTransport o ["app-bootstrap"]
RabbitMessagingTransport o ["app-bootstrap"]
PulsarMessagingTransport o []
NatsJetStreamTransport o []

넷 다 같은 SPI를 구현하고 둘만 편입된다 — docs/messaging/support-matrix.md의 experimental 구분과 정합한다. 각 어댑터의 조건부 활성화는 해당 leaf SSOT가 소유한다.

12.3 Duplicate mechanism sweep

(a) 드레인 마감 30초가 세 곳에 있다

위치 가시성
MessagingLifecycle.DEFAULT_DRAIN_DEADLINE public 인터페이스 상수
DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE private
MessagingShutdownLifecycle(starter) 생성자 인자

public 상수가 있는데 같은 leaf의 다른 클래스가 자기 private 복사본을 쓴다. MessagingLifecycleTest의 여섯 번째 테스트가 public 쪽만 고정한다 — private 쪽이 바뀌어도 통과한다.

(b) 정산 인터페이스가 둘

인터페이스 leaf 연산
TransportSettlement 이 leaf acknowledge / requeue(delay) / discard
SettlementController messaging-core-api ack / retry(delay) / deadLetter(failure) / reject(failure)

책임이 다르다 — 전자는 어댑터 원시 연산, 후자는 M2 수동 정산 API이고 deadLetter가 추가돼 있다. 중복이 아니라 계층이다. 다만 SettlementController는 소비자가 0이므로(messaging-core-api §12.1) 오늘 계층의 위쪽이 비어 있다.

(c) pause scope sentinel이 둘

인터페이스 전체를 뜻하는 값
TransportConsumerRegistration.pause(String scope) 빈 문자열
PauseResumeController.pause(dest, String scope) (core-api) "*"

두 javadoc이 각각 명시한다. 이으려면 변환이 필요하고, 그 변환 코드는 없다(PauseResumeController 소비자 0).

(d) 드레인 조정 로직

GracefulShutdownCoordinator가 유일하다. 저장소의 다른 곳에서 in-flight 계수 + 마감 패턴을 다시 만든 곳은 messaging family 안에 없다. 다른 family(grpc의 admission controller 등)와의 비교는 cross-scope가 소유한다.

12.4 Documentation / measured-count drift

문서 주장 재측정 결과
MessagingLifecycle javadoc: "Each adapter implements the phases" 4개 어댑터 중 0개 구현 불일치
MessagingLifecycle javadoc: "The order in ShutdownPhase is the contract" 그 순서를 읽는 코드 0 불일치
MessagingTransport javadoc: 네이티브 클라이언트 미반환 13개 타입 시그니처 전수 확인 일치
TransportDelivery javadoc: 디코딩이 transport 위에서 TransportDelivery.envelopeMessageEnvelope<EncodedMessage> 일치
TransportSettlement javadoc: 애플리케이션에 미노출 이 leaf가 ..application..에서 참조 0(ArchUnit이 금지) 일치
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치(family drift, messaging-core-api §12.4가 소유)

13. Git/설계 문서에서 확인한 변화와 실패 기록

코드 주석이 네 결함을 보존한다. 전부 장기 실행에서만 드러나는 종류다.

위치 이전 상태 그것이 만든 실패
Generation.retiredAt javadoc 호출자가 넘긴 하나의 retiredAt을 전체 draining 목록에 적용 드레인 중 회전이 겹치면, 방금 은퇴한 세대를 강제 종료하거나 오래된 세대에 새 마감을 주거나 — 호출자가 우연히 넘긴 타임스탬프에 좌우
closeExpiredDraining 주석 이미 닫힌 세대가 목록에 잔류 drainingCount()가 끝난 작업을 영원히 보고
close() javadoc 회전이 은퇴시킨 것만 닫음 회전 없이 종료한 프로세스가 브로커 연결을 JVM 종료에 맡김 → 미전송 producer 배치 소실, consumer 세션이 그룹을 떠나지 않고 브로커에서 타임아웃
endWork javadoc clamp 없음 이중 해제가 계수를 음수로 → 작업이 도는 중에 드레인 완료로 보고

네 번째와 LeakTrackingRuntime.closeCount() javadoc("Closing twice is as much a defect as never closing")이 같은 주제를 반대편에서 말한다 — 해제는 정확히 한 번이어야 하고, 0번도 2번도 결함이다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-280 command evidence/raw/280-transport-spi-lifecycle-unimplemented.txt 13개 타입 참조 수, MessagingLifecycle 구현 0(exit=1)·ShutdownPhase 외부 소비자 0(exit=1), 순서 테스트가 실제로 단언하는 것, 배선된 종료 경로와 그 4개 호출 정적 git grep. 어댑터 내부의 pause/flush 수행 여부는 각 leaf가 답함
EVD-279 command ./gradlew :messaging:messaging-transport-spi:test --rerun-tasks BUILD SUCCESSFUL, 24 / 0 / 0 실제 브로커 없음

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 네이티브 클라이언트를 반환하지 않는 이유 — MessagingTransport javadoc
  • 디코딩이 transport 위에서 일어나는 이유 — TransportDelivery javadoc
  • 어댑터가 codec/limit을 고르지 않는 이유 — TransportPublishRequest javadoc
  • 회전이 세대 교체인 이유 — MessagingRuntime javadoc
  • lease가 세대를 pin하는 이유 — MessagingRuntimeLease javadoc
  • 드레인 마감이 필요한 이유, 재시도 금지 이유 — GracefulShutdownCoordinator javadoc
  • 종료 3단계 중 중간 단계를 건너뛰면 생기는 일 — 같은 javadoc
  • 순서 단위별 pause가 필요한 이유 — TransportConsumerRegistration javadoc
  • TransportSettlement을 애플리케이션에 노출하지 않는 이유 — 그 javadoc
  • 네 개의 이전 결함 — §13

추론

  • MessagingLifecycle이 미구현인 것은 MessagingShutdownLifecycle이 Spring SmartLifecycle로 같은 일을 다르게 하기로 했기 때문이다 → 추론. 두 타입의 존재와 후자의 배선은 관측이고, 전자를 버린 결정은 어디에도 기록되지 않았다.
  • current는 lock-free, drainingsynchronized인 이유 → 추론(경합 빈도 차이). 주석 없음.
  • pause sentinel이 둘인 이유 → 미상.

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

확인한 것

  • 13개 타입 776줄 전문의 계약과 불변식
  • 24개 테스트가 통과하고 무엇을 단언하는지, 그리고 MessagingLifecycleTest가 enum 선언 순서만 단언한다는 것
  • MessagingLifecycle 구현 0, ShutdownPhase 외부 소비자 0 (둘 다 exit 1로 확인)
  • 실제 배선된 종료 경로(MessagingShutdownLifecycle)가 8단계 중 셋을 명시적으로 수행하고 하나를 Spring 단계에 위임한다는 것
  • 참조 계수·CAS·이중 검사·clamp의 동시성 설계와 그것을 만든 네 개의 이전 결함

확인하지 못한 것

  • 어댑터가 PAUSE_CONSUMERS·FLUSH_SETTLEMENTS·AWAIT_PRODUCER_CONFIRMS를 다른 이름으로 수행하는지. KafkaConsumerRegistrar·RabbitConsumerRegistrarGracefulShutdownCoordinator를 쓰는 것은 확인했으나 그 내부는 각 leaf가 소유한다.
  • 실제 종료에서 이 순서가 지켜지는지. 컨테이너 레인(KafkaBrokerIT, KafkaConsumerSettlementIT)이 있으나 이번 분석에서 실행하지 않았다.
  • MessagingLifecycle을 남겨 둔 것이 의도인지, 미완인지.
  • close()install()이 동시에 일어나는 경우의 실제 빈도. 코드상 창은 존재한다(§4.2).

17. 손볼 것

P2 — 8단계 종료 순서 계약을 구현하는 것이 없고, 그것을 검증한다는 테스트는 enum 선언 순서만 본다

  • 사실. MessagingLifecycle은 8단계 종료 순서를 선언하고 javadoc이 "The order in ShutdownPhase is the contract, not an implementation detail. … Each adapter implements the phases; none of them chooses the order"라고 적는다. 저장소에 구현체가 없고(git grep -E 'implements .*MessagingLifecycle' exit 1), ShutdownPhase의 외부 소비자도 없다(exit 1). MessagingTransport를 구현하는 네 어댑터 중 어느 것도 이 인터페이스를 구현하지 않는다. MessagingLifecycleTest의 다섯 순서 테스트는 전부 List.of(ShutdownPhase.values()).indexOf(A) < indexOf(B) 형태로, 소스에 상수가 적힌 순서를 단언한다.
  • 근거. evidence/raw/280-transport-spi-lifecycle-unimplemented.txt §B·§C.
  • 왜 문제인가. 세 겹이다.
    • 실제 종료는 MessagingShutdownLifecycle(starter)이 하고, 8단계 중 셋만 명시적으로 수행한다(admission 정지 · 새 핸들러 정지 · 드레인). 나머지는 Spring getPhase() 정수와 bean 소멸 순서에 위임되거나 명시 단계가 없다. 순서를 결정하는 것은 ShutdownPhase가 아니다.
    • 테스트 이름과 as(...) 문구가 시스템 동작을 서술한다("flushing before the handlers finish would lose the settlements they produce"). 통과하는 것은 그 동작이 아니라 타이핑 순서다. 이 여섯 테스트는 enum 상수를 재배열하면 실패하고, 재배열해도 시스템은 바뀌지 않는다 — 게이트가 지키는 것과 이름이 어긋난다.
    • 인터페이스는 컴파일 강제를 만들지 않는다. MessagingTransport는 구현 안 하면 빌드가 깨지고, 이것은 아무것도 깨지지 않는다.
  • 확인 방법. evidence/raw/280 재실행. 또는 git grep -n -w MessagingLifecycle -- src → 두 줄(자기 선언, 자기 테스트).
  • 후보. (a) 네 어댑터가 MessagingLifecycle을 구현하고 MessagingShutdownLifecycleshutdown(deadline)을 호출하게 한다. (b) 인터페이스를 제거하고 순서 규칙을 MessagingShutdownLifecycle과 각 registrar의 계약으로 옮긴다. (c) 인터페이스를 "미실현 설계"로 표시하고 테스트가 enum 순서만 본다는 것을 이름과 javadoc에 반영한다.
  • 다음 단계. CASE 후보 + REFERENCE 후보. Case는 "선언된 순서 계약과 실제 종료 경로의 불일치"이고, Reference는 "enum 선언 순서를 단언하는 테스트는 그 순서를 읽는 코드가 있을 때만 게이트다"이다.

P3 — 드레인 마감 30초가 세 곳에서 독립적으로 결정된다

  • 사실. MessagingLifecycle.DEFAULT_DRAIN_DEADLINE(public), DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE(private), MessagingShutdownLifecycle의 생성자 인자.
  • 근거. 세 위치.
  • 왜 문제인가. public 상수가 같은 leaf 안에 있는데 다른 클래스가 자기 private 복사본을 쓴다. MessagingLifecycleTest.theDefaultDrainDeadlineMatchesTheDesign이 public 쪽만 고정하므로 private 쪽이 바뀌어도 통과한다. 그리고 §17 첫 항목대로 public 상수가 있는 인터페이스는 구현체가 없다 — 즉 살아 있는 값(private)이 죽은 인터페이스의 값(public)을 참조하지 않는다.
  • 확인 방법. git grep -n 'DEFAULT_DRAIN_DEADLINE' -- 'src/messaging/**/*.java'
  • 후보. registry가 MessagingLifecycle.DEFAULT_DRAIN_DEADLINE를 참조하거나, 값의 주인을 한 곳으로 정한다.
  • 다음 단계. 첫 항목과 같은 사건의 일부다 → 그 CASE에 MERGED 후보.

P3 — 종료 중 install이 닫히지 않는 창

  • 사실. close()drainingsynchronized로 비우고, currentList.copyOf(current.keySet()) 후 개별 remove한다. 그 사이 install이 새 세대를 넣으면 그 세대는 닫히지 않는다.
  • 근거. DefaultMessagingRuntimeRegistry.java:141-156.
  • 왜 문제인가. 종료 중 회전은 정상 시나리오가 아니므로 실질 위험이 낮다. 다만 이 클래스의 다른 모든 경로가 "정확히 한 번 close"를 CAS로 보장하는 것과 대비되고, 남는 것은 닫히지 않은 브로커 연결이다 — §13의 세 번째 결함과 같은 결과다.
  • 확인 방법. 코드 검토. 테스트로 재현하려면 close()install을 끼워 넣어야 한다.
  • 후보. close()에 종료 플래그를 두고 install이 그 이후에는 즉시 runtime.close()하도록 한다.
  • 다음 단계. REFERENCE 후보(멱등 종료를 보장하는 컴포넌트는 종료 이후의 등록도 정의한다).

P3 — pause scope sentinel이 두 인터페이스에서 다르다

  • 사실. TransportConsumerRegistration.pause는 빈 문자열이 전체, PauseResumeController.pause(core-api)는 "*"가 전체.
  • 근거. 두 javadoc.
  • 왜 문제인가. PauseResumeController가 소비자 0이므로 오늘 충돌하지 않는다. 그것을 배선하려는 사람이 변환을 넣어야 하고, 빠뜨리면 "*"가 이름이 "*"인 파티션을 가리키게 된다 — 실패하지 않고 아무것도 일시정지하지 않는다.
  • 확인 방법. 두 javadoc 대조.
  • 후보. sentinel을 통일하거나 Optional<String>으로 바꾼다.
  • 다음 단계. REFERENCE 후보(같은 개념의 sentinel은 계층을 넘어 하나로 정한다).

확인된 설계(문제 아님)

  • 네이티브 클라이언트를 반환하지 않는 SPI 경계
  • 인코딩/디코딩을 transport 밖에 두어 실패 분류를 플랫폼이 소유하는 것
  • 세대 교체 + 참조 계수 + CAS로 "정확히 한 번 close"를 보장하는 것과, 그것을 20세대 회전으로 확인하는 테스트
  • computeIfPresent로 조회-증가를 원자화한 것
  • tryBeginWork의 이중 검사와 endWork의 clamp
  • 마감 도달 작업을 정산하지 않고 버려 브로커가 재전달하게 하는 것
  • 세대별 retiredAt과 닫힌 세대의 목록 제거

Source anchors

id kind path revision what it proves limitations
MTS-001 registry src/config/architecture/modules.json 21234e38 deps 3개, memberships ["app-bootstrap"] 선언
MTS-002 build messaging-transport-spi/build.gradle same 벤더 의존성 0, 세 project 의존이 전부 api
MTS-003 code .../transport/MessagingTransport.java same SPI 경계와 네이티브 미노출
MTS-004 code .../transport/DefaultMessagingRuntimeRegistry.java 전문 same §4.2 동시성 설계 전부와 세 개의 이전 결함 종료 중 install 창(§17)
MTS-005 code .../transport/GracefulShutdownCoordinator.java 전문 same §4.3 드레인 계약과 clamp 결함 이력
MTS-006 code .../transport/MessagingLifecycle.java same 8단계 선언과 "order is the contract" 진술 구현 없음(§12.1)
MTS-007 code .../transport/Transport*.java (6) same 발행·수신·정산 계약
MTS-008 test MessagingRuntimeRegistryTest (9) same 세대 관리 실제 브로커 없음
MTS-009 test ResourceLeakGateTest (4) same 20세대 회전, 누수 lease 강제 종료, 정확히 한 번 close 며칠 단위 아님
MTS-010 test GracefulShutdownTest (5) same 드레인 경계 29/30초, 이중 endWork clamp
MTS-011 test MessagingLifecycleTest (6) same enum 선언 순서와 상수 값만 종료 동작 미증명(§10.2)
MTS-012 cross-leaf code messaging-spring-boot-starter/.../MessagingShutdownLifecycle.java 전문 same 실제 배선된 종료 경로와 그것이 수행하는 3단계, getPhase() 위임 해당 leaf SSOT가 소유
MTS-013 cross-leaf code 4개 *MessagingTransport.java same SPI 구현 넷, MessagingLifecycle 구현 0 각 leaf SSOT가 소유
EVD-280 command evidence/raw/280-transport-spi-lifecycle-unimplemented.txt same §12.1 전부, exit code 포함 정적 검색
EVD-279 command ./gradlew :messaging:messaging-transport-spi:test --rerun-tasks same 24 / 0 / 0 브로커 없음