Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/multitenancy-isolation/case/case-analysis-finding-a04-f002.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
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>
2026-09-04 22:51:59 +09:00

15 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-a04-f002 메시징만 성공 로그를 try 밖으로 옮기고 알림은 같은 try 에 남겨 두었다 multitenancy-isolation clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:analysis-finding-a04-f002 2026-09-04 case-analysis-finding-a04-f002.body.md
key file
analysis-finding-a04-f002 ../../../final/evidence/rendered/analysis-finding-a04-f002.svg
../../../final/evidence/raw/analysis-finding-a04-f002.txt
원본 분석 절은 analysis/04-adapter-outbound-support.md §5 이다.

메시징만 성공 로그를 try 밖으로 옮기고 알림은 같은 try 에 남겨 두었다

821fe00c 초기 커밋에서 OutboundMessagePublisherFailOpenNotificationProvider 는 같은 모양이었다. 2f5d2fc2 가 메시징 쪽만 boolean sentobserveQuietly 로 갈랐고 알림 쪽은 그대로다. 그래서 알림 쪽에서는 위임 전송이 성공한 뒤 성공 로거가 던지면 그 예외를 전송 실패용 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. 초기 커밋 구조에서 그 회귀 시험의 두 단언이 성립하는지 프로브 안에서 판정한다.

본문

FailOpenDependencyLogger 는 선택적 어댑터들이 공유하는 로그 계약이다. 실패는 항상 WARN 이고, 클래스 자바독은 본문·수신자·payload 를 인자로 받지 않으므로 PII 가 로그에 닿을 수 없다고 적는다. 예외 메시지를 통해서는 닿는다는 것을 다른 기록이 프로브로 확인했다.

같은 로거를 두 어댑터가 쓴다

:::evidence key="analysis-finding-a04-f002" alt="저장소 루트에서 돌린 정적 검색과 프로브 실행의 출력 209줄. 먼저 FailOpenDependencyLogger 가 나오는 자리 여덟이 실리는데 메시징 어댑터 main 과 그 시험, 알림 어댑터 main 과 그 시험 둘, 지원 모듈 시험, 그리고 두 설정 클래스다. 이어서 두 파일의 git 이력이 나온다. FailOpenNotificationProvider 는 821fe00c 초기 커밋 한 줄뿐이고, OutboundMessagePublisher 는 2f5d2fc2 와 821fe00c 두 줄이다. 다음으로 821fe00c 시점의 메시징 publish 2637번 줄이 실리는데 try 안에 broker.send 와 logSuccess 가 함께 있고 catch 가 logFailure 를 부른다. 그 아래에 지금의 메시징 publish 2660번 줄이 실려 2831번 줄 주석과 32번 줄 sent 플래그와 3335번 줄 try 와 39번·43번 줄의 관측 호출과 5460번 줄 observeQuietly 가 보인다. 지금의 알림 send 는 711번 줄 클래스 자바독과 3545번 줄 본문이 실리는데 37번 줄 try 안에 38번 delegate.send 와 39번 logSuccess 가 있고 40번 catch 가 42번에서 logFailure 를 부른다. 그 send 를 부르는 RoutingNotifier 115123번 줄이 나오는데 117119번 주석이 데코레이터가 전파하지 않으므로 try/catch 가 필요 없다고 적고 120122번이 try/catch 없는 팬아웃 루프다. NotificationPort 를 참조하는 main 파일은 NotificationConfig 와 RoutingNotifier 둘이다. 메시징 쪽 회귀 시험 111145번 줄이 전문으로 실리는데 119135번이 던지는 로거 프록시를 만들고 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:19FailOpenNotificationProvider:17 둘이다.

두 구조는 같은 커밋에서 같은 모양으로 출발했다

FailOpenNotificationProvider 의 커밋 이력은 821fe00c 한 줄이다. OutboundMessagePublisher821fe00c2f5d2fc2 두 줄이다.

821fe00c 시점의 메시징 publish 를 꺼내 보면 try 안에 broker.sendlogSuccess 가 함께 있고 catch (Exception ex)logFailure 를 부른다. 지금의 알림 send 와 같은 모양이다.

설계가 갈린 것이 아니라 한쪽만 옮겨 갔다.

OutboundMessagePublisher 는 sent 플래그를 세운 뒤 try 밖에서 관측을 부른다

:32boolean sent = false 를 두고, :33~:35try 가 감싸는 것은 broker.send(message) 하나다. 성공하면 :35 가 플래그를 세운다.

실패 관측은 catch:39 에서, 성공 관측은 try:42~:45 에서 일어난다. 둘 다 observeQuietly 를 거치고 :55~:59 가 그 안에서 던진 RuntimeException 을 삼킨다. 그 메서드의 자바독에는 디스크가 찬 appender 가 호출자가 브로커에 대해 믿는 것을 바꾸면 안 되므로 진단은 권위를 갖지 않는다고 적혀 있다.

