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

19 KiB

messaging-nats-experimental 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 7파일 755줄 축자 통독 완료. test 2파일 460줄 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-nats-experimental SSOT owner: messaging-nats-experimental integration/family document: analysis/19-messaging-platform.md (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • 선언 의존: messaging 계열 project 7 + vendor jnats:2.26.2
  • runtime_memberships: [] — build-only · 등급 EXPERIMENTAL
파일 LOC
NatsJetStreamTransport 295
NatsJetStreamProfile 103
NatsMaxDeliverParkingWorkflow 85
NatsJetStreamProfileValidator · NatsStreamPosition 75 · 75
NatsPreSendRejection 65
NatsAckMode 57
main 합계 755
NatsAdapterContractTest · NatsMaxDeliverParkingTest 337 · 123

Coverage ledger

scope count disposition reason
main/java/** 7 FULL_READ 755줄 전 본문
test/java/** 2 FULL_READ 460줄 전 본문 · 테스트 28개
build.gradle 1 FULL_READ 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 이 어댑터의 판단 셋

JetStream 만 쓴다.

"A core publish returns as soon as the bytes are written to the socket, with no persistence and no acknowledgement, so an adapter using it would report success for messages that were never stored — the failure is total and silent."

거부 코드는 NatsJetStreamProfileValidator.validate 에 있다 — 최소 한 번 배달 목적지에 코어 NATS 는 안 된다. 다만 그 검증기를 호출하는 곳이 저장소에 하나도 없다(§17.3). 이 절이 서술하는 것은 판단이 코드로 적혀 있다는 사실이지, 그 판단이 실행 경로에 걸려 있다는 사실이 아니다.

확인은 지속 증거다. 발행 승인이 메시지가 안착한 스트림과 순번을 이름 짓는다. 소켓에 바이트를 쓴 영수증이 아니다.

기본 실패는 모호다. 사전 거절 타입만 확실히 전송되지 않음으로 다루고 나머지는 전부 모호다.

"a caller that reads REJECTED may republish under a new identity and duplicate a message the server already stored."

2. 죽은 편지가 없는 브로커에서 죽은 편지를 만든다

NatsMaxDeliverParkingWorkflow javadoc:

"JetStream has no dead-letter queue. When a message hits maxDeliver the server terminates it: no redelivery, no routing, no record beyond an advisory. Every other broker in this platform parks a poison message somewhere an operator can find it, and this workflow is what makes NATS behave the same way."

핵심은 시점이다.

"The parking therefore happens on the delivery before the limit, not on the limit itself. Acting at maxDeliver would mean acting on the delivery JetStream is about to discard, so any failure in the dead-letter publish would lose the message outright."

그래서 프로파일이 maxDeliver < 2 를 거부한다 — 플랫폼이 주차할 여유 배달이 최소 하나 있어야 한다.

그리고 정착은 죽은 편지 발행이 확인된 뒤에만 허용된다.

"Terminating first would discard the message on a broker that cannot redeliver it, which is the one irreversible mistake available here."

세 번째 결과 ALREADY_TERMINATED 는 살아 있는 소비자 아래에서 프로파일이 바뀐 경우에만 도달한다. 회복할 것이 없고, 재배달로 오인되지 않도록 결과로 남긴다.

3. 능력 선언

CAPABILITIES = (true, true, true, true, true, false, true, false, false, true, false, true);

nativeDeadLetter=false 의 근거가 클래스 javadoc 과 검증기 javadoc 양쪽에 있다 — 없는 큐를 찾아 나서게 만들지 않는다.

keyedOrdering=false 도 검증기가 강제한다 — 키 순서를 요구하는 목적지를 거부한다.

deduplicatedPublish=true 는 §17.1 이 다룬다.

4. 프로파일이 스스로 거부하는 것

NatsJetStreamProfile 은 record 이고, 압축 생성자가 이 어댑터의 불변식을 전부 들고 있다. 검증기가 호출되지 않는 지금, 실제로 실행되는 유일한 게이트가 여기다.

if (!ackMode.supportsAtLeastOnce()) throw ;              // NONE · ALL 거부
if (ackWait.isNegative() || ackWait.isZero()) throw ;
if (maxDeliver < 2) throw ;                              // "headroom"
if (deduplicationWindow.isPresent() && isZero()) throw ; // 설정했으면 양수

ackMode 거부 사유는 NatsAckMode 자신이 문장으로 들고 있고(rejectionReason()), 프로파일이 그 문장을 예외 메시지에 그대로 싣는다. NONE 은 "forgotten", ALL 은 "still in flight" — 테스트가 그 두 낱말로 각각 걸어 잠근다.

PARKING_HEADROOM = 1 상수와 parkAtDelivery() = maxDeliver - PARKING_HEADROOM 가 §2 의 시점 선택을 숫자로 못 박는다. NatsMaxDeliverParkingWorkflow.parkingThreshold() 는 이 값을 그대로 위임한다 — 임계값의 정의가 한 곳에만 있다.

주의할 비대칭. 편의 팩토리 durable(subject, stream, durableName) 는 중복 제거 창을 Optional.of(2분) 으로 채운다. 즉 팩토리를 거친 프로파일은 §17.1 의 구멍에 빠지지 않는다. 그러나 팩토리에도 호출자가 없고(저장소 전역 grep 0건), 정규 생성자는 빈 창을 정상값으로 받는다. 기본 경로가 안전하다는 사실이 그 구멍을 닫아 주지 않는다.

10. 테스트 레인

두 테스트 460줄 · 28개.

NatsAdapterContractTest 17개 — 지속 증거(REPLICATION_OR_PERSISTENCE_ACK), 위치 반환, 시간 초과의 모호 판정, 사전 거절만이 NOT_TRANSMITTED 라는 것, 감싸인 미지 실패의 모호 판정, 호출자 마감의 유효성, 중복 제거 식별자 유무, 초과 페이로드 거절, 능력 두 개, 닫힌 전송, 재배달 인식, 순번 하한, 실패 범주.

전송은 (subject, deduplicationId, request) -> CompletionStage<NatsStreamPosition> 람다로 주입된다. 실제 JetStream 클라이언트는 이 리프에 없고, 테스트가 성공·실패·영영 안 끝남을 직접 만든다.

두 테스트가 회귀를 이름으로 기록한다 — aFailureNamedLikeAKnownOneIsStillAmbiguous 는 "예외 클래스 이름이 분류자였던" 과거를, aPublishThatNeverCompletesIsBoundedByTheCallersTimeout 은 "호출자 마감이 아예 무시되던" 과거를 주석에 남긴다. 셋째 회귀 기록은 어셈블이 비어 있다(§17.4).

NatsMaxDeliverParkingTest 11개 — 한계 직전 주차, 한계 자체도 주차, 한계 초과의 ALREADY_TERMINATED, 확인 뒤 정착, maxDeliver=1 거부, 배달 수 하한, 임계값, ackMode 세 값.

NatsJetStreamProfileValidator 를 세우는 테스트는 없다.

12. negative-space probes

12.1 도달성. dev.caskeleton.messaging.nats 를 import 하는 코드가 리프 밖에 없다. 리프 밖에서 이 모듈이 등장하는 곳은 세 군데인데 전부 이름 문자열이다 — config/architecture/modules.json 의 등록, messaging-testkit/CompatibilityMatrix("messaging-nats-experimental", List.of("2.14"), Tier.EXPERIMENTAL, false, false) 항목, 그리고 그 표를 문서와 대조하는 MessagingDocumentationContractTest. 즉 등급표가 이 어댑터를 알고 있을 뿐, 어떤 실행 경로도 이 클래스들에 닿지 않는다. build-only · experimental 표기 그대로다.

12.2 대조군 — 자매 실험 어댑터. messaging-pulsar-experimental 과 구조가 같다 — 주입되는 전송 연산, 타입 있는 사전 거절, 기본 모호, 실험 등급 게이트. 차이는 능력 선언의 출처다. Pulsar 는 전송과 검증기가 서로 다른 값을 답하고(그쪽 §17.1), NATS 는 두 곳이 같은 값을 답한다.

다만 그 일치는 공유가 아니라 복사다. NatsJetStreamTransport.CAPABILITIES 상수와 NatsJetStreamProfileValidator.capabilities() 가 열두 개 불리언 리터럴을 각자 손으로 적어 두었고, 둘을 묶는 것은 아무것도 없다. 오늘 같은 값인 것이 내일도 같으리라는 보장은 코드에 없다 — Pulsar 가 이미 그 갈라짐의 실물이다.

이쪽의 문제는 따로 있다. 그 값이 프로파일에서 파생되지 않는다는 것이다(§17.1).

12.4 드리프트. 실험 등급 표기와 코드가 일치한다.

16. 확인하지 못한 것

  • 실제 JetStream 서버를 띄우지 않았다. 클라이언트 브리지를 싣지 않는 리프다.
  • 중복 제거 창이 없는 프로파일로 모호 재발행을 재현하지 않았다. 능력 상수와 deduplicationId 구현으로 판정했다.
  • 검증기를 부르는 조립 지점이 다른 형태(설정 클래스 · 스타터)로 어딘가에 있을 가능성은 클래스 이름 · 패키지 이름 두 가지 grep 으로만 배제했다. 리플렉션이나 문자열 기반 조립이라면 잡히지 않는다.
  • 테스트를 실행하지 않았다. §17.4 의 "항상 통과"는 어셈블 의미론으로 판정한 것이다.

17. 손볼 것

17.1 P2 — deduplicatedPublish 를 무조건 참으로 선언하는데 실제 중복 제거는 프로파일에 창이 있을 때만 일어난다

능력은 상수다.

private static final MessagingCapabilities CAPABILITIES =
    new MessagingCapabilities(true, true, true, true, true, false, true, false, false, true, false, true);
//                                                                             ^^^^ deduplicatedPublish

검증기의 capabilities() 도 같은 값을 돌려준다.

그런데 중복 제거 식별자는 프로파일에 창이 있을 때만 만들어진다.

private Optional<String> deduplicationId(TransportPublishRequest request) {
  return profile.deduplicationWindow().map(window -> request.envelope().messageId().value().toString());
}

NatsJetStreamProfile.deduplicationWindowOptional<Duration> 이고, 비어 있는 것이 정상 상태다 — 프로파일 생성자도 검증기도 창을 요구하지 않는다. 창이 없으면 Nats-Msg-Id 가 실리지 않고 서버는 중복을 제거하지 않는다.

즉 능력 선언이 프로파일과 무관하게 참이다.

왜 이 플래그인가. 이 저장소에서 능력 열두 개 중 부재가 예외를 만드는 유일한 것이 deduplicatedPublish 다(DefaultMessagePublisher:250). 나머지는 읽히지 않거나 분기에 쓰인다. 그러므로 이 플래그의 과대 선언은 다른 어느 플래그의 과대 선언보다 직접적이다 — 창 없는 목적지가 그 가드를 통과한다.

그리고 어댑터 자신이 그 조건을 알고 있다. 클래스 javadoc:

"A publish that times out is AMBIGUOUS: JetStream may have stored it and lost only the acknowledgement, and the deduplication window is what makes retrying it safe when the profile enables one."

"when the profile enables one" 이 정확히 능력이 담지 않은 조건이다. 창이 없는 목적지에서 모호를 재시도하면 스트림에 같은 메시지가 두 번 들어간다.

MessagingCapabilities 의 클래스 javadoc 이 이 상황을 미리 서술한다 — "a silently weakened guarantee is indistinguishable from a working one until the incident."

테스트가 두 쪽을 동시에 못 박는다. NatsAdapterContractTest 안에서, 같은 빈 창 프로파일(confirming(Optional.empty()))에 대해:

void theAdapterAdvertisesDeduplicatedPublish() {
  assertThat(confirming(Optional.empty()).capabilities().capabilities()
      .deduplicatedPublish()).isTrue();          // 능력은 참이라고 한다
}

void noDeduplicationWindowSendsNoDeduplicationId() {
  confirming(Optional.empty()).publish(request(64));
  assertThat(capturedDeduplicationIds).singleElement()
      .satisfies(id -> assertThat(id).isEmpty()); // 선에는 아무것도 안 실린다
}

둘 다 통과한다. 모순이 우연히 남은 것이 아니라 테스트로 고정되어 있다는 뜻이고, 수정할 때 함께 고쳐야 할 지점이 어디인지도 이 두 개가 알려 준다.

팩토리는 이 구멍을 메우지 않는다. NatsJetStreamProfile.durable(...) 는 창을 2분으로 채워 주지만 호출자가 없고, 정규 생성자는 빈 창을 정상값으로 받는다(§4).

수정. 능력을 프로파일에서 파생시킨다.

new MessagingCapabilities(, profile.deduplicationWindow().isPresent(), )

또는 검증기가 최소 한 번 배달 목적지에 중복 제거 창을 요구한다. 후자는 코어 NATS 거부와 같은 형태의 시작 시점 거부다.

17.2 P3 — 닫힌 전송의 거절이 영구 업무 실패로 분류된다

rejectedLocallyFailureCategory.PERMANENT_BUSINESS 를 고정으로 쓰고, 두 호출자 중 하나가 NATS_TRANSPORT_CLOSED 다.

자매 어댑터(Pulsar)와 같은 형태이고 같은 판단이다 — 종료 중이라는 것은 이 세대의 사정이지 업무의 영구 실패가 아니다. 같은 파일의 classify 는 범주를 신중히 나눈다.

두 리프가 같은 형태를 공유하므로 수정도 함께 하는 편이 낫다.

17.3 P2 — NatsJetStreamProfileValidator 를 호출하는 곳이 저장소에 없다. javadoc 링크 하나가 유일한 흔적이다

75줄짜리 검증기가 이 어댑터의 시작 시점 판단 넷을 들고 있다 — 실험 스위치가 꺼져 있으면 거부, 최소 한 번 배달에 코어 NATS 거부, 순서 있는 소비자와 경쟁 작업자 동시 사용 거부, 키 순서 목적지 거부.

저장소 전역에서 이 클래스 이름이 나오는 곳은 두 줄뿐이다.

NatsJetStreamTransport.java:35:  * is why {@link NatsJetStreamProfileValidator} refuses the combination at startup.
NatsJetStreamProfileValidator.java:21: public final class NatsJetStreamProfileValidator {

하나는 선언이고 하나는 javadoc 링크다. 코드 호출자 0, 테스트 0.

validatejetStreamEnabled · orderedConsumer · competingWorkers · enabled 를 전부 인자로 받는다. 즉 스스로 아무것도 관찰하지 않고, 호출자가 이미 알고 있는 사실을 넘겨 줘야만 판단한다. 그런 호출자가 없으니 이 판단들은 한 번도 실행된 적이 없다.

왜 P2 인가. 전송의 클래스 javadoc 이 "그래서 검증기가 시작 시 그 조합을 거부한다"고 단언한다. 읽는 사람에게 이 어댑터는 코어 NATS 오설정으로부터 보호되는 것처럼 보이는데, 실제로는 아무 게이트도 걸려 있지 않다. 실험 등급이라 지금 당장 사고가 나지는 않지만, 이 어댑터를 실전에 붙이는 사람이 가장 먼저 신뢰할 문장이 지금 사실이 아니다.

같은 형태를 이 저장소에서 여러 번 봤다 — 채점기는 있는데 그 채점기에 값을 넣어 주는 생산자가 없는 구조(GrpcRawApiImportRule · GrpcApplicationBoundaryRules · GrpcNettyParityContract 등). 이쪽이 더 나쁜 쪽인 이유는 그 리프들에서는 최소한 테스트가 리터럴을 먹여 판단 자체는 실행해 보는데, 여기서는 그것조차 없다는 점이다.

수정. 어댑터 조립 지점에서 validate 를 부르거나, 그럴 지점이 아직 없다면 최소한 프로파일 생성 시점에 걸리도록 옮긴다(§4 의 압축 생성자가 이미 실행되는 유일한 게이트다). 어느 쪽도 못 하겠다면 전송 javadoc 의 "refuses ... at startup" 을 사실에 맞게 고친다.

17.4 P3 — 경과 시간 회귀를 막으려는 어셈블이 항상 참이다

@Test
void theReportedElapsedTimeIsMeasuredRatherThanZero() {
  PublishResult result = await(failingWith(new TimeoutException("no ack")).publish(request(64)));

  assertThat(result.elapsed())
      .as("every outcome reported Duration.ZERO, so latency evidence was fiction")
      .isGreaterThanOrEqualTo(Duration.ZERO);
}

as(...) 가 막으려는 회귀는 "모든 결과가 Duration.ZERO 를 보고하던 것"이다. 그런데 어셈블은 >= Duration.ZERO 다. Duration.ZERO 는 이 조건을 통과한다. 경과 시간은 시작 시점에서 잰 값이라 음수가 될 수 없으므로, 이 어셈블은 구현이 무엇을 하든 통과한다.

이름과 as 메시지가 정확히 짚은 회귀를, 어셈블만 못 잡는다. 그래서 이 테스트는 회귀 방지가 아니라 회귀 방지의 표시다.

수정. isGreaterThan(Duration.ZERO) 로 바꾼다. 시간 분해능이 불안하면 전송 람다에 관측 가능한 지연을 넣고 그 하한과 비교한다 — 같은 클래스의 aPublishThatNeverCompletesIsBoundedByTheCallersTimeout 가 이미 50밀리초 마감으로 그 방식을 쓴다.

확인된 설계(문제 아님)

  • 코어 NATS 를 최소 한 번 배달에 쓰지 못하게 시작 시 거부한 것과 그 근거.
  • 확인을 지속 증거로 기록한 것 — 스트림과 순번을 이름 짓는 승인이다.
  • 알 수 없는 실패의 기본값을 모호로 둔 것.
  • 한계 직전 배달에서 주차하는 것과 그 시점 선택의 근거.
  • maxDeliver < 2 를 거부해 주차 여유를 강제한 것.
  • 죽은 편지 발행이 확인된 뒤에만 원본을 정착시키는 것.
  • ALREADY_TERMINATED 를 별도 결과로 남겨 재배달과 구분한 것.
  • nativeDeadLetter=false 를 선언하고 그 이유를 두 곳에 적은 것.
  • 순서 있는 소비자와 경쟁 작업자의 배타성을 검증기가 강제한 것.
  • 중복 제거 식별자로 논리 메시지 식별자를 쓰는 것 — 시도마다 새 식별자를 만들면 창이 필요한 상황에서 쓸모가 없어진다.
  • 예외 클래스 이름으로 실패를 분류하던 것을 걷어내고 타입으로 옮긴 것 — 테스트가 그 회귀를 주석으로 남겨 두었다.
  • 주차 임계값의 정의를 프로파일 한 곳에만 둔 것 — 워크플로는 parkAtDelivery() 를 위임만 한다.
  • NatsStreamPosition 이 스트림 순번과 소비자 순번을 따로 들고 있는 것 — 재배달 인식이 둘의 차이에서 나오고, 재생은 스트림 순번으로만 되돌아간다.

Source anchors

src/messaging/messaging-nats-experimental/build.gradle
main/java/…/nats/NatsJetStreamTransport.java:1-295
main/java/…/nats/NatsJetStreamProfile.java:1-103
main/java/…/nats/NatsMaxDeliverParkingWorkflow.java:1-85
main/java/…/nats/NatsJetStreamProfileValidator.java:1-75
main/java/…/nats/NatsStreamPosition.java:1-75
main/java/…/nats/NatsPreSendRejection.java:1-65
main/java/…/nats/NatsAckMode.java:1-57
test/java/…/nats/NatsAdapterContractTest.java:1-337
test/java/…/nats/NatsMaxDeliverParkingTest.java:1-123
src/messaging/messaging-core-api/…/destination/MessagingCapabilities.java (성분 의미)
src/messaging/messaging-testkit/…/CompatibilityMatrix.java:107-109 (등급표의 이름 항목)
src/config/architecture/modules.json (등록)