Files
document-haness/docs/clean-architecture-backend-template/analysis/20-grpc-platform.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

52 KiB
Raw Blame History

20. gRPC platform family — 18 leaf 통합 분석

  • 분석 대상 리비전: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 (feat: grpc 기능 deep 구현, 2026-08-31)
  • 직전 기준선: a24ece9cf797f7ea647e33bf846b115208ed1ba5 — 모듈 01~19의 분석 리비전
  • 범위: src/grpc/**(12 leaf) + src/grpc-advanced/**(6 leaf) — modules.json 등록 18개
  • 분모: git 추적 파일 383개 (main Java 260 / 18,726 LOC · test Java 68 · 나머지 build/lockfile/resource/governance)
  • 가족 로컬 권위 문서: src/grpc/CLAUDE.md(117줄) · src/grpc-advanced/CLAUDE.md(77줄)
  • 테스트 레인: 18개 leaf :test 전량 + 증거 레인 3종 — 전부 BUILD SUCCESSFUL

0. 이 문서가 왜 20번인가 — 분석 도중 코드베이스가 이동했다

교차 스코프 분석을 시작하며 레지스트리를 다시 전수로 읽었을 때 등록 모듈이 62개인데 state.json이 추적하던 것은 44개였다. 누락 18개는 전부 gRPC 가족이다.

원인은 분석 누락이 아니라 리비전 이동이다:

$ git log --oneline -3
21234e38 feat: grpc 기능 deep 구현        <- 현재 HEAD (2026-08-31)
a24ece9c feat: web, websocket 어댑터 추가 구현   <- 모듈 01~19의 분석 기준선
01372634 refactor: 각 어댑터터별 리펙토링 진행

$ git diff --stat a24ece9c..HEAD
400 files changed, 40217 insertions(+), 4 deletions(-)

변경 경로는 src/grpc/** · src/grpc-advanced/** · modules.json(18개 항목 추가) · src/build.gradle(테스트 클래스패스 조건에 :grpc:·:grpc-advanced: 추가) · docs 15개뿐이다. src/messaging/ 이하는 한 줄도 바뀌지 않았고, 모듈 01~19가 다룬 어떤 경로도 변경되지 않았다(삭제 4줄은 src/build.gradle의 주석 교체분이다). 따라서 앞선 19개 문서와 그 증거는 그대로 유효하다.

state.jsongitRevision을 HEAD로 올리고 reanalysis 블록을 ADDITIVE_SCOPE로 채웠으며, 18개 스코프를 추가해 이 문서로 닫는다.


1. 분모와 커버리지 원장

1.1 등록 leaf 18개

# leaf 파일 main test main LOC 허용 의존 runtime_memberships
1 grpc-policy 82 62 18 4,781 1 []
2 grpc-core-api 41 32 7 1,897 0 []
3 grpc-testkit 36 26 8 2,313 10 []
4 grpc-advanced-compat 25 17 5 962 5 []
5 grpc-server 24 17 5 1,106 2 []
6 grpc-advanced-resilience 23 16 4 940 5 []
7 grpc-advanced-streaming 20 14 4 833 3 []
8 grpc-admin 19 13 4 913 2 []
9 grpc-client 19 13 4 931 2 []
10 grpc-codegen 16 10 4 725 2 []
11 grpc-advanced-bootstrap 13 9 2 610 1 []
12 grpc-advanced-edition 11 6 2 326 3 []
13 grpc-discovery 11 7 2 409 2 []
14 grpc-proto-contract 11 3 1 605 1 []
15 grpc-advanced-diagnostics 8 4 1 277 3 []
16 grpc-spring-boot-starter 8 4 1 468 10 []
17 grpc-observability 7 4 1 354 1 []
18 grpc-operation-ledger-jpa 7 3 1 276 1 []
합계 381 260 74 18,726

분모 383 = leaf 381 + 가족 공통 문서 2개(src/grpc/CLAUDE.md, src/grpc-advanced/CLAUDE.md). 미배정 0.

18개 전부 runtime_memberships: [] — 가족 전체가 build-only다. 이것이 이 문서의 심각도 축이다(모듈 17 §26.6·모듈 19 §1.1의 원칙 적용). 어떤 배포 아티팩트도 이 코드를 싣고 있지 않으므로, 조립 결함은 오늘의 사고가 아니라 채택 시점의 부채로 기록한다.

1.2 sub-scope 분할

# sub-scope leaf 파일
01 core contracts core-api · proto-contract · codegen 68
02 policy policy 82
03 server · client · discovery server · client · discovery 54
04 admin · observability · ledger · 조립 경계 admin · observability · operation-ledger-jpa · spring-boot-starter 41
05 testkit · 증거 등급 · 릴리스 게이트 testkit + src/grpc/CLAUDE.md 37
06 advanced 6 leaf + src/grpc-advanced/CLAUDE.md 101
합계 18 leaf 383

2. 이 가족이 공개한 주장과 검증 결과

src/grpc/CLAUDE.md는 기계로 검사 가능한 주장을 여러 개 한다. 모듈 19에서와 같이 검증을 먼저 했다.

2.1 "grpc-core-api는 io.grpc를 이름조차 부르지 않는다" → 성립

grep -rn "io\.grpc"              grpc-core-api/src/main → 3
grep -rn "org\.springframework"  grpc-core-api/src/main → 0
grep -rn "com\.google\.protobuf" grpc-core-api/src/main → 0
grep -rn "jakarta\.persistence"  grpc-core-api/src/main → 0
(test 소스는 네 패턴 모두 0)

3건은 전부 javadoc 산문이고, 그 내용이 왜 타입을 쓰지 않는지를 설명한다:

"The canonical gRPC status codes, mirrored so that grpc-core-api stays free of io.grpc. ... a failure context that names io.grpc.Status would put the transport inside the contract that exists to describe what the transport did."

그리고 결정적으로 grpc-core-api/build.gradle이 이렇다:

apply plugin: 'java-library'
dependencies {
}

의존성 블록이 비어 있다. io.grpc가 컴파일 클래스패스에 아예 없으므로 이 제약은 문서가 아니라 빌드가 강제한다. 32개 main 파일 1,897 LOC가 Java stdlib만으로 서 있다.

2.2 "Stable leaf는 :grpc-advanced:*를 참조하지 않는다" → 성립

  • 레지스트리: 비-advanced leaf의 allowed_dependencies에 advanced id가 등장하는 경우 0건.
  • grpc-spring-boot-starterallowed_dependencies = Stable 10개 leaf뿐.
  • 소스: src/grpc 전체에서 dev.caskeleton.grpc.advanced 참조 0건.
  • 문서가 말하는 이중 강제: verifyCleanArchitectureDependencies(build time) + GrpcStableBuildInvariant·GrpcAdvancedModuleGuard.requireStableStarterIsClean(runtime).

단, runtime 절반은 실행되지 않는다. GrpcStableBuildInvariant를 호출하는 프로덕션 경로는 GrpcPlatformStartupValidator.validateAdvancedIsolation 하나이고, 그 validator 자체가 조립에서 호출되지 않는다(§3.1). 따라서 오늘 살아 있는 강제는 build time 한 층이다. 레지스트리 검사가 실효적이므로 규칙 자체는 성립하지만(위 세 확인), "runtime에도 같은 규칙을 강제한다"는 서술은 현재 상태를 서술하지 않는다.

grpc-advanced의 CLAUDE.md가 별도 디렉터리·별도 Gradle prefix를 쓴 이유를 명시한다 — "그 불변 조건을 registry의 allowed_dependencies만으로 기계 검증할 수 있게 하기 위해서."

2.3 "모든 grpc leaf의 runtime_memberships가 비어 있다" → 성립

18개 전부 []. 그리고 adapter-inbound-grpc(모듈 15에서 분석한 leaf)의 allowed_dependencies

["domain-core", "application-core", "shared-contract"]

— 이 가족을 볼 수 없다. CLAUDE.md가 "현재 adapter:inbound:grpc는 이 family에 의존하지 않는다 — registry의 allowed_dependencies를 보라"고 적은 그대로다.

이것이 messaging과의 결정적 차이다. messaging은 messaging-spring-boot-starterapp-bootstrap 의존으로 들어가면서 18개 leaf가 출하 아티팩트에 실렸고, 그 결과 §MSG-015(서로 모르는 두 스택)가 실재 문제가 됐다. gRPC 가족은 아직 그 선을 넘지 않았고, 넘지 않았다는 사실을 문서가 정확히 말한다.

2.4 "GrpcEvidenceGrade가 in-process 결과로 TLS를 주장하는 것을 거부한다" → 성립

public void requireCertifies(String capability) {
  Set<String> certified = certifies();
  if (!certified.contains(capability)) {
    throw new IllegalStateException(
        this + " evidence does not certify '" + capability + "'; it establishes " + ...);
  }
}

CONTRACT가 증명하는 것은 service-adapter·interceptor-order·status-mapping·validation·idempotency-replay·context-propagation 여섯이고, tls·mutual-tls·http2·goaway·keepalive 등은 TRANSPORT에만 있다. 등급별 집합을 필드가 아니라 switch로 계산하는 이유까지 적혀 있다 — "an enum with a collection field is a mutable enum as far as any static analysis can tell."

2.5 "performance lane은 기본 test에서 제외된다" → 성립

tasks.named('test') { useJUnitPlatform { excludeTags 'grpc-performance' } }

근거도 적혀 있다 — "a measurement in the release gate is a flaky test on a shared CI runner; it runs when somebody asks for it, by name."

2.6 지원 매트릭스가 자기 상태를 정확히 말한다 → 성립 (모듈 19와 정반대)

docs/compatibility/grpc-support-matrix.md:

"Not released. Every :grpc:* leaf is runtime_memberships: [] in the module registry, so the platform is build-only: it compiles, its lanes run, and no deployed artifact carries it."

그리고 미해결 릴리스 게이트 입력 두 개(성능 baseline 부재, protoc 미실행에 따른 스키마 codegen)를 스스로 나열한다.

모듈 19 §6.4는 정확히 이 문장의 반대 사례였다 — messaging의 지원 매트릭스는 "모든 leaf가 build-only"라고 적었지만 실제로는 18/25가 출하 중이었고, 가족 CLAUDE.md는 이미 그 문장이 틀렸다고 기록해 두었는데도 운영 문서는 고쳐지지 않았다. gRPC 쪽은 같은 문장이 사실이다.

GrpcCompatibilityMatrix.caSkeleton()의 7개 레인·등급도 문서 표와 전수 일치한다(certified 3 / compatibility 2 / watch 2).

판정: 이 가족의 공개된 주장 6건은 전부 성립한다. 모듈 19에 이어 두 번째 사례이고, grpc-core-api의 빈 dependencies {}는 이 저장소에서 본 가장 강한 형태의 자기 제약이다.


3. 발견

3.1 P2 — GrpcPlatformStartupValidator가 조립에서 호출되지 않는다

src/grpc/CLAUDE.md의 Stable 범위 절:

"GrpcPlatformStartupValidator가 Stable catalog에 streaming method가 등록되면 startup을 거부한다."

이 가족의 유일한 조립 지점은 GrpcPlatformAutoConfiguration이고(.imports 1줄), 그 클래스는 106줄에 @Bean 9개를 등록한다:

GrpcExecutorProfile · GrpcServerProfile · GrpcAdmissionController · GrpcServiceHealthRegistry ·
GrpcReflectionPolicy · GrpcAdminExposurePolicy · GrpcDrainPolicy · GrpcContextBinder · GrpcErrorMapper

GrpcPlatformStartupValidator(188줄)는 그 목록에 없다. 전수 참조:

grpc-spring-boot-starter/src/test/.../GrpcPlatformStartupValidatorTest.java   (12개 호출)
grpc-spring-boot-starter/src/main/.../GrpcPlatformStartupValidator.java       (선언 자신)

main 참조 0. 클래스는 final + private 생성자 + static 메서드(violations(...), requireValid(...))이므로 bean이 될 수도 없다 — 누군가 requireValid를 호출해야 하고, 호출하는 곳이 없다.

실행되지 않는 규칙이 13개다. validator 본문을 읽어 전수 확인했다:

그룹 규칙 거부 사유
transport·security 4 production 트래픽을 받을 수 없는 transport / 배포 환경에서 TLS 비활성 / 배포 환경에서 trustAllCertificates / 배포 환경에서 reflection ENABLED
executor 2 queue capacity < 1 / pool size < 1
methods 4 UNARY인데 deadline이 0 / explicitRetry가 idempotency 프로파일이 허용하지 않는 조합 / IDEMPOTENCY_KEY_REQUIRED인데 operation ledger 비활성 / rpcType이 Stable이 아님
channels 2 지원되지 않는 채널 프로파일 / in-process 재시도 소유자가 둘 이상
advanced isolation 1 Stable starter가 advanced 모듈을 해석함

클래스 javadoc이 13개를 고른 기준을 적는다:

"Every rule here is a mistake whose runtime symptom is either silence or a misattributed failure: a unary method with no deadline hangs until the client's, an unbounded executor turns overload into unbounded latency, trust-all in production reports TLS while providing none, reflection in production publishes the schema, and a keyed method without a ledger accepts idempotency keys it cannot honour. None of them fails a smoke test."

그리고 "Fails once with every violation, so a deployment learns the whole list in one restart" — 한 번에 전부 보고하도록 설계돼 있다.

CLAUDE.md가 인용한 "streaming method가 Stable catalog에 등록되면 거부"는 methods 그룹의 네 번째 규칙(!policy.rpcType().stable())이고, §2.2의 runtime 강제는 advanced isolation 그룹의 유일한 규칙이다. 둘 다 실행되지 않는다.

이 형태는 이 저장소에서 네 번째다 — 모듈 14 §44.2(WebPlatformStartupValidator), 모듈 17 §4.1(WebSocketPlatformStartupValidator), 모듈 19 §3.5(KafkaTransactionProfileValidator), 그리고 여기. 그리고 모듈 18에서 확립한 규칙이 다시 성립한다 — 시작 검증기가 도는지 여부는 그 능력에 자동설정 루트가 있는지와 일치한다. 여기서는 루트가 있는데도 검증기를 부르지 않는 첫 사례다.

채택 시점 실패 시나리오. 팀이 runtime_memberships에 런타임을 추가하고 ca-skeleton.grpc.platform.enabled=true로 켠다. Stable catalog에 client-streaming 메서드를 하나 등록한다(Stable 범위 밖이라는 것을 모른 채). 부팅은 성공한다. 그 메서드는 Stable이 보장하지 않는 경로로 실행되고, grpc-advanced-streaming의 세션·중복제거·체크포인트 기계는 조립돼 있지 않다. 거부했어야 할 검증기는 존재하고, 테스트도 12개 통과하며, 호출되지 않는다.

증거: 268-grpc-assembly-and-release-gate.txt

3.2 P2 — 릴리스 게이트가 스스로 증거를 읽지 않는다. messaging이 이미 고친 모양을 되풀이한다

docs/compatibility/grpc-support-matrix.md:

"GrpcCompatibilityMatrix.caSkeleton()은 이 표의 machine-readable form이고, GrpcStableReleaseGate가 certified lane에 결과가 없거나 실패하면 릴리스를 막는다."

게이트 자체의 설계는 훌륭하다 — missingResults + missingGrades + 스키마 판정 + 런북/ADR/지원매트릭스 존재를 합쳐 blocker 목록을 만들고, 문서 부재를 후속 과제가 아니라 차단 사유로 둔 근거까지 적는다:

"Documents are a blocker rather than a follow-up. ... shipping the behaviour and writing the runbook afterwards means the first person to meet it is the one who has to work it out at three in the morning."

그런데 게이트가 읽는 증거를 아무도 생산하지 않는다.

new GrpcReleaseEvidence(...) 생성 지점:
  grpc-testkit/src/test/.../GrpcStableReleaseGateTest.java:28, 84, 103, 127     <- 전부 테스트

GrpcStableReleaseGate / GrpcCompatibilityMatrix 참조 파일:
  grpc-testkit/src/test/.../GrpcStableReleaseGateTest.java
  grpc-testkit/src/main/.../GrpcStableReleaseGate.java
  grpc-testkit/src/main/.../GrpcCompatibilityMatrix.java

GrpcReleaseEvidence는 record이고 그 다섯 성분 — gradesRun, certifiedCapabilities, runbookPresent, architectureDecisionRecordsPresent, supportMatrixPresent — 이 전부 호출자가 넘기는 값이다. runbookPresent는 파일 시스템을 보지 않고, gradesRun은 레인 출력에서 파생되지 않는다. evaluate(...)에 넘기는 laneResultsMap<String, Boolean>으로 호출자가 만든다.

이것이 messaging 가족이 이미 고친 모양이다. 모듈 19 §6.6이 인용한 CompatibilityMatrix.Entry javadoc:

"Read from the evidence rather than declared. As a field it was a boolean an author set next to the tier, and RabbitMQ carried true while no fault scenario had ever been executed against it."

messaging은 그것을 세 층으로 닫았다 — (a) 레인이 broker-certification-evidence.jsonl쓰고, (b) verifyMessagingCertificationEvidence Gradle 태스크가 실행 산출물과 커밋본을 양방향 대조하며, (c) messaging-certification.ymlsrc/messaging/** PR마다 그 게이트를 돌린다.

gRPC 가족에는 (a)·(b)·(c) 어느 것도 없다:

src/grpc*/*/build.gradle 의 tasks.register  → 0건
.github/workflows 28개 중 grpc를 언급하는 것 → 0건

