# grpc-discovery 완전 해부 > 상태: COMPLETE > 재오픈 게이트: cycle 2 — `src/main` production 7파일 409줄, test 2파일 220줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음. > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/grpc/grpc-discovery` > SSOT owner: `grpc-discovery` > integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지 - `allowed_dependencies`: `["grpc-core-api", "grpc-client"]` - `runtime_memberships`: **`[]`** — build-only | 파일 | LOC | |---|---:| | `GrpcKubernetesProfile` | 90 | | `GrpcDiscoveryPolicyValidator` | 67 | | `GrpcResolverProfile` | 63 | | `GrpcKubernetesProfileValidator` | 61 | | `GrpcResolverType` · `GrpcKubernetesRoutingMode` | 45 · 45 | | `GrpcStableLoadBalancer` | 38 | | **main 합계 (7파일)** | **409** | | `GrpcKubernetesProfileTest` · `GrpcDiscoveryPolicyValidatorTest` | 127 · 93 | ### Coverage ledger | scope | count | disposition | reason | |---|---:|---|---| | `main/java/**` | 7 | `FULL_READ` | 409줄 전 본문 | | `test/java/**` | 2 | `FULL_READ` | 220줄 전 본문 · 테스트 15개 | | `build.gradle` | 1 | `FULL_READ` | 9줄 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체 ```groovy // build.gradle:3-5 // Stable discovery: Static/DNS resolvers, pick_first/round_robin load balancing, and the // Kubernetes VIP / headless / mesh routing profiles. Custom resolvers, custom load balancers and // xDS are Advanced and are refused here by GrpcDiscoveryPolicyValidator. ``` ## 2. 이 리프가 붙드는 한 가지 짝 세 타입이 같은 사실을 다른 각도에서 말한다. - `GrpcResolverType` — 각 리졸버가 주소를 여럿 돌려줄 수 있는가. `STATIC`·`DNS` 는 예, `UNIX` 는 아니오. - `GrpcStableLoadBalancer` — 주소 수에 맞는 정책. 1개면 `PICK_FIRST`, 여럿이면 `ROUND_ROBIN`. - `GrpcKubernetesRoutingMode` — 누가 균형을 잡는가. VIP 는 kube-proxy, headless 는 클라이언트, MESH 는 사이드카. 세 javadoc 이 같은 실패를 다르게 서술한다. > `GrpcResolverType` — "A resolver that returns one address makes `round_robin` a no-op, and the > pairing is the most common way a deployment has load balancing on paper and none in practice." > `GrpcStableLoadBalancer` — "`pick_first` over a headless record pins every request from this > client to one pod, which shows up as one instance at capacity while the rest are idle." > `GrpcKubernetesRoutingMode` — "A Service VIP balances per connection in kube-proxy, which for a > long-lived HTTP/2 connection means it does not balance at all after the first request." ## 3. 두 검증기가 다른 질문에 답한다 `GrpcKubernetesProfileValidator` javadoc 이 분리 이유를 적는다. > "The resolver validator asks whether a load-balancing policy does anything over the addresses it > will see; this one asks whether the deployment shape, the retry owner and the stream obligations > agree with each other. A deployment can have a perfectly coherent resolver profile and still have > put retries in two places." | 검사 | 어디 | |---|---| | 균형 정책이 주소 수에 대해 무의미한가 | `GrpcDiscoveryPolicyValidator` | | 재시도 소유자가 라우팅 모드가 요구하는 것과 다른가 | `GrpcKubernetesProfileValidator` | | VIP 인데 긴 스트림을 싣는가 | 〃 | | 배수 유예가 재접속 예산보다 짧은가 | 〃 | ## 4. 생성자가 거부하는 것과 검증기가 보고하는 것 `GrpcResolverProfile` 정규 생성자가 네 조합을 아예 만들 수 없게 한다 — 주소 0 이하, 단일 엔드포인트 리졸버에 복수 주소, 음수 갱신 주기, DNS 인데 갱신 주기 0. `GrpcKubernetesProfile` 정규 생성자는 셋을 막는다 — 메시 라우팅에 in-process 재시도 소유자, 긴 스트림인데 재접속 예산 0, 긴 스트림인데 배수 유예 0. 두 겹의 역할 분담이 이 저장소의 다른 곳에 적힌 규칙과 같다 — 위험한 조합은 정책이 아니라 생성자가 거부하게 만든다. 그리고 그 분담 때문에 검증기의 재시도 소유자 규칙은 일부 조합에서만 발화한다. `MESH` + `GRPC_PLATFORM` 은 생성자가 먼저 던지므로(둘 다 in-process 재시도) 검증기까지 오지 않고, `MESH` + `NONE` 이나 `K8S_VIP` + `SERVICE_MESH` 는 생성자를 통과해 검증기가 잡는다. 도달 불가 분기가 아니라 역할 분담이다. ## 10. 테스트 레인 두 테스트 220줄 · 15개. `GrpcDiscoveryPolicyValidatorTest` 7개 — Stable 리졸버 셋의 열거, Advanced 스킴과 미지 스킴 거부, 주소 수에 따른 권고, 단일 주소 위의 round_robin 보고, 올바른 짝의 통과, 갱신 주기 0 인 DNS 거부, 단일 엔드포인트 리졸버의 복수 주소 거부. `GrpcKubernetesProfileTest` 8개 — 라우팅 모드별 균형자·재시도 소유자, 메시 + in-process 재시도의 생성자 거부, 긴 스트림의 두 필수 값, 세 팩토리의 통과, headless 인데 주소 1개, VIP 인데 긴 스트림, 배수 유예 < 재접속 예산, 라우팅 모드가 함의하는 리졸버 프로파일. **레인에 없는 것 하나.** §4 가 "생성자를 통과해 검증기가 잡는다" 고 설명한 분기 — `retryOwner != routingMode.requiredRetryOwner()` — 를 실제로 발화시키는 테스트가 없다. ```java // GrpcKubernetesProfileValidator:204 if (profile.retryOwner() != profile.routingMode().requiredRetryOwner()) { violations.add(…); } ``` `routingModesImplyTheirOwners` 는 열거형의 `requiredRetryOwner()` 값만 단언하고 검증기를 부르지 않는다. `aMeshProfileMayNotAlsoRetryInProcess` 는 생성자 쪽을 친다. `MESH` + `NONE` 이나 `K8S_VIP` + `SERVICE_MESH` — 두 검증기 분담을 실증하는 조합 — 은 어느 테스트에도 없다. 규칙은 있고 그것을 붙드는 단언이 없다. ## 12. negative-space probes **12.1 도달성.** 이 리프 밖의 production 소비자는 하나뿐이다. | 타입 | leaf 밖 main 참조 | |---|---:| | `GrpcDiscoveryPolicyValidator` | 1 — `GrpcPlatformStartupValidator.validateChannels` | | 나머지 6종 | **0** | 그리고 그 하나의 소비자인 시작 검증기는 시작 시 실행되지 않는다(`grpc-spring-boot-starter` §17.1). 그러므로 Advanced 스킴 거부(`requireStableScheme`)에 도달하는 production 경로가 없다. **12.2 거절 목록은 안전이 아니라 메시지를 위해 있다.** ```java private static final List ADVANCED_SCHEMES = List.of("xds", "consul", "etcd", "eureka"); … if (ADVANCED_SCHEMES.contains(scheme)) { throw new IllegalArgumentException("… Advanced capability …"); } return GrpcResolverType.forScheme(scheme).orElseThrow(() -> new IllegalArgumentException("unknown resolver scheme …")); ``` 두 번째 줄이 이미 허용 목록이다 — `GrpcResolverType` 이 아는 것은 `static`·`dns`·`unix` 셋뿐이고, 그 밖은 전부 `orElseThrow` 로 떨어진다. 그러므로 `xds` 는 거절 목록이 없어도 거부된다. 거절 목록이 하는 일은 **거부 사유를 바꾸는 것**이다 — "unknown resolver scheme" 대신 "Advanced capability with its own control plane and promotion gate". 클래스 javadoc 이 그 구분을 명시한다. > "`xds:///` in a Stable profile is not a configuration mistake to warn about — it is a capability > with its own control plane, its own failure modes and its own promotion gate." 읽는 사람에게 중요한 함의: 다섯 번째 Advanced 스킴 이름을 이 목록에 넣지 않아도 **안전은 유지된다.** 빠지면 나빠지는 것은 메시지의 정확도뿐이고, 그것이 이 목록이 감당하는 유일한 부채다. 기본 거절이 바깥을 지킨다. **12.4 드리프트.** build.gradle 이 서술한 범위(Static/DNS, pick_first/round_robin, VIP/headless/mesh, xDS 거부)가 전부 코드에 있다. 드리프트 없음. ## 16. 확인하지 못한 것 - 실제 DNS 리졸버로 헤드리스 레코드를 조회해 주소 수를 확인하지 않았다. 이 리프는 그 수를 입력으로 받는다. - 시작 검증기를 통한 스킴 거부를 실행으로 확인하지 않았다. 그 검증기가 돌지 않는다. - 테스트를 실행하지 않았다. 15개 전부 본문으로만 확인했다. - §17.3 의 `violations(profile, 0)` 을 실행으로 재현하지 않았다. `resolverProfile` → `GrpcResolverProfile` 정규 생성자 경로로 판정했다. ## 17. 손볼 것 ### 17.1 P3 — 프로파일이 스트림 재접속 예산을 선언하는데 그것이 함의하는 DNS 갱신 주기를 정하지 않는다 `GrpcKubernetesProfile` 은 세 시간 값을 다룬다. ```java streamReconnectBudget // 프로파일이 선언 readinessDrainGrace // 프로파일이 선언 refreshInterval // resolverProfile(...) 이 30초로 하드코딩 ``` ```java public GrpcResolverProfile resolverProfile(int expectedAddressCount) { return new GrpcResolverProfile( GrpcResolverType.DNS, routingMode.loadBalancingPolicy(), Duration.ofSeconds(30), expectedAddressCount); } ``` 검증기는 앞의 둘만 비교한다 — 배수 유예가 재접속 예산보다 짧으면 위반. 셋째는 비교 대상에 없다. 그래서 `headlessStreaming()`(재접속 예산 5초, 배수 유예 30초)에서 갱신 주기는 여전히 30초다. 롤아웃으로 스트림이 끊긴 클라이언트가 5초 예산 안에 재접속하려 할 때, 그 클라이언트의 DNS 캐시는 최대 30초 동안 사라진 파드 주소를 들고 있을 수 있다. 그 실패가 `GrpcResolverProfile` 자신의 javadoc 이 서술한 것이다 — "A channel that resolved once at startup keeps sending to addresses that stopped existing an hour ago; the calls fail with `UNAVAILABLE` and the deployment looks unhealthy long after it finished." 수정은 갱신 주기를 재접속 예산에서 파생시키거나(예: 예산 이하), 검증기에 세 값의 순서 규칙을 추가하는 것이다. ### 17.2 P3 — 리졸버 검증기의 규칙이 하나뿐인데 javadoc 은 복수형으로 서술한다 ```java public static List violations(GrpcResolverProfile profile) { … } // 규칙 1개 ``` javadoc 은 "Checks a discovery configuration for **the things** that look right and are not" 라고 적는다. 실제로 담긴 규칙은 균형 정책의 무의미함 하나다. 나머지 위험 조합은 `GrpcResolverProfile` 정규 생성자가 이미 거부하므로 결과적으로 빈틈은 아니다. 다만 목록으로 보고하는 API 형태와 규칙 하나라는 내용이 어긋나 있어, 다음 사람이 여기에 규칙을 더할 자리로 읽거나 이미 여러 규칙이 있다고 읽는다. §17.1 이 실제로 그 자리다. ### 17.3 P3 — 목록으로 보고하는 검증기가 주소 수 0 에서 던진다 ```java public static List violations(GrpcKubernetesProfile profile, int expectedAddressCount) { … List violations = new ArrayList<>( GrpcDiscoveryPolicyValidator.violations(profile.resolverProfile(expectedAddressCount))); ``` `profile.resolverProfile(n)` 이 `new GrpcResolverProfile(DNS, …, n)` 을 만들고, 그 정규 생성자가 거부한다. ```java if (expectedAddressCount < 1) { throw new IllegalArgumentException("a target resolves to at least one address"); } ``` 그래서 `violations(profile, 0)` 은 빈 목록도 위반 목록도 아닌 `IllegalArgumentException` 이다. 같은 메서드가 `profile == null` 에는 명시적으로 던지고 나머지는 목록으로 답하므로, 호출자는 이 API 를 "던지지 않고 보고한다" 로 읽는다. **왜 0 이 실제 값인가.** `expectedAddressCount` 는 이 리프가 계산하지 않고 입력으로 받는 값이고(§16), 그 출처는 헤드리스 레코드의 DNS 조회 결과다. 롤아웃 중 파드가 모두 교체되는 순간이나 셀렉터가 어긋난 서비스에서 그 답은 0 이다. 그것은 이 리프가 다루는 문제 영역 안의 상태이지 프로그래밍 오류가 아니다 — 그리고 운영자가 가장 보고받고 싶어 할 상태다. `GrpcResolverProfile` 쪽 거부 자체는 옳다. 값 객체가 "주소 0 개인 목표"를 표현하지 않는 것은 §4 의 두 겹 분담과 일치한다. 어긋난 것은 그 위에 얹힌 검증기가 그 예외를 그대로 통과시킨다는 점이다. **수정.** `violations` 가 `expectedAddressCount < 1` 을 먼저 보고 위반 문자열로 보고한 뒤 나머지 검사를 건너뛴다. 그러면 이 리프가 답할 수 있는 가장 중요한 배포 상태 하나가 예외가 아니라 목록의 한 줄이 된다. ### 확인된 설계(문제 아님) - **리졸버의 다중 주소 가능성을 열거형 속성으로 둔 것** — 짝이 맞지 않는 조합을 타입 수준에서 판정할 수 있다. - **위험한 조합을 생성자가 거부하고 애매한 조합만 검증기가 보고하는 두 겹.** - **두 검증기를 분리하고 그 이유를 적은 것.** - **Advanced 스킴을 이름으로 거부하고 그 근거를 적은 것** — 통제 평면과 승격 게이트가 따로 있는 능력이다. - **알 수 없는 스킴을 기본 거절로 둔 것.** - **긴 스트림을 싣는 프로파일에 재접속 예산과 배수 유예를 필수로 만든 것.** - **세 팩토리(`virtualIp` · `headlessStreaming` · `mesh`)가 각자 일관된 조합을 들고 있고, 테스트가 셋 다 위반 0 임을 확인하는 것** — 기본으로 고르는 값이 스스로의 규칙을 만족한다. - **`GrpcRetryOwner.NONE` 이 "아직 정하지 않았다" 와 구분되는 것** — 그 열거형 javadoc 이 "A method whose owner is NONE has been looked at" 라고 적고, 이 리프의 두 겹 분담이 그 값 덕분에 의미를 갖는다(§4). --- ## Source anchors ``` src/grpc/grpc-discovery/build.gradle:1-9 main/java/…/discovery/GrpcKubernetesProfile.java:1-90 main/java/…/discovery/GrpcDiscoveryPolicyValidator.java:1-67 main/java/…/discovery/GrpcResolverProfile.java:1-63 main/java/…/discovery/GrpcKubernetesProfileValidator.java:1-61 main/java/…/discovery/GrpcResolverType.java:1-45 main/java/…/discovery/GrpcKubernetesRoutingMode.java:1-45 main/java/…/discovery/GrpcStableLoadBalancer.java:1-38 test/java/…/discovery/GrpcKubernetesProfileTest.java:1-127 test/java/…/discovery/GrpcDiscoveryPolicyValidatorTest.java:1-93 src/grpc/grpc-policy/…/resilience/GrpcRetryOwner.java:14-33 ```