:28~:31 주석은 이 구조가 무엇을 고친 것인지 적는다. 전송과 성공 로그가 한 try 를 공유하던 시절, 브로커가 이미 메시지를 받은 뒤 로거가 던지면 같은 catch 가 그것을 발행 실패로 보고했다.

알림 쪽은 전송과 성공 로그가 한 try 안에 있다

:37try 안에 :38delegate.send(notification):39logSuccess 가 함께 있다. :40catch (Exception ex):42 에서 logFailure 를 부른다.

메시징 쪽의 sent 같은 플래그가 없어서 delegate.send 의 결과와 logSuccess 의 결과를 구분하지 않는다.

클래스 자바독 :8~:10 에는 제공자 실패를 기록하고 삼켜서 알림이 코어 유스케이스를 실패시키지 않게 한다고 적혀 있다.

던지는 로거를 세 구조에 넣었을 때의 호출 수

프로브는 저장소의 build 산출물에서 세 클래스를 로드한다. 출력 첫 세 줄이 그 경로를 찍는다.

slf4j Logger 를 프록시로 만들어 debugwarn 이 불릴 때 던지게 했다. 저장소의 메시징 회귀 시험이 쓰는 방식과 같다. 위임 전송과 브로커 전송은 항상 성공하도록 두었다.

알림 쪽에서 로거가 던지지 않으면 delegate.send 1 회 · debug 1 회 · warn 0 회다. 성공 로거만 던지면 warn 이 1 회가 된다. debug 호출이 던져서 SUCCESS 줄은 남지 않고, 위임 전송이 성공한 그 한 번에 대해 실패 쪽 기록이 남는다.

성공 로거와 실패 로거가 모두 던지면 IllegalStateExceptionsend 밖으로 나간다. 위임 전송은 이미 성공한 뒤다.

메시징 쪽은 같은 세 경우 모두 warn 0 회이고 나가는 예외가 없다.

그 예외가 나가면 남은 제공자도 호출되지 않는다

RoutingNotifier:120~:122 의 팬아웃 루프는 라우트가 지목한 제공자들을 차례로 부른다. try/catch 가 없다.

:117~:119 주석이 그 이유를 적는다 — FailOpenNotificationProvider.sendthrows 를 선언하지 않으므로 try/catch 가 필요 없다는 것이다.

NotificationPort 를 참조하는 main 파일은 NotificationConfigRoutingNotifier 둘이다.

메시징 쪽 회귀 시험은 이 회귀를 고정하지 못한다

OutboundMessagePublisherTest:114aLoggerFailureAfterAConfirmedSendIsNotAPublishFailure:119~:135 에서 debug 에만 던지는 로거 프록시를 만든다. 단언은 둘이다 — :141publish 가 예외를 던지지 않는 것, :142~:144 가 브로커가 그 메시지를 받은 것이다.

프로브에서 821fe00c 구조에 같은 로거를 물려 보면 warn 이 1 회 불리지만 두 단언은 모두 통과한다. 그 시험은 WARN 이 남았는지를 보지 않는다.

알림 쪽 시험은 위임 제공자만 던지게 한다

FailOpenNotificationProvider 를 조립하는 시험은 NotificationAdapterTest 네 자리와 RoutingNotifierTest 여덟 자리다.

그 두 파일이 던지게 만든 것은 각각 :79:130 의 위임 제공자다. 로거를 던지게 만드는 Proxy.newProxyInstance 는 알림 모듈에 0 개이고 메시징 모듈에 1 개다.

그 클래스 이름을 파일명에 가진 시험 파일은 0 개다.

원문과 갈리는 자리

원문은 메시징 쪽 회귀 시험이 이 구조를 고정한다고 적었다. 프로브는 그것을 반박한다. 그 시험의 두 단언은 초기 커밋 구조에서도 통과하므로, 되돌려도 초록이다.

원문은 두 소비자가 같은 로거를 다르게 쓴다고 적었다. 맞지만 이력을 보면 설계가 갈린 것이 아니다. 둘은 같은 커밋에서 같은 모양으로 출발했고 한쪽만 옮겨 갔다.

알림 쪽에서 성공한 전송에 실패 기록이 남는다는 것과 소스 주석이 과거 버그를 적는다는 것은 원문대로다.

확인하지 못한 것

실제 배포에서 어떤 조건이 로거를 던지게 하는지 조사하지 않았다. 프로브는 던지는 상황을 만들어 세 구조를 나란히 놓은 것이다.

예외가 나갔을 때 같은 라우트의 남은 제공자가 실제로 건너뛰어지는지 실행으로 보지 않았다. 팬아웃 루프에 try/catch 가 없다는 줄과 그 이유를 적은 주석을 읽었다.

WARN 줄을 세는 알림 규칙이 저장소 밖 어딘가에 있는지는 찾지 않았다.

예외가 나갔을 때 같은 라우트의 남은 제공자가 실제로 건너뛰어지는지 실행으로 보지 않았다. 팬아웃 루프에 try/catch 가 없다는 줄과 그 이유를 적은 주석을 읽었다.