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>
11 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-f004 | 실패 로거가 던지면 그 예외가 제공자 예외를 밀어내고 호출자로 나간다 | multitenancy-isolation | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:analysis-finding-a04-f004 | 2026-09-04 | case-analysis-finding-a04-f004.body.md |
|
|
|
실패 로거가 던지면 그 예외가 제공자 예외를 밀어내고 호출자로 나간다
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
재현 조건
- 데코레이터의 클래스 자바독과 send 본문을 인용한다.
- 메시징 쪽 publish 를 주석과 플래그까지 포함해 싣고 관측 헬퍼를 함께 인용한다.
- 이 데코레이터를 부르는 팬아웃 루프를 메서드 서명부터 인용한다.
- NotificationPort 를 참조하는 main 파일을 전부 찾는다.
- 위임 제공자가 항상 던지도록 만들고 slf4j Logger 프록시를 붙인다.
- warn 이 정상인 경우와 던지는 경우로 나눠 호출 수와 나간 예외를 적고, 그 예외의 cause 와 suppressed 도 함께 출력한다.
- 두 모듈에서 로거 프록시를 만드는 자리를 세고, 조립 시험이 무엇을 던지게 만드는지 나열한다.
본문
FailOpenNotificationProvider 는 제공자 실패를 삼켜서 알림이 코어 유스케이스를 실패시키지 않게 하는 데코레이터다. 그 계약이 클래스 자바독 :8~:10 에 적혀 있다.
catch 안의 logFailure 호출에는 try 가 없다
:::evidence key="analysis-finding-a04-f004" alt="저장소 루트에서 돌린 정적 검색과 프로브의 출력 89줄. 먼저 FailOpenNotificationProvider 711번 줄의 클래스 자바독이 실려 제공자 실패를 기록하고 삼켜서 알림이 코어 유스케이스를 실패시키지 않는다고 적는다. 3545번 줄은 send 본문인데 37번 try 안에 38번 delegate.send 와 39번 logSuccess 가 있고 40번 catch 가 42번에서 logFailure 를 부른다. 이어서 메시징 쪽 OutboundMessagePublisher 2646번 줄이 실리는데 2831번 주석이 전송과 성공 로그가 한 try 를 공유하던 시절의 버그를 적고 32번이 sent 플래그를 두며 3335번 try 가 브로커 전송만 감싸고 39번과 43번이 관측을 부른다. 4860번 줄의 observeQuietly 가 RuntimeException 을 삼킨다. 그 아래에 RoutingNotifier 112123번 줄이 실려 notify 서명과 117119번 주석과 예외 처리기 없는 팬아웃 루프가 보인다. 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 파일은 빈을 조립하는 쪽과 구현하는 쪽 둘뿐이고 실제로 알림을 보내는 코드가 없다. 오늘 이 예외를 받을 자리가 없으므로 등급을 그대로 두되 그 조건을 적어 둔다.