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>
278 lines
15 KiB
Markdown
278 lines
15 KiB
Markdown
# 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. 모듈의 정체
|
|
|
|
```groovy
|
|
// 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`·`GrpcServerProfile` 은 `grpc-spring-boot-starter` 가 빈으로 만들고, `GrpcAdmissionController` 는 `grpc-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 (규칙이 어댑터를 열거하려고 만든 마커)
|
|
```
|
|
|
|
그리고 이 리프의 테스트는 저장소 파일을 훑지 않는다. 인라인 소스 문자열을 넣는다.
|
|
|
|
```java
|
|
assertThat(rule.violations("DocumentClient.java", applicationSource)).isNotEmpty();
|
|
assertThat(rule.violations("ChannelFactory.java", platformSource)).isEmpty();
|
|
```
|
|
|
|
즉 규칙의 판정 로직은 검증되지만, 저장소의 어떤 파일도 그 판정을 받지 않는다. `GrpcServiceAdapterMarker` 는 규칙이 어댑터를 런타임에 열거할 수 있도록 만든 애너테이션인데, 그것을 붙인 타입도 그것을 읽는 코드도 없다.
|
|
|
|
**수정.** 이 리프의 테스트에 저장소 소스를 훑는 검사를 추가한다 — `src/**/*.java` 를 읽어 `GrpcRawApiImportRule.violations` 를 돌리고 비어 있음을 단언하는 형태다. 규칙이 이미 파일 이름과 소스 텍스트를 받는 서명이므로 재료는 갖춰져 있다.
|
|
|
|
### 17.2 P3 — 원시 API 규칙이 import 문만 보므로 완전 수식 사용과 와일드카드를 놓친다
|
|
|
|
```java
|
|
private static final Pattern IMPORT = Pattern.compile("^\\s*import\\s+(?:static\\s+)?([\\w.]+)\\s*;", MULTILINE);
|
|
…
|
|
if (RAW_API_TYPES.contains(imported)) { violations.add(…); }
|
|
```
|
|
|
|
두 형태가 빠진다.
|
|
|
|
```java
|
|
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 — 빌더 경로에서 순서 규칙 넷 중 셋이 발화할 수 없다
|
|
|
|
```java
|
|
public GrpcServerInterceptorChain build() {
|
|
GrpcServerInterceptorOrder.requireStableOrder(List.copyOf(byStage.keySet()));
|
|
…
|
|
}
|
|
```
|
|
|
|
`byStage` 는 `EnumMap` 이므로 `keySet()` 은 언제나 열거형 선언 순서다. 그리고 `stage(...)` 가 같은 단계의 두 번째 등록을 이미 거부한다.
|
|
|
|
따라서 빌더가 만드는 목록에서는 중복도, 역순도, 예외 경계가 최외곽이 아닌 경우도 발생할 수 없다. 발화 가능한 규칙은 필수 단계 누락 하나다.
|
|
|
|
결함은 아니다 — 나머지 셋은 `violations(List)` 를 직접 부르는 외부 호출자를 위한 것이고, 테스트가 그 경로로 셋을 모두 확인한다. 기록하는 이유는 빌더를 쓰는 조립 코드가 그 셋의 보호를 받는다고 읽기 쉽기 때문이다. 실제 보호는 자료구조가 준다.
|
|
|
|
### 17.4 P2 — 승인 제어기의 세 메서드가 원자적이지 않고, 큐 계수기를 되돌리는 경로가 없다
|
|
|
|
이 리프가 SSOT 이므로 여기에 적는다. `grpc-policy` §17.1 이 이 클래스를 대조군으로 지목하는데, 지목된 쪽 문서에 판정이 없었다.
|
|
|
|
**첫째, 읽고 나서 따로 증가시킨다.**
|
|
|
|
```java
|
|
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` 는 올리지 않는다. 그리고 끝난 호출을 반납하는 메서드는 하나뿐이다.
|
|
|
|
```java
|
|
public void release() {
|
|
if (inFlight.get() > 0) { inFlight.decrementAndGet(); } // ← queued 는 건드리지 않는다
|
|
}
|
|
```
|
|
|
|
따라서 호출자가 `promoteFromQueue()` 를 정확히 한 번 끼워 넣지 않으면 계수기가 어긋난다 — 큐에서 실행된 호출이 끝나면 `queued` 는 그대로이고 `inFlight` 만 줄어든다. `releaseQueued()` 같은 메서드도, 그 짝짓기를 요구하는 서술도 없다.
|
|
|
|
**시험이 이것을 볼 수 없는 이유.** 두 시험 모두 단일 스레드이고, `releaseAndPromotionTrackCapacity` 는 `release()` 와 `promoteFromQueue()` 를 **짝지어** 부른다. 짝짓지 않는 경로는 시험되지 않는다.
|
|
|
|
**등급.** 오늘 호출자가 없으므로(§12.1) P2. 승인 단계를 배선하는 순간 P1 이다 — 부하 아래에서 경계가 새는 것과, 큐 계수기가 단조 증가해 `at capacity` 가 영구히 참이 되는 것이 함께 온다.
|
|
|
|
**수정.** 세 메서드를 비교 후 교체 루프로 바꾸고, 큐 경로에 대응하는 반납 메서드를 두거나 `promoteFromQueue` 를 `release` 안으로 접는다.
|
|
|
|
### 확인된 설계(문제 아님)
|
|
|
|
- **열 단계의 순서와 각 위치의 이유를 열거형 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)
|
|
```
|