GrpcStableReleaseGate자기 단위 테스트가 유일한 실행 경로인 클래스다. 지원 매트릭스의 "릴리스를 막는다"는 현재 시제 문장이 그 상태를 서술하지 않는다.

이 가족은 messaging의 MSG-015를 반복하지 않는 것을 목표로 삼았고(§2.3에서 확인했듯 그 목표는 달성했다), 다른 교훈 하나를 옮겨 오지 않았다.

3.3 P2 — 증거 등급 모델 전체가 자동 실행 경로 밖에 있고, CLAUDE.md는 현재 시제로 서술한다

src/grpc/CLAUDE.md:

"현재 in-process·Netty·fault lane은 실제로 실행되어 통과하지만, 실제 배포 환경에서의 soak·performance baseline은 없다."

앞 절반은 사실이다. 직접 돌려 확인했다:

./gradlew :grpc:grpc-testkit:grpcInProcessContractTest \
          :grpc:grpc-testkit:grpcNettyContractTest \
          :grpc:grpc-testkit:grpcFaultTest
→ BUILD SUCCESSFUL, GRADLE_EXIT=0

grpcInProcessContractTest   classes=1 tests=7 failures=0 skipped=0
grpcNettyContractTest       classes=1 tests=9 failures=0 skipped=0
grpcFaultTest               classes=1 tests=9 failures=0 skipped=0

