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>
16 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | body | assets | evidence | source | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a19-f014-kafka-msg | Kafka 스택이 둘이고, 브로커 이름만 주면 기동이 실패한다 | messaging-and-outbox | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a19-f014-kafka-msg | 2026-09-04 | case-a19-f014-kafka-msg.body.md |
|
|
|
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
재현 조건
- Kafka Producer 를 만드는 @Bean 을 main 에서 모두 찾아 시그니처와 빈 이름을 적는다.
- 각 빈에 걸린 조건 애너테이션을 클래스 단위와 메서드 단위로 나눠 읽는다.
- 앞쪽 빈이 파라미터로 받는 설정 타입을 등록하는 자리를 main 에서 전수로 찾는다.
- 그 등록 클래스를 @Import 하거나 자동설정으로 올리는 자리를 찾고 각각의 조건을 읽는다.
- 컴포넌트 스캔 제외 정규식과 @ConfigurationPropertiesScan 목록이 그 패키지를 덮는지 본다.
- ShippedCompositionHarness 로 컴포지션 루트를 세 조합으로 띄우고 실패한 빈과 없는 타입을 적는다.
- 브로커 값을 쓰는 시험 파일마다 스위치를 몇 줄 주는지 세고, 0 줄인 파일의 러너 구성을 읽는다.
- 능력 의존 검증기가 어떤 조합을 위반으로 모으는지 조건을 읽는다.
- 런북과 출하 설정 파일이 두 키를 어떻게 다루는지 확인한다.
본문
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 는 그 때문에 끝에서 죽는다.