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>
457 lines
32 KiB
Markdown
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 — 공급자 부재)
|
|
```
|