문제는 "실행되어"의 주어다. ca.strict-test-lane.gradle은 레인을 verification 그룹의 Test 태스크로 등록만 하고 check에 연결하지 않는다:

tasks.register(lane.name, Test) { group = 'verification'; ... }
(check dependsOn 관련 라인 → 0건)

그리고 CI에서 grpc를 이름으로 부르는 워크플로가 없다. ci-quality-gates.yml./gradlew check를 돌리므로 각 leaf의 **기본 test**는 CI에서 실행된다(classes=71 tests=579 failures=0 skipped=0으로 통과 확인). 그러나 네 증거 레인은 check에 없고 어떤 워크플로도 이름으로 부르지 않는다.

결과적으로 이 플랫폼의 CONTRACT/TRANSPORT/FAULT 등급을 뒷받침하는 것은 25개 테스트(7+9+9)이고, 그 25개는 누군가 명령을 직접 입력할 때만 돈다.

모듈 18 §4.1c가 ConditionalTransportCompositionContractTest의 javadoc에서 인용한 문장이 그대로 적용된다:

"A gate that is red in a lane nobody runs locally is a gate that reports whatever the last person to run it saw."

차이는 이쪽 레인이 오늘 초록이라는 것이고, 그것을 확인한 방법이 내가 직접 돌린 것이라는 점이다. 자동화된 관찰자는 없다.

(비교: messaging의 인증 레인도 test에서 제외되지만, 전용 CI 워크플로가 게이트를 돌리고 게이트가 레인에 의존한다. gRPC 쪽은 제외만 있고 대체 경로가 없다.)

3.4 P2 — 조립 경계가 정책 객체 9개를 만들고 서버를 만들지 않는다

GrpcPlatformAutoConfiguration이 등록하는 9개는 전부 프로파일·정책·레지스트리다. 서버도, 인터셉터 체인도, 서비스 어댑터 등록도 없다. 그리고 그것을 담당하는 타입들이 main 참조 0이다:

