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,158 @@
---
kind: CONCEPT
slug: transport-failure-stage-and-category
title: 전송 실패의 단계와 범주 — AttemptStage와 FailureCategory
topic: http-failure-classification
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:transport-failure-stage-and-category
evidenceCapturedOn: 2026-09-01
assets:
- key: transport-failure-stage-and-category
file: ../../../final/evidence/rendered/transport-failure-stage-and-category.svg
- key: transport-failure-stage-and-category-diagram
file: ../../../final/assets/diagrams/transport-failure-stage-and-category.svg
evidence:
- ../../../final/evidence/raw/transport-failure-stage-and-category.txt
source:
- 원본 분석 절은 final/document.md#8-1 · analysis/11 §51 이다.
---
# 전송 실패의 단계와 범주 — AttemptStage와 FailureCategory
HTTP 전송 실패를 "어디까지 갔는가"와 "무엇이 실패했는가" 두 축으로 나눠 기록하고, 그 둘이 함께 재시도 안전성 판정의 입력이 된다.
## 관계
- **붉은 테스트를 제품 결함으로 읽은 오진**
이 두 축이 실제로 어떻게 채워지는지, 그리고 축을 채우는 정보가 사라지면 어떻게 되는지 보여 주는 사례다.
- **분류기는 엔진이 남긴 것만 볼 수 있다**
이 모델의 입력 한계를 규칙으로 옮긴 것이다.
- **재시도 가능성은 멱등성과 실패 범주를 함께 봐야 정해진다**
이 모델의 출력이 재시도 결정으로 이어지는 규칙이다.
- **재시도 안전성은 증거에 기반해 판정한다**
이 모델을 채택한 프로젝트 결정이다.
## 본문
<!-- body:start -->
HTTP 전송 실패를 "어디까지 갔는가"(`AttemptStage`: pool·DNS·CONNECT·TLS·전송·응답)와 "무엇이 실패했는가"(`FailureCategory`)의 두 축으로 모델링하는 구조의 설명이다.
## 두 축이 만드는 판정
:::evidence key="transport-failure-stage-and-category-diagram" alt="단계 축과 범주 축의 조합에서 notSent 와 maybeSent 두 갈래가 나온다" caption="두 축이 만드는 판정" zoom="false"
:::
두 축이 함께 `notSent`/`maybeSent` 판정을 만들고, 그것이 재시도 결정표의 입력이 된다 — `TLS_PERMANENT``permanent()`로 절대 차단되고 `CONNECT`는 멱등성과 무관하게 재시도된다.
## AttemptStage 참조 위치
:::evidence key="transport-failure-stage-and-category" alt="코드베이스에서 AttemptStage 를 검색한 출력 23줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AttemptStage 코드베이스 검색 — 23줄 · exit 0" zoom="true"
:::
## 분류가 곧 재시도 안전성이다
분류의 입력은 엔진이 남긴 예외 사슬이므로, 사슬 순회 순서와 **사슬에 무엇이 남았는가**가 함께 그 분류를 정한다.
:::note
없음 — 분류기와 결정표를 코드로 확인했다
:::
## 두 축
`AttemptStage`는 한 번의 물리적 시도가 어디까지 진행했는지를 담는 단조 진행 표식이다.
```java
public enum AttemptStage {
VALIDATION(0, true),
AUTHENTICATION(1, true),
POOL_ACQUIRE(2, true),
DNS(3, true),
CONNECT(4, true),
TLS_HANDSHAKE(5, true),
PROXY_CONNECT(6, true),
REQUEST_HEADERS(7, false),
REQUEST_BODY(8, false),
RESPONSE_HEADERS(9, false),
RESPONSE_BODY(10, false),
COMPLETE(11, false);
```
두 번째 인자가 `provesNotSent`다. `REQUEST_HEADERS` 앞의 일곱 단계는 전부 `true`이고, 요청 쓰기가 시작된 뒤로는 전부 `false`다. 첫 인자인 `order`는 명시적 진행 순위이며, javadoc이 그 이유를 적는다 — "the progress tracker forbids regression and the evidence classifier reads the rank, so neither depends on enum declaration ordinals."
`FailureCategory`는 무엇이 실패했는지를 담는 전송 중립 어휘 23개다. `permanent()`가 그중 여덟을 영구로 표시한다.
```java
public boolean permanent() {
return this == CONFIGURATION
|| this == TARGET_REJECTED
|| this == TLS_PERMANENT
|| this == SERIALIZATION
|| this == RESPONSE_TOO_LARGE
|| this == REDIRECT_REJECTED
|| this == DEADLINE_EXCEEDED
|| this == CANCELLED;
}
```
## 세 번째 값: 증거
두 축의 결과는 `TransportFailure` 레코드로 합쳐지고, 거기에 `ExecutionEvidence`가 함께 실린다.
```java
public record TransportFailure(
AttemptStage stage, ExecutionEvidence evidence, FailureCategory category, String safeReason) {
public static TransportFailure notSent(
AttemptStage stage, FailureCategory category, String safeReason) {
return new TransportFailure(stage, ExecutionEvidence.NOT_SENT, category, safeReason);
}
public static TransportFailure sentNoResponse(
AttemptStage stage, FailureCategory category, String safeReason) {
return new TransportFailure(stage, ExecutionEvidence.SENT_NO_RESPONSE, category, safeReason);
}
}
```
`ExecutionEvidence``NOT_SENT`, `SENT_NO_RESPONSE`, `RESPONSE_RECEIVED`, `PARTIAL_RESPONSE` 넷이고, javadoc이 승격을 금지한다 — "NOT_SENT is only used when a stage failure proves the request never reached the server. A generic engine I/O failure is never upgraded to NOT_SENT."
`safeReason`은 고정 어휘 토큰이며 엔진 메시지가 아니다. 이유도 javadoc에 있다 — 엔진 메시지에는 전체 URL이나 해석된 주소가 들어갈 수 있다.
## 왜 분류가 곧 재시도 안전성인가
`DefaultRetryEligibilityEngine`이 이 세 값을 그대로 읽는다.
```java
if (context.failureCategory().permanent()) {
return RetryDenied.permanentFailure(context.failureCategory().name());
}
...
case CONNECT -> RetryAllowed.of("CONNECT");
```
같은 실패가 `TLS_PERMANENT`로 분류되면 절대 재시도되지 않고, `CONNECT`로 분류되면 멱등성과 무관하게 재시도된다. 두 경로 사이에 정책이 개입할 여지가 없다. 분류가 곧 결정이다.
## 이 모델의 입력 한계
분류의 입력은 엔진이 호출자에게 남긴 예외 사슬이다. `ApacheFailureClassifier.classify`는 사슬 전체를 훑는다.
```java
for (Throwable cause : chain(failure)) {
TransportFailure recognized = recognize(cause, lastObservedStage);
if (recognized != null) {
return recognized;
}
}
return conservative(lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
```
주석이 그렇게 하는 이유를 적는다 — Spring이 엔진 예외를 감싸고, 감싸인 `ConnectException`도 요청이 전송되지 않았음을 증명하므로 바깥 타입만 보면 증명 가능한 `NOT_SENT`를 모호한 `SENT_NO_RESPONSE`로 낮추게 된다.
그러나 사슬 순회는 사슬에 있는 것만 볼 수 있다. 엔진이 원인을 버리면 이 모델은 그 원인을 분류할 수 없고, 그때는 순회 순서를 어떻게 짜도 결과가 달라지지 않는다. 그 경계가 실제로 어떻게 드러났는지는 관련 Case에 있다.
<!-- body:end -->