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>
171 lines
18 KiB
Markdown
171 lines
18 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: analysis-finding-a06-f005
|
|
title: cause 를 받지 않는다는 루트 규칙이 스물둘 중 하나에 대해 거짓이다
|
|
topic: multitenancy-isolation
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:analysis-finding-a06-f005
|
|
evidenceCapturedOn: 2026-09-04
|
|
body: case-analysis-finding-a06-f005.body.md
|
|
assets:
|
|
- key: analysis-finding-a06-f005
|
|
file: ../../../final/evidence/rendered/analysis-finding-a06-f005.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/analysis-finding-a06-f005.txt
|
|
source:
|
|
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md §17 이다.
|
|
---
|
|
|
|
# cause 를 받지 않는다는 루트 규칙이 스물둘 중 하나에 대해 거짓이다
|
|
|
|
루트 예외의 자바독은 생성자가 원인을 받지 않으므로 드라이버 예외가 다시 새지 않는다고 적는다. 상속 타입 스물둘 중 `MongoTimeoutException:25` 가 그 생성자를 갖고 있고, 생성자를 아예 지나지 않는 자리도 하나 있다 — `SpringMongoTransactionSessionFactory:194` 다.
|
|
|
|
## 관계
|
|
|
|
- **관측을 위해 수집한 데이터가 관측 대상보다 위험할 수 있다**
|
|
`MongoPersistenceException` 의 두 번째 규칙이 그 규칙을 구현한다. 실패 컨텍스트가 일부러 버린 값이 원인 사슬로 되돌아오는 경로를 막으려는 것이다.
|
|
- **시그니처가 payload를 받지 않는데 예외 메시지로 PII가 로그에 남았다**
|
|
그 기록에서는 실제로 값이 새어 로그에 남았다. `MongoTimeoutException:25` 가 받는 원인은 Reactor 자신의 타임아웃이라 문서도 질의도 자격증명도 담지 않는다.
|
|
- **문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다**
|
|
규칙과 검사가 같은 것을 말하는지의 문제다. `MongoFailureContextTest:58` 이 원인을 받는 생성자가 없는 타입을 골라 단언한다.
|
|
|
|
## 문제
|
|
|
|
루트 예외의 자바독이 두 규칙을 세운다. 하나는 메시지가 담을 수 있는 것을 제한하고, 다른 하나는 원인 사슬 자체를 금지한다.
|
|
|
|
그 두 번째 규칙이 하위 타입 전부에 대해 참인지 확인했다.
|
|
|
|
## 결론
|
|
|
|
이 규칙이 걸리는 범위는 스물둘이고, 전부 final 이라 그 아래가 더 없다.
|
|
|
|
그중 원인을 받는 생성자를 가진 것은 MongoTimeoutException 하나이고 :27 이 initCause 를 부른다. 검색이 351 개 파일을 훑었고 persistence-jpa 에 같은 것을 걸면 19 줄이 나온다.
|
|
|
|
MongoTimeoutException:20~:24 의 자바독이 원인을 남기는 이유를 적는다.
|
|
|
|
그 생성자를 부르는 자리는 DefaultReactiveMongoExecutor:152 하나다. 넘기는 값은 :146 이 잡은 Reactor 의 java.util.concurrent.TimeoutException 이고, 드라이버 예외는 :161 이후의 다른 블록에서 처리된다.
|
|
|
|
:147~:150 주석이 감싸게 된 내력을 적는다. 예전 동작은 호출자에게 이름조차 알린 적 없는 타입이 아무 맥락 없이 도달하는 것이었고 실패가 기록되지도 않았다.
|
|
|
|
어긋난 것은 루트 자바독의 문장이다. 하위 타입 자바독에는 원인을 남기는 이유가 적혀 있는데 루트에는 그 예외가 없어서, 규칙만 읽은 채택자가 잘못된 전제로 로깅 정책을 세울 수 있다.
|
|
|
|
생성자만 훑어서는 잡히지 않는 자리가 하나 더 있다. :194 가 inFlight 에 정리 실패를 붙이는데, 그 필드는 :214·:220 에서 이 계층의 번역된 예외가 되고 붙는 값은 :169·:175 가 세션 중단과 닫기에서 잡은 드라이버 예외다.
|
|
|
|
규칙의 문자는 지켜진다. 지켜지지 않는 것은 그 규칙이 든 두 통로 중 뒤쪽이다.
|
|
|
|
검사도 이 두 자리를 비껴간다. 이 계층에서 getCause() 를 단언하는 시험 줄은 MongoFailureContextTest:65 하나인데, :60 이 고른 대상이 MongoTransactionCommitUnknownException 이다. 그 타입에는 원인을 받는 생성자가 없어서 :65 의 isNull() 이 무엇을 넣든 통과한다. 공교롭게도 그 타입이 SpringMongoTransactionSessionFactory:249 가 만드는 둘 중 하나여서, 억눌린 예외를 보는 단언이었다면 결과가 달랐을 수 있다.
|
|
|
|
MongoTimeoutException 은 시험 소스에 이름조차 없다. 같은 검색이 다른 타입에 대해서는 4 줄을 찾는다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
확인 방식 : 루트 예외의 자바독과 생성자 인용, 하위 타입 계수와 전수 나열, 이 계층에서 원인을 받거나 설정하는 줄 계수와 전수와 옆 계층 대조, 그 하위 타입 전문 인용, 2 인자 생성자 호출처 전수와 그 자리 인용, addSuppressed 를 부르는 줄 전수와 그 세 자리 인용, getCause() 단언 줄 계수와 저장소 전체 대조, 규칙 검사 시험 인용, 두 타입의 시험 등장 계수
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. 루트 예외의 자바독 두 규칙과 유일한 생성자를 인용한다.
|
|
2. MongoPersistenceException 을 상속하는 main 타입을 세고 전부 나열한다.
|
|
3. 이 계층에서 Throwable cause) 와 initCause 가 나오는 줄을 세고, 훑은 파일 수와 옆 계층 계수를 함께 낸다.
|
|
4. 규칙을 벗어난 타입을 전문으로 싣는다.
|
|
5. 그 타입의 생성자를 부르는 자리를 전부 찾고 DefaultReactiveMongoExecutor:152 의 본문을 인용한다.
|
|
6. 이 계층에서 addSuppressed 를 부르는 줄을 전부 찾고, 무엇이 무엇에 붙는지 정리 경로와 번역 경로를 함께 인용한다.
|
|
7. 이 계층에서 getCause() 를 단언하는 줄을 세고 저장소 전체와 대조한다.
|
|
8. 그 규칙을 검사하는 시험을 인용하고, 두 타입이 시험에 몇 줄 나오는지 센다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
`MongoPersistenceException` 은 이 계층의 오류 계층 루트다. 클래스 자바독이 규칙 둘을 적는다.
|
|
|
|
## 루트가 세우는 두 규칙
|
|
|
|
:::evidence key="analysis-finding-a06-f005" alt="저장소 루트에서 돌린 정적 검색 출력 238줄. 먼저 MongoPersistenceException 6~31번 줄이 실린다. 6~15번 자바독이 이 클래스를 빚는 규칙 둘을 적는데, 첫째는 메시지가 MongoFailureContext 에서 파생되고 그 컨텍스트가 경계 있는 데이터 없는 값만 담을 수 있어서 어떤 호출자도 문서나 질의 파라미터를 예외 메시지에 실수로 넣을 수 없다는 것이고, 둘째는 어떤 생성자도 Throwable 원인을 받지 않는다는 것이며 드라이버 예외를 붙이면 실패 컨텍스트가 일부러 버린 모든 것이 getCause 와 모든 스택 트레이스 출력기를 통해 다시 노출된다고 적고, 드라이버의 정보는 컨텍스트가 이미 들고 있는 오류 라벨과 서버 코드로 남는다고 적는다. 22~26번의 유일한 생성자는 요약 문자열과 실패 컨텍스트만 받는다. 이어서 그 클래스를 상속하는 main 타입이 22 개라고 나오고 이름이 전부 나열되는데 MongoBulkPartialFailureException 부터 MongoWriteConflictException 까지다. 다음으로 이 계층에서 Throwable 을 인자로 받는 생성자 줄이 1 개, initCause 를 부르는 줄이 1 개라고 나오고 그 둘이 MongoTimeoutException 25번과 27번으로 나열되며, 그 검색이 훑은 파일이 351 개이고 옆 계층 persistence-jpa 에 같은 검색을 걸면 19 줄이 나온다고 적힌다. 이어서 MongoTimeoutException 5~29번 전문이 실린다. 5~11번 자바독은 타임아웃이 클라이언트가 기다리기를 멈춘 시점을 말할 뿐 서버가 무엇을 했는지는 말하지 않으며, 보내진 뒤 시간이 다한 쓰기는 WRITE_RESULT_UNKNOWN 이라 재조정해야 하고 명령이 떠나기 전에 터진 타임아웃만 재실행이 안전하다고 적는다. 16~18번이 1 인자 생성자이고, 20~24번 자바독이 클라이언트 쪽 타임아웃에서 이 예외를 만든다며 원인을 리액티브 스택 트레이스가 출처를 계속 이름 짓도록 남긴다고 적고, 25~28번이 2 인자 생성자로 27번에서 initCause 를 부른다. 다음으로 그 생성자를 부르는 자리가 나열되는데 선언 둘 말고 DefaultMongoFailureTranslator 104번의 1 인자 호출과 DefaultReactiveMongoExecutor 152번의 완전한 이름으로 쓴 2 인자 호출 둘이다. 그 아래 DefaultReactiveMongoExecutor 138~162번이 실린다. 140~145번이 이미 번역된 예외면 그대로 돌려주고, 146번이 java.util.concurrent.TimeoutException 을 잡으며, 147~150번 주석이 이것은 드라이버가 아니라 Reactor 자신의 타임아웃이고 예전에는 날것으로 새어 나가 이 플랫폼이 호출자에게 알려 준 적 없는 타입이 연산도 결과도 관측도 없이 도달했으며 관측기에 닿기 전에 반환해 실패가 기록되지도 않았다고 적는다. 151~155번이 MongoFailureContext.timedOut 으로 컨텍스트를 만들어 reactorTimeout 을 원인으로 넘기고, 156~159번이 관측기에 실패를 알린 뒤 돌려주고, 161번부터가 드라이버 예외를 다루는 블록이라 162~172번이 그것을 translator 로 보낸다. 이어서 이 계층에서 addSuppressed 를 부르는 줄이 3 개라고 나오고 그 셋이 SpringMongoTransactionSessionFactory 88번과 194번과 200번으로 나열된다. 그 아래 같은 파일 159~176번이 실려 161번의 session.abortTransaction 과 166~176번의 close 가 abort 와 session.close 에서 나온 RuntimeException 을 각각 잡아 recordCleanupFailure 로 넘기는 것이 보인다. 185~199번에서는 186~190번 자바독이 정리 실패를 결과로 만들지 않으면서 호출자가 보는 스택에 실제로 잘못된 것이 남고 실패한 abort 가 그것을 대신하는 대신 옆에 붙게 하려는 것이라고 적고, 192~195번이 inFlight 가 있으면 거기에 붙이고 돌아간다. 212~256번에서는 212~216번의 translate 와 218~222번의 translateCommit 이 classify 결과를 inFlight 에 넣고, 224~256번의 classify 가 248~251번에서 MongoTransactionCommitUnknownException 을, 253~255번에서 MongoTransactionTransientException 을 돌려준다. 마지막으로 이 계층의 시험에서 getCause 를 단언하는 줄이 1 개이고 그것이 MongoFailureContextTest 65번이며 test 소스 세트에서 9 줄, 시험 소스 세트를 모두 훑어도 9 줄이고, 이 계층의 하위 타입 가운데 final 로 닫힌 것이 22 개다. 그 시험 56~67번이 실리는데 58번 이름이 exceptionsDoNotExposeADriverCause 이고 60번이 대상으로 고른 것이 MongoTransactionCommitUnknownException 이며 65번이 원인이 null 인지, 66번이 범주가 TRANSACTION_COMMIT_UNKNOWN 인지 단언한다. 그 아래 MongoTimeoutException 이 시험 소스에 나오는 줄이 0 개이고 대조로 센 MongoTransactionCommitUnknownException 은 4 줄이다." caption="루트 예외의 두 규칙과 유일한 생성자 · 상속 타입 스물둘 · 이 계층에서 원인을 받는 줄 하나와 옆 계층 대조 · 규칙을 벗어난 타입 전문 · 그 생성자를 부르는 자리와 Reactor 타임아웃을 감싸는 이유 · addSuppressed 세 자리와 번역 예외에 드라이버 예외가 붙는 경로 · getCause 단언 하나가 고른 대상과 두 타입의 시험 등장 계수 — 238줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
첫째는 메시지 쪽이다. 메시지가 `MongoFailureContext` 에서 파생되고 그 컨텍스트는 경계 있는 데이터 없는 값만 담을 수 있으므로, 어떤 호출자도 문서나 질의 파라미터를 예외 메시지에 실수로 넣을 수 없다.
|
|
|
|
둘째가 `:11`\~`:13` 이다. 생성자가 `Throwable` 을 받는 일이 없다고 못 박는다. 드라이버 예외를 붙이면 실패 컨텍스트가 일부러 버린 모든 것이 `getCause()` 와 모든 스택 트레이스 출력기를 통해 다시 노출되기 때문이다.
|
|
|
|
`:13`\~`:14` 는 그 대신 무엇이 남는지도 적는다. 드라이버의 정보는 컨텍스트가 이미 들고 있는 오류 라벨과 서버 코드로 남는다.
|
|
|
|
`:22`\~`:26` 의 유일한 생성자가 요약 문자열과 실패 컨텍스트만 받는다.
|
|
|
|
## 스물둘 중 하나
|
|
|
|
그 클래스를 상속하는 main 타입은 스물둘이고 전부 `final` 이다. 사이에 낀 추상 계층이 없으므로 닫힌 집합이다.
|
|
|
|
이 계층에서 `Throwable cause)` 가 나오는 생성자 줄이 하나, `initCause` 를 부르는 줄이 하나다. 둘 다 `MongoTimeoutException` 의 `:25` 와 `:27` 이다.
|
|
|
|
1 은 pathspec 이 아무것도 훑지 못해 나온 값이 아니다. 같은 pathspec 이 훑은 파일이 351 개이고, `persistence-jpa` 에 같은 검색을 걸면 19 줄이 나온다.
|
|
|
|
## MongoTimeoutException\:20\~\:24 자바독이 적은 이유
|
|
|
|
`MongoTimeoutException:20`\~`:24` 가 2 인자 생성자의 자바독이다. 클라이언트 쪽 타임아웃에서 이 예외를 만들고, 원인을 리액티브 스택 트레이스가 출처를 계속 이름 짓도록 남긴다고 적는다.
|
|
|
|
`:16`\~`:18` 의 1 인자 생성자는 원인을 받지 않는다. `DefaultMongoFailureTranslator:104` 는 이 쪽을 부른다.
|
|
|
|
## 붙는 값은 드라이버 예외가 아니다
|
|
|
|
그 2 인자 생성자를 부르는 main 자리는 하나뿐이다. `DefaultReactiveMongoExecutor:152` 다.
|
|
|
|
`:146` 이 `java.util.concurrent.TimeoutException` 을 잡고 `:155` 가 그것을 원인으로 넘긴다. `:161` 부터가 드라이버 예외를 다루는 블록이라, 이 `catch` 에는 드라이버 예외가 들어오지 않는다.
|
|
|
|
`:147`\~`:150` 주석이 그렇게 감싸는 이유를 적는다. 이것은 드라이버가 아니라 Reactor 자신의 타임아웃이고, 예전에는 날것으로 새어 나가 이 플랫폼이 호출자에게 알려 준 적 없는 타입이 연산도 결과도 관측도 없이 도달했다. 관측기에 닿기 전에 반환했으므로 실패가 기록되지도 않았다.
|
|
|
|
`:156`\~`:158` 이 지금은 관측기에 알린다.
|
|
|
|
## 루트 자바독에는 이 예외가 적혀 있지 않다
|
|
|
|
루트 자바독만 읽으면 이 계층의 예외는 원인 사슬까지 로깅해도 안전하다고 읽을 수 있다.
|
|
|
|
그 판단의 근거가 스물둘 중 하나에 대해 거짓이다. 하위 타입 자바독에는 이유가 적혀 있지만 루트 규칙에는 그 예외가 없다.
|
|
|
|
## 생성자를 지나지 않는 두 번째 경로
|
|
|
|
이 계층에서 `addSuppressed` 를 부르는 줄은 셋인데 전부 `SpringMongoTransactionSessionFactory` 에 있다.
|
|
|
|
`:194` 의 `inFlight.addSuppressed(failure)` 가 그중 하나다. `inFlight` 는 `:214` 와 `:220` 에서 `classify(...)` 가 돌려준 값이 되는데, `:249` 의 `MongoTransactionCommitUnknownException` 이거나 `:254` 의 `MongoTransactionTransientException` 이다. 둘 다 `MongoPersistenceException` 하위 타입이다.
|
|
|
|
붙는 쪽은 드라이버 예외다. `:168` 이 `abort()` 를 부르고 그 안 `:161` 이 `session.abortTransaction()` 을 부르는데, 거기서 나온 `RuntimeException` 을 `:169` 가 잡아 `recordCleanupFailure` 로 넘긴다. `:173` 의 `session.close()` 도 같은 경로다.
|
|
|
|
`:186`\~`:190` 자바독이 의도를 적는다. 정리 실패를 결과로 만들지 않으면서, 호출자가 보는 스택에 실제로 잘못된 것이 남고 실패한 abort 가 그것을 대신하는 대신 옆에 붙게 하려는 것이다.
|
|
|
|
생성자 규칙은 여기서 깨지지 않는다. 어떤 생성자도 `Throwable` 을 받지 않았다. 다만 루트 자바독이 든 두 통로 가운데 `getCause()` 가 아니라 모든 스택 트레이스 출력기 쪽이 이 경로에서 열린다.
|
|
|
|
`:88` 의 `startFailed.addSuppressed(closeFailed)` 는 다르다. 번역 이전의 두 예외끼리라 이 계층의 타입이 관여하지 않는다.
|
|
|
|
## 그 규칙을 검사하는 시험이 고른 대상
|
|
|
|
이 계층의 시험에서 `getCause()` 를 단언하는 줄은 `MongoFailureContextTest:65` 하나다. 시험 소스 세트를 모두 훑어도 저장소 전체에 9 줄이다.
|
|
|
|
그 시험 `:58` 의 이름이 `exceptionsDoNotExposeADriverCause` 인데, `:60` 이 고른 대상은 `MongoTransactionCommitUnknownException` 이다. 원인을 받는 생성자가 없는 타입이라 `:65` 의 `isNull()` 은 그 타입에 대해 항상 참이다.
|
|
|
|
그 타입이 `:249` 에서 `inFlight` 가 되는 둘 중 하나다. `getCause()` 로는 드라이버 예외를 드러내지 않는데 `getSuppressed()` 로는 드러낼 수 있고, 시험은 앞의 것만 본다.
|
|
|
|
규칙을 유일하게 벗어나는 타입은 시험 소스에 한 줄도 나오지 않는다. 같은 검색으로 `MongoTransactionCommitUnknownException` 은 4 줄이 나온다.
|
|
|
|
## 원문에 없는 것
|
|
|
|
원문은 하위 타입 스무 개 중 하나가 규칙을 벗어난다고 적는다. 이 리비전에서 `MongoPersistenceException` 을 상속하는 main 타입은 스물둘이고, 생성자로 원인을 받는 것이 하나라는 판정은 그대로다.
|
|
|
|
여기에 더한 것은 둘이다. 하나는 그 1 이 검색 실패가 아니라는 확인이고, 다른 하나는 생성자를 세는 것으로는 잡히지 않는 경로다. `SpringMongoTransactionSessionFactory:194` 가 드라이버 예외를 번역된 예외에 `addSuppressed` 로 붙이므로, 루트 규칙을 읽고 스택 트레이스를 통째로 남겨도 안전하다고 판단하면 그 판단이 두 자리에서 틀린다.
|
|
|
|
그리고 `MongoTimeoutException` 이 시험 소스에 한 줄도 나오지 않고, `getSuppressed()` 를 보는 시험도 이 계층에 없다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
붙은 원인이 스택 트레이스에 무엇을 남기는지 찍어 보지 않았다. 코드에서 확인한 것은 `:25` 가 원인을 받고 `:27` 이 `initCause` 를 부른다는 것까지다.
|
|
|
|
이 계층의 예외를 원인 사슬까지 로깅하는 호출자가 실제로 있는지 저장소 밖을 보지 않았다.
|
|
|
|
`Throwable cause)` 검색은 그 문자열 모양에 기댄다. 억눌린 예외가 붙는 장면을 세션을 띄워 관측하지 않았다. 정리 실패와 번역이 함께 일어나는 조합을 만들지 않았다.
|
|
|
|
`addSuppressed` 경로가 실제로 도는 것을 실행으로 재현하지 않았다. `abortTransaction()` 이 던지고 그보다 먼저 번역이 일어난 조합을 만들어 보지 않았다.
|
|
|
|
<!-- body:end -->
|