Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/multitenancy-isolation/case/case-analysis-finding-a05-f031.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

156 lines
18 KiB
Markdown

---
kind: CASE
slug: analysis-finding-a05-f031
title: 오타 난 벤더는 기동을 멈추지만 그 프로퍼티를 지목하지 못한다
topic: multitenancy-isolation
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a05-f031
evidenceCapturedOn: 2026-09-04
body: case-analysis-finding-a05-f031.body.md
assets:
- key: analysis-finding-a05-f031
file: ../../../final/evidence/rendered/analysis-finding-a05-f031.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a05-f031.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §116 이다.
---
# 오타 난 벤더는 기동을 멈추지만 그 프로퍼티를 지목하지 못한다
`PersistenceVendorSettings:13`~`:16` 은 열거형에 바인딩하는 것이 알 수 없는 벤더를 기동 실패로 만드는 근거라고 적고, 그 바인딩이 없을 때 무엇이 대신 나타나는지도 같은 자리에 적어 둔다. `app-bootstrap` 에서 그 타입은 빈이 아니고, 프로브가 자바독이 예고한 증상을 그대로 냈다.
## 관계
- **@Bean이 있다는 것은 조립 증거가 아니다**
애너테이션이 후보만 만든다는 규칙이다. `@ConfigurationProperties` 가 붙어 있어도 어느 스캔 범위에 드는지에 따라 바인딩 여부가 배포마다 갈린다.
- **"꺼짐"은 조건의 반복이 아니라 구조여야 한다**
켜지 않은 능력이 자기 설정을 바인딩하지도 거절하지도 못하게 한다는 점에서 같은 방향이다. 다만 여기서 뺀 것은 컴포넌트 스캔이 아니라 `@ConfigurationPropertiesScan` 이다.
- **스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다**
그 기록은 `@ComponentScan` 의 정규식 제외라 컴포넌트가 어디에도 없게 됐고, 이 기록은 `@ConfigurationPropertiesScan` 의 basePackages 목록이라 값 검증이 사라졌다.
## 문제
벤더 선택기는 프로퍼티 하나로 RDBMS 조합을 고른다. 그 타입의 자바독은 열거형 바인딩이 오타를 기동 실패로 만드는 근거라고 적는다.
그 바인딩이 어디서 일어나고 어디서 일어나지 않는지, 일어나지 않는 쪽에서 오타가 실제로 무엇을 내는지 확인했다.
## 결론
PersistenceVendorSettings:18 에 @ConfigurationProperties 가 붙어 있고, 중첩된 Vendor 열거형 :25~:28 의 값은 POSTGRESQL 과 H2 둘이다. 압축 생성자 :30~:34 가 값이 없으면 POSTGRESQL 로 채운다.
이 타입을 손으로 켜는 main 줄이 하나도 없고, CaSkeletonApplication:58 이 열거한 스무 개 basePackages 에 persistence 패키지가 빠져 있다. SamplePortfolioApplication:46 은 다르다. basePackages = "dev.caskeleton" 을 제외 없이 걸고, sample-portfolio/build.gradle:37 이 그 리프를 의존에 넣는다.
제외는 실수가 아니다. PersistenceJpaRootAutoConfiguration:85~:91 과 JpaAdapterComponentsConfig:55~:56 이 각각 그 사실과 목적을 적어 둔다.
app-bootstrap 이 벤더를 정하는 자리는 PersistenceJpaRootAutoConfiguration:111~:112 의 environment.getProperty 와 :113 의 "postgresql".equalsIgnoreCase(vendor) 다. 허용값 목록이 없어서 mysql 도 postgresq1 도 H2 도 여기서 false 가 된다.
그 false 는 두 곳으로 간다. JpaDataSourceProfileValidator.validateResolved:52~:55 는 false 를 받으면 곧바로 돌아가므로 PostgreSQL 버전 검사가 함께 꺼진다. 그리고 @ConditionalOnProperty 두 개가 모두 어긋나 벤더 SPI 빈이 하나도 만들어지지 않는다.
프로브가 그 뒤를 확인했다. 벤더 설정 둘만 올린 조립은 기동에 성공하고 OutboxClaimRepository 빈이 0 이다. 거기에 그 저장소를 요구하는 @Repository 를 얹으면 두 오타 모두 기동이 멎는데, 예외가 없는 빈의 이름만 말하고 프로퍼티 이름은 말하지 않는다. 반대로 그 타입을 바인딩한 조립에서는 같은 값이 ca-skeleton.persistence.vendor 를 지목하며 멎는다.
PersistenceVendorSelectionTest:52 가 단언하는 것이 뒤쪽이다. :59 가 실패 스택에 프로퍼티 이름이 담기는지 보는데, :92 가 @EnableConfigurationProperties 로 그 타입을 직접 켠 뒤다. app-bootstrap 은 켜지 않으므로 같은 값이 같은 메시지를 내지 않는다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 벤더 설정 타입 전문 게재와 그 자바독 인용, 그 타입을 등록하는 main 자리 계수와 이름이 나오는 자리 전수, 두 애플리케이션의 스캔 범위 대조, 합성 루트의 제외 설명과 벤더 판정 자리와 그 불린을 받는 검증기 인용, 조립 경로 인용, ApplicationContextRunner 세 조립 실행, 시험이 그 타입을 켜는 자리 인용
소스 수정 : x
## 재현 조건
1. 벤더 설정 타입을 전문으로 싣고 열거형 바인딩을 근거로 든 자바독을 읽는다.
2. 그 타입을 켜는 main 줄을 세고 두 애플리케이션의 @ConfigurationPropertiesScan 범위를 나란히 싣는다.
3. 제외를 적어 둔 두 자바독과 벤더 판정 자리와 그 불린을 받는 검증기를 인용한다.
4. 무조건 @Import 되는 컴포넌트 스캔과 그 안의 @Repository 생성자를 인용한다.
5. /tmp/probe-vendor/VendorProbe.java 를 컴파일해 세 조립에 =mysql 과 =postgresq1 을 넣고 돌린다.
## 본문
<!-- body:start -->
`PersistenceVendorSettings` 는 배포가 어떤 RDBMS 조합을 돌릴지 프로퍼티 하나로 고르게 한다.
## 그 타입이 약속하는 기동 실패
:::evidence key="analysis-finding-a05-f031" alt="저장소 루트에서 돌린 정적 검색과 프로브 실행 출력 279줄. 먼저 PersistenceVendorSettings 1~35번 줄이 실린다. 13~16번 자바독은 열거형에 바인딩하는 것이 알 수 없는 벤더를 기동 실패로 만드는 근거라고 적고, 문자열로 두면 두 조건부 벤더 설정이 모두 꺼진 채 남아 첫 번째로 없는 SPI 빈이 OutboxClaimRepository 를 지목하는 NoSuchBeanDefinitionException 으로 나타난다고 적는다. 18번이 ConfigurationProperties 애너테이션, 19번이 record 선언, 25~28번이 POSTGRESQL 과 H2 두 값, 30~34번 압축 생성자가 값이 없으면 POSTGRESQL 로 채운다. 다음으로 EnableConfigurationProperties 가 이 타입을 담은 main 줄이 0 개라고 나오고, ConfigurationPropertiesScan 이 붙은 main 자리 둘이 나온다 — app-bootstrap 의 CaSkeletonApplication 58번과 sample-portfolio 의 SamplePortfolioApplication 46번이며 뒤쪽은 basePackages 가 dev.caskeleton 하나다. 이어서 이 타입 이름이 나오는 자리가 전부 나열되는데 main 은 자기 자신과 H2PersistenceConfig 와 PostgreSqlPersistenceConfig 와 PersistenceJpaRootAutoConfiguration 넷이고 나머지는 PersistenceVendorSelectionTest 이며, 그 시험 92번에만 EnableConfigurationProperties 가 있다. 다음으로 두 애플리케이션의 스캔 범위가 나란히 실린다. CaSkeletonApplication 58~80번은 basePackages 를 스무 개 열거하는데 bootstrap 하위 열하나와 adapter.inbound 셋과 adapter.outbound 의 cache.redis·fileserver·objectstorage 와 application·domain·shared 이고 adapter.outbound.persistence 로 시작하는 항목이 없다. SamplePortfolioApplication 44~48번은 46번이 basePackages 를 dev.caskeleton 으로만 두고, 그 아래 sample-portfolio/build.gradle 37번이 persistence-jpa 를 implementation 의존에 넣는다. 이어서 PersistenceJpaRootAutoConfiguration 56~70번이 실려 57~60번 ConditionalOnProperty 와 62~69번 Import 목록이 나오는데 JpaAdapterComponentsConfig 가 그 목록 맨 앞이고 조건이 붙어 있지 않다. 85~115번에서는 85~91번 자바독이 벤더를 Environment 에서 읽는 이유를 적는다 — 그 타입이 여기 등록된 빈이 아니고 합성 루트의 프로퍼티 스캔이 persistence 패키지를 일부러 제외했으며, 켠 적 없는 선택적 기능이 자기 세부 설정을 바인딩하거나 거절하지 못하게 하려는 것이라고 적는다. 111~112번이 프로퍼티를 기본값 postgresql 로 읽고 113번이 postgresql 과 대소문자 무시로 견준 불린을 validateResolved 에 넘긴다. 다음으로 JpaDataSourceProfileValidator 44~60번이 실리는데 50번 서명 뒤 52~55번이 그 불린이 false 면 주석 두 줄을 달고 곧바로 돌아간다. 이어서 조립 경로가 실린다. JpaAdapterComponentsConfig 55~56번 자바독이 ConfigurationPropertiesScan 이 이 나무를 ComponentScan 만큼 일부러 제외한다고 적고, 59~67번 ComponentScan 의 basePackages 여섯 중 하나가 outbox 패키지이며, OutboxStoreAdapter 23번이 Repository 이고 29~30번 생성자가 OutboxEventJpaRepository 와 OutboxClaimRepository 를 요구한다. 그다음 프로브가 나온다. VendorProbe.java 36~47번이 두 조립 클래스를 정의하는데 OutboxConsumer 가 OutboxStoreAdapter 를 빈으로 만들고 BoundSettings 가 EnableConfigurationProperties 로 그 타입을 켠다. 74~81번 main 이 세 조립을 부른다. 그 아래가 실행 결과다. 두 벤더 설정만 올리고 mysql 을 넣으면 기동 실패가 false 이고 OutboxClaimRepository 빈이 0 이다. SPI 소비자를 더하면 mysql 과 postgresq1 모두 기동 실패가 true 인데 ca-skeleton.persistence.vendor 를 지목하는지가 false 이고 OutboxClaimRepository 를 지목하는지가 true 이며 맨 앞 예외가 UnsatisfiedDependencyException 이다. 그 타입을 켜고 mysql 을 넣으면 기동 실패가 true 이고 이번에는 프로퍼티를 지목하는지가 true 이며 맨 앞 예외가 ConfigurationPropertiesBindException 이다. 마지막으로 PersistenceVendorSelectionTest 28~62번과 88~94번이 실리는데, 52번 시험이 mysql 을 넣고 57번에서 실패를, 59번에서 그 스택에 프로퍼티 이름이 담기는지를 단언하며, 92번이 EnableConfigurationProperties 로 그 타입을 켠다." caption="벤더 설정 타입과 그 자바독이 예고한 증상 · 그 타입을 켜는 main 줄 0 과 두 애플리케이션의 스캔 범위 · 제외를 적어 둔 자바독과 문자열 비교와 그 불린을 받는 검증기 · 무조건 Import 되는 스캔과 그 안의 Repository 생성자 · 세 조립에 오타를 넣고 돌린 결과 · 시험이 그 타입을 켜고 단언하는 자리 — 279줄 · exit 0" zoom="true"
:::
`:13`\~`:16` 자바독이 열거형 바인딩의 목적을 적는다. 문자열로 두면 두 조건부 벤더 설정이 모두 꺼진 채로 남고, 첫 번째로 없는 SPI 빈이 `OutboxClaimRepository` 를 지목하는 `NoSuchBeanDefinitionException` 으로 나타난다는 것이다. 오타 난 값과 그 예외 사이에는 벤더 설정 둘이 함께 꺼지는 단계와 SPI 빈이 없어 주입이 실패하는 단계가 있어서, 예외를 읽어도 어느 프로퍼티가 잘못됐는지 알 수 없다.
`:18``@ConfigurationProperties` 이고 `:25`\~`:28``POSTGRESQL``H2` 둘이다. `:30`\~`:34` 압축 생성자는 값이 없으면 `POSTGRESQL` 로 채운다. 이 선택기가 생기기 전의 모든 배포가 PostgreSQL 을 썼기 때문에, 키를 설정하지 않고 올린 배포가 쓰던 데이터스토어를 그대로 유지하게 하려는 것이다.
## app-bootstrap 의 스캔 목록에 이 패키지가 없다
`@EnableConfigurationProperties(PersistenceVendorSettings.class)` 를 쓰는 main 줄은 0 이다. 그렇게 쓰는 자리는 `PersistenceVendorSelectionTest:92` 하나이고 시험이다.
이름이 나오는 main 파일은 넷이다. 자기 자신, `H2PersistenceConfig`, `PostgreSqlPersistenceConfig`, `PersistenceJpaRootAutoConfiguration` 이다. 앞의 둘은 `@ConditionalOnProperty``prefix` 자리에 `PREFIX` 상수만 쓰고, 마지막 하나는 `import` 와 자바독과 `VENDOR_PROPERTY` 상수만 쓴다.
`CaSkeletonApplication:58`\~`:80``@ConfigurationPropertiesScan` 은 basePackages 를 스무 개 열거한다. `dev.caskeleton.adapter.outbound` 로 시작하는 항목은 `cache.redis``fileserver``objectstorage` 셋이고 `persistence` 가 없다.
이름으로 세는 방식은 패키지째 스캔하는 쪽을 잡지 못하므로 두 합성 루트를 따로 봐야 한다. `SamplePortfolioApplication:46``basePackages = "dev.caskeleton"` 을 제외 없이 걸고, `sample-portfolio/build.gradle:37``implementation project(':adapter:outbound:persistence-jpa')` 로 그 리프를 클래스패스에 올린다. 그 배포에서는 이 타입이 스캔 범위 안이다.
## PersistenceJpaRootAutoConfiguration 자바독이 그 제외를 적어 두었다
`:85`\~`:91` 은 벤더를 `Environment` 에서 읽는 이유를 적는다. 그 타입이 여기서 등록된 빈이 아니고, 합성 루트의 `@ConfigurationPropertiesScan` 이 persistence 패키지를 일부러 제외했기 때문이다.
목적도 함께 있다. 켠 적 없는 선택적 기능이 자기 세부 설정을 바인딩하거나 거절하지 못하게 하려는 것이다. 선택기를 설정하지 않은 배포가 PostgreSQL 로 가는 것이 `PostgreSqlPersistenceConfig``matchIfMissing = true` 와 같은 결과라는 것도 같은 자바독에 있다.
`JpaAdapterComponentsConfig:55`\~`:56` 이 같은 사실을 한 번 더 적는다. 그 패키지들의 `@ConfigurationProperties` 타입을 각자 자기 패키지 안에서 켜야 하는 이유가 이 제외라는 것이다.
제외는 의도된 것이다. 다만 그 제외 때문에 열거형 바인딩이 하던 값 검증도 `app-bootstrap` 에서는 일어나지 않는다.
## \:113 이 "postgresql".equalsIgnoreCase 로 벤더를 정한다
`:111`\~`:112``environment.getProperty(PersistenceVendorSettings.VENDOR_PROPERTY, "postgresql")` 로 문자열을 읽고 `trim` 한다.
`:113``"postgresql".equalsIgnoreCase(vendor)``validator.validateResolved(dataSource, ...)` 에 넘긴다.
허용값 목록이 없다. `mysql``postgresq1``H2` 도 여기서 `false` 가 되어 같은 값으로 들어간다. 열거형에 바인딩했다면 앞의 둘은 값 변환에서 실패했을 것이고 `H2` 는 통과했을 것이다.
## 그 false 를 받는 검증기는 곧바로 돌아간다
`JpaDataSourceProfileValidator.validateResolved:50` 이 그 불린을 `requirePostgreSql` 로 받는다.
`:52`\~`:55``false` 면 즉시 `return` 한다. 주석은 로컬 개발이 H2 를 돌리는 것이 설계이고 여기서까지 PostgreSQL 을 요구하면 모든 노트북을 거절하게 된다고 적는다.
그래서 오타 난 벤더는 `:57`\~`:58` 의 PostgreSQL 버전 검사도 함께 지나친다. 자바독 `:47` 이 이 인자를 "벤더 선택기가 PostgreSQL 을 골랐는지"라고 적는데, 오타는 고르지 않은 것과 구별되지 않는다.
## 세 조립에 오타를 넣고 돌린 결과
`PersistenceJpaRootAutoConfiguration:62`\~`:69``@Import` 는 조건이 없다. 그 목록 맨 앞이 `JpaAdapterComponentsConfig` 이고, 그것이 `@ComponentScan` 하는 여섯 패키지에 `dev.caskeleton.adapter.outbound.persistence.outbox` 가 있다. 그 패키지의 `OutboxStoreAdapter:23``@Repository` 이며 `:29`\~`:30` 생성자가 `OutboxClaimRepository` 를 요구한다.
`ApplicationContextRunner` 로 세 조립을 만들어 값을 넣었다.
두 벤더 설정만 올리고 `=mysql` 을 넣으면 컨텍스트는 성공하고 `OutboxClaimRepository` 빈이 0 이다. `@ConditionalOnProperty` 둘이 모두 어긋난 결과다.
거기에 `OutboxStoreAdapter` 를 소비자로 더하면 `=mysql``=postgresq1` 모두 기동이 실패한다. 예외 사슬이 `OutboxClaimRepository` 를 지목하고 `ca-skeleton.persistence.vendor` 는 지목하지 않는다. 맨 앞은 `UnsatisfiedDependencyException` 이다.
`@EnableConfigurationProperties` 로 그 타입을 켜면 같은 `=mysql``ConfigurationPropertiesBindException` 을 내고 이번에는 `ca-skeleton.persistence.vendor` 를 지목한다.
자바독 `:13`\~`:16` 이 적은 증상이 첫째와 둘째이고, 그것이 막겠다던 증상이 셋째다.
## 시험이 켜는 것을 app-bootstrap 은 켜지 않는다
`PersistenceVendorSelectionTest:52``rejectsAnUnknownVendorAtStartupRatherThanComposingNothing``=mysql` 을 넣고 `:57` 에서 컨텍스트 실패를, `:59` 에서 그 스택에 `VENDOR_PROPERTY` 가 담기는지를 단언한다.
그 시험이 쓰는 러너는 `:91`\~`:93``@EnableConfigurationProperties(PersistenceVendorSettings.class)` 를 함께 올린다. 프로브의 셋째 조립과 같은 모양이다.
그래서 이 시험이 초록이어도 `app-bootstrap` 에서 같은 값이 같은 메시지를 내는지는 말해 주지 않는다. 프로브의 둘째 조립이 그 답이고, 거기서는 프로퍼티 이름이 나오지 않는다.
## 원문에 없는 것
원문은 이 타입이 프로덕션에서 설정 프로퍼티 빈으로 등록되지 않는다고 적는다. 여기에 더한 것은 그것이 실수가 아니라는 점과 그 대가가 무엇인지다. 제외는 두 자바독에 적혀 있고 목적도 함께 적혀 있다. 대가는 둘인데, 오타가 기동을 멈추기는 하되 잘못된 프로퍼티를 지목하지 못한다는 것과, `:113``false` 가 PostgreSQL 버전 검사까지 함께 끈다는 것이다.
## 확인하지 못한 것
프로브가 세운 것은 `ApplicationContextRunner` 위의 부분 조립이고 `app-bootstrap` 전체를 부팅하지 않았다. `OutboxEventJpaRepository``Proxy` 로 대신했고, 실제 애플리케이션을 오타 난 값으로 띄우지는 않았다.
`sample-portfolio` 쪽은 스캔 범위와 의존 그래프로만 판단했고 부팅해 보지 않았다.
제외를 되돌렸을 때 다른 선택적 기능이 무엇을 바인딩하게 되는지 따지지 않았다.
<!-- body:end -->