타입 leaf 역할 (javadoc) main 참조 test 참조
GrpcServerInterceptorChain server "Builds the server interceptor chain in the Stable order and hands it over in the order gRPC actually wants" 0 1
ProtovalidateGrpcInterceptor policy 요청 검증 인터셉터 0 1
GrpcIdempotencyInterceptor policy 멱등성 인터셉터 0 1
GrpcServiceAdapter server typed service adapter SPI 0 1
GrpcRetryCoordinator · GrpcRetryOwnershipValidator policy 재시도 소유권 0 1
GrpcStreamAdmission · GrpcSerializedStreamWriter · GrpcStreamGapDetector · GrpcStreamLifecycleCoordinator policy server streaming 단일 writer·갭 탐지 0 1
GrpcDrainCoordinator · GrpcPlatformSnapshotService admin drain·정책 스냅샷 0 1
GrpcTypedStubFactory · GrpcClientCallContext client typed stub·호출 컨텍스트 0 1

GrpcServerInterceptorChain의 javadoc이 자기 존재 이유를 이렇게 적는다:

"That reversal is the reason this class exists rather than a list literal at the call site. ServerInterceptors.intercept wraps each interceptor around the previous one, so the last one passed is the outermost at runtime — the opposite of how the order reads. Every codebase that builds this list by hand gets it backwards at least once, and the symptom is an exception boundary that catches nothing."

그 클래스를 조립에서 쓰는 곳이 없으므로, 채택자가 인터셉터 목록을 직접 만들면 그 javadoc이 서술한 실수를 그대로 하게 된다.

전체로 보면 260개 main 타입 중 73개가 main 참조 0이다. 다만 이 숫자는 그대로 결함 수가 아니다 — build-only 라이브러리 가족에서 공개 API 표면(채택자가 부르는 타입)이 내부 참조를 갖지 않는 것은 정상이다. 위 표는 그중 가족 내부의 다른 코드가 불러야 하는 조립·기계 타입만 골라낸 것이다.

3.5 P3 — 저장소 어디에도 참조가 없는 타입 3개

main = 0이면서 test = 0인 것, 즉 선언 파일 외에 아무 곳에서도 이름이 등장하지 않는 타입:

타입 leaf javadoc이 말하는 용도
ReactiveGrpcClient advanced-compat "Exposes a unary call as a Mono and a server stream as a Flux"
ReactiveGrpcServerAdapter<C,R> advanced-compat "Runs a reactive use case behind a gRPC service adapter"
GrpcDeadlineExceededException core-api "Its own type rather than a generic platform exception because callers branch on it"

앞의 둘은 advanced 가족의 Reactor 표면이고 채택자가 부를 타입이므로 참조 0이 설계와 모순되지는 않는다 — 다만 테스트도 0이라 다른 advanced 타입들과 다르다(나머지 advanced 미참조 타입은 전부 test=1).

세 번째가 더 구체적이다. GrpcDeadlineExceededException은 Stable core-api에 있고, javadoc이 "callers branch on it"이라고 단정하는데 던지는 코드도 잡는 코드도 테스트도 없다. requiresReconciliation()이 "status code가 답할 수 없는 질문에 답한다"고 적혀 있고, 그 메서드를 부르는 곳이 없다.

3.6 P3/기록 — 가족 문서의 grpc-discovery 행이 UDS를 빠뜨린다

src/grpc/CLAUDE.md의 family 표:

grpc-discovery | Static/DNS resolver, pick_first/round_robin, Kubernetes routing profile

코드와 지원 매트릭스는 셋을 말한다:

// GrpcResolverType
/** A Unix domain socket. One endpoint by construction. */
UNIX("unix", false);

// GrpcDiscoveryPolicyValidator:65
"'; Stable schemes are dns, static and unix"
docs/compatibility/grpc-support-matrix.md
| Stable resolvers | Static, DNS, Unix domain socket |

CLAUDE.md 쪽이 덜 완전하다. 드리프트 방향이 과소 진술이므로(있는 능력을 빠뜨림) 위험은 낮다 — 모듈 19 §6.3의 P1은 반대 방향(없는 능력을 있다고 적음)이었다. 기록으로 남긴다.


4. 네 가지 필수 negative-space 탐침

4.1 §8.1 도달성

§3.1·§3.4·§3.5가 결과다. 요약: main 참조 0인 260개 중 73개, 그중 가족 내부가 불러야 할 조립·검증 타입 15종이 §3.4 표와 §3.1이다. build-only 등급이 전부를 채택 시점 부채로 만든다.

4.2 §8.2 조건부 형제 비교

형제 쌍 차이 판정
GrpcPlatformAutoConfiguration(9 bean 등록) vs GrpcPlatformStartupValidator(호출 0) 같은 leaf, 같은 패키지, 4개 파일 중 하나만 조립에 연결 P2 §3.1
messaging 인증 게이트(레인→manifest→Gradle→CI 4층) vs grpc 릴리스 게이트(테스트 1층) 같은 설계, 강제 층이 다름 P2 §3.2
messaging messagingCertificationTest(전용 CI 워크플로) vs grpc 4개 레인(check 밖, CI 0건) 둘 다 test에서 제외, 대체 경로는 한쪽만 P2 §3.3
advanced 미참조 타입 대부분(test=1) vs Reactor 2종(test=0) 같은 leaf 안의 검증 비대칭 P3 §3.5
grpc-core-apidependencies{} vs messaging-core-api(deps 0이지만 build.gradle에 명시 없음) 둘 다 framework-free, grpc 쪽이 더 강한 형태 결함 아님 §2.1

4.3 §8.3 중복 장치 쓸기

이 가족에서는 중복 장치가 발견되지 않았다. 확인한 축:

  • 상태 코드 번역: GrpcStatusCode(core-api, 미러) ↔ GrpcStatusMapping(policy, 양방향 번역) — CLAUDE.md가 "양방향 번역은 grpc-policyGrpcStatusMapping단독으로 소유한다"고 선언하고, 실제로 core-api에는 번역 코드가 없다.
  • 시작 검증: Stable 쪽 GrpcPlatformStartupValidator 1개, advanced 쪽 GrpcAdvancedModuleGuard·GrpcXdsStartupGuard·GrpcServletStartupValidator가 각 capability를 나눠 담당 — 겹치지 않는다.
  • 증거 등급: GrpcEvidenceGrade 하나가 등급을 소유하고 GrpcReleaseEvidence.supports가 그것을 재사용한다 — 두 번째 등급 어휘가 없다.

모듈 19에서 4건(접근 검사·자격 증명 회전·Kafka producer·인증 증거 검증)이 나온 것과 대비된다.

4.4 §8.4 문서·카운트 드리프트

# 주장 실제 판정
1 지원 매트릭스: "Not released … build-only" 18 leaf 전부 rt=[]사실 결함 아님 §2.6
2 지원 매트릭스: 7개 레인·등급표 GrpcCompatibilityMatrix.caSkeleton()과 전수 일치 결함 아님 §2.6
3 지원 매트릭스: Spring Boot 4.0.8 / Stable resolvers Static·DNS·UDS src/build.gradle:13 = 4.0.8, GrpcResolverType에 UNIX 존재 — 일치 결함 아님
4 지원 매트릭스: "GrpcStableReleaseGate가 릴리스를 막는다" 게이트를 호출하는 build·CI 경로 0 P2 §3.2
5 CLAUDE.md: "GrpcPlatformStartupValidator가 startup을 거부한다" main 참조 0 P2 §3.1
6 CLAUDE.md: "in-process·Netty·fault lane은 실제로 실행되어 통과한다" 돌리면 통과(검증함). 자동으로 도는 경로는 없음 P2 §3.3
7 CLAUDE.md: grpc-discovery = Static/DNS 코드·지원매트릭스는 Static/DNS/UDS P3 §3.6
8 state.json 44 스코프 vs 레지스트리 62 모듈 리비전 이동(a24ece9c21234e38)이 원인. 이 문서로 해소 기록 §0

