--- kind: CASE slug: a-validator-checking-the-wrong-datasource title: validator가 요청을 서비스하지 않는 datasource를 검증하고 있었다 topic: transaction-deadline-and-pool project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: case:a-validator-checking-the-wrong-datasource evidenceCapturedOn: 2026-09-03 assets: - key: a-validator-checking-the-wrong-datasource file: ../../../final/evidence/rendered/a-validator-checking-the-wrong-datasource.svg - key: a-validator-checking-the-wrong-datasource-run file: ../../../final/evidence/rendered/a-validator-checking-the-wrong-datasource-run.svg evidence: - ../../../final/evidence/raw/a-validator-checking-the-wrong-datasource.txt - ../../../final/evidence/raw/a-validator-checking-the-wrong-datasource-run.txt source: - 이 사건의 1차 기록은 두 곳이다. 하나는 `JpaDataSourceProfileValidator` 와 `JpaResolvedDataSourceValidationTest` 두 클래스의 자바독이고, 다른 하나는 저장소 자신의 설계 문서 `docs/superpowers/specs/2026-08-15-five-adapter-runtime-remediation-review-design.md:307` 의 `JPA-INT-002` 항목이다. - root-tree 노드가 가리키는 `analysis/05 §14.5` 는 지금 그 파일에 없다. `analysis/05-adapter-outbound-persistence-jpa.md` 에서 `14.5` 는 0 건이고, 그 문서에 이 검증기와 병렬 네임스페이스가 나오지 않는다. - 원본 상류에 없는 것이 둘이다. `validateResolved` 를 부르는 프로덕션 코드가 지금은 하나 있다는 것과, 그 호출자가 발행된 빈을 쓰지 않고 자기 인스턴스를 만든다는 것이다. --- # validator가 요청을 서비스하지 않는 datasource를 검증하고 있었다 `JpaDataSourceProfileValidator` 의 자바독에 두 결함이 적혀 있다. 이 검증기가 `app.jpa-platform.datasource.*` 를 읽었는데 요청을 받는 풀은 `spring.datasource.hikari.*` 에서 만들어졌다는 것, 그리고 그 병렬 네임스페이스가 어떤 출하 설정에도 없어 두 필드가 늘 널이었다는 것이다. 널에서 던지는 `requirePoolBounds` 를 부르는 코드가 없어서 애플리케이션은 그대로 기동했다. ## 관계 - **connection-timeout: 5s가 모든 prod 배포를 시작 실패시켰고 local만 통과했다** 둘 다 `spring.datasource.hikari.*` 를 두고 벌어진 일이다. 저쪽은 그 값을 바인더가 거부했고, 여기는 다른 네임스페이스를 읽느라 그 값을 보지 못했다. - **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다** 풀을 만드는 네임스페이스와 검증기가 읽는 네임스페이스가 다르면, 검증기는 배포가 쓰지 않는 값을 보고 통과한다. 어느 쪽이 풀을 만드는지 먼저 확인해야 그 통과가 무엇에 대한 것인지 알 수 있다. - **@Bean이 있다는 것은 조립 증거가 아니다** 이 타입의 빈 정의가 하나 있는데 그것을 주입받는 프로덕션 코드가 없고, 실제 검사는 호출자가 직접 만든 객체가 한다. ## 문제 검증기는 배포가 실제로 향하는 데이터소스를 봐야 한다. 그런데 상한 값의 출처는 JpaDataSourceSettings 였다. 그것이 바인딩하는 접두는 app.jpa-platform.datasource 이고, 풀을 만드는 접두는 spring.datasource.hikari 다. ## 결론 자바독은 두 결함이 서로를 상쇄했다고 적는다. 첫째, JpaDataSourceProfileValidator 가 배포가 쓰지 않는 네임스페이스를 읽었다. 둘째, 그 네임스페이스는 출하 YAML 과 환경 키 레지스트리 어디에도 없어 두 필드가 늘 널이었다. requirePoolBounds 가 불렸다면 모든 배포가 기동에 실패했겠지만, 그것을 부르는 코드가 없었다. 지금은 JpaDataSourceSettings 클래스가 없고 설정 파일에서도 0 건이다. src 에 남은 일곱 자리 중 여섯은 자바독이고 하나는 회귀 테스트가 쓰는 검색 리터럴이다. 옛 메서드 이름 셋도 전부 자바독뿐이다. 다만 src 밖이 남았다. infra/jpa/postgres/README.md:18 이 운영자에게 max_connections 를 이제 없는 프로퍼티에 맞춰 잡으라고 지시한다. validateResolved 는 해석된 데이터소스를 받아 널을 검사하고, 벤더 선택이 PostgreSQL 일 때만 커넥션을 열어 제품과 버전을 본다. 열지 못하면 던지므로 도달할 수 없는 데이터베이스가 첫 질의가 아니라 기동에서 실패한다. 여기서 원본 상류에 없는 것이 나온다. 지금은 그 검사를 부르는 프로덕션 코드가 있다. PersistenceJpaRootAutoConfiguration:113 한 자리이고, 그 자동설정은 ca-skeleton.persistence-jpa.enabled 뒤에 있으며 출하 기본값은 거짓이다. 그 메서드는 발행된 빈을 주입받지 않고 :109 에서 자기 인스턴스를 만드는데, 이유로 적힌 조건은 이미 제거됐다. 이 타입의 빈 정의는 JpaPlatformRuntimeAutoConfiguration:112 하나이고 그것이 JpaPlatformAutoConfiguration:49 에 위임한다. 그 빈은 어디에도 주입되지 않는다. 풀 상한 검사는 의도적으로 여기서 하지 않는다. 다만 HikariPoolConstraintValidator 가 읽는 다섯 키는 타임아웃과 수명과 누수 임계이고, maximum-pool-size 하한은 RuntimeNumericBoundsValidator:22 가 본다. 자바독이 함께 적은 이득 하나는 지금 성립하지 않는다 — HikariCP 는 선언만 testImplementation 이고 runtimeClasspath 에는 전이로 올라온다. 회귀 테스트가 그 부재를 고정한다. 훑을 목록이 비어 있지 않다는 것을 먼저 단언하고 주석을 걷어낸 뒤 검사하되, 훑는 범위는 두 모듈이다. 세 건을 돌려 전부 통과하는 것을 확인했다. 상류 어디에도 등급이 없다. root-tree 노드에도 candidate ledger 에도 설계 문서의 JPA-INT-002 항목에도 없어서, 이 기록도 새로 매기지 않는다. 두 결함은 닫혀 있고 남은 것은 관찰이다. ## 검증 환경 OpenJDK : 21.0.12 Spring Boot : 4.0.8 확인 방식 : 자바독의 사후 기록 확인, app.jpa-platform.datasource 와 requirePoolBounds 의 잔존 자리 전수, validateResolved 본문과 그 프로덕션 호출자·조건 확인, 이 타입의 빈 정의와 주입처 계수, runtimeClasspath 전개, 회귀 테스트 실행 소스 수정 : x ## 재현 조건 1. JpaDataSourceProfileValidator 의 클래스 자바독을 읽는다. 두 결함과 그 상쇄가 적혀 있다. 2. app.jpa-platform.datasource 를 src 안팎에서 각각 찾고, 설정 파일과 바인딩 클래스 쪽을 따로 센다. 3. 남은 히트가 주석인지 코드인지 소스 세트별로 가른다. 4. requirePoolBounds 가 남은 자리를 센다. 5. validateResolved 가 무엇을 읽고 어떤 조건에서 무엇을 던지는지 본문에서 읽는다. 6. 그것을 부르는 자리를 main 과 test 로 갈라 세고, 그 자동설정이 붙는 프로퍼티와 출하 기본값을 확인한다. 7. 그 호출자가 검증기를 주입받는지 직접 만드는지 보고, 직접 만드는 이유로 적힌 조건이 지금도 있는지 확인한다. 8. 이 타입의 @Bean 정의를 모두 찾고 그것이 무엇에 위임하는지 확인한 뒤, 그 빈을 받는 코드를 센다. 9. HikariPoolConstraintValidator 가 읽는 키를 나열하고, maximum-pool-size 를 검사하는 코드를 따로 찾는다. 10. 이 모듈의 HikariCP 선언 구성과 runtimeClasspath 전개 결과를 대조한다. 11. 회귀 테스트의 단언과 가드와 스캔 범위를 읽고, 그 테스트를 실행해 결과를 읽는다. ## 본문 `JpaDataSourceProfileValidator` 의 클래스 자바독에는 이 검증기가 예전에 다른 데이터소스를 검증했다는 내용이 먼저 적혀 있다. ## 자바독이 적은 두 결함과 그 상쇄 :::evidence key="a-validator-checking-the-wrong-datasource" alt="저장소 루트에서 돌린 정적 검색 출력 158줄. JpaDataSourceProfileValidator 11행부터 19행까지의 자바독이 두 결함과 상쇄를 원문 그대로 적는다. 이어서 app.jpa-platform.datasource 가 src 안에 남아 있는 일곱 자리가 main 다섯과 test 둘로 나오고, src 밖에서 그 문자열을 쓰는 파일 다섯과 줄 수, 그중 infra 의 운영자용 표에 살아 있는 줄이 이어진다. 설정 파일에서는 0 건이고 주석이 아닌 줄은 main 0 test 1 이며 JpaDataSourceSettings 클래스도 0 건이다. requirePoolBounds 가 남은 세 자리는 전부 자바독이다. 그다음 validateResolved 의 본문 50행부터 66행, 그 호출자가 붙는 프로퍼티 조건과 출하 기본값 false, 직접 생성의 이유로 적힌 조건과 그 조건이 지금 제거됐다는 사슬, 호출자가 자기 인스턴스를 만드는 106행부터 114행이 나온다. 이 타입의 빈 정의는 하나이고 JpaPlatformAutoConfiguration 의 @Bean 개수는 0 이며 그 빈을 받는 프로덕션 코드는 0 건이다. 끝으로 HikariPoolConstraintValidator 가 읽는 다섯 키와 그 빈, maximum-pool-size 를 실제로 보는 다른 검증기, HikariCP 가 선언은 testImplementation 인데 runtimeClasspath 에는 올라온다는 출력, 회귀 테스트 세 건의 이름과 그 스캔 범위와 공허한 통과를 막는 단언이 보인다." caption="자바독의 두 결함 · 네임스페이스 잔존 위치와 설정 0 · validateResolved 의 본문과 조건 · 빈 정의 하나와 주입 0 · 풀 키의 실제 소유자 · HikariCP 의 선언과 런타임 · 회귀 테스트 셋 — 158줄 · exit 0" zoom="true" ::: 첫 번째 결함으로 자바독이 적는 것은 읽는 대상의 어긋남이다. 상한 값은 `JpaDataSourceSettings` 가 `app.jpa-platform.datasource.*` 에 바인딩한 설정에서 왔고, 요청을 처리하는 풀은 `spring.datasource.hikari.*` 에서 만들어졌다. 자바독은 이것을 풀 하나에 설명이 둘인 상태로 적고, 그러면 검증기가 실제로 쓰이지 않는 쪽 설명을 보고 통과할 수 있다고 적는다. 두 번째가 더 나쁘다고 자바독은 적는다. 그 병렬 네임스페이스는 어떤 출하 YAML 에도, 환경 키 레지스트리의 어떤 행에도 없었다. 두 필드가 항상 널이었고 `requirePoolBounds` 는 널에서 던진다. 무언가 그것을 불렀다면 모든 배포가 기동에 실패했을 것이다. 아무것도 그것을 부르지 않았고, 그래서 아무것도 실패하지 않았다. 자바독은 여기까지를 두 결함이 서로를 상쇄한 것으로 적고, 애플리케이션이 시작된 이유를 두 번째 결함이 첫 번째를 가린 것으로 적는다. ## app.jpa-platform.datasource 가 지금 남아 있는 자리 `src` 안에서 이 문자열을 찾으면 일곱 자리가 나온다. 설정 파일에서는 0 건이고, 그것을 바인딩하던 `JpaDataSourceSettings` 클래스도 없다. main 다섯은 전부 자바독이다. 그중 넷은 이 검증기가 아니라 다른 리프의 것으로, `NotificationSmtpProviderConfig:48` 과 `NotificationSmtpSettings:14` 와 `SmtpProviderRuntimeAssembler:40` 이 자기 결정의 이유를 대면서 이 사건을 선례로 인용하고, `KafkaMessagingAutoConfiguration:119` 도 브로커 주소를 두 번 기술하지 않는 이유로 같은 것을 든다. 나머지 하나가 검증기 자신의 `:12` 다. test 둘 중 하나는 자바독이고, 나머지 하나는 `JpaResolvedDataSourceValidationTest:57` 의 `.contains("app.jpa-platform.datasource")` — 회귀 테스트가 이 문자열을 찾을 때 쓰는 검색 리터럴이다. `src` 밖에도 다섯 파일이 이 문자열을 쓴다. 넷은 설계 문서와 계획 문서지만 하나는 다르다. `infra/jpa/postgres/README.md:18` 이 운영자용 표에서 `max_connections` 를 `app.jpa-platform.datasource.maximum-pool-size` 에 맞춰 잡으라고 지금도 지시한다. 그 프로퍼티는 더 이상 없다. 옛 상한 검사 메서드 이름도 셋 남았는데 역시 전부 자바독이다. 검증기 자신의 `:17` 과 회귀 테스트의 `:26`·`:29` 다. 부를 수 있는 메서드로는 남아 있지 않다. ## validateResolved 가 읽는 값과 던지는 조건 `validateResolved(DataSource, boolean)` 는 `DataSource` 널 검사를 먼저 하고, 벤더 선택이 PostgreSQL 이 아니면 그대로 돌아온다. 그 자리 주석이 이유를 적는다 — 로컬 개발은 설계상 H2 로 돌고 프로덕션에서 그것을 막는 것은 `PersistenceVendorProdSafetyValidator` 이며, 여기서도 PostgreSQL 을 요구하면 모든 노트북을 거절하게 된다. PostgreSQL 이 선택된 배포에서만 커넥션을 열어 `versionPolicy.requireStable` 에 메타데이터를 넘긴다. 열지 못하면 `IllegalStateException` 을 던지고, 메시지는 `"A context that starts without this check reports healthy and fails on whoever sends the first request."` 다. ## validateResolved 의 프로덕션 호출자 하나와, 발행된 빈 `validateResolved` 를 부르는 프로덕션 코드는 `PersistenceJpaRootAutoConfiguration:113` 하나다. `@Bean InitializingBean jpaResolvedDataSourceCheck` 가 해석된 `DataSource` 와 `Environment` 를 받아, 벤더 프로퍼티를 읽고 그 값이 `postgresql` 인지를 두 번째 인자로 넘기는 람다를 돌려준다. 이 자동설정은 `:57`\~`:60` 의 `@ConditionalOnProperty(prefix = "ca-skeleton.persistence-jpa", name = "enabled", havingValue = "true")` 뒤에 있고 `application.yml:364` 의 출하 기본값은 `false` 다. JPA 마스터 스위치를 켠 배포에서만 이 검사가 돈다. 그 메서드는 검증기를 주입받지 않는다. `:109`\~`:110` 에서 `new JpaDataSourceProfileValidator(new PostgreSqlVersionPolicy())` 로 자기 인스턴스를 만든다. 이유도 `:95`\~`:99` 에 적혀 있다. 빈을 내놓는 클래스가 `@ConditionalOnBean(DataSource.class)` 를 달고 있어 실제 애플리케이션에서 조용히 빠지므로, 그 빈에 의존하면 이 검사도 같은 이유로 사라진다는 것이다. 그 이유는 지금 성립하지 않는다. `JpaPlatformRuntimeAutoConfiguration` 의 자바독 `:57`\~`:58` 은 이 클래스가 마스터 스위치를 이미 든 루트 아래에 있어 데이터소스 존재 여부가 파싱 시점에 이미 답해져 있다고 적고, 그 조건은 제거됐다. 이 타입을 빈으로 정의하는 자리는 하나다. `JpaPlatformRuntimeAutoConfiguration:112` 의 `@Bean` 이고, 그 메서드가 `JpaPlatformAutoConfiguration:49` 의 `dataSourceProfileValidator()` 에 위임한다. 뒤쪽 클래스에는 `@Bean` 이 하나도 없다 — 자바독 `:19` 가 `app-bootstrap` 이 조립을 소유한다는 규칙 때문에 평범한 생성으로 둔다고 적는다. 그 빈을 파라미터나 필드로 받는 프로덕션 코드는 0 건이다. 검사는 도는데, 도는 것은 발행된 빈이 아니라 호출자가 직접 만든 객체다. ## 풀 크기 상한을 읽는 것은 또 다른 검증기다 자바독은 풀 상한을 여기서 보지 않는 결정을 굵게 적으면서 `HikariPoolConstraintValidator` 를 지목한다. 그 검증기가 실제로 읽는 다섯 키는 `connection-timeout`, `validation-timeout`, `keepalive-time`, `max-lifetime`, `leak-detection-threshold` 이고, `RuntimeSafetyConfig:49` 의 `@Bean` 이 그것을 내놓는다. 옛 `requirePoolBounds` 가 널에서 던지던 `maximum-pool-size` 는 그쪽이 아니라 `RuntimeNumericBoundsValidator:22` 가 하한 1 로 본다. `minimum-idle` 은 `:26` 이다. 자바독은 이 클래스가 풀 타입 이름을 대지 않게 되어 HikariCP 가 `app-bootstrap` 의 프로덕션 클래스패스에서 빠진다고도 적는다. 선언은 그렇다 — `app-bootstrap/build.gradle:140` 이 `testImplementation` 하나뿐이다. 그런데 `runtimeClasspath` 를 풀어 보면 `com.zaxxer:HikariCP:7.0.2` 가 올라와 있다. `implementation project(':adapter:outbound:persistence-jpa')` 를 타고 `spring-boot-starter-data-jpa` 가 끌어온 것이라, 자바독이 적은 그 이득은 지금 성립하지 않는다. ## 회귀 테스트가 공허한 통과까지 막는다 :::evidence key="a-validator-checking-the-wrong-datasource-run" alt="gradle 실행 출력 6줄. PIPESTATUS 로 읽은 gradle 종료 코드 0 이 먼저 찍히고, JpaResolvedDataSourceValidationTest 스위트의 JUnit 결과가 tests 3, failures 0, errors 0, skipped 0 으로 나온다. 이어서 세 테스트가 각각 통과로 표시되는데 도달할 수 없는 데이터베이스가 기동에서 실패하는지, 제품 검사가 벤더 선택을 따르는지, 프로덕션 코드가 병렬 네임스페이스를 바인딩하지 않는지 셋이다." caption="회귀 테스트 세 건 실행 결과 — 6줄 · exit 0" zoom="true" ::: `theParallelNamespaceIsGone`(\:46)이 소스를 훑어 병렬 바인딩이 없다고 단언한다. 그 앞에 단언이 하나 더 있다. 훑은 목록이 비어 있지 않다는 것(\:49\~\:51)이고, 그 단언의 문구가 이유를 적는다 — 아무 소스에도 닿지 못한 스캔은 모든 바인딩을 없다고 보고한다. `:53`\~`:54` 의 주석이 왜 주석을 걷어내고 검사하는지도 적는다. 네임스페이스를 퇴역시켰다고 기록한 문장은 결함의 반대이기 때문이다. 다만 이 테스트가 잡을 수 있는 범위는 두 모듈이다. `productionSources()`(\:115\~\:119)가 `app-bootstrap` 과 `adapter/outbound/persistence-jpa` 만 훑는다. 위에서 센 다른 리프 넷은 애초에 스캔 대상 밖이라, 그것들이 이 테스트를 깨뜨리지 않는 이유는 주석 제거만이 아니다. 나머지 둘은 부재가 아니라 지금 동작을 고정한다. `anUnreachableDatabaseFailsAtStartup`(\:71)이 죽은 포트를 가리키는 `HikariDataSource` 에 대해 커넥션을 열지 못한다는 메시지를 요구하고, `theProductCheckFollowsTheVendorSelector`(\:92)가 H2 를 벤더 선택이 거짓일 때는 통과시키고 참일 때는 거절한다고 단언한다. 돌리면 세 건이 통과한다. ## 확인하지 못한 것 결함이 있던 시점의 코드를 직접 열어 보지 않았다. 두 결함을 적어 둔 곳은 자바독과 위 설계 문서이고, 지금의 상태만 계수와 실행으로 확인했다. 주입처가 0 건이라는 판정의 근거는 이름 기반 정적 검색이다. 빈 이름으로 찾아 쓰거나 리플렉션으로 꺼내는 경로까지 배제하지는 못했다. 결함이 있던 시점의 코드를 직접 열어 보지 않았다. 두 결함을 적어 둔 곳은 자바독과 위 설계 문서이고, 지금의 상태만 계수와 실행으로 확인했다.