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 -->
@@ -0,0 +1,111 @@
---
kind: CONCEPT
slug: deadline-propagation
title: 호출 예산에서 DB 로컬 타임아웃까지의 데드라인 전파
topic: transaction-deadline-and-pool
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:deadline-propagation
evidenceCapturedOn: 2026-09-01
assets:
- key: deadline-propagation
file: ../../../final/evidence/rendered/deadline-propagation.svg
- key: deadline-propagation-diagram
file: ../../../final/assets/diagrams/deadline-propagation.svg
evidence:
- ../../../final/evidence/raw/deadline-propagation.txt
source:
- 원본 분석 절은 final/document.md#3-2 · analysis/05 §3.3 이다.
---
# 호출 예산에서 DB 로컬 타임아웃까지의 데드라인 전파
호출자가 가진 시간 예산이 트랜잭션 타임아웃으로, 다시 데이터베이스의 로컬 타임아웃으로 좁혀진다. 각 단계가 앞 단계보다 작아야 상위 호출자가 포기한 뒤에도 하위가 계속 도는 상황이 생기지 않는다.
## 관계
- **데드라인은 호출 예산에서 시작해 세 단계로 좁힌다**
이 개념을 규칙으로 옮긴 것이다.
- **쓰기 트랜잭션에는 유한 타임아웃이 필수다**
이 전파의 마지막 단계가 없을 때의 문제를 다룬 규칙이다.
- **세션 스코프 설정은 풀로 돌아간 커넥션에 남는다**
로컬 타임아웃을 설정할 때의 함정이다.
## 본문
<!-- body:start -->
호출자의 남은 예산이 세 단계로 좁혀져 DB 세션 설정에 도달하는 구조의 설명이다.
## 마감이 좁혀지는 세 단계
:::evidence key="deadline-propagation-diagram" alt="호출 예산과 트랜잭션 마감과 DB 로컬 타임아웃이 위에서 아래로 쌓여 있고 오른쪽에 좁아지는 방향 화살표가 있다" caption="마감이 좁혀지는 세 단계" zoom="false"
:::
## 획득 전에 요구하는 것
`connectionTimeout + beginBudget + minimumActionWindow + completionMargin`을 요구하고, Spring의 초 단위 타임아웃이 1초 미만이면 시작하지 않는다. begin 이후에는 statement/lock/idle 셋을 각각 유도하고 하나라도 1ms 미만이면 거부한다.
## 예산이 좁혀지는 경로
:::evidence key="deadline-propagation" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## SET 이 아니라 set_config 인 이유
둘이다 — `SET`은 파라미터 바인딩 전에 파싱되어 syntax error가 나고, 함수 호출은 값이 statement text에서 빠진다. 세 번째 인자 `true`가 transaction-local을 뜻한다.
## H2가 두 가지에서 다르다
세션 스코프이고 idle 가드가 없다. 그것이 H2의 성질이지 선택이 아니라는 점도 함께 적혀 있다.
:::note
실제 세션에서 SHOW statement_timeout으로 적용을 확인하지 않았다
:::
## 왜 전파해야 하는가
상위 호출자가 30 초 예산을 갖고 있는데 데이터베이스 쿼리에 타임아웃이 없으면, 호출자가 포기한 뒤에도 쿼리는 계속 돈다. 그 커넥션은 반납되지 않고 풀에서 빠져 있다.
부하가 걸리면 그 상태가 누적된다. 아무도 기다리지 않는 작업이 풀을 점유한다.
## 계산이 자기 타입을 갖는다
데드라인 계산기가 별도 타입이다. 획득 봉투를 포함하는 형태와 포함하지 않는 형태를 나눠 갖는다.
획득 봉투는 커넥션을 얻는 데 드는 시간이다. 그것을 예산에서 빼지 않으면, 커넥션을 기다리다가 남은 시간이 없는 채로 쿼리를 시작하게 된다.
## 데이터베이스마다 다른 설정기
로컬 타임아웃을 실제로 거는 방법은 데이터베이스마다 다르다. PostgreSQL 용 설정기와 H2 용 설정기가 따로 있다.
이 분리가 필요한 이유는 두 가지다. 설정 문법이 다르고, 세션 스코프 설정이 커넥션에 남는 방식도 다르다.
## 세 단계
```text
호출 예산 상위 호출자가 기다릴 수 있는 시간
↓ 획득 봉투를 뺀다
트랜잭션 타임아웃 스프링 트랜잭션 템플릿에 설정
↓ 여유를 남긴다
DB 로컬 타임아웃 데이터베이스가 스스로 끊는 시간
```
각 단계가 앞 단계보다 작다. 마지막이 가장 작아야 데이터베이스가 먼저 끊고, 그래야 애플리케이션이 그 실패를 분류할 기회를 갖는다.
:::note
순서가 반대가 되면 애플리케이션이 먼저 타임아웃되고 데이터베이스는 계속 돈다. 그 쿼리는 아무도 결과를 받지 않은 채 자원을 쓴다.
:::
## 템플릿을 미리 만드는 것과의 관계
트랜잭션 템플릿은 모드마다 미리 만들어져 있고 전부 같은 격리 수준에 고정되어 있다. 템플릿을 호출마다 고쳐 쓰면 경합이 생기기 때문이다.
그래서 데드라인은 템플릿의 필드가 아니라 실행 시점에 계산되어 전달된다.
<!-- body:end -->
@@ -0,0 +1,106 @@
---
kind: CONCEPT
slug: requires-new-connection-cost
title: REQUIRES_NEW의 커넥션 비용과 풀 사이징 제약
topic: transaction-deadline-and-pool
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:requires-new-connection-cost
evidenceCapturedOn: 2026-09-01
assets:
- key: requires-new-connection-cost
file: ../../../final/evidence/rendered/requires-new-connection-cost.svg
evidence:
- ../../../final/evidence/raw/requires-new-connection-cost.txt
source:
- 원본 분석 절은 final/document.md#3-2 · analysis/05 §3.1, §13.2 이다.
---
# REQUIRES_NEW의 커넥션 비용과 풀 사이징 제약
새 트랜잭션은 새 커넥션을 요구하고 바깥 커넥션은 반납되지 않는다. 그래서 풀 크기 하한이 동시 스레드 수와 중첩 깊이의 곱에 묶인다.
## 관계
- **REQUIRES_NEW가 바깥 커넥션을 핀한 채 새 커넥션을 딴다**
이 제약이 실제로 나타나는 사례다.
- **호출 예산에서 DB 로컬 타임아웃까지의 데드라인 전파**
풀 획득이 예산의 일부라는 점에서 연결된다.
- **풀 계약 레인이 실행되지 않아 포화 동작이 확인되지 않았다**
이 제약의 검증에 대한 미해결 질문이다.
## 본문
<!-- body:start -->
`REQUIRES_NEW`는 바깥 트랜잭션의 커넥션을 **핀한 채로** 새 물리 JDBC 커넥션을 딴다. 그래서 풀 사이징 제약이 곱셈이 된다 — `maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1`.
## 커넥션 비용이 곱셈이 되는 이유
:::evidence key="requires-new-connection-cost" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 레코드마다 inNew 를 도는 루프가 금지인 이유
풀 고갈과 데드락이다.
## 같은 곱셈 함정이 database-per-tenant에서 반복된다
각 tenant 풀은 개별적으로 합리적이고 그 합이 아니다 — 50 tenant × 10 = 서버 `max_connections` 100에 500 커넥션. 실패는 idle이던 것 포함 모든 tenant에 동시에 도착한다.
:::note
풀 계약 레인 미실행
:::
## 일시 중단은 커넥션을 놓지 않는다
새 트랜잭션 모드는 바깥 트랜잭션을 일시 중단한다. 그 일시 중단은 트랜잭션 경계에 대한 것이다.
바깥 커넥션은 유지된다. 나중에 재개해야 하기 때문이다.
## 그래서 하한이 생긴다
설정 파일이 그 제약을 수식으로 적는다.
```text
maxPoolSize >= concurrent_threads * (1 + max_inNew_depth) + 1
```
중첩 깊이 1 이면 스레드당 두 커넥션이다. 동시 스레드가 100 이면 최소 201 이 필요하다.
## 지켜지지 않으면 데드락이다
모든 스레드가 바깥 커넥션을 잡고 안쪽 커넥션을 기다린다. 아무도 반납하지 않으므로 전부 풀 획득 타임아웃까지 대기한다.
이 상태는 부하가 임계를 넘는 순간 한꺼번에 나타난다. 그 전까지는 아무 증상이 없다.
## 풀 사이징의 다른 근거와 충돌한다
같은 주석 블록이 반대 방향의 근거도 적는다.
```text
D1 (feature-database-connection-pool-contract): small-pool axiom + PostgreSQL formula
starting point (maximumPoolSize = cores * 2 + effective_spindle_count, adjust via load
test). Fixed-size pool recommended (minimumIdle = maximumPoolSize).
```
작은 풀이 낫다는 공리와 코어 수 기반 시작점이다. 그 값은 대개 동시 스레드 수보다 훨씬 작다.
:::warning
두 근거가 같은 손잡이를 반대 방향으로 민다. 작은 풀 공리는 값을 줄이라 하고 중첩 하한은 늘리라 한다. 해소하는 방법은 풀을 키우는 것이 아니라 중첩 깊이를 줄이는 것이다.
:::
## 고정 크기 풀
최소 유휴를 최대 크기와 같게 두는 것을 권장한다. 풀이 줄었다가 늘어나는 동안 중첩 하한이 일시적으로 깨지는 것을 막는다.
## 멀티테넌시에서의 확장
테넌트별 풀 예산 타입이 이 제약을 테넌트 단위로 다시 적용한다. 테넌트 하나가 풀 전체를 소진하는 것을 막으면서도 각 테넌트의 중첩 하한을 만족해야 한다.
<!-- body:end -->
@@ -0,0 +1,57 @@
---
kind: PROJECT_DECISION
slug: in-root-write-fails-fast
title: inRootWrite는 suspend하지 않고 fail-fast한다
topic: transaction-deadline-and-pool
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: decision:in-root-write-fails-fast
decisionStatus: ADOPTED
decidedOn: 2026-08-30
source:
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
- analysis/05-adapter-outbound-persistence-jpa.md
---
# inRootWrite는 suspend하지 않고 fail-fast한다
## 결정문
루트 쓰기 연산은 이미 트랜잭션이 열려 있으면 바깥을 일시 중단하지 않고 예외를 던진다.
## 판단 이유
이 연산의 이름이 약속하는 것은 루트 트랜잭션이다. 호출자가 그렇게 부르는 이유는 자기 작업이 다른 트랜잭션에 참여하지 않기를 바라기 때문이다.
이미 트랜잭션이 열려 있는데 일시 중단하고 새로 시작하면 두 가지가 일어난다. 바깥 커넥션이 핀되고, 호출자는 자기가 루트라고 믿는데 실제로는 중첩 안에 있다.
첫 번째는 풀 비용이다. 두 번째는 더 나쁘다. 커밋 순서와 롤백 범위에 대한 호출자의 가정이 틀린 채로 진행된다.
그래서 일시 중단하지 않고 던진다. 중첩된 루트 트랜잭션이 거절되었다는 전용 예외 타입이다.
이 선택은 호출자에게 부담을 넘긴다. 루트를 요구하는 코드가 어디서 불리는지 알아야 하기 때문이다. 그것이 의도다. 그 사실을 아는 것이 이 연산을 쓰는 조건이다.
## 영향
감수하는 것
이 연산을 호출하는 경로가 트랜잭션 안에 들어가면 런타임에 실패한다. 정적으로는 막히지 않는다.
기존 코드에서 이 연산을 쓰려면 호출 경로를 먼저 정리해야 한다.
얻는 것
루트라고 부른 것이 실제로 루트다.
중첩 깊이가 예상 밖으로 늘어나 풀 하한을 깨는 경로 하나가 사라진다.
## 근거
- **REQUIRES_NEW의 커넥션 비용과 풀 사이징 제약**
일시 중단이 커넥션을 놓지 않는다는 사실이 이 결정의 근거 중 하나다.
- **트랜잭션 템플릿은 모드별로 미리 만들어 둔다**
같은 포트의 다른 모드 결정이다.
- **트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문**
참여와 루트를 구별하는 결과 타입이다.
@@ -0,0 +1,53 @@
---
kind: PROJECT_DECISION
slug: templates-are-built-once-per-mode
title: 트랜잭션 템플릿은 모드별로 미리 만들어 둔다
topic: transaction-deadline-and-pool
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: decision:templates-are-built-once-per-mode
decisionStatus: ADOPTED
decidedOn: 2026-08-30
source:
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
- analysis/05-adapter-outbound-persistence-jpa.md
---
# 트랜잭션 템플릿은 모드별로 미리 만들어 둔다
## 결정문
트랜잭션 템플릿을 모드마다 하나씩 미리 만들어 두고, 호출마다 템플릿의 속성을 바꾸지 않는다.
## 판단 이유
템플릿은 가변 객체다. 하나를 공유하면서 호출마다 전파 방식이나 타임아웃을 바꾸면 두 스레드가 서로의 설정을 본다.
그 경합은 재현이 어렵다. 대부분의 호출은 같은 값을 쓰므로 증상이 드물게 나타나고, 나타났을 때는 트랜잭션이 잘못된 전파 방식으로 실행된 결과만 남는다.
그래서 모드마다 별도 템플릿을 두고 전부 같은 격리 수준에 고정한다. 쓰기와 읽기와 새 트랜잭션 셋이다.
데드라인처럼 호출마다 달라지는 값은 템플릿의 필드가 아니라 실행 시점에 계산해 전달한다.
## 영향
감수하는 것
모드가 늘면 템플릿도 늘어난다. 조합이 많아지면 이 방식이 부담이 된다.
호출마다 달라져야 하는 값은 별도 경로로 전달해야 한다. 템플릿에 넣을 수 없다.
얻는 것
템플릿 경합이 구조적으로 사라진다.
각 모드가 무엇으로 설정되어 있는지 한 자리에서 보인다.
## 근거
- **호출 예산에서 DB 로컬 타임아웃까지의 데드라인 전파**
호출마다 달라지는 값이 어떻게 전달되는지 설명한다.
- **Atomic 타입의 존재는 원자성의 증거가 아니다**
가변 공유 상태를 다루는 같은 계열의 규칙이다.
@@ -0,0 +1,72 @@
---
kind: QUESTION
slug: pool-contract-lane-not-executed
title: 풀 계약 레인이 실행되지 않아 포화 동작이 확인되지 않았다
topic: transaction-deadline-and-pool
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:pool-contract-lane-not-executed
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 풀 계약 레인이 실행되지 않아 포화 동작이 확인되지 않았다
중첩 트랜잭션의 커넥션 하한과 포화 시 보고와 대기 동작을 검증하는 레인이 있다. 이 리비전에서 돌리지 않았다.
## 사실
레인은 세 가지 동작 계약을 검증한다. 중첩 깊이 1 이 동시 스레드당 커넥션 두 개를 요구한다는 것, 포화된 풀이 자기 대기 수를 보고한다는 것, 호출자가 커넥션 없이 진행하는 대신 기다린다는 것이다.
이 셋은 어떤 기계에서도 참이므로 플래그가 필요 없다. 그래서 성능 레인이 아니라 동작 계약 레인이다.
레인은 발견 실패 설정과 업투데이트 비주장 설정을 갖는다.
이 컨테이너에 Docker 가 있고 기본 test 레인은 통과했다.
## 가정
풀 사이징 수식이 설정 파일 주석에 적혀 있고 검증기가 획득 하한을 강제하므로 실제 동작도 그러할 것이라고 전제하고 있다. 주석과 검증기는 선언이고 레인이 동작이다.
## 미지수
중첩 트랜잭션이 실제로 스레드당 두 커넥션을 요구하는지.
포화 시 대기 수 보고가 정확한지.
호출자가 커넥션 없이 진행하는 경로가 정말 없는지.
## 제약
컨테이너가 필요하다.
이 레인은 전용 소스셋을 쓴다.
애플리케이션 소스를 수정하지 않는다.
## 선택지
레인만 개별로 돌린다
빠르고 실패를 격리하기 좋다.
릴리스 게이트를 돌려 다른 레인과 함께 확인한다
게이트 조립까지 검증되지만 시간이 더 든다.
## 다음 검증
풀 계약 레인을 실행하고 종료 코드와 실패 목록을 기록한다.
세 계약이 통과하면 이 질문을 닫는다.
실패가 나오면 어느 계약이 깨졌는지에 따라 풀 사이징 수식의 문제인지 구현의 문제인지 구분해 기록한다.
## 관계
- **컨테이너가 필요한 특수 레인의 실제 결과를 실행으로 확인하지 않았다**
이 질문의 상위 항목이다.
- **REQUIRES_NEW가 바깥 커넥션을 핀한 채 새 커넥션을 딴다**
이 레인이 검증하는 제약이다.
- **certified라 불리던 레인이 threshold를 하나도 비교하지 않고 있었다**
이 레인이 지금 이름을 갖게 된 경위다.
@@ -0,0 +1,60 @@
---
kind: REFERENCE
slug: deadline-narrows-in-three-stages
title: 데드라인은 호출 예산에서 시작해 세 단계로 좁힌다
topic: transaction-deadline-and-pool
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:deadline-narrows-in-three-stages
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 데드라인은 호출 예산에서 시작해 세 단계로 좁힌다
## 목적
상위 호출자가 포기한 뒤에도 하위 작업이 계속 돌아 자원을 점유하는 것을 막는다.
## 규칙
1. 출발점은 호출 예산이다
상위 호출자가 기다릴 수 있는 시간에서 시작한다. 하위가 스스로 정한 값에서 시작하지 않는다.
2. 획득 시간을 뺀다
커넥션이나 슬롯을 얻는 데 든 시간은 이미 예산에서 소비된 것이다. 그것을 빼지 않으면 남은 시간이 없는 채로 작업을 시작한다.
3. 각 단계는 앞 단계보다 작다
호출 예산보다 트랜잭션 타임아웃이 작고, 그보다 데이터베이스 로컬 타임아웃이 작다.
4. 가장 안쪽이 먼저 끊는다
데이터베이스가 먼저 끊어야 애플리케이션이 그 실패를 분류할 기회를 갖는다. 반대면 쿼리가 결과를 받을 사람 없이 계속 돈다.
5. 계산을 자기 타입에 둔다
값이 여러 곳에서 계산되면 그중 하나가 획득 시간을 빼는 것을 잊는다.
## 적용 조건
트랜잭션과 원격 호출처럼 시간이 걸리는 모든 하위 작업
풀에서 자원을 얻어 쓰는 경로
## 예외
배치나 백그라운드 작업처럼 상위 호출자가 없는 경우는 예산의 출발점이 다르다. 그때는 그 작업 자체의 상한이 출발점이다.
## 예시
데드라인 계산기가 획득 봉투를 포함하는 형태와 포함하지 않는 형태를 나눠 갖는다.
로컬 타임아웃을 거는 방법은 데이터베이스마다 달라서 설정기가 따로 있다.
## 관계
- **호출 예산에서 DB 로컬 타임아웃까지의 데드라인 전파**
이 규칙이 나온 개념이다.
- **쓰기 트랜잭션에는 유한 타임아웃이 필수다**
마지막 단계가 비어 있을 때의 규칙이다.
- **재시도 가능성은 멱등성과 실패 범주를 함께 봐야 정해진다**
데드라인이 재시도 판정의 입력이 되는 지점이다.
@@ -0,0 +1,58 @@
---
kind: REFERENCE
slug: session-scoped-settings-outlive-the-transaction
title: 세션 스코프 설정은 풀로 돌아간 커넥션에 남는다
topic: transaction-deadline-and-pool
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:session-scoped-settings-outlive-the-transaction
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 세션 스코프 설정은 풀로 돌아간 커넥션에 남는다
## 목적
한 트랜잭션을 위해 건 세션 설정이 커넥션과 함께 풀로 돌아가, 무관한 다음 작업에 적용되는 것을 막는다.
## 규칙
1. 세션 스코프와 트랜잭션 스코프를 구별한다
세션 스코프로 설정하면 커넥션이 살아 있는 동안 유지된다. 트랜잭션이 끝나도 사라지지 않는다.
2. 가능하면 로컬 스코프를 쓴다
트랜잭션 로컬로 설정하면 커밋이나 롤백과 함께 사라진다.
3. 로컬이 불가능하면 반납 전에 되돌린다
설정을 건 쪽이 그것을 지우는 책임을 갖는다. 다음 사용자가 지울 것이라고 가정하지 않는다.
4. 데이터베이스마다 방법이 다르다
같은 개념의 설정이라도 문법과 스코프가 다르므로 설정기를 데이터베이스별로 둔다.
5. 테넌트나 사용자 컨텍스트를 세션에 남기지 않는다
커넥션이 풀에서 재사용되면 다른 테넌트의 요청이 앞 요청의 컨텍스트를 물려받는다.
## 적용 조건
커넥션 풀을 쓰는 모든 데이터베이스 접근
로컬 타임아웃 검색 경로 역할 테넌트 컨텍스트 같은 세션 설정
## 예외
풀을 쓰지 않고 요청마다 새 커넥션을 여는 구성은 이 규칙의 대상이 아니다. 그 경우 다른 비용이 든다.
## 예시
로컬 타임아웃 설정기가 PostgreSQL 과 H2 로 나뉘어 있다. 설정 문법이 다르고 세션에 남는 방식도 다르기 때문이다.
멀티테넌시에서 테넌트 컨텍스트를 세션에 남기면 풀 재사용이 곧 테넌트 경계 위반이 된다.
## 관계
- **호출 예산에서 DB 로컬 타임아웃까지의 데드라인 전파**
로컬 타임아웃을 거는 지점이다.
- **connection-timeout이 5s 문자열로 출하되어 prod와 dev 배포가 전부 시작에 실패했다**
같은 설정 계층의 다른 함정이다.
@@ -0,0 +1,55 @@
---
kind: REFERENCE
slug: write-transactions-need-a-finite-timeout
title: 쓰기 트랜잭션에는 유한 타임아웃이 필수다
topic: transaction-deadline-and-pool
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:write-transactions-need-a-finite-timeout
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 쓰기 트랜잭션에는 유한 타임아웃이 필수다
## 목적
타임아웃 없는 쓰기 트랜잭션이 잠금을 무한히 들고 있어, 다른 쓰기 전부를 막는 것을 방지한다.
## 규칙
1. 쓰기 경로에 무한 대기를 두지 않는다
타임아웃이 없으면 잠금 대기가 끝나지 않는다.
2. 기본값을 무한으로 두지 않는다
설정하지 않았을 때의 동작이 무한 대기면, 설정을 잊은 배포가 가장 위험한 배포가 된다.
3. 읽기와 쓰기의 상한을 따로 둔다
읽기가 길어지는 것과 쓰기가 길어지는 것은 영향 범위가 다르다.
4. 타임아웃 값을 타입으로 강제한다
설정 값이 비어 있을 수 있는 형태면 그 경로가 언젠가 무한이 된다.
## 적용 조건
잠금을 잡는 모든 쓰기 트랜잭션
여러 인스턴스가 같은 행을 경합하는 구조
## 예외
관리자가 명시적으로 실행하는 일회성 마이그레이션이나 백필은 상한이 다를 수 있다. 그 경우 실행 절차에 그 사실이 있어야 한다.
## 예시
트랜잭션 템플릿이 모드마다 미리 만들어져 있고 전부 같은 격리 수준에 고정되어 있다. 데드라인은 실행 시점에 계산되어 전달된다.
풀 커넥션 타임아웃은 기본 30 초 대신 짧은 값으로 고정한다. 풀이 고갈된 스레드를 오래 붙잡지 않고 빠르게 거절하기 위해서다.
## 관계
- **데드라인은 호출 예산에서 시작해 세 단계로 좁힌다**
이 규칙이 속한 전파 구조다.
- **커밋 모호성 판정은 넓혀도 좁혀도 해롭다**
타임아웃이 만드는 실패를 어떻게 분류할지 다룬 규칙이다.