- 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>
143 lines
11 KiB
Markdown
143 lines
11 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: analysis-finding-a04-f004
|
|
title: 실패 로거가 던지면 그 예외가 제공자 예외를 밀어내고 호출자로 나간다
|
|
topic: multitenancy-isolation
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:analysis-finding-a04-f004
|
|
evidenceCapturedOn: 2026-09-04
|
|
body: case-analysis-finding-a04-f004.body.md
|
|
assets:
|
|
- key: analysis-finding-a04-f004
|
|
file: ../../../final/evidence/rendered/analysis-finding-a04-f004.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/analysis-finding-a04-f004.txt
|
|
source:
|
|
- 원본 분석 절은 final/document.md#a04 §5 이다.
|
|
---
|
|
|
|
# 실패 로거가 던지면 그 예외가 제공자 예외를 밀어내고 호출자로 나간다
|
|
|
|
`FailOpenNotificationProvider:40` 의 `catch` 는 `:42` 에서 `logFailure` 를 부르는데 그 호출을 감싸는 `try` 가 없다. 위임 제공자가 `provider is down` 으로 던진 뒤 그 로거까지 던지면, 프로브에서 호출자가 받은 것은 `IllegalStateException : the appender is out of disk` 였고 그 예외의 `cause` 가 없음, `suppressed` 가 0 이었다.
|
|
|
|
## 관계
|
|
|
|
- **메시징만 성공 로그를 try 밖으로 옮기고 알림은 같은 try 에 남겨 두었다**
|
|
같은 `FailOpenNotificationProvider` 의 성공 경로를 다룬 기록이다. 그 기록은 `delegate.send` 가 성공한 뒤 `logSuccess` 가 던지는 경우이고, 여기는 `delegate.send` 가 실패한 뒤 `catch` 안의 `logFailure` 가 던지는 경우다.
|
|
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
|
|
그 규칙은 확정되지 않은 결과를 성공이나 실패로 접지 말라고 한다. 여기서는 확정된 제공자 실패가 로거 실패에 덮여, 호출자가 받는 예외가 제공자 결과를 가리키지 않는다.
|
|
|
|
## 문제
|
|
|
|
이 데코레이터의 클래스 자바독은 알림이 어떤 경우에도 업무 흐름을 깨뜨리지 않게 하는 것이 자기 일이라고 적는다.
|
|
|
|
그 계약이 관측 자체가 실패하는 경우에도 성립하는지 확인했다.
|
|
|
|
## 결론
|
|
|
|
send 는 :37 의 try 안에서 위임 전송과 성공 로그를 함께 부른다. 실패를 받는 :40 의 처리기가 :42 에서 로그를 남기는데, 그 자리에서 예외가 나면 잡아 줄 것이 남아 있지 않다.
|
|
|
|
메시징 쪽은 같은 자리를 다르게 쓴다. OutboundMessagePublisher:39 의 실패 관측도 observeQuietly 를 거치고 :57 이 RuntimeException 을 삼킨다. :28~:31 주석은 그 구조가 무엇을 고친 것인지 적어 둔다.
|
|
|
|
프로브에서 위임 제공자가 항상 던지게 하고 로거 프록시를 물렸다. 로거가 멀쩡한 경우는 계약대로다.
|
|
|
|
그 로거까지 던지게 하면 예외가 send 밖으로 나간다. 나간 것에는 원인도 억제된 예외도 붙어 있지 않아서, 제공자가 왜 실패했는지가 호출자 쪽에서 복구되지 않는다.
|
|
|
|
이 데코레이터를 부르는 RoutingNotifier 의 루프는 개별 실패를 잡지 않고 지나간다. 그렇게 써도 되는 근거를 :117~:119 가 밝혀 두었는데, 프로브가 그 근거를 무너뜨리는 경우를 하나 보였다.
|
|
|
|
이 경우를 붙드는 시험도 없다. 던지는 로거를 만드는 기법이 메시징 모듈에는 한 번 쓰였고 알림 모듈에는 한 번도 쓰이지 않았다. 조립 시험 둘이 던지도록 손본 것은 로거가 아니라 그 아래 제공자다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
Gradle : 9.0.0
|
|
확인 방식 : 데코레이터의 클래스 자바독과 send 본문 인용, 메시징 쪽 publish 전체와 관측 헬퍼 인용, 팬아웃 루프와 그 서명·주석 인용, NotificationPort 를 참조하는 main 파일 전수, 저장소 클래스를 그대로 써서 위임 제공자가 던지는 경로에 로거 예외를 겹친 두 경우 관측하고 나간 예외의 cause 와 suppressed 까지 출력, 두 모듈의 로거 프록시 계수와 조립 시험이 던지게 만드는 대상 나열
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. 데코레이터의 클래스 자바독과 send 본문을 인용한다.
|
|
2. 메시징 쪽 publish 를 주석과 플래그까지 포함해 싣고 관측 헬퍼를 함께 인용한다.
|
|
3. 이 데코레이터를 부르는 팬아웃 루프를 메서드 서명부터 인용한다.
|
|
4. NotificationPort 를 참조하는 main 파일을 전부 찾는다.
|
|
5. 위임 제공자가 항상 던지도록 만들고 slf4j Logger 프록시를 붙인다.
|
|
6. warn 이 정상인 경우와 던지는 경우로 나눠 호출 수와 나간 예외를 적고, 그 예외의 cause 와 suppressed 도 함께 출력한다.
|
|
7. 두 모듈에서 로거 프록시를 만드는 자리를 세고, 조립 시험이 무엇을 던지게 만드는지 나열한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
`FailOpenNotificationProvider` 는 제공자 실패를 삼켜서 알림이 코어 유스케이스를 실패시키지 않게 하는 데코레이터다. 그 계약이 클래스 자바독 `:8`\~`:10` 에 적혀 있다.
|
|
|
|
## catch 안의 logFailure 호출에는 try 가 없다
|
|
|
|
:::evidence key="analysis-finding-a04-f004" alt="저장소 루트에서 돌린 정적 검색과 프로브의 출력 89줄. 먼저 FailOpenNotificationProvider 7~11번 줄의 클래스 자바독이 실려 제공자 실패를 기록하고 삼켜서 알림이 코어 유스케이스를 실패시키지 않는다고 적는다. 35~45번 줄은 send 본문인데 37번 try 안에 38번 delegate.send 와 39번 logSuccess 가 있고 40번 catch 가 42번에서 logFailure 를 부른다. 이어서 메시징 쪽 OutboundMessagePublisher 26~46번 줄이 실리는데 28~31번 주석이 전송과 성공 로그가 한 try 를 공유하던 시절의 버그를 적고 32번이 sent 플래그를 두며 33~35번 try 가 브로커 전송만 감싸고 39번과 43번이 관측을 부른다. 48~60번 줄의 observeQuietly 가 RuntimeException 을 삼킨다. 그 아래에 RoutingNotifier 112~123번 줄이 실려 notify 서명과 117~119번 주석과 예외 처리기 없는 팬아웃 루프가 보인다. NotificationPort 를 참조하는 main 파일은 NotificationConfig 와 RoutingNotifier 둘이다. 마지막으로 프로브가 실린다. 위임 제공자가 항상 던지도록 만들고 로거 프록시를 물렸는데, 실패 로거가 정상일 때는 debug 0 warn 1 이고 호출자에게 나간 예외가 없다. 실패 로거도 던질 때는 debug 0 warn 1 이고 IllegalStateException 이 the appender is out of disk 라는 메시지로 나가며, 그 예외의 cause 가 없음이고 suppressed 가 0 이다. 그 경로를 고정하는 시험으로는 알림 모듈의 Proxy.newProxyInstance 가 0 개 메시징 모듈이 1 개이고, 이 데코레이터를 조립하는 두 시험이 던지게 만드는 것은 각각 79번과 130번의 위임 제공자뿐이다." caption="데코레이터가 자기에게 매긴 계약과 send 본문 · 메시징 쪽 publish 전체와 observeQuietly · notify 서명과 예외 처리기 없는 팬아웃 루프 · 위임 실패에 로거 예외를 겹친 두 경우와 그 예외의 원인 사슬 · 그 경로를 고정하는 시험의 부재 — 89줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
`:37` 의 `try` 안에 `:38` 의 `delegate.send` 와 `:39` 의 `logSuccess` 가 있다. `:40` 의 `catch (Exception ex)` 가 `:42` 에서 `logFailure` 를 부른다.
|
|
|
|
`catch` 블록 안에서 던진 예외는 그 `catch` 가 다시 받지 않는다. 메서드 밖으로 나간다.
|
|
|
|
## OutboundMessagePublisher 는 실패 관측도 observeQuietly 로 감싼다
|
|
|
|
`:28`\~`:31` 주석이 그 구조의 내력을 적는다. 전송과 성공 로그가 한 `try` 를 공유하던 시절, 브로커가 이미 메시지를 받은 뒤 로거가 던지면 같은 `catch` 가 그것을 발행 실패로 보고했다.
|
|
|
|
지금은 `:32` 가 `boolean sent` 를 두고 `:33`\~`:35` 의 `try` 가 `broker.send` 만 감싼다. 실패 관측은 `catch` 안 `:39` 에서, 성공 관측은 `try` 밖 `:43` 에서 일어나고 둘 다 `observeQuietly` 를 거친다.
|
|
|
|
`:54`\~`:60` 의 그 헬퍼가 `:57` 에서 `RuntimeException` 을 삼킨다. 자바독은 진단이 권위를 갖지 않는다고 적는다.
|
|
|
|
## 위임 실패에 로거 예외를 겹쳤을 때
|
|
|
|
프로브는 위임 제공자가 항상 `IllegalStateException("provider is down")` 을 던지도록 만든다. 로거는 slf4j `Logger` 프록시다.
|
|
|
|
실패 로거가 정상이면 `debug` 0 회 · `warn` 1 회이고 호출자에게 나가는 예외가 없다. 계약대로 삼킨다.
|
|
|
|
실패 로거도 던지게 하면 호출 수는 같은데 `IllegalStateException : the appender is out of disk` 가 `send` 밖으로 나간다.
|
|
|
|
그 예외의 `cause` 는 없음이고 `suppressed` 는 0 이다. 제공자가 던진 `provider is down` 은 `ex` 변수에 담겨 `logFailure` 의 넷째 인자로만 넘어갔고, 그 호출이 던졌으므로 호출자가 받는 예외에 아무 흔적도 남기지 않았다.
|
|
|
|
## RoutingNotifier 의 팬아웃 루프에는 예외 처리기가 없다
|
|
|
|
`:113` 의 `notify` 가 `:114` 에서 라우트를 풀고 `:120`\~`:122` 가 제공자들을 차례로 부른다.
|
|
|
|
`:117`\~`:119` 주석이 그 이유를 적는다. 개별 제공자 실패는 데코레이터 안에서 관측되고 전파되지 않으므로 하나가 실패해도 남은 팬아웃이 막히지 않는다는 것이다.
|
|
|
|
프로브에서는 그 데코레이터가 예외를 전파했다. 주석이 든 전제가 그 경우에는 성립하지 않는다.
|
|
|
|
## 오늘 그 예외를 받을 호출자가 없다
|
|
|
|
`NotificationPort` 를 참조하는 main 파일은 `NotificationConfig` 와 `RoutingNotifier` 둘이다. 앞엣것은 빈을 조립하고 뒤엣것은 그 포트를 구현한다.
|
|
|
|
그 포트를 부르는 프로덕션 코드가 없다. 지금 이 경로를 지나는 요청이 없다는 뜻이고, 이 템플릿을 가져다 호출자를 붙이는 순간 살아난다.
|
|
|
|
## 로거가 던지는 경우를 만드는 시험이 없다
|
|
|
|
로거를 던지게 만드는 `Proxy.newProxyInstance` 는 알림 모듈에 0 개이고 메시징 모듈에 1 개다.
|
|
|
|
`NotificationAdapterTest` 와 `RoutingNotifierTest` 가 던지게 만드는 것은 각각 `:79` 와 `:130` 의 위임 제공자다. 로거는 정상이다.
|
|
|
|
## 원문과 갈리는 자리
|
|
|
|
원문은 실패 경로에서 경고 로거가 실패하면 그 예외가 호출자까지 전파된다고 적었고 프로브가 그대로 재현한다.
|
|
|
|
원문에는 무엇이 전파되는지가 빠져 있다. 나간 예외는 로거의 것이고, `cause` 와 `suppressed` 가 비어 있어 제공자 예외와 연결되지 않는다.
|
|
|
|
원문이 확인하지 않은 것도 있다. 이 경로를 지나는 프로덕션 호출자가 지금은 없다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
실제 배포에서 WARN 호출이 던질 조건을 조사하지 않았다.
|
|
|
|
진짜 로그 설비가 던지기 직전에 무엇을 남기는지는 보지 않았다. 프록시는 아무 기록 없이 곧바로 던진다.
|
|
|
|
수정 위치를 공유 로거 쪽으로 잡을지 이 데코레이터 쪽으로 잡을지 결론 내지 않았다.
|
|
|
|
## 등급에 대해
|
|
|
|
원본은 P1 이다. 다만 이 리비전에서 그 포트를 참조하는 main 파일은 빈을 조립하는 쪽과 구현하는 쪽 둘뿐이고 실제로 알림을 보내는 코드가 없다. 오늘 이 예외를 받을 자리가 없으므로 등급을 그대로 두되 그 조건을 적어 둔다.
|
|
|
|
<!-- body:end -->
|