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>
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_READ18 /STRUCTURAL_ONLY0 /EXCLUDED0 /UNCLASSIFIED0 - 최초 분석 revision
a24ece9c→ 재검증 revision21234e38· 이 리프의 변경 파일 0 - 재검증 증거:
EVD-333(소스 드리프트 0),EVD-334(lane 재실행)
재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
분석 대상
src/adapter/inbound/grpc· revisiona24ece9cf797f7ea647e33bf846b115208ed1ba5분모 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.grpcNetty 서버를 직접 소유한다 — "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=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를 재작성한다:
// 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가 원인 사슬을 훑는다:
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의 필드·기본값과 정확히 일치한다. 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 eachinitCause'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 으로 선언된다
// 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건.
세 가지가 이 결과를 만든다:
- 범위가 좁고 그 경계가 문서에 있다. "전송 인프라만, feature RPC 없음"이 CLAUDE.md의 Responsibility·Forbidden 두 절에 명시되고, 코드에 feature 흔적이 없다.
- 활성화가 삼중으로 닫혀 있다. 프로퍼티 게이트 ·
@AssertTrue교차 검증 · feature 서비스가 있을 때 인증 정책을 강제하는 런타임 검사. 셋 다 fail-closed이고 셋 다 테스트가 있다. - 검증이 와이어 수준이다. 실제 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+:grpcTransportQualificationTest→BUILD SUCCESSFUL, tests=48 failures=0 skipped=0 - 거짓 양성 후보 검증 후 기각:
ServerInterceptors.intercept인자 순서(→ 인증이 바깥이지만 자기 예외를 삼키므로 안전) ·closeWithError의Status.fromThrowable(→ 두 호출 지점이 모두sanitizingCall이라 원문이 재작성됨) ·authenticationInterceptor가 null일 가능성(→ feature 서비스가 없을 때만 null이고 그때는 루프가 돌지 않음) ·ApiErrorException.errorCode의transient(→ 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