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

15 KiB

grpc-server 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 17파일 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-server SSOT owner: grpc-server integration/family document: analysis/20-grpc-platform.md (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • runtime_memberships: [] — build-only
  • vendor: grpc-api(BOM). Netty 의존 없음 — 프로파일은 설정 모델이지 배선이 아니다
파일 LOC
GrpcServerInterceptorChain 110
GrpcRawApiImportRule 108
GrpcAdmissionController 103
GrpcServiceAdapter · GrpcApplicationBoundaryRules 92 · 92
GrpcServerInterceptorOrder 90
GrpcServerProfile · GrpcNettyParityContract · GrpcExecutorProfile 84 · 69 · 66
GrpcNettyVariantSelector · GrpcServerInterceptorStage · GrpcServiceAdapterDescriptor 52 · 51 · 48
GrpcServerTransport · GrpcNettyVariant · GrpcApplicationInvocation · GrpcServiceAdapterMarker · GrpcResponseMapper 38 · 32 · 28 · 25 · 18
test 5파일 730

Coverage ledger

scope count disposition reason
main/java/** 17 FULL_READ 1,106줄 전 본문
test/java/** 5 FULL_READ 730줄
build.gradle 1 FULL_READ 전문
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

// build.gradle:3-6
// Server boundary: the ArchUnit-shaped application boundary rules, the typed service adapter SPI,
// the interceptor order contract, and the Netty server/executor/admission profiles.
//
// The Netty profiles are configuration models, not Netty wiring — no netty dependency here. Real
// Netty lives in `grpc-testkit`'s certification lane, which is where transport evidence is produced.

2. 인터셉터 순서 계약

열 단계이고 선언 순서가 계약이다. 각 위치의 이유가 열거형 javadoc 에 있다.

EXCEPTION_BOUNDARY → TRACE → AUTHENTICATION → ACTOR_TENANT → AUTHORIZATION
→ ADMISSION → DEADLINE_CANCELLATION → IDEMPOTENCY → VALIDATION → SERVICE_ADAPTER
  • 예외 경계가 가장 바깥 — 이후 단계의 실패가 매핑되지 않은 상태로 새지 않는다
  • 인증 → 행위자·소속 → 인가 — 각 단계가 앞 단계의 답을 필요로 한다
  • 승인이 마감보다 먼저 — 부하 중 서버가 일을 쓰기 전에 흘려보낸다
  • 멱등이 검증보다 먼저 — 재생된 요청이 이미 받아들인 본문을 다시 검증하지 않고 저장된 결과를 돌려준다
  • 검증이 어댑터 직전 — 사용 사례는 믿을 수 있는 메시지를 받는다

필수가 아닌 단계는 멱등 하나다 — 상태 변경 키 메서드가 없는 서버에는 할 일이 없기 때문이다.

3. 뒤집기가 이 클래스의 존재 이유다

"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."

inStableOrder()inGrpcRegistrationOrder() 를 나누고, 후자가 전자의 역순임을 테스트가 붙든다.

4. 순서 검증의 근거

"A chain with validation before authentication lets an anonymous caller probe the schema through error messages; one with the exception boundary in the middle lets a throwable from an earlier stage escape as UNKNOWN. Neither shows up in a test of the happy path."

네 규칙이다 — 중복 단계, 필수 단계 누락, 역순, 예외 경계가 최외곽이 아님.

5. 원시 API 차단 규칙

금지 타입 열네 개가 채널·서버·호출을 손으로 만드는 구성 API 다. 금지가 아니라 허용 패키지 목록을 받는다.

"They are legitimate inside the platform and inside generated code, which is why this rule takes an allowlist of packages rather than banning them outright."

그리고 허용 목록이 비면 생성자가 거부한다 — 플랫폼 자신은 어딘가에서 채널을 만들어야 한다.

6. 응용 경계 규칙

금지 접두 열세 개와 금지 타입 셋. 접두만으로 너무 넓은 경우를 위해 정확한 타입 목록을 따로 둔다.

"a transport adapter that calls a repository has moved the use case into the transport, and the next caller of that use case — a scheduled job, a message consumer — either duplicates it or reaches through the controller."

10. 테스트 레인

다섯 테스트. 순서 계약(등록 역순 포함), 프로파일 거부(무제한 큐·in-process production·킵얼라이브·연결 수명), 승인 경계, 어댑터의 매핑, 경계 규칙과 원시 API 규칙을 확인한다.

12. negative-space probes

12.1 도달성. 이 리프의 다섯 타입은 저장소 어디에서도(자기 리프 밖) 참조되지 않는다.

GrpcApplicationBoundaryRules · GrpcRawApiImportRule · GrpcServiceAdapterMarker
GrpcServerInterceptorChain   · GrpcNettyParityContract      → leaf 밖 참조 0

GrpcAdmissionController·GrpcExecutorProfile·GrpcServerProfilegrpc-spring-boot-starter 가 빈으로 만들고, GrpcAdmissionControllergrpc-admin 의 배수 조정자가 협력자로 받는다.

다만 만들어지는 것과 불리는 것은 다르다. 재통독에서 다시 세었다.

tryAdmit()          production 호출 0   (테스트 3곳)
release()           production 호출 0
promoteFromQueue()  production 호출 0

즉 승인 제어기는 빈으로 존재하고 아무 호출도 승인받지 않는다. 그것을 부를 자리인 ADMISSION 인터셉터 단계의 구현이 이 가족에 없기 때문이다(§12.1 의 GrpcServerInterceptorChain 미참조와 같은 원인). §17.4 가 그 첫 호출자가 만나게 될 것을 다룬다.

12.4 드리프트. build.gradle 이 서술한 네 요소가 전부 존재하고, Netty 의존이 없다는 서술도 맞다.

16. 확인하지 못한 것

  • 실제 서버를 세워 인터셉터 사슬을 돌리지 않았다. 이 리프에 서버를 만드는 코드가 없다.
  • GrpcNettyParityContract 가 서술하는 두 변형의 동등성을 실행으로 확인하지 않았다. 그 클래스의 unproven(...) 을 부르는 코드도 저장소에 없다 — grpc-testkit §17.5 와 같은 형태의 평가기다.
  • §17.4 의 경합을 실행으로 재현하지 않았다. 읽기와 증가가 분리되어 있다는 것과 하한 가드가 없다는 것으로 판정했다.
  • gradle.lockfile 은 읽지 않았다(STRUCTURAL_ONLY).

17. 손볼 것

17.1 P2 — 두 아키텍처 규칙이 저장소 소스에 적용되지 않는다

GrpcApplicationBoundaryRules javadoc:

"The list is package prefixes rather than a prose rule, so it can be applied by an architecture test, by a source scan and by a review checklist without three people deciding what 'must not use a repository' covers."

세 적용처 중 저장소에 존재하는 것이 없다.

GrpcApplicationBoundaryRules  leaf 밖 참조 0
GrpcRawApiImportRule          leaf 밖 참조 0
GrpcServiceAdapterMarker      leaf 밖 참조 0   (규칙이 어댑터를 열거하려고 만든 마커)

그리고 이 리프의 테스트는 저장소 파일을 훑지 않는다. 인라인 소스 문자열을 넣는다.

assertThat(rule.violations("DocumentClient.java", applicationSource)).isNotEmpty();
assertThat(rule.violations("ChannelFactory.java", platformSource)).isEmpty();

즉 규칙의 판정 로직은 검증되지만, 저장소의 어떤 파일도 그 판정을 받지 않는다. GrpcServiceAdapterMarker 는 규칙이 어댑터를 런타임에 열거할 수 있도록 만든 애너테이션인데, 그것을 붙인 타입도 그것을 읽는 코드도 없다.

수정. 이 리프의 테스트에 저장소 소스를 훑는 검사를 추가한다 — src/**/*.java 를 읽어 GrpcRawApiImportRule.violations 를 돌리고 비어 있음을 단언하는 형태다. 규칙이 이미 파일 이름과 소스 텍스트를 받는 서명이므로 재료는 갖춰져 있다.

17.2 P3 — 원시 API 규칙이 import 문만 보므로 완전 수식 사용과 와일드카드를 놓친다

private static final Pattern IMPORT = Pattern.compile("^\\s*import\\s+(?:static\\s+)?([\\w.]+)\\s*;", MULTILINE);

if (RAW_API_TYPES.contains(imported)) { violations.add(); }

두 형태가 빠진다.

io.grpc.ManagedChannelBuilder.forAddress("h", 1).build();   // import 없이 완전 수식
import io.grpc.*;                                            // 정확 일치 실패

이것이 가정에 그치지 않는 이유는 이 저장소 자신의 문체다. 같은 가족의 여러 파일이 완전 수식 참조를 본문에 그대로 쓴다.

GrpcConsumerFixture         java.util.regex.Pattern.compile(...)
GrpcAdvancedSupportMatrix   java.util.stream.Collectors.toUnmodifiableMap(...)
GrpcProtoStyleManifest      java.util.Set / java.util.LinkedHashSet 인라인

즉 이 코드베이스에서 완전 수식 사용은 예외가 아니라 흔한 형태다.

규칙 클래스의 자바독은 "there is nothing to reach for" 를 목표로 든다. 지금 형태는 손이 닿는 경로 하나만 본다.

수정은 정규식을 타입 이름의 등장 자체로 넓히거나(오탐이 생기므로 주석·문자열 제거가 필요), 바이트코드 기반 검사로 옮기는 것이다. 후자가 이 저장소의 다른 아키텍처 게이트와 형태가 같다.

17.3 P3 — 빌더 경로에서 순서 규칙 넷 중 셋이 발화할 수 없다

public GrpcServerInterceptorChain build() {
  GrpcServerInterceptorOrder.requireStableOrder(List.copyOf(byStage.keySet()));
  
}

byStageEnumMap 이므로 keySet() 은 언제나 열거형 선언 순서다. 그리고 stage(...) 가 같은 단계의 두 번째 등록을 이미 거부한다.

따라서 빌더가 만드는 목록에서는 중복도, 역순도, 예외 경계가 최외곽이 아닌 경우도 발생할 수 없다. 발화 가능한 규칙은 필수 단계 누락 하나다.

결함은 아니다 — 나머지 셋은 violations(List) 를 직접 부르는 외부 호출자를 위한 것이고, 테스트가 그 경로로 셋을 모두 확인한다. 기록하는 이유는 빌더를 쓰는 조립 코드가 그 셋의 보호를 받는다고 읽기 쉽기 때문이다. 실제 보호는 자료구조가 준다.

17.4 P2 — 승인 제어기의 세 메서드가 원자적이지 않고, 큐 계수기를 되돌리는 경로가 없다

이 리프가 SSOT 이므로 여기에 적는다. grpc-policy §17.1 이 이 클래스를 대조군으로 지목하는데, 지목된 쪽 문서에 판정이 없었다.

첫째, 읽고 나서 따로 증가시킨다.

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();            // ← 같은 형태
    

경계에 있는 N 개 스레드가 모두 통과한다. AtomicInteger 를 쓰면서 비교와 증가를 나눈 형태이고, 같은 가족의 정본이 GrpcRetryBudget.tryConsume 의 비교 후 교체 루프다.

release()·promoteFromQueue() 도 같다 — get() > 0 을 확인한 뒤 별도로 감소시키므로, 두 스레드가 같은 마지막 하나를 보고 둘 다 감소시켜 음수가 될 수 있다. 클래스가 Math.max(0, …) 같은 하한도 두지 않는다.

둘째, 큐 계수기를 되돌리는 경로가 없다.

큐에 들어간 호출도 admitted=true 를 받는다. 그런데 그 경로는 queued 만 올리고 inFlight 는 올리지 않는다. 그리고 끝난 호출을 반납하는 메서드는 하나뿐이다.

public void release() {
  if (inFlight.get() > 0) { inFlight.decrementAndGet(); }   // ← queued 는 건드리지 않는다
}

따라서 호출자가 promoteFromQueue() 를 정확히 한 번 끼워 넣지 않으면 계수기가 어긋난다 — 큐에서 실행된 호출이 끝나면 queued 는 그대로이고 inFlight 만 줄어든다. releaseQueued() 같은 메서드도, 그 짝짓기를 요구하는 서술도 없다.

시험이 이것을 볼 수 없는 이유. 두 시험 모두 단일 스레드이고, releaseAndPromotionTrackCapacityrelease()promoteFromQueue()짝지어 부른다. 짝짓지 않는 경로는 시험되지 않는다.

등급. 오늘 호출자가 없으므로(§12.1) P2. 승인 단계를 배선하는 순간 P1 이다 — 부하 아래에서 경계가 새는 것과, 큐 계수기가 단조 증가해 at capacity 가 영구히 참이 되는 것이 함께 온다.

수정. 세 메서드를 비교 후 교체 루프로 바꾸고, 큐 경로에 대응하는 반납 메서드를 두거나 promoteFromQueuerelease 안으로 접는다.

확인된 설계(문제 아님)

  • 열 단계의 순서와 각 위치의 이유를 열거형 javadoc 에 적은 것.
  • 등록 순서 뒤집기를 클래스로 분리하고 그 이유를 적은 것.
  • 멱등만 선택 단계로 둔 것.
  • 순서 위반의 증상이 정상 경로 테스트에 나타나지 않는다는 근거.
  • 원시 API 를 금지가 아니라 허용 패키지 목록으로 다룬 것.
  • 허용 목록이 빈 규칙을 거부한 것.
  • 접두 목록으로 너무 넓은 경우를 위해 정확한 타입 목록을 따로 둔 것.
  • Netty 의존 없이 프로파일만 두고, 실제 전송 증거를 테스트킷 인증 레인으로 넘긴 것.

Source anchors

src/grpc/grpc-server/build.gradle:1-14
main/java/…/server/GrpcServerInterceptorChain.java:1-110
main/java/…/server/GrpcServerInterceptorOrder.java:1-90
main/java/…/server/GrpcServerInterceptorStage.java:1-51
main/java/…/architecture/GrpcRawApiImportRule.java:1-108
main/java/…/architecture/GrpcApplicationBoundaryRules.java:1-92
main/java/…/architecture/GrpcServiceAdapterMarker.java:1-25
main/java/…/server/(GrpcAdmissionController · GrpcServiceAdapter · GrpcServerProfile · GrpcExecutorProfile · GrpcNettyParityContract · GrpcNettyVariantSelector · GrpcServiceAdapterDescriptor · GrpcServerTransport · GrpcNettyVariant · GrpcApplicationInvocation · GrpcResponseMapper)
test/java/…/(architecture/GrpcApplicationBoundaryRulesTest · server/GrpcServerInterceptorOrderTest · server/GrpcServerProfileTest · server/GrpcServiceAdapterTest · server/GrpcNettyVariantSelectorTest)