# 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` 람다로 주입된다. 실제 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 deduplicationId(TransportPublishRequest request) { return profile.deduplicationWindow().map(window -> request.envelope().messageId().value().toString()); } ``` `NatsJetStreamProfile.deduplicationWindow` 는 `Optional` 이고, 비어 있는 것이 정상 상태다 — 프로파일 생성자도 검증기도 창을 요구하지 않는다. 창이 없으면 `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 (등록) ```