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>
14 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | body | assets | evidence | source | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a20-f006-grpcadmissioncontroller-tryadmit | 승격이 상한 필드를 읽지 않고, 세 승인 메서드의 main 호출자가 0 이다 | grpc-and-streaming | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a20-f006-grpcadmissioncontroller-tryadmit | 2026-09-04 | case-a20-f006-grpcadmissioncontroller-tryadmit.body.md |
|
|
|
승격이 상한 필드를 읽지 않고, 세 승인 메서드의 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
재현 조건
- 승인 메서드 본문에서 읽기와 비교와 증가가 각각 어느 줄인지 적는다.
- 대기열 분기와 해제 메서드도 같은 방식으로 읽는다.
- 계수기 필드의 타입과 이 파일의 compareAndSet 사용 수를 센다.
- 승격 메서드에 상한 필드를 읽는 줄이 있는지 본다.
- 세 메서드를 호출하는 자리를 소스 세트별로 나눠 센다.
- main 에서 이 타입을 들고 있는 클래스가 제어기에 어떤 호출을 거는지 전부 찾는다.
- 저장소의 승격 시험이 해제와 승격을 어떤 순서로 부르는지 읽는다.
- 같은 상한으로 두 순서를 각각 실행하고 진행 중 수를 읽는다.
- 같은 가족에서 compareAndSet 을 쓰는 main 파일을 찾고 그 루프를 읽는다.
본문
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() 를 읽어 무엇을 판단하는지는 읽지 않았다. 그것이 제어기에 하는 유일한 호출이라는 데까지 확인했다.