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

290 lines
22 KiB
Markdown

# adapter-inbound-grpc — 코드베이스 분석
## SSOT identity — 2026-08-31 재검증
- registered leaf id: `adapter-inbound-grpc`
- canonical state `analysisFile`: `analysis/15-adapter-inbound-grpc.md` (이 문서) — 이 leaf의 단일 SSOT
- source path: `src/adapter/inbound/grpc` · Gradle `:adapter:inbound:grpc`
- registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract"]`
- registry `runtime_memberships`: **`[]`**
- coverage ledger: `FULL_READ` **18** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
---
> **분석 대상** `src/adapter/inbound/grpc` · revision `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
> **분모** 18 tracked files (main 8 · test 6 · governance 4) — 단일 bounded scope
> **LOC** main Java 602 · test Java 782
> **근거** `evidence/raw/204-inbound-grpc-probes.txt` (`file_count=18`)
## 1. 커버리지 원장
| # | scope | main | test | governance | 합 | 상태 |
|---|---|---:|---:|---:|---:|---|
| 1 | 모듈 전체 (단일 bounded scope) | 8 | 6 | 4 | 18 | **COMPLETE** |
`FULL_READ 18 / 18 · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0`.
지금까지 분석한 15개 모듈 중 가장 작다. inbound-web(638)의 2.8%이고, main 파일이 8개라 sub-scope 분할이 의미를 갖지 않는다.
## 2. 무엇을 하는 코드인가
**전송 인프라만.** 서버 수명주기 · 타입드 설정 · 인증 정책 경계 · 에러 매핑, 그리고 `.proto` 없이 부팅하는 최소 표면(standard health, 명시 opt-in 시 reflection).
`build.gradle`의 첫 세 결정이 이 모듈의 성격을 정한다:
- **third-party starter 없음.** `SmartLifecycle` 빈(`GrpcServerRunner`)이 `io.grpc` Netty 서버를 직접 소유한다 — "so this module depends on NO third-party grpc-spring-boot starter (no Spring Boot version coupling)."
- **protobuf 컴파일 없음.** `com.google.protobuf` 플러그인도 `.proto`도 없다. health와 reflection은 `grpc-services`가 런타임에 제공하고, 향후 feature가 자기 `.proto`를 소유한다.
- **BOM을 모듈 스코프에서 import.** `io.grpc:*`/protobuf 버전은 Spring Boot BOM이 관리하지 않으므로 `grpc-bom`/`protobuf-bom`을 여기서 가져온다 — "this keeps the strict-locking blast radius to this module (the shared root dependencyManagement block stays io.grpc-free)."
**feature-agnostic 등록.** `GrpcServerRunner`가 모든 `BindableService` 빈을 `ObjectProvider`로 받아 이름을 모른 채 등록한다. 그리고 그 등록에 조건을 건다:
```java
// GrpcServerRunner.start()
if (!featureServices.isEmpty() && policies.size() != 1) {
throw new IllegalStateException(
"feature gRPC services require exactly one caller-supplied authentication policy");
}
```
feature 서비스가 하나라도 있으면 caller-supplied 인증 정책이 **정확히 하나** 있어야 하고, 없거나 둘 이상이면 listener가 시작되지 않는다. 그래서 "인증 없이 노출된 RPC"가 구조적으로 불가능하다.
**활성화가 삼중으로 닫혀 있다.**
```java
// GrpcServerConfig
@ConditionalOnProperty(prefix = "ca-skeleton.grpc", name = "enabled",
havingValue = "true", matchIfMissing = false)
// GrpcServerProperties
@ConfigurationProperties(prefix = "ca-skeleton.grpc", ignoreUnknownFields = false)
@Validated
@AssertTrue(message = "insecure gRPC requires allow-insecure-local=true and a loopback bind address")
public boolean isInsecureLocalConfigurationValid() {
return !enabled || (allowInsecureLocal && isLoopbackBindAddress());
}
```
현재 transport credential이 plaintext뿐이므로, `enabled=true``allowInsecureLocal=true`**실제로 loopback으로 해석되는** bind address를 함께 요구한다. `isLoopbackBindAddress()`는 문자열 비교가 아니라 `InetAddress.getByName(...).isLoopbackAddress()`로 판정하므로 `localhost`·`127.0.0.2`·`::1`이 모두 통과하고 `0.0.0.0`은 통과하지 않는다.
**에러 계약.** `GrpcStatusMapper`가 10값 `Category` SSOT를 gRPC `Status`로 옮기고, 정확한 `code``category`는 트레일러(`error-code` / `error-category`)에 싣는다 — "the wire status (like an HTTP status) is coarse, while the exact `ApiErrorCode.code()` and the category name ride in the trailers."
`GrpcExceptionHandlingInterceptor`가 그 계약의 단일 지점이다. 네 개의 서로 다른 실패 경로를 하나의 sanitizer로 모은다:
| 경로 | 처리 |
|---|---|
| `next.startCall` 이 던짐 | `:47-50` catch → `closeWithError` |
| 리스너 콜백(`onMessage`·`onHalfClose`·`onReady`·`onCancel`·`onComplete`)이 던짐 | `runGuarded``closeWithError` |
| feature가 `responseObserver.onError(...)` | `ServerCalls``call.close(...)` → sanitizing override |
| feature가 raw `StatusRuntimeException` | 같은 override |
그리고 override가 **모든 non-OK close를 재작성**한다:
```java
// sanitizingCall(...).close
if (status.isOk()) { super.close(status, trailers); return; }
ApiErrorCode code = errorCodeOf(status.getCause());
if (code == null) { code = OperationalError.INTERNAL_ERROR; }
super.close(statusMapper.toStatus(code.category()).withDescription(code.code()),
statusMapper.trailersFor(code));
```
호출자가 넘긴 description과 트레일러는 **버려진다**. javadoc이 그 이유를 적는다 — "raw descriptions and input trailers, which may carry a SQLState or upstream detail, are never surfaced." `closeWithError``Status.fromThrowable(exception)`를 쓰는데도 원문이 새지 않는 것은 두 호출 지점(`:48` · `:84`)이 모두 `sanitizingCall`에 대고 부르기 때문이다.
## 3. Negative-space probes
### 3.1 (8.1) 도달성 — feature 표면이 존재하는가
```
$ grep -rn 'BindableService|GrpcAuthenticationPolicy' --include=*.java . (grpc leaf 제외) -> 0
$ grep -rn 'ca-skeleton.grpc' --include=*.yml --include=*.yaml . -> 0
$ grep -rn 'inbound.grpc' --include=*.java app-bootstrap/src/main
app-bootstrap/.../CaSkeletonApplication.java:71: "dev.caskeleton.adapter.inbound.grpc",
```
`BindableService` 구현도, `GrpcAuthenticationPolicy` 구현도, `ca-skeleton.grpc` 설정값도 저장소에 없다. app-bootstrap이 이 leaf를 언급하는 곳은 `@ConfigurationPropertiesScan` 목록 한 줄뿐이다.
**이것은 결함이 아니라 선언된 상태다.** CLAUDE.md가 명시한다 — "현재 저장소에는 production feature RPC나 sample gRPC service가 없다. 향후 feature를 도입할 때는 `.proto`/generated stub/`BindableService`를 해당 feature가 소유하고, 서비스 빈과 정확히 한 개의 caller-supplied `GrpcAuthenticationPolicy` 빈을 함께 제공한다." 기본값이 `enabled=false`이므로 출하 배포에서 리스너가 뜨지 않는 것도 의도다.
앞선 두 모듈(notification · inbound-web)에서 반복해서 만난 "장치는 있고 회로가 닫히지 않았다"와 형태가 비슷해 보이지만 **다르다**: 저기서는 플랫폼이 설치해야 할 것을 설치하지 않았고, 여기서는 채택자가 기여할 자리를 비워 둔 것이며 그 사실이 문서와 기본값과 테스트(`missingActivationPropertyCreatesNoGrpcRuntimeBeansOrListener`)에 함께 적혀 있다.
### 3.2 (8.2) 조건 형제 비교 — cause chain 순회 관용구가 저장소에 두 가지다
`GrpcExceptionHandlingInterceptor.errorCodeOf`가 원인 사슬을 훑는다:
```java
private static ApiErrorCode errorCodeOf(Throwable throwable) {
Throwable current = throwable;
while (current != null) {
if (current instanceof ApiErrorCarrier carrier) { return carrier.errorCode(); }
if (current.getCause() == current) { break; } // 자기참조만 감지
current = current.getCause();
}
return null;
}
```
같은 일을 하는 코드가 저장소에 아홉 곳 있고 두 갈래로 갈린다:
| 관용구 | 위치 |
|---|---|
| **깊이 제한** (어떤 순환에도 안전) | `web/auth/JwtDecoderConfig:63`(32) · `notification/.../NotificationSchedulerWorker:124`(8) · `notification/.../JdkNotificationHttpGateway:98`(10) · `mongo/.../SpringDataBulkFailureExtractor:30` · `mongo/failure/MongoFailureExtractor:41` |
| **자기참조 검사만** (2-순환에서 무한 루프) | **`grpc/GrpcExceptionHandlingInterceptor:124`** · `web/advanced/mvc/MvcDisconnectDetector:61` · `web/advanced/webflux/WebFluxDisconnectDetector:66` · `persistence-jpa/.../TransactionRetryClassifier:19` |
§4.1.
### 3.3 (8.3) 중복 메커니즘 — 인증과 예외 처리의 인터셉터 순서
`ServerInterceptors.intercept(service, exceptionInterceptor, authenticationInterceptor)` — gRPC 규약상 **마지막 인터셉터의 `interceptCall`이 먼저** 호출되므로 인증이 바깥, 예외 처리가 안쪽이다.
인증 인터셉터가 예외 처리 바깥에 있는데도 안전한 이유는 그것이 스스로 예외를 삼키기 때문이다:
```java
private boolean isAuthenticated(Metadata headers) {
try { return authenticationPolicy.isAuthenticated(headers); }
catch (RuntimeException ignored) { return false; }
}
```
CLAUDE.md의 약속("정책이 `false`를 반환하거나 예외를 던진 요청은 ... 안정적인 `UNAUTHENTICATED` status/code/category로 종료된다")이 코드와 일치하고, 두 경우 모두 같은 `call.close(Status.UNAUTHENTICATED.withDescription(OperationalError.UNAUTHENTICATED.code()), trailersFor(...))`로 끝난다. 정책 진단은 클라이언트에 닿지 않는다. 중복 아님.
### 3.4 (8.4) 문서/구현 드리프트
**설정 표.** CLAUDE.md의 여섯 개 knob(`enabled`·`port`·`bindAddress`·`allowInsecureLocal`·`reflectionEnabled`·`shutdownGraceSeconds`)과 기본값이 `GrpcServerProperties`의 필드·기본값과 정확히 일치한다. `port``0..65535` 범위 설명도 `@Min(0) @Max(65535)`와 일치한다. 드리프트 없음.
**`Category` 망라.** `GrpcStatusMapper.toStatus`가 10개 값을 전부 다루고 `default` 분기가 없다 — 값이 추가되면 컴파일이 깨진다. 그리고 테스트 `coversEveryCategoryValue`가 그것을 별도로 고정한다.
**컴포지션 루트 규칙과의 어긋남.** `CaSkeletonApplication`의 javadoc은 이렇게 선언한다:
> "The five optional adapters are absent from the list below on purpose. Each one's settings are registered by its capability root through `@EnableConfigurationProperties`, which is what ties binding to the master switch. **Adding a package back here would restore the binding and silently undo the gate.**"
그런데 `dev.caskeleton.adapter.inbound.grpc`는 그 목록(`@ConfigurationPropertiesScan`)에 **있고**, `GrpcServerConfig``@EnableConfigurationProperties(GrpcServerProperties.class)`**쓴다**. §4.2.
**health 상태 시점.** `GrpcServerRunner.start()``healthStatusManager.setStatus(SERVICE_NAME_ALL_SERVICES, SERVING)``server = builder.build().start()` **앞에서** 부른다. §4.3.
## 4. Findings
### 4.1 P2 — 원인 사슬 순회가 2-순환에서 무한 루프에 빠지고, 저장소는 이미 그 사례를 이름으로 적어 두었다
`errorCodeOf`의 종료 조건은 `current.getCause() == current` 하나다. 서로를 원인으로 갖는 두 예외(`a.cause = b`, `b.cause = a`)에서는 이 조건이 참이 되지 않고 `current`가 a→b→a→b로 무한히 순환한다. 이 사슬은 평범한 자바로 구성 가능하다 — `a = new RuntimeException(); b = new RuntimeException(a); a.initCause(b);`.
**같은 저장소가 이 정확한 위험을 다른 모듈에서 이름으로 서술하고 다른 관용구를 택했다:**
> `JdkNotificationHttpGateway:93-97` — "Depth-bounded rather than cycle-detecting: **a cause chain can be circular (two exceptions each `initCause`'d to the other)**, and an unbounded walk over one hangs the dispatch thread. Ten is far deeper than any real transport wrapping."
즉 이 주석은 자기참조 검사가 놓치는 바로 그 경우를 지목하고, 깊이 제한을 그 이유로 채택한다. 저장소의 아홉 개 순회 지점 중 다섯이 깊이 제한이고 넷이 자기참조 검사다(§3.2).
**실패 시나리오** — feature gRPC 서비스가 순환 원인 사슬을 가진 라이브러리 예외를 전파한다(일부 커넥션 풀과 재시도 래퍼가 실패 원인을 상호 참조하는 형태로 만든다). `closeWithError`가 그 예외를 `Status.withCause`에 실어 sanitizing `close`로 보내고, `errorCodeOf`가 진입해 돌아오지 않는다. gRPC 핸들러 스레드 하나가 CPU를 태우며 멈추고, 클라이언트는 응답도 상태도 받지 못한 채 데드라인까지 기다린다. 같은 예외가 반복되면 서버 스레드가 하나씩 소진된다.
**나머지 세 지점의 영향도**`MvcDisconnectDetector``WebFluxDisconnectDetector`는 요청 처리 중 클라이언트 연결 끊김을 판정하는 곳이고, `TransactionRetryClassifier`는 트랜잭션 재시도 여부를 판정하는 곳이다. 셋 다 요청 스레드 위에서 실행된다.
**권고** — 네 지점을 깊이 제한으로 통일한다. `JdkNotificationHttpGateway`의 형태가 이미 정본이고 그 근거까지 코드에 있다. 이 leaf에서는 `errorCodeOf``while``for (int depth = 0; current != null && depth < 16; depth++, current = current.getCause())`로 바꾸면 닫힌다.
### 4.2 P3 — 설정 바인딩이 마스터 스위치 밖에서 일어난다. 컴포지션 루트의 자기 규칙과 어긋난다
§3.4. `GrpcServerProperties`는 두 경로로 등록된다 — `GrpcServerConfig``@EnableConfigurationProperties`(게이트 안쪽)와 `CaSkeletonApplication``@ConfigurationPropertiesScan`(게이트 바깥). 후자가 있으면 `ca-skeleton.grpc.enabled`와 무관하게 바인딩이 일어난다.
`CaSkeletonApplication`의 javadoc은 이 구조가 과거에 만든 사고를 기록한다 — "The asymmetry that existed before — beans gated, settings not — is why a notification settings object bound itself in a deployment whose notification master was off." 그리고 그 교훈을 다섯 optional 어댑터에 적용하면서 grpc·web·websocket은 목록에 남겼다.
**지금 이 leaf에서는 무해하다.** 검증이 전부 게이트를 존중하거나 안전한 기본값을 갖는다:
| 검증 | `enabled=false`에서 |
|---|---|
| `@AssertTrue isInsecureLocalConfigurationValid()` | `!enabled` 로 즉시 참 |
| `@Min(0) @Max(65535) port` | 기본 9090 |
| `@NotBlank bindAddress` | 기본 `127.0.0.1` |
| `@Min(0) shutdownGraceSeconds` | 기본 5 |
부작용은 두 가지뿐이다: (a) 비활성 배포에서도 프로퍼티 빈이 만들어진다, (b) `ignoreUnknownFields = false`이므로 `ca-skeleton.grpc.*` 아래 오타 하나가 gRPC를 쓰지 않는 배포의 부팅을 실패시킨다. (b)는 오히려 바람직한 쪽에 가깝다.
기록하는 이유는 **규칙과 적용이 갈린다**는 점이다. 같은 javadoc이 "Adding a package back here would restore the binding and silently undo the gate"라고 경고하고, 이 패키지가 그 목록에 있다. 지금 이 leaf가 안전한 것은 규칙이 지켜져서가 아니라 기본값이 전부 유효하기 때문이고, 새 검증이 하나 추가되면 그 보호막이 사라진다.
### 4.3 P3/기록 — health 가 바인드 이전에 SERVING 으로 선언된다
```java
// GrpcServerRunner.start()
healthStatusManager.setStatus(SERVICE_NAME_ALL_SERVICES, ServingStatus.SERVING);
builder.addService(healthStatusManager.getHealthService());
...
server = builder.build().start(); // 이 뒤에야 실제로 바인드된다
```
`start()``IOException`으로 실패하면 `UncheckedIOException`이 던져지고 컨텍스트 시작이 실패하므로, "SERVING인데 서버가 없다"는 상태가 관측되는 창은 없다. 다만 이 순서는 health를 "프로세스가 살아 있음"이 아니라 "서비스가 준비됨"으로 쓰는 배포에서 의미가 없어진다 — 값이 항상 SERVING이고 어떤 조건에서도 NOT_SERVING이 되지 않는다(종료 시 `enterTerminalState()` 하나 제외).
feature 서비스가 없는 현재 상태에서는 판단할 근거가 없고, feature가 들어올 때 "무엇이 준비되면 SERVING인가"를 정해야 한다는 기록이다.
### 4.4 P3/기록 — raw gRPC status 를 INTERNAL 로 강등하는 것은 의도이며, 표준 관용구를 막는다
`responseObserver.onError(Status.NOT_FOUND.asRuntimeException())`은 gRPC의 표준 오류 보고 방식이지만, 이 인터셉터에서는 `status.getCause()``ApiErrorCarrier`가 아니므로 `INTERNAL` + `INTERNAL_ERROR`로 재작성된다. javadoc이 그것을 명시하고("An unrecognised exception or raw gRPC status maps to `Status.INTERNAL`") 테스트 `rawStatusRuntimeExceptionIsSanitizedToInternal`이 고정한다.
즉 이 플랫폼에서 non-INTERNAL 오류를 내는 유일한 방법은 `ApiErrorCarrier`(보통 `ApiErrorException`)를 던지는 것이다. 강한 의견이고 문서화돼 있으므로 결함이 아니다. feature 개발자가 표준 관용구를 쓰면 조용히 INTERNAL이 된다는 사실만 기록한다 — CLAUDE.md의 "Feature 기여 방법" 절에 그 규칙이 없다.
## 5. 실행 검증
```
$ ./gradlew :adapter:inbound:grpc:test :adapter:inbound:grpc:grpcTransportQualificationTest
> Task :adapter:inbound:grpc:grpcTransportQualificationTestEvidence
grpcTransportQualificationTest: 15 tests, 0 skipped
BUILD SUCCESSFUL in 12s
GRADLE_EXIT=0
test-results 집계: classes=8 tests=48 failures=0 errors=0 skipped=0
```
`registerStrictQualificationTest`가 두 클래스(`GrpcSafeActivationTest` · `GrpcP1BoundaryWireTest`)를 이름으로 요구하고 skip 0을 강제한다. 24개 테스트 이름이 활성화 기본값 · 설정 검증 4종 · 인증 3종 · 네 개 오류 경로 · reflection off · `Category` 전수를 덮는다.
`GrpcP1BoundaryWireTest`가 실제 loopback ephemeral Netty 서버를 띄워 와이어 수준에서 확인한다는 점이 중요하다 — 이 모듈의 계약은 인터셉터 조합 순서에 의존하고(§3.3), 그것은 목으로 재현되지 않는다.
## 6. 종합
**결함 밀도가 이 저장소에서 가장 낮은 모듈이다.** main 8파일 602 LOC에 P1 0건, P2 1건(그것도 저장소 전반의 관용구 분열이 이 지점에 나타난 것), P3 3건.
세 가지가 이 결과를 만든다:
1. **범위가 좁고 그 경계가 문서에 있다.** "전송 인프라만, feature RPC 없음"이 CLAUDE.md의 Responsibility·Forbidden 두 절에 명시되고, 코드에 feature 흔적이 없다.
2. **활성화가 삼중으로 닫혀 있다.** 프로퍼티 게이트 · `@AssertTrue` 교차 검증 · feature 서비스가 있을 때 인증 정책을 강제하는 런타임 검사. 셋 다 fail-closed이고 셋 다 테스트가 있다.
3. **검증이 와이어 수준이다.** 실제 Netty 서버 · 실제 loopback 소켓 · skip 0 강제. inbound-web에서 확인한 "픽스처가 조립하고 레인이 픽스처를 인증한다"는 형태가 여기서는 성립하지 않는다 — 조립할 것이 `GrpcServerConfig` 하나이고 그것을 테스트가 직접 켜기 때문이다.
**앞 모듈들과의 대조.** inbound-web은 397개 main 파일 중 대부분이 조립되지 않았고 그것을 검증 장치가 가렸다. 여기서는 조립할 것이 여덟 개뿐이고 전부 하나의 `@Configuration`에 있으며, 그 `@Configuration`이 켜지는지 꺼지는지를 두 테스트가 양방향으로 확인한다. **모듈 크기가 아니라 조립 지점의 수가 이 차이를 만든다.**
## 7. 완료 게이트
- [x] denominator 18 / 18 FULL_READ (probe가 `file_count=18` 확인)
- [x] §8.1~§8.4 negative-space probe 수행 — 도달성(선언된 빈 상태 확인) · 조건 형제 비교(저장소 전체 관용구 전수) · 중복 메커니즘(인터셉터 순서) · 문서 드리프트(설정 표 · Category 망라 · 컴포지션 루트 규칙)
- [x] 실행 검증: `:test` + `:grpcTransportQualificationTest``BUILD SUCCESSFUL`, tests=48 failures=0 **skipped=0**
- [x] 거짓 양성 후보 검증 후 기각: `ServerInterceptors.intercept` 인자 순서(→ 인증이 바깥이지만 자기 예외를 삼키므로 안전) · `closeWithError``Status.fromThrowable`(→ 두 호출 지점이 모두 `sanitizingCall`이라 원문이 재작성됨) · `authenticationInterceptor`가 null일 가능성(→ feature 서비스가 없을 때만 null이고 그때는 루프가 돌지 않음) · `ApiErrorException.errorCode``transient`(→ gRPC는 자바 직렬화를 쓰지 않음)
- [x] 소스 미변경
## Source anchors
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **10개** (main 7 · test 2 · 기타 1).
```
src/adapter/inbound/grpc/build.gradle
src/config/architecture/modules.json (adapter-inbound-grpc 항목)
main:
src/main/java/dev/caskeleton/adapter/inbound/grpc/ApiErrorException.java
src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcAuthenticationPolicy.java
src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcExceptionHandlingInterceptor.java
src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerConfig.java
src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerProperties.java
src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerRunner.java
src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcStatusMapper.java
test:
src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcP1BoundaryWireTest.java
src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcSafeActivationTest.java
기타:
src/build.gradle
해석되지 않은 인용 (1종) — 외부 타입·문서상 약칭 등:
evidence/raw/204-inbound-grpc-probes.txt
```