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

18 KiB

grpc-spring-boot-starter 완전 해부

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


0. SSOT identity / 커버리지와 숫자 지도

  • runtime_memberships: [] — build-only
  • 선언 의존: api project 7 · implementation project 3 + vendor 2
파일 LOC 성격
GrpcPlatformStartupValidator 188 5개 검증 묶음, static 유틸
GrpcPlatformProperties 139 ca-skeleton.grpc.platform.* 결속
GrpcPlatformAutoConfiguration 106 빈 9개
GrpcPlatformConfigurationException 35 위반 목록 예외
main java 합계 468
AutoConfiguration.imports 1 자동 설정 1개 등록
GrpcPlatformStartupValidatorTest 267 테스트
build.gradle 24 의존 선언

Coverage ledger

scope count disposition reason
main/java/** 4 FULL_READ 188+139+106+35 전 본문
main/resources/META-INF/spring/*.imports 1 FULL_READ 1줄
test/java/** 1 FULL_READ 267줄 · 테스트 12개
build.gradle 1 FULL_READ 24줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체와 격리 규칙

// build.gradle:3-7
// The platform's composition boundary: typed properties, auto-configuration and the startup
// validator that refuses a deployment whose configuration contradicts a Stable invariant.
//
// It must never reach `:grpc-advanced:*`. That is not a comment — the registry's
// allowed_dependencies for this leaf omits every advanced id, `verifyCleanArchitectureDependencies`
// enforces it, and GrpcPlatformStartupValidatorTest asserts the same rule from the Java side.

격리 규칙은 세 겹이다 — 레지스트리, 빌드 검증 태스크, 그리고 자바 쪽 단언. 세 번째는 validateAdvancedIsolationGrpcStableBuildInvariant.requireNoAdvancedDependency 를 부르는 형태다.

2. 자동 설정이 만드는 것

@ConditionalOnProperty(prefix = "ca-skeleton.grpc.platform", name = "enabled", havingValue = "true", matchIfMissing = false) — 기본 꺼짐.

만들어지는 값
GrpcExecutorProfile boundedPool(maxPoolSize, queueCapacity)
GrpcServerProfile stableNetty(executorProfile)
GrpcAdmissionController forExecutor(executorProfile)
GrpcServiceHealthRegistry new …(GrpcHealthPolicy.standalone())
GrpcReflectionPolicy 설정 값이 없으면 defaultFor(environment)
GrpcAdminExposurePolicy standard()
GrpcDrainPolicy stable()
GrpcContextBinder new …(GrpcContextPropagationPolicy.stable())
GrpcErrorMapper new …("grpc-platform", UUID::randomUUID)

전부 정책·프로파일·레지스트리다. 서버도, 인터셉터 사슬도, 서비스 어댑터 등록도 없다.

3. 설정 표면

@ConfigurationProperties(prefix = "ca-skeleton.grpc.platform", ignoreUnknownFields = false).

두 판단이 javadoc 에 적혀 있다.

"Off by default, like every other optional capability in this repository. A platform that starts because its jar is on the classpath is a platform that opens a port on a deployment nobody decided to give one to."

"ignoreUnknownFields = false so a misspelled key fails startup rather than silently leaving a setting at its default."

4. 검증기가 담은 규칙

javadoc 이 선정 기준을 적는다.

"Every rule here is a mistake whose runtime symptom is either silence or a misattributed failure… None of them fails a smoke test."

다섯 묶음이다.

  • 전송·보안 — production 전송이 아니면 거부, 배포 환경에서 TLS 미사용·trust-all·반사 전체 공개 거부
  • 실행기 — 큐 용량 1 미만(무제한) 거부, 풀 크기 양수 요구
  • 메서드 — 단항인데 사용 가능한 마감이 0, 명시적 재시도가 멱등 프로파일과 모순, 멱등 키 필수인데 원장 비활성, Stable 범위 밖 RPC 종류
  • 채널 — Stable 스킴 요구, 두 재시도 소유자가 동시에 in-process 재시도
  • 고급 격리 — Stable 스타터가 advanced 의존을 끌면 위반

그리고 한 번에 전부 모아 실패한다 — "so a deployment learns the whole list in one restart."

10. 테스트 레인

GrpcPlatformStartupValidatorTest 267줄 · 12개. 검증기의 규칙별 거부와 통과를 직접 호출로 확인한다. 자동 설정 컨텍스트를 세우는 테스트는 없다ApplicationContextRunner 도, 슬라이스 테스트도 없다. 빈 아홉 개가 실제로 조립되는지는 이 레인이 답하지 않는다.

aCoherentConfigurationStarts 가 통과 쪽을, 나머지 아홉이 규칙별 거부 쪽을 잡는다 — in-process 전송, TLS 둘, 반사, 무제한 실행기, 원장 없는 멱등 키, client-streaming, 재시도 소유자 둘, advanced 누출. aRefusalNamesEveryViolation 이 세 개를 동시에 깨뜨려 목록이 한 번에 나오는지 본다.

마지막 하나가 형태로 특이하다.

void theAutoConfigurationIsRegisteredAndStable() {
  assertThat(read(Path.of("src/main/resources/META-INF/spring/…imports")).strip())
      .isEqualTo("dev.caskeleton.grpc.boot.GrpcPlatformAutoConfiguration");
  // The dependency declaration, not the word: the build file's own comment says it must never
  // reach an advanced module, and matching on the prose would fail on the sentence stating the rule.
  assertThat(read(Path.of("build.gradle"))).doesNotContain("project(':grpc-advanced");
}

단위 테스트가 자기 모듈의 build.gradle 을 파일로 읽어 의존 선언을 단언한다. 주석이 왜 낱말이 아니라 선언 문법에 맞추는지까지 적어 두었다 — 규칙을 서술한 문장 자체가 낱말 검색에 걸리기 때문이다. verifyCleanArchitectureDependencies 가 도는 것과 별개로 이 레인 안에서도 격리가 붙들린다.

12. negative-space probes

12.1 도달성. 리프 밖에서 이 리프의 타입을 부르는 코드가 0 이고, 이 리프를 의존하는 모듈도 0 이다.

$ grep -rn "grpc-spring-boot-starter" --include=*.gradle src/
(매치 없음)
$ grep -rn "ca-skeleton.grpc.platform" --include=*.yml --include=*.yaml --include=*.properties .
(매치 없음 — 이 리프 자신을 빼고)
$ grep -rn "GrpcPlatformAutoConfiguration\|GrpcPlatformProperties\|GrpcPlatformStartupValidator"       --include=*.java src/ | grep -v grpc-spring-boot-starter
(매치 없음)
타입 production 호출자
GrpcPlatformAutoConfiguration 등록 파일 1줄 — 그러나 이 스타터를 클래스패스에 올리는 모듈이 없다
GrpcPlatformStartupValidator 0
GrpcPlatformConfigurationException 검증기 안에서만

enabled=true 를 쓰는 설정 파일도 저장소에 없다. 즉 @ConditionalOnProperty 가 참이 되는 배포가 지금 하나도 없고, 아홉 빈은 아직 한 번도 만들어진 적이 없다. §17.1 의 등급을 P2 로 둔 근거가 이것이다 — 오늘의 사고가 아니라, 이 스타터를 처음 채택하는 배포가 맞을 상태다.

12.3 선언만 있고 쓰이지 않는 의존 셋.

implementation project(':grpc:grpc-proto-contract')
implementation project(':grpc:grpc-codegen')
implementation project(':grpc:grpc-operation-ledger-jpa')

이 리프의 자바 4파일 어디에도 dev.caskeleton.grpc.contract · …grpc.codegen · 운영 원장 타입의 import 가 없다. operation-ledger-enabledboolean 프로퍼티일 뿐 원장 타입을 참조하지 않는다.

세 의존 모두 build-only 판정 도구다 — 스키마 규칙 엔진, 코드 생성 거버넌스, JPA 원장. 스타터가 그것들을 런타임 조립에 쓰지 않으면서 클래스패스에 끌고 온다. 이 리프의 존재 이유가 "구성 경계"이므로, 경계가 끌어오는 것이 실제로 필요한 것인지가 다른 리프보다 더 중요하다.

같은 사실을 반대편에서도 기록해 두었다 — grpc-codegen §12.1, grpc-proto-contract §12.1.

12.2 설정 키별 소비자.

읽는 곳
enabled @ConditionalOnProperty
executor-queue-capacity · executor-max-pool-size 자동 설정 + 검증기
environment 자동 설정(반사 정책) + 검증기
reflection-mode 자동 설정 + 검증기
transport 검증기뿐
tls-enabled · trust-all-certificates 검증기뿐
operation-ledger-enabled 검증기뿐
default-unary-deadline 없음

12.4 드리프트. build.gradle 이 서술한 세 요소(타입 있는 설정·자동 설정·시작 검증기)가 전부 존재한다. 어긋난 것은 세 번째가 시작 시 돌지 않는다는 점이고 §17.1 이다.

16. 확인하지 못한 것

  • 스타터를 실제 애플리케이션에 올려 컨텍스트를 세우지 않았다. build-only 이고, 이 스타터를 의존하는 모듈이 저장소에 없다(§12.1).
  • verifyCleanArchitectureDependencies 태스크를 이 리비전에서 실행하지 않았다.
  • 테스트를 실행하지 않았다. 12개 전부 본문으로만 확인했다.
  • implementation 의존이 쓰이지 않는다는 것(§12.3)은 패키지 이름 grep 으로 판정했다. 상수나 문자열을 통한 간접 사용이라면 잡히지 않는다.

17. 손볼 것

17.1 P2 — 시작 검증기가 시작 시 실행되지 않는다

GrpcPlatformStartupValidator 를 이름으로 부르는 파일은 둘뿐이다 — 자기 자신과 자기 테스트.

src/grpc/grpc-spring-boot-starter/src/main/java/…/GrpcPlatformStartupValidator.java
src/grpc/grpc-spring-boot-starter/src/test/java/…/GrpcPlatformStartupValidatorTest.java

GrpcPlatformAutoConfiguration 은 빈 9개를 만들고 requireValid 를 부르지 않는다. 초기화 콜백도, @PostConstruct 도, ApplicationRunner 도 없다.

그래서 클래스 javadoc 이 약속한 성질이 성립하지 않는다 — "Refuses to start on a configuration that would be wrong in a way nobody would notice." 지금은 그 설정으로 그냥 시작한다.

함께 사라지는 것. 검증기가 유일한 소비자인 설정 키가 넷이다.

  • transport — production 이 아닌 전송을 거부할 곳이 없다. 게다가 자동 설정은 이 값을 보지 않고 GrpcServerProfile.stableNetty(...) 를 하드코딩한다(§17.2).
  • tls-enabled · trust-all-certificates — 배포 환경의 TLS 바닥을 강제할 곳이 없다.
  • operation-ledger-enabled — 멱등 키 필수 메서드가 원장 없이 열리는 것을 막을 곳이 없다.

같은 저장소가 이 형태를 두 번 기록했다 — WebPlatformStartupValidator 가 시작 시 실행되지 않고, BrokerAclManifest 의 시작 자기점검이 없다. 반대로 messaging 의 StartupProfileValidationInitializingBean.afterPropertiesSet 으로 돌려 그 문제를 이미 한 번 해결했고, fileserver 는 attestMapping() 을 app-bootstrap 의 @Bean 으로 연결했다. 정본이 저장소 안에 둘 있다.

왜 배선되지 않았는지가 서명에 보인다. violations 는 넷을 받는다.

public static List<String> violations(
    GrpcPlatformProperties properties,          // 자동 설정이 @EnableConfigurationProperties 로 가진다
    GrpcMethodPolicyCatalog catalog,            // 이 자동 설정에 빈 정의 없음
    List<GrpcNamedChannelProfile> channelProfiles,  // 빈 정의 없음
    Set<String> stableModuleDependencies)       // 이것을 런타임에 계산하는 코드가 저장소에 없음

넷 중 셋에 생산자가 없다. 특히 마지막은 "스타터가 해석한 모듈 id 집합" 인데, 그것을 실행 중에 산출하는 코드가 저장소 어디에도 없다 — 테스트는 Set.of("grpc-core-api", "grpc-policy", "grpc-server", "grpc-client") 리터럴을 넣는다. 검증기가 요구하는 입력을 구성 경계가 만들지 않으므로, 지금 형태로는 부를 수가 없다.

수정. 자동 설정에 검증기를 부르는 InitializingBean(또는 SmartInitializingSingleton) 빈을 하나 추가하되, 세 입력의 생산자를 함께 정한다.

  • GrpcMethodPolicyCatalog · List<GrpcNamedChannelProfile>ObjectProvider 로 받고 비어 있을 때의 동작(건너뛸지, 그 자체를 위반으로 볼지)을 정한다.
  • stableModuleDependencies — 런타임에 계산할 방법이 없다면 GrpcStableModuleCatalog 가 아는 정적 목록으로 대체하거나, 이 규칙을 빌드 태스크 쪽에만 남기고 검증기 서명에서 뺀다. 지금은 같은 불변식을 세 겹으로 둔다고 §1 이 말하지만, 세 번째 겹이 실행되려면 아무도 만들지 않는 입력이 필요하다.

17.2 P3 — 자동 설정이 transport 를 읽지 않고 전송을 하드코딩한다

@Bean @ConditionalOnMissingBean
public GrpcServerProfile grpcServerProfile(GrpcExecutorProfile executorProfile) {
  return GrpcServerProfile.stableNetty(executorProfile);
}

GrpcPlatformProperties.transportGrpcServerTransport 열거형이고 기본값이 NETTY_SHADED 다. 그 값을 자동 설정이 보지 않으므로 다른 값을 설정해도 만들어지는 프로파일은 같다.

지금은 무해에 가깝다 — 기본값이 하드코딩된 것과 같고, 다른 값은 §17.1 때문에 거부되지도 않지만 반영되지도 않는다. 그러나 설정 키가 존재하고 문서화되어 있으므로 운영자는 그것이 전송을 고른다고 읽는다.

수정은 프로파일 팩토리를 transport 로 분기시키거나, 그 키를 검증 전용임을 자바독에 명시하는 것이다.

17.3 P3 — default-unary-deadline 은 읽는 코드가 저장소에 없다

/** The default deadline applied to a Stable unary method that declares none. */
private Duration defaultUnaryDeadline = Duration.ofSeconds(2);

