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,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 -->