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

292 lines
19 KiB
Markdown

# 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. 능력 선언
```java
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 이고, 압축 생성자가 이 어댑터의 불변식을 전부 들고 있다. 검증기가 호출되지 않는 지금, **실제로 실행되는 유일한 게이트가 여기다.**
```java
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` 를 무조건 참으로 선언하는데 실제 중복 제거는 프로파일에 창이 있을 때만 일어난다
능력은 상수다.
```java
private static final MessagingCapabilities CAPABILITIES =
new MessagingCapabilities(true, true, true, true, true, false, true, false, false, true, false, true);
// ^^^^ deduplicatedPublish
```
검증기의 `capabilities()` 도 같은 값을 돌려준다.
그런데 중복 제거 식별자는 프로파일에 창이 있을 때만 만들어진다.
```java
private Optional<String> deduplicationId(TransportPublishRequest request) {
return profile.deduplicationWindow().map(window -> request.envelope().messageId().value().toString());
}
```
`NatsJetStreamProfile.deduplicationWindow``Optional<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())`)에 대해:
```java
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).
**수정.** 능력을 프로파일에서 파생시킨다.
```java
new MessagingCapabilities(, profile.deduplicationWindow().isPresent(), )
```
또는 검증기가 최소 한 번 배달 목적지에 중복 제거 창을 요구한다. 후자는 코어 NATS 거부와 같은 형태의 시작 시점 거부다.
### 17.2 P3 — 닫힌 전송의 거절이 영구 업무 실패로 분류된다
`rejectedLocally``FailureCategory.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.
`validate``jetStreamEnabled` · `orderedConsumer` · `competingWorkers` · `enabled` 를 전부 인자로 받는다. 즉 스스로 아무것도 관찰하지 않고, 호출자가 이미 알고 있는 사실을 넘겨 줘야만 판단한다. 그런 호출자가 없으니 이 판단들은 한 번도 실행된 적이 없다.
**왜 P2 인가.** 전송의 클래스 javadoc 이 "그래서 검증기가 시작 시 그 조합을 거부한다"고 단언한다. 읽는 사람에게 이 어댑터는 코어 NATS 오설정으로부터 보호되는 것처럼 보이는데, 실제로는 아무 게이트도 걸려 있지 않다. 실험 등급이라 지금 당장 사고가 나지는 않지만, 이 어댑터를 실전에 붙이는 사람이 가장 먼저 신뢰할 문장이 지금 사실이 아니다.
같은 형태를 이 저장소에서 여러 번 봤다 — 채점기는 있는데 그 채점기에 값을 넣어 주는 생산자가 없는 구조(`GrpcRawApiImportRule` · `GrpcApplicationBoundaryRules` · `GrpcNettyParityContract` 등). 이쪽이 더 나쁜 쪽인 이유는 그 리프들에서는 최소한 테스트가 리터럴을 먹여 판단 자체는 실행해 보는데, 여기서는 그것조차 없다는 점이다.
**수정.** 어댑터 조립 지점에서 `validate` 를 부르거나, 그럴 지점이 아직 없다면 최소한 프로파일 생성 시점에 걸리도록 옮긴다(§4 의 압축 생성자가 이미 실행되는 유일한 게이트다). 어느 쪽도 못 하겠다면 전송 javadoc 의 "refuses ... at startup" 을 사실에 맞게 고친다.
### 17.4 P3 — 경과 시간 회귀를 막으려는 어셈블이 항상 참이다
```java
@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 (등록)
```