Files
document-haness/docs/clean-architecture-backend-template/analysis/grpc/grpc-observability.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 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>
2026-09-04 22:51:59 +09:00

16 KiB

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. 모듈의 정체와 경계

// 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 성분이다. GrpcStreamObservationGrpcMethodName 하나만 쓴다.

배선 없음(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) 의 판정 순서가 셋이다.

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 값 검사는 세 형태만 잡는다

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 조건부 기록 둘

if (observation.completionOutcome().requiresReconciliation())  COMPLETION_UNKNOWN 카운터
if (!observation.queueWaitTime().isZero())                     QUEUE_WAIT 타이머

대기 시간이 0 이면 타이머를 등록조차 하지 않는다. 즉 큐 대기가 없던 배포에서는 그 meter 가 생기지 않는다.

4.6 생성자 검증의 비대칭 — 의도된 쪽

GrpcRpcObservation 의 검증에서 durationqueueWaitTime 은 음수를 거부하고 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_idFORBIDDEN_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 로 노출한다.

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 중복 장치. GrpcStreamTerminationReasongrpc-policy 에 열거형으로 존재한다. 이 리프의 GrpcStreamObservation.terminationReasonString 이다. §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 는 넷이다.

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());

queueWaitTimeQUEUE_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-policyGrpcStreamTerminationReason.

쓰지 않은 이유는 의존 방향으로 설명된다. 이 리프의 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 (대비)