- 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>
166 lines
15 KiB
Markdown
166 lines
15 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: analysis-finding-a04-f002
|
|
title: 메시징만 성공 로그를 try 밖으로 옮기고 알림은 같은 try 에 남겨 두었다
|
|
topic: multitenancy-isolation
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:analysis-finding-a04-f002
|
|
evidenceCapturedOn: 2026-09-04
|
|
body: case-analysis-finding-a04-f002.body.md
|
|
assets:
|
|
- key: analysis-finding-a04-f002
|
|
file: ../../../final/evidence/rendered/analysis-finding-a04-f002.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/analysis-finding-a04-f002.txt
|
|
source:
|
|
- 원본 분석 절은 final/document.md#a04 §5 이다.
|
|
---
|
|
|
|
# 메시징만 성공 로그를 try 밖으로 옮기고 알림은 같은 try 에 남겨 두었다
|
|
|
|
`821fe00c` 초기 커밋에서 `OutboundMessagePublisher` 와 `FailOpenNotificationProvider` 는 같은 모양이었다. `2f5d2fc2` 가 메시징 쪽만 `boolean sent` 와 `observeQuietly` 로 갈랐고 알림 쪽은 그대로다. 그래서 알림 쪽에서는 위임 전송이 성공한 뒤 성공 로거가 던지면 그 예외를 전송 실패용 `catch` 가 받는다.
|
|
|
|
## 관계
|
|
|
|
- **시그니처가 payload를 받지 않는데 예외 메시지로 PII가 로그에 남았다**
|
|
같은 `FailOpenDependencyLogger` 를 다룬다. 그 사례는 `logFailure` 가 예외 메시지를 그대로 로그에 넣는 것을 프로브로 확인했고, 여기서는 그 `logFailure` 를 두 소비자가 각각 어느 `try` 안에서 부르는지를 확인했다.
|
|
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
|
|
그 규칙은 구현이 둘일 때 어느 것이 조립되는지를 묻는다. 여기서는 두 소비자가 모두 조립되고, 갈리는 것은 같은 로거를 부르는 자리다.
|
|
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
|
|
그 규칙은 확정되지 않은 결과를 성공이나 실패로 접지 말라고 한다. 여기서는 확정된 성공이 실패로 접힌다.
|
|
|
|
## 문제
|
|
|
|
메시징 어댑터와 알림 어댑터가 같은 FailOpenDependencyLogger 를 쓴다. 둘 다 실패를 삼키는 fail-open 구조다.
|
|
|
|
두 소비자가 그 로거를 어느 자리에서 부르는지, 그리고 언제부터 그렇게 갈렸는지 확인했다.
|
|
|
|
## 결론
|
|
|
|
821fe00c 이 출하한 메시징 publish 에는 지금의 알림 쪽과 구별되는 점이 없다. 브로커 호출과 성공 로그가 한 블록에 있고 예외 처리기가 실패 로그를 남긴다.
|
|
|
|
2f5d2fc2 가 메시징 쪽만 바꿨다. :32 가 boolean sent 를 두고 :33~:35 의 try 가 broker.send 만 감싼다. 관측은 :39 와 :43 에서 observeQuietly 를 거치고 :54~:60 이 그 안에서 RuntimeException 을 삼킨다. FailOpenNotificationProvider 는 초기 커밋 이후 수정된 적이 없다.
|
|
|
|
알림 쪽에는 그 플래그가 없다. :38 의 delegate.send 와 :39 의 logSuccess 가 같은 try 안에 있고 :40 의 catch 가 :42 에서 logFailure 를 부른다.
|
|
|
|
저장소 클래스를 그대로 로드한 프로브에 slf4j Logger 프록시를 물렸다. debug 에서만 던지게 하면 알림 쪽은 위임 전송이 성공했는데도 warn 이 한 번 불린다. 메시징 쪽은 같은 조건에서 warn 이 0 회다.
|
|
|
|
debug 와 warn 양쪽에서 던지게 하면 알림 쪽만 IllegalStateException 이 send 밖으로 나간다. :8~:10 이 선언한 fail-open 계약이 그 경우에 성립하지 않는다.
|
|
|
|
RoutingNotifier:120~:122 의 팬아웃 루프에는 예외 처리기가 없다. 데코레이터가 아무것도 전파하지 않는다는 전제 위에 그렇게 쓰였다고 :117~:119 가 밝힌다.
|
|
|
|
이름이 회귀를 가리키는 그 시험은 회귀를 막지 못한다. OutboundMessagePublisherTest:141 과 :142 가 단언하는 것은 예외가 나가지 않는다는 것과 브로커가 메시지를 받았다는 것뿐이다. 프로브에서 초기 커밋 구조를 돌려 보니 둘 다 통과했다.
|
|
|
|
알림 쪽에서 이 클래스를 조립하는 시험은 열둘이다. 그 시험들이 던지게 만든 것은 위임 제공자다. 메시징 회귀 시험이 로거를 던지게 할 때 쓴 Proxy.newProxyInstance 는 알림 모듈에 한 번도 나오지 않는다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
Gradle : 9.0.0
|
|
확인 방식 : 같은 로거가 나오는 자리 전수, 두 파일의 커밋 이력과 초기 커밋 시점의 메시징 본문 인용, 지금의 두 본문과 팬아웃 루프 인용, NotificationPort 를 참조하는 main 파일 계수, 메시징 회귀 시험 111~145 전문 인용, 알림 쪽에서 이 클래스를 조립하는 시험과 그 시험들이 던지게 만든 대상 나열 및 두 모듈의 Proxy.newProxyInstance 계수, 저장소 build 산출물에서 로드했음을 출력에 찍은 프로브로 세 구조에 같은 로거를 물려 일곱 행 관측
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. 그 로거가 나오는 자리를 저장소에서 모두 찾는다.
|
|
2. 두 소비자 파일의 커밋 이력을 나란히 뽑고, 초기 커밋 시점의 메시징 본문을 꺼낸다.
|
|
3. 지금의 두 본문과 알림 데코레이터를 부르는 팬아웃 루프를 인용한다.
|
|
4. 메시징 회귀 시험을 단언부까지 포함해 전문으로 싣는다.
|
|
5. 알림 쪽에서 이 클래스를 조립하는 시험을 모두 찾고, 그 시험들이 무엇을 던지게 만들었는지 나열한다.
|
|
6. 두 모듈에서 Proxy.newProxyInstance 를 센다.
|
|
7. slf4j Logger 프록시를 만들어 debug 에서만, 그리고 양쪽에서 던지게 한다. 로드한 클래스의 출처를 출력에 찍고, 지금의 두 구조와 초기 커밋 구조에 같은 로거를 물린다.
|
|
8. 초기 커밋 구조에서 그 회귀 시험의 두 단언이 성립하는지 프로브 안에서 판정한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
`FailOpenDependencyLogger` 는 선택적 어댑터들이 공유하는 로그 계약이다. 실패는 항상 WARN 이고, 클래스 자바독은 본문·수신자·payload 를 인자로 받지 않으므로 PII 가 로그에 닿을 수 없다고 적는다. 예외 메시지를 통해서는 닿는다는 것을 다른 기록이 프로브로 확인했다.
|
|
|
|
## 같은 로거를 두 어댑터가 쓴다
|
|
|
|
:::evidence key="analysis-finding-a04-f002" alt="저장소 루트에서 돌린 정적 검색과 프로브 실행의 출력 209줄. 먼저 FailOpenDependencyLogger 가 나오는 자리 여덟이 실리는데 메시징 어댑터 main 과 그 시험, 알림 어댑터 main 과 그 시험 둘, 지원 모듈 시험, 그리고 두 설정 클래스다. 이어서 두 파일의 git 이력이 나온다. FailOpenNotificationProvider 는 821fe00c 초기 커밋 한 줄뿐이고, OutboundMessagePublisher 는 2f5d2fc2 와 821fe00c 두 줄이다. 다음으로 821fe00c 시점의 메시징 publish 26~37번 줄이 실리는데 try 안에 broker.send 와 logSuccess 가 함께 있고 catch 가 logFailure 를 부른다. 그 아래에 지금의 메시징 publish 26~60번 줄이 실려 28~31번 줄 주석과 32번 줄 sent 플래그와 33~35번 줄 try 와 39번·43번 줄의 관측 호출과 54~60번 줄 observeQuietly 가 보인다. 지금의 알림 send 는 7~11번 줄 클래스 자바독과 35~45번 줄 본문이 실리는데 37번 줄 try 안에 38번 delegate.send 와 39번 logSuccess 가 있고 40번 catch 가 42번에서 logFailure 를 부른다. 그 send 를 부르는 RoutingNotifier 115~123번 줄이 나오는데 117~119번 주석이 데코레이터가 전파하지 않으므로 try/catch 가 필요 없다고 적고 120~122번이 try/catch 없는 팬아웃 루프다. NotificationPort 를 참조하는 main 파일은 NotificationConfig 와 RoutingNotifier 둘이다. 메시징 쪽 회귀 시험 111~145번 줄이 전문으로 실리는데 119~135번이 던지는 로거 프록시를 만들고 141번과 142~144번이 두 단언이다. 알림 쪽에서 그 클래스를 조립하는 시험은 NotificationAdapterTest 네 자리와 RoutingNotifierTest 여덟 자리인데, 그 두 파일이 던지게 만든 것은 각각 79번과 130번의 위임 제공자뿐이고 Proxy.newProxyInstance 는 알림 모듈에 0 개 메시징 모듈에 1 개다. 클래스 이름을 파일명에 가진 시험은 0 개다. 마지막으로 프로브가 실린다. 로드한 세 클래스가 모두 저장소 build 산출물에서 왔다는 것이 먼저 나오고, 알림 쪽은 로거가 던지지 않으면 전송 1 debug 1 warn 0, 성공 로거만 던지면 warn 1, 둘 다 던지면 IllegalStateException 이 나간다. 메시징 쪽은 세 경우 모두 warn 0 이고 나간 예외가 없다. 821fe00c 구조에 성공 로거만 던지게 하면 warn 1 이 되는데, 그 회귀 시험의 두 단언은 그 구조에서도 모두 통과한다." caption="같은 로거를 쓰는 여덟 자리 · 두 파일의 이력과 초기 커밋의 메시징 구조 · 지금의 두 구조 · 팬아웃 루프와 NotificationPort 참조 · 회귀 시험 전문과 두 단언 · 알림 쪽 조립 시험 열둘과 던지는 대상 · 저장소 클래스를 로드한 프로브의 일곱 행 — 209줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
이 타입을 필드로 가진 main 클래스는 `OutboundMessagePublisher:19` 와 `FailOpenNotificationProvider:17` 둘이다.
|
|
|
|
## 두 구조는 같은 커밋에서 같은 모양으로 출발했다
|
|
|
|
`FailOpenNotificationProvider` 의 커밋 이력은 `821fe00c` 한 줄이다. `OutboundMessagePublisher` 는 `821fe00c` 와 `2f5d2fc2` 두 줄이다.
|
|
|
|
`821fe00c` 시점의 메시징 `publish` 를 꺼내 보면 `try` 안에 `broker.send` 와 `logSuccess` 가 함께 있고 `catch (Exception ex)` 가 `logFailure` 를 부른다. 지금의 알림 `send` 와 같은 모양이다.
|
|
|
|
설계가 갈린 것이 아니라 한쪽만 옮겨 갔다.
|
|
|
|
## OutboundMessagePublisher 는 sent 플래그를 세운 뒤 try 밖에서 관측을 부른다
|
|
|
|
`:32` 가 `boolean sent = false` 를 두고, `:33`\~`:35` 의 `try` 가 감싸는 것은 `broker.send(message)` 하나다. 성공하면 `:35` 가 플래그를 세운다.
|
|
|
|
실패 관측은 `catch` 안 `:39` 에서, 성공 관측은 `try` 밖 `:42`\~`:45` 에서 일어난다. 둘 다 `observeQuietly` 를 거치고 `:55`\~`:59` 가 그 안에서 던진 `RuntimeException` 을 삼킨다. 그 메서드의 자바독에는 디스크가 찬 appender 가 호출자가 브로커에 대해 믿는 것을 바꾸면 안 되므로 진단은 권위를 갖지 않는다고 적혀 있다.
|
|
|
|
`:28`\~`:31` 주석은 이 구조가 무엇을 고친 것인지 적는다. 전송과 성공 로그가 한 `try` 를 공유하던 시절, 브로커가 이미 메시지를 받은 뒤 로거가 던지면 같은 `catch` 가 그것을 발행 실패로 보고했다.
|
|
|
|
## 알림 쪽은 전송과 성공 로그가 한 try 안에 있다
|
|
|
|
`:37` 의 `try` 안에 `:38` 의 `delegate.send(notification)` 와 `:39` 의 `logSuccess` 가 함께 있다. `:40` 의 `catch (Exception ex)` 가 `:42` 에서 `logFailure` 를 부른다.
|
|
|
|
메시징 쪽의 `sent` 같은 플래그가 없어서 `delegate.send` 의 결과와 `logSuccess` 의 결과를 구분하지 않는다.
|
|
|
|
클래스 자바독 `:8`\~`:10` 에는 제공자 실패를 기록하고 삼켜서 알림이 코어 유스케이스를 실패시키지 않게 한다고 적혀 있다.
|
|
|
|
## 던지는 로거를 세 구조에 넣었을 때의 호출 수
|
|
|
|
프로브는 저장소의 build 산출물에서 세 클래스를 로드한다. 출력 첫 세 줄이 그 경로를 찍는다.
|
|
|
|
slf4j `Logger` 를 프록시로 만들어 `debug` 나 `warn` 이 불릴 때 던지게 했다. 저장소의 메시징 회귀 시험이 쓰는 방식과 같다. 위임 전송과 브로커 전송은 항상 성공하도록 두었다.
|
|
|
|
알림 쪽에서 로거가 던지지 않으면 `delegate.send` 1 회 · `debug` 1 회 · `warn` 0 회다. 성공 로거만 던지면 `warn` 이 1 회가 된다. `debug` 호출이 던져서 SUCCESS 줄은 남지 않고, 위임 전송이 성공한 그 한 번에 대해 실패 쪽 기록이 남는다.
|
|
|
|
성공 로거와 실패 로거가 모두 던지면 `IllegalStateException` 이 `send` 밖으로 나간다. 위임 전송은 이미 성공한 뒤다.
|
|
|
|
메시징 쪽은 같은 세 경우 모두 `warn` 0 회이고 나가는 예외가 없다.
|
|
|
|
## 그 예외가 나가면 남은 제공자도 호출되지 않는다
|
|
|
|
`RoutingNotifier:120`\~`:122` 의 팬아웃 루프는 라우트가 지목한 제공자들을 차례로 부른다. `try`/`catch` 가 없다.
|
|
|
|
`:117`\~`:119` 주석이 그 이유를 적는다 — `FailOpenNotificationProvider.send` 가 `throws` 를 선언하지 않으므로 `try`/`catch` 가 필요 없다는 것이다.
|
|
|
|
`NotificationPort` 를 참조하는 main 파일은 `NotificationConfig` 와 `RoutingNotifier` 둘이다.
|
|
|
|
## 메시징 쪽 회귀 시험은 이 회귀를 고정하지 못한다
|
|
|
|
`OutboundMessagePublisherTest:114` 의 `aLoggerFailureAfterAConfirmedSendIsNotAPublishFailure` 는 `:119`\~`:135` 에서 `debug` 에만 던지는 로거 프록시를 만든다. 단언은 둘이다 — `:141` 이 `publish` 가 예외를 던지지 않는 것, `:142`\~`:144` 가 브로커가 그 메시지를 받은 것이다.
|
|
|
|
프로브에서 `821fe00c` 구조에 같은 로거를 물려 보면 `warn` 이 1 회 불리지만 두 단언은 모두 통과한다. 그 시험은 WARN 이 남았는지를 보지 않는다.
|
|
|
|
## 알림 쪽 시험은 위임 제공자만 던지게 한다
|
|
|
|
`FailOpenNotificationProvider` 를 조립하는 시험은 `NotificationAdapterTest` 네 자리와 `RoutingNotifierTest` 여덟 자리다.
|
|
|
|
그 두 파일이 던지게 만든 것은 각각 `:79` 와 `:130` 의 위임 제공자다. 로거를 던지게 만드는 `Proxy.newProxyInstance` 는 알림 모듈에 0 개이고 메시징 모듈에 1 개다.
|
|
|
|
그 클래스 이름을 파일명에 가진 시험 파일은 0 개다.
|
|
|
|
## 원문과 갈리는 자리
|
|
|
|
원문은 메시징 쪽 회귀 시험이 이 구조를 고정한다고 적었다. 프로브는 그것을 반박한다. 그 시험의 두 단언은 초기 커밋 구조에서도 통과하므로, 되돌려도 초록이다.
|
|
|
|
원문은 두 소비자가 같은 로거를 다르게 쓴다고 적었다. 맞지만 이력을 보면 설계가 갈린 것이 아니다. 둘은 같은 커밋에서 같은 모양으로 출발했고 한쪽만 옮겨 갔다.
|
|
|
|
알림 쪽에서 성공한 전송에 실패 기록이 남는다는 것과 소스 주석이 과거 버그를 적는다는 것은 원문대로다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
실제 배포에서 어떤 조건이 로거를 던지게 하는지 조사하지 않았다. 프로브는 던지는 상황을 만들어 세 구조를 나란히 놓은 것이다.
|
|
|
|
예외가 나갔을 때 같은 라우트의 남은 제공자가 실제로 건너뛰어지는지 실행으로 보지 않았다. 팬아웃 루프에 `try`/`catch` 가 없다는 줄과 그 이유를 적은 주석을 읽었다.
|
|
|
|
WARN 줄을 세는 알림 규칙이 저장소 밖 어딘가에 있는지는 찾지 않았다.
|
|
|
|
예외가 나갔을 때 같은 라우트의 남은 제공자가 실제로 건너뛰어지는지 실행으로 보지 않았다. 팬아웃 루프에 `try`/`catch` 가 없다는 줄과 그 이유를 적은 주석을 읽었다.
|
|
|
|
<!-- body:end -->
|