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>
16 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 | analysis-finding-a06-f019 | MongoChangeHistoryLostException 을 만드는 코드가 없다 | multitenancy-isolation | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:analysis-finding-a06-f019 | 2026-09-04 | case-analysis-finding-a06-f019.body.md |
|
|
|
MongoChangeHistoryLostException 을 만드는 코드가 없다
MongoChangeHistoryLostException 의 클래스 자바독은 이 예외가 왜 전용 타입인지를 적고, docs/mongodb/change-stream-guide.md:75 는 그것이 던져진다고 적는다. 그런데 자바 소스에서 그 이름이 나오는 두 줄은 둘 다 자기 선언이고, 히스토리 유실을 재현한 시험은 호출자가 받는 예외를 MongoQueryException 으로 단언한다.
관계
- 타입이 문서화한 불변식은 타입이 강제한다
MongoChangeHistoryLostException:11~:14가 복구를 업무 결정이라 부르며 전용 타입을 둔 이유를 적는데, 그 예외를new하는 줄이 main 에도 시험에도 없다. - 중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다
onFailure:53~:55가 히스토리 유실을 자기 안에서 처리하므로onHistoryLost:29는 같은 결정을 만드는 두 번째 구현이다. - TTL 규칙 셋을 가진 타입들을 부르는 프로덕션 코드가 없다 두 기록 모두 자바독에 규칙을 적어 둔 타입이 선언만 있고, 그것을 만들거나 부르는 프로덕션 코드가 0 이다.
문제
이 패키지는 스트림 실패 뒤의 처리를 담고, 네 파일이 정책과 결정과 무효화 복구와 전용 예외를 나눠 맡는다.
그중 무엇이 프로덕션 호출자를 갖는지 확인했다.
결론
MongoChangeStreamRecoveryPolicy 는 실패 종류마다 진입점을 두어 넷이 있는데, 프로덕션에서 불리는 것은 onFailure:51 뿐이다. 나머지 셋 가운데 둘은 시험만 부르고 onInvalidate 는 아무도 부르지 않는다.
그 이유가 onFailure 안에 있다. :53~:55 가 히스토리 유실 서버 코드를 자기 안에서 걸러 onHistoryLost:31~:32 와 똑같은 결정을 만든다.
무효화 복구 쪽도 둘 중 하나만 불린다. checkpointFor 는 프로덕션 호출이 없고, 복구 패키지 밖 타입인 MongoChangeStreamState:32 의 autoResumable() 도 마찬가지다.
그 하나뿐인 호출이 자기 자신과 견준다. ReactiveMongoChangeStreamConsumer:119 가 체크포인트와 그 체크포인트의 위치를 두 인자로 넘기는데, MongoInvalidateRecovery:35 의 검사는 그 둘이 다른지를 본다.
MongoChangeHistoryLostException 은 어디에서도 만들어지지 않는다. 그 이름이 자바 소스에 나오는 줄이 둘뿐이고 둘 다 그 파일 자신의 선언이며, new 를 부르는 줄은 main 에도 시험에도 없다.
:11~:14 자바독이 그 타입이 왜 전용이어야 하는지 적는다. 남은 선택지가 투영 재생성과 원본 재조정 둘뿐이고 어느 쪽도 코드가 혼자 고를 수 없다는 것이다.
실제로는 상태만 HISTORY_LOST 가 되고 드라이버 예외가 그대로 나간다. ChangeStreamConsumerLifecycleTest:115 가 verifyError(MongoQueryException.class) 로 그것을 고정한다. 그래서 호출자는 예외 타입으로 이 상황을 구분할 수 없고 MongoChangeStreamState 를 확인해야 한다.
문서 셋이 그 반대를 적는다. docs/mongodb/change-stream-guide.md:71 이 히스토리 유실을 이 예외로 잇고 :75 가 그것이 던져진다고 적으며, docs/mongodb/runbooks/history-lost.md:23 이 증상 목록에 그 이름을 올린다.
검증 환경
OpenJDK : 21.0.12 확인 방식 : 복구 패키지의 파일 전수, 정책 전문 인용, 진입점마다 main 과 시험 호출 계수와 대조, 전용 예외의 자바독과 그 이름이 자바 소스와 문서에 나오는 줄 전수, onFailure 의 히스토리 유실 분기 인용, 소비자가 복구를 부르는 자리 전수, requireCorrectResumeOption 정의와 호출처 전수, 히스토리 유실 시 호출자가 받는 것을 고정한 시험 인용, 상태 열거형 자바독과 autoResumable 인용 소스 수정 : x
재현 조건
- 복구 패키지의 파일을 전부 나열하고 정책을 전문으로 싣는다.
- 진입점마다 main 과 시험에서 부르는 줄을 각각 센다.
- 전용 예외의 이름이 자바 소스에 몇 줄 나오는지 세고 전부 나열한 뒤, 같은 이름을 문서가 쓰는 자리도 함께 싣는다.
- 그 예외의 클래스 자바독을 인용한다.
- onFailure 의 히스토리 유실 분기와 소비자가 복구를 부르는 자리를 인용한다.
- requireCorrectResumeOption 의 검사와 그것을 부르는 자리를 전부 나열한다.
- 히스토리 유실 시 호출자가 받는 예외 타입을 고정한 시험을 인용한다.
본문
변경 스트림이 실패한 뒤의 처리를 이 패키지가 맡는다.
자바독이 나눈 세 실패와 진입점 넷
:::evidence key="analysis-finding-a06-f019" alt="저장소 루트에서 돌린 정적 검색 출력 208줄. 먼저 복구 패키지의 파일 넷이 나열되는데 MongoChangeHistoryLostException 과 MongoChangeStreamRecoveryDecision 과 MongoChangeStreamRecoveryPolicy 와 MongoInvalidateRecovery 다. 이어서 MongoChangeStreamRecoveryPolicy 766번 전문이 실린다. 813번 자바독은 실패를 셋으로 나누는데 재개 가능한 네트워크나 선출 실패는 저장된 토큰이 아직 유효하므로 자동으로 재개하고, 컬렉션이 삭제되거나 이름이 바뀐 무효화는 resumeAfter 가 아니라 startAfter 가 필요하며 드라이버가 그것을 암묵적으로 해 주지 않고, 유실된 히스토리는 사람이 필요하다고 적는다. 1721번이 두 런북 경로 상수이고, 2333번의 onHistoryLost 는 절대 자동 재개하지 않으며 현재 시각부터 다시 시작하면 알 수 없는 범위의 변경이 빠진 투영이 스스로를 건강하다고 보고하게 된다고 적는다. 3638번이 onResumableFailure, 4048번이 onInvalidate 인데 4344번 자바독이 저장하는 체크포인트가 startAfter 위치여야 한다고 적는다. 5061번의 onFailure 가 5355번에서 서버 코드가 히스토리 유실이면 HISTORY_LOST 로 멈추고, 5758번에서 재개 가능 라벨이면 재개하며, 60번에서 나머지를 FAILED 로 멈춘다. 6366번이 그 서버 코드를 286 과 280 으로 적는다. 다음으로 진입점마다 main 과 시험 호출 계수가 나오는데 onFailure 가 main 1 에 시험 3, onHistoryLost 가 main 0 에 시험 1, onResumableFailure 가 main 0 에 시험 1, onInvalidate 가 둘 다 0, requireCorrectResumeOption 이 main 1 에 시험 1, checkpointFor 가 main 0 에 시험 1, autoResumable 이 main 0 에 시험 3 이고, new MongoChangeHistoryLostException 은 main 과 시험 모두 0 이다. onInvalidate 라는 이름이 나오는 줄 전부가 2 개라는 대조가 먼저 나오고, 그 예외 이름이 자바 소스에 나오는 줄이 2 개인데 둘 다 MongoChangeHistoryLostException 자신의 16번 클래스 선언과 22번 생성자다. 이어서 같은 이름을 문서가 쓰는 자리 넷이 나오는데 docs/architecture/mongo-api-surface.txt 168번이 공개 표면 목록에 올리고, docs/mongodb/change-stream-guide.md 71번 표가 oplog 에 토큰이 없는 경우를 이 예외로 잇고 75번이 재개 토큰이 가장 오래된 oplog 항목보다 앞설 때 이 예외가 던져진다고 적으며, docs/mongodb/runbooks/history-lost.md 23번이 증상 목록에 그 이름을 올린다. 이어서 그 예외 829번이 실린다. 9번 자바독은 oplog 에 저장된 재개 위치가 더 이상 없다는 뜻이고, 1114번은 복구가 기술적 결정이 아니라 업무 결정이라 전용 타입이며, 플랫폼이 지금부터 재개할 수도 있고 드라이버가 그것을 쉽게 해 주지만 그러면 투영이 oplog 에서 떨어진 모든 변경을 조용히 놓치므로 정직한 선택지는 투영을 다시 만들거나 원본과 재조정하는 것뿐이고 둘 다 누군가가 골라야 한다고 적는다. 2228번 생성자의 메시지는 변경 스트림의 재개 위치가 더 이상 oplog 에 없어서 지금부터 다시 시작해 틈을 조용히 건너뛰는 대신 소비를 멈췄다는 것이다. 그 아래 onFailure 5061번이 다시 실리고, 소비자가 복구를 부르는 자리로 ReactiveMongoChangeStreamConsumer 119번과 214번이 나온다. 이어서 히스토리 유실 시 호출자가 받는 것을 고정한 시험 ChangeStreamConsumerLifecycleTest 108124번이 실리는데 112번이 서버 코드 286 오류를 흘리고 115번이 MongoQueryException 으로 끝나는 것을 단언하며 117119번이 상태가 HISTORY_LOST 이고 런북이 히스토리 유실 런북인지를, 120122번이 스트림을 한 번만 열었는지를 확인한다. 다음으로 MongoInvalidateRecovery 2445번이 실려 3132번 서명과 35번의 checkpoint.position 이 intended 와 다른지 보는 검사가 나오고, 그 메서드를 부르는 자리가 ReactiveMongoChangeStreamConsumer 119번과 정의와 시험 하나로 나열되는데 소비자 쪽은 checkpoint 와 checkpoint.position 을 두 인자로 넘긴다. 마지막으로 MongoChangeStreamState 135번이 실리는데 69번 자바독이 HISTORY_LOST 가 FAILED 와 분리된 이유를 적는다 — 플랫폼이 스스로 복구하기를 거부하는 유일한 상태이고, oplog 가 저장된 토큰을 지나쳐 굴러간 뒤 지금부터 재개하면 그 사이의 모든 변경을 조용히 버려서 투영이 건강해 보이면서 조용히 틀리게 되는데 그것이 누군가 봐야 하는 멈춘 소비자보다 나쁘다는 것이다." caption="복구 패키지 파일 넷과 정책 전문 · 진입점별 main·시험 호출 계수 · 예외 이름이 자바 소스에 두 줄이고 문서에 넷 · 그 예외가 적은 존재 이유 · onFailure 의 히스토리 유실 분기와 소비자가 부르는 두 자리 · 호출자가 받는 예외 타입을 고정한 시험 · 자기 자신과 견주는 검사 · 상태 열거형이 적은 구분 — 208줄 · exit 0" zoom="true"
:::
MongoChangeStreamRecoveryPolicy:8~:13 자바독이 실패를 셋으로 나눈다. 재개 가능한 네트워크나 선출 실패는 저장된 토큰이 아직 유효하므로 자동 재개하고, 무효화는 startAfter 가 필요하며 드라이버가 암묵적으로 해 주지 않고, 유실된 히스토리는 사람이 필요하다.
진입점은 넷이다. onHistoryLost:29, onResumableFailure:36, onInvalidate:46, onFailure:51 이다.
호출자를 가진 것과 갖지 못한 것
main 에서 불리는 것은 onFailure 하나다. onHistoryLost 와 onResumableFailure 는 main 0 에 시험 1 씩이고 onInvalidate 는 둘 다 0 이다.
MongoInvalidateRecovery 의 두 메서드도 호출자 유무가 다르다. requireCorrectResumeOption 은 main 1 이고 checkpointFor 는 0 이다. MongoChangeStreamState:32 의 autoResumable() 도 main 0 에 시험 3 인데, 이쪽은 복구 패키지 밖 타입이다.
onHistoryLost 가 호출자를 갖지 못한 이유는 onFailure 안에 있다. :53~:55 가 서버 코드 286 이나 280 이면 HISTORY_LOST 로 멈추는 결정을 만든다. onHistoryLost:31~:32 가 만드는 것과 같은 결정이다.
전용 예외가 만들어지지 않는다
MongoChangeHistoryLostException 이라는 이름이 저장소 전체에 두 줄 나온다. :16 의 클래스 선언과 :22 의 생성자다.
new 를 부르는 줄이 main 도 시험도 0 이다.
:11~:14 자바독은 복구가 기술적 결정이 아니라 업무 결정이라 전용 타입을 두었다고 적는다. 플랫폼이 지금부터 재개할 수도 있고 드라이버가 그것을 쉽게 해 주지만, 그러면 투영이 oplog 에서 떨어진 모든 변경을 조용히 놓친다. 남은 선택지는 투영을 다시 만들거나 원본과 재조정하는 것뿐이고 둘 다 누군가가 골라야 한다.
:22~:28 의 생성자 메시지에도 재개 위치가 더 이상 oplog 에 없어서, 지금부터 다시 시작해 틈을 건너뛰는 대신 소비를 멈췄다고 적혀 있다.
실제로 나가는 것은 드라이버 예외다
ReactiveMongoChangeStreamConsumer:214 가 recovery.onFailure(MongoDriverFailureView.from(driverFailure)) 로 결정을 받는다. 상태와 런북은 그 결정에서 온다.
예외는 바뀌지 않는다. ChangeStreamConsumerLifecycleTest:112 가 서버 코드 286 오류를 흘리고 :115 가 verifyError(MongoQueryException.class) 로 끝나는 것을 단언한다. :117~:119 가 상태는 HISTORY_LOST 이고 런북은 히스토리 유실 런북인 것을 확인한다.
그래서 호출자가 catch (MongoChangeHistoryLostException) 으로 이 상황을 가르려 하면 잡히지 않는다. 상태를 물어보는 경로로만 알 수 있다.
MongoChangeStreamState:6~:9 는 HISTORY_LOST 가 FAILED 와 분리된 이유를 적는다. 플랫폼이 스스로 복구하기를 거부하는 유일한 상태이고, 지금부터 재개하면 투영이 건강해 보이면서 조용히 틀리게 되는데 그것이 누군가 봐야 하는 멈춘 소비자보다 나쁘기 때문이다.
이 구분은 MongoChangeStreamState 에만 있고, 호출자가 받는 예외 타입에는 없다.
requireCorrectResumeOption 에 같은 값이 두 번 들어간다
MongoInvalidateRecovery:31~:32 의 requireCorrectResumeOption 이 체크포인트와 의도한 위치를 받는다. :35 가 checkpoint.position() != intended 면 던진다.
requireCorrectResumeOption 을 부르는 main 코드는 ReactiveMongoChangeStreamConsumer:119 하나인데, 인자가 checkpoint 와 checkpoint.position() 이다. 같은 값을 두 번 넣으므로 그 조건이 참이 될 수 없다.
시험 쪽 MongoChangeStreamRecoveryPolicyTest:59 만 다른 값을 넘겨 던지는 것을 확인한다.
원문에 없는 것
원문은 호출 계수를 정리하면서 전용 예외가 어디에서도 만들어지지 않는 것을 가장 무겁게 봤다. 그 판정은 그대로다.
원문이 적지 않은 것은 문서 쪽이다. docs/mongodb/change-stream-guide.md:75 가 이 예외는 재개 토큰이 가장 오래된 oplog 항목보다 앞설 때 던져진다고 적고, :71 의 표가 히스토리 유실을 그 예외로 잇는다. docs/mongodb/runbooks/history-lost.md:23 은 그것을 증상 목록에 올린다. 세 자리가 코드에 없는 예외를 운영자에게 안내한다.
확인하지 못한 것
히스토리 유실을 실제로 일으켜 보지 않았다. 예외 타입 판단은 시험 단언에 기댄다.
MongoChangeStreamRecoveryDecision 이 halt 와 resume 말고 다른 상태를 만들 수 있는지 그 타입을 따로 열지 않았다.
docs/architecture/mongo-api-surface.txt:168 이 이 예외를 공개 표면에 올려 둔 것이 의도인지 판단하지 않았다.
서버 코드 286 을 직접 흘려 호출자가 받는 타입을 관측하지 않았다. 판단 근거는 시험이 단언하는 값이다.
호출자가 없는 다섯 진입점을 포크가 쓰도록 남겨 둔 것인지 설계 문서로 판단하지 않았다.