# grpc-client 완전 해부 > 상태: COMPLETE > 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 13파일 931줄 + `src/test` 4파일 581줄 축자 통독 완료. 재통독에서 §17.1–§17.4 를 독립적으로 재도출했고 넷 다 성립한다. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나. > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/grpc/grpc-client` > SSOT owner: `grpc-client` > integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지 - `allowed_dependencies`: `["grpc-core-api", "grpc-policy"]` + vendor `grpc-api`·`grpc-stub`(BOM) - `runtime_memberships`: **`[]`** — build-only | 파일 | LOC | |---|---:| | `GrpcChannelRuntimeRegistry` | 137 | | `GrpcTypedStubFactory` | 119 | | `GrpcNamedChannelProfile` | 100 | | `GrpcChannelRuntime` | 96 | | `GrpcClientMetadataPolicy` | 87 | | `GrpcChannelProfileValidator` | 81 | | `GrpcStubPolicyApplier` · `GrpcClientCallContext` | 68 · 67 | | `GrpcChannelGeneration` · `GrpcLoadBalancingPolicy` · `GrpcChannelDrainPolicy` · `GrpcStubDescriptor` · `GrpcCallCredentialProvider` | 42 · 35 · 34 · 33 · 32 | | test 4파일 | 581 | ### Coverage ledger | scope | count | disposition | reason | |---|---:|---|---| | `main/java/**` | 13 | `FULL_READ` | 931줄 전 본문 | | `test/java/**` | 4 | `FULL_READ` | 581줄 | | `build.gradle` | 1 | `FULL_READ` | 17줄 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체 ```groovy // build.gradle:3-4 // Client runtime: named channel profiles, channel runtime generations with drain, the typed stub // factory that refuses to hand a raw Channel to application code, and client metadata/credentials. ``` ## 2. 채널은 한 번 만들고 재사용한다 레지스트리 javadoc 이 두 성질을 든다. > "a channel is created once and reused. Creating one per request is a mistake that works — every > call succeeds — while spending a TCP handshake, a TLS handshake and an HTTP/2 setup on each one, > and it is usually found by a connection count rather than by a failure." > "prepare-then-swap. The new runtime exists before the pointer moves, so no call ever finds nothing > there; the old one drains rather than being closed under its in-flight work." `require` 가 빈 값을 돌려주지 않고 던지는 이유도 적혀 있다 — 빈 값은 "설정되지 않음" 과 "설정됐지만 도달 불가" 를 구분할 수 없게 만든다. ## 3. 세대와 배수 `GrpcChannelRuntime` 이 단항 호출과 열린 스트림을 따로 센다. > "a drain treats them differently: unary calls are waited for, streams are signalled. A single > counter would make the drain either cut a stream that could have finished or wait an hour for one > that never will." 그리고 기저 채널을 노출하지 않는다 — 그것을 건네는 것이 정책 없는 스텁이 만들어지는 경로다. ## 4. 타입 있는 스텁 공장 — 두 거절 > "It will not build a stub type nobody registered, so a service cannot acquire a channel without a > policy; and it never returns a `Channel` or a builder, so application code has no way to construct > one itself. Both are what make the raw-API import rule enforceable rather than merely stated: > there is nothing to reach for." 등록 함수가 원시 채널이 아니라 런타임을 받는 것도 같은 이유다 — 등록이 채널을 몰래 빼돌릴 수 없다. 빈 공장은 만들 수 없다 — "a stub factory with no registered types can build nothing and refuses everything." ## 5. 메타데이터 허용 목록이 둘인 이유 > "Tenant and actor metadata is meaningful to a service inside the same trust domain and is an > unverified assertion to one outside it; sending it across the boundary invites the receiver to > trust it." 그리고 교차 경계 목록은 같은 도메인 목록의 부분집합이어야 한다 — 생성자가 강제한다. 인가 헤더는 어느 목록에도 올 수 없다. > "`authorization` is supplied per call by a credential provider, not set as metadata; a header set > by the application is a header that survives a rotation." 나가는 방향은 허용 목록 밖을 거절이 아니라 폐기로 다룬다. 그 비대칭의 이유도 적혀 있다 — 알 수 없는 상관 헤더 때문에 나가는 호출이 실패하는 것이 더 나쁜 결과다. 예산은 그대로 강제된다. ## 10. 테스트 레인 네 테스트 581줄. 프로파일 검증, 레지스트리 설치·회전·배수, 메타데이터 정책, 스텁 공장의 두 거절을 확인한다. ## 12. negative-space probes **12.1 도달성.** build-only. `grpc-spring-boot-starter` 는 이 리프의 타입을 빈으로 만들지 않는다(§20 가족 문서 §3.4). **12.2 대조군 — 비원자적 해제.** 이 리프의 `finishUnaryCall`·`closeStream` 과 `grpc-server` 의 `GrpcAdmissionController.release`, `grpc-policy` 의 `GrpcStreamAdmission.release` 가 같은 형태다 — `get() > 0` 을 본 뒤 별도로 감소. §17.2 가 이 리프에서의 구체적 결과를 다룬다. **12.3 이 리프를 import 하는 곳.** 재통독에서 다시 세었다. `grpc-discovery` main 셋(`GrpcResolverProfile`·`GrpcStableLoadBalancer`·`GrpcKubernetesRoutingMode`)과 `grpc-spring-boot-starter` 의 `GrpcPlatformStartupValidator` 가 이 리프의 타입을 이름으로 부른다 — 빈으로 만들지는 않고 검증·판정에 쓴다. `GrpcTypedStubFactory`·`GrpcChannelRuntimeRegistry` 를 실제로 조립하는 코드는 없다. **12.4 드리프트.** build.gradle 이 서술한 네 요소가 전부 존재한다. 드리프트 없음. ## 16. 확인하지 못한 것 - 실제 채널을 만들어 회전시키지 않았다. `ManagedChannel` 을 만드는 코드가 이 리프에 없다. - 동시 회전과 동시 해제를 실행으로 재현하지 않았다. 원자성 분석으로 판정했다. ## 17. 손볼 것 ### 17.1 P2 — `rotate` 가 비교 후 교체가 아니라 덮어쓰기다 `install` 은 정확하다. ```java if (!holder.compareAndSet(null, runtime)) { throw new IllegalStateException("… already has a runtime; use rotate()"); } ``` `rotate` 는 그렇지 않다. ```java GrpcChannelRuntime previous = holder.get(); if (!previous.generation().supersededBy(next)) { throw …; } GrpcChannelRuntime replacement = new GrpcChannelRuntime(next); holder.set(replacement); // ← 비교 없이 덮어쓴다 previous.beginDrain(); draining.computeIfAbsent(…).add(previous); ``` 두 회전이 동시에 들어오면 둘 다 같은 `previous` 를 읽고, 둘 다 대체본을 만들고, 나중 `set` 이 앞의 대체본을 덮는다. 덮인 대체본은 어디에도 등록되지 않는다 — `draining` 목록에 들어가는 것은 `previous` 뿐이다. 그러므로 그 세대는 배수도 회수도 되지 않고, 그 위에서 시작된 호출은 아무도 세지 않는다. 클래스가 이 문제를 인지하고 있다는 증거가 같은 파일에 있다 — `install` 의 비교 후 교체와 `AtomicReference` 선택이다. 회전 쪽만 그 규율에서 벗어나 있다. 수정은 `holder.compareAndSet(previous, replacement)` 로 바꾸고 실패 시 다시 읽어 판정하거나 던지는 것이다. ### 17.2 P2 — 비원자적 감소가 세대를 영구히 회수 불가로 만든다 ```java public void finishUnaryCall() { if (inFlightUnaryCalls.get() > 0) { inFlightUnaryCalls.decrementAndGet(); } } public void closeStream() { if (openStreams.get() > 0) { openStreams.decrementAndGet(); } } ``` 카운터가 1 일 때 두 스레드가 동시에 끝나면 둘 다 조건을 통과해 둘 다 감소시켜 −1 이 된다. 그 결과가 이 리프에서는 구체적이다. ```java public boolean quiescent() { return inFlightUnaryCalls.get() == 0 && openStreams.get() == 0; } ``` 정확히 0 을 요구한다. 음수가 되면 조용해짐 판정이 영원히 거짓이고, `retireQuiescent` 가 그 세대를 결코 제거하지 않는다. 회전이 반복될수록 `draining` 목록이 자란다. 같은 형태가 이 가족의 다른 두 곳에도 있다(`GrpcAdmissionController.release`, `GrpcStreamAdmission.release`). 그쪽은 경계가 느슨해지는 결과였고, 이쪽은 자원이 회수되지 않는 결과다. 수정은 `updateAndGet(v -> Math.max(0, v - 1))` 이나 `decrementAndGet()` 후 하한 보정이다. 같은 가족의 `GrpcRetryBudget` 이 정확한 비교 후 교체 루프를 이미 쓴다. ### 17.3 P3 — 배수 목록의 순회가 동기화 밖에서 일어난다 ```java draining.computeIfAbsent(name, key -> java.util.Collections.synchronizedList(new ArrayList<>())).add(previous); … public List draining(GrpcChannelProfileName profileName) { return List.copyOf(draining.getOrDefault(profileName, List.of())); } public int retireQuiescent(GrpcChannelProfileName profileName) { List runtimes = draining.get(profileName); … List quiescent = runtimes.stream().filter(GrpcChannelRuntime::quiescent).toList(); runtimes.removeAll(quiescent); ``` `Collections.synchronizedList` 는 개별 연산만 동기화한다. 순회는 호출자가 그 목록을 잠그고 해야 한다는 것이 그 API 의 계약이다. `List.copyOf(...)` 와 `stream()` 둘 다 순회다. 회전이 동시에 `add` 하면 동시 변경 예외가 가능하다. 그리고 읽고 지우는 두 단계가 원자적이지 않으므로, 그 사이에 조용해진 세대가 추가되면 이번 회수에서 빠진다. 후자는 다음 호출에서 회수되므로 무해하다. 수정은 `CopyOnWriteArrayList` 로 바꾸는 것이다. 배수 목록은 쓰기가 드물고 읽기가 잦아 그 자료구조의 전형적 용례다. ### 17.4 P3 — 프로파일 검증기가 javadoc 이 든 두 실수 중 하나만 검사한다 javadoc: > "Two in particular. Round-robin over a target that resolves to one address … and **two profiles > pointing at the same target with the same settings are one channel with two names**, which is the > shape that appears when somebody wanted a different SLO and copied the profile instead." 구현된 것은 첫째와 **다른 것**이다. ```java String previous = seenNames.putIfAbsent(profileName, profile.target().toString()); if (previous != null) { violations.add("channel profile '…' is declared twice, for '…' and '…'"); } ``` 이름이 같은 프로파일이 두 번 선언된 경우를 잡는다. javadoc 이 든 둘째는 **이름이 다르고 대상이 같은** 경우인데, 그 검사가 없다. 지도는 이름을 키로 쓰므로 같은 대상을 가리키는 두 이름은 서로를 만나지 않는다. 그리고 둘째가 실제로 더 찾기 어려운 형태다 — 이름이 같으면 설정 결속이 먼저 실패하거나 나중 것이 이기지만, 이름이 다르면 조용히 두 채널이 생긴다. 수정은 대상과 설정을 키로 하는 두 번째 지도를 두고 역방향 중복을 보고하는 것이다. ### 확인된 설계(문제 아님) - **채널을 한 번 만들고 재사용하는 것과, 그 실수가 실패가 아니라 연결 수로 발견된다는 근거.** - **준비 후 교체** — 새 런타임이 먼저 존재하고 포인터가 나중에 움직인다. - **`require` 가 빈 값 대신 던지는 것.** - **단항 호출과 스트림을 따로 세는 것.** - **기저 채널을 노출하지 않는 것과 등록 함수가 런타임을 받는 것.** - **등록되지 않은 스텁 타입을 거절하는 것.** - **신뢰 도메인별 메타데이터 허용 목록 둘과 부분집합 불변식.** - **인가 헤더를 자격증명 제공자에게만 맡기는 것.** - **나가는 방향에서 허용 목록 밖을 폐기로 다루고 그 비대칭의 이유를 적은 것.** --- ## Source anchors ``` src/grpc/grpc-client/build.gradle:1-17 main/java/…/client/GrpcChannelRuntimeRegistry.java:1-137 main/java/…/client/GrpcTypedStubFactory.java:1-119 main/java/…/client/GrpcNamedChannelProfile.java:1-100 main/java/…/client/GrpcChannelRuntime.java:1-96 main/java/…/client/GrpcClientMetadataPolicy.java:1-87 main/java/…/client/GrpcChannelProfileValidator.java:1-81 main/java/…/client/(GrpcStubPolicyApplier · GrpcClientCallContext · GrpcChannelGeneration · GrpcLoadBalancingPolicy · GrpcChannelDrainPolicy · GrpcStubDescriptor · GrpcCallCredentialProvider) test/java/…/client/(GrpcNamedChannelProfileTest · GrpcClientMetadataPolicyTest · GrpcTypedStubFactoryTest · GrpcChannelRuntimeRegistryTest) ```