5. 발견 종합 — P1 0건 · P2 10건 · P3 3건

심각도 § 발견 위치
P2 3.1 GrpcPlatformStartupValidator가 유일한 조립 지점에서 호출되지 않는다 (main 참조 0) GrpcPlatformAutoConfiguration
P2 3.2 릴리스 게이트가 읽는 증거를 아무도 생산하지 않는다 — Gradle 태스크 0, CI 워크플로 0. messaging이 이미 닫은 모양의 재발 GrpcStableReleaseGate · GrpcReleaseEvidence
P2 3.3 증거 등급 모델(25개 테스트)이 check 밖·CI 밖이고, 문서는 현재 시제로 서술한다 ca.strict-test-lane.gradle · grpc-testkit/build.gradle
P2 3.4 조립 경계가 정책 객체 9개만 만들고, 인터셉터 체인·서비스 어댑터·스트리밍 기계가 전부 미조립 GrpcPlatformAutoConfiguration
P2 7.1 GrpcAdmissionController.tryAdmit() check-then-act — 조립되는 9개 bean 중 하나이고, 부하 아래에서 지키라고 만든 동시성 경계가 부하 아래에서 샌다. release()는 카운터를 음수로 만들 수 있다 grpc-server
P2 7.2 GrpcStreamAdmission도 같은 TOCTOU. 추가로 perCaller 맵이 caller fingerprint마다 자라고 제거되지 않는다 grpc-policy
P2 7.3 GrpcSerializedStreamWriterDROP_OLDEST버려지는 메시지가 아니라 들어오는 메시지의 바이트를 뺀다. 봉투가 크기를 담지 않아 알 방법이 없고, 테스트는 고정 크기 sizer라 결함이 보이지 않는다 grpc-policy
P2 7.4 GrpcCredentialRotationManager가 CAS 없이 read-then-write — 동시 회전 시 한 세대가 드레인 없이 사라진다. messaging이 CredentialRotationContractTest로 닫은 결함의 재현 grpc-policy
P2 7.5 GrpcOutcomeReplay가 제거·TTL·개수 상한이 하나도 없는 인메모리 저장소. 커밋한 멱등 연산마다 영구 적재 grpc-policy
P2 7.6 GrpcCompletionReconciler.pending이 요청 경로에서 동기화 없이 변경되는 ArrayList grpc-policy
P3 3.5 저장소 어디에도 참조가 없는 타입 3개 (ReactiveGrpcClient·ReactiveGrpcServerAdapter·GrpcDeadlineExceededException) advanced-compat · core-api
P3 3.6 가족 문서의 grpc-discovery 행이 UDS resolver를 빠뜨린다 src/grpc/CLAUDE.md
P3/기록 0 분석 기준선 이후 리비전이 이동해 18개 모듈이 state.json 밖에 있었다 state.json

P1이 0인 이유는 명확하다 — 18개 leaf 전부 runtime_memberships: []이고, 그 사실을 운영 문서가 정확히 공시한다(§2.6). 어떤 배포도 이 코드를 싣지 않으므로 "지금 틀린 동작"이 성립하지 않는다. 다만 §7.1의 GrpcAdmissionController는 조립되는 9개 bean 중 하나이므로, 채택하는 날 가장 먼저 청구되는 부채다.

5.1 검증된 설계 — 8건

  1. grpc-core-apidependencies {} — framework-free가 문서가 아니라 클래스패스로 강제됨 (§2.1)
  2. Stable → advanced 금지가 레지스트리·소스·빌드·런타임 네 층에서 일치 (§2.2)
  3. adapter-inbound-grpc가 이 가족을 볼 수 없다 — messaging MSG-015의 재발 방지가 실제로 성립 (§2.3)
  4. GrpcEvidenceGrade가 in-process 결과로 전송 능력을 주장하는 것을 런타임에 거부 (§2.4)
  5. 성능 레인을 기본 test에서 제외하고 그 이유를 적음 (§2.5)
  6. 지원 매트릭스가 "Not released / build-only"와 미해결 게이트 입력 2건을 스스로 공시 (§2.6)
  7. GrpcCompatibilityMatrix ↔ 문서 표 전수 일치 (§2.6)
  8. 중복 장치 0 — 상태 번역·시작 검증·증거 등급 모두 단일 소유자 (§4.3)

5.2 이 가족의 성격 — 계약은 강하고 조립은 아직 없다

이 가족은 messaging을 명시적으로 참조하며 만들어졌다. src/grpc/CLAUDE.md가 "messaging:*의 MSG-015(bridge 부재)를 반복하지 않는 것이 이 family의 목표"라고 적고, 실제로 그 목표는 달성했다 — adapter-inbound-grpc가 이 가족에 의존하지 않도록 레지스트리가 막고 있고, 그 사실을 문서가 정확히 말한다.

그런데 옮겨 오지 않은 교훈이 하나 있다. messaging이 값비싸게 배운 것은 "게이트는 자기가 검사할 증거를 스스로 읽어야 하고, 그 게이트를 CI가 돌려야 한다"였다(모듈 19 §2.3·§6.6). gRPC 가족은 그 게이트의 설계를 더 정교하게 만들었으면서(4등급 증거, 문서 부재를 blocker로) 강제 배선은 만들지 않았다 — Gradle 태스크 0, CI 워크플로 0, check 연결 0.

그래서 이 가족의 조립 층 P2 네 건은 전부 같은 축에 있다: 판정하는 코드는 잘 만들어졌고, 그것을 부르는 코드가 없다. §3.1(시작 검증기), §3.2(릴리스 게이트), §3.3(증거 레인), §3.4(인터셉터·어댑터)가 모두 그 형태다. build-only 등급이 오늘의 사고를 막고 있고, 채택하는 날 그 넷이 동시에 부채로 청구된다.

그리고 §7이 같은 판정을 구현 층에서 반복한다. 조립 층의 형태가 "부르는 코드가 없다"였다면 구현 층의 형태는 "원자적으로 하지 않는다"다 — AtomicInteger/AtomicReference를 쓰면서 compareAndSet을 쓰지 않는 것이 세 곳, 경계를 선언하고 유지 장치를 두지 않은 것이 두 곳이다. 그리고 두 층 모두, 정확한 참조 구현이 같은 가족 안에 이미 있다.


6. 검증

6.1 테스트 레인

./gradlew (18개 grpc leaf의 :test 전량) --console=plain
→ BUILD SUCCESSFUL in 1m 12s · 81 actionable tasks · GRADLE_EXIT=0
   XML 집계: classes=71 tests=579 failures=0 errors=0 skipped=0

