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>
26 KiB
grpc-policy 완전 해부
상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) —
src/mainproduction 62파일 4,781줄 +src/test18파일 2,800줄 축자 통독 완료.STRUCTURAL_ONLY는gradle.lockfile하나. 기준 revision:21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/grpc/grpc-policySSOT owner:grpc-policyintegration/family document:analysis/20-grpc-platform.md(secondary, INTEGRATION_ONLY)
0. SSOT identity / 커버리지
runtime_memberships:[]— build-only- vendor:
io.grpcBOM 을 모듈 범위로 가져온다
// build.gradle:6-8
// io.grpc versions are NOT managed by the Spring Boot BOM and this repo has no version catalog, so
// the grpc-bom is imported at MODULE scope from the root `ext.grpcVersion` SSOT — the same shape
// `adapter:inbound:grpc` uses, keeping the strict-locking blast radius local.
| 패키지 | 파일 | 줄 | 주제 |
|---|---|---|---|
streaming |
19 | 1,440 | 봉투·재개 토큰·직렬 기록기·간극 탐지·흐름 제어·수명·승인·심박 |
resilience |
11 | 809 | 재시도 설정·예산·조정자·결정·자격·소유권·소유권 검증·서비스 설정·wait-for-ready 3종 |
idempotency |
8 | 627 | 결정·인터셉터·지문·결과 재생·완료 조정·완료 판정·연산 상태·상태 질의 |
deadline |
6 | 402 | 계산기·정책 검증기·취소 조정자·취소 가능 연산·취소 사유·의존 예산 |
error |
4 | 407 | 매퍼·노출 정책·리치 상세·상태 매핑 |
policy |
4 | 314 | 적재물 경계·메시지 크기·압축 프로파일·크기 위반 |
security |
4 | 329 | TLS 프로파일·인증 프로파일·자격 세대·회전 관리자 |
context |
3 | 229 | 문맥 결속기·전파 정책·스냅숏 |
validation |
3 | 284 | 전송 검증기·위반·protovalidate 인터셉터 |
main 총 62파일 / 4,781줄.
Coverage ledger
| scope | count | disposition | reason |
|---|---|---|---|
main/java/** |
62 | FULL_READ |
4,781줄. 패키지 9개의 전 파일 본문 |
test/java/** |
18 | FULL_READ |
2,800줄. 패키지 9개 전부 |
build.gradle |
1 | FULL_READ |
23줄 전문 |
gradle.lockfile |
1 | STRUCTURAL_ONLY |
잠금 파일 — 생성물이고 의미 없는 좌표 반복 |
UNCLASSIFIED 0.
이 표는 2026-09-01 재통독에서 다시 세었다. 이전 판의 패키지 표는 합이 47이었다 —
streaming을 9,resilience를 7 로 적고security·validation에3+라고 썼다. 전 파일을 열지 않은 채 적힌 수였고, 그 표가 곧 통독이 끝나지 않았다는 증거였다. 아래 §17.6–17.8 은 나머지 15파일을 읽고 나서야 나온 것이다.
1. 오류 매퍼 — 클라이언트는 메시지 문자열을 읽지 않는다
"The rule it exists to hold is that a client never reads a message string. Everything a caller needs to branch on is a code, an
ErrorInfo.reason, or a typed detail; the description is for a human reading a log and is replaced wholesale whenever it is not provably safe."
"An unrecognised exception becomes
INTERNALwith an opaque execution id and nothing else. The id is the entire bridge between what the client saw and what the operator can find, and it is generated rather than derived so that it cannot accidentally encode a key or a row id."
실행 식별자 공급자가 주입되는 이유도 적혀 있다 — 무작위 값을 단언하지 않고도 그 식별자가 트레일러에 닿는 것을 테스트가 확인할 수 있게 하기 위해서다.
2. 적재물 경계 — 자원이 아니라 구조의 문제
"The binary rule is the one with an architectural reason behind it rather than a resource one. This repository already has a file server and an object store; a method that accepts a file as bytes duplicates their responsibility, loses their resumability and lifecycle, and puts the file in a request that has to be buffered whole to be parsed."
그리고 도달할 수 없는 설정을 생성자가 거부한다 — 인라인 이진 임계값이 메시지 상한보다 크면 결코 발화하지 않는다.
3. 재개 토큰 — 서명하고, 구분자를 봉인한다
GrpcResumeToken 의 아홉 성분 각각이 왜 필요한지가 javadoc 에 있다 — 스냅숏 판본 없이는 사라진 뷰의 위치에서 재개하고, 만료 없이는 이력이 사라진 커서에서 재개하고, 필터 지문 없이는 남의 필터를 자기 위치에서 재개해 요청하지 않은 행을 받는다.
그리고 문자열 성분이 구분자를 담지 못하게 생성자가 거부한다.
if (value.indexOf('|') >= 0) {
throw new IllegalArgumentException(what + " must not contain '|', which separates the token's fields");
}
GrpcResumeTokenCodec 의 검증이 세 성질을 지킨다 — 상수 시간 비교(MessageDigest.isEqual), 알 수 없는 키 식별자 거부, 세 실패의 구분 불가.
"A codec that retries verification with every key it holds turns key rotation into a window in which a token signed by a compromised key still verifies."
"The three are deliberately indistinguishable to a caller: telling them apart is a probing oracle."
4. 재시도 예산 — 이 가족의 원자성 정본
public boolean tryConsume() {
while (true) {
long observed = tokens.get();
if (observed < tokensPerRetry) { return false; }
if (tokens.compareAndSet(observed, observed - tokensPerRetry)) { return true; }
}
}
같은 문제를 이 리프의 GrpcStreamAdmission 과 grpc-server 의 GrpcAdmissionController 는 비원자적으로 푼다(§17.1).
5. 자격증명 회전 — 준비 후 교체 후 배수
"Replacing the material in place is what produces the failure this exists to avoid: every call that was mid-flight when the swap happened fails with an authentication error that looks, from the client, exactly like a credential that was never valid."
같은 세대를 다시 적용하는 것은 무동작이다 — 재시도된 회전이 첫 회전이 받아들인 호출을 취소하면 안 되기 때문이다.
10. 테스트 레인
18파일 2,800줄. 패키지 9개 전부에 테스트가 있고, 배치는 균등하지 않다.
| 패키지 | 테스트 | 줄 |
|---|---|---|
streaming |
5 | 675 |
resilience |
3 | 630 |
idempotency |
3 | 397 |
validation |
1 | 240 |
error |
1 | 182 |
security |
1 | 163 |
context |
1 | 156 |
deadline |
2 | 227 |
policy |
1 | 130 |
idempotency 의 세 번째 파일은 테스트가 아니라 손으로 쓴 원장 이중 InMemoryOperationLedger 이고, 그 javadoc 이 자기 존재 이유를 적어 둔다.
"
putIfAbsenton a concurrent map is the in-memory equivalent of the unique constraint the real adapter relies on, so the claim is atomic here for the same reason it is there. A fake that read and then wrote would let the policy tests pass while the property they exist to check does not hold."
즉 이 리프의 멱등 테스트는 원장의 원자성을 가정한다. 그 가정이 실제 어댑터에서 성립하는지는 grpc-operation-ledger-jpa §17.1 의 주제이고, 그 리프의 판정은 성립하지 않는다는 것이다. 이중이 production 보다 엄격하다.
이 리프는 동시성 테스트를 쓸 줄 안다. GrpcSerializedStreamWriterTest.concurrentProducersDoNotTouchTheTransport 는 생산자 8개로 400회를 밀어 넣고 순서·중복 없음을 단언한다. 그래서 §17.1·§17.2·§17.6 의 경합이 단일 스레드로만 시험되는 것은 능력의 한계가 아니라 선택이다.
12. negative-space probes
12.1 도달성. build-only. grpc-spring-boot-starter 가 이 리프의 타입 중 문맥 결속기와 오류 매퍼만 빈으로 만든다. 인터셉터 둘(ProtovalidateGrpcInterceptor·GrpcIdempotencyInterceptor)과 스트림 계열 19파일 전부는 조립되지 않는다. 이 리프의 모든 판정 등급이 그래서 한 칸 낮다 — 오늘의 사고가 아니라 배선하는 날의 사고다.
12.2 대조군 — 원자성 셋. 같은 저장소 안에 세 구현이 있다.
| 구현 | 형태 |
|---|---|
GrpcRetryBudget.tryConsume |
비교 후 교체 루프 — 정확 |
GrpcStreamAdmission.tryAdmit |
읽고 비교한 뒤 별도 증가 — §17.1 |
GrpcAdmissionController.tryAdmit(grpc-server) |
같은 형태 |
12.3 대조군 — 배수 플래그. 저장소 전체에서 volatile boolean 은 정확히 둘이다.
grpc-client/…/GrpcChannelRuntime.java:20 private volatile boolean draining;
grpc-admin/…/GrpcServiceHealthRegistry.java:25 private volatile boolean draining;
같은 뜻의 세 번째 플래그가 GrpcStreamLifecycleCoordinator.drainSignalled 인데 여기에는 volatile 이 없다(§17.6). 같은 저장소가 같은 문제를 두 번은 표시하고 한 번은 표시하지 않았다.
12.4 드리프트. build.gradle 이 서술한 아홉 주제가 전부 패키지로 존재한다. 파일 수의 분포는 균등하지 않다 — streaming 하나가 main 의 30%(19/62)다.
12.5 테스트가 볼 수 없는 것. 세 곳에서 테스트의 형태가 결함을 구조적으로 가린다.
| 결함 | 가리는 형태 |
|---|---|
| §17.4 DROP_OLDEST 바이트 계산 | writer(policy, messageSize) 가 () -> messageSize 상수 크기 공급자를 넘긴다. 모든 메시지가 같은 크기면 잘못 뺀 값과 옳은 값이 같다 |
| §17.1 승인 경계 경합 | streamAdmissionBoundsTotalAndPerCaller 가 단일 스레드다 |
| §17.6 배수 신호 가시성 | aDrainOutranksTheTimers 가 signalDrain() 과 terminationDue() 를 같은 스레드에서 부른다 |
12.6 설정처럼 보이지만 상수인 것. GrpcContextPropagationPolicy.clearAfterTask 는 두 값을 받는 성분인데 생성자가 false 를 무조건 거부한다. 합법 값이 하나뿐이고, 그 값을 읽는 production 코드도 없다(§17.8).
16. 확인하지 못한 것
- 어떤 인터셉터도 실제 서버에 걸어 돌리지 않았다. 배선 경로가 없다.
- 동시 회전·동시 해제·동시 승인을 실행으로 재현하지 않았다. 원자성 분석과 JMM 으로 판정했다.
- §17.6 의 가시성 실패를 관측하지 않았다.
volatile부재와 두 호출자의 스레드 소속으로 판정했다. 관측하려면 배수 스레드와 스트림 틱 스레드를 분리한 반복 시험이 필요하고, 이런 실패는 재현되지 않는 것이 정상이다. - §17.7 의 IPv6 누출을 실제 예외 메시지로 재현하지 않았다. 거부 목록 아홉 패턴을 전부 읽고 IPv4 점표기 외에 주소 형태를 보는 패턴이 없음을 확인해 판정했다.
gradle.lockfile은 읽지 않았다(STRUCTURAL_ONLY).
17. 손볼 것
17.1 P2 — 스트림 승인의 경계가 동시성 아래에서 새고, caller별 맵이 줄지 않는다
AtomicInteger callerCount = perCaller.computeIfAbsent(callerFingerprint, key -> new AtomicInteger());
if (callerCount.get() >= maxStreamsPerCaller) { return false; }
if (openStreams.get() >= maxConcurrentStreams) { return false; }
callerCount.incrementAndGet();
openStreams.incrementAndGet();
읽고 비교한 뒤 별도로 증가한다. 경계에 있는 N 개 스레드가 모두 통과한다.
이 클래스의 javadoc 이 서술하는 실패 상황이 곧 고동시성이다 — "a client that reconnects on every error opens streams faster than the old ones close." 재접속 폭풍에서 경계가 가장 많이 샌다.
release 도 같은 형태라 음수로 갈 수 있다.
그리고 perCaller 에서 항목이 제거되지 않는다. computeIfAbsent 가 호출자 지문마다 계수기를 만들고 release 는 값만 줄인다. 서로 다른 호출자 수만큼 맵이 자란다 — grpc-observability 의 GrpcMetricCardinalityPolicy 가 지표 태그에 대해 명시적으로 막는 것과 같은 종류의 증가이고, 여기에는 그 가드가 없다.
정본이 같은 리프에 있다 — GrpcRetryBudget.tryConsume 의 비교 후 교체 루프.
17.2 P2 — 자격증명 회전이 비교 후 교체가 아니고, 배수 완료가 진행 중인 회전을 되돌릴 수 있다
State observed = state.get();
…
state.set(new State(next, observed.current(), deadline)); // rotate
…
public void completeDrain() {
State observed = state.get();
state.set(new State(observed.current(), null, null)); // completeDrain
}
AtomicReference 를 쓰면서 두 메서드 모두 읽고 나서 조건 없이 쓴다.
두 결과가 다르다.
회전 경합. 두 회전이 같은 observed 를 읽으면 둘 다 승계 검사를 통과할 수 있고, 나중 set 이 앞의 것을 덮는다. 덮인 회전이 배수 대상으로 기록해 둔 세대가 상태에서 사라진다. 그 세대 위의 호출은 아무도 배수하지 않는다.
javadoc 이 이 상황을 이미 알고 있다 — 승계 검사의 존재 이유로 "the usual reason for one is two rotators racing" 를 든다. 검사는 있고 원자성이 없다.
배수 완료의 되돌림이 더 무겁다. completeDrain() 이 자기가 읽은 observed.current() 로 새 상태를 만든다. 읽기와 쓰기 사이에 회전이 일어나면, 그 회전이 활성화한 세대가 지워지고 이전 세대가 다시 현재가 된다. 즉 방금 교체된 자격증명이 되살아난다.
클래스의 존재 이유가 "in-flight 작업을 떨어뜨리지 않고 자격 자재를 교체하는 것" 인데, 이 경로는 교체 자체를 되돌린다.
수정은 두 메서드를 비교 후 교체로 바꾸는 것이다. rotate 는 compareAndSet(observed, next) 가 실패하면 다시 읽어 판정하고, completeDrain 은 updateAndGet(s -> new State(s.current(), null, null)) 로 현재 값을 원자적으로 읽어 쓰면 된다. 후자는 한 줄이다.
같은 형태가 grpc-client 의 GrpcChannelRuntimeRegistry.rotate 에도 있다. 두 리프가 같은 자료구조를 같은 방식으로 잘못 쓴다.
17.3 P2 — 결과 재생 저장소에 제거 경로가 없다
private final ConcurrentMap<String, byte[]> storedOutcomes = new ConcurrentHashMap<>();
maxInlineBytes 는 항목 하나의 크기를 제한하고, 개수를 제한하는 것은 없다. remove·clear·축출·만료가 전부 없다. size() 만 있고 그 값을 읽는 곳도 없다.
store 는 멱등 키가 필요한 메서드가 커밋될 때마다 불리므로 프로세스 수명 동안 커밋 수만큼 쌓인다.
javadoc 이 이 저장소를 "a small inline store" 라 부르는데 작게 유지하는 장치가 없다. 크기를 넘는 응답은 거부하면서 개수는 거부하지 않는다.
이웃 리프의 자매 클래스가 같은 문제를 명시적으로 다룬다 — GrpcClientMessageDeduplicator(advanced-streaming)의 javadoc 이 "A set grows without bound for the life of a session" 을 집합 방식을 거부한 이유로 들고, endSession() 으로 세션 단위 정리를 한다.
다만 그 자매 클래스도 절반만 지킨다. 재통독에서 확인했다 — 체크포인트 맵은 세션당 항목 하나로 유지되지만, 형제인 replayableOutcomes 는 적용된 메시지마다 항목을 쌓고 endSession 전까지 줄지 않는다. 그 리프의 §17.1 이 그것을 자기 판정으로 기록한다. 그러므로 이 자리의 대조는 "저쪽은 풀었고 이쪽은 안 풀었다" 가 아니라 "두 리프가 같은 형태의 무제한 증가를 갖고 있고, 한쪽만 세션 경계라는 부분적 상한을 갖는다" 이다.
17.4 P2 — 직렬 스트림 기록기의 가장 오래된 것 버리기가 잘못된 메시지의 바이트를 뺀다
case DROP_OLDEST -> {
GrpcStreamEnvelope<T> dropped = queue.pollFirst();
if (dropped != null) {
queuedBytes = Math.max(0L, queuedBytes - nextBytes); // ← 들어오는 메시지의 크기
droppedMessages++;
}
enqueue(kind, payload, snapshotVersion, resumeToken, nextBytes);
yield GrpcStreamWriteResult.DROPPED;
}
버려지는 것은 꺼낸 봉투인데 빼는 값은 새 메시지의 크기다. 봉투는 크기를 성분으로 담지 않으므로 이 지점에서 버려지는 크기를 알 방법이 없다.
계산을 따라가면 이렇다. 한 번의 DROP_OLDEST 마다 queuedBytes 는 nextBytes 만큼 빠졌다가 enqueue 에서 같은 값만큼 다시 더해진다 — 순변화 0. 그런데 큐의 실제 내용은 nextBytes - droppedBytes 만큼 바뀐다. 그 차이가 매 낙차마다 쌓인다.
방향은 둘 다 틀렸다. 들어오는 메시지가 버려지는 것보다 크면 추적값이 실제보다 낮아져 바이트 경계가 늦게 발화한다(메모리). 반대면 실제보다 높아져 경계가 이르게 발화한다(불필요한 종료·낙차). 누적 바이트는 흐름 제어 정책의 판정 입력이고, 바이트 경계의 존재 이유가 javadoc 에 있다 — 개수 경계만 있으면 메모리 한도를 가장 큰 메시지가 정한다.
범위는 flush 창 하나다. flush() 가 큐를 비우면서 queuedBytes = 0L 로 되돌리므로 오차가 flush 를 건너 누적되지는 않는다. 그래서 이것은 영구 드리프트가 아니라 한 flush 주기 안의 폭주 구간에서 바이트 경계를 잘못 판정하는 결함이다. 낙차가 일어나는 상황이 곧 소비자가 못 따라가는 상황이고, 그때 flush 간격이 가장 길어진다.
17.5 P2 — 완료 조정자가 요청 경로에서 동기화 없는 가변 리스트를 변경한다
private final List<PendingCase> pending = new ArrayList<>();
…
pending.add(new PendingCase(...)); // reconcile(...) — 요청 경로
List.copyOf(pending); // pendingCases()
pending.remove(resolved); // clearPending(...)
synchronized·Concurrent*·volatile·Lock 전부 0 이고 단일 스레드 전용 표기도 없다. 같은 리프의 GrpcSerializedStreamWriter 는 아홉 마커로 제대로 닫혀 있어, 이 리프가 동시성을 인지하고 있음을 보여 준다.
reconcile 은 완료 결과가 불확실한 호출마다 불린다 — 장애 상황에서 동시에 몰리는 경로다. 그리고 pending 이 담는 것은 사람이 조정해야 하는 연산 목록이므로, 유실은 조정되지 않은 채 잊히는 연산이 된다.
17.6 P2 — 스트림 수명 조정자의 배수 신호가 스레드를 건너면서 volatile 이 아니다
private boolean drainSignalled;
public void signalDrain() { drainSignalled = true; }
public Optional<GrpcStreamTerminationReason> terminationDue(Instant now, Instant credentialExpiry) {
…
if (drainSignalled) { return Optional.of(GrpcStreamTerminationReason.SERVER_DRAIN); }
두 메서드의 호출자가 다른 스레드다. signalDrain() 은 서버가 내려갈 때 종료 훅이 부르고, terminationDue(...) 는 스트림 자신의 틱에서 불린다 — 클래스 javadoc 이 검사 순서를 "then drain, because a server that has been told to stop should stop before its own timers fire" 로 규정한 그 틱이다.
평범한 boolean 이고 volatile·synchronized·AtomicBoolean 어느 것도 없다. 자바 메모리 모델 아래서 틱 스레드가 이 쓰기를 관측할 보장이 없다. 관측하지 못하면 스트림은 배수 명령을 받고도 계속 돌고, 최대 수명(기본 1시간)이 차야 끝난다.
같은 저장소가 같은 뜻의 플래그를 두 번은 volatile 로 적었다(§12.3). 세 번째만 빠졌다.
수정은 volatile boolean 한 단어다. heartbeat 의 lastActivity 는 같은 문제가 아니다 — 스트림 틱 스레드만 만진다.
17.7 P3 — 오류 노출 거부 목록의 "호스트와 포트" 규칙이 IPv4 점표기만 본다
Pattern.compile("\\b\\d{1,3}(\\.\\d{1,3}){3}(:\\d{1,5})?\\b"),
아홉 패턴을 전부 읽으면 주소 형태를 보는 것은 이 하나다. 클래스 javadoc 은 거부 대상을 "a stack frame, a SQL fragment, a JDBC URL, a bearer token, a host and port, a file path" 로 서술하는데, 실제로 걸리는 host 는 IPv4 점표기뿐이다.
통과하는 것들:
- IPv6 리터럴 —
fe80::1,[2001:db8::1]:5432 - DNS 이름과 포트 —
documents-db.internal:5432,kafka-0.kafka-headless:9092
jdbc:postgresql://db/app 이 막히는 것은 host 규칙이 아니라 jdbc: 규칙 때문이다. 즉 이 구멍은 테스트에도 없다 — exposurePolicyRefusesLeakyStrings 의 아홉 사례 중 주소는 upstream 10.0.3.14:5432 refused 하나이고 IPv4 다.
닿는 경로는 mapUnknown 이다. 인식되지 않은 예외의 메시지를 safeToExpose 가 통과시키면 그대로 클라이언트로 간다. IPv6 클러스터나 쿠버네티스 서비스 이름을 쓰는 배포에서 상류 좌표가 밖으로 나간다.
등급이 P3 인 이유는 두 가지다. 이 리프가 build-only 라 오늘 닿지 않고, 노출되는 것이 자격증명이 아니라 내부 좌표다. 다만 이 정책이 존재하는 이유 자체가 "부분 마스킹이 아니라 통째 교체" 이므로, 목록에 빠진 형태는 통째로 통과한다.
17.8 P3 — clearAfterTask 는 합법 값이 하나뿐인 성분이고, 아무도 읽지 않는다
public record GrpcContextPropagationPolicy(
boolean failClosedWithoutContext, boolean clearAfterTask) {
…
public GrpcContextPropagationPolicy {
if (!clearAfterTask) { throw new IllegalArgumentException("context must be cleared after every task; …"); }
}
}
false 를 무조건 거부하므로 이 성분이 가질 수 있는 값은 true 하나다. 그리고 저장소 전체에서 clearAfterTask() 를 읽는 production 코드가 없다 — 호출처는 이 생성자의 가드와 테스트의 단언 한 줄뿐이다.
읽지 않아도 되는 이유는 GrpcContextBinder 가 옳게 쓰였기 때문이다. runWith·callWith·wrap 이 전부 finally 에서 detach 한다. 불변식이 이미 구조로 지켜진다.
그래서 이 성분은 설정처럼 보이지만 설정이 아니다. 읽는 사람은 정책으로 끌 수 있는 것이라고 읽고, 테스트는 그 가드를 시험한다.
수정은 성분을 지우고 javadoc 에 "always cleared" 를 남기는 것이다. 그러면 backgroundWork()·stable() 이 인자 하나가 되고, 불변식은 검증이 아니라 구조가 된다.
확인된 설계(문제 아님)
- 클라이언트가 메시지 문자열을 읽지 않는다는 규칙과, 인식되지 않은 예외의 불투명 처리.
- 실행 식별자를 파생이 아니라 생성으로 만든 것 — 키나 행 식별자를 우연히 담을 수 없다.
- 적재물 경계의 근거를 자원이 아니라 책임 중복으로 든 것.
- 도달할 수 없는 임계값 설정을 생성자가 거부한 것.
- 재개 토큰의 아홉 성분 각각에 이유를 붙인 것.
- 토큰 문자열 성분이 구분자를 담지 못하게 한 것 — 같은 저장소의 web 지문이 이 프레이밍을 하지 않는 것과 대비된다.
- 토큰 검증의 상수 시간 비교·키 식별자 거부·실패 구분 불가.
- 재시도 예산의 비교 후 교체 루프.
- 자격증명 회전의 준비-교체-배수 순서와 같은 세대 재적용의 무동작 처리.
- BOM 을 모듈 범위로 가져와 잠금 파급을 지역화한 것.
Source anchors
src/grpc/grpc-policy/build.gradle:1-23
main/java/…/context/{GrpcContextBinder:1-122, GrpcContextPropagationPolicy:1-37, GrpcContextSnapshot:1-70}
main/java/…/deadline/{GrpcCancellableOperation:1-23, GrpcCancellationCoordinator:1-122, GrpcCancellationReason:1-48,
GrpcDeadlineCalculator:1-68, GrpcDeadlinePolicyValidator:1-93, GrpcDependencyBudget:1-48}
main/java/…/error/{GrpcErrorExposurePolicy:1-82, GrpcErrorMapper:1-179, GrpcRichErrorDetail:1-93, GrpcStatusMapping:1-53}
main/java/…/idempotency/{GrpcCompletionReconciler:1-93, GrpcCompletionResolution:1-52, GrpcIdempotencyDecision:1-95,
GrpcIdempotencyInterceptor:1-148, GrpcOperationStatus:1-32, GrpcOperationStatusQuery:1-90,
GrpcOutcomeReplay:1-62, GrpcRequestFingerprint:1-55}
main/java/…/policy/{GrpcCompressionProfile:1-50, GrpcMessageSizeProfile:1-57, GrpcPayloadBoundaryPolicy:1-161,
GrpcSizeViolation:1-46}
main/java/…/resilience/{GrpcMethodRetryConfig:1-99, GrpcRetryBudget:1-76, GrpcRetryCoordinator:1-126,
GrpcRetryDecision:1-64, GrpcRetryEligibility:1-103, GrpcRetryOwner:1-39,
GrpcRetryOwnershipValidator:1-79, GrpcServiceConfigPolicy:1-73,
GrpcWaitForReadyDecision:1-40, GrpcWaitForReadyProfile:1-54, GrpcWaitForReadyValidator:1-56}
main/java/…/security/{GrpcAuthenticationProfile:1-59, GrpcCredentialGeneration:1-47,
GrpcCredentialRotationManager:1-122, GrpcTlsProfile:1-101}
main/java/…/streaming/{GrpcFlowControlDecision:1-43, GrpcFlowControlPolicy:1-88, GrpcResumeDecision:1-57,
GrpcResumeToken:1-91, GrpcResumeTokenCodec:1-142, GrpcSerializedStreamWriter:1-176,
GrpcSlowConsumerPolicy:1-26, GrpcStreamAdmission:1-76, GrpcStreamEnvelope:1-128,
GrpcStreamGapDetector:1-104, GrpcStreamHeartbeat:1-62, GrpcStreamId:1-41,
GrpcStreamLifecycleCoordinator:1-82, GrpcStreamLifetimePolicy:1-71, GrpcStreamProfile:1-47,
GrpcStreamSequence:1-48, GrpcStreamTerminationReason:1-51, GrpcStreamWriteResult:1-25,
GrpcStreamWriterState:1-22}
main/java/…/validation/{GrpcTransportValidator:1-134, GrpcValidationViolation:1-35, ProtovalidateGrpcInterceptor:1-115}
test/java/…/{context:1, deadline:2, error:1, idempotency:3, policy:1, resilience:3, security:1, streaming:5, validation:1} — 18파일 2,800줄
grpc-client/…/GrpcChannelRuntime.java:20 (§12.3 대조)
grpc-admin/…/GrpcServiceHealthRegistry.java:25 (§12.3 대조)