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>
42 KiB
messaging-transport-spi 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-transport-spiSSOT owner:messaging-transport-spiintegration/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 시그니처에 직접 등장하기 때문이다 — 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 세대 모델: 회전은 변경이 아니라 교체다
// 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()가 draining은 synchronized로 비우지만 current는 List.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-api의 PauseResumeController는 "*"를 전체로 쓴다 — 두 인터페이스가 같은 개념에 다른 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-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의 자기 규정
// :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의 세 겹 부재
git grep -E 'implements .*MessagingLifecycle'→ exit 1. 구현체 없음.git grep -w ShutdownPhase -- src ':!src/messaging/messaging-transport-spi'→ exit 1. 8단계 enum의 외부 소비자 없음.MessagingLifecycle의 저장소 전체 언급이 자기 선언과 자기 테스트 두 줄뿐.
한편 MessagingTransport는 넷이 구현한다 — KafkaMessagingTransport, RabbitMessagingTransport, PulsarMessagingTransport, NatsJetStreamTransport. 네 어댑터 중 어느 것도 MessagingLifecycle을 구현하지 않는다. javadoc이 "Each adapter implements the phases"라고 적은 그 어댑터들이다.
실제 종료 경로는 존재하고 다른 타입으로 되어 있다.
messaging-spring-boot-starter의 MessagingShutdownLifecycle 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가 어댑터 내부에서 다른 이름으로 수행될 수 있다. KafkaConsumerRegistrar와 RabbitConsumerRegistrar가 GracefulShutdownCoordinator를 쓰므로 그 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.envelope이 MessageEnvelope<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. 명시적 설계 이유와 추론을 구분한 정리
명시적
- 네이티브 클라이언트를 반환하지 않는 이유 —
MessagingTransportjavadoc - 디코딩이 transport 위에서 일어나는 이유 —
TransportDeliveryjavadoc - 어댑터가 codec/limit을 고르지 않는 이유 —
TransportPublishRequestjavadoc - 회전이 세대 교체인 이유 —
MessagingRuntimejavadoc - lease가 세대를 pin하는 이유 —
MessagingRuntimeLeasejavadoc - 드레인 마감이 필요한 이유, 재시도 금지 이유 —
GracefulShutdownCoordinatorjavadoc - 종료 3단계 중 중간 단계를 건너뛰면 생기는 일 — 같은 javadoc
- 순서 단위별 pause가 필요한 이유 —
TransportConsumerRegistrationjavadoc TransportSettlement을 애플리케이션에 노출하지 않는 이유 — 그 javadoc- 네 개의 이전 결함 — §13
추론
MessagingLifecycle이 미구현인 것은MessagingShutdownLifecycle이 SpringSmartLifecycle로 같은 일을 다르게 하기로 했기 때문이다 → 추론. 두 타입의 존재와 후자의 배선은 관측이고, 전자를 버린 결정은 어디에도 기록되지 않았다.current는 lock-free,draining은synchronized인 이유 → 추론(경합 빈도 차이). 주석 없음.- 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·RabbitConsumerRegistrar가GracefulShutdownCoordinator를 쓰는 것은 확인했으나 그 내부는 각 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 정지 · 새 핸들러 정지 · 드레인). 나머지는 SpringgetPhase()정수와 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을 구현하고MessagingShutdownLifecycle이shutdown(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()가draining은synchronized로 비우고,current는List.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 | 브로커 없음 |