./gradlew :grpc:grpc-testkit:grpcInProcessContractTest \
          :grpc:grpc-testkit:grpcNettyContractTest \
          :grpc:grpc-testkit:grpcFaultTest --console=plain
→ BUILD SUCCESSFUL · GRADLE_EXIT=0
   grpcInProcessContractTest  classes=1 tests=7 failures=0 skipped=0
   grpcNettyContractTest      classes=1 tests=9 failures=0 skipped=0
   grpcFaultTest              classes=1 tests=9 failures=0 skipped=0

Netty 레인이 실제 소켓을 열고 통과한다 — 컨테이너 안에서도 재현된다.

돌리지 않은 레인: grpcPerformanceTest. 공유 러너에서의 측정이 flaky 게이트가 된다는 이유로 test에서 제외돼 있고(§2.5), 분석 컨테이너의 측정값은 baseline이 될 수 없다.

6.2 소스 트리 변경 없음

git status --short → (출력 없음)

6.3 커버리지 원장

이 문서의 읽기 깊이는 앞선 모듈들보다 얕다. 그 사실을 숫자로 적는다.

FULL_READ는 파일 전문 또는 그에 준하게 읽은 것만 센다. 나머지는 전부 STRUCTURAL_ONLY이며, 그 근거는 (a) 파일·패키지 전수 목록, (b) 260개 main 타입 전수 도달성 스윕(선언 파일 제외 참조 수를 main/test로 분리 계수), (c) 레지스트리·build.gradle·.imports 전수 판독, (d) 18 leaf :test 전량 + 증거 레인 3종 실행이다. 즉 조립·경계·거버넌스 층은 전수로 확인했고, 각 leaf의 구현 내부는 읽지 않았다.

sub-scope leaf 파일 FULL_READ 전문으로 읽은 것
01 core contracts 3 68 3 grpc-core-api/build.gradle · GrpcStatusCode(javadoc+상수) · GrpcDeadlineExceededException(헤더)
02 policy 1 82 21 idempotency 4 · streaming 5 · resilience 3 · security 2 · deadline 1 · validation/policy/error/context 전수 스윕 + 후보 본문 확인 (§7)
03 server·client·discovery 3 54 5 GrpcServerInterceptorChain(헤더+javadoc) · GrpcResolverType/GrpcDiscoveryPolicyValidator(해당 행)
04 admin·observability·ledger·조립 4 41 6 GrpcPlatformAutoConfiguration(106줄 전문) · GrpcPlatformStartupValidator(188줄 전문) · .imports
05 testkit·증거·릴리스 1+1 37 5 GrpcEvidenceGrade · GrpcStableReleaseGate · GrpcReleaseEvidence · grpc-testkit/build.gradle · src/grpc/CLAUDE.md
06 advanced 6+1 101 5 src/grpc-advanced/CLAUDE.md · ReactiveGrpcClient(헤더) · ReactiveGrpcServerAdapter(헤더)
18 383 45

STRUCTURAL_ONLY 338 · EXCLUDED 0 · 미배정 0.

(2026-08-31 보강: 최초 기재는 FULL_READ 16이었다. §7의 구현 내부 판독으로 29개가 추가됐다 — grpc-policy 21, 나머지 leaf 8. leaf 귀속분 43 + 가족 거버넌스 문서 2 = 45.)

이 깊이로 확정할 수 있는 것과 없는 것:

  • 확정됨 — §2(공개 주장 6건), §3.1~§3.4(조립·릴리스 게이트·증거 레인·인터셉터 미조립), §3.5(참조 0 타입), §4.1~§4.4. 이 판정들은 전부 도달성·조립·빌드 구성에 대한 것이고 근거가 전수다.
  • §7로 확정됨grpc-policy의 동시성·경계 층. 6건이 나왔고 2건은 검증 중 철회했다(§7.7).
  • 여전히 확정되지 않음 — 읽기의 초점이 동시성과 경계였으므로, 각 leaf의 도메인 로직 정확성은 그 초점 밖이다. 구체적으로 grpc-proto-contract의 스키마 규칙 판정(3 main / 605 LOC), grpc-codegen의 매니페스트 해시 규약, grpc-advanced-resilience의 hedging 적격성·xDS 실패 정책, grpc-advanced-compat의 Servlet/gRPC-Web 프로파일 판정은 구조와 도달성만 확인했다.

비교를 위해: 모듈 19(messaging)는 550 파일에 1,284줄 문서, 모듈 14(web)는 638 파일에 1,702줄이었다. 이 문서는 383 파일에 (§7 보강 후) 약 700줄이다.

6.4 증거

evidence/raw/266268 (3개 신규). 264·265는 교차 스코프 준비 중 생성됐고, 264는 리비전 이동 이전 트리 상태에서 만들어져 .imports 7개·leaf 44개를 담고 있다 — 이동 이후 값은 .imports 8개·leaf 62개이며 265268이 현재 상태를 담는다.


7. 구현 내부 판독 (2026-08-31 보강)

§6.3이 미독으로 기록한 층 — grpc-policy 62 main / 4,781 LOC, grpc-server·grpc-client·grpc-admin의 상태 보유 클래스, grpc-advanced-streaming·-bootstrap — 을 읽었다. 동시성과 경계(bound)에 초점을 두었다. 앞선 모듈에서 상태·펜싱·순서 결함이 나온 층이 정확히 여기이기 때문이다.

방법: (a) 가족 전체 main 소스에서 mutable 컬렉션·카운터를 보유한 클래스를 전수 추출하고 각 파일의 동기화 마커 수를 병기, (b) get() 비교 후 increment/set을 수행하는 check-then-act 패턴 전수 추출, (c) 키가 늘기만 하고 제거가 없는 컬렉션 전수 추출. 그 뒤 각 후보를 본문으로 확인했다.

7.1 P2 — GrpcAdmissionController.tryAdmit()의 동시성 경계가 동시성 아래에서 성립하지 않는다

이 클래스는 조립된다GrpcPlatformAutoConfiguration의 9개 bean 중 하나(grpcAdmissionController)다.

public Decision tryAdmit() {
  int running = inFlight.get();
  if (running < maxConcurrentCalls) {
    inFlight.incrementAndGet();          // 검사와 증가 사이가 열려 있다
    return new Decision(true, ...);
  }
  int waiting = queued.get();
  if (waiting < maxQueuedCalls) {
    queued.incrementAndGet();
    return new Decision(true, ...);
  }
  ...
}

AtomicInteger를 쓰지만 원자적 연산은 하나도 하지 않는다. get()으로 읽고 비교한 뒤 별도로 incrementAndGet()한다. 경계에 있는 N개 스레드가 모두 같은 running을 읽고 모두 통과해 모두 증가시킨다 — inFlightmaxConcurrentCalls를 최대 N1만큼 초과한다.

클래스 javadoc이 존재 이유를 이렇게 적는다:

"Rejecting with RESOURCE_EXHAUSTED is a better outcome than queueing for two reasons that both matter under load... A server that queues instead spends its capacity finishing requests nobody is reading."

