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

32 KiB

messaging-spring-boot-starter 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) — src/main production 28파일 3,528줄 + src/test 10파일 2,349줄 축자 통독 완료. STRUCTURAL_ONLYgradle.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-bootstrapapplication.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. 하나의 뿌리가 조건을 소유한다

@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 에 지도가 셋이다.

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 로 표현된다

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-bootstrapapplication.yml·application-{local,dev,prod}.yml 어디에도 app.messaging.enabled 가 없다. 클래스패스에 있고 꺼져 있다. 그래서 이 리프의 판정은 전부 "속성 하나를 켜는 날" 의 것이다 — 그리고 그 속성을 켜는 것이 곧 이 스타터를 채택하는 행위다.

12.2 대조군 — 검증기를 부르는가. grpc-spring-boot-starter 는 시작 검증기를 만들어 놓고 부르지 않는다. 이쪽은 StartupProfileValidation 으로 실제로 부른다 — 다만 셋 중 하나가 빠져 있다(§17.2).

12.3 도달하지 않는 설정 클래스. RabbitMessagingAutoConfiguration 98줄은 PROVIDER_CONFIGURATIONS 에 등록되어 있지만 selectedBrokerrabbit 을 먼저 거부하므로 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:

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:

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(...) 가 정확히 다섯을 만든다.

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=trueauthentication-enabled=true 를 요구한다.
  3. 운영자가 둘을 켜고, app.messaging.security.k 에 SASL 자격 식별자를 적고, CredentialProvider 빈을 공급한다. 시작이 통과한다.
  4. 만들어진 생산자는 평문·무인증으로 접속한다.

세 검증(KafkaProfileValidator·MessagingCredentialRequirementValidator·BrokerTlsPolicy)이 전부 통과하고, 통과의 대상이 실제 연결이 아니다. 보안을 요구하지 않는 브로커에는 인증 없이 붙고, 요구하는 브로커에는 첫 발행에서 실패한다 — 어느 쪽도 "선언한 대로 접속했다" 가 아니다.

테스트가 이것을 볼 수 없는 이유. 조립을 확인하는 두 테스트(selectingKafkaAssemblesOnlyKafka·aSelectedTransportAssemblesAPublisher)는 빈의 존재만 단언한다. 유일한 실 브로커 시험 MessagingLiveRoundTripQualificationTest 는 보안 없는 KafkaContainerproduction=false 프로파일로 붙는다. 즉 이 플랫폼이 실제로 증명한 왕복은 평문 왕복 하나다.

수정. messagingKafkaProducerKafkaSecurityConfigurer 와 선택된 브로커의 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 은 검증기 셋을 만든다.

@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 — 출고되는 신뢰성 체인 전체가 아무도 공급하지 않는 빈 뒤에 있고, 그 사슬이 자기 클래스 안을 가리킨다

@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-postgresqlJdbcOutboxRepository 는 스프링 스테레오타입도 @Bean 선언도 없고, new JdbcOutboxRepository 가 main 에 0 건이다. 그래서 이 스타터를 켠 배포는 발행 경로는 얻고 발신함 경로는 얻지 못하며, 그 사실이 시작 시점에 어떤 신호도 내지 않는다.

사슬이 자기 클래스 안을 가리킨다. 둘째와 셋째가 같은 설정 클래스 안에서 방금 선언된 빈의 존재를 조건으로 삼는다. 스프링은 @ConditionalOnBean 을 자동 설정 클래스에서만, 그리고 등록 순서에 의존하는 방식으로만 신뢰할 수 있다고 문서화한다. 지금은 첫 조건이 이미 거짓이라 결과가 드러나지 않는다. 발신함을 배선하는 순간 이 사슬이 실제로 평가된다.

같은 가족의 다른 결정과 대비된다. 관리 평면은 스위치가 켜졌을 때 만들어지지 않는 타입의 부재를 javadoc 에 명시한다(DestructiveMessagingAdmin 하나). 이쪽은 여섯이 조용히 빠진다.

수정은 둘이다. 발신함을 요구하는 설정에서 저장소 빈이 없으면 시작을 거부하는 검증(이 가족의 StartupProfileValidation 형태), 그리고 중계·작업자·수명을 하나의 @Bean 으로 합치거나 조건을 전부 최초 두 타입으로 표현하는 것.

17.4 P3 — 죽은 매개변수 하나가 유일한 비기본값에서 NPE 를 낳는다

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 다.

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 는 성분 열이고 그중 둘이 분류 집합이다.

Set<FailureCategory> retryableCategories,      // "categories added to the retryable set"
Set<FailureCategory> nonRetryableCategories,   // "categories removed from the retryable set"

DestinationSettings.Retry 에는 이 둘에 대응하는 키가 없고, 컴파일러가 상수로 채운다.

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 를 돌려주면서 동기 예외를 던진다

public CompletionStage<BatchPublishResult> publish(List<PublishRequest<?>> requests, BatchPublishOptions options) {
  
  if (requests.size() > options.maxBatchSize()) {
    throw new MessageTooLargeException("BATCH_COUNT_EXCEEDED", );   // ← 스테이지가 아니라 던진다
  }

같은 클래스가 자기 의존 대상에 대해서는 정확히 이 형태를 방어한다.

} 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 — 공급자 부재)