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>
161 lines
16 KiB
Markdown
161 lines
16 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: a19-f014-kafka-msg
|
|
title: Kafka 스택이 둘이고, 브로커 이름만 주면 기동이 실패한다
|
|
topic: messaging-and-outbox
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:a19-f014-kafka-msg
|
|
evidenceCapturedOn: 2026-09-04
|
|
body: case-a19-f014-kafka-msg.body.md
|
|
assets:
|
|
- key: a19-f014-kafka-msg
|
|
file: ../../../final/evidence/rendered/a19-f014-kafka-msg.svg
|
|
- key: a19-f014-kafka-msg-probe
|
|
file: ../../../final/evidence/rendered/a19-f014-kafka-msg-probe.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/a19-f014-kafka-msg.txt
|
|
- ../../../final/evidence/raw/a19-f014-kafka-msg-probe.txt
|
|
source:
|
|
- 원본 분석 절은 analysis/19-messaging-platform.md#L735 이다.
|
|
---
|
|
|
|
# Kafka 스택이 둘이고, 브로커 이름만 주면 기동이 실패한다
|
|
|
|
한 아티팩트가 Kafka `Producer` 빈을 둘 발행한다. 원문은 마스터 스위치가 꺼진 채 브로커 이름만 주면 `KafkaSenderConfig` 쪽만 올라온다고 적었는데, 컴포지션 루트를 그 조합으로 띄우면 `kafkaSeamProducer` 에서 컨텍스트가 죽는다.
|
|
|
|
## 관계
|
|
|
|
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
|
|
이름이 겹치는 둘을 만났을 때 조립되는 쪽을 가리는 절차다. 여기서는 조건 애너테이션만으로는 갈리지 않아 컨텍스트를 띄워 갈랐다.
|
|
- **마스터 스위치는 루트 하나가 소유하고 자식 설정은 조건을 갖지 않는다**
|
|
그 결정은 능력 하나를 켜고 끄는 권한을 루트 한 곳에 둔다. 두 스택이 그 루트를 각각 다른 경로로 지나므로 조건만 읽으면 한쪽이 스위치 밖에 있는 것처럼 보인다.
|
|
- **조건부 빈의 평가 시점 — 파싱 시점과 등록 시점**
|
|
조건 애너테이션만 나란히 읽으면 `KafkaSenderConfig` 는 조건이 하나로 보인다. 그 빈이 파라미터로 받는 설정 타입을 누가 등록하는지까지 봐야 실제로 조립되는 조합이 나온다.
|
|
|
|
## 문제
|
|
|
|
한 아티팩트가 Kafka 생산자를 만드는 자리를 둘 갖고 있고, 둘의 조건이 달라 보인다.
|
|
|
|
마스터 스위치가 그중 어디까지 막는지 확인했다.
|
|
|
|
## 결론
|
|
|
|
생산자 빈은 둘이다. KafkaSenderConfig:63 이 Producer<String, String> 을, KafkaMessagingAutoConfiguration:130 이 Producer<byte[], byte[]> 를 만든다. modules.json 상 두 모듈은 서로를 의존하지 않는다.
|
|
|
|
조건은 달라 보인다. 앞쪽은 클래스에 app.messaging.broker 값 조건 하나만 달고, 뒤쪽은 MessagingPlatformRootAutoConfiguration:28 의 마스터 스위치를 지난 뒤 MessagingProviderSelection:48 이 같은 값으로 고른다.
|
|
|
|
그런데 앞쪽도 스위치 뒤에 있다. kafkaSeamProducer 가 받는 KafkaAdapterSettings 는 KafkaAdapterConfig:19 만 등록하고, 그 클래스를 수입하는 main 코드는 MessagingBridgeRootAutoConfiguration:23 하나이며 그 루트가 :21 에서 마스터 스위치를 요구한다. 다른 경로도 없다 — CaSkeletonApplication 의 스캔 제외 정규식이 그 패키지를 잘라 내고 @ConfigurationPropertiesScan 목록에도 없다.
|
|
|
|
ShippedCompositionHarness 로 컴포지션 루트를 세 조합으로 띄웠다. 스위치를 끈 채 브로커 이름만 주면 kafkaSeamProducer 가 KafkaAdapterSettings 를 못 찾아 기동이 실패한다. 스위치까지 켜면 그 생산자가 만들어진다. :62 의 빈 조건은 정의 등록 시점을 보는 것이라 이 클래스는 물러나지 않는다.
|
|
|
|
브로커 이름만 주고도 통과하는 시험 둘이 있는데 둘 다 슬라이스다. MessagingConfigTest:41 과 OptionalAdapterBeanGatingTest:68 이 KafkaAdapterConfig 를 손으로 넣고, 어느 쪽도 CaSkeletonApplication 이나 KafkaSenderConfig 를 참조하지 않는다.
|
|
|
|
CapabilityDependencyValidator:70 은 스위치가 켜졌는데 브로커가 빈 경우만 잡는다. 런북은 :28~:30 에서 이 키의 성격을 선택자로 못박는다. 출하 설정은 두 키를 늘 짝으로 주므로 이 조합에 닿지 않는다.
|
|
|
|
판정은 P2 이고 원문과 같다. 다만 받치는 근거가 하나 교체된다. 문서보다 좁은 기본 보호 범위 대신, 검증기를 통과하는 조합이 컨텍스트를 죽인다는 사실이 들어온다.
|
|
|
|
## 검증 환경
|
|
|
|
확인 방식 : 두 @Bean 선언과 각각의 조건 애너테이션 확인, 설정 타입의 등록 지점과 그것을 수입하는 자리 전수, 컴포넌트 스캔 제외 정규식과 프로퍼티 스캔 목록 확인, ShippedCompositionHarness 로 컴포지션 루트를 세 조합으로 기동, 브로커 값을 쓰는 시험 파일별 스위치 지정 횟수 계수와 그 시험들의 러너 구성 확인, 능력 의존 검증기의 조건 확인, 런북 서술과 출하 설정 파일의 두 키 확인, modules.json 의 의존 방향 확인
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. Kafka Producer 를 만드는 @Bean 을 main 에서 모두 찾아 시그니처와 빈 이름을 적는다.
|
|
2. 각 빈에 걸린 조건 애너테이션을 클래스 단위와 메서드 단위로 나눠 읽는다.
|
|
3. 앞쪽 빈이 파라미터로 받는 설정 타입을 등록하는 자리를 main 에서 전수로 찾는다.
|
|
4. 그 등록 클래스를 @Import 하거나 자동설정으로 올리는 자리를 찾고 각각의 조건을 읽는다.
|
|
5. 컴포넌트 스캔 제외 정규식과 @ConfigurationPropertiesScan 목록이 그 패키지를 덮는지 본다.
|
|
6. ShippedCompositionHarness 로 컴포지션 루트를 세 조합으로 띄우고 실패한 빈과 없는 타입을 적는다.
|
|
7. 브로커 값을 쓰는 시험 파일마다 스위치를 몇 줄 주는지 세고, 0 줄인 파일의 러너 구성을 읽는다.
|
|
8. 능력 의존 검증기가 어떤 조합을 위반으로 모으는지 조건을 읽는다.
|
|
9. 런북과 출하 설정 파일이 두 키를 어떻게 다루는지 확인한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
`app-bootstrap` 하나에 Kafka `Producer` 를 만드는 `@Bean` 이 둘 있다. `MSG-015` 가 그 상태를 미해결로 들고 있다(`src/messaging/CLAUDE.md:70`, `:82`).
|
|
|
|
## 두 생산자 빈과 각각의 조건
|
|
|
|
:::evidence key="a19-f014-kafka-msg" alt="저장소 루트에서 돌린 정적 검색 출력 140줄. 두 Producer 빈의 선언 줄과 각각의 조건이 나온다 — KafkaSenderConfig 는 클래스에 app.messaging.broker=kafka 조건 하나를 달고 Producer<String,String> 을 만들고, KafkaMessagingAutoConfiguration 은 Producer<byte[],byte[]> 를 만들며 MessagingPlatformRootAutoConfiguration 의 app.messaging.enabled=true 와 MessagingProviderSelection 의 제공자 선택을 거쳐 닿는다. 이어서 KafkaAdapterSettings 를 등록하는 유일한 자리와 그 자리를 @Import 하는 유일한 루트, 그 루트의 조건이 나오고, CaSkeletonApplication 의 컴포넌트 스캔 제외 정규식과 @ConfigurationPropertiesScan 목록이 adapter.outbound.messaging 을 덮지 않는 것이 보인다. app.messaging.broker=kafka 를 쓰는 시험 파일마다 app.messaging.enabled 를 몇 줄 주는지 세어 보이고, 그중 0 줄인 두 파일이 KafkaAdapterConfig 를 손으로 등록하며 CaSkeletonApplication 도 KafkaSenderConfig 도 참조하지 않는 것이 나온다. 마지막으로 이 조합을 거르지 않는 CapabilityDependencyValidator 의 조건, 이 키를 선택자라고 설명하는 런북, 두 키를 항상 짝으로 주는 출하 설정, 그리고 KafkaSenderConfig 의 빈 조건 애너테이션이 실린다." caption="두 Producer 빈과 조건 · 설정 빈의 유일한 등록·수입 경로 · 스캔 제외 정규식 · broker 만 준 시험이 통과하는 이유 · 이 조합을 거르지 않는 검증기와 그것을 권하는 런북 · 출하 설정의 짝 — 140줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
`KafkaSenderConfig:63` 은 `Producer<String, String> kafkaSeamProducer` 를 만든다. `:61` 이 빈 이름을 `kafkaSeamProducer` 로 고정하고 `:62` 가 같은 이름의 빈이 없을 때만 만든다는 조건을 단다. 클래스에는 `:38` 의 `@ConditionalOnProperty(name = "app.messaging.broker", havingValue = "kafka")` 하나가 붙어 있다.
|
|
|
|
`KafkaMessagingAutoConfiguration:130` 은 `Producer<byte[], byte[]> messagingKafkaProducer` 를 만든다. 여기까지 오는 길은 `MessagingPlatformRootAutoConfiguration:28` 의 `app.messaging.enabled=true` 를 지나고, `MessagingProviderSelection:48` 이 `kafka` 라는 값에 이 자동설정을 물린다.
|
|
|
|
`modules.json` 상 `adapter-outbound-messaging` 의 의존에 `messaging-*` 이 없고, `messaging-spring-boot-starter` 의 의존에 adapter 가 없다. 두 스택은 서로를 참조하지 않는다.
|
|
|
|
## KafkaSenderConfig 도 app.messaging.enabled=true 를 지나야 조립된다
|
|
|
|
조건 애너테이션이 하나뿐이라고 해서 그 하나만으로 조립된다는 뜻은 아니다.
|
|
|
|
`kafkaSeamProducer` 가 파라미터로 받는 `KafkaAdapterSettings` 를 등록하는 자리는 `KafkaAdapterConfig:19` 의 `@EnableConfigurationProperties` 하나뿐이다. 그 클래스를 `@Import` 하는 main 코드는 `MessagingBridgeRootAutoConfiguration:23` 하나이고, 그 루트는 `:21` 에서 `app.messaging.enabled=true` 를 요구한다.
|
|
|
|
다른 경로로 들어올 수도 없다. `CaSkeletonApplication:100`\~`:105` 의 `AUTO_CONFIGURED_PACKAGES` 정규식이 `dev\.caskeleton\.adapter\.outbound\.messaging\..*` 를 컴포넌트 스캔에서 잘라 내고(`:55`\~`:56`), `@ConfigurationPropertiesScan` 목록에도 그 패키지가 없다.
|
|
|
|
`KafkaSenderConfig` 자신은 `dev.caskeleton.bootstrap.messaging` 패키지에 있고 그 이름은 제외 정규식에 없다. 스캔으로 들어온다.
|
|
|
|
## 세 조합을 실제로 기동한 결과
|
|
|
|
:::evidence key="a19-f014-kafka-msg-probe" alt="출하 컴포지션 루트를 세 조합으로 기동한 프로브 출력 43줄. 세 조합 모두 ShippedCompositionHarness 의 requiredOperatorInputs 와 allOffArguments 를 받고 조합마다 인자를 덮어썼다. C1 은 마스터 스위치가 꺼지고 브로커 이름이 없는 조합인데 jpaSharedEM_entityManagerFactory 에서 실패한다. C2 는 브로커 이름만 kafka 로 덮어쓴 조합인데 실패한 빈이 kafkaSeamProducer 로 바뀌고 없는 것이 KafkaAdapterSettings 라고 나온다. C3 는 마스터 스위치까지 켠 조합인데 KafkaProducer 가 실제로 만들어져 bootstrap.servers 와 두 직렬화기와 security.protocol 이 찍히고, 그 뒤 C1 과 같은 JPA 빈에서 끝난다. 세 조합을 통틀어 만들어진 KafkaProducer 는 1 개다. 마지막 세 줄은 이 프로브의 클래스패스에 시험 출력이 함께 있어 JPA 저장소 스캔이 켜진다는 것과, C2 의 실패는 그보다 먼저 난다는 것을 밝힌다." caption="컴포지션 루트 세 조합 기동 — 브로커 이름만 더하면 실패 지점이 kafkaSeamProducer 로 바뀐다 · 스위치를 켜면 그 생산자가 만들어진다 · 관측된 KafkaProducer 1 개 · 프로브 클래스패스가 남긴 JPA 실패 명시 — 43줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
`ShippedCompositionHarness.shippedComposition("local")` 에 `requiredOperatorInputs()` 와 `allOffArguments()` 를 주고 세 조합으로 돌렸다. `allOffArguments()` 는 `--app.messaging.enabled=false` 를 포함한다.
|
|
|
|
브로커 이름을 주지 않은 C1 에서는 messaging 쪽 빈이 만들어지지 않는다. 브로커 이름만 `kafka` 로 덮어쓴 C2 에서는 실패한 빈이 `kafkaSeamProducer` 이고, 없다고 보고된 것이 `KafkaAdapterSettings` 다. 마스터 스위치까지 켠 C3 에서는 그 생산자가 실제로 만들어져 `bootstrap.servers = [localhost:9092]`, 두 직렬화기가 `StringSerializer`, `security.protocol = PLAINTEXT` 로 찍힌다.
|
|
|
|
`:62` 의 `@ConditionalOnMissingBean(name = "kafkaSeamProducer")` 은 빈 정의를 등록할지 정하는 조건이다. 그 시점에 같은 이름의 빈이 없으므로 정의는 등록되고, 실패는 정의를 실체로 만들 때 파라미터를 못 찾아서 난다. `KafkaSenderConfig` 는 물러나지 않는다.
|
|
|
|
이 프로브의 클래스패스에는 `app-bootstrap` 의 시험 출력이 함께 있어 Spring Data JPA 저장소 스캔이 켜진다. 그래서 C1 과 C3 는 끝에서 `entityManagerFactory` 부재로 죽는다. C2 의 실패는 그보다 먼저 난다.
|
|
|
|
## 슬라이스 시험이 보여 주는 것과 다른 것
|
|
|
|
`app.messaging.broker=kafka` 를 쓰는 시험 파일 넷 중 둘은 `app.messaging.enabled` 를 한 줄도 주지 않는다. `MessagingConfigTest` 와 `OptionalAdapterBeanGatingTest` 다.
|
|
|
|
그 둘은 `ApplicationContextRunner` 에 설정 클래스를 골라 넣는 슬라이스다. `MessagingConfigTest:41` 과 `OptionalAdapterBeanGatingTest:68` 이 `KafkaAdapterConfig` 를 직접 등록한다. 마스터 스위치 뒤에 있는 것을 손으로 넣으므로 스위치를 지날 필요가 없다.
|
|
|
|
두 파일 모두 `CaSkeletonApplication` 을 참조하지 않고 `KafkaSenderConfig` 도 참조하지 않는다. 컴포넌트 스캔이 없으니 실패하는 빈 자체가 그 슬라이스에 없다.
|
|
|
|
`MessagingConfigTest` 의 broker 전용 시험 둘은 이름이 `selectedKafkaBrokerWithoutProjectSenderFailsStartupCharacterization`(`:39`)과 `selectedBrokerIdMismatchFailsStartupCharacterization`(`:54`)이다. 선택자만 준 조합을 기동 실패로 특성화한다.
|
|
|
|
## 이 조합을 거르는 검증기가 없다
|
|
|
|
`CapabilityDependencyValidator:70` 은 `app.messaging.enabled=true` 인데 브로커가 비어 있으면 위반으로 모은다. 반대 조합은 조건에 없다.
|
|
|
|
`docs/runbooks/outbox-publish-failed.md:28`\~`:30` 은 `APP_MESSAGING_BROKER` 가 활성화 스위치가 아니라 선택자이고 messaging 을 끄는 것은 `APP_MESSAGING_ENABLED=false` 라고 적는다. 그 설명대로 스위치를 끈 채 선택자만 남기면 C2 가 된다.
|
|
|
|
출하 설정은 그 조합에 닿지 않는다. `compose-profile-contracts.json` 의 세 자리(`:170`, `:202`, `:604`)가 두 키를 항상 짝으로 주고, `.env.example:229` 와 `.env.local.example:21` 은 브로커를 공백으로 둔다.
|
|
|
|
## 원문과 갈리는 자리
|
|
|
|
원문은 `app.messaging.enabled=false` 인 기본 상태에서 브로커 이름만 주면 `KafkaSenderConfig` 쪽 스택만 올라온다고 적었고, 그래서 마스터 스위치가 두 스택 중 하나만 막는다고 했다.
|
|
|
|
C2 가 그 반대를 보인다. 그 조합에서 올라오는 것은 없고 컨텍스트가 `kafkaSeamProducer` 에서 죽는다. 마스터 스위치는 두 스택을 모두 막는다. 원문이 정정 대상으로 지목한 `src/messaging/CLAUDE.md:82` 의 서술 — 지금 안전한 이유가 설계가 아니라 `app.messaging.enabled=false` 라는 기본값이라는 것 — 은 그대로 성립한다.
|
|
|
|
원문이 인용한 `KafkaSenderConfig:23`\~`:27` 의 자바독은 정확하다. 다만 그 자바독이 없다고 적은 빈은 `KafkaSender` 이고, C2 에서 없는 것은 `KafkaAdapterSettings` 다. 층이 다르다.
|
|
|
|
## 등급에 대해
|
|
|
|
원본 분석의 등급은 P2 이고 이 기록은 그대로 둔다. 다만 등급을 받치던 근거 하나가 바뀐다.
|
|
|
|
기본값이 지켜 주는 범위가 문서보다 좁다는 것은 성립하지 않는다. 남는 근거는 한 아티팩트에 서로를 참조하지 않는 Kafka 스택이 둘이라는 것이고, 이것은 정상 운영 조합에서 빈 이름이 달라 기동이 성공하므로 알려 주는 신호가 없다.
|
|
|
|
그 자리에 들어오는 근거가 하나 늘었다. 검증기가 통과시키고 런북의 설명이 이끄는 조합이 기동을 죽인다. 출하 설정이 그 조합에 닿지 않아 P2 를 넘기지는 않는다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
두 생산자가 한 컨텍스트에 함께 등록된 것을 보지 못했다. 스위치를 켠 조합에서 `kafkaSeamProducer` 하나가 만들어진 데까지 갔다.
|
|
|
|
두 생산자가 같은 클러스터에 붙는지 확인하지 않았다. 각자 어느 설정에서 주소를 읽는지까지 봤다.
|
|
|
|
프로브의 클래스패스는 출하 클래스패스가 아니다. `app-bootstrap` 시험 출력이 함께 있어 JPA 저장소 스캔이 켜지고, C1 과 C3 는 그 때문에 끝에서 죽는다.
|
|
|
|
프로브의 클래스패스는 출하 클래스패스가 아니다. `app-bootstrap` 시험 출력이 함께 있어 JPA 저장소 스캔이 켜지고, C1 과 C3 는 그 때문에 끝에서 죽는다.
|
|
|
|
<!-- body:end -->
|