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

20 KiB

grpc-advanced-bootstrap 완전 해부

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


0. SSOT identity / 커버리지

  • allowed_dependencies: ["grpc-core-api"] — Advanced 는 Stable 공개 타입에 의존하고 그 반대는 없다
  • runtime_memberships: [] — build-only
파일 LOC 패키지
GrpcAdvancedFeatureFlags 105 bootstrap
GrpcAdvancedModuleGuard 84 bootstrap
GrpcAdvancedCapability 65 bootstrap
GrpcAdvancedCapabilityDisabledException 42 bootstrap
GrpcCapabilityGrade 38 bootstrap
GrpcAdvancedPromotionGate 85 release
GrpcAdvancedPromotionEvidence 75 release
GrpcAdvancedSupportMatrix 66 release
GrpcAdvancedPromotionDecision 50 release
main 합계 610

Coverage ledger

scope count disposition reason
main/java/** 9 FULL_READ 610줄 전 본문
test/java/** 2 FULL_READ 279줄(150+129) · 테스트 17개
build.gradle 1 FULL_READ 12줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

// build.gradle:3-9
// The Advanced boundary itself: capability grades, the `ca-skeleton.grpc.advanced.*` feature-flag
// contract, the module guard that refuses an unflagged capability, and the per-capability
// promotion gate.
// This leaf depends on Stable public types and never the other way round.

2. 능력 15종과 등급 4종

능력을 하나씩 등급 매기는 것이 설계다.

"Bundling them under one 'advanced' flag makes enabling gRPC-Web — a compatibility bridge with a proxy in front of it — the same decision as enabling xDS, which brings a control plane and its outage modes. They are not the same decision, and a single switch is how the second one gets made by accident."

등급 시작 가능 production 별도 승인
ADVANCED_STABLE 아니오
EXPERIMENTAL
WATCH 아니오
DISABLED 아니오

기본 등급 분포는 ADVANCED_STABLE 11, EXPERIMENTAL 3(HEDGING·CUSTOM_LOAD_BALANCER·XDS), WATCH 1(EDITION_2026)이다.

EXPERIMENTAL 에 두 번째 승인을 요구하는 근거가 적혀 있다.

"The flag says somebody wanted the feature; the approval says somebody accepted that its failure modes are not fully characterised, which is a different person's decision on most teams."

3. 게이트가 세 조건을 순서대로 본다

if (!flags.flagSet(capability))                   "its feature flag is not set"
if (!grade.startable())                           "it is graded WATCH, which cannot start"
if (production && requiresApproval && !approved)  "production needs a separate approval"

"Collapsing them into one boolean produces a 'not enabled' message for three situations with three different remedies."

requireStableStarterIsClean 이 같은 불변식을 런타임에서도 확인한다 — 팻 자, 셰이드 산출물, 테스트 하네스처럼 다른 방식으로 조립된 런타임을 위해서다. 다만 그 메서드를 부르는 런타임이 없다(§12.1). 지금 그 검사를 실행하는 것은 이 리프의 자기 테스트뿐이다.

4. 승격 게이트

증거는 능력마다 따로 기록된다.

"a shared record makes promoting one of them promote whichever others happened to be measured at the same time."

일곱 항목(호환성·보안 검토·고장·성능·ADR·런북·실환경 테스트)과 담금 기간을 본다. 임계값이 둘이다.

ADVANCED_STABLE_SOAK = 7일
STABLE_DEFAULT_SOAK  = 30일

"becoming a Stable default means every deployment gets it, which additionally puts its dependencies on every classpath and its failure modes in every on-call rotation."

그리고 WATCHEXPERIMENTAL 을 먼저 거쳐야 한다.

GrpcAdvancedSupportMatrix.apply 는 결정의 시작 등급이 현재 등급과 다르면 거부한다 — 두 승격이 경합했거나 하나가 재생된 경우다.

10. 테스트 레인

두 테스트 279줄, 17개 테스트. 게이트의 세 조건, 능력별 개별 깃발, WATCH 거부, production 이중 승인, 활성 집합, 승격 임계값 둘, WATCH 선행 규칙, 매트릭스 경합 거부를 확인한다.

capabilitiesDraggedAlong 이 항상 빈 목록을 돌려주고, 그 메서드가 존재하는 이유를 javadoc 이 적는다 — "the method exists so a test can assert that rather than a comment claiming it". 실제로 그 테스트가 있다.

12. negative-space probes

12.1 도달성. Advanced 가족 전체가 배선되지 않는다. 리프 안에서도 절반만 쓰인다.

의존 선언은 다섯이다 — grpc-advanced-diagnostics · -streaming · -compat · -edition · -resilience 가 각각 api project(':grpc-advanced:grpc-advanced-bootstrap').

그중 실제로 타입을 부르는 것은 둘뿐이다.

호출 지점 무엇을
GrpcChannelDiagnosticsPolicy:47,53 GrpcAdvancedModuleGuard.available(flags, CHANNEL_DIAGNOSTICS) · …(flags, XDS)
GrpcXdsStartupGuard:33 GrpcAdvancedModuleGuard.available(flags, XDS)

-streaming · -compat · -edition 은 의존만 선언하고 참조가 없다.

그리고 리프 밖에서 불리는 것은 available 하나뿐이다.

  • GrpcAdvancedModuleGuard.require(...) — 던지는 형태. 외부 호출자 0. 세 갈래 거부 메시지 전체가 자기 테스트에서만 실행된다. GrpcAdvancedCapabilityDisabledException 도 마찬가지다.
  • requireStableStarterIsClean(...) — 외부 호출자 0. javadoc 이 겨냥한 "다르게 조립된 런타임"이 이 검사를 부르지 않는다.
  • advancedModules() — 외부 호출자 0.
  • release 패키지 4파일 276줄 전체 — 리프 밖 참조 0. 승격 게이트·증거·결정·지원 매트릭스를 만드는 곳이 자기 테스트 말고 없다.

즉 이 리프에서 실행 경로에 걸려 있는 것은 GrpcAdvancedCapability · GrpcCapabilityGrade · GrpcAdvancedFeatureFlags · GrpcAdvancedModuleGuard.available 네 조각이고, 나머지 절반은 선언이다.

12.2 대조군. 능력을 하나씩 등급 매기는 형태가 messaging 의 CompatibilityMatrix(어댑터별 STABLE/EXPERIMENTAL/EXTENSION)와 같은 계열이다. 차이는 이쪽이 시작 가능 여부와 production 승인 요구를 등급 자체의 속성으로 둔 점이다.

12.4 드리프트. build.gradle 이 서술한 네 요소(등급·깃발 계약·모듈 가드·승격 게이트)가 전부 존재한다. 드리프트 없음.

16. 확인하지 못한 것

  • verifyCleanArchitectureDependencies 를 이 리비전에서 실행하지 않았다.
  • 등급 재정의로 WATCH 능력을 켜는 것을 실행으로 재현하지 않았다(§17.1). 코드 경로로 판정했다.
  • 테스트를 실행하지 않았다. 17개 전부 본문으로만 확인했다. §17.4 의 담금 역전도 evaluate 본문과 두 테스트가 고른 숫자(60일 · DISABLED)로 판정한 것이다.
  • -streaming · -compat · -edition 이 이 리프를 의존만 하고 쓰지 않는다는 것은 타입 이름 grep 으로 판정했다. 각 리프 SSOT 에서 다시 본다.

17. 손볼 것

17.1 P3 — 등급 재정의에 하한이 없어 "켤 수 없다" 는 등급이 켜질 수 있다

GrpcCapabilityGrade 의 javadoc 이 두 등급을 단정한다.

WATCH     — "Tracked, not implemented. Cannot be enabled."
DISABLED  — "Withdrawn or refused. Cannot be enabled."

그런데 등급은 런타임에 갈아끼울 수 있다.

public GrpcAdvancedFeatureFlags withGrade(GrpcAdvancedCapability capability, GrpcCapabilityGrade grade) {
  grades.put(capability, grade);
  return this;
}

withGrade(EDITION_2026, ADVANCED_STABLE).enable(EDITION_2026) 이면 가드의 두 번째 조건이 통과한다.

등급 올리기 자체는 의도된 기능이다 — 테스트 a deployment may raise a capability's grade on its own evidenceHEDGING(EXPERIMENTAL)을 ADVANCED_STABLE 로 올린다. 문제는 그 재정의에 하한이 없다는 것이다.

  • EXPERIMENTAL 을 올리는 것은 "실패 양식이 충분히 규명되지 않은 것을 감수한다" 는 판단이고 배포가 자기 증거로 내릴 수 있다.
  • WATCH 를 올리는 것은 다르다. 그 등급의 뜻이 "추적할 뿐 구현되지 않았다" 이므로 배포가 가질 자기 증거가 없다.

그리고 승격 게이트는 WATCHEXPERIMENTAL 을 먼저 거쳐야 한다는 규칙을 갖는데, 런타임 재정의는 그 게이트를 지나지 않는다. 같은 리프 안에 문이 둘이고 증거 규칙은 한쪽에만 있다.

수정은 withGrade 가 현재 등급이 startable() 인 능력에만 적용되게 하거나, WATCH·DISABLED 에서 올리는 재정의를 거부하는 것이다.

17.2 P3 — 승격 게이트가 하향 전이도 승격 규칙으로 판정하고, javadoc 이 약속한 거부는 없다

/** @throws IllegalArgumentException when the transition is not one this gate governs */
public static GrpcAdvancedPromotionDecision evaluate(
    GrpcAdvancedPromotionEvidence evidence, GrpcCapabilityGrade from, GrpcCapabilityGrade to) {
  
  if (from == to) { throw new IllegalArgumentException("a promotion changes the grade"); }

던지는 경우는 널과 from == to 둘뿐이다. "이 게이트가 다루는 전이가 아닐 때" 라는 조건에 해당하는 검사가 없다.

그래서 하향 전이가 승격 규칙으로 판정된다.

evaluate(none(XDS), ADVANCED_STABLE, DISABLED)
  → to != ADVANCED_STABLE 이므로 requiredSoak = 30일
  → 증거 일곱 항목 부재 + 담금 부족으로 blockers 여덟
  → 결정: 거부

능력을 철회하려는 결정이 증거 부족을 이유로 막힌다. 방향이 뒤집혀 있다.

지금은 도달성이 낮다 — 이 게이트를 부르는 production 코드가 없고 테스트도 상향 전이만 넣는다. 기록하는 이유는 javadoc 이 그 거부를 이미 약속했다는 점이다.

수정은 to.ordinal() 이 아니라 등급의 서열을 명시한 뒤 상향 전이만 받고 나머지는 던지는 것이다. 철회는 별도 경로가 필요하다.

17.3 P3 — 깃발 홀더가 가변이고 동기화가 없다

GrpcAdvancedFeatureFlags 는 두 EnumMapenable·withGrade 로 갱신하고, available·active 가 같은 맵을 읽는다. synchronized·volatile·동시 자료구조가 없다.

시작 시 전부 설정하고 그 뒤로 읽기만 한다면 안전 공개 문제만 남는다. 다만 두 메서드가 this 를 돌려주는 유창한 형태라 런타임 중 갱신을 권하는 모양이고, active() 는 순회 중 갱신에 노출된다.

같은 저장소가 이 형태를 다른 리프에서 결함으로 기록했다(GrpcCompletionReconciler 의 동기화 없는 ArrayList). 여기서는 등급과 깃발이 요청 경로에서 읽히므로 같은 노출이 생길 수 있다.

수정은 홀더를 불변으로 만들고 enable·withGrade 가 새 인스턴스를 돌려주게 하는 것이다. 이 저장소가 다른 곳에서 쓰는 형태다(GrpcProtoStyleManifest.allowingWellKnownTypes 등).

17.4 P2 — 30일 담금이 열거형에 없는 등급을 위해 쓰였고, 그 결과 WATCH → EXPERIMENTAL→ ADVANCED_STABLE 보다 어렵다

GrpcAdvancedPromotionGate javadoc 의 모형은 등급 둘이다.

"Reaching Advanced Stable means the capability works and is documented; becoming a Stable default means every deployment gets it… The second needs the first plus a longer soak."

그리고 상수도 둘이다.

public static final Duration ADVANCED_STABLE_SOAK = Duration.ofDays(7);
public static final Duration STABLE_DEFAULT_SOAK  = Duration.ofDays(30);

그런데 GrpcCapabilityGrade 의 값은 ADVANCED_STABLE · EXPERIMENTAL · WATCH · DISABLED 넷이다. "Stable default" 라는 등급이 없다.

선택은 이렇게 적혀 있다.

Duration requiredSoak =
    to == GrpcCapabilityGrade.ADVANCED_STABLE ? ADVANCED_STABLE_SOAK : STABLE_DEFAULT_SOAK;

ADVANCED_STABLE 이 아닌 나머지 전부가 30일 갈래로 떨어진다 — EXPERIMENTAL, WATCH, DISABLED. 존재하지 않는 등급을 위해 만든 갈래가 존재하는 세 등급을 삼켰다.

따라오는 역전. missing() 검사도 목표 등급과 무관하게 일곱 항목을 전부 요구한다. 그래서:

전이 필요한 증거 필요한 담금
EXPERIMENTAL → ADVANCED_STABLE 일곱 전부 7일
WATCH → EXPERIMENTAL 일곱 전부 30일

WATCH 능력이 밟도록 강제된 유일한 첫 걸음이(같은 메서드의 셋째 blocker: "it becomes EXPERIMENTAL before anything else") 상위 등급보다 엄격하다. 그리고 WATCH 의 뜻은 "추적할 뿐 구현되지 않았다" 이므로, 정의상 담금 기록이 가장 적은 등급에 가장 긴 담금을 요구한다.

테스트가 이 뒤틀림을 그대로 보여 준다.

@DisplayName("becoming a Stable default needs a longer soak than becoming Advanced Stable")
void theStableDefaultThresholdIsHigher() {
  
  GrpcAdvancedPromotionGate.evaluate(weekLongSoak,
      GrpcCapabilityGrade.ADVANCED_STABLE, GrpcCapabilityGrade.DISABLED)   // ← 철회 전이
      .blockers()   .contains("requires 30");
}

30일 갈래를 실행하려고 고른 전이가 ADVANCED_STABLE → DISABLED, 즉 철회다. 이름은 "Stable default 가 되는 것"이라고 말한다. 겨냥한 등급이 열거형에 없으니 그것을 밟을 방법이 없었고, 남은 것 중 아무거나 골라야 했다는 흔적이다.

그리고 WATCH → EXPERIMENTAL 을 확인하는 테스트는 담금을 60일로 준다.

GrpcAdvancedPromotionEvidence.complete(EDITION_2026, Duration.ofDays(60))

7일로 줬다면 통과하지 않는다. 30일 요구가 레인에 걸리지 않는 이유가 이 숫자 선택이다.

§17.2 와의 관계. §17.2 는 이 갈래의 증상 하나(철회 전이가 승격 규칙으로 판정되는 것)를 기록했다. 원인은 목표 등급별 요구 사항이 없다는 것이고, 그래서 상향 전이 안에서도 순서가 뒤집혔다.

수정. 목표 등급마다 요구 사항을 명시한다.

record Requirement(Set<String> evidence, Duration soak) {}
static Requirement requirementFor(GrpcCapabilityGrade to) {  }   // EXPERIMENTAL 은 더 얕게

STABLE_DEFAULT 를 실제로 표현하려면 등급으로 추가하거나(그러면 GrpcAdvancedCapability 를 떠나 Stable 기본값이 된다는 뜻이므로 별도 개념이 맞다) 이 게이트가 다루지 않는다고 적고 상수를 지운다. 지금은 이름만 있고 대상이 없다.

17.5 P3 — capabilitiesDraggedAlong 은 독립성을 증명하지 않는다. 상수를 상수와 비교한다

/** Always empty, and the method exists so a test can assert that rather than a comment claiming it */
public static List<GrpcAdvancedCapability> capabilitiesDraggedAlong(GrpcAdvancedCapability promoted) {
  if (promoted == null) throw ;
  return List.of();
}

javadoc 이 스스로 밝히듯 본문은 무조건 빈 목록이다. 그것을 단언하는 테스트는 리터럴이 리터럴임을 확인한다 — 증거를 능력마다 따로 기록했다는 §4 의 설계 속성과는 아무 연결이 없다. 설계가 무너져 apply 가 다른 능력의 등급을 바꾸게 되어도 이 메서드는 여전히 빈 목록을 돌려준다.

진짜 증거는 같은 테스트의 다른 줄에 있다.

matrix.apply(evaluate(complete(HEDGING, 7일), EXPERIMENTAL, ADVANCED_STABLE));
assertThat(matrix.gradeOf(HEDGING)).isEqualTo(ADVANCED_STABLE);
assertThat(matrix.gradeOf(GRPC_WEB)).isEqualTo(webBefore);   // ← 이 줄이 독립성을 붙든다

승격을 실제로 적용하고 다른 능력의 등급이 그대로임을 확인한다. 이쪽은 설계가 무너지면 깨진다.

이 문서의 이전 판정을 고친다. 앞선 판에서 이 메서드를 "주석이 주장하는 대신 테스트가 붙든다"는 확인된 설계로 분류했다. 다시 읽으니 붙드는 것은 옆줄이고, 이 메서드는 그 옆줄이 있다는 사실을 가린다.

수정. 메서드를 지우고 단언을 매트릭스 비교 쪽으로 남긴다. 남겨 둔다면 실제로 매트릭스를 훑어 등급이 바뀐 다른 능력을 돌려주게 만든다 — 그때 비로소 이름이 하는 말과 본문이 맞는다.

17.6 P3 — 예외가 들고 있는 능력이 transient 라 역직렬화 뒤 사라진다

public class GrpcAdvancedCapabilityDisabledException extends RuntimeException {
  private static final long serialVersionUID = 1L;
  private final transient GrpcAdvancedCapability capability;
  
  public GrpcAdvancedCapability capability() { return capability; }

transient 는 보통 직렬화 가능하지 않은 필드를 담은 Serializable 클래스에 대한 정적 분석 경고를 끄려고 붙인다. 그런데 열거형은 언제나 직렬화 가능하다 — 여기서 transient 가 막을 문제가 애초에 없다.

대가는 있다. 예외가 직렬화를 거쳐 오면 capability()null 이다. 메시지 문자열은 살아남으므로 사람이 읽는 데는 지장이 없고, 그래서 눈에 띄지 않는다.

등급. 이 예외를 던지는 require 자체가 리프 밖에서 불리지 않으므로(§12.1) 오늘 도달하지 않는다. transient 를 지우는 것이 수정 전부다.

확인된 설계(문제 아님)

  • 능력별 개별 등급과 개별 깃발 — 하나의 스위치가 두 번째 결정을 사고로 만들지 않는다.
  • EXPERIMENTAL 의 production 이중 승인 — 원하는 사람과 감수하는 사람이 다르다.
  • 가드가 세 조건을 순서대로 보고 각각 다른 메시지를 내는 것.
  • 증거를 능력마다 따로 기록한 것 — 공유 기록은 하나의 승격이 다른 것을 함께 올린다.
  • 두 담금 임계값 — 한 배포에서 일주일 돈 것과 모든 배포에 나가는 것은 같은 주장이 아니다.
  • 매트릭스가 시작 등급 불일치를 거부하는 것 — 경합과 재생을 구분해 준다.
  • 빈 컬렉션에 대한 EnumSet.copyOf 함정을 삼항으로 피한 것.
  • GrpcAdvancedSupportMatrix.apply 가 결정의 from 을 현재 등급과 대조하는 것 — 이 저장소가 같은 문제를 반대로 푼 자리가 있어서 대비된다. grpc-codegenGrpcSchemaArtifactPublisher.publish(candidate, decision) 는 결정이 어느 후보를 판정한 것인지 확인하지 않아 짝이 어긋날 수 있다(그쪽 §17.4). 이쪽은 결정이 자기가 밟고 선 상태를 들고 있고 적용 시점에 대조한다 — 같은 형태의 올바른 판본이다.
  • ADVANCED_STABLE 11 · EXPERIMENTAL 3 · WATCH 1 의 분포가 능력 성격과 맞는 것 — 제어 평면을 끌고 오는 XDS, 부하를 복제하는 HEDGING, 이름 해석을 갈아끼우는 CUSTOM_LOAD_BALANCER 만 이중 승인 대상이다.

Source anchors

src/grpc-advanced/grpc-advanced-bootstrap/build.gradle:1-12
main/java/…/bootstrap/GrpcAdvancedFeatureFlags.java:1-105
main/java/…/bootstrap/GrpcAdvancedModuleGuard.java:1-84
main/java/…/bootstrap/GrpcAdvancedCapability.java:1-65
main/java/…/bootstrap/GrpcCapabilityGrade.java:1-38
main/java/…/bootstrap/GrpcAdvancedCapabilityDisabledException.java:1-42
main/java/…/release/GrpcAdvancedPromotionGate.java:1-85
main/java/…/release/GrpcAdvancedPromotionEvidence.java:1-75
main/java/…/release/GrpcAdvancedSupportMatrix.java:1-66
main/java/…/release/GrpcAdvancedPromotionDecision.java:1-50
test/java/…/release/GrpcAdvancedPromotionGateTest.java:1-150
test/java/…/bootstrap/GrpcAdvancedModuleGuardTest.java:1-129
grpc-advanced/grpc-advanced-diagnostics/…/GrpcChannelDiagnosticsPolicy.java:46-53 (available 소비)
grpc-advanced/grpc-advanced-resilience/…/xds/GrpcXdsStartupGuard.java:33-38 (available 소비)