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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
+147
@@ -0,0 +1,147 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a20-f003-claude
|
||||
title: grpc 레인 시험 25개는 check 로 돌고, 레인만 거는 실행 0 검사는 돌지 않는다
|
||||
topic: grpc-and-streaming
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a20-f003-claude
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-a20-f003-claude.body.md
|
||||
assets:
|
||||
- key: a20-f003-claude
|
||||
file: ../../../final/evidence/rendered/a20-f003-claude.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a20-f003-claude.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/20-grpc-platform.md#L260 이다.
|
||||
---
|
||||
|
||||
# grpc 레인 시험 25개는 check 로 돌고, 레인만 거는 실행 0 검사는 돌지 않는다
|
||||
|
||||
원문은 증거 레인의 시험 스물다섯 개에 자동 실행 경로가 없다고 적었다. `:grpc:grpc-testkit` 의 두 태스크를 돌려 시험 이름을 집합으로 비교하니 기본 `test` 의 56 개가 그 스물다섯을 전부 포함한다. 자동 경로에 없는 것은 시험이 아니라 레인이 거는 실행 0 검사다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **아무도 돌리지 않는 레인의 게이트는 마지막으로 돌린 사람이 본 것을 보고한다**
|
||||
원문이 이 사례를 그 규칙으로 읽었다. 시험 쪽에는 붙지 않고, 레인만 거는 실행 0 검사 쪽에 붙는다.
|
||||
- **네 레인이 check 에 붙지 않고, 이 가족을 이름으로 부르는 워크플로가 없다**
|
||||
그 기록이 25개 시험을 직접 입력할 때만 도는 것으로 적었는데, 기본 `test` 가 그 25개를 포함한다.
|
||||
- **release gate가 실제로 차단하는 것은 hermetic test 3개이고, mongo용 CI workflow는 없다**
|
||||
mongo 가족에서도 레인을 이름으로 부르는 워크플로가 없어서, 게이트가 실제로 차단하는 것은 기본 `test` 에 들어 있는 밀폐 클래스뿐이다.
|
||||
|
||||
## 문제
|
||||
|
||||
src/grpc/CLAUDE.md 가 세 레인의 현재 상태를 통과로 서술한다.
|
||||
|
||||
그 문장의 주어가 무엇이고 어느 경로로 실행되는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
앞 절반은 사실이다. grpcInProcessContractTest·grpcNettyContractTest·grpcFaultTest 를 이름으로 실행하니 모두 BUILD SUCCESSFUL 이고 시험 수는 7·9·9 다.
|
||||
|
||||
이어서 grpc-testkit 의 기본 test 를 실행했다. 7 클래스 56 개가 통과하는데, 시험 이름을 하나씩 맞춰 보면 레인 쪽 25 개가 빠짐없이 그 안에 들어 있다.
|
||||
|
||||
겹치는 이유는 제외 태그 목록에 있다. 레인은 태그로 시험을 고르고 그 시험들은 공유 test 소스 세트에 있는데, 기본 test 가 제외하는 것은 루트 규약의 quarantine 과 grpc-testkit/build.gradle:42 의 grpc-performance 뿐이다. ci-quality-gates.yml:50 이 ./gradlew check 를 돌리므로 이 25 개에는 사람이 부르지 않아도 도는 경로가 있다.
|
||||
|
||||
넷째 레인만 다르다. GrpcPerformanceLaneTest 는 @Tag("grpc-performance") 를 달아 기본 test 결과에 0 건이다.
|
||||
|
||||
자동 경로에 없는 것은 따로 있다. ca.strict-test-lane.gradle:293 의 최신 재사용 거부와, :246·:257 이 실행한 시험 수를 세어 0 이면 GradleException 을 던지는 검사다. failOnNoDiscoveredTests 는 이 둘에 들어가지 않는다. 레인을 쓰지 않는 leaf 의 테스트 태스크에서도 같은 값이라 빌드 도구 기본값이다.
|
||||
|
||||
@Tag 가 지워지면 기본 test 는 같은 수를 계속 돌리고, 클래스가 지워지면 더 적은 수를 돌리며 그대로 통과한다. :grpc:grpc-testkit:check 의 dry-run 그래프에 레인 태스크는 0 건이고, grpc-testkit/build.gradle 에 check 가 0 번 나온다.
|
||||
|
||||
.github/workflows 의 파일 어디에도 grpc 라는 문자열이 없다. 다만 ci-quality-gates.yml:61 의 conditionalTransportQualification 이 src/build.gradle:606 을 통해 :adapter:inbound:grpc:grpcTransportQualificationTest 를 요구하므로, 자동으로 도는 grpc 레인이 다른 프로젝트에 하나 있다.
|
||||
|
||||
원본 분석의 등급은 P2 이고 이 기록은 새로 매기지 않는다. 그 등급을 떠받치던 근거 — 스물다섯 개 시험이 자동 실행 밖에 있다 — 는 성립하지 않는다. 남는 것은 레인만 거는 두 보증이 자동 경로에 없다는 더 좁은 결함이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Gradle : 9.0.0
|
||||
확인 방식 : 레인 등록 블록과 태그 확인, 기본 test 에 걸리는 제외 태그를 루트 규약과 leaf 양쪽에서 확인, 태그가 클래스 단위인지 확인, :grpc:grpc-testkit:check dry-run 으로 태스크 그래프 확인, 결과 디렉터리를 비우고 실행 전 XML 0 건 확인 후 세 레인과 기본 test 를 각각 실행하고 종료 코드로 집계를 막음, 두 실행의 시험 이름 집합 비교, 성능 레인 클래스의 포함 여부 확인, 레인 규약의 보증 구현 확인, failOnNoDiscoveredTests 를 레인 없는 leaf 와 대조, check 언급과 워크플로 검색, 지속 통합이 부르는 다른 grpc 레인 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 레인을 등록하는 블록에서 이름과 태그를 모은다.
|
||||
2. 기본 테스트 태스크에 걸리는 제외 태그를 루트 규약과 leaf 빌드 파일 양쪽에서 찾는다.
|
||||
3. 레인 태그가 클래스 선언에 붙는지 확인한다.
|
||||
4. 검사 태스크를 dry-run 해 그래프에 기본 테스트가 있고 레인 태스크가 없는 것을 본다.
|
||||
5. 결과 디렉터리를 비우고 남은 XML 이 0 인지 찍는다.
|
||||
6. 세 레인을 이름으로 실행하고 종료 코드를 확인한 뒤에만 결과를 집계한다.
|
||||
7. 같은 leaf 의 기본 테스트 태스크를 같은 방식으로 실행하고 집계한다.
|
||||
8. 두 실행이 남긴 시험 이름을 모아 한쪽이 다른 쪽에 포함되는지 판정한다.
|
||||
9. 제외 태그를 단 클래스가 기본 실행 결과에 있는지 센다.
|
||||
10. 레인 규약에서 기본 태스크에 없는 보증을 찾고, failOnNoDiscoveredTests 를 레인이 없는 leaf 와 대조한다.
|
||||
11. 이 leaf 의 빌드 파일이 검사 태스크를 언급하는지, 워크플로가 이 가족을 이름으로 부르는지 세고, 다른 프로젝트의 grpc 레인이 자동으로 도는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`src/grpc/CLAUDE.md` 는 in-process·Netty·fault 레인이 실제로 실행되어 통과한다고 적는다. 원본 분석은 앞 절반을 사실로 확인한 뒤, 그 레인들이 `check` 에 없고 어떤 워크플로도 이름으로 부르지 않으므로 스물다섯 개 시험이 누군가 명령을 입력할 때만 돈다고 적었다.
|
||||
|
||||
## 레인 넷과 기본 test 가 제외하는 태그
|
||||
|
||||
:::evidence key="a20-f003-claude" alt="src 디렉터리에서 돌린 gradle 실행과 정적 검색을 합친 출력 134줄. 먼저 gradle 판이 나오고, grpc-testkit 의 build.gradle 이 등록하는 레인 넷과 태그, 그리고 기본 test 에 걸리는 제외 태그가 루트 규약의 quarantine 과 leaf 의 grpc-performance 두 곳에서 온다는 것이 원문과 함께 실린다. 세 레인 태그가 클래스 선언 바로 위에 붙어 있는 것이 보인다. 이어서 check 를 dry-run 한 태스크 그래프가 나오는데 test 와 check 는 있고 레인 태스크는 0 건이다. 그 다음 결과 디렉터리를 지우고 실행 전 남은 XML 이 0 건임을 찍은 뒤 세 레인을 이름으로 돌려 GRADLE_EXIT=0 과 클래스별 7·9·9 가 실패 0 으로 나오고, 이어서 기본 test 를 돌려 GRADLE_EXIT=0 과 7 클래스 56 개가 나온다. 두 실행의 시험 이름을 집합으로 비교해 레인 25 개가 기본 test 56 개 안에 전부 있다는 판정이 True 로 찍히고 기본 test 에만 있는 시험이 31 개라는 것, 성능 레인 클래스가 기본 test 결과에 0 건이라는 것이 나온다. 마지막으로 레인 규약에서 보증에 해당하는 줄들이 인용되고, grpc-testkit 의 build.gradle 이 check 를 0 번 언급하며 워크플로 28 개 중 grpc 문자열을 담은 것이 0 건인데 ci-quality-gates 의 48번부터 61번 줄에는 check 가 모든 레인을 덮지 않는다는 주석과 별도 스텝 둘이 있고, 그중 마지막 스텝이 부르는 태스크가 grpc 이름의 레인을 요구한다는 것이 나온다." caption="레인 넷과 태그 · 기본 test 의 제외 태그 두 곳 · check 그래프에 레인 0 건 · 실행 전 XML 0 건과 GRADLE_EXIT · 레인 25 개가 기본 test 56 개의 부분집합 True · 레인 규약의 보증 · check 가 레인을 덮지 않는다는 주석과 grpc 레인을 부르는 별도 스텝 — 134줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`grpc-testkit/build.gradle:14`\~`:35` 가 레인 넷을 등록한다. `grpcInProcessContractTest`, `grpcNettyContractTest`, `grpcFaultTest`, `grpcPerformanceTest` 이고 태그는 `grpc-inprocess`, `grpc-netty`, `grpc-fault`, `grpc-performance` 다. 세 태그는 클래스 선언 바로 위에 붙어 클래스 전체를 덮는다.
|
||||
|
||||
기본 `test` 에 걸리는 제외 태그는 두 곳에서 온다. 루트 규약 `src/build.gradle:551` 의 `quarantine` 과 `grpc-testkit/build.gradle:42` 의 `grpc-performance` 다. `grpc-testkit` 의 시험에 `quarantine` 태그는 0 건이므로 이 leaf 에서 실효 제외는 성능 태그 하나다. 나머지 세 태그는 어느 쪽에도 없다.
|
||||
|
||||
## 레인 25 개는 기본 test 56 개 안에 있다
|
||||
|
||||
`:grpc:grpc-testkit:check` 를 dry-run 했다. 그래프에 `:grpc:grpc-testkit:test` 가 있고 레인 태스크는 0 건이다. 시험을 돌리지 않고 얻은 결과다.
|
||||
|
||||
그다음 결과 디렉터리를 지우고 돌렸다. 실행 전 남은 XML 이 0 건임을 찍은 뒤, 세 레인을 이름으로 돌려 `GRADLE_EXIT=0` 과 `GrpcInProcessContractFixtureTest` 7, `GrpcNettyContractProfileTest` 9, `GrpcTransportEvidenceClassifierTest` 9 를 얻었다. 이어서 기본 `test` 를 돌려 `GRADLE_EXIT=0` 과 7 클래스 56 개를 얻었다.
|
||||
|
||||
두 실행의 시험 이름을 집합으로 비교했다. 레인이 돌린 25 개가 기본 `test` 의 56 개 안에 전부 있다. 기본 `test` 에만 있는 시험은 31 개다.
|
||||
|
||||
`GrpcPerformanceLaneTest` 는 기본 `test` 결과에 0 건이다. 제외 태그가 그 클래스를 걸러낸다.
|
||||
|
||||
`ci-quality-gates.yml:50` 이 `./gradlew check` 를 돌린다. 그러므로 이 25 개에는 사람이 이름을 입력하지 않아도 도는 경로가 있다.
|
||||
|
||||
## 레인만 거는 보증 둘은 check 에 없다
|
||||
|
||||
레인 태스크가 기본 `test` 와 다른 점은 둘이다.
|
||||
|
||||
하나는 `ca.strict-test-lane.gradle:293` 의 `outputs.upToDateWhen { false }` 다. 레인은 결과를 최신으로 재사용하지 않는다.
|
||||
|
||||
다른 하나는 실행한 시험 수를 세어 0 을 거부하는 것이다. `:246` 이 `executedTests` 를 두고 `:250` 이 시험마다 증가시키며, `:257` 이 그 값이 0 이면 `GradleException` 을 던진다. `:239`\~`:245` 는 왜 그것이 필요한지 적는다 — `failOnNoDiscoveredTests` 는 발견 단계에 적용되는데 태그 필터는 발견 이후에 시험을 걸러 내므로, 태그가 아무것도 맞히지 못한 레인은 그것만으로는 성공으로 끝난다는 것이다.
|
||||
|
||||
`failOnNoDiscoveredTests` 자체는 레인만의 것이 아니다. 레인이 없는 `:grpc:grpc-core-api:test` 에서도 `true` 이므로 Gradle 9.0.0 의 기본값이다. `:23` 이 적는 레인의 몫은 그 값을 갖는 것이 아니라 leaf 가 그것을 끄지 못하게 하는 것이다.
|
||||
|
||||
`@Tag("grpc-netty")` 가 지워지면 그 클래스는 여전히 test 소스 세트에 있으므로 기본 `test` 는 같은 수를 돌린다. 클래스 자체가 지워지면 기본 `test` 는 더 적은 수를 돌리고 그대로 통과한다. 두 경우 모두 레인은 실행 0 으로 실패하는데, 레인을 부르는 자동 경로가 없다.
|
||||
|
||||
`grpc-testkit/build.gradle` 은 `check` 를 한 번도 언급하지 않는다. 워크플로 스물여덟 개 중 `grpc` 문자열을 담은 것은 0 건이다.
|
||||
|
||||
다만 그 0 건이 이 가족 전체를 뜻하지는 않는다. `ci-quality-gates.yml:61` 이 `conditionalTransportQualification` 을 돌리고, `src/build.gradle:606` 이 그 태스크에 `:adapter:inbound:grpc:grpcTransportQualificationTest` 를 건다. 지속 통합이 자동으로 부르는 grpc 이름의 레인이 하나 있고, 그것은 `:grpc:grpc-testkit` 의 레인 넷이 아니다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 `:286` 에서 `ci-quality-gates.yml` 이 `check` 를 돌리므로 각 leaf 의 기본 `test` 는 지속 통합에서 실행된다고 스스로 적었다. 그리고 바로 다음 문단에서 그 25 개가 누군가 명령을 직접 입력할 때만 돈다고 적었다. 두 문장이 함께 설 수 없다.
|
||||
|
||||
성립하는 쪽은 앞 문장이다. 세 레인의 시험은 태그가 붙은 채 공유 test 소스 세트에 있고, 기본 `test` 가 제외하는 것은 성능 태그와 `quarantine` 뿐이다. 집합 비교가 25 개 전부의 포함을 보인다.
|
||||
|
||||
원문이 인용한 문장 — 아무도 지역에서 돌리지 않는 레인의 붉은 게이트는 마지막으로 돌린 사람이 본 것을 보고한다 — 은 시험 자체에는 붙지 않는다. 붙는 것은 레인이 거는 실행 0 검사 쪽이다.
|
||||
|
||||
원문은 레인을 `:286` 에서 "네 증거 레인"으로, `:8` 과 `:465` 에서 "증거 레인 3종"으로 부른다. 등록된 것은 넷이고 돌린 것은 셋이며 넷째는 기본 `test` 에서 의도적으로 제외된 성능 레인이므로 두 표기 모두 대상이 다를 뿐 틀리지 않았다.
|
||||
|
||||
## 등급에 대해
|
||||
|
||||
원본 분석의 등급은 P2 다. 제목이 든 근거 — 증거 등급 모델 전체가 자동 실행 경로 밖에 있다 — 는 이 리비전에서 성립하지 않는다.
|
||||
|
||||
남는 것은 더 좁은 결함이다. 레인이 존재하는 이유인 실행 0 검사와 최신 재사용 거부가 자동 경로에 없다. 이 기록은 등급을 새로 매기지 않고 상류가 P2 로 둔 근거와 여기서 확인한 반대 근거를 함께 남긴다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
태그 삭제와 클래스 삭제를 실제로 해 보고 두 태스크가 어떻게 갈리는지 관측하지는 않았다. 필터 구성과 규약 코드를 읽었고, 겹치는 시험은 이름 대조로 확인했다.
|
||||
|
||||
GitHub 러너 위에서 검사 태스크를 돌리지 않았다. 워크플로에 적힌 명령과 로컬 dry-run 그래프를 이어 붙여 판단했다.
|
||||
|
||||
지속 부하와 성능 기준선은 확인 범위 밖이다. 지침 문서도 그것이 없다고 적는다.
|
||||
|
||||
`@Tag` 를 지우거나 클래스를 지운 상태를 만들어 두 태스크의 결과가 갈리는 것을 실행으로 보이지 않았다. 두 태스크의 필터 구성과 레인 규약의 실행 0 검사를 읽고, 같은 시험이 양쪽에서 도는 것을 집합 비교로 확인한 데까지다.
|
||||
|
||||
<!-- body:end -->
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a20-f006-grpcadmissioncontroller-tryadmit
|
||||
title: 승격이 상한 필드를 읽지 않고, 세 승인 메서드의 main 호출자가 0 이다
|
||||
topic: grpc-and-streaming
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a20-f006-grpcadmissioncontroller-tryadmit
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-a20-f006-grpcadmissioncontroller-tryadmit.body.md
|
||||
assets:
|
||||
- key: a20-f006-grpcadmissioncontroller-tryadmit
|
||||
file: ../../../final/evidence/rendered/a20-f006-grpcadmissioncontroller-tryadmit.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a20-f006-grpcadmissioncontroller-tryadmit.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/20-grpc-platform.md#L518 이다.
|
||||
---
|
||||
|
||||
# 승격이 상한 필드를 읽지 않고, 세 승인 메서드의 main 호출자가 0 이다
|
||||
|
||||
승인 메서드가 `AtomicInteger` 를 읽고 비교한 뒤 따로 증가시킨다. `promoteFromQueue` 는 `maxConcurrentCalls` 를 읽는 줄이 아예 없다. 다만 그 결과가 보이려면 호출 순서가 어긋나야 하고, 이 셋 중 어느 것도 프로덕션 코드가 부르지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **스트림 승인의 경계가 동시성 아래에서 새고, caller별 맵이 줄지 않는다**
|
||||
같은 검사 후 사용 틈을 가진 짝이다. 두 타입 모두 승인 API 를 부르는 main 코드가 없다.
|
||||
- **원자 타입 위의 검사 후 실행과 비교 후 교체 루프**
|
||||
그 개념이 두 형태를 가른다. 같은 가족의 `GrpcRetryBudget` 이 뒤쪽을 쓰는데 두 승인 클래스에는 `compareAndSet` 이 한 건도 없다.
|
||||
- **테스트에서는 드물고 부하에서는 일상인 것**
|
||||
검사와 증가 사이의 틈은 시험에서 거의 걸리지 않고 동시 요청에서는 흔하다. 승격 쪽은 부하가 아니라 호출 순서에 달려 있다.
|
||||
|
||||
## 문제
|
||||
|
||||
GrpcAdmissionController 가 두 축에 상한을 건다 — 동시에 실행 중인 호출 수와 대기열 길이다.
|
||||
|
||||
그 상한이 코드에서 어떻게 지켜지고 어느 경로가 그것을 실제로 부르는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
tryAdmit:52~:54 가 값을 읽고 상한과 비교한 뒤 별도로 증가시킨다. :57~:59 의 대기열 쪽도, release:84~:85 도 같은 모양이다. 계수기 셋이 모두 AtomicInteger 인데 이 파일에 compareAndSet 은 0 건이다.
|
||||
|
||||
promoteFromQueue:76~:78 은 대기 수가 0 보다 큰지만 확인하고 진행 중 수를 늘린다. 상한 필드를 읽지 않는다.
|
||||
|
||||
초과가 드러나는 조건은 호출 순서다. 두 순서를 각각 돌려 값을 얻었다. 해제와 승격을 번갈아 다섯 쌍 부르면 inFlight 는 1 을 유지하고, 해제를 건너뛰고 승격만 다섯 번 부르면 6 까지 오른다. 저장소의 유일한 승격 시험(GrpcServerProfileTest:126~:127)은 앞의 순서를 쓴다.
|
||||
|
||||
부르는 자리 자체가 적다. 호출 아홉 줄이 모두 GrpcServerProfileTest 한 파일에 있고 프로덕션 쪽에는 하나도 없다. 승격을 부르는 :127 은 바로 앞 :126 의 release() 와 짝을 이룬다.
|
||||
|
||||
빈 정의를 담은 자동설정도 켜지지 않는다. GrpcPlatformAutoConfiguration:30~:34 가 matchIfMissing = false 인 프로퍼티 조건을 걸어 두었는데 그것을 켜는 설정 파일이 0 개이고, src/grpc 밖에서 그 starter 에 의존하는 모듈도 0 개다. main 소비자로 잡히던 GrpcDrainCoordinator 마저 자기 시험에서만 생성되고 제어기에는 inFlight() 읽기 한 줄만 건다. 빈은 GrpcPlatformAutoConfiguration:56 이 만들고 GrpcDrainCoordinator 가 들고 있지만, 그 조정자가 제어기에 하는 호출은 :148 의 inFlight() 읽기 하나다.
|
||||
|
||||
같은 가족에 올바른 형태가 있다. GrpcRetryBudget:50~:58 이 읽고 검사한 뒤 compareAndSet 으로 교체하고 실패하면 되돌아간다.
|
||||
|
||||
판정은 P2 이고 원문과 같다. 코드를 읽어 얻은 근거는 유지되고 관측된 초과 폭은 상류가 적은 것보다 크다. 반대편에는 이 코드를 오늘 도는 경로가 어디에도 없다는 사실이 있다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 승인·승격·해제 메서드 본문과 계수기 필드 확인, 빈 정의를 담은 자동설정의 조건과 그 프로퍼티를 켜는 설정 파일 계수와 starter 의존 모듈 계수, 그 소비자 클래스의 생성 지점 확인, 클래스 자바독의 설계 근거 확인, 타입 이름과 세 메서드 호출을 각각 소스 세트별로 계수, GrpcDrainCoordinator 가 제어기에 거는 호출 전수, 저장소의 승격 시험 본문 확인, compareAndSet 사용 계수와 같은 가족의 대조 구현 확인, 해제와 승격을 짝지은 순서와 짝짓지 않은 순서를 각각 단일 스레드로 실행해 값 관측
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 승인 메서드 본문에서 읽기와 비교와 증가가 각각 어느 줄인지 적는다.
|
||||
2. 대기열 분기와 해제 메서드도 같은 방식으로 읽는다.
|
||||
3. 계수기 필드의 타입과 이 파일의 compareAndSet 사용 수를 센다.
|
||||
4. 승격 메서드에 상한 필드를 읽는 줄이 있는지 본다.
|
||||
5. 세 메서드를 호출하는 자리를 소스 세트별로 나눠 센다.
|
||||
6. main 에서 이 타입을 들고 있는 클래스가 제어기에 어떤 호출을 거는지 전부 찾는다.
|
||||
7. 저장소의 승격 시험이 해제와 승격을 어떤 순서로 부르는지 읽는다.
|
||||
8. 같은 상한으로 두 순서를 각각 실행하고 진행 중 수를 읽는다.
|
||||
9. 같은 가족에서 compareAndSet 을 쓰는 main 파일을 찾고 그 루프를 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`GrpcAdmissionController` 는 동시 호출 수와 대기열 길이를 상한으로 지킨다. 클래스 자바독(`:6`\~`:11`)은 `RESOURCE_EXHAUSTED` 로 거부하는 것이 대기열에 넣는 것보다 나은 이유를 둘 적는데, 둘 다 부하 아래에서 성립하는 이유다.
|
||||
|
||||
## 승인 메서드 셋의 본문
|
||||
|
||||
:::evidence key="a20-f006-grpcadmissioncontroller-tryadmit" alt="저장소 루트에서 돌린 정적 검색과 /tmp/probe5 에서 컴파일해 돌린 프로브를 합친 출력 151줄. GrpcAdmissionController 의 tryAdmit 과 promoteFromQueue 와 release 본문이 50번부터 87번 줄까지 원문 그대로 실리고 계수기 필드 셋이 이어진다. 클래스 자바독이 거부가 대기열보다 나은 이유를 적는 대목이 나오고, src/grpc 아래에서 이 타입 이름이 나오는 자리가 소스 세트별로 나열된 뒤, 세 승인 메서드를 호출하는 자리만 따로 뽑히는데 전부 test 이고 main 소스 세트는 0 개 파일이다. main 에서 이 타입을 들고 있는 GrpcDrainCoordinator 가 하는 일은 inFlight 를 읽어 돌려주는 한 줄뿐이고, 그 빈을 만드는 자동설정이 기본값 꺼짐인 프로퍼티 조건 아래 있으며 그 프로퍼티를 켜는 설정 파일도 그 starter 에 의존하는 모듈도 0 개라는 것과, GrpcDrainCoordinator 를 생성하는 자리가 자기 시험 하나라는 것이 이어진다. 이어서 저장소의 유일한 승격 시험이 해제와 승격을 한 쌍으로 부르고 진행 중이 1 임을 단언하는 본문이 실린다. 같은 가족의 GrpcRetryBudget 이 읽고 검사한 뒤 compareAndSet 으로 교체하고 실패하면 되돌아가는 루프가 나오고, compareAndSet 을 쓰는 grpc main 파일 셋과 두 승인 클래스의 0 건 계수가 붙는다. 마지막으로 프로브 소스의 호출 순서가 그대로 실리고 두 실행 결과가 나오는데, 해제와 승격을 짝지으면 진행 중이 1 로 유지되고 해제 없이 승격만 다섯 번 부르면 6 이 된다." caption="세 메서드의 본문과 계수기 · 자바독의 설계 근거 · 승인 API 를 부르는 main 파일 0 개와 GrpcDrainCoordinator 의 읽기 한 줄 · 저장소의 짝지은 승격 시험 · 같은 가족의 compareAndSet 루프 · 프로브의 호출 순서와 짝지은 경우 1 · 짝짓지 않은 경우 6 — 151줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`tryAdmit:52` 가 `inFlight.get()` 으로 값을 읽고 `:53` 이 상한과 비교한 뒤 `:54` 가 따로 `incrementAndGet()` 한다. 읽기와 증가가 한 연산이 아니다. `:57`\~`:59` 의 대기열 쪽도 같은 모양이다.
|
||||
|
||||
`release:84` 는 `inFlight.get() > 0` 을 본 뒤 `:85` 가 감소시킨다. 검사와 감소 사이가 열려 있다.
|
||||
|
||||
세 필드 모두 `AtomicInteger` 인데(`:17`\~`:19`) `compareAndSet` 은 이 파일에 0 건이다.
|
||||
|
||||
## promoteFromQueue 는 상한 필드를 읽지 않는다
|
||||
|
||||
`promoteFromQueue:76` 은 `queued.get() > 0` 만 확인한다. `:77` 이 대기 수를 줄이고 `:78` 이 진행 중 수를 늘리는데, 그 사이에 `maxConcurrentCalls` 를 읽는 줄이 없다. 이것은 소스만 읽어도 확정되는 사실이다.
|
||||
|
||||
그 결과가 상한 초과로 나타나려면 조건이 하나 더 필요하다. 승격이 해제를 앞질러야 한다.
|
||||
|
||||
프로브로 두 순서를 나란히 돌렸다. 저장소 시험과 같이 해제 하나에 승격 하나를 짝지으면 진행 중은 1 에 머문다. 해제 없이 승격만 다섯 번 부르면 진행 중이 6 이 된다. 상한은 1 이다.
|
||||
|
||||
저장소가 쓰는 순서는 앞쪽이다. `GrpcServerProfileTest:126`\~`:127` 이 `release()` 다음에 `promoteFromQueue()` 를 부르고 `:129` 가 진행 중 1 을 단언한다. 뒤쪽 순서를 쓰는 자리는 저장소에 없다.
|
||||
|
||||
## 세 메서드를 부르는 main 코드가 없다
|
||||
|
||||
`tryAdmit` 과 `promoteFromQueue` 와 `release` 를 호출하는 자리는 `src/grpc` 아래에서 아홉 줄인데 전부 `GrpcServerProfileTest` 다. main 소스 세트는 0 개 파일이다.
|
||||
|
||||
그중 승격을 부르는 줄은 `:127` 하나이고, 그 시험의 이름(`:120`)은 해제가 자리를 비우고 대기 중인 호출이 그 자리로 올라간다는 것이다. `:126` 이 `release()` 를 먼저 부른다. 저장소에 있는 유일한 호출자가 둘을 짝지어 부른다.
|
||||
|
||||
빈 정의는 있다. `GrpcPlatformAutoConfiguration:56`\~`:57` 이 `grpcAdmissionController` 를 만든다.
|
||||
|
||||
그 정의가 컨텍스트에 올라오는 조건은 좁다. 그 자동설정은 `:30`\~`:34` 에서 `ca-skeleton.grpc.platform.enabled` 가 `true` 일 때만 켜지고 `matchIfMissing` 이 `false` 다. 그 프로퍼티를 켜는 설정 파일은 저장소에 0 개이고, `src/grpc` 밖에서 이 starter 에 의존하는 모듈도 0 개이며, `src/grpc` 안에 `ApplicationContextRunner` 를 쓰는 파일도 0 개다.
|
||||
|
||||
main 소비자로 잡히는 `GrpcDrainCoordinator` 도 `:25` 의 필드 선언과 `:42` 의 생성자 인자일 뿐이다. 그 클래스를 생성하는 자리는 `GrpcDrainCoordinatorTest:26` 하나이고, 제어기에 거는 호출은 `:148` 의 `inFlight()` 읽기 한 줄이다.
|
||||
|
||||
## 같은 가족이 올바른 형태를 갖고 있다
|
||||
|
||||
`GrpcRetryBudget:50`\~`:58` 의 `tryConsume` 은 `while (true)` 안에서 `tokens.get()` 으로 읽고, 부족하면 `false` 를 돌려주고, 충분하면 `compareAndSet(observed, observed - tokensPerRetry)` 로 교체한다. 교체가 실패하면 루프가 다시 읽는다.
|
||||
|
||||
`compareAndSet` 을 쓰는 grpc main 파일은 `GrpcRetryBudget`, `GrpcChannelRuntimeRegistry`, `GrpcCancellationToken` 셋이다. 두 승인 클래스는 그 목록에 없다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 원자 정수를 쓰지만 원자적 연산은 하나도 하지 않는다고 적었고, 읽고 비교한 뒤 별도로 증가시킨다고 했다. 그 서술은 맞다.
|
||||
|
||||
원문은 대기열 승격을 "한 단계 더 나아간다"고 적으면서 대기열에서 승격되는 호출이 동시성 한도를 무조건 통과한다고 했다. 상한 필드를 읽지 않는다는 것까지는 맞지만, 진행 중 수가 상한을 넘으려면 승격이 해제를 앞질러야 한다. 저장소의 유일한 승격 시험은 둘을 짝지어 부른다.
|
||||
|
||||
원문은 이 클래스가 조립된다는 것을 근거의 하나로 들었다. 빈으로 만들어지는 것은 맞다. 세 승인 메서드를 부르는 main 코드는 0 건이다.
|
||||
|
||||
## 등급에 대해
|
||||
|
||||
원본 분석의 등급은 P2 다. 이 기록은 새로 매기지 않는다.
|
||||
|
||||
상류가 P2 로 둔 근거 — 부하 아래에서 지키라고 만든 상한이 부하 아래에서 샌다 — 는 코드 수준에서 성립하고, 관측된 초과 폭은 원본 분석이 적은 것보다 크다.
|
||||
|
||||
반대 근거는 오늘 이 코드를 도는 경로가 없다는 것이다. 세 메서드의 main 호출자가 0 이고, 빈 정의를 담은 자동설정은 아무도 켜지 않는 프로퍼티 뒤에 있으며, 그 starter 에 의존하는 모듈도 없다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
경합 쪽 수치는 자료에 없다. 초과 폭이 스레드를 맞춰 출발시키는 방식에 크게 좌우돼 안정된 값을 얻지 못했다.
|
||||
|
||||
실제 gRPC 서버를 띄워 요청을 흘리거나 부하를 걸지 않았다. 이 타입의 메서드를 직접 부른 결과까지다.
|
||||
|
||||
드레인 조정자가 그 값으로 무엇을 결정하는지는 조사하지 않았다.
|
||||
|
||||
동시 호출에서 상한이 넘어가는 것은 자료로 싣지 않은 실행에서 봤다. 관측되는 폭은 스레드를 어떻게 맞춰 출발시키느냐에 크게 달렸다. `CountDownLatch` 로 맞추면 이천 시행에서 위반이 0 회인 실행도 나오는데, 스핀 배리어로 바꾸면 스레드 열둘로 천 시행에 사백여든일곱 회가 상한을 넘고 `inFlight` 가 아홉까지, 예순넷으로는 구백쉰아홉 회에 열하나까지 올라간다. 어느 쪽도 안정된 값이 아니라 자료에는 실행마다 같은 값이 나오는 순서 비교만 실었다.
|
||||
|
||||
`GrpcDrainCoordinator` 가 `inFlight()` 를 읽어 무엇을 판단하는지는 읽지 않았다. 그것이 제어기에 하는 유일한 호출이라는 데까지 확인했다.
|
||||
|
||||
<!-- body:end -->
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a20-f007-grpcstreamadmission
|
||||
title: GrpcStreamAdmission 의 호출자 맵이 줄지 않고 main 소비자는 0 이다
|
||||
topic: grpc-and-streaming
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a20-f007-grpcstreamadmission
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-a20-f007-grpcstreamadmission.body.md
|
||||
assets:
|
||||
- key: a20-f007-grpcstreamadmission
|
||||
file: ../../../final/evidence/rendered/a20-f007-grpcstreamadmission.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a20-f007-grpcstreamadmission.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/20-grpc-platform.md#L572 이다.
|
||||
---
|
||||
|
||||
# GrpcStreamAdmission 의 호출자 맵이 줄지 않고 main 소비자는 0 이다
|
||||
|
||||
`GrpcStreamAdmission.tryAdmit` 이 호출자별 계수기와 전체 계수기를 각각 읽어 상한과 비교한 뒤 따로 늘린다. `release` 는 값만 줄이고 `perCaller` 항목은 지우지 않아 맵이 지금까지 본 지문 수만큼 남는다. 거기에 이 타입을 세우는 프로덕션 코드가 저장소에 하나도 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **스트림 승인의 경계가 동시성 아래에서 새고, caller별 맵이 줄지 않는다**
|
||||
같은 클래스의 같은 두 결함을 적은 기록이다. 그 기록은 원자성 분석으로 판정했고, 여기서는 줄 번호를 세고 단일 스레드 프로브로 맵이 두 규모에서 줄지 않는 것과 main 참조가 0 인 것을 확인했다.
|
||||
- **queued 를 줄이는 유일한 연산이 상한을 읽지 않는 승격 안에 있다**
|
||||
두 타입 모두 계수기를 읽어 상한과 비교한 뒤 별개 연산으로 늘린다. 다만 이 타입의 `release` 는 호출자별 계수와 전체 계수를 모두 내리는데, 그 기록의 승인 제어기는 `inFlight` 만 내린다.
|
||||
- **카디널리티 경계를 타입으로 표현하기**
|
||||
그 개념은 메트릭 태그의 값 공간이 트래픽과 함께 자라지 않도록 금지 목록과 태그별 상한과 폐쇄 집합으로 닫는 방법을 적는다. `perCaller` 는 호출자 지문을 키로 쓰는데 그 셋에 해당하는 장치가 하나도 없다.
|
||||
|
||||
## 문제
|
||||
|
||||
GrpcStreamAdmission 이 두 축에 상한을 건다.
|
||||
|
||||
그 두 상한이 어느 줄에서 검사되는지, perCaller 항목이 언제 지워지는지, 그리고 이 타입을 main 에서 만드는 코드가 있는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
읽는 줄 둘(:44, :47)과 늘리는 줄 둘(:50, :51)이 각각 별개의 연산이다. 그 사이에 다른 스레드가 끼어들 수 있다. release:58~:62 도 검사와 감소가 나뉘어 있다.
|
||||
|
||||
계수기를 지우는 코드는 없다. 이 맵이 나오는 네 줄은 선언(:17), computeIfAbsent(:43), get(:57, :73) 이고 제거 계열 호출은 0 건이다.
|
||||
|
||||
두 규모로 확인했다. 서로 다른 지문 셋으로 열고 전부 닫으면 맵이 3 으로 남고, 오만으로 하면 50,000 으로 남는다.
|
||||
|
||||
닫는 것과 무관한 경로도 있다. 항목을 만드는 줄이 상한 검사 두 줄보다 위에 있어, 거절로 끝나는 시도도 자기 항목을 남긴다. 상한을 (1, 1) 로 두고 하나만 승인한 뒤 오만 번 더 시도하면 전부 거절되는데 맵은 50,001 이 된다. 상한 두 개가 맵 크기는 전혀 제한하지 못한다.
|
||||
|
||||
같은 가족의 카디널리티 정책은 :24~:33 에 허용 태그 여덟을 열거하고 그 밖을 전부 거부한다. perCaller 쪽에는 그런 장치가 하나도 없다.
|
||||
|
||||
이 타입은 라이브러리 안에서도 쓰이지 않는다. 검색에 잡히는 파일이 셋뿐이고 프로덕션 쪽 사용처가 하나도 없다. 승인 제어기 쪽은 프로덕션 파일 둘이 쓴다. 다만 그 차이는 라이브러리 안에 머문다 — modules.json 의 grpc leaf 열여덟이 전부 빈 runtime_memberships 를 갖는다.
|
||||
|
||||
판정은 P2 이고 원문과 같다. 다만 상류가 든 근거에 반대 근거가 하나 있다. 세우는 코드가 없으니 이 두 상한은 오늘 어떤 프로세스에서도 계산되지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 승인·해제 메서드 본문과 필드 넷 확인, 자바독의 실패 시나리오 확인, perCaller 가 나오는 줄 전부와 소속 메서드 확인, 제거 계열 호출 일곱 이름 검색, 경로 제한 없는 참조 검색과 대조군 비교, 자원 파일·자동설정·리플렉션 경로 검색, 카디널리티 정책의 자바독과 허용 목록 전수, 서로 다른 지문 셋과 오만 두 규모로 열고 닫은 뒤 맵 크기를 단일 스레드로 관측
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 승인 메서드에서 값을 읽는 줄과 늘리는 줄을 각각 적는다.
|
||||
2. 해제 메서드에서 검사와 감소가 어느 줄인지 적고, 널 검사가 무엇을 막는지 읽는다.
|
||||
3. 호출자별 맵이 나오는 줄을 전부 찾고 각각 어느 메서드에 속하는지 적는다.
|
||||
4. 제거 계열 호출을 여러 이름으로 검색한다.
|
||||
5. 서로 다른 지문 셋으로 열고 전부 닫은 뒤 열린 수와 맵 크기를 읽는다.
|
||||
6. 같은 절차를 오만으로 반복해 규모가 따라 커지는지 본다.
|
||||
7. 같은 지문들로 한 번 더, 그리고 새 지문 하나로 맵 크기를 읽는다.
|
||||
8. 이 타입의 참조를 경로 제한 없이 검색하고 대조군과 비교한다.
|
||||
9. 자원 파일과 자동설정 목록과 리플렉션 경로도 검색한다.
|
||||
10. 같은 가족에서 값 공간 증가를 막는 정책을 찾아 그 근거와 목록을 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`GrpcStreamAdmission` 은 호출자별 스트림 수와 전체 동시 스트림 수를 상한으로 지킨다. 자바독(`:8`\~`:10`)은 스트림이 요청과 달라 수명 내내 연결과 큐와 생산자를 차지하므로 그 개수가 용량 숫자라고 적고, 상한이 없으면 오류마다 재접속하는 클라이언트가 예전 스트림이 닫히는 것보다 빠르게 새 스트림을 연다고 적는다.
|
||||
|
||||
## tryAdmit 과 release 에서 읽기와 쓰기가 나뉜 줄
|
||||
|
||||
:::evidence key="a20-f007-grpcstreamadmission" alt="저장소 루트에서 돌린 정적 검색과 /tmp/probe5 에서 컴파일해 돌린 프로브를 합친 출력 112줄. GrpcStreamAdmission 의 tryAdmit 과 release 본문이 38번부터 64번 줄까지 원문 그대로 실리고 필드 넷이 14번부터 18번 줄로 이어진다. 자바독의 실패 시나리오가 나오고, perCaller 가 나오는 네 줄이 각각 어느 메서드에 속하는지 주석과 함께 실리며 제거 계열 호출 검색이 0 건으로 나온다. 이어서 경로 제한 없는 git grep 이 이 타입을 언급하는 파일 셋과 대조군인 GrpcAdmissionController 의 일곱 파일을 나란히 내고, 자원 파일과 자동설정과 리플렉션 경로 검색이 각각 0 건이다. 같은 가족의 GrpcMetricCardinalityPolicy 자바독 네 줄과 ALLOWED_TAGS 선언 전체가 원소 여덟과 함께 나온다. 마지막으로 프로브 소스의 호출 줄이 실리고, 호출자 셋으로 돌린 결과와 오만으로 돌린 결과가 나란히 나오는데 둘 다 스트림을 모두 닫아도 맵 크기가 그대로이고 새 지문 하나에 하나씩 는다." caption="tryAdmit·release 의 본문과 필드 넷 · 자바독의 재접속 시나리오 · perCaller 네 줄과 제거 호출 0 건 · 경로 제한 없는 참조 목록과 대조군 · 카디널리티 정책의 자바독과 허용 태그 여덟 · 호출자 3 과 50,000 두 규모의 맵 크기 — 112줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`tryAdmit:43` 이 `computeIfAbsent` 로 호출자별 계수기를 얻는다. 값을 읽는 줄은 `:44` 와 `:47` 둘이고, 값을 늘리는 줄은 `:50` 과 `:51` 둘이다. 읽은 뒤 늘리기 전에 다른 스레드가 같은 값을 읽을 수 있다.
|
||||
|
||||
`release:57` 이 계수기를 꺼내고 `:58` 이 널이 아닌지와 0 보다 큰지를 함께 확인한 뒤 `:59` 가 줄인다. `:61` 이 전체 값을 확인하고 `:62` 가 줄인다. `:58` 의 널 검사가 `release` 를 맵에 항목을 새로 만들지 않는 메서드로 만든다.
|
||||
|
||||
## 스트림 오만 개를 모두 닫아도 perCaller 는 오만이다
|
||||
|
||||
`perCaller` 가 나오는 줄은 넷이다. 선언(`:17`), `tryAdmit` 안의 `computeIfAbsent`(`:43`), `release` 안의 `get`(`:57`), `openStreamsFor` 안의 `get`(`:73`)이다. `remove`·`clear`·`removeIf`·`compute`·`merge`·`keySet`·`entrySet` 을 통틀어 제거 계열 호출은 0 건이다.
|
||||
|
||||
맵 크기는 단일 스레드 프로브로 읽었다. 서로 다른 호출자 지문 셋으로 스트림을 열고 전부 닫으면 `openStreams()` 는 0 인데 맵 크기는 3 이다. 같은 지문들로 한 번 더 열고 닫아도 3 이고, 새 지문 하나를 더하면 4 가 된다. 오만으로 돌리면 같은 모양이 규모로 나타나 50,000 · 50,000 · 50,001 이 된다.
|
||||
|
||||
닫는 것과 무관하게 자라는 경로가 하나 더 있다. `computeIfAbsent`(`:43`)가 두 상한 검사(`:44`, `:47`)보다 앞에 있어, 상한에 걸려 거절되는 승인도 항목을 먼저 만든다.
|
||||
|
||||
상한을 `(1, 1)` 로 두고 확인했다. 스트림 하나를 승인한 뒤 서로 다른 지문으로 오만 번 더 시도하면 오만 번 모두 거절된다. `openStreams()` 는 1 로 상한을 지키는데 맵은 50,001 이다.
|
||||
|
||||
그러므로 두 상한은 맵 크기에 아무 상한도 주지 않는다. 승인된 스트림이 하나뿐인 동안에도 맵은 시도한 지문 수만큼 자란다.
|
||||
|
||||
자바독이 실패 시나리오로 적은 재접속 클라이언트가 재접속마다 다른 지문을 쓰는지는 확인하지 않았다. 다르다면 재접속 한 번마다 항목이 하나씩 늘어난다.
|
||||
|
||||
## 같은 가족이 값 공간의 증가를 막는 자리
|
||||
|
||||
`GrpcMetricCardinalityPolicy:12`\~`:15` 의 자바독은 문제가 나쁜 태그가 아니라 상한 없는 태그이고, 값 공간이 트래픽과 함께 자라는 태그 하나가 시계열 수를 그 공간만큼 곱한다고 적는다. 테넌트 식별자 하나가 시계열 백 개를 십만 개로 만든다는 것이다.
|
||||
|
||||
그래서 그 정책은 허용 목록으로 동작한다. `ALLOWED_TAGS` 는 `:24`\~`:33` 에 여덟 개가 열거돼 있고 그 밖은 전부 거부된다.
|
||||
|
||||
`perCaller` 에는 허용 목록도 크기 상한도 없다.
|
||||
|
||||
## GrpcStreamAdmission 을 쓰는 main 코드가 없다
|
||||
|
||||
경로 제한 없이 저장소 전체를 검색했다. 이 이름이 나오는 파일은 셋이다 — 자기 파일, `GrpcFlowControlPolicyTest`, 그리고 구현 계획 문서 하나다. main 소스 세트에서 자기 파일 밖 참조는 0 이다.
|
||||
|
||||
자원 파일과 자동설정 목록과 `Class.forName` 경로도 각각 0 건이다.
|
||||
|
||||
대조군은 다르다. `GrpcAdmissionController` 는 같은 검색에서 일곱 파일에 나오고 그중 `GrpcDrainCoordinator` 와 `GrpcPlatformAutoConfiguration` 이 main 이다.
|
||||
|
||||
다만 그 차이는 라이브러리 안에서의 차이다. `modules.json` 의 grpc leaf 열여덟 개는 `runtime_memberships` 가 모두 비어 있다. 승인 제어기도 배포 아티팩트에 실려 있지 않다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 `GrpcStreamAdmission` 을 승인 제어기와 "같은 형태"로 적었다. 읽기와 쓰기가 나뉜 것과 해제가 0 아래로 갈 수 있는 것까지는 그렇다.
|
||||
|
||||
원문이 적지 않은 것이 소비자다. 승인 제어기는 자동 설정과 드레인 조정자가 main 에서 쓰는데 이 타입은 그런 코드가 없다. 두 타입 다 배포 아티팩트에는 실려 있지 않으므로, 갈리는 것은 라이브러리 안의 사용처다.
|
||||
|
||||
원문은 맵이 "호출자 수만큼 자라고 줄지 않는다"고 적었다. 그 서술은 맞고, 자라는 경로가 하나 더 있다. 거절된 승인도 `computeIfAbsent` 를 먼저 지나므로 승인이 하나도 나지 않는 동안에도 맵이 자란다.
|
||||
|
||||
## 등급에 대해
|
||||
|
||||
원본 분석의 등급은 P2 다. 이 기록은 새로 매기지 않는다.
|
||||
|
||||
상류가 P2 로 둔 근거는 상한이 부하 아래에서 새는 것과 맵이 줄지 않는 것이다. 뒤쪽은 단일 스레드로 확인했고, 앞쪽은 재현 가능한 값을 얻지 못했다.
|
||||
|
||||
반대 근거는 이 타입을 만드는 main 코드가 0 이라는 것이다. 두 상한은 지금 어느 런타임에서도 검사되지 않는다. 채택자가 스트림 어댑터를 붙여 이 타입을 쓰기 시작하면 두 결함이 나타날 수 있는데, 그 배포를 띄워 확인하지는 않았다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
동시 부하에서 위반 시행이 나오는 것만 확인했다. 최댓값은 실행마다 달라 수치를 적지 않는다.
|
||||
|
||||
실제 클라이언트의 재접속을 일으킨 것이 아니다. 지문 문자열을 손으로 만들어 넣었다.
|
||||
|
||||
지문의 생성 규칙과, 문자열로 조립하는 참조는 좁히지 않았다.
|
||||
|
||||
호출자 지문이 무엇으로 만들어지는지 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a20-f008-grpcserializedstreamwriter-drop-oldest
|
||||
title: GrpcSerializedStreamWriter 가 버린 봉투 대신 들어오는 크기를 뺀다
|
||||
topic: grpc-and-streaming
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a20-f008-grpcserializedstreamwriter-drop-oldest
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-a20-f008-grpcserializedstreamwriter-drop-oldest.body.md
|
||||
assets:
|
||||
- key: a20-f008-grpcserializedstreamwriter-drop-oldest
|
||||
file: ../../../final/evidence/rendered/a20-f008-grpcserializedstreamwriter-drop-oldest.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a20-f008-grpcserializedstreamwriter-drop-oldest.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/20-grpc-platform.md#L595 이다.
|
||||
---
|
||||
|
||||
# GrpcSerializedStreamWriter 가 버린 봉투 대신 들어오는 크기를 뺀다
|
||||
|
||||
넘침 분기가 앞에서 봉투를 꺼내 버리면서 누적 바이트에서는 새로 들어오는 메시지의 크기를 뺀다. 봉투가 크기를 담지 않아 버린 봉투의 크기를 알 방법이 없다. 메시지 상한 3 으로 돌리면 누적이 1,000 에 머무는 동안 실제 큐는 3,000 바이트다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **queued 를 줄이는 유일한 연산이 상한을 읽지 않는 승격 안에 있다**
|
||||
그 기록에서는 큐 계수기를 내리는 유일한 줄이 승격 안에 묶여 있어 대기 값을 되돌릴 수단이 없다. 여기서는 넘침 분기가 버린 봉투 대신 들어오는 메시지 크기를 빼서 누적이 실제 큐 합계와 어긋난다.
|
||||
- **배치 상한이 개수와 바이트 두 축인 이유**
|
||||
그 개념은 messaging 의 배치 발행에서 개수와 바이트를 함께 두는 이유를 다룬다. 여기서는 같은 이유를 자기 자바독에 적은 gRPC 흐름 제어 정책이 실제와 다른 누적값으로 바이트 축을 판정한다.
|
||||
- **테스트에서는 드물고 부하에서는 일상인 것**
|
||||
대응 시험이 크기 공급자를 상수로 고정한다. 크기가 하나로 통일되면 두 값이 우연히 일치해 오차가 0 이 된다. 다만 이 결함은 동시 호출이 아니라 크기가 섞이는 것만으로 단일 스레드에서 재현된다.
|
||||
|
||||
## 문제
|
||||
|
||||
직렬 스트림 기록기가 넘침 정책 중 하나로 가장 오래된 것을 버린다.
|
||||
|
||||
그 분기가 누적 바이트를 어떻게 갱신하는지, 그리고 두 방향에서 결과가 무엇인지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
GrpcSerializedStreamWriter:82 가 queue.pollFirst() 로 봉투를 꺼내고 :84 가 queuedBytes 에서 nextBytes 를 뺀다. nextBytes 는 :73 에서 payloadSizer.getAsLong() 로 얻은 들어오는 메시지의 크기다.
|
||||
|
||||
올바른 값을 쓸 방법도 없다. GrpcStreamEnvelope 의 성분 일곱에 크기가 없고, 크기 공급자는 인자를 받지 않는다.
|
||||
|
||||
이 누적값은 흐름 제어 정책의 판정 입력이다. GrpcFlowControlPolicy:57 이 누적과 다음 메시지 크기의 합을 바이트 상한과 비교한다.
|
||||
|
||||
두 방향을 돌렸다. 작은 것을 버리며 큰 것을 넣으면 누적이 1,000 에 고정되는데 실제 큐는 3,000 이다. 순서를 뒤집으면 반대로 부풀어, 실제 큐가 상한의 4분의 1도 차지 않았는데 5,000 바이트짜리가 버려진다.
|
||||
|
||||
원문은 뒤쪽에서 조기 TERMINATE 가 된다고 적었는데 그렇게 되지 않는다. 이 정책값으로 decide 를 156,282 조합 불러도 그 결정은 0 회다. 두 분기는 같은 필드의 서로 다른 값에 매달려 있어 한 writer 안에서 함께 도달할 수 없다.
|
||||
|
||||
시험이 이 오차를 드러낼 수 없다. DROP_OLDEST 를 쓰는 유일한 자리가 크기 공급자에 상수를 주고, 단언 대상도 결과와 버린 수와 남은 수에 그친다.
|
||||
|
||||
배선은 없다. 프로덕션 쪽에 이 타입을 세우는 파일이 하나도 없다.
|
||||
|
||||
판정은 P2 이고 원문과 같다. 오차 자체는 양쪽 방향에서 실행으로 잡았다. 반대편에는 이 코드를 도는 인스턴스가 오늘 없다는 것과, 기본 프로파일이 아예 다른 분기를 탄다는 것이 있다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 넘침 분기와 enqueue 를 한 창에서 확인, 크기 획득 지점 추적, 봉투 타입의 성분 전수, 흐름 제어 정책의 판정식과 네 갈래 결정과 기본 팩토리 확인, 경로 제한 없는 참조 검색, 대응 시험의 크기 상수와 단언 확인, 두 방향으로 크기를 바꿔 넣어 회차별로 내부 누적값과 리플렉션으로 읽은 실제 큐 합계 비교, 이 정책값으로 decide 가 낼 수 있는 결정 전수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 넘침 정책 분기의 본문을 읽고 빼는 값이 어디서 왔는지 거슬러 올라간다.
|
||||
2. 같은 창에서 enqueue 가 그 값을 다시 더하는지 확인한다.
|
||||
3. 봉투 타입의 성분을 전부 나열해 크기가 있는지 본다.
|
||||
4. 크기 공급자의 시그니처를 읽어 버린 봉투로 되물을 수 있는지 본다.
|
||||
5. 흐름 제어 정책의 판정식과 자바독의 두 축 근거를 읽는다.
|
||||
6. 메시지 상한을 작게 두고 작은 것에서 큰 것으로 크기를 올려 가며 넣는다.
|
||||
7. 반대로 큰 것에서 작은 것으로 내려 가며 넣는다.
|
||||
8. 회차마다 내부 누적값과 큐를 직접 읽어 얻은 실제 합계를 비교한다.
|
||||
9. 같은 정책값으로 판정 함수를 조합 전수로 불러 나오는 결정을 센다.
|
||||
10. 대응 시험이 크기 공급자에 주는 값과 단언 대상을 읽는다.
|
||||
11. 이 타입의 참조를 경로 제한 없이 검색한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`GrpcSerializedStreamWriter` 는 봉투를 큐에 쌓고 하나의 기록기가 빼내며, 큐가 넘칠 때 무엇을 할지는 흐름 제어 정책의 느린 소비자 설정이 정한다. 그중 `DROP_OLDEST` 는 가장 오래된 봉투를 버리고 새 메시지를 받는 손실 허용 프로파일이다.
|
||||
|
||||
## 넘침 분기가 빼는 값
|
||||
|
||||
:::evidence key="a20-f008-grpcserializedstreamwriter-drop-oldest" alt="저장소 루트에서 돌린 정적 검색과 /tmp/probe5 에서 컴파일해 돌린 프로브를 합친 출력 174줄. GrpcSerializedStreamWriter 의 넘침 분기가 70번부터 107번 줄까지 실려 DROP_OLDEST 가 pollFirst 로 봉투를 꺼내고 누적에서 nextBytes 를 뺀 뒤 enqueue 가 같은 값을 다시 더하는 것이 한 화면에 보이고, 크기를 얻는 payloadSizer 줄들이 이어진다. GrpcStreamEnvelope 의 성분 일곱에 크기가 없다. GrpcFlowControlPolicy 는 40번부터 87번 줄까지 실려 stable 팩토리와 decide 의 네 갈래가 모두 나오는데 TERMINATE 분기와 DROP_OLDEST 분기와 PAUSE 와 PROCEED 가 각각 보이고, 개수와 바이트를 함께 두는 이유를 적은 자바독이 따라온다. 경로 제한 없는 검색이 이 타입을 언급하는 파일을 전부 내는데 자기 파일 밖 main 참조는 0 개다. 대응 시험의 헬퍼와 각 호출이 크기 공급자에 주는 상수들이 나오고, DROP_OLDEST 를 쓰는 시험이 무엇을 단언하는지 본문째 실린다. 마지막으로 프로브가 두 방향을 각각 표로 낸다. 작은 것을 버리며 큰 것을 넣으면 누적이 1000 에 머무는데 실제 큐는 3000 이고, 큰 것을 버리며 작은 것을 넣으면 누적이 20010 에 고정되는데 실제 큐는 30 까지 줄어 오차가 플러스 19980 이 된다. 그 아래에서 이 정책값으로 decide 를 156282 조합 불렀을 때 TERMINATE 가 0 회이고 나올 수 있는 결정이 DROP_OLDEST 와 PAUSE 와 PROCEED 셋뿐임이 나온다." caption="넘침 분기와 enqueue 가 같은 화면에 · 크기를 담지 않는 봉투 · decide 의 네 갈래와 stable 팩토리 · 자기 파일 밖 main 참조 0 · 시험이 크기 공급자에 주는 상수 · 두 방향의 회차별 표 · 이 정책값으로 TERMINATE 0/156,282 — 174줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:81`\~`:89` 의 `DROP_OLDEST` 분기는 `queue.pollFirst()` 로 봉투를 꺼내고(`:82`), 널이 아니면 `queuedBytes = Math.max(0L, queuedBytes - nextBytes)` 를 한다(`:84`). `nextBytes` 는 `:73` 에서 `payloadSizer.getAsLong()` 로 얻은 값, 곧 지금 들어오는 메시지의 크기다. 이어서 `:87` 의 `enqueue` 가 `:106` 에서 같은 `nextBytes` 를 다시 더한다.
|
||||
|
||||
꺼낸 봉투의 크기를 쓸 수도 없다. `GrpcStreamEnvelope` 는 성분 일곱을 담는데 — `streamId`, `sequence`, `kind`, `snapshotVersion`, `resumeToken`, `terminationReason`, `payload` — 크기가 없다. `payloadSizer` 는 인자를 받지 않는 `LongSupplier` 라 버린 봉투를 넘겨 되물을 수도 없다.
|
||||
|
||||
## GrpcFlowControlPolicy 가 누적 바이트를 상한과 비교한다
|
||||
|
||||
`:57` 이 `queuedBytes + nextMessageBytes > maxQueuedBytes` 로 바이트 넘침을 판정하고, `:56` 의 개수 넘침과 함께 `:58` 이 둘 중 하나라도 참이면 느린 소비자 정책으로 넘어간다.
|
||||
|
||||
자바독 `:6`\~`:8` 에는 개수와 바이트 중 하나만 두면 다른 축에 상한이 없어진다고 적혀 있다. 바이트 상한 없이 메시지 천 개만 제한하면 큐가 쓸 수 있는 메모리는 누군가 보낸 가장 큰 메시지가 정하고, 반대로 개수 상한 없이 바이트만 제한하면 큐 자체의 부대 비용에 상한이 없다.
|
||||
|
||||
## 두 방향을 돌린 결과
|
||||
|
||||
메시지 상한 3, 바이트 상한 1,000,000 으로 100 바이트 셋을 넣고 1000 바이트를 여섯 번 넣었다. 네 번째에서 개수 상한에 걸려 100 바이트짜리가 버려지는데 누적에서는 1000 이 빠진다. 첫 절단에서 `Math.max` 가 음수를 0 으로 잘라 그때까지의 누적이 사라지고, 그 뒤로는 빼는 값과 더하는 값이 같아 누적이 1000 에 고정된다. 실제 큐는 3,000 바이트다.
|
||||
|
||||
이 구성에서는 개수 상한 3 이 먼저 걸리므로 바이트 상한 1,000,000 은 도달하지 않는다. 바이트 경계가 늦게 발화하는 것을 직접 본 것은 아니고, 본 것은 큐가 3,000 바이트를 들고 있는 동안 판정에 들어가는 값이 1,000 이라는 것이다. 바이트 상한을 그 사이 어딘가로 잡은 배포에서는 큐가 개수 경계까지 임의 크기 메시지로 채워지고, 자바독이 막으려던 것이 그 상태다.
|
||||
|
||||
크기를 반대로 넣으면 누적이 실제보다 높아진다. 메시지 상한 3, 바이트 상한 25,000 으로 10,000 바이트 둘과 10 바이트 하나를 넣은 뒤 10 바이트를 계속 넣으면 누적이 20,010 에 고정되는데 실제 큐는 30 까지 줄어 오차가 19,980 이 된다. 그 상태에서 5,000 바이트를 넣으면 실제 큐가 5,020 뿐인데도 버려진다.
|
||||
|
||||
## 과대 계상 쪽은 원문과 결과가 다르다
|
||||
|
||||
원문은 이 방향에서 조기 `TERMINATE` 가 된다고 적었다. 그렇게 되지 않는다.
|
||||
|
||||
`GrpcFlowControlPolicy.decide` 를 이 정책값으로 156,282 조합 불러도 `TERMINATE` 는 0 회이고, 나오는 결정은 `DROP_OLDEST`·`PAUSE`·`PROCEED` 셋뿐이다. 넘침 판정이 `TERMINATE` 로 가는 분기(`:60`)는 느린 소비자 정책이 `TERMINATE` 일 때만 들어가는데, 잘못된 뺄셈이 있는 분기를 도는 writer 의 정책은 `DROP_OLDEST` 다. 두 값은 같은 record 의 같은 필드라 한 writer 안에서 바뀌지 않는다.
|
||||
|
||||
과대 계상은 종료가 아니라 아직 여유가 있는 큐에서 가장 오래된 봉투를 버리게 만든다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 과소 계상 쪽에서 누적이 `Math.max(0, ...)` 로 0 에서 멈춘다고 적었다. 관측된 것은 0 이 아니라 마지막에 들어온 메시지 하나의 크기다. 절단은 첫 회에만 일어나고 그 뒤로는 뺀 값과 더한 값이 같다.
|
||||
|
||||
원문은 과대 계상 쪽에서 조기 `TERMINATE` 가 된다고 적었다. 그 정책값으로는 `decide` 가 그 결정을 낼 수 없다.
|
||||
|
||||
분기가 빼는 값, 봉투의 성분 일곱, 판정식, 고정 크기 시험은 원문대로다.
|
||||
|
||||
## 대응 시험이 이것을 볼 수 없다
|
||||
|
||||
`GrpcSerializedStreamWriterTest:104` 가 `DROP_OLDEST` 정책을 쓰는 유일한 자리인데 크기 공급자를 `8L` 로 고정한다. 그 시험(`:101`\~`:111`)이 단언하는 것은 결과가 `DROPPED` 인 것과 `droppedMessages()` 가 1 인 것과 `queuedMessages()` 가 1 인 것이다. 누적 바이트를 보는 단언은 없다.
|
||||
|
||||
이 시험 파일의 다른 자리도 `1L`, `8L`, `16L` 로 고정한다. 모든 메시지가 같은 크기이면 잘못된 뺄셈이 옳은 값과 같아진다.
|
||||
|
||||
## GrpcSerializedStreamWriter 를 만드는 main 코드가 없다
|
||||
|
||||
경로 제한 없이 검색하면 이 이름은 자기 파일과 `GrpcSerializedStreamWriterTest` 와 문서 둘에 나온다. 자기 파일 밖에서 이 타입을 쓰는 main 파일은 0 개다.
|
||||
|
||||
## 등급에 대해
|
||||
|
||||
원본 분석의 등급은 P2 다. 이 기록은 새로 매기지 않는다.
|
||||
|
||||
상류가 P2 로 둔 근거는 판정 입력이 틀어진다는 것이고 그것은 두 방향 모두 실행으로 확인됐다.
|
||||
|
||||
반대 근거는 둘이다. 이 타입을 만드는 main 코드가 0 이라 오늘 이 계산을 도는 인스턴스가 없다. 그리고 원문이 적은 대로 `GrpcFlowControlPolicy.stable()` 의 느린 소비자 정책은 `TERMINATE` 이므로, 이 분기는 배포가 손실 허용 프로파일을 직접 고를 때만 들어간다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
gRPC 스트림을 실제로 열지 않았다. 클래스를 직접 인스턴스화해 호출했다.
|
||||
|
||||
필요한 값 둘 다 공개 접근자가 없어 관측에 리플렉션을 썼다. 실제 큐 합계도 같은 방식으로 봉투를 읽어 더했다.
|
||||
|
||||
배포에서 크기를 어떻게 재는지는 조사하지 않았다.
|
||||
|
||||
스트림을 열어 큰 메시지를 흘린 것이 아니다. 타입의 메서드를 직접 호출하고 내부 상태를 읽었다.
|
||||
|
||||
크기 공급자의 실제 구현은 범위 밖으로 두었다. 이 타입을 만드는 main 코드가 없으므로 그 자리도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a20-f010-grpcoutcomereplay
|
||||
title: GrpcOutcomeReplay 의 맵에 put·get·size 뿐이고 부르는 코드도 없다
|
||||
topic: grpc-and-streaming
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a20-f010-grpcoutcomereplay
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-a20-f010-grpcoutcomereplay.body.md
|
||||
assets:
|
||||
- key: a20-f010-grpcoutcomereplay
|
||||
file: ../../../final/evidence/rendered/a20-f010-grpcoutcomereplay.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a20-f010-grpcoutcomereplay.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/20-grpc-platform.md#L664 이다.
|
||||
---
|
||||
|
||||
# GrpcOutcomeReplay 의 맵에 put·get·size 뿐이고 부르는 코드도 없다
|
||||
|
||||
`maxInlineBytes` 는 응답 하나의 바이트 길이만 제한한다. `storedOutcomes` 에 가해지는 연산이 `put`·`get`·`size` 셋뿐이라 수를 줄이는 경로가 없다. 다만 `store` 를 부르는 프로덕션 코드가 없다. 자바독은 이 구현을 배포가 골라 쓰는 여러 방식 가운데 하나로 소개한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **GrpcStreamAdmission 의 호출자 맵이 줄지 않고 main 소비자는 0 이다**
|
||||
같은 `grpc-policy` 리프에 지우는 경로가 없는 맵이 하나 더 있다. `GrpcStreamAdmission` 은 거절된 승인도 `computeIfAbsent` 를 먼저 지나 항목을 남기고, `GrpcOutcomeReplay` 는 넣는 코드 자체가 없다.
|
||||
- **체크포인트 전진이 ConcurrentMap 위의 확인 후 쓰기다**
|
||||
그 기록은 `GrpcClientMessageDeduplicator` 의 체크포인트 맵을 다루는데 크기가 아니라 갱신의 원자성을 본다. 크기 쪽에서는 같은 클래스가 `endSession` 에 정리 메서드를 두고 있어 이 저장소와 갈린다.
|
||||
- **조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다**
|
||||
자료구조만으로는 유계인지까지만 말할 수 있고, 이 저장소가 실제로 자라는지는 `store` 를 부르는 코드를 찾은 뒤에야 판정된다.
|
||||
|
||||
## 문제
|
||||
|
||||
멱등 연산의 응답을 담는 저장소가 ConcurrentMap 하나로 되어 있다.
|
||||
|
||||
그 맵의 크기를 무엇이 제한하고, 무엇이 그것을 채우는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
제한하는 값은 항목 하나의 크기뿐이다. :41 이 maxInlineBytes 를 넘는 응답을 IllegalArgumentException 으로 돌려보내면서 객체 참조 뒤에 두라고 안내한다. 통과한 것은 clone() 사본으로 들어간다(:49).
|
||||
|
||||
수를 줄이는 코드는 없다. storedOutcomes 가 나오는 네 줄이 선언과 put(:49)과 get(:54)과 size(:60)이고, 클래스가 final 이며 필드가 private final 이고 맵을 돌려주는 접근자가 없어 밖에서도 줄일 수 없다. 줄이는 연산 이름 열여덟도 0 건이다.
|
||||
|
||||
프로브로 확인했다. 서로 다른 키 천 개를 넣으면 size 가 1001 이고, 그 천 개를 모두 읽어도 같은 키로 다시 써도 1001 이다.
|
||||
|
||||
그 값을 보고 판단하는 프로덕션 코드도 없다. size() 를 읽는 자리는 GrpcIdempotencyInterceptorTest:166 의 단언 하나다.
|
||||
|
||||
넣는 코드도 없다. 이 이름이 나오는 파일은 자기 파일과 그 시험과 구현 계획 문서 하나이고, 자기 파일 밖 main 참조가 0 이다. 같은 패키지의 GrpcIdempotencyInterceptor:99 조차 참조만 돌려주고 이 저장소를 부르지 않는다.
|
||||
|
||||
대조 구현에 같은 잣대를 대면 GrpcClientMessageDeduplicator 도 main 참조가 0 이고 endSession 을 부르는 코드도 없다. 두 클래스의 차이는 도는지 여부가 아니라 자바독과 API 설계에 있다.
|
||||
|
||||
두 클래스가 속한 family 도 다르다. src/grpc-advanced/CLAUDE.md:15 와 :74 가 grpc:* 에서 grpc-advanced:* 를 참조하는 것을 금지하고 registry 가 거부한다고 적는다.
|
||||
|
||||
원본 분석의 등급은 P2 인데 그것을 떠받치던 인과가 이 리비전에서 성립하지 않는다. 이 기록은 새로 매기지 않고 양쪽 근거를 함께 남긴다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 클래스 62 줄 전수 확인, storedOutcomes 가 나오는 줄 전수와 클래스·필드 한정자 확인, 줄이는 연산 이름 열여덟 검색과 대조 파일 검색, 크기 상한의 거부와 저장·조회·덮어쓰기 뒤의 크기를 프로브로 관측, 이 이름이 나오는 파일 전수와 소스 세트 분리, 같은 패키지 인터셉터의 재생 판정 확인, 대조 클래스의 자바독과 정리 메서드와 그 클래스의 main 참조 계수, modules.json 의 family 키 검색과 두 권위 문서의 family 선언 및 참조 금지 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 클래스를 처음부터 끝까지 읽고 필드와 공개 메서드를 적는다.
|
||||
2. 맵 필드 이름이 나오는 줄을 전부 찾아 어떤 연산이 가해지는지 센다.
|
||||
3. 클래스와 필드의 한정자를 읽고 맵을 돌려주는 접근자가 있는지 본다.
|
||||
4. 줄이는 연산 이름을 여러 가지로 검색하고, 같은 검색을 다른 파일에 걸어 검색이 도는지 확인한다.
|
||||
5. 크기 상한을 넘는 응답을 넣어 거부 메시지를 받고, 그 뒤 크기가 그대로인지 본다.
|
||||
6. 서로 다른 키로 여럿 넣은 뒤 읽기와 덮어쓰기가 크기를 줄이는지 확인한다.
|
||||
7. 이 이름이 나오는 파일을 경로 제한 없이 찾고 소스 세트로 나눈다.
|
||||
8. 같은 패키지에서 이 저장소를 쓸 법한 클래스를 열어 실제로 부르는지 본다.
|
||||
9. 대조 구현을 찾아 자바독과 정리 메서드를 읽고, 같은 방식으로 그 클래스의 참조도 센다.
|
||||
10. 두 클래스가 같은 family 인지 registry 와 각 디렉터리의 권위 문서에서 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`GrpcOutcomeReplay` 는 62 줄짜리 클래스이고 내부는 `ConcurrentMap<String, byte[]> storedOutcomes` 하나(`:18`)다. 클래스 자바독 `:10`\~`:14` 는 원장과 이 저장소를 나눈 이유를 적는다. 원장 행은 작아야 하고 업무 트랜잭션 안에서 쓰이는데 응답은 클 수 있고 누가 실제로 재시도할 때만 필요하다는 것이다. 그래서 원장은 참조만 저장하고, 그 참조를 배포가 고른 대상 — 작은 인라인 저장소, 객체 저장소, 커밋된 자원의 재조회 — 에 대해 이 클래스가 해석한다. 여기 있는 구현이 그중 인라인 저장소 쪽이다.
|
||||
|
||||
## storedOutcomes 에 가해지는 연산 넷
|
||||
|
||||
:::evidence key="a20-f010-grpcoutcomereplay" alt="저장소 루트에서 돌린 정적 검색과 /tmp/probe5 에서 컴파일해 돌린 프로브를 합친 출력 166줄. GrpcOutcomeReplay.java 62 줄이 통째로 실린다. 이어서 storedOutcomes 라는 이름이 나오는 네 줄이 선언과 put 과 get 과 size 로 나오고, 클래스가 final 이며 필드가 private final 이라는 두 줄이 붙는다. 줄이는 연산 이름 열여덟 가지를 통틀어 0 건이고, 같은 검색을 대조 파일에 걸면 0 이 아니다. 프로브가 16 바이트를 넣고 17 바이트를 거부당한 메시지를 그대로 내며, 서로 다른 키 천 개를 넣은 뒤 size 가 1001 이고 모두 replay 해도 같은 키로 덮어써도 그대로임을 보인다. 이 이름이 나오는 파일이 셋으로 나열되고 자기 파일 밖 main 참조가 0 이며 추적되지 않는 변경도 없고 java 밖 파일이 하나다. 같은 패키지의 인터셉터가 재생을 판정하는 열여섯 줄이 실리는데 그 파일은 이 타입을 0 번 부른다. 대조 클래스는 grpc-advanced-streaming 리프에 있고 자바독 두 문단과 endSession 세 줄이 나오며 그 클래스의 자기 파일 밖 main 참조도 0 이다. 마지막으로 modules.json 에 family 키가 0 건이라는 것과 두 CLAUDE.md 가 각각 다른 family 의 권위 문서임을 밝히는 대목, 그리고 Stable 이 advanced 를 참조하는 것이 registry 에서 거부된다는 두 줄이 나온다." caption="클래스 62줄 전체 · 맵에 가해지는 연산 넷과 final·private final · 줄이는 이름 열여덟 0 과 대조 검색 · 거부 메시지와 단조 증가하는 size · 이름이 나오는 파일 셋과 main 참조 0 · 같은 패키지 인터셉터의 재생 판정 · 대조 클래스도 main 참조 0 · 두 family 의 경계와 참조 금지 — 166줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`storedOutcomes` 라는 이름은 네 줄에 나온다. 선언(`:18`), `store` 안의 `put`(`:49`), `replay` 안의 `get`(`:54`), `size()` 의 `return`(`:60`)이다. 이 맵에 가해지는 연산은 그 셋이 전부다.
|
||||
|
||||
밖에서 줄일 수도 없다. 클래스가 `final`(`:16`)이고 필드가 `private final` 이며 맵을 돌려주는 접근자가 없다.
|
||||
|
||||
이름으로도 확인했다. `remove`·`clear`·`removeIf`·`computeIfPresent`·`merge`·`entrySet`·`keySet`·`evict`·`expire`·`TTL`·`Duration`·`Instant`·`maximumSize`·`Caffeine`·`LinkedHashMap`·`WeakReference`·`SoftReference`·`Cleaner` 열여덟을 통틀어 0 건이고, 같은 검색을 대조 파일에 걸면 0 이 아니다.
|
||||
|
||||
`maxInlineBytes`(`:19`)는 `store` 안에서 `serializedResponse.length` 와 비교되고(`:41`), 그보다 큰 응답은 `IllegalArgumentException` 으로 거부되며 메시지가 객체 참조 뒤에 두라고 적는다. 통과한 응답은 `serializedResponse.clone()`(`:49`)으로 복사돼 들어간다.
|
||||
|
||||
## 넣으면 줄지 않는다
|
||||
|
||||
프로브로 확인했다. 16 바이트를 넣으면 `size` 가 1 이고, 17 바이트는 거부되며 거부 뒤에도 `size` 는 그대로다.
|
||||
|
||||
서로 다른 키 천 개를 넣으면 1001 이 된다. 그 천 개를 모두 `replay` 해도 1001 이고, 같은 키로 천 번 덮어써도 1001 이다. 읽기도 덮어쓰기도 수를 줄이지 않고, 늘어나는 축은 서로 다른 참조 문자열의 개수다.
|
||||
|
||||
`size()` 의 반환값을 읽는 프로덕션 코드는 없다. 읽는 자리는 `GrpcIdempotencyInterceptorTest:166` 의 `assertThat(replay.size()).isEqualTo(1)` 하나다.
|
||||
|
||||
## store 를 부르는 main 코드가 없다
|
||||
|
||||
이 이름이 나오는 파일은 셋이다. 자기 파일, `GrpcIdempotencyInterceptorTest`, 그리고 구현 계획 문서 하나(두 줄)다. 자기 파일 밖 main 참조는 0 이고, 추적되지 않는 변경도 0 이다.
|
||||
|
||||
같은 패키지에 인터셉터가 있는데 그것도 이 타입을 부르지 않는다. `GrpcIdempotencyInterceptor:98`\~`:107` 은 원장 기록이 `COMMITTED` 이면 `GrpcIdempotencyDecision.replay(record.outcomeReference())` 로 **참조만** 돌려준다. 참조를 만들고 소비하는 쪽은 main 에 있고, 그 참조를 이 저장소에 넣는 코드만 없다.
|
||||
|
||||
자바독이 적은 세 선택지 가운데 어느 것을 고를지는 배포의 몫이므로, 이 리비전에서 인라인 저장소가 배선되지 않은 것 자체는 설계와 어긋나지 않는다.
|
||||
|
||||
## 대조 구현에 같은 잣대를 대면
|
||||
|
||||
`GrpcClientMessageDeduplicator` 의 자바독 `:11`\~`:14` 는 본 키를 집합으로 들면 세션 수명 동안 경계 없이 자라고, 그것이 답하는 "본 적이 있는가" 는 필요한 질문이 아니며, 필요한 질문인 "적용됐는가" 는 단조 증가하는 적용 순번이 상수 공간으로 답하고 집합과 달리 프로세스 재시작도 견딘다고 적는다. `endSession:110`\~`:112` 가 체크포인트를 지우고 그 세션 접두사를 가진 재생 가능 결과를 지운다.
|
||||
|
||||
그런데 그 클래스도 자기 파일 밖 main 참조가 0 이고, `endSession` 을 부르는 main 코드도 없다. 이 기록이 이 저장소에 댄 잣대 — 부르는 코드가 없으면 오늘 일어나지 않는다 — 를 그대로 대면 두 클래스는 같은 상태다.
|
||||
|
||||
그러므로 이것은 도는 코드 둘의 대비가 아니라 자바독과 API 설계의 대비다. 한쪽은 무한 증가를 설계 문제로 이름 붙이고 정리 메서드를 두었고, 한쪽은 그런 자바독도 그런 메서드도 없다.
|
||||
|
||||
## 두 클래스는 다른 family 다
|
||||
|
||||
`modules.json` 에 `family` 키는 0 건이다. family 를 정하는 것은 각 디렉터리의 권위 문서다.
|
||||
|
||||
`src/grpc/CLAUDE.md:1`·`:3` 이 `grpc:*` family 의 local authority 라고 적고, `src/grpc-advanced/CLAUDE.md:3` 이 `grpc-advanced:*` family 의 local authority 라고 적는다. `:7`\~`:9` 는 후자가 Stable 플랫폼이 의도적으로 제외한 능력을 담으며 디렉터리와 Gradle 접두사를 나눈 이유가 "Stable starter 가 advanced module 을 참조하면 build 가 실패한다" 는 불변 조건을 기계로 검증하기 위해서라고 적는다.
|
||||
|
||||
`:15` 와 `:74` 가 `grpc:*` 에서 `grpc-advanced:*` 로 가는 참조를 금지로 못박는다. registry 가 거부한다는 것이다.
|
||||
|
||||
그러므로 대조 구현을 이쪽으로 옮겨 오는 것은 지금 구조에서 허용되지 않는다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 `store()` 가 멱등 키를 요구하는 메서드가 커밋될 때마다 호출되므로 프로세스 수명 동안 커밋한 멱등 연산 수만큼 항목이 쌓인다고 적었다. main 에는 `store` 를 부르는 줄이 없고 같은 패키지의 인터셉터도 참조 반환에서 멈춘다.
|
||||
|
||||
원문은 비교 대상이 "같은 리프 안에" 있다고 적었다. `GrpcClientMessageDeduplicator` 는 `grpc-advanced-streaming` 이고 이 클래스는 `grpc-policy` 이며, 두 디렉터리는 서로 다른 family 이고 이쪽에서 저쪽을 참조하는 것이 금지돼 있다.
|
||||
|
||||
크기 상한이 항목 하나만 제한한다는 것과 줄이는 경로가 없다는 것은 원문대로다.
|
||||
|
||||
## 등급에 대해
|
||||
|
||||
원본 분석의 등급은 P2 다. 그 등급을 떠받치던 인과 — 커밋마다 `store` 가 불려 쌓인다 — 는 이 리비전에서 성립하지 않는다. 부르는 코드가 없고, 같은 가족의 자매 클래스도 같은 상태이며, 자바독은 이 구현을 배포가 고를 수 있는 세 선택지 중 하나로 적는다.
|
||||
|
||||
이 기록은 등급을 새로 매기지 않는다. 상류가 P2 로 둔 근거와 여기서 확인한 반대 근거를 함께 남기고, 재감정은 상류의 몫으로 둔다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
프로세스를 오래 띄워 증가를 계측하지 않았다. 줄이는 코드의 부재와, 넣고 읽는 동안 수가 유지되는 것을 확인했다.
|
||||
|
||||
참조 계수는 이름 일치와 추적된 파일만 본다. 문자열로 조립하는 사용은 잡히지 않는다.
|
||||
|
||||
이 저장소를 인터셉터에 물린 채택자 코드는 보지 못했다.
|
||||
|
||||
<!-- body:end -->
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a20-f011-grpccompletionreconciler-arraylist
|
||||
title: GrpcCompletionReconciler 의 pending 만 잠금 없는 ArrayList 다
|
||||
topic: grpc-and-streaming
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a20-f011-grpccompletionreconciler-arraylist
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-a20-f011-grpccompletionreconciler-arraylist.body.md
|
||||
assets:
|
||||
- key: a20-f011-grpccompletionreconciler-arraylist
|
||||
file: ../../../final/evidence/rendered/a20-f011-grpccompletionreconciler-arraylist.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a20-f011-grpccompletionreconciler-arraylist.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/20-grpc-platform.md#L678 이다.
|
||||
---
|
||||
|
||||
# GrpcCompletionReconciler 의 pending 만 잠금 없는 ArrayList 다
|
||||
|
||||
`GrpcCompletionReconciler.pending` 은 `ArrayList` 이고, `reconcile` 과 `clearPending` 이 잠금 없이 구조를 바꾸는 동안 `pendingCases()` 가 잠금 없이 그것을 순회한다. 같은 리프의 `GrpcCancellationCoordinator` 는 같은 모양의 맵을 들고 공개 메서드 다섯을 모두 `synchronized` 로 닫았다. 다만 이 타입은 오늘 어디에서도 만들어지지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **queued 를 줄이는 유일한 연산이 상한을 읽지 않는 승격 안에 있다**
|
||||
그 기록은 원자 타입 위의 검사 후 실행이고 여기는 잠금 없는 컬렉션이다. 둘 다 타입이 주는 보증과 코드가 필요로 하는 보증이 다른 자리다.
|
||||
- **의도적으로 스레드 안전하지 않은 타입이 하나 있다**
|
||||
그 기록의 `BoundedByteSink` 는 안전하지 않다는 것을 자바독에 적고 호출마다 새로 만들어 쓴다. 여기에는 그런 표기도 그런 사용 규약도 없다.
|
||||
- **실행 코드가 없어서 동시성 계약이 전부 문서다**
|
||||
그 리프는 실행 코드가 없어 계약을 문서로만 표현한다. 여기는 실행 코드가 있고 그것을 부르는 코드가 없으며, 운영 문서가 그 동작을 미리 적어 두었다.
|
||||
|
||||
## 문제
|
||||
|
||||
GrpcCompletionReconciler 가 원장이 답을 주지 못한 연산을 목록에 모은다.
|
||||
|
||||
그 목록이 어떤 자료구조이고 어느 메서드가 그것을 바꾸거나 읽는지, 무엇이 언제 넣는지, 같은 리프가 같은 모양을 어떻게 다루는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
pending 은 new ArrayList<>()(:25)다. 닿는 자리는 셋인데 하는 일이 다르다 — reconcile:79 의 add 와 clearPending:91 의 remove 는 구조를 바꾸고, pendingCases:86 의 List.copyOf 는 순회해 복사한다. 어느 쪽에도 잠금이 없고 동기화 마커 일곱 가지가 모두 0 이다.
|
||||
|
||||
들어가는 조건은 좁다. :64~:77 이 COMMITTED·FAILED_TERMINAL·업무 조회가 있는 NOT_FOUND 를 모두 먼저 반환하므로, :78 에 닿는 것은 GrpcCompletionResolution:50 이 정의한 UNKNOWN 과 IN_PROGRESS 뿐이다. 앞쪽은 원장 장애 시점이다.
|
||||
|
||||
같은 리프에 같은 모양이 하나 더 있다. GrpcCancellationCoordinator:27 도 동시 자료구조가 아닌 LinkedHashMap 을 오래 들고 있는데, 공개 메서드 register·cancel·markCommitBoundaryCrossed·businessEffectAborted·reason 이 모두 synchronized 다. grpc-policy 안에서 그런 파일은 여덟이다.
|
||||
|
||||
위험은 리스트 구조에 갇혀 있다. pending 은 밖으로 나가지 않고 원소인 PendingCase 는 record 다.
|
||||
|
||||
프로덕션 호출자는 없다. 자기 파일 밖 main 참조가 0 개이고, 같은 검색이 짝 클래스에서는 0 이 아니다.
|
||||
|
||||
운영 런북이 이 클래스의 동작과 에스컬레이션 조건을 적어 두었지만, 같은 문서 :3~:6 이 이 가족 전부가 build-only 라 프로덕션에서 도는 것이 없다고 먼저 밝힌다. 요구하는 스위치는 코드에 정의가 0 개다.
|
||||
|
||||
판정은 P2 이고 원문과 같다. 코드 쪽 사실은 확인됐고, 요청 경로라는 근거는 그것을 부르는 자리가 없어 오늘 서지 않는다. 대신 그 상황을 다룰 절차가 이미 문서에 있고 리스트는 지금 모양 그대로라는 사실이 남는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 클래스 자바독과 목록 선언 확인, pending 이 나오는 줄 전수와 구조 변경·순회 지점 구분, 동기화 마커 일곱을 이 파일·짝 파일·리프 전체 세 열로 계수, 같은 모양의 필드를 가진 클래스 검색과 그 공개 메서드 확인, reconcile 의 분기 전수와 requiresReconciliation 정의 확인, 경로 제한 없는 참조 검색과 대조 타입 계수, 운영 런북의 앞머리와 해당 절차 확인, 그 런북이 요구하는 프로퍼티의 정의 검색
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 목록 필드의 선언과 클래스 자바독을 읽는다.
|
||||
2. 그 이름이 나오는 줄을 전부 찾고 구조를 바꾸는 것과 아닌 것을 가른다.
|
||||
3. 동기화 마커를 여러 이름으로 세되, 같은 검색을 같은 리프의 다른 파일에도 걸어 검색이 도는지 확인한다.
|
||||
4. 그 검색을 리프 전체에 걸어 마커를 가진 파일을 센다.
|
||||
5. 같은 모양의 필드를 가진 클래스를 찾고 그 공개 메서드의 잠금 여부를 읽는다.
|
||||
6. 목록에 넣는 줄까지 오는 분기를 처음부터 따라가고, 그 조건 메서드의 정의를 읽는다.
|
||||
7. 이 타입의 참조를 경로 제한 없이 검색하고, 같은 방식으로 대조 타입도 센다.
|
||||
8. 문서 참조가 있으면 그 문서의 앞머리부터 읽어 절차에 조건이 걸려 있는지 본다.
|
||||
9. 그 문서가 요구하는 설정 프로퍼티가 코드에 정의돼 있는지 찾는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`GrpcCompletionReconciler` 는 원장이 답을 주지 못한 gRPC 호출을 목록에 모아 두는 93 줄짜리 클래스다. 그 목록은 `new ArrayList<>()`(`:25`)이고 감싸는 것이 없다.
|
||||
|
||||
## pending 을 바꾸는 두 자리와 순회하는 한 자리
|
||||
|
||||
:::evidence key="a20-f011-grpccompletionreconciler-arraylist" alt="저장소 루트에서 돌린 정적 검색 출력 137줄. GrpcCompletionReconciler 의 클래스 자바독과 ArrayList 선언이 8번부터 26번 줄까지 실리고, pending 이라는 이름이 나오는 줄이 전부 나오는데 선언과 add 와 copyOf 와 remove 넷에 예외 메시지 둘이다. 동기화 마커 일곱 가지를 이 파일과 같은 리프의 GrpcCancellationCoordinator 와 리프 전체에 각각 걸어 세 열로 비교한 표가 나오는데 이 파일은 전부 0 이고 짝 파일은 synchronized 다섯이다. 이어서 같은 검색을 리프 62 파일에 걸어 표시자를 가진 여덟 파일이 나열된다. pending.add 가 실행되는 조건이 reconcile 본문 전체와 함께 실리고, requiresReconciliation 이 UNKNOWN 이나 IN_PROGRESS 일 때만 참이라는 정의가 따라온다. 같은 모양의 필드를 가진 클래스 다섯이 나오고 그중 GrpcCancellationCoordinator 의 공개 메서드가 전부 synchronized 인 것이 보인다. 참조 검색은 자기 파일 밖 main 이 0 개이고 대조 타입은 0 이 아니다. 마지막으로 운영 런북의 첫 아홉 줄이 실려 이 가족 전부가 build-only 라 여기 있는 것 중 프로덕션에서 도는 것이 없다고 문서가 스스로 밝히는 대목이 나오고, 그 런북이 요구하는 스위치가 코드에 정의된 파일 수와 지원 매트릭스의 같은 서술, 그리고 이 클래스에 기대하는 절차가 이어진다." caption="ArrayList 선언과 pending 이 나오는 줄 전부 · 표시자 일곱을 이 파일·짝·리프에 건 세 열 비교 · 리프 62 중 여덟 · add 의 실행 조건과 requiresReconciliation 정의 · 같은 모양 필드 다섯과 짝의 synchronized · 자기 파일 밖 main 0 · 런북의 build-only 자기 규정 — 137줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`pending` 이라는 이름은 여섯 줄에 나온다. 선언(`:25`), `reconcile` 안의 `add`(`:79`), `pendingCases()` 의 `List.copyOf`(`:86`), `clearPending` 의 `remove`(`:91`), 그리고 `PendingCase` 생성자의 예외 메시지 둘(`:34`, `:37`)이다.
|
||||
|
||||
셋이 잠금 없이 같은 리스트에 닿는데 하는 일이 다르다. `add`(`:79`)와 `remove`(`:91`)는 구조를 바꾸고, `List.copyOf(pending)`(`:86`)은 그 목록을 순회해 복사한다.
|
||||
|
||||
원문은 두 실패 모드를 따로 적었다. 동시 `add` 는 원소 유실이나 `ArrayIndexOutOfBoundsException` 이고, `add` 가 도는 중의 `List.copyOf` 는 `ConcurrentModificationException` 이나 널 원소로 인한 `NullPointerException` 이다. `pending` 이 담는 것은 사람이 조정해야 하는 연산이므로 유실은 조정되지 않은 채 잊히는 연산이 된다. 이 기록은 그 실행을 재현하지 않았다.
|
||||
|
||||
위험이 리스트 구조에 갇혀 있기는 하다. `pending` 은 `private final` 이고 밖으로 나가는 것은 `List.copyOf` 로 만든 불변 복사뿐이라 가변 참조가 새지 않는다. `PendingCase` 는 record 라 원소 자체도 불변이다.
|
||||
|
||||
## 항목이 들어가는 조건
|
||||
|
||||
`reconcile:62`\~`:82` 를 따라가면 `add` 에 닿는 길이 좁다.
|
||||
|
||||
`:64`\~`:67` 이 `COMMITTED` 와 `FAILED_TERMINAL` 을 먼저 돌려보낸다. `:68`\~`:77` 은 `NOT_FOUND` 이고 업무 조회가 주어진 경우인데, 조회가 결과를 보이면 `:71` 에서, 아니면 `:76` 에서 반환한다. 어느 쪽이든 `:78` 에 닿지 않는다.
|
||||
|
||||
`:78` 의 `requiresReconciliation()` 은 `GrpcCompletionResolution:50` 에서 `status == UNKNOWN || status == IN_PROGRESS` 다.
|
||||
|
||||
즉 목록에 쌓이는 것은 원장을 조회하지 못했거나 청구가 아직 진행 중인 경우다. 앞쪽은 원장 장애 시점이고, 그때가 호출이 가장 몰리는 때다.
|
||||
|
||||
## 같은 리프의 짝은 같은 모양을 잠근다
|
||||
|
||||
동기화 마커 일곱 가지를 이 파일과 `GrpcCancellationCoordinator` 와 리프 전체에 각각 걸었다. 이 파일은 전부 0 이고, 짝 파일은 `synchronized` 다섯이다. 같은 검색이 한쪽에서 0 이 아닌 값을 내므로 검색이 헛돌지 않는다.
|
||||
|
||||
두 클래스는 모양이 같다. `GrpcCancellationCoordinator:27` 이 `Map<String, GrpcCancellableOperation> operations = new LinkedHashMap<>()` 를 들고, 이 클래스가 `List<PendingCase> pending = new ArrayList<>()` 를 든다. 둘 다 동시 자료구조가 아니고 둘 다 오래 사는 객체다.
|
||||
|
||||
갈리는 것은 그다음이다. 짝의 공개 메서드는 `register`(`:45`), `cancel`(`:69`), `markCommitBoundaryCrossed`(`:90`), `businessEffectAborted`(`:100`), `reason`(`:110`)이 모두 `synchronized` 다. 이 클래스에는 하나도 없다.
|
||||
|
||||
이 짝만 그런 것도 아니다. 리프를 전부 훑으면 여덟 파일이 잠금이나 동시 자료구조를 쓴다. 이 리프가 동시성을 다루지 않는 것이 아니다.
|
||||
|
||||
## 런북은 자기가 아직 안 도는 것을 알고 쓰였다
|
||||
|
||||
운영 런북이 이 클래스를 안내한다는 것은 원문에 없다. `docs/runbooks/grpc-platform-operations.md:36`\~`:37` 은 `UNKNOWN` 이 원장을 조회할 수 없었다는 뜻이고 아무것도 결론지을 수 없으며 그 사례는 이 클래스가 큐에 넣고 나중에 재시도한다고 적는다. `:41`\~`:42` 는 그 목록이 여러 차례에 걸쳐 자라면 에스컬레이션하라고 적는다.
|
||||
|
||||
그러나 같은 문서 `:3`\~`:6` 이 먼저 조건을 건다. 범위가 `:grpc:*` 가족이고 오늘 전부 build-only 이며 모든 leaf 의 `runtime_memberships` 가 비어 있어 여기 있는 것 중 프로덕션에서 도는 것이 없다는 것이다. 그렇게 미리 쓰는 이유도 적는다 — 이 문서가 다루는 상태들은 당직자가 새벽 세 시에 원리부터 풀어낼 수 있는 것이 아니고, 동작을 런북보다 먼저 출하하면 그것을 처음 만나는 사람이 그 일을 하게 된다는 것이다.
|
||||
|
||||
`:9` 는 `ca-skeleton.grpc.platform.enabled` 가 `true` 여야 플랫폼이 시작된다고 적는데, 그 프로퍼티를 정의하는 파일이 저장소에 0 개다. `docs/compatibility/grpc-support-matrix.md:66`\~`:67` 도 배포 아티팩트가 이 가족을 싣지 않는다고 따로 적는다.
|
||||
|
||||
그러므로 오늘 이 절차를 밟는 운영자는 없다. 남는 것은 그 절차가 이미 적혀 있고, 배선되는 순간 그것이 읽는 대상이 이 잠금 없는 리스트라는 것이다.
|
||||
|
||||
## 이 타입을 만드는 프로덕션 코드가 없다
|
||||
|
||||
자기 파일 밖에서 이 타입을 쓰는 main 파일은 0 개다. 참조는 자기 파일 둘, `GrpcCompletionReconcilerTest` 다섯 줄, 그리고 문서 셋이다. 같은 검색을 `GrpcCancellationCoordinator` 에 걸면 0 이 아니다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 이 리프에서 스레드 안전성을 명시적으로 다루는 유일한 클래스가 `GrpcSerializedStreamWriter` 라고 적었다. 62 파일 중 여덟이 동시성 구성을 쓴다.
|
||||
|
||||
원문은 `reconcile` 이 완료 결과가 불확실한 호출마다 불린다고 적었다. 목록에 쌓이는 조건은 그보다 좁아서 `UNKNOWN` 과 `IN_PROGRESS` 둘뿐이고, `NOT_FOUND` 는 업무 조회가 있는 경로에서 `:78` 에 닿지도 않는다.
|
||||
|
||||
`pending` 이 `ArrayList` 라는 것, 두 자리가 잠금 없이 구조를 바꾸고 한 자리가 잠금 없이 순회한다는 것, 마커가 0 이라는 것은 원문대로다.
|
||||
|
||||
## 등급에 대해
|
||||
|
||||
원본 분석의 등급은 P2 다. 이 기록은 새로 매기지 않는다.
|
||||
|
||||
상류가 P2 로 둔 근거는 요청 경로에서 동기화 없는 리스트가 변경된다는 것이다. 자료구조와 표시자 부재는 확인했고, 요청 경로라는 부분은 부르는 코드가 없어 오늘 성립하지 않는다.
|
||||
|
||||
같은 무게로 반대편에 놓이지 않는 사실이 하나 있다. 이 코드가 오늘 도는 것은 아니지만, 그것이 다룰 상황과 그때의 절차는 이미 문서에 적혀 있다. 배선은 남은 작업이고 리스트는 지금 모양 그대로다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
스레드를 여럿 띄워 동시 호출로 원소가 사라지는 것을 재현하지 않았다. 리스트 타입과 마커 부재와 세 자리가 하는 일을 읽은 데까지다.
|
||||
|
||||
`UNKNOWN` 상태를 만들어 넣지 않았다. 분기와 조건 정의를 코드로 따라갔다.
|
||||
|
||||
다른 브랜치에 이 클래스를 물리는 코드가 있는지 확인하지 않았다. 이 리비전의 main 에 없다는 것까지다.
|
||||
|
||||
다른 브랜치에 이 클래스를 물리는 코드가 있는지 확인하지 않았다. 이 리비전의 main 에 없다는 것까지다.
|
||||
|
||||
<!-- body:end -->
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a15-f001
|
||||
title: 원인 사슬 순회가 2-순환에서 무한 루프에 빠지고, 저장소는 이미 그 사례를 이름으로 적어 두었다
|
||||
topic: grpc-and-streaming
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a15-f001
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-analysis-finding-a15-f001.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a15-f001
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a15-f001.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a15-f001.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/15-adapter-inbound-grpc.md#L171 이다.
|
||||
---
|
||||
|
||||
# 원인 사슬 순회가 2-순환에서 무한 루프에 빠지고, 저장소는 이미 그 사례를 이름으로 적어 두었다
|
||||
|
||||
오류 코드 판정의 종료 조건은 자기참조 하나다. 서로를 원인으로 갖는 두 예외에서는 그 조건이 참이 되지 않는다. 같은 저장소의 다른 모듈이 정확히 그 경우를 이름으로 적고 깊이 제한을 채택했다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **원인 사슬은 바깥부터 안쪽까지 걸어야 한다**
|
||||
같은 계열의 순회 규칙이다.
|
||||
- **자기참조 검사는 2-순환을 잡지 못한다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
- **같은 문제를 다른 모듈에서는 닫았다**
|
||||
기록하는 이유다.
|
||||
|
||||
## 문제
|
||||
|
||||
원격 호출 오류를 코드로 바꾸는 메서드가 원인 사슬을 순회한다.
|
||||
|
||||
종료 조건이 무엇인지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
자기참조 하나뿐이다. 현재 예외의 원인이 자기 자신이면 멈춘다.
|
||||
|
||||
서로를 원인으로 갖는 두 예외에서는 이 조건이 참이 되지 않는다.
|
||||
|
||||
현재 지점이 두 예외 사이를 무한히 순환한다.
|
||||
|
||||
이 사슬은 평범한 자바로 구성 가능하다. 하나를 만들고 그것을 원인으로 삼는 둘째를 만든 뒤, 첫째의 원인을 둘째로 지정하면 된다.
|
||||
|
||||
같은 저장소가 이 정확한 위험을 다른 모듈에서 이름으로 서술하고 다른 관용구를 택했다.
|
||||
|
||||
깊이 제한이지 순환 탐지가 아니라는 것이다. 원인 사슬은 순환일 수 있고 두 예외가 서로를 원인으로 지정한 경우가 그것이며, 제한 없는 순회 하나가 발송 스레드를 멈추게 한다는 것이다. 열이면 실제 전송 감싸기보다 훨씬 깊다는 것이다.
|
||||
|
||||
즉 그 주석은 자기참조 검사가 놓치는 바로 그 경우를 지목하고 깊이 제한을 그 이유로 채택한다.
|
||||
|
||||
저장소의 아홉 순회 지점 중 다섯이 깊이 제한이고 넷이 자기참조 검사다.
|
||||
|
||||
실패 시나리오는 이렇다.
|
||||
|
||||
기능 서비스가 순환 원인 사슬을 가진 라이브러리 예외를 전파한다. 일부 연결 풀과 재시도 감싸개가 실패 원인을 상호 참조하는 형태로 만든다.
|
||||
|
||||
오류 종료 경로가 그 예외를 상태에 실어 정제 종료로 보내고, 오류 코드 판정이 진입해 돌아오지 않는다.
|
||||
|
||||
처리기 스레드 하나가 처리기를 태우며 멈추고, 클라이언트는 응답도 상태도 받지 못한 채 마감까지 기다린다.
|
||||
|
||||
같은 예외가 반복되면 서버 스레드가 하나씩 소진된다.
|
||||
|
||||
나머지 세 지점의 영향도 비슷하다.
|
||||
|
||||
두 연결 끊김 탐지기는 요청 처리 중 클라이언트 끊김을 판정하는 곳이고, 트랜잭션 재시도 분류기는 재시도 여부를 판정하는 곳이다. 셋 다 요청 스레드 위에서 실행된다.
|
||||
|
||||
권고는 네 지점을 깊이 제한으로 통일하는 것이다.
|
||||
|
||||
다른 모듈의 형태가 이미 정본이고 그 근거까지 코드에 있다.
|
||||
|
||||
이 리프에서는 순회 반복문을 깊이 상한이 있는 형태로 바꾸면 닫힌다.
|
||||
|
||||
판정은 P2 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 종료 조건 분석과 저장소 전체 순회 지점 계수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/179 계열에 있다.
|
||||
|
||||
1. 오류 코드 판정의 순회 반복문을 읽는다.
|
||||
2. 종료 조건을 확인한다.
|
||||
3. 두 예외가 서로를 원인으로 갖는 사슬을 만든다.
|
||||
4. 그 사슬에서 종료 조건이 참이 되는지 확인한다.
|
||||
5. 저장소의 다른 순회 지점을 세고 관용구를 분류한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`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);`.
|
||||
|
||||
## MvcDisconnectDetector 참조 위치
|
||||
|
||||
:::evidence key="analysis-finding-a15-f001" alt="코드베이스에서 MvcDisconnectDetector 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MvcDisconnectDetector 코드베이스 검색 — 2줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 같은 저장소가 이 위험을 이름으로 적고 다른 관용구를 택했다
|
||||
|
||||
> `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`는 트랜잭션 재시도 여부를 판정하는 곳이다. 셋 다 요청 스레드 위에서 실행된다. P2.
|
||||
|
||||
## 권고
|
||||
|
||||
네 지점을 깊이 제한으로 통일한다. `JdkNotificationHttpGateway`의 형태가 이미 정본이고 그 근거까지 코드에 있다. 이 leaf에서는 `errorCodeOf`의 `while`을 `for (int depth = 0; current != null && depth < 16; depth++, current = current.getCause())`로 바꾸면 닫힌다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
순환 사슬을 실제로 흘려 스레드가 멈추는 것을 재현하지 않았다. 종료 조건상 그 결과가 나온다.
|
||||
|
||||
<!-- body:end -->
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a15-f002
|
||||
title: 설정 바인딩이 마스터 스위치 밖에서 일어난다. 컴포지션 루트의 자기 규칙과 어긋난다
|
||||
topic: grpc-and-streaming
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a15-f002
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-analysis-finding-a15-f002.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a15-f002
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a15-f002.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a15-f002.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/15-adapter-inbound-grpc.md#L187 이다.
|
||||
---
|
||||
|
||||
# 설정 바인딩이 마스터 스위치 밖에서 일어난다. 컴포지션 루트의 자기 규칙과 어긋난다
|
||||
|
||||
설정 객체가 두 경로로 등록된다. 게이트 안쪽의 명시적 활성화와 게이트 바깥의 전역 훑기다. 후자가 있으면 마스터 스위치와 무관하게 결속이 일어난다. 조립 루트의 자바독이 과거에 같은 비대칭이 만든 사고를 기록한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **원인 사슬 순회가 2-순환에서 무한 루프에 빠지고 저장소는 이미 그 사례를 이름으로 적어 두었다**
|
||||
같은 리프의 다른 사례다.
|
||||
- **지금 안전한 것은 규칙이 지켜져서가 아니라 기본값이 유효해서다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
- **문서가 선언한 경계는 코드가 닫아야 경계다**
|
||||
같은 계열의 규칙이다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 리프의 설정 객체가 마스터 스위치 뒤에 있어야 한다.
|
||||
|
||||
등록 경로를 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
두 경로가 있다.
|
||||
|
||||
하나는 리프 설정 클래스의 명시적 활성화다. 게이트 안쪽이다.
|
||||
|
||||
다른 하나는 조립 루트의 전역 설정 훑기다. 게이트 바깥이다.
|
||||
|
||||
후자가 있으면 마스터 스위치와 무관하게 결속이 일어난다.
|
||||
|
||||
조립 루트의 자바독이 이 구조가 과거에 만든 사고를 기록한다.
|
||||
|
||||
이전에 있던 비대칭이 문제였다는 것이다. 빈은 게이트를 받고 설정은 받지 않았기 때문에, 알림 마스터가 꺼진 배포에서 알림 설정 객체가 스스로 결속했다는 것이다.
|
||||
|
||||
그리고 그 교훈을 다섯 선택 어댑터에 적용하면서 이 리프를 포함한 셋은 목록에 남겼다.
|
||||
|
||||
지금 이 리프에서는 무해하다.
|
||||
|
||||
검증이 전부 게이트를 존중하거나 안전한 기본값을 갖는다.
|
||||
|
||||
지역 비보안 설정 검증은 비활성일 때 즉시 참이 된다. 포트 범위 검증과 결속 주소 검증과 종료 유예 검증은 모두 유효한 기본값을 가진다.
|
||||
|
||||
부작용은 두 가지뿐이다.
|
||||
|
||||
비활성 배포에서도 속성 빈이 만들어진다. 그리고 미지 필드 거부가 켜져 있으므로 이 이름공간 아래 오타 하나가 이 기능을 쓰지 않는 배포의 부팅을 실패시킨다.
|
||||
|
||||
둘째는 오히려 바람직한 쪽에 가깝다.
|
||||
|
||||
기록하는 이유는 규칙과 적용이 갈린다는 점이다.
|
||||
|
||||
같은 자바독이 이 목록에 패키지를 되돌리면 결속이 복원되어 게이트를 조용히 무효화한다고 경고하고, 이 패키지가 그 목록에 있다.
|
||||
|
||||
지금 이 리프가 안전한 것은 규칙이 지켜져서가 아니라 기본값이 전부 유효하기 때문이고, 새 검증이 하나 추가되면 그 보호막이 사라진다.
|
||||
|
||||
판정은 P3 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Spring Boot : 4.0.8
|
||||
확인 방식 : 등록 경로 대조와 검증 애너테이션 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/179 계열에 있다.
|
||||
|
||||
1. 설정 객체의 등록 경로를 모두 찾는다.
|
||||
2. 각 경로가 게이트 안쪽인지 바깥인지 확인한다.
|
||||
3. 조립 루트의 자바독을 읽는다.
|
||||
4. 그 자바독이 든 목록에 이 패키지가 있는지 확인한다.
|
||||
5. 설정 객체의 검증 애너테이션과 기본값을 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`GrpcServerProperties`는 두 경로로 등록된다 — `GrpcServerConfig`의 `@EnableConfigurationProperties`(게이트 안쪽)와 `CaSkeletonApplication`의 `@ConfigurationPropertiesScan`(게이트 바깥). 후자가 있으면 `ca-skeleton.grpc.enabled`와 무관하게 바인딩이 일어난다(§3.4).
|
||||
|
||||
## GrpcServerProperties 참조 위치
|
||||
|
||||
:::evidence key="analysis-finding-a15-f002" alt="코드베이스에서 GrpcServerProperties 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcServerProperties 코드베이스 검색 — 4줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 컴포지션 루트가 이 형태를 사고로 기록했다
|
||||
|
||||
`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에서는 무해하다
|
||||
|
||||
검증이 전부 게이트를 존중하거나 안전한 기본값을 갖는다. P3.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
마스터 스위치를 끄고 띄워 속성 빈이 만들어지는 것을 확인하지 않았다. 등록 경로상 그 결과가 나온다.
|
||||
|
||||
<!-- body:end -->
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a20-f004
|
||||
title: 조립 경계가 정책 객체 9개를 만들고 서버를 만들지 않는다
|
||||
topic: grpc-and-streaming
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a20-f004
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-analysis-finding-a20-f004.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a20-f004
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a20-f004.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a20-f004.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/20-grpc-platform.md#L298 이다.
|
||||
---
|
||||
|
||||
# 조립 경계가 정책 객체 9개를 만들고 서버를 만들지 않는다
|
||||
|
||||
플랫폼 자동 설정이 등록하는 아홉 빈은 전부 프로파일과 정책과 레지스트리다. 서버도 인터셉터 사슬도 서비스 어댑터 등록도 없다. 그것을 담당하는 타입들이 주 참조 0 이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **크로스 스택 게이트가 검증하는 조립은 픽스처의 조립이고 플랫폼의 조립이 아니다**
|
||||
다른 가족의 같은 형태다.
|
||||
- **저장소 어디에도 참조가 없는 타입 3개**
|
||||
같은 리프의 더 나아간 사례다.
|
||||
- **손으로 만드는 목록은 적어도 한 번은 거꾸로 만든다**
|
||||
인터셉터 사슬 클래스가 존재하는 이유다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 플랫폼의 자동 설정이 무엇을 등록하는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
아홉 빈이 전부 프로파일과 정책과 레지스트리다.
|
||||
|
||||
서버도, 인터셉터 사슬도, 서비스 어댑터 등록도 없다.
|
||||
|
||||
그리고 그것을 담당하는 타입들이 주 참조 0 이다. 테스트 참조는 각각 하나씩 있다.
|
||||
|
||||
서버 인터셉터 사슬과 두 인터셉터, 서비스 어댑터 규격, 재시도 조정자와 소유권 검증자, 스트림 승인과 직렬 기록기와 간극 탐지기와 수명 조정자, 배수 조정자와 스냅숏 서비스, 형 있는 스텁 공장과 호출 문맥이다.
|
||||
|
||||
인터셉터 사슬 클래스의 자바독이 자기 존재 이유를 적는다.
|
||||
|
||||
그 뒤집힘이 호출 지점의 목록 리터럴이 아니라 이 클래스가 존재하는 이유라는 것이다.
|
||||
|
||||
프레임워크의 감싸기 함수가 각 인터셉터를 이전 것 위에 감싸므로 마지막에 넘긴 것이 실행 시점에 가장 바깥이 되며, 그것은 순서가 읽히는 방식과 반대라는 것이다.
|
||||
|
||||
이 목록을 손으로 만드는 모든 코드베이스가 적어도 한 번은 거꾸로 만든다는 것이다.
|
||||
|
||||
그 클래스가 주 참조 0 이다.
|
||||
|
||||
판정은 P2 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Spring Boot : 4.0.8
|
||||
확인 방식 : 자동 설정 빈 목록과 타입별 참조 계수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/255 계열에 있다.
|
||||
|
||||
1. 플랫폼 자동 설정의 빈 목록을 확인한다.
|
||||
2. 각 빈의 성격을 분류한다.
|
||||
3. 서버와 인터셉터 사슬과 어댑터 등록을 검색한다.
|
||||
4. 그 역할의 타입들을 나열하고 참조를 센다.
|
||||
5. 인터셉터 사슬 클래스의 자바독을 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`GrpcPlatformAutoConfiguration`이 등록하는 9개는 전부 **프로파일·정책·레지스트리**다. 서버도, 인터셉터 체인도, 서비스 어댑터 등록도 없다.
|
||||
|
||||
## GrpcPlatformAutoConfiguration 참조 위치
|
||||
|
||||
:::evidence key="analysis-finding-a20-f004" alt="코드베이스에서 GrpcPlatformAutoConfiguration 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcPlatformAutoConfiguration 코드베이스 검색 — 2줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 그것을 담당하는 타입들이 main 참조 0이다
|
||||
|
||||
| 타입 | leaf | 역할 (javadoc) | main | test |
|
||||
|---|---|---|---|---|
|
||||
| `GrpcServerInterceptorChain` | server | "Builds the server interceptor chain in the Stable order" | **0** | 1 |
|
||||
| `ProtovalidateGrpcInterceptor` | policy | 요청 검증 인터셉터 | **0** | 1 |
|
||||
| `GrpcIdempotencyInterceptor` | policy | 멱등성 인터셉터 | **0** | 1 |
|
||||
| `GrpcServiceAdapter` | server | typed service adapter SPI | **0** | 1 |
|
||||
| `GrpcRetryCoordinator` · `GrpcRetryOwnershipValidator` | policy | 재시도 소유권 | **0** | 1 |
|
||||
| `GrpcStreamAdmission` · `GrpcSerializedStreamWriter` · `GrpcStreamGapDetector` · `GrpcStreamLifecycleCoordinator` | policy | 단일 writer·갭 탐지 | **0** | 1 |
|
||||
| `GrpcDrainCoordinator` · `GrpcPlatformSnapshotService` | admin | drain·정책 스냅샷 | **0** | 1 |
|
||||
| `GrpcTypedStubFactory` · `GrpcClientCallContext` | client | typed stub·호출 컨텍스트 | **0** | 1 |
|
||||
|
||||
## 그 클래스가 존재하는 이유가 그대로 위험이 된다
|
||||
|
||||
`GrpcServerInterceptorChain`의 javadoc — "`ServerInterceptors.intercept` wraps each interceptor around the previous one, so the last one passed is the outermost at runtime — the opposite of how the order reads. **Every codebase that builds this list by hand gets it backwards at least once, and the symptom is an exception boundary that catches nothing.**" 그 클래스를 조립에서 쓰는 곳이 없으므로, 채택자가 인터셉터 목록을 직접 만들면 그 javadoc이 서술한 실수를 그대로 하게 된다.
|
||||
|
||||
## 73이라는 숫자를 그대로 결함 수로 읽으면 안 된다
|
||||
|
||||
260개 main 타입 중 73개가 main 참조 0이지만, build-only 라이브러리 가족에서 공개 API 표면이 내부 참조를 갖지 않는 것은 정상이다. 위 표는 그중 **가족 내부의 다른 코드가 불러야 하는 조립·기계 타입**만 골라낸 것이다. P2.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 플랫폼을 켜고 서버가 뜨지 않는 것을 재현하지 않았다. 빈 목록상 그 결과가 나온다.
|
||||
|
||||
<!-- body:end -->
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a20-f005
|
||||
title: 저장소 어디에도 참조가 없는 타입 3개
|
||||
topic: grpc-and-streaming
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a20-f005
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-analysis-finding-a20-f005.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a20-f005
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a20-f005.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a20-f005.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/20-grpc-platform.md#L321 이다.
|
||||
---
|
||||
|
||||
# 저장소 어디에도 참조가 없는 타입 3개
|
||||
|
||||
주 참조와 테스트 참조가 모두 0 인 타입이 셋이다. 앞의 둘은 채택자가 부를 표면이라 참조 0 이 설계와 모순되지는 않는다. 셋째는 안정 계약에 있고 자바독이 호출자가 이것으로 분기한다고 단정한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **조립 경계가 정책 객체 9개를 만들고 서버를 만들지 않는다**
|
||||
같은 리프의 상위 사실이다.
|
||||
- **소비자가 없는 fixture 셋**
|
||||
같은 계수 방식의 사례다.
|
||||
- **던지는 코드도 잡는 코드도 없으면 분기할 것이 없다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 가족의 타입별 참조를 셌다.
|
||||
|
||||
주 참조도 테스트 참조도 0 인 것, 즉 선언 파일 외에 아무 곳에서도 이름이 등장하지 않는 타입을 가려냈다.
|
||||
|
||||
## 결론
|
||||
|
||||
셋이다.
|
||||
|
||||
앞의 둘은 고급 호환 리프의 반응형 표면이다.
|
||||
|
||||
하나는 단항 호출을 단일 값 흐름으로, 서버 스트림을 다중 값 흐름으로 노출한다.
|
||||
|
||||
다른 하나는 반응형 사용 사례를 서비스 어댑터 뒤에서 돌린다.
|
||||
|
||||
둘 다 채택자가 부를 타입이므로 참조 0 이 설계와 모순되지는 않는다.
|
||||
|
||||
다만 테스트도 0 이라 다른 고급 타입들과 다르다. 나머지 고급 미참조 타입은 전부 테스트 참조가 하나씩 있다.
|
||||
|
||||
셋째가 더 구체적이다.
|
||||
|
||||
마감 초과 예외가 안정 핵심 계약에 있다.
|
||||
|
||||
자바독이 일반 플랫폼 예외가 아니라 자기 타입인 이유를 호출자가 이것으로 분기하기 때문이라고 단정한다.
|
||||
|
||||
그런데 던지는 코드도 잡는 코드도 테스트도 없다.
|
||||
|
||||
그리고 그 타입의 조정 필요 여부 메서드가 상태 코드가 답할 수 없는 질문에 답한다고 적혀 있는데, 그 메서드를 부르는 곳이 없다.
|
||||
|
||||
판정은 P3 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
확인 방식 : 타입별 주 참조와 테스트 참조 전수 계수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/255 계열에 있다.
|
||||
|
||||
1. 가족의 타입 목록을 만든다.
|
||||
2. 각 타입의 주 참조와 테스트 참조를 센다.
|
||||
3. 둘 다 0 인 타입을 가려낸다.
|
||||
4. 각 타입의 자바독 용도를 읽는다.
|
||||
5. 셋째 타입을 던지거나 잡는 코드를 검색한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`main = 0`이면서 `test = 0`인 것, 즉 선언 파일 외에 아무 곳에서도 이름이 등장하지 않는 타입이 셋이다.
|
||||
|
||||
| 타입 | leaf | javadoc이 말하는 용도 |
|
||||
|---|---|---|
|
||||
| `ReactiveGrpcClient` | advanced-compat | "Exposes a unary call as a `Mono` and a server stream as a `Flux`" |
|
||||
| `ReactiveGrpcServerAdapter<C,R>` | advanced-compat | "Runs a reactive use case behind a gRPC service adapter" |
|
||||
| `GrpcDeadlineExceededException` | **core-api** | "Its own type rather than a generic platform exception **because callers branch on it**" |
|
||||
|
||||
## main 과 test 양쪽에서 0 인 타입
|
||||
|
||||
:::evidence key="analysis-finding-a20-f005" alt="분석 문서 analysis/20-grpc-platform.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/20-grpc-platform.md 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 앞의 둘은 설계와 모순되지 않는다
|
||||
|
||||
advanced 가족의 Reactor 표면이고 채택자가 부를 타입이다 — 다만 **테스트도 0**이라 다른 advanced 타입들과 다르다(나머지 advanced 미참조 타입은 전부 `test=1`).
|
||||
|
||||
## 세 번째가 더 구체적이다
|
||||
|
||||
`GrpcDeadlineExceededException`은 Stable core-api에 있고, javadoc이 "callers branch on it"이라고 단정하는데 **던지는 코드도 잡는 코드도 테스트도 없다.** `requiresReconciliation()`이 "status code가 답할 수 없는 질문에 답한다"고 적혀 있고, 그 메서드를 부르는 곳이 없다. P3.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
채택자가 앞의 두 타입을 실제로 쓰는지 저장소 밖에서 확인할 수 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user