# 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 (대비) ```