getDefaultUnaryDeadline() 의 호출자가 0 이다. 검증기도 이 값을 쓰지 않는다 — 검증기가 보는 것은 정책 목록의 policy.deadline().usable() 이고 그 값이 0 이면 위반을 낸다. 즉 자바독이 말하는 "선언하지 않은 메서드에 적용되는 기본 마감" 을 적용하는 코드가 없다.

ignoreUnknownFields = false 라서 이 키를 설정하는 것은 성공하고 아무 효과가 없다.

수정은 그 기본값을 실제로 적용하는 지점을 만들거나(정책 목록 조립 시), 필드를 제거하는 것이다.

17.4 P3 — 반사 모드를 명시하면 서비스·역할 허용 목록이 조용히 하드코딩으로 바뀐다

@Bean @ConditionalOnMissingBean
public GrpcReflectionPolicy grpcReflectionPolicy(GrpcPlatformProperties properties) {
  return properties.getReflectionMode() == null
      ? GrpcReflectionPolicy.defaultFor(properties.getEnvironment())
      : new GrpcReflectionPolicy(
          properties.getReflectionMode(),
          java.util.Set.of("admin"),                     // ← 리터럴
          java.util.Set.of("ROLE_PLATFORM_ADMIN"));      // ← 리터럴
}

두 갈래가 만드는 것이 같은 종류의 값이 아니다.

  • 설정하지 않으면 defaultFor(environment) — 환경이 서비스 목록과 역할 목록을 함께 결정한다.
  • 설정하면 모드만 운영자 것이고, 허용 서비스와 허용 역할은 이 자동 설정에 박힌 리터럴이 된다.

