Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/grpc-and-streaming/case/case-a20-f008-grpcserializedstreamwriter-drop-oldest.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

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-f008-grpcserializedstreamwriter-drop-oldest GrpcSerializedStreamWriter 가 버린 봉투 대신 들어오는 크기를 뺀다 grpc-and-streaming clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a20-f008-grpcserializedstreamwriter-drop-oldest 2026-09-04 case-a20-f008-grpcserializedstreamwriter-drop-oldest.body.md
key file
a20-f008-grpcserializedstreamwriter-drop-oldest ../../../final/evidence/rendered/a20-f008-grpcserializedstreamwriter-drop-oldest.svg
../../../final/evidence/raw/a20-f008-grpcserializedstreamwriter-drop-oldest.txt
원본 분석 절은 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. 이 타입의 참조를 경로 제한 없이 검색한다.

본문

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~:89DROP_OLDEST 분기는 queue.pollFirst() 로 봉투를 꺼내고(:82), 널이 아니면 queuedBytes = Math.max(0L, queuedBytes - nextBytes) 를 한다(:84). nextBytes:73 에서 payloadSizer.getAsLong() 로 얻은 값, 곧 지금 들어오는 메시지의 크기다. 이어서 :87enqueue:106 에서 같은 nextBytes 를 다시 더한다.

꺼낸 봉투의 크기를 쓸 수도 없다. GrpcStreamEnvelope 는 성분 일곱을 담는데 — streamId, sequence, kind, snapshotVersion, resumeToken, terminationReason, payload — 크기가 없다. payloadSizer 는 인자를 받지 않는 LongSupplier 라 버린 봉투를 넘겨 되물을 수도 없다.

GrpcFlowControlPolicy 가 누적 바이트를 상한과 비교한다

:57queuedBytes + 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:104DROP_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 코드가 없으므로 그 자리도 없다.