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>
148 lines
14 KiB
Markdown
148 lines
14 KiB
Markdown
---
|
|
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 -->
|