- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다 - 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5 (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를 techviz 로 만들었다 - 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs 돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다. Concept 이 인용한 코드가 SSOT 에 없어 뺐다 - candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
156 lines
18 KiB
Markdown
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:
|
|
- 원본 분석 절은 final/document.md#a05 §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 -->
|