Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/multitenancy-isolation/case/case-analysis-finding-a06-f005.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- 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>
2026-09-07 12:39:20 +09:00

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
key file
analysis-finding-a06-f005 ../../../final/evidence/rendered/analysis-finding-a06-f005.svg
../../../final/evidence/raw/analysis-finding-a06-f005.txt
원본 분석 절은 final/document.md#a06 §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. 그 규칙을 검사하는 시험을 인용하고, 두 타입이 시험에 몇 줄 나오는지 센다.

본문

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 다.

:146java.util.concurrent.TimeoutException 을 잡고 :155 가 그것을 원인으로 넘긴다. :161 부터가 드라이버 예외를 다루는 블록이라, 이 catch 에는 드라이버 예외가 들어오지 않는다.

:147~:150 주석이 그렇게 감싸는 이유를 적는다. 이것은 드라이버가 아니라 Reactor 자신의 타임아웃이고, 예전에는 날것으로 새어 나가 이 플랫폼이 호출자에게 알려 준 적 없는 타입이 연산도 결과도 관측도 없이 도달했다. 관측기에 닿기 전에 반환했으므로 실패가 기록되지도 않았다.

:156~:158 이 지금은 관측기에 알린다.

루트 자바독에는 이 예외가 적혀 있지 않다

루트 자바독만 읽으면 이 계층의 예외는 원인 사슬까지 로깅해도 안전하다고 읽을 수 있다.

그 판단의 근거가 스물둘 중 하나에 대해 거짓이다. 하위 타입 자바독에는 이유가 적혀 있지만 루트 규칙에는 그 예외가 없다.

생성자를 지나지 않는 두 번째 경로

이 계층에서 addSuppressed 를 부르는 줄은 셋인데 전부 SpringMongoTransactionSessionFactory 에 있다.

:194inFlight.addSuppressed(failure) 가 그중 하나다. inFlight:214:220 에서 classify(...) 가 돌려준 값이 되는데, :249MongoTransactionCommitUnknownException 이거나 :254MongoTransactionTransientException 이다. 둘 다 MongoPersistenceException 하위 타입이다.

붙는 쪽은 드라이버 예외다. :168abort() 를 부르고 그 안 :161session.abortTransaction() 을 부르는데, 거기서 나온 RuntimeException:169 가 잡아 recordCleanupFailure 로 넘긴다. :173session.close() 도 같은 경로다.

:186~:190 자바독이 의도를 적는다. 정리 실패를 결과로 만들지 않으면서, 호출자가 보는 스택에 실제로 잘못된 것이 남고 실패한 abort 가 그것을 대신하는 대신 옆에 붙게 하려는 것이다.

생성자 규칙은 여기서 깨지지 않는다. 어떤 생성자도 Throwable 을 받지 않았다. 다만 루트 자바독이 든 두 통로 가운데 getCause() 가 아니라 모든 스택 트레이스 출력기 쪽이 이 경로에서 열린다.

:88startFailed.addSuppressed(closeFailed) 는 다르다. 번역 이전의 두 예외끼리라 이 계층의 타입이 관여하지 않는다.

그 규칙을 검사하는 시험이 고른 대상

이 계층의 시험에서 getCause() 를 단언하는 줄은 MongoFailureContextTest:65 하나다. 시험 소스 세트를 모두 훑어도 저장소 전체에 9 줄이다.

그 시험 :58 의 이름이 exceptionsDoNotExposeADriverCause 인데, :60 이 고른 대상은 MongoTransactionCommitUnknownException 이다. 원인을 받는 생성자가 없는 타입이라 :65isNull() 은 그 타입에 대해 항상 참이다.

그 타입이 :249 에서 inFlight 가 되는 둘 중 하나다. getCause() 로는 드라이버 예외를 드러내지 않는데 getSuppressed() 로는 드러낼 수 있고, 시험은 앞의 것만 본다.

규칙을 유일하게 벗어나는 타입은 시험 소스에 한 줄도 나오지 않는다. 같은 검색으로 MongoTransactionCommitUnknownException 은 4 줄이 나온다.

원문에 없는 것

원문은 하위 타입 스무 개 중 하나가 규칙을 벗어난다고 적는다. 이 리비전에서 MongoPersistenceException 을 상속하는 main 타입은 스물둘이고, 생성자로 원인을 받는 것이 하나라는 판정은 그대로다.

여기에 더한 것은 둘이다. 하나는 그 1 이 검색 실패가 아니라는 확인이고, 다른 하나는 생성자를 세는 것으로는 잡히지 않는 경로다. SpringMongoTransactionSessionFactory:194 가 드라이버 예외를 번역된 예외에 addSuppressed 로 붙이므로, 루트 규칙을 읽고 스택 트레이스를 통째로 남겨도 안전하다고 판단하면 그 판단이 두 자리에서 틀린다.

그리고 MongoTimeoutException 이 시험 소스에 한 줄도 나오지 않고, getSuppressed() 를 보는 시험도 이 계층에 없다.

확인하지 못한 것

붙은 원인이 스택 트레이스에 무엇을 남기는지 찍어 보지 않았다. 코드에서 확인한 것은 :25 가 원인을 받고 :27initCause 를 부른다는 것까지다.

이 계층의 예외를 원인 사슬까지 로깅하는 호출자가 실제로 있는지 저장소 밖을 보지 않았다.

Throwable cause) 검색은 그 문자열 모양에 기댄다. 억눌린 예외가 붙는 장면을 세션을 띄워 관측하지 않았다. 정리 실패와 번역이 함께 일어나는 조합을 만들지 않았다.

addSuppressed 경로가 실제로 도는 것을 실행으로 재현하지 않았다. abortTransaction() 이 던지고 그보다 먼저 번역이 일어난 조합을 만들어 보지 않았다.