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,125 @@
---
kind: CASE
slug: a-build-gate-that-is-not-in-the-build
title: '"build gate"라 불리는 catalog drift 검사가 어디에서도 실행되지 않는다'
topic: redis-command-admission
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-build-gate-that-is-not-in-the-build
evidenceCapturedOn: 2026-09-02
assets:
- key: a-build-gate-that-is-not-in-the-build
file: ../../../final/evidence/rendered/a-build-gate-that-is-not-in-the-build.svg
evidence:
- ../../../final/evidence/raw/a-build-gate-that-is-not-in-the-build.txt
source:
- 원본 분석 절은 analysis/10 §47 · final/document.md#4-4 이다. 참조 수는 §8.1 의 원문 측정과 같다.
- Gradle 태스크와 CI 단계가 없다는 것은 이 기록에 붙은 자산에 있다. 토폴로지 레인의 두 설정은 analysis/10 §0 과 evidence/raw/158-cache-redis-module-inventory.txt 에, 레인의 결합 상태는 `cache-redis/build.gradle` 과 `.github/ci-gate-matrix.yml` 에 있다.
module: adapter-outbound-cache-redis
priority: P2
---
# "build gate"라 불리는 catalog drift 검사가 어디에서도 실행되지 않는다
명령 메타데이터 드리프트를 검사하는 코드가 있고 정책 파일 머리 주석이 그것을 빌드 게이트라고 부른다. 그 검사를 실행하는 Gradle 태스크도 CI 단계도 없다.
## 관계
- **산문이 선언한 게이트는 빌드에 있는 게이트가 아니다**
이 사례가 그 규칙을 만든 형태다.
- **두 파일이 같은 검증기를 "빌드를 실패시키는 것"이라 적고, 어떤 빌드도 그것을 부르지 않는다**
같은 형태가 gRPC 가족에서 나타난 사례다.
- **서버 메타데이터가 명령의 정의이고 정책 파일은 허용 범위다**
이 검사가 지키려는 관계다.
## 문제
명령 카탈로그의 정의는 서버 메타데이터에서 온다. 정책 파일은 그 위에서 허용 범위를 정한다.
두 쪽이 어긋날 수 있다. 서버 버전이 올라가면서 명령의 키 스펙이나 플래그가 바뀌면 정책 파일이 옛 정의 위에 서 있게 된다.
그 드리프트를 검사하는 코드가 있다. 정책 파일 머리 주석이 그것을 빌드 게이트라고 부른다.
## 결론
빌드에 없다.
그 검사를 실행하는 Gradle 태스크가 등록되어 있지 않고, CI 워크플로에도 그것을 부르는 단계가 없다. 그 코드는 누군가 수동으로 부를 때만 돌고, 부르는 절차는 문서에 없다.
이름은 게이트인데 빌드에서 그것을 부르는 곳이 없다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 검사 코드 확인과 태스크 및 CI 단계 이름 검색
소스 수정 : x
## 재현 조건
1. 명령 메타데이터 드리프트 검사 클래스를 확인한다.
2. 정책 파일 머리 주석에서 그것을 부르는 이름을 확인한다.
3. 그 검사를 실행하는 Gradle 태스크를 찾는다.
4. CI 워크플로에서 그 검사를 부르는 단계를 찾는다.
1~2 단계의 원문 근거는 final/evidence/raw/163-cache-redis-guard-connection-codec-probes.txt §8.1 이다. 3~4 단계의 출력은 이 기록에 붙은 자산에 있다.
## 본문
<!-- body:start -->
cache-redis 어댑터의 명령 정책 파일이 머리 주석에서 이 저장소에서 가장 강한 governance 주장을 한다 — 명령이 무엇인지는 서버 메타데이터가 정하고, 이 SDK 가 그것으로 무엇을 할지는 이 파일이 정하며, **"드리프트 게이트가 둘을 비교해 서버가 이 파일이 판정하지 않은 명령을 추가하면 빌드를 실패시킨다"**. `RedisCommandMetadataDiff` 의 javadoc 도 자신을 "The build gate" 라고 부른다.
비교 로직은 완성돼 있다. 테스트 여섯 개가 다섯 버킷을 덮는다.
## 이 이름을 참조하는 파일이 둘뿐이다
:::evidence key="a-build-gate-that-is-not-in-the-build" alt="코드베이스에서 RedisCommandMetadataDiff 를 검색한 출력 9줄. 이 이름을 참조하는 파일이 선언 자신과 자기 단위 테스트 둘뿐이고, 이 비교를 실행하는 Gradle 태스크와 CI 단계가 없다는 것이 그 출력에 그대로 보인다." caption="RedisCommandMetadataDiff 참조 · Gradle 태스크 · CI 단계 — 9줄 · exit 0" zoom="true"
:::
선언 자신과 자기 단위 테스트. 이 비교를 실행하는 Gradle 태스크가 없고, CI 워크플로에서 부르는 단계도 없다. 실제 서버 메타데이터를 이 함수에 넣는 코드가 저장소에 없다.
빌드를 깨는 게이트는 존재하지 않는다. 존재하는 것은 게이트가 쓸 비교 함수와 그 함수의 단위 테스트다.
## 다섯 버킷이 막기로 되어 있던 것
1. 아무도 분류하지 않은 새 명령
2. 서버에서 사라진 명령
3. 키 추출이 엉뚱한 인자를 가리키게 만드는 key spec 이동
4. 계정을 조용히 넓히는 ACL 카테고리 변경
5. 타입 있는 API 가 아직 노출하는 deprecation
여섯 번째 테스트는 버킷이 아니라 드리프트가 없을 때를 고정하는 음성 케이스다.
## 첫 버킷은 다른 장치가 대신 막는다
새 명령이 조용히 통과하지는 않는다. `RedisCommandCatalog.require` 가 분류되지 않은 명령을 fail-closed 로 거부하고, `theCatalogFailsClosedForAnUnclassifiedCommand` 가 그것을 고정한다. 그래서 이 결함의 데이터 위험이 즉각적이지 않다.
원본 분석이 위험으로 지목한 것은 3·4·5 번 셋이다. key spec 이 이동하면 이 SDK 의 네임스페이스·슬롯 검사가 잘못된 인자를 키로 본다. ACL 카테고리가 넓어지면 계정 분리 가정이 조용히 약해진다. deprecation 은 타입 있는 API 가 사라질 명령을 계속 노출하게 둔다.
2 번(서버에서 사라진 명령)은 어느 쪽으로도 논의되지 않았다. 카탈로그가 그것을 잡는다는 근거도, 위험 목록에 든다는 근거도 원본에 없다.
## 게이트라는 이름이 정책 파일의 주의 수준을 낮춘다
정책 파일을 읽는 사람이 그 주석을 보고 드리프트가 자동으로 잡힌다고 이해할 수 있다 — 이것은 원본 분석에 적힌 판정이 아니라 이 기록의 추론이다. 다만 주석의 문장이 조건 없는 단정이라는 것은 사실이고, 그 문장을 읽고 나서 정책 파일을 손볼 때 확인해야 할 것이 하나 줄어든다.
## 이 리프의 토폴로지 레인과 비교하면
같은 리프에 `redisTopologyTest` 레인이 있다. 그 레인은 `failOnNoDiscoveredTests = true``outputs.upToDateWhen { false }` 를 갖는다 — 발견 0 이 성공이 되지 않고, 이전 실행 결과를 다시 내놓지도 않는다.
다만 그 레인도 릴리스 게이트 Gradle 태스크에 묶여 있지는 않다. 기본 `test``excludeTags 'redis-topology'` 로 그것을 제외하고, 실행하는 것은 CI 워크플로 하나다. gate matrix 에서 그 항목은 `mechanism: workflow-job` 이고 `release_blocking: conditional` 이다 — 같은 매트릭스의 `httpclient-stable-contract``gradle-custom-task``release_blocking: true` 인 것과 다르다.
드리프트 검사에는 그 둘 중 아무것도 없다. 레인도 아니고 워크플로 항목도 아니다.
## 없는 것은 연결 한 줄이다
토폴로지 레인은 이미 실제 서버에 붙어 있고 비교 함수도 완성돼 있다. 그 레인에서 `COMMAND DOCS``COMMAND INFO` 를 읽어 `RedisCommandMetadataDiff.compare(...)` 를 돌리고, 결과가 비어 있지 않으면 실패시키면 된다.
## 확인하지 못한 것
지금 드리프트가 나 있는지는 직접 돌려 보지 않았다. 그 검사를 부르는 실행 경로가 없다는 것까지만 확인했다.
태스크와 CI 단계의 부재는 이름 기반 검색으로 판정했다. 리플렉션이나 서비스 로더처럼 이름이 문자열로만 등장하는 호출 형태는 배제하지 못했다.
<!-- body:end -->
@@ -0,0 +1,136 @@
---
kind: CASE
slug: a-startup-probe-that-never-runs
title: startup probe가 production에서 한 번도 실행되지 않는다
topic: redis-command-admission
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-startup-probe-that-never-runs
evidenceCapturedOn: 2026-09-02
assets:
- key: a-startup-probe-that-never-runs
file: ../../../final/evidence/rendered/a-startup-probe-that-never-runs.svg
evidence:
- ../../../final/evidence/raw/a-startup-probe-that-never-runs.txt
source:
- 분석 문서는 cache-redis 어댑터 편 §6 이다. 그 절이 탐침이 확인하는 넷을 나열하고, 넷째의 javadoc 을 인용하고, 두 탐침의 프로덕션 참조가 javadoc 링크 하나뿐이라는 것과 자동설정이 탐침을 부르지 않는다는 것을 기록한다. 판정은 P2 이고, 수정은 자동설정에 탐침을 실행하는 빈 하나를 더하는 것이다.
- 같은 사고의 더 상세한 기록은 저장소 문서 쪽에 있다. 지원 매트릭스가 승격과 강등 시각을 초 단위로 적고, 운영 문서와 런북이 가드를 적용한 뒤 같은 승격에서 무엇이 달라졌는지 적는다.
---
# startup probe가 production에서 한 번도 실행되지 않는다
기동 시 서버 능력을 확인하는 탐침이 있다. 그것을 만들거나 부르는 프로덕션 코드가 없어 한 번도 실행되지 않는다.
## 관계
- **@Bean이 있다는 것은 조립 증거가 아니다**
이 사례가 그 규칙의 형태다.
- **시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다**
시작 검증기가 도는지를 자동설정 루트로 확인하는 절차다.
- **하위 시스템 전체가 미배선인데 그것을 켜는 플래그는 시작 검사를 수행한다**
플래그와 실행의 어긋남이 반대 방향으로 나타난 사례다.
## 문제
기동 탐침은 서버가 실제로 무엇인지 묻는다. 그것이 확인하는 것은 넷이다. 서버 버전, 클러스터와 데이터베이스 번호, 명시적으로 켠 능력의 실재, 그리고 복제 배포의 쓰기 내구성이다.
넷째의 javadoc 이 이 저장소의 실측 사고 기록을 숫자째로 담고 있다.
## 결론
그 기동 실패는 일어나지 않는다.
두 탐침 클래스를 언급하는 프로덕션 코드는 다른 설정 클래스의 javadoc 링크 하나뿐이고, 확인 메서드를 부르는 프로덕션 코드는 0 이다. 자동설정이 만드는 빈 일곱에 탐침은 없다.
로직은 완성되어 있고 테스트 열여덟 케이스가 네 규칙을 고정한다. 없는 것은 호출 지점 하나다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 정적 도달성 확인
소스 수정 : x
## 재현 조건
1. 기동 탐침의 클래스 javadoc 과 확인 메서드 본문을 읽고, 무엇을 몇 가지 확인하는지 센다.
2. 넷째 항목의 javadoc 에 적힌 사고 기록과 결론, 그리고 검사가 두 겹인 이유를 읽는다.
3. 두 탐침 클래스를 언급하는 프로덕션 코드를 세고 그것이 무엇인지 확인한다.
4. 확인 메서드를 부르는 프로덕션 코드를 센다.
5. 자동설정이 만드는 빈을 나열하고 그중 탐침이 있는지 센다.
6. 두 테스트 파일의 케이스 수를 센다.
## 본문
<!-- body:start -->
기동 탐침은 서버가 실제로 무엇인지 한 번, 기동 시에 묻는다.
이 탐침이 무엇을 막는지부터 본다. 그것이 실행되지 않는다는 판정이 무엇을 잃는 것인지가 거기서 정해진다.
## 확인하는 것은 넷이다
:::evidence key="a-startup-probe-that-never-runs" alt="코드베이스에서 기동 탐침이 자기를 소개하는 문단과 확인 메서드 본문이 부르는 두 검사, 넷째 항목의 javadoc 이 담은 실측과 결론과 검사가 두 겹인 이유, 두 탐침을 언급하는 프로덕션 코드 하나와 그것이 javadoc 링크라는 것과 확인 메서드를 부르는 코드 수, 자동설정이 만드는 빈 일곱과 그중 탐침의 수, 그리고 로직을 고정하는 테스트 케이스 수를 뽑은 출력 70줄. 탐침을 부르는 프로덕션 코드가 0 이고 자동설정의 빈 일곱에 탐침이 없다는 것이 그 출력에 보인다." caption="탐침의 소개 문단과 두 검사 · 넷째의 실측과 두 겹 검사 · 언급 1(javadoc 링크) · 호출 0 · 빈 일곱에 탐침 없음 · 테스트 18케이스 — 70줄" zoom="true"
:::
확인 메서드가 두 호출로 넷을 덮는다. 앞의 호출이 서버 버전과 배포 모드와 데이터베이스 번호와 명령 목록으로 능력을 판정하고, 뒤의 호출이 복제 배포의 쓰기 내구성을 요구한다.
클래스 javadoc 이 왜 서버에 묻는지 적는다. 설정은 배포가 무엇을 의도하는지 말하고 서버만이 무엇이 참인지 말한다는 것, 관리형 Redis 가 광고하는 버전이 모듈의 존재를 뜻하지 않는다는 것, 그리고 복제 배포의 쓰기 내구성은 어떤 클라이언트도 보상할 수 없는 서버 설정이라는 것이다.
같은 문단이 시점의 값도 적는다. 빠진 능력을 첫 요청에서 발견하면 장애이고, 여기서 발견하면 실패한 배포다.
## 넷째 항목의 javadoc 은 실측을 숫자째로 담는다
그 javadoc 은 이것이 SDK 가 보상할 수 없는 유일한 서버 설정이라고 적는다.
승격으로 밀려난 주 노드는 그 사실을 즉시 알지 못한다. 아는 순간까지 쓰기에 계속 성공을 답하고, 새 주 노드에서 재동기화할 때 그 쓰기들이 버려진다.
센티널 레인이 그것을 쟀다. 11초 동안 2,086건이 승인된 뒤 폐기됐고, 같은 실행에서 호출자에게 실패로 보인 명령은 통틀어 하나뿐이었다.
그 아래 문장이 왜 아무도 못 보는지 적는다. 서버가 답했으므로 드라이버도, 이 SDK 도, 호출자도 전부 성공으로 기록한다. 더할 지표도, 재시도할 실패도, 그것을 서술할 확신 값도 없다.
## 그래서 경고가 아니라 기동 실패다
복제 최소 개수와 상한이 걸린 최대 지연이 그 상황을 호출자가 대응할 수 있는 거절로 바꾼다. 같은 승격이 그때는 2,086건 대신 한 건을 잃었다.
그래서 그 설정 없는 복제 배포는 경고가 아니라 기동 실패라고 적는다. 보증을 무의미하게 만드는 설정은 조용히 성능을 낮추는 대신 컨텍스트를 멈춘다는 것이 이 SDK 의 원칙이라는 것이다.
면제는 가능하다. 배포가 정말로 쓰기 하나를 잃어도 괜찮을 수 있고 이 SDK 가 서버를 소유하지 않기 때문이다. 다만 면제에는 명시적 설정이 필요해서, 그 거래가 사고 중에 발견되는 대신 기록으로 남는다.
## 그 검사 자체도 한 번 고쳐졌다
같은 javadoc 이 검사가 두 겹인 이유를 적는다.
복제 개수만으로는 몇 개가 연결되어 있어야 하는지만 정해진다. 얼마나 뒤처져도 되는지는 최대 지연이 정하고, Redis 는 그 값 0 을 지연 요구 없음으로 다룬다.
그래서 복제 둘을 요구하면서 지연 상한을 0 으로 둔 배포는, 임의로 뒤처진 복제 둘이 붙어 있기만 하면 쓰기를 받는다. 개수 요구가 없애려던 바로 그 노출이 그대로 남는다.
개수만 검사하던 동안 그 설정이 통과했다. 그러면서 실패 메시지는 운영자에게 지연 상한을 설정하라고 말하고 있었다.
## 그 기동 실패는 일어나지 않는다
두 탐침 클래스를 언급하는 프로덕션 코드가 하나뿐이다. 그것도 다른 설정 클래스의 javadoc 안에 있는 링크다.
확인 메서드를 부르는 프로덕션 코드는 0 이다.
자동설정은 빈 일곱을 만든다. 설정과 그 검증, 자격증명, 런타임 클라이언트와 소유자, 그리고 헬스 지표 둘이다. 그중 탐침은 0 이다.
## 없는 것은 호출 지점 하나다
로직은 완성되어 있다. 두 테스트 파일의 열여덟 케이스가 네 규칙을 고정한다.
탐침이 연결을 들고 있지 않은 것은 설계된 것이다. 서버 사실을 인자로 받아서, 같은 로직을 단위 테스트와 실제 레인이 함께 쓰고 어느 계정이 물을지를 호출자가 정한다.
그 사실 중 둘을 읽는 명령이 관리 평면이라 애플리케이션 계정은 거부된다. 그래서 조립은 관리 계정이 설정된 배포에서만 완전하다.
## 지금 남은 것은 장애 쪽이다
빠진 능력을 첫 요청에서 발견하면 장애이고 여기서 발견하면 실패한 배포다. 조립이 없으므로 남은 것은 앞쪽이다.
복제 내구성은 그마저도 아니다. 서버가 성공을 답하므로 장애로도 나타나지 않는다.
## 확인하지 못한 것
애플리케이션을 부팅해 탐침이 실행되지 않는 것을 관측하지 않았다. 정적으로 도달성만 확인했고, 위 실측값은 이 저장소의 센티널 레인이 잰 것을 javadoc 에서 인용한 것이지 이 기록을 위해 다시 잰 것이 아니다.
<!-- body:end -->
@@ -0,0 +1,104 @@
---
kind: CASE
slug: five-adapters-bypass-the-single-admission-point
title: 의미 어댑터 다섯이 gateway를 직접 불러 admission 아홉 단계를 건너뛴다
topic: redis-command-admission
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:five-adapters-bypass-the-single-admission-point
evidenceCapturedOn: 2026-09-01
body: case-five-adapters-bypass-the-single-admission-point.body.md
assets:
- key: five-adapters-bypass-the-single-admission-point
file: ../../../final/evidence/rendered/five-adapters-bypass-the-single-admission-point.svg
evidence:
- ../../../final/evidence/raw/five-adapters-bypass-the-single-admission-point.txt
source:
- 원본 분석 절은 final/document.md#4-4 · analysis/10 §64 이다.
---
# 의미 어댑터 다섯이 gateway를 직접 불러 admission 아홉 단계를 건너뛴다
명령 정책 가드는 자기를 모든 명령이 지나는 단일 승인 지점이라고 적는다. 캐시와 멱등성과 리스와 레이트리밋과 실시간 어댑터 다섯은 그 가드도 실행기도 타입 API 도 참조하지 않고 게이트웨이를 직접 부른다.
## 관계
- **명령 카탈로그와 admission 아홉 단계**
우회되는 대상이다.
- **단일 admission point는 우회 경로를 세어야 성립한다**
이 사례에서 끌어낸 규칙이다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
같은 계열의 확인 규칙이다.
## 문제
가드의 클래스 javadoc 은 자기를 모든 명령이 지나는 단일 승인 지점이라고 선언한다.
그 선언이 참인지는 우회 경로를 세어야 알 수 있다.
## 결론
다섯 어댑터가 우회한다.
가드나 실행기나 명령 요청 타입을 참조하는 파일을 다섯 어댑터 패키지에서 찾으면 일치가 없다.
타입 지정 연산 API 를 참조하는 파일도 일치가 없다.
대신 게이트웨이를 직접 부른다. 호출 수는 이렇다.
캐시 리전 어댑터 6
멱등성 저장소 어댑터 6
분산 리스 어댑터 4
엣지 레이트리밋 어댑터 1
커넥션 레지스트리 어댑터 13
합계 30 이다.
임포트 목록도 같은 그림을 보여 준다. 다섯 어댑터가 SDK 에서 가져오는 것은 네임스페이스와 커넥션 소유자와 리스와 커넥션 종류와 게이트웨이 계열이다. 가드나 실행기는 없다.
즉 아홉 단계는 우회하지 않은 호출에만 적용되는 규칙이다. 능력 확인도 허가 출처 확인도 네임스페이스 검사도 예산도 이 다섯 경로에는 걸리지 않는다.
가드의 javadoc 이 틀린 것은 아니다. 그 문장은 가드를 지나는 명령에 대해 참이다. 틀린 것은 그 문장을 읽고 모든 명령이 지난다고 이해하는 것이다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 다섯 어댑터 패키지에 대한 참조 검색과 호출 계수
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/165-cache-redis-semantic-adapters-probes.txt 8.1 절에 있다.
1. 다섯 어댑터 패키지에서 가드와 실행기와 명령 요청 타입을 참조하는 파일을 찾는다. 일치 0 이다.
2. 같은 패키지에서 타입 지정 연산 API 를 참조하는 파일을 찾는다. 일치 0 이다.
3. 같은 패키지에서 게이트웨이 호출 수를 센다.
4. 다섯 어댑터가 SDK 에서 가져오는 임포트를 집계한다.
## 본문
<!-- body:start -->
두 javadoc이 "The single admission point every command passes through"와 "everything routed through it has already passed `CommandPolicyGuard`"를 주장한다.
## CommandPolicyGuard 참조 위치
:::evidence key="five-adapters-bypass-the-single-admission-point" alt="코드베이스에서 CommandPolicyGuard 를 검색한 출력 26줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="CommandPolicyGuard 코드베이스 검색 — 26줄 · exit 0" zoom="true"
:::
## 다섯 어댑터가 gateway를 직접 부른다
`RedisRuntimeOwner``RedisLease``lease.gateway()`를 30곳에서 부른다. `SyncRedisCommandExecutor`·`ReactiveRedisCommandExecutor`·`CommandPolicyGuard`·`CommandRequest`를 참조하는 파일이 0이고 타입 있는 API 참조도 0이다.
## 아홉 단계 중 남는 것 하나
네임스페이스만 `CapabilityKeyspace`가 같은 `RedisNamespace`에서 키를 조립해 구성으로 유지된다.
## 확인하지 못한 것
우회 경로로 실제로 거부되어야 할 명령이 통과하는 것을 재현하지 않았다. 이 기록은 참조 계수에 근거한다.
실제 Redis에 붙여 우회 경로의 명령이 정책 없이 나가는지 관측하지 않았다
<!-- body:end -->
@@ -0,0 +1,89 @@
---
kind: CASE
slug: five-copies-of-noscript-recovery
title: NOSCRIPT 복구가 다섯 벌이고 넷은 스크립트 레지스트리를 지나지 않는다
topic: redis-command-admission
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:five-copies-of-noscript-recovery
evidenceCapturedOn: 2026-09-01
assets:
- key: five-copies-of-noscript-recovery
file: ../../../final/evidence/rendered/five-copies-of-noscript-recovery.svg
evidence:
- ../../../final/evidence/raw/five-copies-of-noscript-recovery.txt
source:
- 원본 분석 절은 final/document.md#4-4 · analysis/10 §56 이다.
---
# NOSCRIPT 복구가 다섯 벌이고 넷은 스크립트 레지스트리를 지나지 않는다
서버가 스크립트를 모른다고 답할 때의 복구 경로가 다섯 곳에 있다. 그중 넷은 스크립트 레지스트리를 거치지 않고 자기 자리에서 다시 적재한다.
## 관계
- **같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다**
같은 계열의 중복 문제다.
- **단일 admission point는 우회 경로를 세어야 성립한다**
같은 리프에서 반복되는 형태다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
다섯 벌 중 어느 것이 도는지 확인해야 하는 이유다.
## 문제
Redis 는 스크립트를 해시로 실행한다. 서버가 그 해시를 모르면 특정 오류로 답하고, 클라이언트는 스크립트를 다시 적재한 뒤 재시도해야 한다.
이 복구는 흔한 경로다. 서버 재시작이나 스크립트 캐시 정리 후에 반드시 일어난다.
## 결론
복구 경로가 다섯 벌이다.
그중 하나만 스크립트 레지스트리를 지난다. 나머지 넷은 자기 자리에서 스크립트를 다시 적재한다.
이 상태의 비용은 셋이다.
레지스트리가 어떤 스크립트가 적재되어 있는지에 대한 정본이 아니게 된다. 넷이 그것을 우회해 적재하기 때문이다.
스크립트 내용이 바뀔 때 다섯 곳을 함께 고쳐야 한다. 하나를 놓치면 그 경로만 옛 스크립트를 다시 적재한다.
복구 정책을 바꾸기 어렵다. 재시도 횟수나 백오프를 조정하려면 다섯 곳을 봐야 한다.
증상이 드러나지 않는 것이 이 계열의 특징이다. 다섯 벌이 각각 동작하므로 기능은 정상이다. 비용은 전부 다음에 고치는 사람에게 간다.
## 검증 환경
OpenJDK : 21.0.12
Redis 클라이언트 : Lettuce
확인 방식 : 복구 경로 전수 확인
소스 수정 : x
## 재현 조건
1. 스크립트 없음 오류를 처리하는 코드를 리프 전체에서 찾는다.
2. 각각이 스크립트를 어떻게 다시 적재하는지 확인한다.
3. 스크립트 레지스트리를 지나는 것과 그렇지 않은 것을 구분한다.
## 본문
<!-- body:start -->
Redis가 `NOSCRIPT`를 반환했을 때의 복구(스크립트 재등록 후 재시도)가 다섯 벌로 구현돼 있고 그중 넷이 스크립트 레지스트리를 지나지 않는다.
## NOSCRIPT 복구가 구현된 다섯 곳
:::evidence key="five-copies-of-noscript-recovery" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 레지스트리를 지나는 한 벌만 대응을 유지한다
레지스트리가 스크립트 SHA와 소스의 대응을 유지하므로, 나머지 넷은 재등록하는 스크립트가 레지스트리가 아는 것과 같다는 보장이 없다.
## 확인하지 못한 것
서버 재시작 후 다섯 경로가 각각 어떻게 동작하는지 실행으로 확인하지 않았다. 이 기록은 코드 경로 전수 확인에 근거한다.
실제 SCRIPT FLUSH 이후 다섯 경로의 동작 차이를 관측하지 않았다
<!-- body:end -->
@@ -0,0 +1,124 @@
---
kind: CONCEPT
slug: redis-admission-stages
title: 명령 카탈로그와 admission 아홉 단계
topic: redis-command-admission
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:redis-admission-stages
evidenceCapturedOn: 2026-09-01
assets:
- key: redis-admission-stages
file: ../../../final/evidence/rendered/redis-admission-stages.svg
- key: redis-admission-stages-diagram
file: ../../../final/assets/diagrams/redis-admission-stages.svg
evidence:
- ../../../final/evidence/raw/redis-admission-stages.txt
source:
- 원본 분석 절은 final/document.md#4-4 · analysis/10 §64 이다.
---
# 명령 카탈로그와 admission 아홉 단계
모든 Redis 명령이 하나의 승인 지점을 지나고, 그 지점은 정해진 순서로 검사한다. 순서의 기준은 비용이다. 명백히 거부될 명령은 무엇도 인코딩되거나 전송되기 전에 거부된다.
## 관계
- **의미 어댑터 다섯이 gateway를 직접 불러 admission 아홉 단계를 건너뛴다**
이 단일 지점이 실제로 단일인지에 대한 사례다.
- **단일 admission point는 우회 경로를 세어야 성립한다**
이 개념에서 끌어낸 규칙이다.
- **분류되지 않은 명령은 fail-closed로 거부한다**
카탈로그가 답하지 못하는 명령을 어떻게 다룰지 정한 결정이다.
## 본문
<!-- body:start -->
이 SDK가 명령 하나를 내보내기 전에 지나는 단계의 설명이다 — 카탈로그 분류(BLOCKED·R3·R4 거부) · capability/최소 버전 확인 · permit provenance 검증 · 네임스페이스 검사 · Cluster 동일 슬롯 검사 · 요청 예산 · 정책 기반 레인·타임아웃 유도 · 실패 번역 · 관측.
## 명령 입장의 단일 지점
:::evidence key="redis-admission-stages-diagram" alt="CommandPolicyGuard 에서 카탈로그를 통과하면 실행이고 미분류나 BLOCKED 이면 거절인 두 갈래가 나온다" caption="명령 입장의 단일 지점" zoom="false"
:::
그 위에 얹힌 계약은 gateway가 "everything routed through it has already passed `CommandPolicyGuard`"를 전제한다는 것이다 — 그래서 정책·permit·예산·타임아웃·관측을 자기 관심사로 두지 않는다.
## CommandPolicyGuard 참조 위치
:::evidence key="redis-admission-stages" alt="코드베이스에서 CommandPolicyGuard 를 검색한 출력 26줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="CommandPolicyGuard 코드베이스 검색 — 26줄 · exit 0" zoom="true"
:::
## 이 구조의 바닥
카탈로그가 미분류 명령을 fail-closed로 거부하는 것이다.
:::note
토폴로지 레인 미실행 — 실제 서버에서 각 단계가 도는지 관측하지 않았다
:::
## 순서가 비용순이다
```java
/**
* The single admission point every command passes through.
*
* <p>Validation order is fixed and each step is cheaper than the one after it, so an obviously
* inadmissible command is refused before anything is encoded or sent:
*
* <pre>{@code
* capability -> risk and permit provenance -> namespace -> slot -> request budget
* -> connection lane -> timeout/retry -> invocation -> reply budget -> translation -> telemetry
* }</pre>
*/
```
앞쪽 단계는 메모리 안에서 끝난다. 능력 확인과 허가 출처 확인과 네임스페이스 검사가 그렇다.
슬롯 계산과 요청 예산이 그다음이고, 그다음이 커넥션 레인 선택이다. 실제 호출은 그 뒤에 있다.
이 순서가 아니면 거부될 명령이 인코딩되고 전송된 뒤에 거부된다.
## 허가를 제시하는 것으로 충분하지 않다
```java
/**
* <p>Presenting a permit is never sufficient. The guard verifies who issued it and which policy it
* was issued for, so an application that implements the permit interface itself gets rejected with
* the same message as one that presents no permit at all.
*/
```
허가 인터페이스를 애플리케이션이 직접 구현하면 허가를 만들어 낼 수 있다. 그래서 가드가 발급자와 발급 정책을 확인한다.
거절 메시지가 같다는 것도 설계의 일부다. 자기가 만든 허가와 허가 없음이 구별되지 않으므로 우회 시도가 정보를 얻지 못한다.
## 가드가 들고 있는 협력자
```java
private final RedisCommandCatalog catalog;
private final RedisPermitVerifier permitVerifier;
private final RedisCapabilities capabilities;
private final RedisNamespace namespace;
private final RedisKeyRenderer keyRenderer;
private final ToIntFunction<String> slotCalculator;
```
카탈로그는 명령이 무엇인지 안다. 허가 검증기는 누가 발급했는지 안다. 능력은 이 배포가 무엇을 할 수 있는지 안다. 네임스페이스와 키 렌더러는 키가 어디에 속하는지 정한다. 슬롯 계산기는 클러스터 배치를 안다.
## 카탈로그의 지위
카탈로그는 명령의 정의를 담는다. 그 정의의 출처는 서버 메타데이터다. 정책 파일은 그 위에서 허용 범위를 정한다.
둘을 뒤집으면 정책 파일이 명령의 정의가 되고, 서버가 실제로 하는 일과 어긋날 수 있다.
:::tip
단일 승인 지점은 그것이 실제로 단일일 때만 의미가 있다. 우회 경로가 있으면 이 아홉 단계는 우회하지 않은 호출에만 적용되는 규칙이 된다.
:::
<!-- body:end -->
@@ -0,0 +1,60 @@
---
kind: PROJECT_DECISION
slug: unclassified-commands-are-refused
title: 분류되지 않은 명령은 fail-closed로 거부한다
topic: redis-command-admission
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: decision:unclassified-commands-are-refused
decisionStatus: ADOPTED
decidedOn: 2026-08-30
source:
- src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/RedisCommandCatalog.java
- src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/CommandPolicyGuard.java
- analysis/10-adapter-outbound-cache-redis.md
---
# 분류되지 않은 명령은 fail-closed로 거부한다
## 결정문
명령 카탈로그가 분류하지 못하는 명령은 통과시키지 않고 거부한다.
## 판단 이유
승인 아홉 단계는 명령이 무엇인지 아는 것을 전제한다. 위험 등급도 키 스펙도 슬롯 계산도 카탈로그의 정의에서 나온다.
분류되지 않은 명령을 통과시키면 그 단계들이 적용되지 않은 채 실행된다. 위험 등급을 모르므로 허가 요구도 걸 수 없고, 키 스펙을 모르므로 네임스페이스 검사도 슬롯 계산도 할 수 없다.
즉 통과는 검사를 건너뛰는 것과 같다. 그리고 그 사실이 호출자에게 보이지 않는다.
거부는 시끄럽다. 새 명령을 쓰려면 카탈로그에 먼저 넣어야 한다. 그 마찰이 이 결정의 목적이다.
카탈로그의 정의가 서버 메타데이터에서 온다는 것과 함께 보면 구조가 완성된다. 서버가 아는 명령만 카탈로그에 있고, 카탈로그에 있는 명령만 실행된다.
## 영향
감수하는 것
새 Redis 명령을 쓰려면 카탈로그 갱신이 선행되어야 한다. 서버가 지원해도 바로 쓸 수 없다.
카탈로그가 뒤처지면 정상적인 명령이 거부된다. 그래서 드리프트 검사가 필요하고, 그 검사가 현재 빌드에 없다.
우회 경로가 있으면 이 결정이 그 경로에 적용되지 않는다. 다섯 어댑터가 게이트웨이를 직접 부르는 경로가 그렇다.
얻는 것
정책이 적용되지 않은 명령이 실행되지 않는다.
새 명령의 도입이 명시적 행위가 된다.
## 근거
- **서버 메타데이터가 명령의 정의이고 정책 파일은 허용 범위다**
이 결정이 기대는 관계다.
- **명령 카탈로그와 admission 아홉 단계**
카탈로그가 없으면 적용될 수 없는 단계들이다.
- **의미 어댑터 다섯이 gateway를 직접 불러 admission 아홉 단계를 건너뛴다**
이 결정이 적용되지 않는 경로다.
@@ -0,0 +1,72 @@
---
kind: QUESTION
slug: redis-topology-lane-not-executed
title: Redis 토폴로지 레인이 실행되지 않아 key spec 드리프트가 확인되지 않았다
topic: redis-command-admission
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:redis-topology-lane-not-executed
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# Redis 토폴로지 레인이 실행되지 않아 key spec 드리프트가 확인되지 않았다
명령 카탈로그가 전제하는 키 스펙이 실제 서버와 맞는지는 서버에 붙어야 알 수 있다. 그 레인을 이 리비전에서 돌리지 않았다.
## 사실
명령 메타데이터 드리프트를 검사하는 코드가 있다.
그 검사를 실행하는 Gradle 태스크도 CI 단계도 없다.
이 컨테이너에 Docker 가 있고 cache-redis 의 기본 test 레인은 435 테스트가 통과했다.
기동 탐침은 프로덕션에서 실행되지 않는다.
## 가정
기본 test 레인이 통과했으므로 카탈로그가 맞다고 전제하고 싶어지지만, 그 레인은 서버 메타데이터와 대조하지 않는다. 카탈로그가 스스로 일관적인지만 확인한다.
## 미지수
카탈로그의 키 스펙이 대상 Redis 버전의 실제 메타데이터와 일치하는가.
클러스터 구성에서 슬롯 계산이 실제 배치와 맞는가.
드리프트 검사를 지금 돌리면 무엇이 나오는가.
## 제약
컨테이너가 필요하다.
버전마다 결과가 다를 수 있으므로 대상 버전을 정해야 한다.
애플리케이션 소스를 수정하지 않는다.
## 선택지
드리프트 검사를 수동으로 돌린다
가장 직접적이다. 현재 드리프트 유무가 바로 나온다.
토폴로지 레인 전체를 돌린다
슬롯 계산과 커넥션 레인까지 함께 확인되지만 시간이 더 든다.
## 다음 검증
Redis 컨테이너를 띄우고 명령 메타데이터를 조회해 카탈로그와 대조한다. 그 결과를 드리프트 검사의 출력으로 남긴다.
드리프트가 없으면 이 질문을 닫고, 그 검사를 빌드에 넣는 것을 별도 항목으로 남긴다.
드리프트가 있으면 그것이 새 Case 가 되고, 어느 명령의 어느 속성이 어긋났는지 기록한다.
## 관계
- **build gate라 불리는 catalog drift 검사가 어디에서도 실행되지 않는다**
이 질문이 확인하려는 검사다.
- **서버 메타데이터가 명령의 정의이고 정책 파일은 허용 범위다**
이 질문이 검증하려는 관계다.
- **컨테이너가 필요한 특수 레인의 실제 결과를 실행으로 확인하지 않았다**
이 질문의 상위 항목이다.
@@ -0,0 +1,60 @@
---
kind: REFERENCE
slug: a-single-admission-point-must-count-its-bypasses
title: 단일 admission point는 우회 경로를 세어야 성립한다
topic: redis-command-admission
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:a-single-admission-point-must-count-its-bypasses
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 단일 admission point는 우회 경로를 세어야 성립한다
## 목적
단일 승인 지점이라는 선언을 그 지점이 실제로 유일하다는 증거로 읽는 것을 막는다.
## 규칙
1. 선언은 두 가지를 함께 주장한다
지나는 것이 전부 검사된다는 것과 모든 것이 지난다는 것이다. 코드가 보장하는 것은 대개 첫 번째뿐이다.
2. 하위 계층 타입을 직접 참조하는 곳을 센다
승인 지점이 감싸고 있는 타입을 상위 코드가 직접 부르면 그것이 우회다.
3. 임포트 목록이 빠른 지표다
어떤 패키지에서 무엇을 가져오는지 집계하면 우회 여부가 드러난다.
4. 우회가 있으면 선언을 좁히거나 경로를 막는다
둘 중 하나를 하지 않으면 다음 사람이 같은 오해를 한다.
5. 컴파일 시점에 막을 수 있으면 그렇게 한다
하위 타입을 패키지 밖에서 볼 수 없게 하면 우회 경로가 생기지 않는다.
## 적용 조건
단일 진입점이나 단일 승인 지점을 표방하는 모든 계층
정책과 실행이 분리된 구조
## 예외
성능이나 특수 목적으로 의도적으로 우회를 허용하는 경로가 있을 수 있다. 그 경우 어떤 검사가 생략되는지가 그 자리에 적혀 있어야 한다.
## 예시
명령 정책 가드가 자기를 모든 명령이 지나는 단일 승인 지점이라고 적는다. 의미 어댑터 다섯이 가드도 실행기도 타입 API 도 참조하지 않고 게이트웨이를 30 회 직접 부른다.
허가 출처 확인이 그 우회로 함께 건너뛰어진다. 가드는 애플리케이션이 허가 인터페이스를 직접 구현하는 경우까지 막도록 설계되어 있다.
## 관계
- **의미 어댑터 다섯이 gateway를 직접 불러 admission 아홉 단계를 건너뛴다**
이 규칙을 만든 사례다.
- **명령 카탈로그와 admission 아홉 단계**
우회되는 대상이다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
같은 계열의 확인 규칙이다.
@@ -0,0 +1,58 @@
---
kind: REFERENCE
slug: server-metadata-defines-the-command
title: 서버 메타데이터가 명령의 정의이고 정책 파일은 허용 범위다
topic: redis-command-admission
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:server-metadata-defines-the-command
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 서버 메타데이터가 명령의 정의이고 정책 파일은 허용 범위다
## 목적
정책 파일이 명령의 정의 노릇을 해서, 서버가 실제로 하는 일과 어긋난 채 허용 판정이 내려지는 것을 막는다.
## 규칙
1. 정의는 서버에서 온다
명령의 키 스펙과 플래그와 인자 구조는 서버 메타데이터가 정본이다.
2. 정책 파일은 그 위에서 범위만 정한다
무엇이 허용되고 무엇이 위험한지를 적는다. 명령이 무엇인지를 다시 적지 않는다.
3. 두 쪽의 드리프트를 검사한다
서버 버전이 올라가면 정의가 바뀔 수 있다. 정책 파일이 옛 정의 위에 서 있는지 확인하는 검사가 필요하다.
4. 그 검사를 실제로 돌린다
게이트라고 부르는 것과 빌드에 있는 것은 다르다.
5. 분류되지 않은 명령은 거부한다
카탈로그가 답하지 못하는 명령을 통과시키면 정책이 적용되지 않은 명령이 실행된다.
## 적용 조건
명령 단위로 허용 여부를 판정하는 모든 데이터 저장소 클라이언트
서버 버전에 따라 명령 정의가 달라지는 환경
## 예외
서버 메타데이터를 조회할 수 없는 구성에서는 정의를 고정할 수밖에 없다. 그 경우 고정한 버전을 명시하고, 다른 버전에 붙었을 때의 동작을 정해 둔다.
## 예시
명령 메타데이터 드리프트를 검사하는 코드가 있고 정책 파일 머리 주석이 그것을 빌드 게이트라고 부르지만, 그 검사를 실행하는 태스크도 CI 단계도 없다.
## 관계
- **명령 카탈로그와 admission 아홉 단계**
카탈로그가 승인에서 하는 역할이다.
- **build gate라 불리는 catalog drift 검사가 어디에서도 실행되지 않는다**
네 번째 규칙이 필요한 사례다.
- **분류되지 않은 명령은 fail-closed로 거부한다**
다섯 번째 규칙을 채택한 결정이다.