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,136 @@
---
kind: CASE
slug: an-order-contract-with-no-implementation
title: 순서 계약을 구현하는 것이 없고, 그것을 검증한다는 테스트는 enum 선언 순서만 본다
topic: declared-contract-without-enforcement
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:an-order-contract-with-no-implementation
evidenceCapturedOn: 2026-09-04
body: case-an-order-contract-with-no-implementation.body.md
assets:
- key: an-order-contract-with-no-implementation
file: ../../../final/evidence/rendered/an-order-contract-with-no-implementation.svg
evidence:
- ../../../final/evidence/raw/an-order-contract-with-no-implementation.txt
source:
- 원본 분석은 evidence/raw/280-transport-spi-lifecycle-unimplemented.txt 에 남은 덤프이고 별도의 분석 절은 없다.
---
# 순서 계약을 구현하는 것이 없고, 그것을 검증한다는 테스트는 enum 선언 순서만 본다
`MessagingLifecycle``ShutdownPhase` 여덟 상수로 종료 순서를 적어 두었는데 이것을 구현한다고 선언한 클래스가 저장소에 없다. 순서를 검증한다는 시험 다섯은 `values()` 가 돌려주는 선언 순서를 보고, 사본에서 `CLOSE_CONNECTIONS` 를 앞으로 옮기면 그중 셋이 깨진다.
## 관계
- **8단계 종료 순서 계약과 실제 종료 경로**
이 사례가 속한 구조다. 계약은 여덟 단계를 선언하고 실제 종료는 `SmartLifecycle` 을 구현한 두 클래스가 맡는다.
- **선언 순서를 단언하는 테스트는 그 순서를 읽는 코드가 있을 때만 게이트다**
이 사례가 만든 규칙이다. 인덱스를 읽는 프로덕션 코드가 없으므로 재배열 말고는 아무것도 막지 못한다.
- **같은 안전 규칙은 하나의 공식과 하나의 강제 시점을 갖는다**
그 규칙이 든 예시는 공개 상수와 비공개 복사본과 생성자 인자 셋이다. `DEFAULT_DRAIN_DEADLINE` 이라는 이름으로 다시 세면 `MessagingLifecycle:40``OutboxRelayWorker:35``DefaultMessagingRuntimeRegistry:28` 이 나온다.
- **드레인 마감 30초가 세 곳에서 독립적으로 결정된다**
같은 상수를 먼저 센 기록이다. 여기서는 실제로 주입되는 값이 `MessagingSettings:281` 의 프로퍼티 기본값이라는 것까지 확인했다.
## 문제
종료 순서를 열거형으로 적어 두면 순서가 한 파일에 모이고, 그 단계들을 인터페이스로 두면 구현한다고 선언한 클래스가 여덟 단계를 다 채워야 컴파일된다.
이 리프가 그 두 가지를 함께 노린 형태라, 둘 중 어느 쪽이 실제로 성립하는지 확인했다.
## 결론
인터페이스는 ShutdownPhase 여덟 상수와 start() · shutdown(Duration) · isRunning() 을 선언한다. 자바독 :8~:11 은 단계의 배열이 각 어댑터가 알아서 정할 몫이 아니라고 못 박는다.
구현체가 없다. implements MessagingLifecycle 로 검색하면 0 건이고, 두 이름을 언급하는 파일은 설계 계획 문서와 인터페이스와 그 시험 셋뿐이다. 같은 검색식으로 센 implements MessagingTransport 가 6 건을 내므로 이 0 은 매치를 놓친 결과가 아니다. 그 여섯 중 넷이 어댑터이고 둘은 시험 클래스다.
컴파일 강제도 생기지 않는다. 자바에서 메서드 구현을 요구받는 쪽은 인터페이스가 아니라 그것을 구현한다고 적은 클래스이기 때문이다.
순서를 검증한다는 시험은 MessagingLifecycleTest 안의 다섯이다. 넷은 List.of(ShutdownPhase.values()) 에서 두 상수의 indexOf 를 비교하고, connectionsCloseLastOfAll:30 은 values() 배열의 마지막 원소가 CLOSE_CONNECTIONS 인지 본다.
두 파일만 /tmp 로 복사해 CLOSE_CONNECTIONS 를 첫 상수로 옮기고 컴파일했더니 여섯 중 셋이 깨졌다. 열거형을 읽는 프로덕션 코드가 0 건이므로 이 재배열의 영향은 그 시험 파일 안에서 끝난다.
이름이 DEFAULT_DRAIN_DEADLINE 인 30 초짜리 선언은 셋이다. 다만 배선된 경로가 쓰는 값은 그 셋이 아니라 MessagingSettings:281 의 프로퍼티 기본값이고, MessagingCoreAutoConfiguration 이 :192·:204·:243 에서 그것을 넘긴다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 인터페이스와 시험 파일 전문 게재, ShutdownPhase 상수 계수와 인터페이스가 요구하는 메서드 나열, 두 이름의 pathspec 없는 전수 검색, implements 검색을 대조군과 자기시험과 함께 셋으로 제시, DEFAULT_DRAIN_DEADLINE 선언과 사용처, 프로퍼티 기본값과 그것을 넘기는 자리, SmartLifecycle 구현 둘, 두 파일 사본을 /tmp 에서 컴파일해 상수 순서를 바꾸기 전후로 실행
소스 수정 : x
## 재현 조건
1. 인터페이스와 시험 파일을 각각 전문으로 싣는다.
2. 열거형 상수를 세고 인터페이스가 요구하는 메서드를 나열한다.
3. 경로 한정 없이 저장소 전체에 두 이름을 검색해 언급 파일을 뽑는다.
4. implements 검색을 세 가지로 건다 — 대상, 없는 이름, 대조 타입.
5. DEFAULT_DRAIN_DEADLINE 선언과 사용처를 모듈과 소스 세트를 남긴 채 나열한다.
6. 프로퍼티로 들어오는 드레인 마감과 그것을 받는 자리를 함께 찾는다.
7. 두 파일을 /tmp 로 복사해 그대로 한 번, 상수 순서를 바꿔 한 번 컴파일하고 시험을 돌린다. 끝나고 저장소 변경이 0 건인지 확인한다.
## 본문
<!-- body:start -->
`MessagingLifecycle` 은 종료 순서를 타입으로 적어 둔 인터페이스다. `ShutdownPhase` 열거형이 여덟 단계를 선언하고(`:20`\~`:37`), 인터페이스 자신은 `start()``shutdown(Duration)``isRunning()` 을 요구한다.
## ShutdownPhase 여덟 상수와 자바독이 적는 순서 이유
:::evidence key="an-order-contract-with-no-implementation" alt="저장소 루트에서 돌린 정적 검색과 /tmp 사본 실행의 출력 181줄. 먼저 MessagingLifecycle.java 67줄이 전문으로 실린다. 클래스 자바독은 ShutdownPhase 의 순서가 계약이지 구현 세부가 아니라고 적고, 열거형이 STOP_PUBLISH_ADMISSION 부터 CLOSE_CONNECTIONS 까지 여덟 상수를 각각의 설명과 함께 선언하며, 40번 줄에 30초짜리 DEFAULT_DRAIN_DEADLINE 이 있다. 상수 개수가 8 개로 세어지고, 인터페이스가 요구하는 메서드로 start 와 shutdown 과 isRunning 셋이 나온다. 이어서 MessagingLifecycleTest.java 59줄이 전문으로 실린다. 시험은 여섯이고, 넷은 List.of 로 만든 목록에서 두 상수의 indexOf 를 비교하며, connectionsCloseLastOfAll 은 values 배열의 마지막 원소가 CLOSE_CONNECTIONS 인지 보고, 여섯째는 상수가 30초인지 본다. 각 시험의 as 설명문도 함께 실린다. 다음으로 두 이름이 나오는 파일이 pathspec 없이 셋으로 나오는데 설계 계획 문서와 인터페이스와 그 시험이다. implements MessagingLifecycle 를 가진 파일은 0 개, 없는 이름으로 건 자기시험도 0 개, 대조로 센 implements MessagingTransport 는 6 개이고 그 여섯이 나열되는데 넷은 main 어댑터이고 둘은 시험 클래스다. DEFAULT_DRAIN_DEADLINE 이라는 이름의 선언은 셋인데 OutboxRelayWorker 35번 줄과 DefaultMessagingRuntimeRegistry 28번 줄과 MessagingLifecycle 40번 줄이고, 앞 둘은 각각 129번과 36번 줄에서 자기 상수를 읽는다. 그 아래에 프로퍼티로 들어오는 드레인 마감이 나오는데 MessagingSettings 281번 줄의 기본값 30초를 MessagingCoreAutoConfiguration 이 192번·204번·243번 줄에서 세 곳에 넘긴다. 오늘 종료를 담당하는 구현으로 MessagingShutdownLifecycle 과 MessagingOutboxRelayLifecycle 이 나오는데 둘 다 SmartLifecycle 을 구현한다. 마지막으로 레포 사본을 /tmp 에서 컴파일한 결과가 나온다. 그대로면 통과 6 실패 0 이고, CLOSE_CONNECTIONS 를 첫 상수로 옮기면 통과 3 실패 3 이 되며 깨지는 시험은 connectionsCloseLastOfAll 과 outboxLeasesAreReleasedBeforeClosing 과 producerConfirmsAreAwaitedBeforeTheConnectionGoesAway 다. 레포 변경은 0 건이다." caption="인터페이스 67줄과 시험 59줄 전문 · implements 0 과 자기시험 0 과 대조 6 및 그 main/test 구분 · 이름이 같은 상수 셋과 실제로 주입되는 프로퍼티 값 · SmartLifecycle 로 구현된 종료 둘 · 사본에서 상수를 재배열했을 때 깨지는 시험 셋 — 181줄 · exit 0" zoom="true"
:::
여덟은 선언 순서대로 `STOP_PUBLISH_ADMISSION`, `STOP_NEW_HANDLERS`, `PAUSE_CONSUMERS`, `DRAIN_HANDLERS`, `FLUSH_SETTLEMENTS`, `AWAIT_PRODUCER_CONFIRMS`, `RELEASE_OUTBOX_LEASES`, `CLOSE_CONNECTIONS` 다.
클래스 자바독 `:8`\~`:11` 은 이 순서가 계약이지 구현 세부가 아니라고 적는다. 정착이 전송되기 전에 연결을 닫으면 그 정착을 잃고, 드레인 뒤에 소비자를 멈추면 이미 종료 중인 런타임으로 새 배달이 들어온다는 것이다. 단계를 구현하는 것은 각 어댑터이고 순서는 어느 어댑터도 고르지 않는다고 적는다.
## MessagingLifecycle 을 구현한다고 선언한 클래스가 0 개다
두 이름이 나오는 파일을 pathspec 없이 저장소 전체에서 찾으면 셋이다 — 설계 계획 문서 하나, 인터페이스 자신, 그 시험.
`implements MessagingLifecycle` 을 가진 파일은 0 개다. 같은 검색식에 없는 이름을 넣어도 0 이 나오므로, 0 만으로는 검색이 매치를 놓친 경우와 구별되지 않는다. 그래서 같은 식으로 `implements MessagingTransport` 를 세면 6 개가 나온다. 검색식은 매치를 찾을 수 있는 상태다.
그 여섯 중 넷은 `KafkaMessagingTransport:41` · `NatsJetStreamTransport:54` · `PulsarMessagingTransport:47` · `RabbitMessagingTransport:39` 어댑터이고, 나머지 둘은 `DefaultMessagePublisherTest:426``MessagingRuntimeRegistryTest:230` 의 시험 더블이다.
`implements` 선언이 없으면 컴파일러가 여덟 단계 중 무엇도 요구하지 않는다. 전송 SPI 에서 강제가 걸리는 이유는 그것을 구현한다고 적은 파일이 여섯 있기 때문이고, 그 여섯 각각이 선언한 메서드를 다 채우지 않으면 빌드가 실패한다.
## MessagingLifecycleTest 의 여섯 시험이 비교하는 값
시험은 여섯이고 그중 다섯이 순서를 다룬다. 넷은 `List.of(ShutdownPhase.values())` 로 목록을 만들어 두 상수의 `indexOf` 를 비교한다. 나머지 하나인 `connectionsCloseLastOfAll:30``ShutdownPhase.values()` 를 배열로 받아 `order[order.length - 1]``CLOSE_CONNECTIONS` 인지 본다. 여섯째 `theDefaultDrainDeadlineMatchesTheDesign:56` 은 상수가 30 초인지 본다.
다섯이 한 번도 언급하지 않는 상수는 `STOP_NEW_HANDLERS` 하나다.
각 시험의 `.as()` 설명문은 시스템 동작을 적는다. `handlersDrainBeforeTheirSettlementsAreFlushed:25` 는 핸들러가 끝나기 전에 플러시하면 그 핸들러가 만들 정착을 잃는다고 적고, `producerConfirmsAreAwaitedBeforeTheConnectionGoesAway:43` 은 닫은 뒤에 도착한 확인은 관측할 수 없어 발행이 미결로 남는다고 적는다. 그 시험을 통과시키는 조건은 두 상수의 선언 위치뿐이다.
## 상수를 재배열하면 시험 셋이 깨지고 그 밖에는 아무것도 깨지지 않는다
인터페이스와 시험 두 파일을 `/tmp` 로 복사해 저장소를 건드리지 않고 컴파일했다. 사본 그대로는 여섯이 모두 통과한다.
`CLOSE_CONNECTIONS` 를 첫 상수로 옮기고 다시 컴파일하니 셋이 깨졌다 — `connectionsCloseLastOfAll`, `outboxLeasesAreReleasedBeforeClosing`, `producerConfirmsAreAwaitedBeforeTheConnectionGoesAway`. 나머지 셋은 그대로 통과한다.
`ShutdownPhase` 를 자기 파일 밖에서 읽는 프로덕션 코드가 없으므로, 이 재배열이 바꾸는 것은 이 시험 파일의 통과 여부뿐이다.
## DEFAULT_DRAIN_DEADLINE 이라는 이름이 세 곳에 따로 선언된다
`MessagingLifecycle:40` 의 공개 상수, `OutboxRelayWorker:35``public static final`, `DefaultMessagingRuntimeRegistry:28``private static final` 이 각각 `Duration.ofSeconds(30)` 을 적는다. 뒤 둘은 앞의 것을 참조하지 않고 `:129``:36` 에서 자기 상수를 읽는다.
다만 실제로 주입되는 값은 넷째 자리에 있다. `MessagingSettings:281``drainDeadline` 기본값이 30 초이고, `MessagingCoreAutoConfiguration``:192``:204``:243` 에서 그 값을 세 군데로 넘긴다. `DefaultMessagingRuntimeRegistry:28` 의 복사본은 무인자 생성자(`:36`)로만 닿는다.
인터페이스의 상수를 읽는 자리는 `MessagingLifecycleTest:57` 하나다.
## 오늘 종료를 담당하는 것은 다른 인터페이스다
`MessagingShutdownLifecycle:30``MessagingOutboxRelayLifecycle:23` 이 Spring 의 `SmartLifecycle` 을 구현한다. 여덟 단계가 아니라 그쪽이 실제 종료 순서를 정한다.
이 기록은 그 둘이 여덟 중 무엇을 밟는지까지 세지 않았다.
## 확인하지 못한 것
`SmartLifecycle` 을 구현한 `MessagingShutdownLifecycle``MessagingOutboxRelayLifecycle` 이 여덟 단계 중 몇을 실제로 밟는지 세지 않았다. 계약을 구현한다고 선언한 타입이 없다는 데까지 확인했다.
재배열은 두 파일만 복사한 사본에서 돌렸다. 전체 빌드로 확인하지는 않았다.
`STOP_NEW_HANDLERS` 만 시험에 나오지 않는 이유는 찾지 않았다.
<!-- body:end -->