부하 아래에서 지키라고 만든 경계가 부하 아래에서 새는 구조다. 동시 요청이 없을 때는 정확하고, 있을 때 부정확하다.

해제 쪽도 같다:

public void release() {
  if (inFlight.get() > 0) { inFlight.decrementAndGet(); }
}

inFlight == 1일 때 두 스레드가 동시에 release()하면 둘 다 > 0을 통과해 둘 다 감소시켜 1이 된다. 그 뒤로는 running < maxConcurrentCalls가 한 칸 더 쉽게 통과하므로 경계가 영구적으로 느슨해진다.

promoteFromQueue()는 한 단계 더 나아간다 — queued를 줄이고 inFlight를 늘리면서 inFlight를 경계와 대조하지 않는다. 큐에서 승격되는 호출은 동시성 한도를 무조건 통과한다.

같은 가족이 올바른 형태를 이미 갖고 있다. GrpcRetryBudget은 정확한 CAS 루프다:

public boolean tryConsume() {
  while (true) {
    long observed = tokens.get();
    if (observed < tokensPerRetry) return false;
    if (tokens.compareAndSet(observed, observed - tokensPerRetry)) return true;
  }
}

grpc-policy의 예산은 CAS로 닫혀 있고 grpc-server·grpc-policy의 두 admission은 check-then-act다.

7.2 P2 — GrpcStreamAdmission도 같은 형태이고, per-caller 맵이 줄지 않는다

public boolean tryAdmit(String callerFingerprint) {
  AtomicInteger callerCount = perCaller.computeIfAbsent(callerFingerprint, key -> new AtomicInteger());
  if (callerCount.get() >= maxStreamsPerCaller) return false;
  if (openStreams.get() >= maxConcurrentStreams) return false;
  callerCount.incrementAndGet();
  openStreams.incrementAndGet();
  return true;
}

§7.1과 동일한 TOCTOU이고, 이쪽은 javadoc이 서술하는 실패 시나리오가 곧 고동시성 상황이다:

"Without a bound, a client that reconnects on every error opens streams faster than the old ones close."

재접속 폭풍은 정의상 동시 요청이 몰리는 상황이고, 그때 경계가 가장 많이 샌다.

release()get() > 0decrementAndGet()이라 음수로 갈 수 있다.

그리고 perCaller에서 엔트리가 제거되지 않는다. computeIfAbsent가 caller fingerprint마다 AtomicInteger를 하나 만들고, release()는 값을 줄일 뿐 키를 지우지 않는다. 서로 다른 caller 수만큼 맵이 자라고 줄지 않는다 — 이 가족이 GrpcMetricCardinalityPolicy로 지표 태그에 대해 명시적으로 막는 것과 같은 종류의 증가이며, 여기에는 그 가드가 없다.

7.3 P2 — GrpcSerializedStreamWriterDROP_OLDEST가 잘못된 메시지의 바이트를 뺀다

case DROP_OLDEST -> {
  GrpcStreamEnvelope<T> dropped = queue.pollFirst();
  if (dropped != null) {
    queuedBytes = Math.max(0L, queuedBytes - nextBytes);   // nextBytes = 들어오는 메시지 크기
    droppedMessages++;
  }
  enqueue(kind, payload, snapshotVersion, resumeToken, nextBytes);
  yield GrpcStreamWriteResult.DROPPED;
}

버려지는 것은 dropped인데 빼는 값은 **새로 들어오는 메시지의 크기 nextBytes**다. GrpcStreamEnvelope는 7개 성분(streamId·sequence·kind·snapshotVersion·resumeToken·terminationReason·payload) 중 크기를 담지 않으므로, 이 지점에서 버려지는 메시지의 크기를 알 방법이 애초에 없다.

queuedBytes는 장식이 아니라 판정 입력이다:

// GrpcFlowControlPolicy.decide
boolean overflowsBytes = queuedBytes + nextMessageBytes > maxQueuedBytes;

그리고 그 바이트 경계의 존재 이유가 javadoc에 있다:

"Both a message count and a byte count, because either alone is unbounded in the other dimension: a thousand-message bound with no byte bound is a memory limit set by the largest message anyone ever sends."

실패 시나리오. DROP_OLDEST 프로파일 + 가변 크기 메시지. 10바이트 메시지를 버리며 10,000바이트를 넣으면 queuedBytes는 10,000을 빼고 10,000을 더해 변화 없음인데 실제 큐는 9,990바이트 늘었다. 반복되면 queuedBytes가 실제보다 계속 낮아지고(Math.max(0, ...)로 0에서 멈춘다) 바이트 경계가 발화하지 않게 되어, 큐는 메시지 수 경계까지 임의 크기 메시지로 채워진다 — 바이트 경계가 막으려던 바로 그 상태다. 반대 방향(큰 것을 버리고 작은 것을 넣음)에서는 과대 계상돼 조기 TERMINATE가 된다.

테스트가 이 결함을 볼 수 없는 구성으로 되어 있다. GrpcSerializedStreamWriterTest의 lossy 케이스는

writer(new GrpcFlowControlPolicy(1, 1024L, 1, GrpcSlowConsumerPolicy.DROP_OLDEST), 8L)

— sizer가 상수 8L이라 모든 메시지 크기가 같고, maxQueuedMessages=1이라 발화하는 것은 개수 경계다. 크기가 같으면 잘못된 뺄셈이 우연히 옳은 값이 된다.

(Stable 기본값 GrpcFlowControlPolicy.stable()TERMINATE이므로 기본 경로는 영향을 받지 않는다. DROP_OLDEST는 opt-in 손실 허용 프로파일이다.)

7.4 P2 — GrpcCredentialRotationManager가 CAS 없이 read-then-write 한다. messaging이 고친 결함의 재현이다

public RotationPlan rotate(GrpcCredentialGeneration next, Instant now) {
  State observed = state.get();                                  // :89
  ...
  state.set(new State(next, observed.current(), deadline));       // :103
}
public void completeDrain() {
  State observed = state.get();                                  // :119
  state.set(new State(observed.current(), null, null));           // :120
}

AtomicReference를 쓰지만 compareAndSet·updateAndGet한 번도 없고 synchronized도 없다. 순수한 홀더로만 쓰인다.

두 회전이 동시에 일어나면 둘 다 같은 observed를 읽고 둘 다 supersededBy를 통과해 둘 다 set한다. 나중 것이 앞선 것을 덮으므로 한 세대가 draining에 오르지 못한 채 사라진다 — 그 세대 위의 in-flight 호출은 추적되지도, 드레인되지도 않는다. completeDrain()rotate()가 겹치면 새로 draining이 된 세대가 즉시 잊힌다.

이 클래스의 javadoc이 그 경합을 이미 알고 있다:

"@throws IllegalArgumentException when next does not supersede the current generation — a rotation that goes backwards would reactivate material that was already replaced, and the usual reason for one is two rotators racing"

경합의 존재를 적어 두고, 그 경합을 닫는 연산은 쓰지 않았다.

그리고 이것은 messaging이 이미 고친 결함이다. 모듈 19 §5.4가 인용한 CredentialRotationContractTest:

