Files
document-haness/docs/clean-architecture-backend-template/analysis/grpc/grpc-advanced-streaming.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 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>
2026-09-04 22:51:59 +09:00

238 lines
13 KiB
Markdown

# grpc-advanced-streaming 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 14파일 833줄 + `src/test` 4파일 429줄 축자 통독 완료. §17.1·§17.2 를 독립적으로 재도출했고 둘 다 성립한다. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc-advanced/grpc-advanced-streaming`
> SSOT owner: `grpc-advanced-streaming`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: `["grpc-core-api", "grpc-policy", "grpc-advanced-bootstrap"]`
- `runtime_memberships`: **`[]`** — build-only
| 파일 | LOC |
|---|---:|
| `GrpcClientMessageDeduplicator` | 123 |
| `GrpcDemandController` | 105 |
| `GrpcBidiSession` · `GrpcBidiSequenceTracker` | 77 · 62 |
| `GrpcBidiDirectionState` · `GrpcClientStreamResumeDecision` · `GrpcClientStreamCheckpoint` | 61 · 58 · 56 |
| `GrpcClientStreamPolicy` · `GrpcClientStreamSessionId` · `GrpcManualFlowControlPolicy` | 50 · 46 · 45 |
| `GrpcBidiResumeState` · `GrpcDemandDecision` · `GrpcClientStreamState` · `GrpcClientStreamMessage` | 42 · 40 · 36 · 32 |
| test 4파일 | 429 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 14 | `FULL_READ` | 833줄 전 본문 |
| `test/java/**` | 4 | `FULL_READ` | 429줄 |
| `build.gradle` | 1 | `FULL_READ` | 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
```groovy
// build.gradle:3-5
// The streaming shapes the Stable plan deliberately excludes: client streaming sessions with
// dedup/checkpoint/resume, bidirectional sessions with independent per-direction sequences, and the
// manual flow-control approval API.
```
## 2. 적용됨과 수신됨을 구분한다
`GrpcClientStreamCheckpoint` javadoc:
> "Applied, not received. The distinction is the whole contract: the transport acknowledging a
> message means it reached the server's buffer, and a checkpoint means the application committed its
> effect. A resume that continues from a transport acknowledgement skips everything that was
> received and not yet applied when the connection died."
그리고 체크포인트는 뒤로 갈 수 없다 — 뒤로 가려는 시도는 두 기록자가 한 세션을 체크포인트하고 있다는 뜻이다.
## 3. 집합이 아니라 체크포인트
`GrpcClientMessageDeduplicator` javadoc:
> "Checkpoint-based rather than a set of seen keys. **A set grows without bound for the life of a
> session** and answers 'have I seen this' — which is not quite the question. The question is 'has
> this been applied', and a monotonic applied-sequence answers it in constant space and survives the
> process restart that a set does not."
판정은 셋이다 — 이미 적용됨이면 재생, 다음 순번보다 앞서면 간극, 아니면 적용.
재개 판정은 두 겹이다. 제시한 호출자가 세션 소유자와 다르면 거절하고, 체크포인트가 없으면 새 세션으로 돌린다.
> "the server holds no checkpoint for this session; resuming would leave its prefix either lost or
> applied twice, with nothing to tell which"
그리고 적용 기록의 자바독이 저장소 쪽 요구를 적는다 — 적용 효과와 체크포인트는 한 트랜잭션에 있어야 하며, 따로 커밋하면 효과는 내구적이고 체크포인트는 아닌 창이 생긴다.
## 4. 방향마다 독립된 순번
> "the client's message 5 and the server's message 5 are unrelated events, and a shared counter makes
> a resume token from one side meaningless to the other — so a reconnect either skips or replays,
> depending on which side moved faster."
절반 닫기와 취소가 방향별로 따로 있다.
## 5. 수동 흐름 제어
승인이 record 의 필드이고 거짓이면 생성자가 거부한다.
> "Approval is a field because this capability is granted per method, not per service. A method that
> reads a large result set benefits; the one next to it does not, and enabling both because they
> share a service is how the second one acquires a bug nobody was looking for."
수요 상한과 교착 감시가 필수다 — 상한 없는 `request(n)` 은 단계만 늘린 무제한 버퍼링이다.
감시견은 잠들지 않고 두 시각을 비교한다 — 마지막으로 수요를 요청한 때와 마지막으로 메시지가 움직인 때. 둘 다 시간 제한만큼 멈춰 있으면 교착이다.
## 10. 테스트 레인
네 테스트 429줄. 중복 제거 판정과 재개, 수요 상한과 교착, 방향별 순번, 클라이언트 스트림 정책 거부를 확인한다.
## 12. negative-space probes
**12.1 도달성.** Advanced 가족이므로 배선 경로가 없다. 리프 밖 참조도 없다.
**12.2 대조군 — 동시성 규율.** 이 리프는 가족 안에서 동시성을 가장 잘 다룬다.
| 클래스 | 보호 |
|---|---|
| `GrpcDemandController` | 모든 공개 메서드 `synchronized` |
| `GrpcBidiSequenceTracker` | 모든 공개 메서드 `synchronized` |
| `GrpcClientMessageDeduplicator` | `ConcurrentHashMap` 둘 |
특히 `GrpcDemandController.messageReceived``if (outstandingDemand > 0) outstandingDemand--;``synchronized` 안이라 경합하지 않는다. 같은 형태가 `grpc-server``GrpcAdmissionController.release``grpc-client``GrpcChannelRuntime.finishUnaryCall` 에서는 보호 없이 쓰여 각각 결함이 된다.
**12.4 드리프트.** build.gradle 이 서술한 세 요소가 전부 존재한다.
## 16. 확인하지 못한 것
- 실제 스트림을 열어 재개를 재현하지 않았다. 배선 경로가 없다.
- `replayableOutcomes` 의 증가를 장시간 실행으로 측정하지 않았다(§17.1). 제거 경로 부재로 판정했다.
## 17. 손볼 것
### 17.1 P3 — 클래스가 비판한 무제한 증가를 형제 맵이 그대로 한다
클래스 javadoc 이 집합 방식을 거부한 이유가 무제한 증가다 — "A set grows without bound for the life of a session".
체크포인트는 그 비판을 지킨다. 세션당 항목 하나이고 순번만 앞으로 간다.
형제 맵은 지키지 않는다.
```java
private final ConcurrentMap<String, String> replayableOutcomes = new ConcurrentHashMap<>();
public void recordApplied(GrpcClientStreamMessage<?> message, String outcomeReference, Instant at) {
checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at));
if (outcomeReference != null && !outcomeReference.isBlank()) {
replayableOutcomes.put(message.dedupKey(), outcomeReference); // ← 메시지마다 한 항목
}
}
```
제거는 `endSession` 뿐이고, 그때 그 세션의 접두를 가진 키를 전부 지운다.
그러므로 결과 참조를 기록하는 세션에서는 적용된 메시지 수만큼 항목이 쌓인다. 상한도 만료도 없다.
클래스 javadoc 은 다르게 말한다.
> "Replayed outcomes are kept for **the small window after the checkpoint**, so a duplicate that
> arrives before the checkpoint advances gets the original answer rather than being reapplied."
작은 창이 코드에 없다. 체크포인트가 앞으로 가도 그 이전 결과들은 남는다.
그리고 실제로 필요한 창은 좁다 — 판정이 `alreadyApplied(sequence)` 로 재생을 결정하고, 재생 응답에 쓰이는 것은 그 순번의 결과 하나다. 체크포인트보다 한참 뒤처진 순번의 결과가 필요할 상황은 재개 직후의 좁은 구간뿐이다.
수정은 창을 실제로 만드는 것이다 — 세션당 최근 N개만 유지하거나, 체크포인트가 앞으로 갈 때 그보다 오래된 항목을 지운다. 후자가 자바독의 서술과 정확히 같다.
### 17.2 P3 — 클라이언트 스트림 정책의 네 상한 중 둘은 읽는 코드가 없다
`GrpcClientStreamPolicy` javadoc 이 네 상한을 모두 든다.
> "all four bounds are about the client rather than the server: how long it may hold the stream, how
> long it may go quiet, how fast it may send, and how much it may have unacknowledged."
저장소 전체에서 접근자 호출을 세면 둘이 0 이다.
```
maxMessagesPerSecond production 호출 0
maxInFlightMessages production 호출 0
wholeStreamRetryAllowed production 호출 0
```
Advanced 가족이 미배선이라는 사실과는 별개다 — 이 리프 안에도 그 값을 쓰는 코드가 없다. 수요 상한을 강제하는 `GrpcDemandController``GrpcManualFlowControlPolicy` 를 쓰고, 이 정책을 보지 않는다.
`wholeStreamRetryAllowed()` 는 항상 거짓을 돌려주는 형태이므로 그 자체가 문서화 장치다. 나머지 둘은 강제 지점이 필요하다.
수정은 상한을 강제하는 지점을 만들거나(수신 경로에 속도·미확인 수 검사), 강제되지 않는 값이 강제되는 것처럼 읽히지 않도록 자바독을 낮추는 것이다.
### 17.3 P3 — 체크포인트 전진이 `ConcurrentMap` 위의 확인 후 쓰기다
`GrpcClientStreamCheckpoint.advancedTo` 가 뒤로 가는 것을 거부하고, 그 메시지가 원인을 정확히 짚는다 — "two writers are checkpointing one session". 그 가드가 보는 것은 **호출한 스레드가 읽은 값** 이다.
```java
public void recordApplied(GrpcClientStreamMessage<?> message, String outcomeReference, Instant at) {
GrpcClientStreamCheckpoint checkpoint = requireCheckpoint(message.sessionId()); // ← 읽기
checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at)); // ← 조건 없는 쓰기
```
두 스레드가 순번 5 와 6 을 적용하며 같은 체크포인트(4)를 읽으면 둘 다 `advancedTo` 를 통과한다. 5 를 든 쪽이 나중에 `put` 하면 체크포인트는 6 에서 5 로 **뒤로 간다**`advancedTo` 가 막겠다고 한 바로 그 상태이고, 이번에는 예외 없이 조용히 일어난다.
그러면 순번 6 의 메시지가 다시 `APPLY` 로 판정되어 두 번 적용된다. 이 클래스가 존재하는 이유가 정확히 그것을 막는 것이다.
`ConcurrentHashMap` 에는 이 형태를 위한 연산이 있다.
```java
checkpoints.compute(key, (k, existing) -> existing.advancedTo(message.sequence(), at));
```
`compute` 안에서는 읽기와 쓰기가 원자적이므로, 뒤처진 쪽이 `advancedTo` 의 예외를 실제로 받는다 — 가드가 설계대로 발화한다.
**대조.** 같은 리프의 `GrpcDemandController` 는 모든 공개 메서드가 `synchronized` 이고, `GrpcBidiSequenceTracker` 도 그렇다(§12.2 가 그것을 이 가족의 모범으로 든다). 중복 제거기만 `ConcurrentMap` 의 원자 연산을 쓰지 않는다.
**시험이 보지 못하는 이유.** 중복 제거기 시험 아홉 개가 전부 단일 스레드다. 순차적으로는 `advancedTo` 가 정확히 작동하고, 전용 시험(`aCheckpointRecordsWhatWasApplied`)이 그것을 확인한다 — 확인하는 것은 record 의 메서드이지 맵에 쓰는 경로가 아니다.
**등급.** 미배선이므로 P3. 다만 이 클래스의 javadoc 이 "The application effect and this checkpoint belong in one transaction" 이라고 적어 둔 것과 함께 보면, 이 자리는 배선되는 날 트랜잭션 경계와 함께 다시 설계될 곳이다.
### 확인된 설계(문제 아님)
- **적용됨과 수신됨을 구분하고 그 차이를 계약으로 삼은 것.**
- **집합 대신 단조 증가 순번으로 상수 공간을 쓴 것.**
- **체크포인트가 뒤로 가려는 시도를 두 기록자의 신호로 읽는 것.**
- **재개에서 소유자 불일치를 거절하고, 체크포인트 부재를 새 세션으로 돌리는 것.**
- **적용 효과와 체크포인트를 한 트랜잭션에 두라는 요구를 자바독에 남긴 것.**
- **방향별 순번을 합치지 않은 것과 그 근거.**
- **수동 흐름 제어 승인을 메서드 단위 필드로 둔 것.**
- **감시견이 잠들지 않고 두 시각을 비교하는 것.**
- **전체 스트림 재시도를 설정이 아니라 상수 거절로 둔 것.**
- **동시성 보호를 실제로 적용한 것** — 가족의 다른 리프와 대조된다.
---
## Source anchors
```
src/grpc-advanced/grpc-advanced-streaming/build.gradle
main/java/…/streaming/GrpcClientMessageDeduplicator.java:1-123
main/java/…/streaming/GrpcDemandController.java:1-105
main/java/…/streaming/GrpcBidiSequenceTracker.java:1-62
main/java/…/streaming/GrpcClientStreamCheckpoint.java:1-56
main/java/…/streaming/GrpcClientStreamPolicy.java:1-50
main/java/…/streaming/GrpcManualFlowControlPolicy.java:1-45
main/java/…/streaming/GrpcClientStreamMessage.java:1-32
main/java/…/streaming/(GrpcBidiSession · GrpcBidiDirectionState · GrpcBidiResumeState · GrpcClientStreamResumeDecision · GrpcClientStreamSessionId · GrpcDemandDecision · GrpcClientStreamState)
test/java/…/streaming/(GrpcClientMessageDeduplicatorTest · GrpcDemandControllerTest · GrpcBidiSequenceTrackerTest · GrpcClientStreamPolicyTest)
```