- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다 - 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5 (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를 techviz 로 만들었다 - 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs 돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다. Concept 이 인용한 코드가 SSOT 에 없어 뺐다 - candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
18 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-f005 | cause 를 받지 않는다는 루트 규칙이 스물둘 중 하나에 대해 거짓이다 | multitenancy-isolation | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:analysis-finding-a06-f005 | 2026-09-04 | case-analysis-finding-a06-f005.body.md |
|
|
|
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
재현 조건
- 루트 예외의 자바독 두 규칙과 유일한 생성자를 인용한다.
- MongoPersistenceException 을 상속하는 main 타입을 세고 전부 나열한다.
- 이 계층에서 Throwable cause) 와 initCause 가 나오는 줄을 세고, 훑은 파일 수와 옆 계층 계수를 함께 낸다.
- 규칙을 벗어난 타입을 전문으로 싣는다.
- 그 타입의 생성자를 부르는 자리를 전부 찾고 DefaultReactiveMongoExecutor:152 의 본문을 인용한다.
- 이 계층에서 addSuppressed 를 부르는 줄을 전부 찾고, 무엇이 무엇에 붙는지 정리 경로와 번역 경로를 함께 인용한다.
- 이 계층에서 getCause() 를 단언하는 줄을 세고 저장소 전체와 대조한다.
- 그 규칙을 검사하는 시험을 인용하고, 두 타입이 시험에 몇 줄 나오는지 센다.
본문
MongoPersistenceException 은 이 계층의 오류 계층 루트다. 클래스 자바독이 규칙 둘을 적는다.
루트가 세우는 두 규칙
:::evidence key="analysis-finding-a06-f005" alt="저장소 루트에서 돌린 정적 검색 출력 238줄. 먼저 MongoPersistenceException 631번 줄이 실린다. 615번 자바독이 이 클래스를 빚는 규칙 둘을 적는데, 첫째는 메시지가 MongoFailureContext 에서 파생되고 그 컨텍스트가 경계 있는 데이터 없는 값만 담을 수 있어서 어떤 호출자도 문서나 질의 파라미터를 예외 메시지에 실수로 넣을 수 없다는 것이고, 둘째는 어떤 생성자도 Throwable 원인을 받지 않는다는 것이며 드라이버 예외를 붙이면 실패 컨텍스트가 일부러 버린 모든 것이 getCause 와 모든 스택 트레이스 출력기를 통해 다시 노출된다고 적고, 드라이버의 정보는 컨텍스트가 이미 들고 있는 오류 라벨과 서버 코드로 남는다고 적는다. 2226번의 유일한 생성자는 요약 문자열과 실패 컨텍스트만 받는다. 이어서 그 클래스를 상속하는 main 타입이 22 개라고 나오고 이름이 전부 나열되는데 MongoBulkPartialFailureException 부터 MongoWriteConflictException 까지다. 다음으로 이 계층에서 Throwable 을 인자로 받는 생성자 줄이 1 개, initCause 를 부르는 줄이 1 개라고 나오고 그 둘이 MongoTimeoutException 25번과 27번으로 나열되며, 그 검색이 훑은 파일이 351 개이고 옆 계층 persistence-jpa 에 같은 검색을 걸면 19 줄이 나온다고 적힌다. 이어서 MongoTimeoutException 529번 전문이 실린다. 511번 자바독은 타임아웃이 클라이언트가 기다리기를 멈춘 시점을 말할 뿐 서버가 무엇을 했는지는 말하지 않으며, 보내진 뒤 시간이 다한 쓰기는 WRITE_RESULT_UNKNOWN 이라 재조정해야 하고 명령이 떠나기 전에 터진 타임아웃만 재실행이 안전하다고 적는다. 1618번이 1 인자 생성자이고, 2024번 자바독이 클라이언트 쪽 타임아웃에서 이 예외를 만든다며 원인을 리액티브 스택 트레이스가 출처를 계속 이름 짓도록 남긴다고 적고, 2528번이 2 인자 생성자로 27번에서 initCause 를 부른다. 다음으로 그 생성자를 부르는 자리가 나열되는데 선언 둘 말고 DefaultMongoFailureTranslator 104번의 1 인자 호출과 DefaultReactiveMongoExecutor 152번의 완전한 이름으로 쓴 2 인자 호출 둘이다. 그 아래 DefaultReactiveMongoExecutor 138162번이 실린다. 140145번이 이미 번역된 예외면 그대로 돌려주고, 146번이 java.util.concurrent.TimeoutException 을 잡으며, 147150번 주석이 이것은 드라이버가 아니라 Reactor 자신의 타임아웃이고 예전에는 날것으로 새어 나가 이 플랫폼이 호출자에게 알려 준 적 없는 타입이 연산도 결과도 관측도 없이 도달했으며 관측기에 닿기 전에 반환해 실패가 기록되지도 않았다고 적는다. 151155번이 MongoFailureContext.timedOut 으로 컨텍스트를 만들어 reactorTimeout 을 원인으로 넘기고, 156159번이 관측기에 실패를 알린 뒤 돌려주고, 161번부터가 드라이버 예외를 다루는 블록이라 162172번이 그것을 translator 로 보낸다. 이어서 이 계층에서 addSuppressed 를 부르는 줄이 3 개라고 나오고 그 셋이 SpringMongoTransactionSessionFactory 88번과 194번과 200번으로 나열된다. 그 아래 같은 파일 159176번이 실려 161번의 session.abortTransaction 과 166176번의 close 가 abort 와 session.close 에서 나온 RuntimeException 을 각각 잡아 recordCleanupFailure 로 넘기는 것이 보인다. 185199번에서는 186190번 자바독이 정리 실패를 결과로 만들지 않으면서 호출자가 보는 스택에 실제로 잘못된 것이 남고 실패한 abort 가 그것을 대신하는 대신 옆에 붙게 하려는 것이라고 적고, 192195번이 inFlight 가 있으면 거기에 붙이고 돌아간다. 212256번에서는 212216번의 translate 와 218222번의 translateCommit 이 classify 결과를 inFlight 에 넣고, 224256번의 classify 가 248251번에서 MongoTransactionCommitUnknownException 을, 253255번에서 MongoTransactionTransientException 을 돌려준다. 마지막으로 이 계층의 시험에서 getCause 를 단언하는 줄이 1 개이고 그것이 MongoFailureContextTest 65번이며 test 소스 세트에서 9 줄, 시험 소스 세트를 모두 훑어도 9 줄이고, 이 계층의 하위 타입 가운데 final 로 닫힌 것이 22 개다. 그 시험 5667번이 실리는데 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() 이 던지고 그보다 먼저 번역이 일어난 조합을 만들어 보지 않았다.