"resolve was get → fetch → put → clear with no synchronization. Two callers rotating the same credential both read the same old runtime and both fetched a replacement: one replacement was dropped from the map without ever being cleared — a secret left in memory that nothing owns — and the loser could clear material the winner was still using."

같은 주제(자격 증명 회전), 같은 결함 형태(공유 상태에 대한 read-then-write), 한 가족은 동시성 계약 테스트까지 만들어 닫았고 다른 가족이 재현했다. grpc-policy의 테스트 16개 중 동시성을 다루는 것은 없다.

7.5 P2 — GrpcOutcomeReplay가 제거 경로 없는 인메모리 저장소다

private final ConcurrentMap<String, byte[]> storedOutcomes = new ConcurrentHashMap<>();
  • maxInlineBytes엔트리 하나의 크기를 제한한다. 엔트리 개수를 제한하는 것은 없다.
  • remove·clear·evict·TTL이 하나도 없다(전수 grep 0건). size()만 있고 그 값을 읽는 곳도 없다.
  • store()IDEMPOTENCY_KEY_REQUIRED 메서드가 커밋될 때마다 호출되므로, 프로세스 수명 동안 커밋한 멱등 연산 수만큼 엔트리가 쌓인다.

javadoc은 "a small inline store"라고 부르지만 작게 유지하는 장치가 없고, 크기를 넘는 응답은 거부하면서("store it behind an object reference instead") 개수는 거부하지 않는다.

비교 대상이 같은 leaf 안에 있다. GrpcClientMessageDeduplicator는 정확히 이 문제를 피하려고 설계됐고 그 이유를 적는다 — "A set grows without bound for the life of a session... a monotonic applied-sequence answers it in constant space" — 그리고 endSession()으로 두 맵을 모두 정리한다. 같은 가족에서 한쪽은 정리하고 한쪽은 하지 않는다.

7.6 P2 — GrpcCompletionReconciler가 요청 경로에서 동기화 없는 ArrayList를 변경한다

private final List<PendingCase> pending = new ArrayList<>();          // :25
...
pending.add(new PendingCase(...));      // reconcile(...) 안 — 요청 경로
List.copyOf(pending);                   // pendingCases()
pending.remove(resolved);               // clearPending(...)

synchronized·Concurrent*·volatile·Lock 전부 0건이고, 단일 스레드 전용이라는 javadoc 표기도 없다. 이 leaf에서 스레드 안전성을 명시적으로 다루는 유일한 클래스는 GrpcSerializedStreamWriter이며(그쪽은 9개 마커로 제대로 닫혀 있다), 그 사실이 이 leaf가 동시성을 인지하고 있음을 보여준다.

reconcile(...)은 완료 결과가 불확실한 호출마다 불린다 — 장애 상황에서 동시에 몰리는 경로다. ArrayList에 대한 동시 add는 원소 유실 또는 ArrayIndexOutOfBoundsException이고, add 중의 List.copyOfConcurrentModificationException 또는 null 원소로 인한 NPE다. 그리고 pending이 담는 것은 결과를 알 수 없어 사람이 조정해야 하는 연산 목록이므로, 유실은 조정되지 않은 채 잊히는 연산이 된다.

7.7 검증 중 철회한 판정 2건

읽기 전 후보로 잡았다가 본문 확인 후 취소한 것들이다. 기록해 둔다.

후보 왜 취소했나
GrpcChannelRuntime.draining이 비-volatile이라 드레인 신호가 요청 스레드에 안 보일 수 있다 private volatile boolean draining — 이미 volatile이다. 남는 것은 release 계열의 check-then-act뿐이고, 그 경우 카운터가 음수가 되면 quiescent()가 영원히 false가 되어 드레인이 끝나지 않는 쪽으로 실패한다(조기 완료가 아니라). 호출자의 이중 해제를 전제하므로 별도 결함으로 세지 않는다
GrpcClientMessageDeduplicator의 두 ConcurrentMap이 무한 증가한다 endSession(sessionId)checkpoints.remove(...)replayableOutcomes.keySet().removeIf(...)로 둘 다 정리한다. 자동 스윕에서 제외로 잡힌 것이 맞았고 내가 과독했다

7.8 확인된 올바른 설계 (구현 층)

  1. GrpcResumeTokenCodec — 상수 시간 비교(MessageDigest.isEqual), 알 수 없는 key id를 현재 키로 폴백하지 않고 거부("turns key rotation into a window in which a token signed by a compromised key still verifies"), malformed·unknown key·verify 실패를 구별 불가능하게 반환("telling them apart is a probing oracle"). 그리고 GrpcResumeToken.requireBounded가 모든 문자열 필드에서 구분자 |를 명시적으로 거부해 인코딩/디코딩 비대칭이 생기지 않는다.
  2. GrpcCancellationCoordinator — 5개 메서드 전부 synchronized. 등록이 취소 이후 실패하는 것을 "no new external side effect after cancel"의 구현으로 삼고, markCommitBoundaryCrossed()로 커밋 이후 취소를 abort로 오해하지 않게 분리한다.
  3. GrpcRetryBudget — 정확한 CAS 루프. 성공이 토큰을 상한까지 회복시켜 "실패가 전면화되면 재시도가 사실상 0으로 수렴"하는 성질을 만든다.
  4. GrpcRetryCoordinator — 검사 순서가 고정(자격 → 설정 → status → 시도 수 → 남은 데드라인 → 백오프 후 잔여 → 예산)이고, 예산을 마지막에 소모한다. 어차피 거부할 재시도에 예산을 쓰지 않는다.
  5. GrpcRetryOwnershipValidator — 서비스 설정의 메서드 이름을 카탈로그와 대조한다. "rename CreateDocument to CreateDocumentV2 and the entry stops matching, silently."
  6. GrpcMetricCardinalityPolicy.retryBucket(attempts) — 시도 횟수를 버킷으로 접어 태그 카디널리티를 제한한다.

7.9 이 층의 성격

여섯 건 중 넷이 같은 형태다 — AtomicInteger/AtomicReference를 쓰면서 원자적 연산을 하지 않는 것(§7.1·§7.2·§7.4)과, 경계를 선언하고 그 경계를 유지하는 장치를 두지 않는 것(§7.2의 per-caller 맵·§7.5). 그리고 같은 가족 안에 정확한 참조 구현이 이미 있다 — 예산은 CAS 루프로, 스트림 라이터는 synchronized로, 중복제거기는 endSession으로 닫혀 있다.

§5.2가 조립 층에 대해 내린 판정이 구현 층에도 그대로 적용된다: 판정하는 코드는 잘 만들어졌고, 그것을 정확히 실행하는 부분이 빠져 있다. 조립 층에서는 "부르는 코드가 없다"였고, 구현 층에서는 "원자적으로 하지 않는다"다.

여전히 P1이 없는 이유는 §5와 같다 — 18 leaf 전부 runtime_memberships: []이고 어떤 배포도 이 코드를 싣지 않는다. 다만 §7.1의 GrpcAdmissionController조립되는 9개 bean 중 하나이므로, 채택 시점에 가장 먼저 청구되는 부채다.