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

22 KiB

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로 받아 이름을 모른 채 등록한다. 그리고 그 등록에 조건을 건다:

// 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"가 구조적으로 불가능하다.

활성화가 삼중으로 닫혀 있다.

// 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=trueallowInsecureLocal=true실제로 loopback으로 해석되는 bind address를 함께 요구한다. isLoopbackBindAddress()는 문자열 비교가 아니라 InetAddress.getByName(...).isLoopbackAddress()로 판정하므로 localhost·127.0.0.2·::1이 모두 통과하고 0.0.0.0은 통과하지 않는다.

에러 계약. GrpcStatusMapper가 10값 Category SSOT를 gRPC Status로 옮기고, 정확한 codecategory는 트레일러(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)이 던짐 runGuardedcloseWithError
feature가 responseObserver.onError(...) ServerCallscall.close(...) → sanitizing override
feature가 raw StatusRuntimeException 같은 override

그리고 override가 모든 non-OK close를 재작성한다:

// 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." closeWithErrorStatus.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가 원인 사슬을 훑는다:

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이 먼저 호출되므로 인증이 바깥, 예외 처리가 안쪽이다.

인증 인터셉터가 예외 처리 바깥에 있는데도 안전한 이유는 그것이 스스로 예외를 삼키기 때문이다:

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의 필드·기본값과 정확히 일치한다. port0..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를 태우며 멈추고, 클라이언트는 응답도 상태도 받지 못한 채 데드라인까지 기다린다. 같은 예외가 반복되면 서버 스레드가 하나씩 소진된다.

나머지 세 지점의 영향도MvcDisconnectDetectorWebFluxDisconnectDetector는 요청 처리 중 클라이언트 연결 끊김을 판정하는 곳이고, TransactionRetryClassifier는 트랜잭션 재시도 여부를 판정하는 곳이다. 셋 다 요청 스레드 위에서 실행된다.

권고 — 네 지점을 깊이 제한으로 통일한다. JdkNotificationHttpGateway의 형태가 이미 정본이고 그 근거까지 코드에 있다. 이 leaf에서는 errorCodeOfwhilefor (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 으로 선언된다

// 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. 완료 게이트

  • denominator 18 / 18 FULL_READ (probe가 file_count=18 확인)
  • §8.1~§8.4 negative-space probe 수행 — 도달성(선언된 빈 상태 확인) · 조건 형제 비교(저장소 전체 관용구 전수) · 중복 메커니즘(인터셉터 순서) · 문서 드리프트(설정 표 · Category 망라 · 컴포지션 루트 규칙)
  • 실행 검증: :test + :grpcTransportQualificationTestBUILD SUCCESSFUL, tests=48 failures=0 skipped=0
  • 거짓 양성 후보 검증 후 기각: ServerInterceptors.intercept 인자 순서(→ 인증이 바깥이지만 자기 예외를 삼키므로 안전) · closeWithErrorStatus.fromThrowable(→ 두 호출 지점이 모두 sanitizingCall이라 원문이 재작성됨) · authenticationInterceptor가 null일 가능성(→ feature 서비스가 없을 때만 null이고 그때는 루프가 돌지 않음) · ApiErrorException.errorCodetransient(→ gRPC는 자바 직렬화를 쓰지 않음)
  • 소스 미변경

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