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

457 lines
32 KiB
Markdown

# messaging-spring-boot-starter 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 28파일 3,528줄 + `src/test` 10파일 2,349줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-spring-boot-starter`
> SSOT owner: `messaging-spring-boot-starter`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `runtime_memberships`: **`["app-bootstrap"]`** — 출하. 이 리프가 messaging 폐포 전체를 실행 클래스패스에 올린다
- 자동 설정 등록: `MessagingPlatformRootAutoConfiguration` 하나
- 다만 `app-bootstrap``application.yml` 어디에도 `app.messaging.enabled` 가 없다. 클래스패스에는 있고 꺼져 있다
| 파일 | LOC | 역할 |
|---|---:|---|
| `MessagingCoreAutoConfiguration` | 480 | 정책·전송·관측 빈 26개 + 발행자 + 런타임 설치 |
| `MessagingConfigurationCompiler` | 331 | 문서화된 설정 → 플랫폼 프로파일 |
| `MessagingSettings` | 301 | `app.messaging` 바인딩 + 중첩 4클래스 |
| `DefaultBatchMessagePublisher` | 254 | 배치 팬아웃 + 마감 |
| `MessagingConfigurationKeyValidator` | 227 | 바인딩되지 않는 키 거부 |
| `MessagingReliabilityAutoConfiguration` | 185 | 발신함·수신함 운영 빈 |
| `DestinationSettings` | 177 | 목적지 한 항목(중첩 record 7) |
| `KafkaMessagingAutoConfiguration` | 170 | Kafka 검증기·보안 설정기·생산자·전송 |
| `MessagingProviderSelection` | 150 | 닫힌 레지스트리에서 전송 하나 선택 |
| `MessagingShutdownLifecycle` | 124 | 승인 차단 → 배수 |
| `RabbitMessagingAutoConfiguration` | 98 | 검증기·분류기·보안 설정기 (전송 없음) |
| `MessagingCredentialRequirementValidator` | 92 | 운영 프로파일에 자격 출처 요구 |
| `MessagingAdminAutoConfiguration` | 86 | 관리 평면(별도 스위치) |
| `MessagingPrefixMigrationValidator` | 83 | 죽은 접두 거부 |
| `MessagingEndpoint` | 78 | 읽기 전용 actuator |
| `PublishResults` | 71 | 예외 → 결과 변환 |
| `BrokerSettings` | 69 | 브로커 한 항목(두 가족 한 record) |
| `MessagingOutboxRelayLifecycle` | 69 | 중계 구동 |
| `MessagingAdminDurabilityValidator` | 68 | 비내구 저널 위 운영 프로파일 거부 |
| `DefaultBlockingMessagePublisher` | 65 | 블로킹 파사드 |
| `ValidatedDestinationRegistry` | 59 | 검증 통과 목적지 |
| `CompiledMessagingConfiguration` | 53 | 컴파일 결과 4묶음 |
| `BrokerSecuritySettings` | 50 | 보안 한 항목(비밀 없음) |
| `StartupProfileValidation` | 46 | 검증기를 실제로 부르는 어댑터 |
| `DefaultReactiveMessagePublisher` | 39 | Reactor 파사드 |
| `MessagingPlatformRootAutoConfiguration` | 36 | 마스터 조건 소유 |
| `MessageContracts` | 35 | 메시지 계약 홀더 |
| `ReactiveMessagePublisher` | 32 | Reactor 인터페이스 |
main 총 **28파일 / 3,528줄**.
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 28 | `FULL_READ` | 3,528줄. 위 표가 전부 |
| `main/resources/META-INF/spring/*.imports` | 1 | `FULL_READ` | 1줄 |
| `test/java/**` | 10 | `FULL_READ` | 2,349줄 |
| `build.gradle` | 1 | `FULL_READ` | 66줄 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
`UNCLASSIFIED` 0.
> 이 표는 2026-09-01 재통독에서 다시 세었다. 이전 판은 큰 파일 아홉만 적고 "나머지 18파일 — " 로 닫았다. 그 "나머지" 안에 §17.4 가 있었다.
---
## 1. 하나의 뿌리가 조건을 소유한다
```java
@AutoConfiguration
@ConditionalOnProperty(prefix = MessagingSettings.PREFIX, name = "enabled", havingValue = "true")
@EnableConfigurationProperties(MessagingSettings.class)
@Import({MessagingCoreAutoConfiguration.class, MessagingProviderSelection.class,
MessagingReliabilityAutoConfiguration.class, MessagingAdminAutoConfiguration.class})
public class MessagingPlatformRootAutoConfiguration {}
```
javadoc 이 이전 상태와 수정을 적는다.
> "The starter registered five auto-configurations directly, and not one carried a messaging master
> condition — putting the starter on the classpath assembled the platform… one root owning the
> condition, importing children that carry none, so a bean added to any child next month is gated
> without anyone remembering to repeat a condition."
그리고 제공자 선택의 이전 상태도 적는다.
> "Kafka and Rabbit were each conditioned on their client class being present, so an application that
> happened to have both libraries — a transitive dependency is enough — assembled both providers and
> published through whichever bean won. Selection now reads `app.messaging.broker` against a closed
> registry, and a value outside it is a startup error rather than a context with no provider at all."
꺼진 상태의 계약도 명시된다 — 빈도, 클라이언트도, 스레드도, 결속된 상세 이름공간도 없다. `MessagingStarterOffContractTest` 가 그것을 빈 이름과 **살아 있는 스레드** 로 붙든다.
## 2. 선택은 닫힌 레지스트리이고, 등록과 조립은 다르다
`MessagingProviderSelection` 에 지도가 셋이다.
```java
REGISTERED_BROKERS = {kafka: org.apache.kafka.clients.producer.Producer,
rabbit: com.rabbitmq.client.Channel}
PROVIDER_CONFIGURATIONS = {kafka: KafkaMessagingAutoConfiguration,
rabbit: RabbitMessagingAutoConfiguration}
BROKERS_WITHOUT_A_TRANSPORT = {rabbit: "…ships its validators and security configuration but no
MessagingTransport…"}
```
셋째 지도가 이 클래스의 판단이다. 등록되어 있다는 것과 조립할 수 있다는 것을 분리했고, 그 이유를 적었다 — Rabbit 을 고르면 핵심 설정 깊은 곳에서 `MessagingTransport` 빈이 없다는 오류가 나는데, 그것은 운영자에게 빈이 없다고만 말하지 고른 전송이 완성되지 않았다고는 말하지 않는다.
결과로 오늘 조립 가능한 전송은 `kafka` 하나다. `RabbitMessagingAutoConfiguration` 98줄은 선택 단계에서 거부되므로 **어떤 경로로도 도달하지 않는다**(§12.3).
## 3. 설정이 프로파일이 된다
`MessagingConfigurationCompiler` 가 닫는 것은 기능이 아니라 바인더의 부재다.
> "`docs/messaging/configuration-reference.md` described destination, broker and security sections;
> the only thing that bound was four flags… So a deployment that followed the documentation
> configured nothing, and nothing said so — which is the worst of the three possible outcomes, the
> other two being 'it works' and 'it refuses to start'."
컴파일과 검증을 나눈 이유도 적혀 있다. 컴파일은 객체 모델이 표현할 수 없는 것만 본다 — 목적지의 브로커가 존재하는지, 사후 처리 목적지가 선언되었는지, 보안 항목이 실재하는 브로커를 지키는지. 프로파일이 자체로 정합한지는 `DestinationProfileValidator` 의 질문이고 레지스트리 전체에 대해 던져진다. 그래서 설정으로 만든 프로파일과 빈으로 선언한 프로파일이 **같은 규칙**을 받는다.
그리고 모든 거부가 키를 부른다. 타입을 부르는 오류는 운영자가 고칠 줄을 알려 주지 않기 때문이다.
## 4. 시작 프로파일 검증
`StartupProfileValidation` 이 이 가족에서 이미 한 번 고쳐진 결함을 기록한다.
> "The Kafka, RabbitMQ and security validators were all beans and none of them was injected
> anywhere: the context published a validator per broker and validated nothing."
수정의 두 판단이 적혀 있다 — `afterPropertiesSet` 으로 돌려 컨텍스트 구성 중에 실패하게 한 것, 그리고 프로파일을 `Supplier` 로 받아 애플리케이션 선언 빈과 설정에서 컴파일된 프로파일 **두 출처** 를 모두 보게 한 것.
> "a validator that saw only one of the two would leave the other half of a deployment's
> configuration unchecked. Which half went unchecked would depend on how the deployment happened to
> be written, which is the worst possible rule."
## 5. 신뢰성 배선의 원칙
> "Every bean here is conditional on the application having supplied the corresponding repository.
> The platform cannot provide those: they write inside the application's own transaction, against the
> application's own datasource, and a default implementation would silently write to the wrong place
> — or to nowhere at all, which is worse because the outbox would look healthy while nothing was ever
> staged."
정리 작업과 중계의 처리가 갈리고 그 이유도 적혀 있다.
> "The cleanup jobs are beans but no scheduler is registered for them. Scheduling is the
> application's decision: a service running several replicas usually wants one of them to run
> cleanup, and auto-registering a fixed-rate task would have every replica delete the same rows."
> "The relay is the opposite case and is driven here. Its claims are fenced by owner and token under
> `SKIP LOCKED`, so every replica running one is safe, while nobody running one is a table that fills
> up behind a business transaction that reported success."
## 6. 종료 순서가 두 수명 주기의 phase 로 표현된다
```java
MessagingOutboxRelayLifecycle.getPhase() = Integer.MAX_VALUE
MessagingShutdownLifecycle.getPhase() = Integer.MAX_VALUE - 1024
```
`SmartLifecycle` 은 내림차순으로 멈추므로 중계가 먼저, 승인 차단과 배수가 다음이다. 두 클래스의 javadoc 이 서로를 근거로 든다 — 중계가 발행 중일 때 승인을 닫으면 그 회차의 행이 모호해지고, 그 모호함이야말로 배수가 없애려는 것이다. 그리고 브로커 연결을 쥔 빈(`@Bean(destroyMethod = "close")` 인 생산자)은 `Lifecycle` 이 아니므로 컨텍스트가 `destroyBeans()` 에 도달할 때, 즉 두 수명 주기가 모두 끝난 뒤에 닫힌다. 순서가 맞는다.
## 10. 테스트 레인
10파일 2,349줄.
| 파일 | 줄 | 무엇을 붙드나 |
|---|---:|---|
| `MessagingAutoConfigurationTest` | 546 | 빈 조립·바인딩·모순 프로파일 거부·접두 이관·저널 내구성·자격 요구 |
| `MessagingConfigurationBindingTest` | 320 | **문서를 실행한다**`docs/messaging/configuration-reference.md` 의 YAML 블록을 꺼내 컨텍스트를 띄운다. 그리고 거부 9종 |
| `BatchPublisherTest` | 316 | 인덱스별 결과·동기 실패·마감·지연된 거부 |
| `MessagingStarterOffContractTest` | 254 | 꺼짐=빈 0·스레드 0, 선택 계약, Rabbit 거부 |
| `MessagingLiveRoundTripQualificationTest` | 217 | Testcontainers Kafka 4.1.0 에 실제로 바이트를 보내고 읽어 온다 |
| `MessagingOutboxRelayLifecycleTest` | 203 | 컨텍스트가 중계를 실제로 돌리는지 |
| `BlockingFacadeTest` · `ReactiveFacadeTest` | 148 · 134 | 마감·모호 처리 / 차가운 `Mono` |
| `MessagingEndpointTest` | 133 | 보고 내용·쓰기 연산 0 |
| `MessagingShutdownLifecycleTest` | 78 | 승인 차단이 배수보다 먼저 |
두 테스트가 이 리프의 검증 태도를 규정한다.
**문서를 실행한다.** `MessagingConfigurationBindingTest.documented()` 가 마크다운에서 ```` ```yaml ```` 블록을 뽑아 `YamlPropertySourceLoader` 로 올린다. 문서를 고쳐 바인더가 감당 못 하면 여기서 깨지고, 바인더를 고쳐 문서가 없는 모양을 서술하게 되어도 깨진다.
**가짜가 결함을 가리는 것을 막는다.** `selectingRabbitIsRefused` 의 주석이 자기 이전 판을 기록한다.
> "This test used to run under `withAPublisher()` and assert the context started. The fake
> MessagePublisher tripped @ConditionalOnMissingBean and removed the very bean whose missing
> dependency is the defect — so a configuration that cannot start in any deployment passed as
> 'assembles Rabbit and not Kafka'."
그리고 그 교훈을 지키는 가드 테스트(`aFakePublisherDoesNotHideAnUnassemblableTransport`)를 따로 둔다.
## 12. negative-space probes
**12.1 도달성.** 이 리프는 `app-bootstrap` 에 출하되고 자동 설정이 등록된다. 그런데 `app-bootstrap` 의 `application.yml`·`application-{local,dev,prod}.yml` 어디에도 `app.messaging.enabled` 가 없다. 클래스패스에 있고 꺼져 있다. 그래서 이 리프의 판정은 전부 "속성 하나를 켜는 날" 의 것이다 — 그리고 그 속성을 켜는 것이 곧 이 스타터를 채택하는 행위다.
**12.2 대조군 — 검증기를 부르는가.** `grpc-spring-boot-starter` 는 시작 검증기를 만들어 놓고 부르지 않는다. 이쪽은 `StartupProfileValidation` 으로 실제로 부른다 — 다만 셋 중 하나가 빠져 있다(§17.2).
**12.3 도달하지 않는 설정 클래스.** `RabbitMessagingAutoConfiguration` 98줄은 `PROVIDER_CONFIGURATIONS` 에 등록되어 있지만 `selectedBroker` 가 `rabbit` 을 먼저 거부하므로 `Selector.selectImports` 가 이 클래스 이름을 돌려주는 경로가 없다. 죽은 코드이되 **의도된** 죽은 코드다 — 전송이 생기는 날 `BROKERS_WITHOUT_A_TRANSPORT` 에서 항목이 빠지면 살아난다. 그 의도가 지도 이름과 javadoc 에 적혀 있다.
**12.4 드리프트.** 등록 파일이 뿌리 하나만 담고, 그 뿌리가 넷을 가져온다. 서술과 일치한다.
**12.5 설정처럼 보이지만 상수인 것.** `MessagingConfigurationCompiler.credential(...)` 의 넷째 매개변수 `Supplier<Boolean> required` 는 호출처 셋 모두 `() -> true` 다(§17.4).
**12.6 두 설정 경로의 비대칭.** 이 리프는 "빈으로 선언한 프로파일과 설정으로 만든 프로파일이 같은 규칙을 받아야 한다" 를 반복해서 근거로 든다. 그런데 `DestinationSettings.Retry` 에는 `retryableCategories`·`nonRetryableCategories` 에 대응하는 키가 없다(§17.5).
**12.7 보안 설정기를 부르는 곳이 없다.** 저장소 전체에서 `KafkaSecurityConfigurer` 를 언급하는 production 코드는 이 리프의 빈 선언 한 줄뿐이다. 나머지는 자기 자신과 자기 테스트다(§17.1).
## 16. 확인하지 못한 것
- 애플리케이션이 저장소 빈을 공급한 상태로 컨텍스트를 세우지 않았다. 저장소에 그런 애플리케이션이 없다.
- `@ConditionalOnBean` 의 평가 순서를 실제 컨텍스트로 재현하지 않았다(§17.3). 스프링의 문서화된 제약으로 판정했다.
- §17.1 을 TLS·SASL 을 요구하는 실제 브로커에 붙여 재현하지 않았다. 조립되는 생산자 설정 맵의 성분 전부(`bootstrap.servers`·직렬화기 둘·`acks`·`enable.idempotence`)와 `KafkaSecurityConfigurer.configure` 가 만드는 성분 다섯(`security.protocol`·`ssl.enabled.protocols`·`ssl.endpoint.identification.algorithm`·`sasl.mechanism`·`sasl.jaas.config`)이 교집합 0 이라는 것으로 판정했다.
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
## 17. 손볼 것
### 17.1 P1 — 운영 배포에 TLS 와 인증을 **선언하라고 요구한 뒤**, 그 둘이 없는 생산자를 만든다
두 사실을 나란히 놓으면 보인다.
**검증기가 요구한다.** `KafkaProfileValidator`:
```java
if (profile.production() && !profile.tlsEnabled()) {
throw new IllegalArgumentException("a production Kafka connection requires TLS: " + profile.broker());
}
if (profile.production() && !profile.authenticationEnabled()) {
throw new IllegalArgumentException("a production Kafka connection requires broker authentication: " + profile.broker());
}
```
그리고 이 리프의 `kafkaProfileStartupValidation` 이 그것을 설정에서 컴파일된 프로파일에도 실제로 돌린다. 전용 테스트가 있다 — `aProductionKafkaBrokerWithoutTransportSecurityFailsStartup`.
**조립되는 생산자에는 그 둘이 없다.** `KafkaMessagingAutoConfiguration.messagingKafkaProducer`:
```java
Map<String, Object> config = new HashMap<>();
config.put(BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
config.put(KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
config.put(VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
config.put(ACKS_CONFIG, "all");
config.put(ENABLE_IDEMPOTENCE_CONFIG, true);
return new KafkaProducer<>(config);
```
다섯 항목이 전부다. `security.protocol` 이 없으므로 Kafka 클라이언트의 기본값 `PLAINTEXT` 로 접속한다.
**그 둘을 만드는 코드는 있고, 아무도 부르지 않는다.** `KafkaSecurityConfigurer.configure(...)` 가 정확히 다섯을 만든다.
```java
properties.put(SECURITY_PROTOCOL, securityProtocol(profile, credential)); // SASL_SSL | SSL | SASL_PLAINTEXT | PLAINTEXT
if (profile.tlsEnabled()) {
properties.put(ENABLED_PROTOCOLS, String.join(",", enabledProtocols));
properties.put(ENDPOINT_IDENTIFICATION, "https");
}
… properties.put(SASL_MECHANISM, "SCRAM-SHA-512");
properties.put(SASL_JAAS_CONFIG, scramJaas(scram.credentialId(), resolved));
```
이 클래스를 언급하는 production 코드는 저장소 전체에서 이 리프의 빈 선언 한 줄뿐이다. 나머지 참조는 자기 자신과 `KafkaSecurityConfigurerTest` 다.
**그래서 배포가 겪는 것.**
1. `app.messaging.brokers.k.production=true` 를 쓴다.
2. 검증기가 `tls-enabled=true` 와 `authentication-enabled=true` 를 요구한다.
3. 운영자가 둘을 켜고, `app.messaging.security.k` 에 SASL 자격 식별자를 적고, `CredentialProvider` 빈을 공급한다. 시작이 통과한다.
4. 만들어진 생산자는 평문·무인증으로 접속한다.
세 검증(`KafkaProfileValidator`·`MessagingCredentialRequirementValidator`·`BrokerTlsPolicy`)이 전부 통과하고, 통과의 대상이 실제 연결이 아니다. 보안을 요구하지 않는 브로커에는 인증 없이 붙고, 요구하는 브로커에는 첫 발행에서 실패한다 — 어느 쪽도 "선언한 대로 접속했다" 가 아니다.
**테스트가 이것을 볼 수 없는 이유.** 조립을 확인하는 두 테스트(`selectingKafkaAssemblesOnlyKafka`·`aSelectedTransportAssemblesAPublisher`)는 빈의 존재만 단언한다. 유일한 실 브로커 시험 `MessagingLiveRoundTripQualificationTest` 는 보안 없는 `KafkaContainer` 에 `production=false` 프로파일로 붙는다. 즉 이 플랫폼이 실제로 증명한 왕복은 평문 왕복 하나다.
**수정.** `messagingKafkaProducer` 가 `KafkaSecurityConfigurer` 와 선택된 브로커의 `BrokerSecurityProfile` 을 받아 `config.putAll(configurer.configure(profile, profile.producerCredential(), protocols, now))` 를 하는 것이다. 자격 회전이 목적이라면 생산자 하나를 고정 설정으로 만드는 형태 자체를 다시 봐야 한다 — `KafkaSecurityConfigurer` 의 javadoc 이 그 이유를 이미 적어 두었다.
> "a client configured from a value read once at startup holds that value until the process
> restarts, so the rotation the credential store performs never reaches the broker connection."
지금 조립되는 생산자가 정확히 그 형태이고, 심지어 한 번 읽지도 않는다.
### 17.2 P2 — 같은 자동 설정 안에서 검증기 하나만 감싸이지 않는다
`KafkaMessagingAutoConfiguration` 은 검증기 셋을 만든다.
```java
@Bean public KafkaProfileValidator kafkaProfileValidator() { … }
@Bean public StartupProfileValidation<KafkaBrokerProfile> kafkaProfileStartupValidation(…) { … } // ← 감싼다
@Bean public KafkaTransactionProfileValidator kafkaTransactionProfileValidator() { … }
@Bean public KafkaPublishFailureClassifier kafkaPublishFailureClassifier() { … }
```
`KafkaTransactionProfileValidator` 에는 대응하는 `StartupProfileValidation` 이 없다. 즉 컨텍스트가 그 검증기를 발행하고 아무도 주입하지 않는다 — `StartupProfileValidation` 의 javadoc 이 서술한 이전 상태와 정확히 같은 형태다.
`RabbitMessagingAutoConfiguration` 은 검증기 하나이고 그것을 감싼다. 그러므로 이 가족에서 감싸이지 않은 검증기는 이 하나다.
트랜잭션 프로파일 검증이 무엇을 막는지는 그 클래스가 안다 — 비트랜잭션 생산자 위의 정확히 한 번 주장 같은 조합이다. 그 검증이 지금 돌지 않는다.
수정은 한 블록이다. 같은 파일의 `kafkaProfileStartupValidation` 형태를 복사해 세 번째 검증기를 감싼다.
### 17.3 P2 — 출고되는 신뢰성 체인 전체가 아무도 공급하지 않는 빈 뒤에 있고, 그 사슬이 자기 클래스 안을 가리킨다
```java
@Bean @ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class}) public OutboxRelay outboxRelay(…)
@Bean @ConditionalOnBean(OutboxRelay.class) public OutboxRelayWorker outboxRelayWorker(…)
@Bean @ConditionalOnBean(OutboxRelayWorker.class) public MessagingOutboxRelayLifecycle outboxRelayLifecycle(…)
@Bean @ConditionalOnBean(OutboxRepository.class) public OutboxCleanupJob outboxCleanupJob(…)
@Bean @ConditionalOnBean(InboxRepository.class) public InboxCleanupJob inboxCleanupJob(…)
@Bean @ConditionalOnBean(IdempotentConsumer.class) public TransactionalInboxHandler<Object> transactionalInboxHandler(…)
```
**공급자가 없다.** 여섯 빈 전부가 애플리케이션이 공급해야 하는 타입에 걸려 있다. 조건 자체는 옳고 근거도 정확하다 — 플랫폼이 기본 구현을 주면 조용히 엉뚱한 곳에, 또는 아무 데도 쓰지 않게 된다. 문제는 저장소 안에 그 타입을 공급하는 코드가 없다는 것이다. `messaging-outbox-jdbc-postgresql` 의 `JdbcOutboxRepository` 는 스프링 스테레오타입도 `@Bean` 선언도 없고, `new JdbcOutboxRepository` 가 main 에 0 건이다. 그래서 이 스타터를 켠 배포는 발행 경로는 얻고 발신함 경로는 얻지 못하며, 그 사실이 시작 시점에 어떤 신호도 내지 않는다.
**사슬이 자기 클래스 안을 가리킨다.** 둘째와 셋째가 같은 설정 클래스 안에서 방금 선언된 빈의 존재를 조건으로 삼는다. 스프링은 `@ConditionalOnBean` 을 자동 설정 클래스에서만, 그리고 등록 순서에 의존하는 방식으로만 신뢰할 수 있다고 문서화한다. 지금은 첫 조건이 이미 거짓이라 결과가 드러나지 않는다. 발신함을 배선하는 순간 이 사슬이 실제로 평가된다.
같은 가족의 다른 결정과 대비된다. 관리 평면은 스위치가 켜졌을 때 만들어지지 **않는** 타입의 부재를 javadoc 에 명시한다(`DestructiveMessagingAdmin` 하나). 이쪽은 여섯이 조용히 빠진다.
수정은 둘이다. 발신함을 요구하는 설정에서 저장소 빈이 없으면 시작을 거부하는 검증(이 가족의 `StartupProfileValidation` 형태), 그리고 중계·작업자·수명을 하나의 `@Bean` 으로 합치거나 조건을 전부 최초 두 타입으로 표현하는 것.
### 17.4 P3 — 죽은 매개변수 하나가 유일한 비기본값에서 NPE 를 낳는다
```java
private static BrokerCredentialProfile credential(
String broker, String role, BrokerSecuritySettings.Credential credential, Supplier<Boolean> required) {
if (credential == null && Boolean.TRUE.equals(required.get())) {
throw configurationError(key("security", broker, role), "a configured broker needs a %s credential; …");
}
String type = credential.type() == null ? "" : credential.type().toUpperCase(Locale.ROOT);
```
호출처가 셋이고 전부 `() -> true` 다.
```java
credential(name, "producer", security.producer(), () -> true),
credential(name, "consumer", security.consumer(), () -> true),
Optional.ofNullable(security.admin()).map(admin -> credential(name, "admin", admin, () -> true))
```
그래서 이 매개변수는 값을 하나만 갖는다. 그리고 그것이 죽어 있다는 것보다 나쁜 성질이 있다 — 이 매개변수가 존재하는 이유("이 역할은 선택적이다")대로 `() -> false` 를 넘기면 `credential == null` 인 경로가 가드를 지나 다음 줄의 `credential.type()` 에서 NPE 로 죽는다. 즉 이 매개변수의 유일한 비기본값이 의도한 동작이 아니라 널 역참조다.
수정은 매개변수를 지우고 널 검사를 무조건으로 만드는 것이다. 선택적 역할이 필요해지는 날에는 `Optional` 을 돌려주는 별도 메서드가 그 자리다 — `admin` 이 이미 호출처에서 그렇게 다뤄진다.
### 17.5 P3 — 설정 경로의 재시도가 예외 분류를 표현할 수 없다
`RetryPolicy` 는 성분 열이고 그중 둘이 분류 집합이다.
```java
Set<FailureCategory> retryableCategories, // "categories added to the retryable set"
Set<FailureCategory> nonRetryableCategories, // "categories removed from the retryable set"
```
`DestinationSettings.Retry` 에는 이 둘에 대응하는 키가 없고, 컴파일러가 상수로 채운다.
```java
return new RetryPolicy(retry.mode(), retry.maxAttempts(), retry.initialDelay(), retry.maxDelay(),
retry.multiplier(), retry.jitter(), retry.orderingImpact(),
Set.of(), Set.of(), // ← 설정으로 표현할 수 없다
Optional.ofNullable(blankToNull(retry.destination())).map(DestinationName::new));
```
빈 집합은 "기본 분류 그대로" 라는 중립값이므로 오동작은 아니다. 문제는 비대칭이다. `DestinationProfile` 을 자바로 선언한 배포는 두 집합을 조정할 수 있고, 문서대로 YAML 로 설정한 배포는 할 수 없다. 이 리프가 반복해서 근거로 든 규칙이 정확히 그 비대칭을 금지한다.
> "Which half went unchecked would depend on how the deployment happened to be written, which is the
> worst possible rule."
수정은 `Retry` 에 두 키를 더하는 것이다. `FailureCategory` 는 열거이므로 relaxed binding 이 그대로 처리한다.
### 17.6 P3 — 배치 발행자가 `CompletionStage` 를 돌려주면서 동기 예외를 던진다
```java
public CompletionStage<BatchPublishResult> publish(List<PublishRequest<?>> requests, BatchPublishOptions options) {
if (requests.size() > options.maxBatchSize()) {
throw new MessageTooLargeException("BATCH_COUNT_EXCEEDED", …); // ← 스테이지가 아니라 던진다
}
```
같은 클래스가 자기 의존 대상에 대해서는 정확히 이 형태를 방어한다.
```java
} catch (RuntimeException synchronousFailure) {
// A publisher that validates eagerly throws instead of returning a failed stage. Converting
// it here keeps the "one result per index" contract that the caller resubmits from.
```
즉 "게으르게 검증하고 실패한 스테이지를 돌려준다" 가 이 클래스가 아는 계약인데, 자기 호출자에게는 그것을 지키지 않는다. 비동기 파이프라인으로 배치를 부르는 코드는 `.exceptionally(...)` 로 잡히지 않는 예외를 만난다.
등급이 P3 인 이유는 이것이 프로그래밍 오류(배치 크기 초과)이고 결과가 손실이 아니라 예외 형태의 불일치이기 때문이다. 전용 테스트(`aBatchLargerThanItsLimitIsRefusedBeforeAnythingIsPublished`)가 `assertThatThrownBy` 로 현재 동작을 고정하고 있으므로, 고치려면 그 테스트도 함께 바꾼다.
### 확인된 설계(문제 아님)
- **하나의 뿌리가 마스터 조건을 소유하고 자식은 조건을 갖지 않는 것.**
- **제공자 선택을 클래스패스 사고가 아니라 닫힌 레지스트리의 속성으로 만든 것.**
- **등록과 조립 가능을 분리하고, 조립 못 하는 전송을 선택 단계에서 이유와 함께 거부한 것.**
- **꺼진 상태의 계약을 빈 이름과 살아 있는 스레드로 붙든 것** — 빈 목록만으로는 "꺼짐" 이 증명되지 않는다.
- **시작 검증을 `afterPropertiesSet` 으로 돌린 것과 그 이유.**
- **프로파일을 두 출처에서 모으는 `Supplier` 를 쓴 것과 그 근거.**
- **모든 설정 거부가 타입이 아니라 키를 부르는 것.**
- **바인딩되지 않는 키를 record 성분에서 파생해 거부한 것** — 목록을 손으로 적으면 쓰는 날에만 맞는다.
- **환경변수를 키 검증에서 제외하고 그 이유를 적은 것** — 밑줄 경계를 되돌릴 방법이 없고, 추측은 정상 배포를 거부한다.
- **설정 참조 문서를 실행 가능한 진술로 만든 것.**
- **가짜 발행자가 조립 불가를 가린 사례를 테스트 주석에 남기고 가드 테스트를 붙인 것.**
- **저장소 기본 구현을 제공하지 않기로 한 판단과 그 근거.**
- **정리 작업은 스케줄러를 등록하지 않고 중계는 구동하는 비대칭과 각각의 이유.**
- **두 수명 주기의 phase 로 종료 순서를 표현한 것과 서로를 근거로 든 javadoc.**
- **배수 예산을 임차 기간으로 둔 것** — 그보다 오래 기다려도 증명되는 것이 없다.
- **`MessageContracts` 를 맨 `Map` 빈이 아니라 홀더로 만든 것** — 스프링에서 `Map` 은 중립적인 주입 타입이 아니다.
- **메시지 계약 기본값을 빈 것으로 두어 fail-closed 로 만든 것.**
- **actuator 끝점을 읽기 전용으로 두고 그것을 리플렉션으로 붙든 것.**
- **JAAS 값 이스케이프와 제어문자 거부**(`KafkaSecurityConfigurer`) — 지금은 아무도 부르지 않지만 코드 자체는 옳다.
---
## Source anchors
```
src/messaging/messaging-spring-boot-starter/build.gradle:1-66
main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:1
main/java/…/autoconfigure/MessagingCoreAutoConfiguration.java:1-480
main/java/…/autoconfigure/MessagingConfigurationCompiler.java:1-331
main/java/…/autoconfigure/MessagingSettings.java:1-301
main/java/…/autoconfigure/DefaultBatchMessagePublisher.java:1-254
main/java/…/autoconfigure/MessagingConfigurationKeyValidator.java:1-227
main/java/…/autoconfigure/MessagingReliabilityAutoConfiguration.java:1-185
main/java/…/autoconfigure/DestinationSettings.java:1-177
main/java/…/autoconfigure/KafkaMessagingAutoConfiguration.java:1-170
main/java/…/autoconfigure/MessagingProviderSelection.java:1-150
main/java/…/autoconfigure/MessagingShutdownLifecycle.java:1-124
main/java/…/autoconfigure/RabbitMessagingAutoConfiguration.java:1-98
main/java/…/autoconfigure/MessagingCredentialRequirementValidator.java:1-92
main/java/…/autoconfigure/MessagingAdminAutoConfiguration.java:1-86
main/java/…/autoconfigure/MessagingPrefixMigrationValidator.java:1-83
main/java/…/autoconfigure/MessagingEndpoint.java:1-78
main/java/…/autoconfigure/PublishResults.java:1-71
main/java/…/autoconfigure/BrokerSettings.java:1-69
main/java/…/autoconfigure/MessagingOutboxRelayLifecycle.java:1-69
main/java/…/autoconfigure/MessagingAdminDurabilityValidator.java:1-68
main/java/…/autoconfigure/DefaultBlockingMessagePublisher.java:1-65
main/java/…/autoconfigure/ValidatedDestinationRegistry.java:1-59
main/java/…/autoconfigure/CompiledMessagingConfiguration.java:1-53
main/java/…/autoconfigure/BrokerSecuritySettings.java:1-50
main/java/…/autoconfigure/StartupProfileValidation.java:1-46
main/java/…/autoconfigure/DefaultReactiveMessagePublisher.java:1-39
main/java/…/autoconfigure/MessagingPlatformRootAutoConfiguration.java:1-36
main/java/…/autoconfigure/MessageContracts.java:1-35
main/java/…/autoconfigure/ReactiveMessagePublisher.java:1-32
test/java/…/autoconfigure/{MessagingAutoConfigurationTest:546, MessagingConfigurationBindingTest:320,
BatchPublisherTest:316, MessagingStarterOffContractTest:254, MessagingLiveRoundTripQualificationTest:217,
MessagingOutboxRelayLifecycleTest:203, BlockingFacadeTest:148, ReactiveFacadeTest:134,
MessagingEndpointTest:133, MessagingShutdownLifecycleTest:78}
messaging-kafka/…/KafkaSecurityConfigurer.java:1-173 (§17.1 — 부르는 곳 없음)
messaging-kafka/…/KafkaProfileValidator.java:47-56 (§17.1 — 운영 TLS·인증 요구)
messaging-kafka/…/KafkaMessagingTransport.java:1-215 (§17.1 — 생산자를 감싸기만 한다)
messaging-policy/…/RetryPolicy.java:28-37 (§17.5)
app-bootstrap/src/main/resources/application*.yml (§12.1 — app.messaging.enabled 부재)
messaging-outbox-jdbc-postgresql/…/JdbcOutboxRepository.java (§17.3 — 공급자 부재)
```