# 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 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 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 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 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 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 retryableCategories, // "categories added to the retryable set" Set 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 publish(List> 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 — 공급자 부재) ```