운영자가 조정한다고 생각하는 것은 노출 수위 하나인데, 실제로는 노출 대상 집합까지 바뀐다. 그리고 그 두 리터럴은 설정 표면에 노출되어 있지 않으므로 되돌릴 방법이 reflection-mode 를 다시 비우는 것뿐이다.

ca-skeleton.grpc.platformignoreUnknownFields = false 를 걸어 "오타가 조용히 기본값으로 남지 않게" 한 설정 표면이다. 같은 규율로 보면, 값을 하나 설정했을 때 설정하지 않은 두 값이 함께 바뀌는 것도 같은 종류의 침묵이다.

수정. 허용 서비스·역할을 GrpcPlatformProperties 에 올리거나, 명시 모드에서도 defaultFor(environment) 가 만든 정책의 모드만 바꾼 사본을 쓴다. 후자가 이 저장소의 다른 곳에서 쓰는 형태다(GrpcProtoStyleManifest.allowingWellKnownTypes 처럼 넓힌 사본).

확인된 설계(문제 아님)

  • 기본 꺼짐과 그 근거 — 클래스패스에 있다는 이유로 포트를 여는 플랫폼이 되지 않는다.
  • ignoreUnknownFields = false — 오타가 조용히 기본값으로 남지 않는다.
  • 고급 격리를 세 겹으로 둔 것 — 레지스트리·빌드 태스크·자바 단언.
  • 검증기가 한 번에 전부 보고하는 것 — 재시작 한 번으로 목록 전체를 배운다.
  • 검증 규칙 선정 기준 — 증상이 침묵이거나 오귀인인 실수만 담는다.
  • @ConditionalOnMissingBean 을 아홉 빈 전부에 둔 것 — 채택자가 개별 정책을 갈아끼울 수 있다.

Source anchors

src/grpc/grpc-spring-boot-starter/build.gradle:1-24
main/java/…/boot/GrpcPlatformAutoConfiguration.java:1-106
main/java/…/boot/GrpcPlatformStartupValidator.java:1-188
main/java/…/boot/GrpcPlatformProperties.java:1-139
main/java/…/boot/GrpcPlatformConfigurationException.java:1-35
main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:1
test/java/…/boot/GrpcPlatformStartupValidatorTest.java:1-267