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

405 lines
25 KiB
Markdown

# messaging-rabbit 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 20파일 2,443줄 + `src/test` 10파일 1,727줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-rabbit`
> SSOT owner: `messaging-rabbit`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `runtime_memberships`: **`["app-bootstrap"]`** — 클래스패스에 올라간다
- 도달성: **없다.** 제공자 선택이 `rabbit` 을 이름으로 거부한다(§12.1)
| 파일 | LOC | 참조 |
|---|---:|---|
| `RabbitConfirmCoordinator` | 279 | 전송 + 테스트 |
| `RabbitMessagingTransport` | 246 | 테스트만 |
| `RabbitConsumerRegistrar` | 238 | 테스트만 |
| `RabbitDeliveryMapper` | 195 | 소비자 + 테스트 |
| `RabbitHeaderMapper` | 180 | 매퍼 둘 + 테스트 |
| `RabbitSecurityConfigurer` | 179 | 스타터 빈만 — 호출처 없음 |
| `RabbitBatchConsumerRegistrar` | 165 | 테스트만 |
| `RabbitTopologyProfile` | 137 | 네이티브 DLQ 능력 + 테스트 |
| `RabbitDeadLetterPublisher` | 130 | **자기 파일 밖 참조 0** |
| `RabbitPublishFailureClassifier` | 117 | 전송 + 테스트 |
| `RabbitSettlementController` | 84 | 소비자 + 테스트 |
| `RabbitProfileValidator` | 83 | 스타터 시작 검증 |
| `RabbitPublishMapper` | 72 | 전송 |
| `RabbitNativeDeadLetterCapability` | 70 | DLQ 발행자 + 테스트 |
| `RabbitRetryQueueTopology` | 57 | 테스트만 |
| `RabbitSettlementOperations` | 50 | 인터페이스 — 구현은 테스트 셋뿐 |
| `RabbitBrokerProfile` | 50 | 설정 컴파일 |
| `RabbitChannelPublisher` | 42 | 인터페이스 — **production 구현 0** |
| `RabbitPublishReference` | 37 | 좌표 |
| `RabbitRequestReply` | 32 | **자기 파일 밖 참조 0** |
main 총 **20파일 / 2,443줄**.
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 20 | `FULL_READ` | 2,443줄. 위 표가 전부 |
| `test/java/**` | 10 | `FULL_READ` | 1,727줄 |
| `build.gradle` | 1 | `FULL_READ` | 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
`UNCLASSIFIED` 0.
> "참조" 열은 2026-09-01 재통독에서 저장소 전체 grep 으로 채웠다. 이전 판의 Source anchors 는 절반을 괄호 하나로 묶어 두었고, 그 괄호 안에 §17.2·§17.3·§17.4 가 있었다.
---
## 1. 이 어댑터의 중심 — 확인과 반환은 다른 질문에 답한다
> "AMQP delivers a return *before* the confirm for an unroutable message, so a naive adapter that
> completes on the confirm reports success for a message the broker threw away. The coordinator
> therefore keeps each publish pending until the confirm arrives, and remembers whether a return was
> seen first."
네 결과가 나온다 — 확인+미반환은 `CONFIRMED`, 확인+반환은 `UNROUTABLE``REJECTED`, 부정 확인은 `REJECTED`, 확인 미도착은 `AMBIGUOUS`.
이 리프에서 가장 중요한 판단이고, 실브로커 시험(`RabbitBrokerIT.anUnroutablePublishIsRejectedEvenThoughTheExchangeConfirmedIt`)이 그것을 붙든다. 그리고 그 시험이 production 에 없는 조각을 스스로 채워 넣는다(§17.2).
## 2. 자료구조 선택이 결함 수정이다
```java
private final ConcurrentSkipListMap<Long, PendingPublish> pending = new ConcurrentSkipListMap<>();
```
> "Ordered because a Rabbit confirm carries a `multiple` flag meaning 'everything up to and including
> this tag'. Resolving one sequence per confirm — which is what a hash map forces — leaves every
> earlier publish pending forever: the caller's stage never completes and the entry is never removed,
> so the map grows for the life of the connection."
`confirmed(sequence, multiple=true, …)``headMap(sequence, true)` 로 범위를 해소한다. 전용 시험이 있다.
## 3. 부정 확인의 증거를 전송됨으로 기록한다
```java
// A NACK is the broker's answer to a frame it received. Recording it as never sent contradicts the
// very evidence that produced it, and a caller reading the evidence would conclude the message can
// be re-sent freely.
```
`REJECTED` + `TRANSMITTED` + `ConfirmationLevel.NONE`. 완결 상태와 전송 증거를 분리해서 다루는 곳이 이 가족에서 여기와 Kafka 뿐이다.
## 4. 소비·정착·죽은 편지의 세 규율
**좁은 catch.** `RabbitConsumerRegistrar.onMessage` 가 디코딩만 감싸는 안쪽 `try` 를 따로 둔다.
> "One catch around decode, the handler and the settlement meant a business failure or an ACK that
> could not be written was recorded as an undecodable payload and discarded — a message that should
> have been retried, deleted instead."
**정착하지 않은 핸들러.** 완료했는데 정착하지 않으면 대신 ack 하지 않고 requeue 한다 — "acknowledging on its behalf would silently drop it".
**네이티브 죽은 편지.** `RabbitNativeDeadLetterCapability` 가 두 조건을 모두 요구한다.
> "If the dead-letter exchange is unroutable — nobody bound a queue to it, or the binding was removed
> — the broker discards the message silently and the reject still succeeds."
## 5. 자격증명은 연결 시도마다 해석된다
> "RabbitMQ client connections are long-lived and reconnect on their own, so a factory holding a
> credential from startup will happily reconnect with a revoked one for as long as the process runs —
> the reconnect is exactly the moment a rotated credential should take effect."
`AmqpCredentials` 가 record 가 아니라 class 인 이유도 적혀 있다 — 비밀을 지우려면 가변이어야 하고, record 가 `char[]` 를 동등성에 쓰면 같은 자재를 가진 둘이 서로 다르다고 판정된다.
## 6. 시작 검증
`RabbitProfileValidator` 가 여덟을 요구한다 — Stable 에 확인·반환·mandatory, 소비자 auto-ack 금지, prefetch ≥ 1, 확인 마감 양수, 운영에 TLS·인증. 그리고 목적지 검증이 둘 더 — 작업 큐에 쿼럼 큐, 교환기나 큐 중 하나.
Kafka 쪽과 달리 이 검증기는 스타터에서 `StartupProfileValidation` 으로 **감싸여 있다**. 다만 그 자동 설정 자체가 도달하지 않는다(§12.1).
## 10. 테스트 레인
10파일 1,727줄.
| 파일 | 줄 | 무엇을 붙드나 |
|---|---:|---|
| `RabbitRuntimeTest` | 309 | 전송 4경로 + 소비자 7경로(일시정지·배수·미디코딩·핸들러 실패·미정착·close) |
| `RabbitContractHarness` | 304 | 공유 어댑터 계약을 production 조정자·정착 제어기 위에서 |
| `RabbitBrokerIT` | 232 | 실브로커 `rabbitmq:4.3-management` — 반환-먼저-확인 |
| `RabbitTopologyAndBatchTest` | 218 | 쿼럼 요구·DLX 논리·실패 분류·배치 누적 |
| `RabbitProfileValidatorTest` | 184 | 검증기 여덟 규칙 |
| `RabbitConfirmCoordinatorTest` | 167 | 상태 기계 12경로(다중 확인·채널 종료 포함) |
| `RabbitEnvelopeRoundTripTest` | 152 | 헤더 왕복·위조 거부 |
| `RabbitSettlementControllerTest` | 105 | 일회 종결·지연 재시도 큐 인자 |
| `RabbitAdapterContractTest` · `RabbitFixtureProfiles` | 24 · 32 | 계약 실행·픽스처 |
`RabbitAdapterContractTest` 의 javadoc 이 이 레인의 요점을 적는다.
> "Two brokers with completely different machinery — offsets and commits versus delivery tags and
> confirms — answering the same seven questions the same way is what makes the logical destination
> abstraction real rather than aspirational."
## 12. negative-space probes
**12.1 도달성 — 리프 전체가 production 호출자를 갖지 않는다.**
전송을 만들려면 `RabbitChannelPublisher` 구현이 필요하다. 저장소 전체에서 그 인터페이스의 구현은 **테스트의 익명 클래스 둘**(`RabbitRuntimeTest:49`, `RabbitBrokerIT:709`)뿐이다. 따라서 `new RabbitMessagingTransport(...)` 도 테스트에만 있고, `RabbitConsumerRegistrar`·`RabbitBatchConsumerRegistrar` 도 마찬가지다.
그리고 그 사실이 플랫폼 쪽에 이름으로 기록되어 있다.
```java
// MessagingProviderSelection
static final Map<String, String> BROKERS_WITHOUT_A_TRANSPORT = Map.of(
"rabbit",
"the Rabbit adapter ships its validators and security configuration but no MessagingTransport: "
+ "its native channel publisher is not implemented, so a publish has nothing to travel on");
```
`app.messaging.broker=rabbit` 은 시작 오류이고, 전용 시험이 그 메시지를 단언한다. 그래서 `RabbitMessagingAutoConfiguration` 98줄도 도달하지 않는다.
이 리프의 품질과 도달성이 정반대다. 코드는 이 가족에서 가장 정교한 축이고 — 반환-먼저-확인 상태 기계, `multiple` 범위 해소, 정착 일회성, 네이티브 DLQ 의 조건부 신뢰 — 실행 경로는 없다.
**12.2 리프 자체 기준으로도 죽은 둘.**
| 파일 | LOC | 상태 |
|---|---:|---|
| `RabbitDeadLetterPublisher` | 130 | 자기 파일 밖 참조 0 — production 도 테스트도 부르지 않는다 |
| `RabbitRequestReply` | 32 | 인터페이스. 구현 0, 테스트 0, 호출 0 |
`RabbitDeadLetterPublisher` 가 담고 있는 것이 §4 의 세 번째 규율 — 네이티브 경로와 플랫폼 발행 중 어느 쪽을 쓸지 한 곳에서 결정한다는 판단 — 인데, 그 결정을 내리는 코드를 아무도 부르지 않는다. 그 판단의 근거가 되는 `RabbitNativeDeadLetterCapability` 는 테스트가 있다. 즉 **판단의 재료는 시험되고 판단 자체는 시험되지 않는다.**
`RabbitRequestReply` 는 M2 능력의 인터페이스 선언이다. javadoc 이 왜 제한적으로 제공하는지를 적는데("a synchronous call wearing an asynchronous costume"), 제공되는 것이 없다.
**12.3 대조군 — 재시도 헤더 오염의 처리가 두 어댑터에서 갈린다.** 두 어댑터의 `attemptOf` 는 같은 fail-closed 결정을 같은 문구로 적는다.
```java
// "the message is quarantined rather than restarting its retry budget"
throw new MessagingConfigurationException("RETRY_ATTEMPT_MALFORMED", );
```
그런데 소비자가 그 던짐을 받는 위치가 다르다.
| 어댑터 | `attemptOf` 호출 위치 | 결과 |
|---|---|---|
| Rabbit | `toMetadata` 안 → 디코딩 실패 `catch` 안쪽 | `operations.discard(tag, …)` — DLX 가 있으면 죽은 편지로 |
| Kafka | 디코딩 `catch` **바깥**의 두 번째 블록 | `requeueAfterFailure()` → 무한 pause-and-seek(messaging-kafka §17.4) |
같은 판단, 반대 결과다. Rabbit 쪽이 javadoc 이 약속한 것에 가깝다.
**12.4 대조군 — `pause` 의 뜻이 SPI 하나 뒤에서 두 가지다.**
| 어댑터 | 반환 시점 | 의미 |
|---|---|---|
| Kafka | 다음 폴 주기 | `consumer.pause()` — 브로커에서 더 가져오지 않는다 |
| Rabbit | 즉시 완료 | `onMessage``false` 를 답한다 — 리스너 컨테이너가 계속 밀고, 미확인으로 재배달된다 |
Kafka 쪽 javadoc 은 왜 즉시 완료하지 않는지를 명시한다("'paused' cannot be true until the loop says so"). Rabbit 쪽에는 그 대비 서술이 없다. §17.4.
**12.5 드리프트.** 검증기가 강제하는 항목과 어댑터가 실제로 보내는 플래그(`mandatory`)가 일치한다.
## 16. 확인하지 못한 것
- 실제 브로커로 반환-먼저-확인 순서를 재현하지 않았다. `RabbitBrokerIT` 가 그 레인이고 컨테이너가 필요하다.
- 지연 재시도 큐 토폴로지를 실제로 선언해 보지 않았다.
- §17.2 를 실행으로 재현하지 않았다. `RabbitHeaderMapper.toProperties` 전문에 순번 헤더가 없다는 것과, `RabbitBrokerIT` 가 자기 publish 람다에서 `x-seq` 를 붙인다는 것으로 판정했다.
- `RABBIT-CR-DEMO`(§17.3)를 실제 브로커에 붙여 보지 않았다. 이름과 RabbitMQ 의 기본 활성 상태로 판정했다.
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
## 17. 손볼 것
### 17.1 P3 — 확인 등급이 요구에서 파생되고, 그 요구를 뒷받침하는 강제는 목적지 종류 하나에만 걸린다
```java
ConfirmationLevel level =
requirement == ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK
? ConfirmationLevel.REPLICATION_OR_PERSISTENCE_ACK
: ConfirmationLevel.BROKER_ACK;
```
증거의 등급이 브로커가 무엇을 했는지가 아니라 프로파일이 무엇을 **요구했는지** 에서 나온다.
대부분의 경우 이 파생은 성립한다. 두 강제가 그것을 받쳐 준다.
- `RabbitHeaderMapper.toProperties` 가 배달 모드를 무조건 `PERSISTENT` 로 둔다. RabbitMQ 는 지속 메시지를 디스크에 쓴 뒤에 확인한다.
- `RabbitProfileValidator.validateDestination` 이 내구 작업 큐에 쿼럼 큐를 요구한다. 쿼럼 큐의 확인은 다수 복제 뒤에 온다.
빈틈은 둘째 강제의 범위다.
```java
if (destination.kind() == DestinationKind.WORK_QUEUE && !broker.quorumQueues()) { throw ; }
```
작업 큐가 아닌 목적지에는 쿼럼 요구가 없다. 교환기로 발행하는 목적지가 `REPLICATION_OR_PERSISTENCE_ACK` 를 요구하면, 그 교환기에 바인딩된 큐가 고전 큐여도 어댑터는 그 등급을 보고한다. 지속 모드 덕분에 디스크 기록은 보장되지만 복제는 보장되지 않는다.
이 저장소의 규율은 증거가 관측에서 나와야 한다는 것이다 — `MessagingCapabilities` 의 javadoc 이 "a silently weakened guarantee is indistinguishable from a working one until the incident" 라고 적는다.
수정은 쿼럼 요구를 목적지 종류가 아니라 **요구된 확인 등급** 에 걸거나, 작업 큐가 아닌 목적지에서는 등급을 `BROKER_ACK` 로 낮추는 것이다.
### 17.2 P2 — 반환을 순번에 맞추는 조각이 production 에 없고, 시험이 그 자리를 스스로 메운다
이 어댑터의 핵심 보장(§1)은 반환과 확인을 **같은 발행** 에 묶는 데 달려 있다. 묶는 열쇠는 순번이다.
```java
public void returned(long sequence) { } // RabbitConfirmCoordinator
public void onReturn(long sequence) { } // RabbitMessagingTransport
```
그런데 AMQP 의 `basic.return` 콜백은 순번을 주지 않는다. 교환기·라우팅 키·속성·본문만 온다. 그래서 발행자가 순번을 메시지에 실어 보내고 반환에서 되읽어야 한다.
`RabbitHeaderMapper.toProperties` 전문에 그런 헤더가 없다. 쓰는 것은 `msg.*` 예약 헤더들과 AMQP 의 `messageId`·`correlationId`·`timestamp`·`deliveryMode` 뿐이다.
그 조각이 존재하는 곳은 시험 하나다.
```java
// RabbitBrokerIT
channel.addReturnListener(returned ->
transport.onReturn(Long.parseLong(returned.getProperties().getHeaders().get("x-seq").toString())));
private static Map<String, Object> withSequence(MessageProperties source, long sequence) {
headers.put("x-seq", Long.toString(sequence)); // ← 시험이 직접 붙인다
}
```
그 메서드의 javadoc 이 문제를 정확히 서술한다.
> "A returned message arrives without its publish sequence number, so the adapter has to carry one
> itself to correlate the return with the pending publish."
"the adapter has to" 인데 어댑터는 하지 않는다. `RabbitChannelPublisher` 의 javadoc 은 **등록 경합**(확인이 `basicPublish` 반환보다 먼저 올 수 있다)만 설명하고 이 상관 문제는 언급하지 않는다.
결과는 이렇다. 언젠가 `RabbitChannelPublisher` 를 구현하는 사람은 이 헤더 규약을 다시 발명해야 하고, 발명하지 않으면 `onReturn` 이 호출되지 않아 unroutable 발행이 **`CONFIRMED` 로 보고된다** — 이 어댑터가 존재하는 이유로 든 바로 그 실패다.
수정은 순번 헤더를 `RabbitHeaderMapper``RabbitPublishMapper` 로 올려 production 계약으로 만들고, 그 이름을 `RabbitChannelPublisher` javadoc 에 적는 것이다. 지금은 그 규약이 시험 파일 20줄에만 있다.
### 17.3 P3 — SCRAM 자격을 RabbitMQ 의 데모 기구로 조용히 매핑한다
```java
case BrokerCredentialProfile.SaslScram scram -> {
CredentialRuntime resolved = credentials.resolve(scram.credentialId(), now);
yield new AmqpCredentials("RABBIT-CR-DEMO", scram.credentialId(), resolved.material(), profile.tlsEnabled());
}
```
`RABBIT-CR-DEMO` 는 RabbitMQ 의 시연용 challenge-response 인증 기구(`rabbit_auth_mechanism_cr_demo`)의 이름이고 기본 활성이 아니다. RabbitMQ 는 SCRAM-SHA 를 구현하지 않으므로 `SaslScram` 에 대응하는 AMQP 기구가 없다는 것 자체는 사실이다.
문제는 그 사실을 다루는 방식이 같은 파일 안에서 일관되지 않다는 것이다.
```java
case BrokerCredentialProfile.Nkey ignored ->
throw new IllegalArgumentException("NKey credentials are a NATS concept, not an AMQP one"); // ← 거부
case BrokerCredentialProfile.OAuth2 oauth -> {
// RabbitMQ's OAuth 2 plugin takes the token in the password field of a PLAIN exchange.
yield new AmqpCredentials("PLAIN", ); // ← 주석으로 근거
}
case BrokerCredentialProfile.SaslScram scram -> yield new AmqpCredentials("RABBIT-CR-DEMO", ); // ← 둘 다 없다
```
그리고 이웃 어댑터의 같은 클래스가 정확히 이 상황에 대한 규범을 적어 두었다.
> "Refused rather than half-configured. Setting the mechanism name without a callback handler
> produces a client that authenticates with nothing and fails at connect time, which is later and
> harder to attribute than failing here." — `KafkaSecurityConfigurer`
`SaslScram` 에도 그 규범이 적용되어야 한다. 플러그인이 없는 브로커에서는 handshake 가 알아보기 어려운 오류로 실패하고, 있는 브로커에서는 시연용 기구로 인증한다.
수정은 `Nkey` 와 같이 거부하거나, `PLAIN` 으로 매핑하고 그 이유를 주석으로 남기는 것이다. 어느 쪽이든 지금처럼 말없이 데모 기구를 고르는 것보다 낫다.
### 17.4 P3 — 능력 상수의 `delayedDelivery` 가 무조건 참이고, 그 지연을 제공할 토폴로지는 조립되지 않는다
```java
private static final MessagingCapabilities CAPABILITIES =
new MessagingCapabilities(true, true, true, false, false, false, false, true, false, false, true, true);
// ^^^^ delayedDelivery
```
이 플래그는 읽힌다.
```java
// DefaultRetryDecisionEngine:64
if (policy.mode() == RetryMode.BROKER_DELAYED && context.capabilities().delayedDelivery()) { }
```
그런데 지연을 실제로 만드는 것은 `RabbitRetryQueueTopology` 이고, 그 클래스는 자기 파일과 시험 하나 밖에서 참조되지 않는다. 어떤 production 코드도 그 큐를 선언하지 않는다.
그리고 그 클래스의 javadoc 이 이 지연의 성질을 정확히 적는다.
> "TTL expiry is evaluated at the head of the queue, so mixed delays in one retry queue do not expire
> independently."
즉 제공되는 것은 "메시지별 지연" 이 아니라 "재시도 큐 하나당 TTL 하나" 다. 능력 모델에는 그 구분을 표현하는 자리가 없고, 상수는 프로파일과 무관하게 참을 답한다.
Kafka 는 같은 칸을 `false` 로 둔다. 그래서 이 플래그의 두 값이 "지연 있음/없음" 이 아니라 "지연을 흉내낼 토폴로지를 선언할 수 있음/없음" 을 뜻하게 된다.
수정은 능력을 전송 상수가 아니라 목적지의 재시도 큐 선언에서 파생시키는 것이다. 이 리프가 조립되지 않는 동안에는 P3 이고, `RabbitChannelPublisher` 구현이 생기는 날 함께 봐야 한다.
### 17.5 P3 — `pause` 의 의미가 SPI 하나 뒤에서 두 브로커에 다르게 구현된다
```java
@Override public CompletionStage<Void> pause(String scope) {
pausedScopes.add(scope == null ? "" : scope);
return CompletableFuture.completedFuture(null); // ← 즉시 완료
}
```
호출자가 이 단계를 기다리고 나면 "일시정지되었다" 고 읽는다. 실제로 일어난 것은 `onMessage` 가 이후 배달에 `false` 를 답하기 시작한 것뿐이고, 리스너 컨테이너는 계속 배달을 밀며 그 배달들은 미확인 상태로 재배달된다. 즉 정지가 아니라 거부-재배달 루프다.
Kafka 쪽은 같은 SPI 를 정반대로 구현하고 그 이유를 적는다.
> "The returned stage completes after the poll loop has actually applied the change, so a caller that
> awaits it knows the consumer is paused rather than merely asked to pause… there is no safe way to
> touch the consumer from another thread, so 'paused' cannot be true until the loop says so."
AMQP 에는 대응하는 수단이 있다 — `basicCancel` 로 소비자를 취소하거나 컨테이너를 멈추는 것. 지금 구현이 그것을 하지 않는 이유는 어디에도 없다.
전용 시험(`aPausedQueueRefusesDeliveriesSoTheBrokerRedeliversThem`)의 이름이 이미 실제 동작을 정확히 말한다. 그러므로 수정은 둘 중 하나다 — 컨테이너를 실제로 멈추거나, SPI 의 javadoc 에 "브로커에 따라 정지가 거부-재배달일 수 있다" 를 명시하는 것.
### 확인된 설계(문제 아님)
- **확인과 반환을 두 질문으로 나누고, 반환-먼저 순서를 상태 기계로 다룬 것.**
- **정렬된 맵을 골라 `multiple` 확인의 범위 해소를 가능하게 한 것과, 해시 맵이 만들었을 누수를 javadoc 에 남긴 것.**
- **부정 확인의 증거를 전송됨으로 기록한 것과 그 근거.**
- **채널 종료를 모호로 완결시킨 것** — 보류로 남기면 호출자가 매달린다.
- **순번 예약을 발행과 분리한 것** — 확인이 `basicPublish` 반환을 앞지를 수 있다.
- **동기 발행 실패를 던지지 않고 분류기를 거쳐 스테이지로 돌려주는 것.**
- **적재물 크기와 종료 상태를 채널 앞에서 검사해 미전송 증거로 실패시키는 것.**
- **정착의 일회성과 재사용된 배달 태그의 위험을 명시한 것.**
- **디코딩만 감싸는 좁은 `catch`** — 넓은 catch 가 재시도 가능한 실패를 삭제로 바꾸던 형태를 고쳤다.
- **정착하지 않은 핸들러를 대신 ack 하지 않고 requeue 하는 것.**
- **요구 재큐 대신 지연 재시도 큐를 쓴 것과, TTL 이 큐 머리에서 평가된다는 한계를 javadoc 에 남긴 것.**
- **배수 중 진행 배달을 끝내게 한 것.**
- **네이티브 죽은 편지를 검증된 곳에서만 쓰고 나머지는 공유 조율자에 위임한 것, 그리고 네이티브 경로의 증거를 `BROKER_ACK` 로만 주장한 것.**
- **자격증명을 연결 시도마다 해석하고 짧은 수명 객체로 넘긴 것, `AmqpCredentials` 를 record 가 아니라 class 로 둔 것과 그 근거.**
- **내구 작업 큐에 쿼럼 큐를 요구한 것과 그 근거.**
- **배치 누적에 나이 경계를 필수로 만든 것** — 조용한 큐가 마지막 메시지를 미확인으로 붙들지 않게.
- **prefetch 가 배치 크기보다 작으면 교착이라는 것을 거부로 표현한 것.**
- **배치를 `settlableAsBatch=false` 로 보고한 것** — AMQP multiple-ack 은 진행 중인 작업까지 정착시킨다.
---
## Source anchors
```
src/messaging/messaging-rabbit/build.gradle
main/java/…/rabbit/RabbitConfirmCoordinator.java:1-279 (§17.2 returned:74-79)
main/java/…/rabbit/RabbitMessagingTransport.java:1-246 (능력 상수 41-43 · §17.4)
main/java/…/rabbit/RabbitConsumerRegistrar.java:1-238 (§17.5 pause:694-697)
main/java/…/rabbit/RabbitDeliveryMapper.java:1-195 (§12.3 attemptOf:133-153)
main/java/…/rabbit/RabbitHeaderMapper.java:1-180 (§17.1 배달 모드 235 · §17.2 순번 헤더 부재)
main/java/…/rabbit/RabbitSecurityConfigurer.java:1-179 (§17.3 switch 438-463)
main/java/…/rabbit/RabbitBatchConsumerRegistrar.java:1-165
main/java/…/rabbit/RabbitTopologyProfile.java:1-137
main/java/…/rabbit/RabbitDeadLetterPublisher.java:1-130 (§12.2 참조 0)
main/java/…/rabbit/RabbitPublishFailureClassifier.java:1-117
main/java/…/rabbit/RabbitSettlementController.java:1-84
main/java/…/rabbit/RabbitProfileValidator.java:1-83 (§17.1 validateDestination:543-555)
main/java/…/rabbit/RabbitPublishMapper.java:1-72
main/java/…/rabbit/RabbitNativeDeadLetterCapability.java:1-70
main/java/…/rabbit/RabbitRetryQueueTopology.java:1-57 (§17.4)
main/java/…/rabbit/{RabbitSettlementOperations:1-50, RabbitBrokerProfile:1-50, RabbitChannelPublisher:1-42,
RabbitPublishReference:1-37, RabbitRequestReply:1-32}
test/java/…/rabbit/ 10파일 1,727줄 (RabbitRuntimeTest:309 · RabbitContractHarness:304 · RabbitBrokerIT:232 …)
test/java/…/rabbit/RabbitBrokerIT.java:726-733, 786-813 (§17.2 시험이 메우는 x-seq 규약)
messaging-spring-boot-starter/…/MessagingProviderSelection.java:64-69 (§12.1 rabbit 거부)
messaging-policy/…/DefaultRetryDecisionEngine.java:64 (§17.4 delayedDelivery 소비처)
```