Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/notification-and-delivery/case/case-a13-f004-thymeleaf.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

209 lines
14 KiB
Markdown

---
kind: CASE
slug: a13-f004-thymeleaf
title: Thymeleaf 메시지 삭제 가드가 프로덕션 경로에 없고, 있어도 값은 로그에 남는다
topic: notification-and-delivery
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a13-f004-thymeleaf
evidenceCapturedOn: 2026-09-02
body: case-a13-f004-thymeleaf.body.md
assets:
- key: a13-f004-thymeleaf
file: ../../../final/evidence/rendered/a13-f004-thymeleaf.svg
- key: a13-f004-thymeleaf-messages
file: ../../../final/evidence/rendered/a13-f004-thymeleaf-messages.svg
evidence:
- ../../../final/evidence/raw/a13-f004-thymeleaf.txt
- ../../../final/evidence/raw/a13-f004-thymeleaf-messages.txt
source:
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L476 이다.
---
# Thymeleaf 메시지 삭제 가드가 프로덕션 경로에 없고, 있어도 값은 로그에 남는다
렌더 예외를 잡아 메시지를 버리는 catch 는 모드 없는 오버로드 안에만 있다. 프로덕션은 모드 있는 오버로드만 부른다. 그리고 그 catch 가 막으려던 값은 그보다 앞서 Thymeleaf 자신의 ERROR 로그에 적힌다.
## 관계
- **정리 작업이 무제한 DELETE 를 쏘고, 그것을 막는 오버로드는 호출되지 않는다**
두 사례 모두 가드가 붙은 오버로드를 프로덕션에서 부르지 않는다.
- **정책을 담은 필드가 참이 된 적도 읽힌 적도 없다**
같은 리프의 다른 개인정보 사례다.
- **두 오버로드가 있었고 호출되는 쪽이 틀린 쪽이었다**
이 사례가 그 규칙의 형태다.
## 문제
한 클래스에 렌더 메서드가 둘 있고 예외 처리가 서로 다르다. 그 차이가 실제로 무엇을 바꾸는지 재봤다.
## 결론
모드 없는 쪽은 처리 호출을 감싸고 실행 예외를 잡는다. 잡은 뒤 메시지를 버리고 자체 예외를 던진다. 주석이 이유를 적어 뒀다. 템플릿 식은 실패한 변수를 담고 있고, 이 플랫폼에서 그 변수는 일회용 코드이거나 수신자 이름이라는 것이다.
모드 있는 쪽에는 그 catch 가 없다. 저장소 전체를 뒤져도 배포되는 코드가 엔진을 부르는 자리는 둘이고, 둘 다 모드를 넘긴다. 같은 검색이 테스트에서 잡은 것은 열다섯 자리다.
빠진 것은 catch 하나다. 변수 누락은 두 오버로드가 똑같이 거른다. 문맥을 만드는 자리가 그 검사를 부르기 때문이다.
catch 가 없으면 Thymeleaf 예외가 그대로 호출자에게 간다. 그 메시지는 템플릿 원문을 인용하고, 원인 사슬은 실패한 값을 담을 수 있다.
값이 절에 적힌 자리는 넷이었다. 메서드의 수신 객체, 함수의 인자, 속성의 선택자, 산술의 피연산자다.
무엇이 적히는지는 자리가 아니라 예외 종류가 정한다. 메서드 실패 예외는 원인을 가리지 않고 수신 객체를 적는다. 없는 메서드를 부르든 있는 메서드가 범위를 벗어나 던지든 같다. 변환기는 변환하지 못한 인자를 적고, 속성 예외는 선택자를 적으며, 수로 바꾸지 못한 피연산자는 형식 예외가 적는다. 변환이 끝난 뒤의 실패가 조용한 것은 피연산자 자리뿐이고, 0 으로 나눈 예외가 피연산자를 적지 않기 때문이다.
변수 자체가 아니어도 된다. 거기서 한 단계 타고 들어간 필드나 원소면 충분하다. 수신자 레코드의 전자우편을 자르려다 실패한 식이 그 주소를 남겼다.
적히는 단위가 값이 아니라 객체다. 레코드를 통째로 넘긴 식에서는 그 안의 전자우편까지 절에 들어갔다.
조용한 쪽은 넷이었다. 셋은 그 자리에 호출자 값이 아예 없었거나 0 으로 나눈 예외가 피연산자를 적지 않은 경우였고, 나머지 하나는 변수를 주지 않아 OGNL 에 닿기 전에 걸렸다.
두 자리가 서로 이어져 있지 않아 예외의 행선지도 갈린다. 제공자 시도 어댑터에서는 실패가 그 자리 밖으로 나가지 않는다. 배차 서비스는 감싸지 않는다. 거기서 나간 예외는 두 프레임 위 일정 작업자의 포괄 잡기에 닿고, 작업자는 타입만 적는다. 작업자 주석이 이유를 적어 뒀다. 라이브러리 예외 문구가 수신자 주소를 담을 수 있다는 것이다.
즉 예외를 타고 나가는 쪽은 두 자리에서 모두 막힌다. 막히지 않은 것은 반대쪽이다.
값이 로그에 닿는 경로는 이 어댑터가 아니라 라이브러리다. 던지기에 앞서 ERROR 사건이 하나 먼저 나가고, 그 사건은 두 오버로드 모두에서 남았다. 원본 분석은 예외가 상위 로거에 기록된다고 적었는데, 예외는 타입만 기록되고 문구를 남기는 것은 라이브러리 자신의 로그다.
마스킹 카탈로그도 이 문구를 알아보지 못했다.
판정은 P2 다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 열한 가지 식을 두 오버로드에 넣는 프로브와 호출 경로 전수 확인
소스 수정 : x
## 재현 조건
이 사례의 프로브는 새로 만든 것이고 원문에는 없다. 하위 범위 04 의 근거 파일은 final/evidence/raw/184-notification-template-security-probes.txt 다.
1. 고정 리비전 산출물과 잠금 파일로 클래스패스를 만든다.
2. 루트 로거에서 콘솔 어펜더를 떼고 수집용 어펜더를 붙인다.
3. 열한 가지 식을 두 오버로드에 넣고 호출자가 받는 예외 사슬을 잇는다.
4. 수집한 로그 사건을 메시지와 예외 사슬로 펼친다.
5. 두 문자열에 변수 값이 있는지, 마스킹 카탈로그를 지난 뒤에도 있는지 센다.
6. 실패가 OGNL 까지 간 열 식에서 첫 절을 따로 뽑는다.
## 본문
<!-- body:start -->
`ThymeleafStringTemplateEngine`은 같은 클래스에 `render` 두 개를 갖는다. 처리 호출을 감싸는 것은 그중 하나뿐이다.
```java
// ThymeleafStringTemplateEngine.java:84-100 (mode-less overload)
public String render(String source, Map<String, Object> variables) {
...
try {
return engine.process(source, contextFor(source, variables));
} catch (RuntimeException failure) {
// The message is dropped on purpose. Thymeleaf reports the offending expression, and a
// template expression contains the variable it failed on — which for this platform is a
// one-time code or a recipient name.
throw new TemplateRenderingException(NotificationFailureDescriptor.preDispatch(...));
}
}
```
`:67-82`의 mode-aware overload에는 이 블록이 없다. `engineFor(mode).process(...)`를 그대로 부르고 결과를 `TemplateSlotPolicy.verifyRendered`에 넘긴다.
## 배포되는 코드가 부르는 것은 감싸지 않은 쪽이다
:::evidence key="a13-f004-thymeleaf" alt="코드베이스 정적 검색 출력 29줄. 저장소 전체 엔진 호출과 테스트 건수, 두 오버로드의 검사 위치, 렌더를 부르는 두 자리와 그것이 닿는 작업자, 로거 설정 줄 수가 차례로 보인다." caption="오버로드와 렌더 예외가 지나는 자리 — 29줄 · exit 0" zoom="true"
:::
`CanonicalNotificationRenderer:125``:133``src/main` 전체에서 유일한 호출이고, 첫 인자가 `modeOf(slot)`이다. `NotificationTemplateEngine:29-34`가 이 오버로드를 `default` 가 아니라 추상으로 둔 경위를 적어 두었고, 다른 구현인 `PlaceholderTemplateEngine``:28-30`에서 모드 없는 쪽이 모드 있는 쪽으로 넘어간다. 어느 쪽으로도 `:92`에 닿지 않는다.
## 빠진 것은 catch 하나다
두 오버로드가 갈리는 지점을 catch 로 좁혀야 한다. 누락 변수 검사는 갈리지 않는다.
```java
// ThymeleafStringTemplateEngine.java:154-161
private Context contextFor(String source, Map<String, Object> variables) {
...
requireEveryReferencedVariable(source, variables);
...
}
```
`:88`이 같은 검사를 한 번 더 부르지만, `contextFor`가 이미 부르므로 모드 있는 쪽도 이 검사를 지난다. 아래 자료의 `[[${code}]]` 줄이 그 결과다.
## 열한 가지 식을 두 오버로드에 넣었다
:::evidence key="a13-f004-thymeleaf-messages" alt="JVM 프로브 출력 42줄. 열한 가지 식을 두 오버로드에 넣고 호출자와 로그와 마스킹 뒤에서 변수 값이 보이는지를 있음 없음으로 적은 뒤, 각 식에서 OGNL 이나 JDK 가 만든 절을 열 줄로 나열한다. 로그와 마스킹 칸은 두 오버로드가 같을 때만 한 값으로 적힌다. 유틸리티 객체의 주소는 실행마다 달라지므로 고정 표시로 바꿔 적었다." caption="열한 가지 식을 두 오버로드에 넣은 프로브 — 42줄 · exit 0" zoom="true"
:::
값이 나온 식이 일곱, 나오지 않은 식이 넷이다. 가르는 것은 식의 생김새가 아니라 실패한 자리에 무엇이 놓여 있었느냐다. 자료 마지막 열 줄이 각 식에서 OGNL 이나 JDK 가 만든 첫 절을 보여 준다.
```
[[${recipient.email.substring(99)}]] MethodFailedException: Method "substring" failed for object alice@example.com
[[${#numbers.formatDecimal(code, 1, 2)}]] IllegalArgumentException: Unable to convert type java.lang.String of 483920 to type of java.lang.Number
[[${code[recipient]}]] NoSuchPropertyException: java.lang.String.Recipient[email=alice@example.com, code=483920]
[[${recipient * 2}]] NumberFormatException: For input string: "Recipient[email=alice@example.com, code=483920]"
```
수신 객체, 변환하지 못한 인자, 속성 이름으로 쓰인 선택자, 수로 바꾸지 못한 피연산자다. 네 자리에서 모두 같은 일이 났다. 다만 절에 무엇이 실리느냐는 예외 클래스마다 다르다. 첫 줄이 보여 주듯 그 자리에 놓인 것이 변수 자신일 필요도 없다. `recipient`는 수신 객체가 아니지만 거기서 도달한 필드가 수신 객체가 되면서 그 값이 절에 적혔다.
나오지 않은 넷 가운데 셋은 이렇게 끝났다.
```
[[${#strings.substring(code, 99, 200)}]] MethodFailedException: Method "substring" failed for object org.thymeleaf.expression.Strings@<주소>
[[${code.noSuchProperty}]] NoSuchPropertyException: java.lang.String.noSuchProperty
[[${code.length() / 0}]] ArithmeticException: / by zero
```
첫 줄은 수신 객체가 유틸리티이고, 변수는 인자 자리에 있는데 그 변환이 성공했다. 둘째는 선택자가 리터럴 이름이다. 셋째는 두 피연산자가 모두 수로 바뀐 뒤 나눗셈 자체가 실패한 경우다. 변환이 끝난 다음이면 `ArithmeticException` 은 피연산자를 적지 않는다. `[[${code[recipient]}]]``[[${code.noSuchProperty}]]`가 같은 예외 타입에서 갈리는 것이 이 규칙을 가장 짧게 보여 준다.
`:93-95`의 주석은 노출 범위를 "일회용 코드이거나 수신자 이름"으로 적었다. 실제로 나가는 것은 그 자리에 놓인 객체의 `toString()` 전체이므로, 값 객체를 넘기면 그것이 담은 필드까지 따라 나온다.
넷째인 `[[${code}]]` 는 OGNL 까지 가지도 않는다. 누락 변수 검사가 먼저 거르므로 절 목록에도 없다. 열한 식이 남긴 절이 열 줄인 이유가 그것이다.
원본 분석 `:532`가 든 `${amount.formatted('%.2f')}`는 첫 번째 자리에 걸린다. `amount``BigDecimal`이나 `Double`이면 `formatted`가 없는 메서드가 되고, 문자열이면 아예 실패하지 않는다.
## 렌더를 부르는 두 자리는 서로 독립이다
`NotificationProviderAttemptAdapter:163`은 자기 호출을 감싸고 `:164`에서 실행 예외를 잡아 `TEMPLATE_RENDERING_FAILED` 코드만 남긴다. 예외 객체는 버려진다.
`NotificationDispatchService:146`에는 그런 감싸기가 없다. 거기서 던져진 것은 `dispatch(...)` 밖으로 나가 두 프레임 위에서 잡힌다.
```java
// NotificationSchedulerWorker.java:95-106 (주석 여섯 줄 중 앞 세 줄 생략)
} catch (RuntimeException failure) {
// The cause chain by type, never by message. ... a library's exception text
// can carry a recipient address, so the types are named and the messages are not.
log.warn("notification dispatch failed worker={} reason={} causes={}",
workerId, failure.getClass().getSimpleName(), causeChain(failure));
}
```
`causeChain``:121-132`에서 원인마다 `getSimpleName()`만 이어 붙인다. 그래서 여기 남는 것은 `reason=TemplateInputException causes=TextParseException<...` 같은 타입 나열이고 문구는 없다.
## 값은 그 앞에서 로그에 적힌다
Thymeleaf 는 예외를 올려보내기 전에 `org.thymeleaf.TemplateEngine` 로거로 같은 사슬을 ERROR 에 남긴다. `app-bootstrap`의 yml·xml·properties 어디에도 이 로거를 따로 설정한 줄이 없다. `application.yml:347`의 루트 준위 기본값은 `INFO`이고 환경 변수로만 바뀌며, `logback-spring.xml`은 네 조합 모두에서 `<root level="INFO">`를 직접 적는다. 샘플링도 이 사건을 지우지 못한다.
```java
// SamplingTurboFilter.java:46-48
if (level.toInt() >= Level.WARN_INT) {
return FilterReply.NEUTRAL;
}
```
프로브가 수집한 로그 사건에서 값이 보인 칸은 두 오버로드 모두 `있음`이다. 마스킹도 이것을 잡지 못한다. `SecretMaskingMessageConverter``%maskedMsg` 자리에서 `super.convert(event)`만 훑고, 그 값은 본문이 아니라 예외 쪽에 있다. JSON 어펜더 쪽은 스택까지 훑지만 같은 카탈로그를 지난다.
```java
// LogMaskingPatterns.java:20-21
private static final String VALUE = "[^\\s\"',&}]+";
private static final String SEP = "[\"']?\\s*[:=]\\s*[\"']?";
```
규칙마다 키 이름과 `:` 또는 `=`를 요구한다. 위의 어느 절에도 그 둘이 없다. 프로브가 이 `mask`를 그대로 불러 확인한 칸이 `마스킹 뒤 있음`이다.
## 확인하지 못한 것
운영 템플릿 가운데 이런 식을 쓰는 것이 몇 개인지 세지 않았다. 절을 만드는 예외 종류는 이 열한 식이 만든 것만 봤고, 라이브러리 자신이 던지는 예외가 인자를 문구에 담는 경우는 세지 않았다. 마스킹은 카탈로그 함수로만 확인했고, JSON 어펜더를 붙여 실제 출력 문자열을 만들지는 않았다.
<!-- body:end -->