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,277 @@
# grpc-observability 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 4파일 354줄, test 1파일 172줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-observability`
> SSOT owner: `grpc-observability`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- `allowed_dependencies`: `["grpc-core-api"]`
- `runtime_memberships`: **`[]`** (`EVD-325`)
| 항목 | 수 |
|---|---:|
| production Java 파일 | **4** (354 LOC) |
| test Java 파일 | 1 (172 LOC) |
| build 파일 | `build.gradle` 12줄 |
| test 메서드(실행 확인) | **10** (`EVD-325`) |
| 선언된 의존 | project 1 + vendor 1 (`micrometer-core`) |
파일별 LOC:
| 파일 | LOC | 성격 |
|---|---:|---|
| `GrpcMetricCardinalityPolicy` | 123 | 태그 허용/거절 판정 (static 유틸) |
| `GrpcObservationConvention` | 99 | Micrometer 등록 (유일한 상태 보유 클래스) |
| `GrpcRpcObservation` | 78 | 논리 RPC 관측 record |
| `GrpcStreamObservation` | 54 | 스트림 수명 관측 record |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `build.gradle` | 1 | `FULL_READ` | 12줄 전문 |
| `main/…/observability/*.java` | 4 | `FULL_READ` | 4파일 전 본문 축자 확인 (cycle 2) |
| `test/…/GrpcMetricCardinalityPolicyTest.java` | 1 | `FULL_READ` | 172줄, 10개 `@Test` 전부 단언 대상 확인 |
`STRUCTURAL_ONLY` 0 · `UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
```groovy
// build.gradle:3-5
// Bounded observability: logical RPC vs physical attempt vs stream lifecycle, with a cardinality
// policy that refuses payload, raw metadata and any actor/tenant/object/stream/idempotency
// identifier as a tag.
```
세 층위를 구별한다 — 논리 RPC, 물리 시도, 스트림 수명주기.
Micrometer 를 `api` 로 노출하는 이유도 build.gradle 에 적혀 있다 — "the observation convention's public signatures name Micrometer types, so wiring it requires naming them." 실제로 `GrpcObservationConvention` 의 생성자와 `boundedTags` 반환형이 Micrometer 타입(`MeterRegistry`, `Tags`)이므로 그 서술은 코드와 일치한다.
## 2. 의존성과 런타임 배선
`grpc-core-api` 에서 쓰는 타입은 넷이다 — `GrpcMethodName`, `GrpcStatusCode`, `RpcType`, `GrpcCompletionOutcome`. 네 타입 모두 `GrpcRpcObservation` 의 record 성분이다. `GrpcStreamObservation``GrpcMethodName` 하나만 쓴다.
배선 없음(`EVD-325`). `runtime_memberships` 가 비어 있고, 저장소 어디에서도 `new GrpcObservationConvention(...)` 을 만드는 production 코드가 없다.
## 3. 컴포넌트 지도
```
GrpcMetricCardinalityPolicy 태그 키 allowlist 8 · 명시적 거절 11 · 값 패턴 1
GrpcObservationConvention meter 이름 7개 상수 · record 오버로드 2개
GrpcRpcObservation 9성분 record · tags() 7태그
GrpcStreamObservation 7성분 record · tags() 5태그
```
## 4. 계약·불변식
### 4.1 allowlist 가 기본 거절이고 거절 목록은 메시지를 위한 것이다
`violations(Map)` 의 판정 순서가 셋이다.
```java
if (FORBIDDEN_TAGS.contains(key)) "its value space grows with traffic…"
if (!ALLOWED_TAGS.contains(key)) "not on the bounded allowlist [...]"
if (UNBOUNDED_VALUE.matcher(value)) "looks like an identifier or a credential"
```
클래스 javadoc 이 두 목록이 겹치는 이유를 적는다 — "Everything unlisted is refused anyway; naming the dangerous ones gives the refusal a message that says why rather than just that." 즉 `FORBIDDEN_TAGS` 는 판정을 바꾸지 않고 진단만 바꾼다. 두 번째 분기가 이미 그것들을 거절한다.
허용 태그 8개: `grpc.service` · `grpc.method` · `grpc.rpc_type` · `grpc.status` · `grpc.channel_profile` · `grpc.completion_outcome` · `grpc.retry_bucket` · `grpc.stream_termination_reason`.
명시적 거절 11개: `actor_id` · `tenant_id` · `object_id` · `stream_id` · `idempotency_key` · `request` · `response` · `metadata` · `authorization` · `error_detail` · `trace_id`.
### 4.2 값 검사는 세 형태만 잡는다
```java
Pattern.compile("(?i).*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|sha256:|bearer ).*")
```
UUID · `sha256:` 접두 · `bearer ` 접두. 숫자 id, 이메일, 호스트명은 잡히지 않는다. 그리고 `.` 은 기본적으로 개행에 맞지 않으므로 값에 개행이 섞이면 `matches()` 가 거짓이 된다.
### 4.3 재시도는 값이 아니라 버킷이다
`retryBucket(int)` 이 1-based 시도 수를 받아 `0`/`1`/`2`/`3+` 로 접는다. 0 이하는 던진다. javadoc 이 이유를 적는다 — "an attempt count is unbounded in principle and the distinction anyone acts on is first attempt, one retry, several."
### 4.4 논리 호출과 물리 시도의 분리
`GrpcRpcObservation` javadoc:
> "A retried call is one observation with a retry bucket, and three attempt events beneath it;
> recording three separate calls instead makes the success rate read as 33% when the caller in fact
> got its answer."
그 분리가 `GrpcObservationConvention.record(GrpcRpcObservation)` 에서 실제로 그렇게 구현되어 있다 — `RPC_DURATION` 타이머는 1회, `RPC_ATTEMPTS` 카운터는 `attempts` 만큼 증가. 같은 태그 집합을 쓴다.
### 4.5 조건부 기록 둘
```java
if (observation.completionOutcome().requiresReconciliation()) COMPLETION_UNKNOWN 카운터
if (!observation.queueWaitTime().isZero()) QUEUE_WAIT 타이머
```
대기 시간이 0 이면 타이머를 등록조차 하지 않는다. 즉 큐 대기가 없던 배포에서는 그 meter 가 생기지 않는다.
### 4.6 생성자 검증의 비대칭 — 의도된 쪽
`GrpcRpcObservation` 의 검증에서 `duration``queueWaitTime` 은 음수를 거부하고 `deadlineRemaining` 은 존재만 요구한다. 그리고 `unusedDeadline()` 이 음수일 때 빈 값을 돌려준다. 마감을 넘긴 호출을 표현하기 위한 것으로 읽히고, 두 메서드가 그 해석과 일관된다.
### 4.7 스트림은 지속 시간이 아니라 무엇이 움직였는지로 잰다
`GrpcStreamObservation` javadoc:
> "Duration percentiles are meaningless here — a healthy subscription lasts an hour and an unhealthy
> one lasts an hour — so what is recorded instead is what actually distinguishes them: how many
> messages moved, how often the writer stalled waiting for the transport, and how it ended."
그리고 `tags()` 주석이 "The stream id is deliberately absent" 라고 적는다. 실제로 `grpc.stream_id``FORBIDDEN_TAGS` 에도 있어 두 겹으로 막힌다.
## 10. 테스트 레인
**10 tests, 0 failures, 0 skipped.** 전부 `GrpcMetricCardinalityPolicyTest`(172줄).
| 테스트 | 붙드는 것 |
|---|---|
| `onlyBoundedTagsAreAllowed` | allowlist 원소 |
| `unboundedIdentifierTagsAreRefused` | 식별자 5종 거절 + 메시지 문구 |
| `contentBearingTagsAreRefused` | 페이로드·메타데이터·오류 상세 3종 |
| `anIdentifierShapedValueIsRefused` | 허용 키 + UUID/`Bearer` 값 |
| `unlistedTagsAreRefused` | 목록 밖 키 + 메시지에 allowlist |
| `attemptsAreBucketed` | 1→`0`, 2→`1`, 4→`3+`, 99→`3+`, 0→예외 |
| `aRetriedCallIsOneObservation` | 타이머 1 · 시도 카운터 3 |
| `completionUnknownIsCountedSeparately` | 전용 카운터 |
| `streamsAreMeasuredByMessagesAndStalls` | `grpc.stream_id` 부재 · 메시지·스톨 카운터 |
| `anUnboundedTagThrowsRatherThanBeingDropped` | 등록 거부가 던지기 |
## 12. negative-space probes
**12.1 도달성.** 블록 전체가 배선되지 않았다(`EVD-325`). `unusedDeadline()`·`retried()`·`consumerFellBehind()`·`allowedTags()`·`forbiddenTags()`·`retryBuckets()` 의 production 호출자 0.
리프 밖 참조도 0 이다.
```
$ grep -rn "grpc.observability" --include=*.java src/ | grep -v /grpc-observability/
grpc-core-api/…/GrpcStableModuleCatalog.java:30: "grpc-observability", ← 목록 안의 문자열
```
그런데 **두 모듈이 이 리프를 `api` 로 노출한다.**
```groovy
grpc/grpc-testkit/build.gradle:53 api project(':grpc:grpc-observability')
grpc/grpc-spring-boot-starter/build.gradle:16 api project(':grpc:grpc-observability')
```
`api` 는 그 모듈을 쓰는 쪽까지 Micrometer 를 포함한 이 리프의 타입을 물려받는다는 선언인데, 두 모듈 어느 자바 파일도 `dev.caskeleton.grpc.observability` 를 import 하지 않는다. 스타터 쪽은 같은 형태의 미사용 의존을 셋 더 들고 있다(`grpc-spring-boot-starter` §12.3).
이 리프의 build.gradle 은 Micrometer 를 `api` 로 두는 이유를 적어 두었다 — 공개 서명이 Micrometer 타입을 이름으로 부르므로 배선하려면 그것을 명명해야 한다. 그 논거는 **이 리프를 실제로 쓰는 모듈**에 대해 성립한다. 지금은 쓰지 않는 두 모듈이 그 전파를 받고 있다.
**12.2 대조군 — 세 개의 카디널리티/노출 정책.**
| 위치 | 막는 것 | 배선 |
|---|---|---|
| `messaging` `CardinalityGuard` | 지표 태그 폭발 | 없음 (`EVD-316`) |
| `grpc-observability` `GrpcMetricCardinalityPolicy` | 태그 키 allowlist + 값 형태 | 없음 |
| `grpc-policy` `GrpcErrorExposurePolicy` | 클라이언트에 보낼 수 없는 문자열 | 블록 미배선 |
**12.3 중복 장치.** `GrpcStreamTerminationReason``grpc-policy` 에 열거형으로 존재한다. 이 리프의 `GrpcStreamObservation.terminationReason``String` 이다. §17.2 참조.
**12.4 문서 드리프트.** build.gradle 주석이 거절 대상으로 든 다섯(actor·tenant·object·stream·idempotency)이 `FORBIDDEN_TAGS` 에 전부 있다. 드리프트 없음.
## 16. 확인하지 못한 것
- 이 리프를 실제 `MeterRegistry` 에 배선해 돌린 적이 없다. 배선 자체가 없으므로 런타임 관측이 불가능하다.
- `UNBOUNDED_VALUE` 를 우회하는 값 형태(숫자 id·이메일 등)를 실행으로 확인하지 않았다. 정규식 형태로 판정했다.
- §17.1-b 의 "마감 잔량에 해당하는 meter 가 없다" 는 meter 이름 상수 일곱 개와 두 `record` 오버로드 본문으로 판정했다. 다른 이름의 상수가 그 역할을 겸하는지는 이름만 보고 배제했다.
- 두 모듈의 `api` 의존이 미사용이라는 것(§12.1)은 패키지 이름 grep 으로 판정했다.
## 17. 손볼 것
### 17.1 P3 — `queueHighWatermark` 는 요구되고 검증되지만 아무도 읽지 않는다
`GrpcStreamObservation` 의 7성분 중 `queueHighWatermark` 만 소비자가 없다.
```
GrpcStreamObservation.java:23 long queueHighWatermark, ← 선언
GrpcStreamObservation.java:31 … || queueHighWatermark < 0 ← 검증
그 외 저장소 전체 매치 0
```
`tags()` 에 없고, `GrpcObservationConvention.record(GrpcStreamObservation)` 이 등록하는 세 meter(`STREAM_LIFETIME`·`STREAM_MESSAGES`·`STREAM_FLOW_CONTROL_STALLS`) 어디에도 들어가지 않는다. 테스트도 `250L` 을 넘기고 그 값에 대해 아무것도 단언하지 않는다.
클래스 javadoc 이 "what is recorded instead is …" 로 세 가지를 열거하는데 그 목록에도 없다. 즉 서술과 구현은 일치하고, 어긋난 것은 **필수 생성자 인자**라는 점이다. 호출자는 측정해서 넘겨야 하고 그 값은 버려진다.
수정은 둘 중 하나다 — `STREAM_QUEUE_HIGH_WATERMARK` gauge/counter 를 추가하거나, 성분에서 뺀다. 큐 최고 수위는 소비자 지연의 직접 지표이므로 전자가 이 클래스의 목적에 맞는다.
### 17.1-b P3 — `deadlineRemaining` 도 meter 가 없다. javadoc 은 그것이 기록된다고 말한다
§17.1 과 같은 형태가 `GrpcRpcObservation` 에도 있고, 이쪽은 클래스 javadoc 이 명시적으로 어긋난다.
> "{@code deadlineRemaining} and {@code queueWaitTime} are **recorded** because they are the two
> numbers that explain a latency change without being latency. A p99 that doubles during a rollout
> is a different incident depending on whether callers were queueing."
두 값을 함께 들면서 "기록된다"고 단언하는데, `record(GrpcRpcObservation)` 이 등록하는 meter 는 넷이다.
```java
Timer.builder(RPC_DURATION)record(observation.duration());
registry.counter(RPC_ATTEMPTS, tags).increment(observation.attempts());
if (requiresReconciliation()) registry.counter(COMPLETION_UNKNOWN, tags).increment();
if (!observation.queueWaitTime().isZero()) Timer.builder(QUEUE_WAIT)record(observation.queueWaitTime());
```
`queueWaitTime``QUEUE_WAIT` 타이머로 나간다. `deadlineRemaining` 은 나가는 곳이 없다 — meter 이름 상수 일곱 개 중에도 마감 잔량에 해당하는 것이 없고, `tags()` 에도 들어가지 않는다(태그로 넣으면 카디널리티가 터지므로 그것이 옳다).
그래서 이 성분을 읽는 코드는 `unusedDeadline()` 하나이고, 그 메서드의 production 호출자는 0 이다(§12.1).
**§4.6 과의 관계.** §4.6 은 이 성분의 검증 비대칭(음수 허용)이 "마감을 넘긴 호출을 표현하기 위한 것" 이라고 읽었다. 그 해석은 그대로 유효하다 — 다만 그 표현이 도달하는 곳이 아직 없다. 관측값으로서는 §17.1 의 `queueHighWatermark` 와 같은 처지다.
**수정.** `queueWaitTime` 과 같은 형태로 타이머를 하나 더 둔다(마감을 넘긴 경우는 `unusedDeadline()` 이 이미 빈 값으로 구분해 주므로 기록 대상에서 빼면 된다). 아니면 javadoc 의 "recorded" 를 "carried" 로 낮춘다. 지금은 관측 대상 둘을 나란히 약속하고 하나만 내보낸다.
### 17.2 P3 — 허용 태그 8개 중 둘은 값이 자유 문자열이고, 그중 하나는 bounded 열거형이 이미 존재한다
값 검사는 키가 allowlist 를 통과한 뒤 `UNBOUNDED_VALUE` 세 형태만 본다. 그런데 태그 값의 출처는 균일하지 않다.
| 태그 | 값 출처 | 유계 |
|---|---|---|
| `grpc.service` · `grpc.method` | `GrpcMethodName` | 서비스/메서드 수만큼 |
| `grpc.rpc_type` · `grpc.status` · `grpc.completion_outcome` | 열거형 | 예 |
| `grpc.retry_bucket` | `retryBucket()` 4값 | 예 |
| `grpc.channel_profile` | `String` (null 이면 `"server"`) | **아니오** |
| `grpc.stream_termination_reason` | `String`, 비어 있지 않기만 하면 됨 | **아니오** |
`GrpcStreamObservation` 의 검증은 `terminationReason` 이 널이 아니고 공백이 아닌지만 본다. 호출자가 예외 메시지나 원격 상태 문자열을 그대로 넣으면 그 태그의 값 공간이 트래픽과 함께 자란다 — 이 클래스가 존재하는 이유로 든 바로 그 실패다.
그리고 그 개념의 bounded 열거형이 이미 저장소에 있다 — `grpc-policy``GrpcStreamTerminationReason`.
쓰지 않은 이유는 의존 방향으로 설명된다. 이 리프의 `allowed_dependencies``["grpc-core-api"]` 뿐이고 그 열거형은 `grpc-policy` 에 있다. 그래서 수정은 열거형을 `grpc-core-api` 로 옮기거나, `violations` 가 두 자유 문자열 태그에 대해 허용값 집합을 받도록 서명을 넓히는 것이다.
### 확인된 설계(문제 아님)
- **논리 RPC / 물리 시도 / 스트림 수명주기를 구별한 것.** 재시도가 있는 시스템에서 호출 한 번이 무엇인지가 층위마다 다르고, `record` 구현이 그 구별을 실제로 지킨다.
- **거절이 드롭이 아니라 던지기인 것.** `boundedTags` 의 javadoc 이 이유를 적는다 — 드롭하면 넣은 쪽이 계속 쓰고 첫 증상이 프로덕션 백엔드의 시계열 거부가 된다.
- **`FORBIDDEN_TAGS` 를 진단 전용으로 둔 것.** 판정은 allowlist 가 하고, 이 목록은 왜 거절인지만 바꾼다.
- **스트림 id 를 두 겹으로 막은 것.** `tags()` 에서 빼고 `FORBIDDEN_TAGS` 에도 둔다.
- **`long → double` 확대 변환을 명시하고 이유를 주석에 적은 것.**
---
## Source anchors
```
src/grpc/grpc-observability/build.gradle:1-12
main/…/observability/GrpcMetricCardinalityPolicy.java:1-123
main/…/observability/GrpcObservationConvention.java:1-99
main/…/observability/GrpcRpcObservation.java:1-78
main/…/observability/GrpcStreamObservation.java:1-54
test/…/observability/GrpcMetricCardinalityPolicyTest.java:1-172
src/grpc/grpc-policy/…/streaming/GrpcStreamTerminationReason.java (대비)
src/messaging/messaging-observability/…/CardinalityGuard.java (대비)
```