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>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,118 @@
---
kind: CASE
slug: a-five-second-string-that-broke-every-prod-deploy
title: 'connection-timeout: 5s가 모든 prod 배포를 시작 실패시켰고 local만 통과했다'
topic: transaction-deadline-and-pool
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-five-second-string-that-broke-every-prod-deploy
evidenceCapturedOn: 2026-09-02
body: case-a-five-second-string-that-broke-every-prod-deploy.body.md
assets:
- key: a-five-second-string-that-broke-every-prod-deploy-bind
file: ../../../final/evidence/rendered/a-five-second-string-that-broke-every-prod-deploy-bind.svg
- key: a-five-second-string-that-broke-every-prod-deploy
file: ../../../final/evidence/rendered/a-five-second-string-that-broke-every-prod-deploy.svg
evidence:
- ../../../final/evidence/raw/a-five-second-string-that-broke-every-prod-deploy-bind.txt
- ../../../final/evidence/raw/a-five-second-string-that-broke-every-prod-deploy.txt
source:
- 이 사건의 1차 기록은 `app-bootstrap/src/main/resources/application.yml` 의 해당 키 주석이다. 분석 문서 쪽에는 이 사건을 다루는 절이 없고, 후보 원장이 지정한 `analysis/05` 의 절 번호는 그 파일에 존재하지 않는다. 원장 자신도 이 항목의 근거를 설정 파일 주석의 사후 기록으로 적고 있다.
---
# connection-timeout: 5s가 모든 prod 배포를 시작 실패시켰고 local만 통과했다
풀 연결 타임아웃 기본값이 `5s` 로 출하됐다. 이 키는 `long` 밀리초에 바인딩되므로 문자열은 변환 단계에서 거절된다. 로컬 프로파일이 값을 덮어쓰고 있어서 로컬로 띄우는 실행은 통과했다.
## 관계
- **로컬이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다**
통과한 레인이 다른 값을 보고 있었다.
- **validator가 요청을 서비스하지 않는 datasource를 검증하고 있었다**
같은 app-bootstrap 모듈에서 시작 검증기가 다른 것을 보고 있던 사례다.
## 문제
그 속성의 바인딩 대상은 HikariConfig 의 setConnectionTimeout(long) 세터다. 밀리초 정수만 받는다.
같은 출하 파일의 server.tomcat.connection-timeout 은 지속 시간에 바인딩되고 20s 로 적혀 있다. 마지막 단어가 같고 형식이 다르다.
## 결론
프로파일 오버라이드가 형식 결함을 가렸다. 로컬에는 오버라이드가 있었고 나머지에는 없었다.
풀 제약 검증기는 같은 키를 DurationStyle 로 읽어서 5s 를 5000 밀리초로 받는다. 다만 이 검증기는 모든 싱글턴이 만들어진 뒤에 도는 자리에 있어서, 이 값으로는 그 전에 빈 생성이 실패한다.
지금은 출하 기본값이 정수로 고쳐져 있고, 밀리초 키 일곱 개의 출하 기본값이 정수인지 확인하는 정적 가드가 있다.
## 검증 환경
Spring Boot : 4.0.8
HikariCP : 7.0.2
OpenJDK : 21.0.12
확인 방식 : 출하 설정과 바인딩 대상 확인, 실제 바인더로 두 형식 주입
소스 수정 : x
## 재현 조건
1. 오버라이드가 없는 프로파일에 spring.datasource.hikari.connection-timeout=5s 를 두고 부팅하면 HikariDataSource 빈 생성에서 바인딩이 실패한다.
2. 같은 키를 저장소가 쓰는 Spring Boot·HikariCP 산출물의 바인더에 직접 넣으면 같은 예외 사슬이 나온다.
3. 같은 출하 파일에서 server.tomcat.connection-timeout 의 형식을 확인한다.
4. 시작 검증기가 같은 키를 어떤 파서로 읽는지, 어느 시점에 도는지 확인한다.
## 본문
<!-- body:start -->
`spring.datasource.hikari.connection-timeout``HikariConfig#setConnectionTimeout(long)` 에 바인딩된다. 밀리초 정수만 받는 자리다.
기본값이 `5s` 로 출하됐다. 지금은 `${APP_DATASOURCE_CONNECTION_TIMEOUT:5000}` 로 고쳐져 있다.
## 통과한 레인은 그 값을 보고 있지 않았다
설정 파일의 주석이 사건을 기록한다. prod 와 dev 배포가 전부 시작에 실패했고, 로컬은 `application-local.yml` 이 값을 덮어쓰고 있어서 통과했다는 것이다. 그래서 결함을 만날 수 있는 곳이 prod 레인뿐이었다.
테스트 레인도 이 기본값을 보지 않는다. `application-test.yml` 이 자기 몫으로 `connection-timeout: 30000` 을 들고 있다.
## 바인더에 직접 넣으면 어디서 끊기는지 보인다
:::evidence key="a-five-second-string-that-broke-every-prod-deploy-bind" alt="저장소가 쓰는 Spring Boot 와 HikariCP 산출물로 같은 속성 키에 두 형식을 넣어 실제로 바인딩한 실행 결과. 지속 시간 문자열은 예외 사슬 세 단계로 거절되고 정수는 값이 그대로 들어간다." caption="Spring Boot 4.0.8 · HikariCP 7.0.2 — 같은 키, 두 형식, 실제 바인딩" zoom="true"
:::
예외가 세 단계로 내려간다. 바인딩 실패, 문자열에서 `long` 으로의 변환 실패, 그리고 `"5s"` 에 대한 숫자 형식 예외다. 같은 자리에 `5000` 을 넣으면 값이 그대로 들어간다.
## 같은 파일에 같은 이름이 두 형식으로 있다
:::evidence key="a-five-second-string-that-broke-every-prod-deploy" alt="코드베이스에서 출하 설정 파일의 두 네임스페이스에 같은 이름의 키가 다른 형식으로 있는 것과, 앞쪽이 바인딩되는 세터의 long 시그니처, 같은 키를 읽는 시작 검증기의 인터페이스와 파싱 함수, 그리고 그 형식을 막는 정적 가드의 키 목록과 그 가드가 한 번 헛짚었던 자리를 뽑은 출력. 한 파일 안에서 같은 마지막 단어가 정수와 지속 시간 두 형식으로 쓰인다는 것이 그 출력에 그대로 보인다." caption="같은 파일의 두 네임스페이스 · long 세터 · 검증기의 다른 파서 · 정적 가드의 키 목록" zoom="true"
:::
`hikari` 아래의 키는 정수를 받고, `tomcat` 아래의 키는 `20s` 로 적혀 있다. 전체 경로는 다르고 마지막 단어만 같다.
## 검증기는 이 값을 다르게 읽고, 이 값에는 도달하지 못한다
풀 제약 검증기가 같은 키를 읽는다. 파서는 `DurationStyle.detectAndParse` 다. 그 규칙에서 `5s` 는 5000 밀리초이고 하한을 넘으므로 유효한 값이다.
다만 이 검증기는 `SmartInitializingSingleton` 이라 모든 싱글턴이 만들어진 뒤에 돈다. `5s` 로는 그 전에 `HikariDataSource` 빈 생성에서 바인딩이 죽는다. 검증기가 잘못된 값을 통과시킨 것이 아니라, 이 값에 대해 발언할 수 있는 자리에 있지 않았다.
## 두 문서가 같은 관용을 반대로 부른다
설정 파일의 주석은 그 관용을 결함으로 부른다. 잘못된 기본값이 지원되는 것처럼 보이게 만든 것이 그 관용이고, 여기서 조용히 고치는 대신 열린 항목으로 기록해 둔다고 적는다.
파서 쪽 javadoc 에는 Spring Boot 가 지속 시간 속성에 받아 주는 밀리초 정수와 단순 지속 시간과 ISO-8601 을 똑같이 받는다고 계약으로 적혀 있다. 그리고 테스트 하나가 `connection-timeout=5s``validation-timeout=PT3S` 로 컨텍스트가 실패하지 않음을 고정한다.
한쪽은 열린 결함이라 적고 다른 쪽은 고정된 계약이라 적는다. 두 기술은 서로를 참조하지 않는다.
## 같은 함정을 이름으로 막는다
정적 가드가 있다. 출하 설정 파일 넷을 Spring 의 yaml 로더로 직접 읽어 평탄화한 뒤, 밀리초 정수에 바인딩되는 키 일곱 개의 출하 기본값이 정수인지 확인한다.
부팅이 아니라 스캔이라 타입 목록을 손으로 들고 있다. 밀리초 키가 새로 생기면 그 목록에 줄을 더하는 것이 유일한 편입 경로다.
가드 자신의 주석이 초기 버전의 실패도 적어 둔다. 키의 마지막 단어를 정규식으로 맞춰서, 진짜 지속 시간인 `server.tomcat.connection-timeout: 20s` 를 결함으로 보고했다는 것이다. 실제 로더로 평탄화하도록 고친 이유가 그것이다.
## 확인하지 못한 것
실패 시점을 보려고 애플리케이션 전체를 부팅해 보지는 않았다. 바인딩 실패는 같은 산출물의 바인더에 값을 직접 넣어 확인했다.
<!-- body:end -->
@@ -0,0 +1,167 @@
---
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. 회귀 테스트의 단언과 가드와 스캔 범위를 읽고, 그 테스트를 실행해 결과를 읽는다.
## 본문
<!-- body:start -->
`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 건이라는 판정의 근거는 이름 기반 정적 검색이다. 빈 이름으로 찾아 쓰거나 리플렉션으로 꺼내는 경로까지 배제하지는 못했다.
결함이 있던 시점의 코드를 직접 열어 보지 않았다. 두 결함을 적어 둔 곳은 자바독과 위 설계 문서이고, 지금의 상태만 계수와 실행으로 확인했다.
<!-- body:end -->
@@ -0,0 +1,93 @@
---
kind: CASE
slug: requires-new-pins-the-outer-connection
title: REQUIRES_NEW가 바깥 커넥션을 핀한 채 새 커넥션을 딴다
topic: transaction-deadline-and-pool
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:requires-new-pins-the-outer-connection
evidenceCapturedOn: 2026-09-01
assets:
- key: requires-new-pins-the-outer-connection
file: ../../../final/evidence/rendered/requires-new-pins-the-outer-connection.svg
evidence:
- ../../../final/evidence/raw/requires-new-pins-the-outer-connection.txt
source:
- 원본 분석 절은 final/document.md#3-2 · analysis/05 §3.1 이다.
---
# REQUIRES_NEW가 바깥 커넥션을 핀한 채 새 커넥션을 딴다
새 트랜잭션을 여는 동안 바깥 트랜잭션의 커넥션은 반납되지 않는다. 그래서 동시 스레드 하나가 중첩 깊이만큼의 커넥션을 동시에 점유하고, 그 제약이 풀 크기 수식으로 설정 파일에 적혀 있다.
## 관계
- **REQUIRES_NEW의 커넥션 비용과 풀 사이징 제약**
이 사례가 다루는 메커니즘이다.
- **풀 계약 레인이 실행되지 않아 포화 동작이 확인되지 않았다**
이 제약이 실제로 지켜지는지에 대한 미해결 질문이다.
- **커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지**
같은 중첩 구조가 증거 스택을 필요하게 만든 지점이다.
## 문제
새 트랜잭션을 여는 연산은 바깥 트랜잭션을 일시 중단한다. 일시 중단은 트랜잭션 경계에 대한 것이고 커넥션에 대한 것이 아니다.
바깥 커넥션은 반납되지 않고 유지된다. 나중에 재개해야 하기 때문이다.
## 결론
한 스레드가 동시에 여러 커넥션을 점유한다.
중첩 깊이 1 이면 스레드당 두 개다. 바깥 하나와 안쪽 하나다.
그래서 풀 크기가 동시 스레드 수보다 커야 한다. 그 제약이 설정 파일 주석에 수식으로 적혀 있다.
최대 풀 크기가 동시 스레드 수 곱하기 중첩 깊이에 1 을 더한 값 이상, 그리고 거기에 1 을 더한 값 이상이어야 한다는 형태다.
이 수식이 지켜지지 않으면 데드락이 난다. 모든 스레드가 바깥 커넥션을 잡고 안쪽 커넥션을 기다리는 상태가 되고, 아무도 반납하지 않으므로 풀 획득 타임아웃까지 전부 대기한다.
같은 주석 블록이 풀 사이징의 다른 근거도 함께 적는다. 작은 풀 공리와 PostgreSQL 의 시작점 수식이다. 코어 수의 두 배에 유효 스핀들 수를 더한 값에서 시작해 부하 테스트로 조정하라는 것이고, 고정 크기 풀을 권장한다.
수식이 주석에 있다는 것이 이 사례의 성질을 보여 준다. 제약은 알려져 있고 문서화되어 있으며, 그것을 강제하는 것은 풀 제약 검증기의 획득 하한 검사다.
## 검증 환경
Spring Boot : 4.0.8
커넥션 풀 : HikariCP
확인 방식 : 트랜잭션 포트 구현과 설정 파일 주석 확인
소스 수정 : x
## 재현 조건
1. 트랜잭션 포트의 새 트랜잭션 연산을 읽는다. 모드별로 미리 만든 템플릿을 쓴다.
2. 설정 파일의 hikari 블록에서 풀 크기 주석을 읽는다. 수식이 적혀 있다.
3. 풀 제약 검증기가 획득 하한을 검사하는지 확인한다.
## 본문
<!-- body:start -->
`inNew`가 편해 보이지만 커넥션 비용이 곱셈이라는 것을 문서화한 제약이다.
## 이 제약이 문서화된 이유
:::evidence key="requires-new-pins-the-outer-connection" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 무엇이 금지되고 무엇으로 대신하나
풀 사이징 부등식이 명시돼 있고, 레코드마다 `inNew`를 도는 루프가 금지이며(풀 고갈 + 데드락), 배치로 묶거나 루프를 트랜잭션 밖으로 빼야 한다.
## 같은 이유의 다른 결정
`inRootWrite``REQUIRES_NEW`로 suspend해서 "root인 척"하지 않고 fail-fast한다.
## 확인하지 못한 것
수식을 위반하는 풀 크기로 중첩 트랜잭션을 돌려 데드락을 재현하지 않았다. 풀 계약 레인이 그 동작을 검증하지만 이 사이클에서 돌리지 않았다.
풀 계약 레인 미실행 — 포화 시 pending count 보고와 대기 동작을 관측하지 않았다
<!-- body:end -->