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>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,125 @@
---
kind: CASE
slug: a-policy-reversed-by-a-later-filter
title: 요청 식별자를 클라이언트가 고를 수 없다는 정책이 뒤에 도는 필터에 뒤집혔다
topic: duplicate-mechanisms
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-policy-reversed-by-a-later-filter
evidenceCapturedOn: 2026-09-02
assets:
- key: a-policy-reversed-by-a-later-filter
file: ../../../final/evidence/rendered/a-policy-reversed-by-a-later-filter.svg
evidence:
- ../../../final/evidence/raw/a-policy-reversed-by-a-later-filter.txt
source:
- 분석 문서는 web 어댑터 편 §32.1 이다. 실패 시나리오와 정화가 로그 주입을 막는다는 판정, 그리고 요청 속성 값을 읽는 곳이 없다는 사실이 그 절에 있다. 뒤 필터의 순서를 가장 낮은 우선순위로 적은 것은 §31.1 이고, 같은 헤더가 두 번 처리된다는 목록은 §31.3 이다.
---
# 요청 식별자를 클라이언트가 고를 수 없다는 정책이 뒤에 도는 필터에 뒤집혔다
플랫폼 필터는 클라이언트가 준 요청 식별자를 기본적으로 신뢰하지 않는다. 같은 애플리케이션에 스캔되어 들어오는 로깅 필터는 같은 헤더를 정화한 뒤 받아들이고, 응답 헤더와 로그에 실리는 값은 로깅 필터가 정한다.
## 관계
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
이 사례가 그 규칙을 요구하는 형태다.
- **클라이언트가 준 엔드포인트가 SSRF 가드가 아니라 약한 private 사본을 지났다**
같은 형태가 알림 어댑터에서 나타난 사례다.
- **sanitize가 아니라 reject가 기본이다**
두 필터가 서로 다른 답을 내는 규칙이다.
## 문제
플랫폼 요청 식별자 필터의 javadoc 에는 신뢰가 설정 가능하되 요청 식별자에 대해서는 기본이 꺼짐이고, 자기 요청 id 를 고를 수 있는 호출자는 서로 다른 두 요청이 하나의 신원을 공유하게 만들 수 있다고 적혀 있다.
같은 헤더를 두 번째 필터가 다시 다루는데, 그 필터는 값을 정화한 뒤 받아들인다.
## 결론
관측 가능한 자리에 도달하는 값은 전부 뒤 필터의 것이다. 응답 헤더와 MDC 와 접근 로그가 그렇다.
앞 필터가 남긴 요청 속성을 쓰는 곳이 없다. 접근자는 있는데 부르는 곳이 0 이다.
정화는 0x20 미만을 전부 버리므로 javadoc 이 걱정한 개행 주입은 이 경로로 들어오지 못한다. 막지 못하는 것은 신원 공유다. 한 호출자가 자기 요청 백 개에 같은 헤더 값을 붙이면 응답 백 개와 로그 백 줄이 같은 식별자를 갖는다.
분석 문서는 하나만 남기라고 권고한다. 뒤 필터가 앞 필터의 접근자를 읽으면 정책이 한 곳에 남고 MDC 와 로그와 응답 헤더가 일치한다.
## 검증 환경
OpenJDK : 21.0.12
Spring Boot : 4.0.8
확인 방식 : 두 구현의 코드 비교와 조립 경로·순서 선언 확인
소스 수정 : x
## 재현 조건
1. 앞 필터가 자동설정으로 등록되는지, 어떤 조건과 인자로 조립되는지 확인한다.
2. 설정 레코드가 신뢰 값의 널을 어떻게 접는지 확인한다.
3. 두 필터가 쓰는 헤더 이름 상수를 대조한다.
4. 각 필터의 순서 선언을 확인한다. 한쪽에만 있다.
5. 로깅 필터의 패키지가 컴포지션 루트의 스캔 제외 정규식에 있는지 확인한다.
6. 앞 필터가 요청 속성에 넣은 값을 읽는 프로덕션 참조를 센다.
7. 정화 함수가 버리는 문자 범위를 확인한다.
## 본문
<!-- body:start -->
플랫폼 요청 식별자 필터는 클라이언트가 준 값을 기본으로 신뢰하지 않는다. 같은 헤더를 다루는 두 번째 필터가 있고, 그 필터는 값을 받아들인다.
## 두 필터의 선언을 한 화면에 놓으면
:::evidence key="a-policy-reversed-by-a-later-filter" alt="코드베이스에서 앞 필터가 자동설정으로 조립되는 등록 항목과 조건과 생성자 인자, 설정 레코드가 널을 거짓으로 접는 줄, 무인자 생성자를 부르는 곳, 두 필터가 따로 선언한 같은 헤더 이름 넷, 앞 필터의 순서 선언과 값을 쓰는 두 곳, 뒤 필터의 스테레오타입과 순서 선언 매치 수와 정화·상한·MDC 기록, 스캔 제외 정규식의 web 항목과 이 패키지의 매치 수, 앞 필터가 넣은 값을 읽는 main 참조 수, 그리고 정화가 버리는 문자 범위를 뽑은 출력 53줄. 같은 헤더 이름이 두 파일에 있고 한쪽에만 순서 선언이 있으며 앞 필터의 값을 읽는 곳이 0 이라는 것이 그 출력에 보인다." caption="앞 필터의 조립 경로와 신뢰 기본값 · 두 파일의 같은 헤더 이름 · 순서 선언 한쪽만 · 스캔 제외에 없음 · 앞 필터 값을 읽는 곳 0 — 53줄" zoom="true"
:::
앞 필터는 자동설정이 조립한다. 서블릿 웹 애플리케이션 조건이 붙어 있고, 자동설정 등록 파일에 이름이 올라가 있다.
자동설정은 설정값을 그대로 넘긴다. 그 설정 레코드의 압축 생성자가 널을 거짓으로 접으므로, 아무것도 설정하지 않은 배포는 신뢰가 꺼진 상태로 돈다. 그 자리 주석이 이유를 적는다 — 자기 요청 id 를 고를 수 있는 호출자는 서로 다른 두 요청이 하나의 신원을 공유하게 만들 수 있고, 그것이 지원 조사가 남의 교신을 읽게 되는 경로라는 것이다.
같은 기본값을 넘기는 무인자 생성자도 있지만 부르는 쪽은 테스트뿐이다.
## 같은 헤더 이름이 두 파일에 따로 있다
두 필터가 `X-Request-Id``traceparent` 를 각자의 상수로 선언한다. 서로를 참조하지 않는다.
앞 필터의 상수는 공개되어 있고 뒤 필터는 자기 사본을 비공개로 들고 있다. 가져다 쓸 수 있는 쪽이 이미 공개돼 있는데 쓰지 않는다.
## 뒤 필터는 스캔되어 들어온다
로깅 필터는 컴포넌트이고, 그 패키지는 컴포지션 루트가 스캔하는 범위 안에 있다.
컴포지션 루트의 제외 정규식은 web 아래 여섯 패키지를 이름으로 배제한다. 이 필터의 패키지는 그 여섯에 없다.
## 순서 선언은 한쪽에만 있다
앞 필터는 자기 순서를 최우선에서 열 칸 뒤로 선언한다. 뒤 필터에는 순서 선언이 하나도 없다.
순서를 선언하지 않은 필터 빈에 Spring Boot 가 매기는 기본 순서는 가장 낮은 우선순위다. 분석 문서가 뒤 필터의 순서를 그 이름으로 적는 근거가 이것이다. 그래서 뒤 필터가 나중에 돌고, 두 필터가 쓰는 응답 헤더 설정은 덮어쓰기다.
## 관측 가능한 자리에는 뒤 필터의 값만 도달한다
앞 필터는 자기 값을 요청 속성과 응답 헤더에 쓴다. 뒤 필터는 정화한 클라이언트 값을 같은 응답 헤더와 MDC 에 쓴다.
앞 필터가 요청 속성에 넣은 값을 읽는 프로덕션 코드는 없다. 접근자는 136행에 있고, 그것을 부르거나 속성 상수를 읽는 main 참조가 0 이다.
응답 헤더는 뒤 필터가 덮고, MDC 와 접근 로그는 애초에 뒤 필터만 쓴다. 응답의 `traceparent` 를 쓰는 쪽도 뒤 필터뿐이다. 밖에서 볼 수 있는 요청 식별자는 전부 클라이언트가 제안한 값이다.
## 정화가 걸러내는 범위
정화 함수는 0x20 미만 문자를 버리고 200자에서 자른다. 개행과 캐리지 리턴이 그 범위에 있으므로 javadoc 이 걱정한 줄 쪼개기는 일어나지 않는다.
전부 버리는 것은 아니다. 0x7F 와 U+0085 와 U+2028·U+2029 는 남는다. SLF4J 한 줄 로그에서는 개행이 아니지만, 줄 종결자로 그것들을 세는 파서를 로그 뒤에 두면 전제가 바뀐다.
남는 것은 신원 공유다. 한 호출자가 자기 요청 백 개에 `X-Request-Id: shared-id` 를 붙여 보내면 응답 백 개가 그 값을 돌려주고 접근 로그 백 줄과 MDC 백 건이 같은 식별자를 갖는다. 값을 정하는 쪽이 호출자이므로 남이 쓰는 식별자를 골라 자기 요청에 붙일 수도 있다 — 지원 조사가 그 id 로 검색하면 서로 다른 호출자의 교환이 함께 나온다. 앞 필터의 javadoc 이 기본을 꺼 둔 이유로 적은 그 결과다.
## 고치는 방법이 이미 코드에 있다
분석 문서의 권고는 하나를 남기는 것이다. 뒤 필터가 앞 필터의 접근자를 읽으면 된다.
## 확인하지 못한 것
두 필터가 모두 체인에 오르는지 애플리케이션을 부팅해 보지는 않았다. 순서는 앞 필터의 순서 선언과, 순서를 선언하지 않은 필터 빈에 프레임워크가 매기는 기본값으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,219 @@
---
kind: CASE
slug: a-retry-implementation-nobody-calls
title: 정식 경계라고 적은 클래스가 그것을 구현하지 않는다
topic: duplicate-mechanisms
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-retry-implementation-nobody-calls
evidenceCapturedOn: 2026-09-02
body: case-a-retry-implementation-nobody-calls.body.md
assets:
- key: a-retry-implementation-nobody-calls
file: ../../../final/evidence/rendered/a-retry-implementation-nobody-calls.svg
evidence:
- ../../../final/evidence/raw/a-retry-implementation-nobody-calls.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md 의 17번과 24번이다. 17번은 재시도 분류를 구조화된 상태로 제한하는 절이고 등급이 P1 이다. 24번은 두 트랜잭션 스택의 아키텍처 드리프트를 다루고 등급이 P2 다. 25번은 선언적 재시도 제거 뒤 남은 레지스트리를 P3 로 다룬다.
- 원본의 앞선 판정에서 틀린 것은 구현이 둘이라는 절반이 아니라 그 뒤에 딸려 있던 함의였다. 코디네이터가 조립되지 않는다는 뜻으로 읽혔지만 프로덕션 코드가 조건부 빈으로 만든다. 구현이 둘이라는 절반은 그대로 맞다.
- 이 기록이 더한 것은 셋이다. 삭제를 설명하는 자바독의 마지막 문장이 사실과 다르다는 것 — 코디네이터는 아무 인터페이스도 구현하지 않고, 그 포트를 구현하는 것은 컴포넌트로 등록된 다른 클래스다. 두 구현이 읽는 값과 게이트가 다르다는 것. 그리고 어드바이스를 얹을 경로가 없어서가 아니라 얹는 클래스가 없어서라는 것이다.
---
# 정식 경계라고 적은 클래스가 그것을 구현하지 않는다
선언적 재시도 애너테이션이 지워졌고, 그 삭제를 설명하는 문단이 남아 있다. 그 문단의 마지막 문장이 정식 경계를 구현하는 클래스로 재시도 코디네이터를 지목한다. 코디네이터는 아무 인터페이스도 구현하지 않고, 그 포트를 구현하며 실제로 도는 재시도는 같은 패키지의 다른 클래스다.
## 관계
- **재시도 단위는 statement가 아니라 유스케이스 전체다**
코디네이터가 구현하는 결정이고, 그 결정이 도는 자리는 다른 구현이다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
이 사례가 그 확인 절차를 요구한 형태다.
- **@Bean이 있다는 것은 조립 증거가 아니다**
빈은 등록되고 호출자는 0 이다.
## 문제
전체 트랜잭션 재시도 코디네이터가 조건부 빈으로 생성된다.
그것을 주입받아 호출하는 프로덕션 코드가 있는지, 없다면 실제로 도는 재시도가 무엇인지 확인했다.
## 결론
호출자는 없다. 그리고 재시도는 다른 구현으로 돈다.
먼저 삭제부터다. 선언적 재시도 애너테이션과 그 인터셉터가 지워졌는데, 사고가 아니다. 부트스트랩의 트랜잭션 팩토리 자바독에는 애너테이션이 어댑터 리프에 있어서 애플리케이션 코어가 그것을 붙이려면 바깥 어댑터를 임포트해야 하고, 그러면 이 아키텍처가 세운 의존 방향이 뒤집힌다고 적혀 있다.
같은 문단의 마지막 문장이 틀렸다. 정식 경계가 포트의 한 메서드이고 아래의 재시도 코디네이터가 그것을 구현한다고 적는데, 코디네이터의 선언은 아무 인터페이스도 구현하지 않는다.
그 포트를 구현하는 것은 같은 패키지의 다른 클래스다. 그 클래스는 컴포넌트로 등록되고, 재시도 루프도 그 안에 있다. 시도를 세고, 재시도 여부를 묻고, 백오프로 쉬고, 다시 돈다.
그 루프에는 게이트가 있다. 정책이 하나로 좁혀져 있고, 물리 소유자여야 하고, 시도 수가 최대치 미만이어야 하고, 스레드가 인터럽트되지 않아야 한다.
두 구현이 읽는 값도 다르다. 조립된 쪽의 시도 수와 지연은 설정에서 오고, 기본이 2회에 10 밀리초부터 50 밀리초까지다. 코디네이터는 부트스트랩이 상수로 넘긴 30초 경과 상한과 프로파일에서 만든 정책을 받는다.
지원 매트릭스는 전체 트랜잭션 재시도를 안정 등급으로 선언한다. 선언이 가리키는 구현과 도는 구현이 다르다.
호출자가 없다는 것은 여섯 경로를 모두 확인했다. main 에서 이 타입을 언급하는 파일은 넷이고, 셋은 만드는 쪽이며 하나는 자바독에서 이름만 부른다. 파라미터로 받는 선언도, 공급자로 꺼내는 곳도, 컨테이너에서 꺼내는 곳도, 소스 밖 설정에서 부르는 곳도 0 이다. 인터페이스를 구현하지 않으니 상위 타입을 통해 닿을 길도 없다.
지워진 인터셉터 이름을 건드린 커밋은 넷이다. 하나가 설계 문서에 이름을 넣었고, 하나가 인터셉터와 그 테스트를 더했고, 하루 뒤 909 파일을 건드린 커밋이 둘 다 지웠다. 지운 커밋의 제목에는 삭제가 드러나지 않는다.
어드바이스를 얹을 자리가 없어서가 아니다. 같은 트리의 배출기와 정리기가 이미 트랜잭션 애너테이션으로 프록시되고, 인바운드 쪽은 이 애플리케이션이 직접 쓴 어드바이저를 갖고 있다. 빠진 것은 경로가 아니라 코디네이터를 그 경로에 얹는 클래스 하나다.
판정은 P1 이다. 안정 등급으로 선언한 능력의 구현이 도달 불가이고, 그 자리에서 실제로 도는 것은 다른 값과 좁은 정책을 가진 다른 구현이다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 타입 참조 전수 확인, 포트 구현체 추적, 커밋 이력 검색
소스 수정 : x
## 재현 조건
1. 삭제를 설명하는 자바독 문단을 읽는다.
2. 그 문단이 지목한 클래스의 선언에 구현 절이 있는지 본다.
3. 그 포트를 구현한다고 선언한 클래스를 저장소 전체에서 찾는다.
4. 그 클래스가 어떻게 등록되는지, 그 안의 재시도 루프와 게이트를 읽는다.
5. 두 구현이 읽는 값을 나란히 본다.
6. main 에서 코디네이터를 언급하는 파일을 전수로 센다.
7. 파라미터·공급자·컨테이너 조회·소스 밖 참조를 각각 센다.
8. 지원 매트릭스가 이 능력을 어떤 등급으로 선언하는지 확인한다.
9. 지워진 인터셉터 이름을 경로 제한 없이 이력에서 찾는다.
10. 같은 트리에 어드바이스가 이미 도는지 확인한다.
## 본문
<!-- body:start -->
선언적 재시도 애너테이션이 지워졌고, 그 자리에 이유를 적은 문단이 남아 있다.
## 삭제는 사고가 아니었다
:::evidence key="a-retry-implementation-nobody-calls" alt="삭제를 설명하는 자바독 문단과 그 문단이 정식 경계 구현체로 지목한 클래스의 선언, 그 포트를 실제로 구현한다고 선언한 클래스와 그 등록 애너테이션, 조립된 쪽의 재시도 루프와 그 게이트, 두 구현이 읽는 값, main 에서 코디네이터를 언급하는 파일 전부와 파라미터·공급자·컨테이너 조회·소스 밖 참조의 수, 지원 매트릭스가 이 능력에 매긴 등급, 지워진 인터셉터 이름을 건드린 커밋 전부, 그리고 같은 트리에서 이미 도는 어드바이스를 출력한 터미널 기록." caption="자바독은 코디네이터가 정식 경계를 구현한다고 적지만 그 선언에는 구현 절이 없다 · 포트를 구현하는 것은 컴포넌트로 등록된 다른 클래스이고 재시도 루프도 그 클래스에 있다 · 코디네이터를 파라미터·공급자·컨테이너 조회·소스 밖에서 부르는 곳은 전부 0 · 지원 매트릭스는 이 능력을 Stable 로 선언한다 — 84줄 · exit 0" zoom="true"
:::
```text
* <p>There is no declarative retry annotation any more. {@code @RetryableJpaTransaction} lived in
* the persistence leaf and documented itself as something an application service would put on its
* own methods — which application-core cannot do without importing an outbound adapter and
* inverting the dependency this architecture is built on. The canonical boundary is {@code
* PolicyTransactionPort.inTransaction(TransactionRequest, Supplier)}; the retry coordinator below
* is what implements it, not a second way to ask for the same thing.
```
의존 방향 때문이라는 설명이다. 마지막 문장이 문제다.
```text
30:public final class FullTransactionRetryCoordinator {
```
구현 절이 없다. 이 클래스는 그 포트를 구현하지 않는다.
## 그 포트를 구현하는 쪽
```text
adapter/outbound/persistence/transaction/SpringTransactionPort.java:31:public class SpringTransactionPort implements PolicyTransactionPort {
30:@Component
```
같은 패키지에 있고 컴포넌트로 등록된다. 재시도 루프도 그 클래스 안에 있다.
```java
int attempt = 1;
while (true) {
AttemptResult<T> attemptResult = executeOnce(request, action, policy);
if (!shouldRetry(request.policyId(), attemptResult, attempt)) {
return attemptResult.result();
}
if (!retryBackoff.pauseBeforeRetry(request.callBudget(), attempt)) {
return attemptResult.result();
}
attempt++;
}
```
게이트가 좁다.
```java
if (policyId != TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE
|| !attemptResult.physicalOwner()
|| attempt >= retryBackoff.maximumAttempts()
|| Thread.currentThread().isInterrupted()) {
return false;
```
정책 하나에서만 재시도한다.
## 두 구현이 읽는 값
```text
retryBaseDelay = defaulted(retryBaseDelay, Duration.ofMillis(10), "retry-base-delay");
retryMaximumDelay = defaulted(retryMaximumDelay, Duration.ofMillis(50), "retry-maximum-delay");
retryMaximumAttempts = retryMaximumAttempts == null ? 2 : retryMaximumAttempts;
```
도는 쪽은 설정에서 읽는다. 기본 2회에 10 밀리초부터 50 밀리초다.
```text
43: public static final Duration DEFAULT_MAX_RETRY_ELAPSED = Duration.ofSeconds(30);
110: executor, policy, retrySleeper(), clock, DEFAULT_MAX_RETRY_ELAPSED, listener);
```
코디네이터는 부트스트랩이 넘긴 상수와 프로파일에서 만든 정책을 받는다. 두 값이 만난 적이 없다.
## 부르는 코드가 없다
```text
bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java:170: public FullTransactionRetryCoordinator jpaRetryCoordinator(
bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:99: public FullTransactionRetryCoordinator retryCoordinator(
bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:109: return new FullTransactionRetryCoordinator(
adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java:21: * <p>This class does not retry. Retry lives in {@link FullTransactionRetryCoordinator}, which calls
```
만드는 쪽 셋과 자바독 하나다. 받는 쪽은 이렇다.
```text
FullTransactionRetryCoordinator 를 파라미터로 받는 선언 : 0
ObjectProvider<FullTransactionRetryCoordinator> : 0
getBean 으로 이 타입을 꺼내는 곳 : 0
소스 밖에서 이 이름이 나오는 곳 : 0
```
상위 타입으로 도달할 수도 없다. 구현한 인터페이스가 없기 때문이다.
## 선언은 안정 등급이다
```text
83:| Full-transaction retry | Stable |
```
## 지워진 이름
```text
e98b56eb feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링
2f5d2fc2 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가
0e61f86e feat(jpa): implement the JPA relational persistence platform
3b5aee50 feat: 설계 문서 추가
```
`0e61f86e` 가 인터셉터와 그 테스트를 더했고, 하루 뒤 909 파일을 건드린 `2f5d2fc2` 가 둘 다 지웠다. 그 커밋의 제목은 구현체 추가다.
## 어드바이스를 쓴 사람이 없다
```text
adapter/outbound/persistence/outbox/OutboxReaper.java:38: @Transactional
6:import org.springframework.aop.Advisor;
50: static Advisor requiresPermissionAuthorizationAdvisor(
55: Pointcut onMethod = AnnotationMatchingPointcut.forMethodAnnotation(RequiresPermission.class);
```
같은 트리의 배출기가 이미 프록시되고, 인바운드 쪽에는 이 애플리케이션이 직접 쓴 어드바이저가 있다. AOP 가 없어서가 아니다. 빠진 것은 코디네이터를 어떤 메서드에 얹는 클래스 하나다.
## 확인하지 못한 것
조건은 세 겹이다. 루트 자동설정의 활성화 속성은 기본값이 없어 명시하지 않으면 꺼져 있고, 그 안에서 애드온 속성은 기본 켜짐이며, 마지막으로 빈 자체가 트랜잭션 실행기 빈과 빈 부재 조건을 요구한다. 실제 배포 컨텍스트를 부팅해 이 세 겹을 통과시키지는 않았다.
참조 계수는 이름 기반 정적 검색이므로 리플렉션으로 조립되는 경로까지는 배제하지 못했다.
<!-- body:end -->
@@ -0,0 +1,139 @@
---
kind: CASE
slug: a-weaker-private-copy-on-the-wired-path
title: 강한 가드가 웹푸시를 지목하는데 값 타입은 약한 검사를 다시 썼다
topic: duplicate-mechanisms
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-weaker-private-copy-on-the-wired-path
evidenceCapturedOn: 2026-09-02
assets:
- key: a-weaker-private-copy-on-the-wired-path
file: ../../../final/evidence/rendered/a-weaker-private-copy-on-the-wired-path.svg
- key: a-weaker-private-copy-on-the-wired-path-probe
file: ../../../final/evidence/rendered/a-weaker-private-copy-on-the-wired-path-probe.svg
evidence:
- ../../../final/evidence/raw/a-weaker-private-copy-on-the-wired-path.txt
- ../../../final/evidence/raw/a-weaker-private-copy-on-the-wired-path-probe.txt
source:
- 분석 문서는 알림 어댑터 편 §25.1 이다. private 사본의 위치, 이웃 호출처의 주석, 호출처 테스트의 웹푸시 0, 그리고 blind SSRF 로서의 실패 시나리오가 그 절에 있다. 근거가 된 프로브는 같은 편 §24.2 이고, 컴포지션 루트 편 §12.2 의 표가 이 건을 리프 내부 문제로 분류한다.
---
# 강한 가드가 웹푸시를 지목하는데 값 타입은 약한 검사를 다시 썼다
알림 모듈의 한 파일에 목적지 판정 함수가 둘 있다. 강한 쪽의 javadoc 은 자기가 웹푸시 엔드포인트와 웹훅 대상을 위해 쓰였다고 적는다. 웹푸시 값 타입은 둘 중 어느 것도 부르지 않고, 약한 쪽 논리를 자기 안에 private 으로 다시 썼다.
## 관계
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
이 사례가 그 규칙을 필요로 하는 형태다.
- **sanitize가 아니라 reject가 기본이다**
강한 쪽 함수가 따르는 규칙이다.
- **요청 식별자를 클라이언트가 고를 수 없다는 정책이 뒤에 도는 필터에 뒤집혔다**
같은 형태가 웹 어댑터에서 나타난 사례다.
## 문제
웹푸시 구독에서 엔드포인트 URI 를 정하는 쪽은 클라이언트다. 매퍼는 그 값을 그대로 POST 대상으로 쓴다.
값 타입의 컴팩트 생성자가 여덟을 검사한다. 널 아님 넷, 엔드포인트 하나, 키 길이 둘, 공백 하나다. 다섯째가 이 사례의 대상이다.
## 결론
다섯째 검사는 스킴이 https 이면 호스트를 읽지 않고 통과시킨다. 클라우드 메타데이터 주소도, RFC 1918 주소도, 링크 로컬 주소도, user:pw@ 를 단 주소도 실제로 통과한다. 값 여덟 개를 실제로 통과시켜 봤다.
같은 모듈의 NotificationEndpoints 가 그 문제를 이미 푼 함수를 갖고 있다. requireExternallyRoutable 은 userinfo 를 거절하고, 호스트가 내놓는 주소를 전부 해석해 하나라도 배포 네트워크 안쪽이면 거절한다. 그 함수의 javadoc 이 자기가 왜 있는지 적는데, 거기 웹푸시가 이름으로 적혀 있다.
값 타입은 그 함수를 부르지 않는다. 같은 파일의 약한 공개 함수도 부르지 않는다. 약한 쪽과 같은 논리를 private 메서드로 다시 썼고, 루프백 호스트 집합까지 같다. 그래서 약한 함수 이름으로 검색해도 이 호출처는 나오지 않는다.
이웃 호출처는 같은 결함을 이미 고쳤다. 웹훅 쪽 주석이 고치면서 무엇이 남았는지까지 적는다. 강한 가드는 바로 그 호출처를 위해 쓰였고 한동안 아무 데서도 불리지 않았으며, 자기 테스트는 통과하고 있었다는 것이다.
호출처 도달을 고정하려고 만든 테스트가 있는데, 그 클래스의 머리글이 대상을 웹훅과 SES 둘로 적는다. 가드 자신의 javadoc 이 적은 둘은 웹푸시와 웹훅이다. 두 목록이 어긋나 있고, 그 테스트 파일에 웹푸시를 언급하는 줄은 0 이다.
오늘 이 생성자가 도는 프로덕션 경로는 없다. 값을 만드는 main 코드는 한 곳이고 그것은 저장된 문자열을 되읽어 파싱하는 복호 경로다. 접수 유스케이스는 웹푸시 채널을 주소 문자열로 받기를 거절하고, 플랫폼 전체가 출하 기본값 false 인 스위치에 걸려 있다.
남는 것은 계약이다. 이 레코드의 표준 생성자가 이 값의 유일한 검증 지점이고, 포크가 구독 등록 경로를 붙이는 순간 검증은 이미 통과되어 있다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 두 함수의 구현 대조, 컴파일된 값 타입에 목적지 여덟 개 투입, 호출처 계수
소스 수정 : x
## 재현 조건
1. 값 타입의 컴팩트 생성자가 하는 검사를 세고, 엔드포인트 검사가 몇 번째인지 확인한다.
2. 그 검사의 구현과 같은 모듈의 공개 함수 둘을 나란히 읽는다.
3. 값 타입이 그 둘 중 하나라도 부르는지 센다.
4. 컴파일된 값 타입에 내부망 주소와 메타데이터 주소를 직접 넣어 결과를 본다.
5. 강한 함수의 프로덕션 호출처와, 호출처 도달을 고정하는 테스트의 대상 목록을 비교한다.
## 본문
<!-- body:start -->
웹푸시 구독의 엔드포인트 URI 는 클라이언트가 정한다. 매퍼가 그것을 그대로 POST 대상으로 삼는다.
## 생성자가 여덟을 검사하고, 다섯째가 엔드포인트다
:::evidence key="a-weaker-private-copy-on-the-wired-path" alt="웹푸시 구독 값 타입의 컴팩트 생성자 전문과 그 다섯째 검사의 private 구현, 같은 모듈에 있는 공개 함수 둘의 구현, 그 강한 함수의 프로덕션 호출처 목록과 웹푸시 포함 여부, 그리고 호출처 도달을 고정하는 테스트의 머리글과 대상 목록을 차례로 출력한 터미널 기록." caption="생성자의 검사 여덟과 다섯째의 private 구현 · 같은 모듈의 공개 함수 둘 · 강한 함수의 호출처 둘에 웹푸시 없음 · 호출처 테스트의 웹푸시 언급 0 — 102줄 · exit 1" zoom="true"
:::
널 아님 넷을 확인하고, 다섯째로 엔드포인트가 안전하거나 루프백인지 본다. 그 뒤로 키 길이 둘과 VAPID 키 id 공백 여부가 더 온다.
다섯째 검사의 구현은 같은 파일의 private 메서드다. 스킴이 https 면 참을 돌려주고 끝난다. 호스트를 읽는 것은 http 인 경우뿐이고, 그때 루프백 셋 중 하나인지 본다.
## 그 검사는 자기 목적에 대해서는 옳다
메서드 javadoc 에는 다루는 위협이 경로상의 도청이고 루프백 엔드포인트에는 그 경로가 없다고 적혀 있다. 예외를 루프백으로 좁힌 것은 계약 시험이 진짜 소켓을 쓸 수 있게 하려는 것이다.
전송 보안 규칙으로서 이 판단은 유지된다. 다만 같은 모듈의 이웃 파일이 그 논리로 남은 결과를 주석에 적어 뒀고, 그 내용은 뒤에서 본다.
## 같은 모듈에 그 문제를 이미 푼 함수가 있다
`NotificationEndpoints` 가 목적지 판정 함수를 둘 갖는다. 약한 쪽 `requireSecureOrLoopback` 은 스킴을 보고 루프백 호스트 이름을 본다. 강한 쪽 `requireExternallyRoutable` 은 userinfo 를 거절하고, 호스트가 내놓는 주소를 전부 해석해 하나라도 루프백·링크 로컬·사설·멀티캐스트이거나 캐리어 NAT 대역이면 거절한다.
강한 쪽 javadoc 이 자기가 왜 있는지 적는다.
```text
Web Push endpoints and webhook targets are supplied by clients, which makes this
a server-side request forgery primitive
```
웹푸시가 이름으로 적혀 있다. 그런데 그 함수의 프로덕션 호출처는 둘이고, SES 엔드포인트와 웹훅 대상이다. 웹푸시는 없다.
값 타입은 두 공개 함수 중 어느 것도 부르지 않는다. 약한 쪽과 같은 논리를 private 으로 다시 썼고, 루프백 호스트 집합 `127.0.0.1`, `::1`, `localhost` 까지 같다. 약한 함수 이름으로 검색하면 이 호출처는 드러나지 않는다.
호출처 도달을 고정하려고 만든 테스트도 있다. 그 클래스의 머리글이 대상을 웹훅 대상과 SES 엔드포인트 둘로 적는다. 가드 javadoc 이 적은 둘과 한 자리가 다르고, 그 파일에 웹푸시를 언급하는 줄은 0 이다.
## 내부망 주소를 넣으면 실제로 통과한다
:::evidence key="a-weaker-private-copy-on-the-wired-path-probe" alt="컴파일된 값 타입의 생성자에 목적지 여덟 개를 직접 넣어 통과와 거절을 출력한 결과, 이웃 호출처가 같은 결함을 고치며 남긴 주석, 그 엔드포인트가 POST 대상이 되는 지점, 값을 만드는 유일한 main 코드와 그 코드가 있는 복호 경로, 접수 유스케이스의 채널 거절, 그리고 플랫폼 마스터 스위치의 출하 기본값을 출력한 터미널 기록." caption="목적지 여덟 개 투입 결과 · 이웃 호출처의 주석 · POST 대상 지점 · 값 생성은 복호 경로 한 곳 · 마스터 스위치 기본값 false — 49줄 · exit 0" zoom="true"
:::
컴파일된 값 타입에 여덟 개를 넣었다. https 인 여섯은 전부 통과한다. 클라우드 메타데이터 주소 둘, 사설 대역, 사설 IPv6, 그리고 `user:pw@` 를 단 주소까지 지난다. 거절된 둘은 http 이고, 메시지는 루프백 밖에서는 https 여야 한다는 것이다.
이웃 호출처의 주석이 같은 결함을 고치며 무엇이 남았는지 적는다.
```text
The stronger guard was written for exactly this call site and then called from
nowhere: it existed, its own tests were green, and the two sites it was written
for kept the weaker check.
```
두 클라이언트 제공 표면 중 하나가 연결됐고, 다른 하나는 약한 검사의 사본을 그대로 들고 있다.
## 오늘 이 생성자가 도는 경로는 없다
값을 만드는 main 코드는 한 곳이고, 저장 경로가 아니라 저장된 것을 되읽는 경로다. 보호자의 `reveal` 이 저장 문자열을 파싱해 값을 되돌린다. 접수 유스케이스는 웹푸시 채널을 주소 문자열로 받기를 거절하고, 등록된 연락처를 가리키라고 말한다. 플랫폼 전체도 출하 기본값이 false 인 스위치에 걸려 있다.
그래서 지금 성립하는 것은 계약이다. 이 레코드의 표준 생성자가 이 값의 유일한 검증 지점이고, 포크가 구독 등록 엔드포인트를 붙이는 순간 — 그것이 이 모듈의 존재 이유다 — 검증은 이미 통과되어 있다.
고칠 자리는 두 모듈 사이가 아니라 값 타입의 생성자 안이다. 컴포지션 루트 편의 표가 이 건에 배선 지점이 없다고 따로 적어 둔다.
## 확인하지 못한 것
포크가 붙일 구독 등록 경로의 실제 형태는 이 저장소에 없다. 그 경로에서 클라이언트 입력이 어떤 모양으로 도착하는지는 추정이다.
<!-- body:end -->
@@ -0,0 +1,97 @@
---
kind: CASE
slug: jpa-platform-capabilities-have-no-consumer
title: JPA 플랫폼 capability 대부분에 production 소비자가 없다
topic: duplicate-mechanisms
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:jpa-platform-capabilities-have-no-consumer
evidenceCapturedOn: 2026-09-01
assets:
- key: jpa-platform-capabilities-have-no-consumer
file: ../../../final/evidence/rendered/jpa-platform-capabilities-have-no-consumer.svg
evidence:
- ../../../final/evidence/raw/jpa-platform-capabilities-have-no-consumer.txt
source:
- 원본 분석 절은 final/document.md#8-2 항목 8 · analysis/05 §17 P8, §11.0 이다.
---
# JPA 플랫폼 capability 대부분에 production 소비자가 없다
JPA 리프의 능력별 패키지를 리프 밖 프로덕션 코드가 얼마나 참조하는지 세면 대부분이 0 이다. 코드가 없는 것이 아니라 그것을 쓰는 애플리케이션 코드가 없다.
## 관계
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
이 사례가 그 규칙과 같은 계열이다.
- **Bean 애너테이션이 있다는 것은 조립 증거가 아니다**
같은 계열의 확인 규칙이다.
- **재시도 코디네이터는 빈이지만 그것을 어디에도 적용하지 않는다**
같은 리프에서 나타난 더 좁은 사례다.
## 문제
이 리프는 JPA 플랫폼의 여러 능력을 패키지 단위로 담는다. 스프링 데이터 통합, 하이버네이트 정책, 캐시, 감사 이력, 쿼리 DSL 같은 것들이다.
능력 리포트는 이것들을 지원 등급과 함께 보고한다. 그러면 그 능력을 실제로 쓰는 코드가 있는가.
## 결론
패키지별로 리프 밖 프로덕션 참조 파일 수를 세면 이렇다.
springdata : main 파일 17, 리프 밖 참조 0
hibernate : main 파일 22, 리프 밖 참조 2
cache : main 파일 5, 리프 밖 참조 0
envers : main 파일 6, 리프 밖 참조 0
querydsl : main 파일 3, 리프 밖 참조 0
다섯 중 넷이 0 이다. 하이버네이트 패키지만 둘이다.
이 수치의 의미를 정확히 두어야 한다. 리프 안에서 서로 쓰는 것은 셈에서 빠져 있고, 자동설정이 빈으로 만드는 것도 별개다. 여기서 센 것은 이 리프의 능력 패키지를 리프 밖의 프로덕션 코드가 타입으로 참조하는가다.
그 답이 0 이라는 것은 애플리케이션 계층이 그 능력을 이름으로 부르지 않는다는 뜻이다. 스프링 데이터처럼 리포지터리 인터페이스로 간접 사용되는 경우도 있으므로, 0 이 곧 미사용을 뜻하지는 않는다. 다만 능력 리포트가 지원 등급으로 보고하는 것과 애플리케이션이 그것에 의존하는 것 사이에 거리가 있다는 것은 분명하다.
이 리프에서 같은 형태가 더 좁게 나타난 사례가 재시도 코디네이터다. 그쪽은 빈까지 만들어지고 소비자가 0 이다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 패키지 이름 기준 리프 밖 main 참조 파일 수 계수
소스 수정 : x
## 재현 조건
1. persistence-jpa 의 각 능력 패키지에서 main 파일 수를 센다.
2. 각 패키지의 정규화된 패키지 이름을 리프 밖 main 소스에서 검색해 참조 파일 수를 센다.
3. 두 수를 나란히 놓는다.
## 본문
<!-- body:start -->
`JpaKeysetQuerySupport`·`SafeSortMapper`·`JpaStreamExecutor`·`FetchPlanApplier`·batch/bulk/stateless executor·업서트·work claim·JSON/range/array·COPY·`CacheRegionCatalog`·`EntityGraphCatalog`가 전부 leaf 밖 참조 0이다.
## JpaKeysetQuerySupport 참조 위치
:::evidence key="jpa-platform-capabilities-have-no-consumer" alt="코드베이스에서 JpaKeysetQuerySupport 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="JpaKeysetQuerySupport 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 결정적 증거는 같은 leaf 안의 두 스토어다
`fileserver`(25파일)와 `notification`(53파일)이 합쳐 350KB이고 매일 PostgreSQL에 쓰는데 JPA 플랫폼 타입을 하나도 import하지 않고, 같은 문제(SKIP LOCKED 큐 클레임·안전 정렬·충돌 판정)를 각자 다시 만들었다. 즉 "아직 소비자가 없어서"가 아니라 **소비자가 있는데도 안 쓴다.**
## 거짓말은 아니지만 겹치면 다른 문장이 된다
`support-matrix.md`가 대부분을 Advanced로 표기한다. 다만 이 조합은 §P2(계약 테스트가 production 타입을 실행하지 않음)와 겹쳐 "구현됐고, 안 쓰이고, 테스트도 없다"가 된다.
## 확인하지 못한 것
간접 사용을 세지 않았다. 스프링 데이터 리포지터리처럼 인터페이스 상속과 프록시로 쓰이는 경우는 타입 참조로 나타나지 않는다.
각 능력이 자동설정으로 빈이 되는지 개별로 확인하지 않았다. 이 기록은 애플리케이션 계층의 참조에 대한 것이다.
파생 프로젝트가 실제로 이 capability들을 가져다 쓰는지 확인할 방법이 이 저장소 안에 없다 — 그것이 세 선택지(채택 / 명시적 라이브러리 선언 / 제거) 중 무엇이 맞는지를 정한다
<!-- body:end -->
@@ -0,0 +1,96 @@
---
kind: CASE
slug: the-same-repository-bound-a-decision-once-and-not-the-other-time
title: 같은 저장소가 "결정을 그 결정이 판정한 대상에 묶는 것"을 한 번은 맞게, 한 번은 틀리게 썼다
topic: duplicate-mechanisms
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:the-same-repository-bound-a-decision-once-and-not-the-other-time
evidenceCapturedOn: 2026-09-01
assets:
- key: the-same-repository-bound-a-decision-once-and-not-the-other-time
file: ../../../final/evidence/rendered/the-same-repository-bound-a-decision-once-and-not-the-other-time.svg
evidence:
- ../../../final/evidence/raw/the-same-repository-bound-a-decision-once-and-not-the-other-time.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-codegen.md §17.4 · analysis/grpc/grpc-advanced-bootstrap.md(확인된 설계) 이다.
---
# 같은 저장소가 "결정을 그 결정이 판정한 대상에 묶는 것"을 한 번은 맞게, 한 번은 틀리게 썼다
두 리프가 판정과 기록을 두 호출로 나눈다. 한쪽은 결정이 자기가 밟고 선 상태를 들고 있어 적용 시점에 대조하고, 다른 쪽은 결정이 무엇을 판정했는지 들고 있지 않아 짝이 어긋날 수 있다.
## 관계
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
같은 형태의 장치가 둘일 때 쓰는 규칙이다.
- **같은 자격 증명 회전 결함이 한 가족에서 닫히고 다른 가족에서 재현됐다**
같은 저장소가 같은 형태를 두 번 다르게 쓴 다른 사례다.
- **검증기는 발행이 아니라 주입이 강제다**
판정이 실제로 걸리는 지점을 묻는 계열의 규칙이다.
## 문제
판정과 기록을 나누면 그 사이를 무엇이 묶는지가 문제가 된다.
스키마 산출물 발행자는 후보와 소비자 픽스처 목록을 받아 발행 가능 여부를 결정으로 돌려준다. 그리고 그 결정을 들고 다시 오면 이력에 기록한다. 발행 결정은 허용 여부와 차단 사유 목록으로 되어 있다.
승격 지원 매트릭스도 같은 형태다. 승격 게이트가 증거와 시작 등급과 목표 등급을 받아 결정을 돌려주고, 매트릭스가 그 결정을 받아 적용한다.
## 결론
한쪽만 짝을 확인한다.
승격 매트릭스는 결정의 시작 등급이 현재 등급과 다르면 던진다. 예외 메시지가 그 이유를 적는다. 두 승격이 경합했거나 하나가 재생된 경우라는 것이다. 결정이 자기가 밟고 선 상태를 들고 있고 적용 시점에 그것을 대조한다.
스키마 발행자는 결정이 허용인지만 본다. 그 결정이 지금 발행하려는 후보를 판정한 것인지 확인하지 않는다. 발행 결정 객체가 허용 여부와 차단 사유만 갖고 있어서 확인할 재료도 없다.
그래서 무해한 후보를 평가한 결정으로 다른 후보를 발행할 수 있다. 그 후보는 어떤 소비자 픽스처와도 대조되지 않고, 이미 발행된 버전인지도 확인되지 않은 채 이력에 들어간다. 이 클래스가 존재하는 이유인 두 규칙, 즉 소비자 컴파일 게이트와 릴리스 버전 불변성이 인자 짝 하나로 무력해진다.
테스트의 차이도 같다. 발행자 테스트는 평가와 발행을 한 줄에 겹쳐 써서 규율을 지킨다. 그 규율을 코드가 강제하지 않는다. 매트릭스 쪽에는 어긋난 짝을 넣어 거부를 확인하는 테스트가 따로 있다.
이 저장소는 올바른 형태를 알고 있고 한 곳에서 쓰지 않았다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 두 적용 메서드의 본문 대조와 결정 객체의 성분 확인
소스 수정 : x
## 재현 조건
1. 스키마 발행자의 평가 메서드와 발행 메서드가 각각 무엇을 검사하는지 적는다.
2. 발행 결정 객체의 성분을 나열한다.
3. 승격 매트릭스의 적용 메서드가 결정의 어떤 성분을 현재 상태와 대조하는지 확인한다.
4. 두 테스트가 각각 짝을 어떻게 맞추는지 확인한다.
## 본문
<!-- body:start -->
두 리프가 같은 문제를 푼다 — 판정과 기록이 두 호출로 나뉠 때 그 사이를 무엇이 묶는가.
## PublishDecision 참조 위치
:::evidence key="the-same-repository-bound-a-decision-once-and-not-the-other-time" alt="코드베이스에서 PublishDecision 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="PublishDecision 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 묶지 않은 쪽
`GrpcSchemaArtifactPublisher.publish(candidate, decision)``decision.allowed()` 만 보고 기록한다. `PublishDecision``(boolean, List<String>)` 뿐이라 자기가 무엇을 판정했는지 들고 있지 않으므로, A 를 평가한 결정으로 B 를 발행할 수 있고 그러면 소비자 컴파일 게이트와 릴리스 버전 불변성을 둘 다 우회한다. 이 클래스가 존재하는 이유인 두 규칙이 인자 짝 하나로 무력해진다.
## 묶은 쪽
`GrpcAdvancedSupportMatrix.apply(decision)` 는 결정의 `from` 이 현재 등급과 다르면 던지고, 그 이유를 "두 승격이 경합했거나 하나가 재생된 경우" 라고 적는다. 결정이 자기가 밟고 선 상태를 들고 있고 적용 시점에 대조하는 형태다.
## 두 테스트의 차이도 같다
전자의 테스트는 `publish(artifact, evaluate(artifact, …))` 로 한 줄에서 짝을 맞춰 규율을 지키지만 코드가 그것을 강제하지 않고, 후자는 어긋난 짝을 넣는 테스트가 따로 있다.
## 확인하지 못한 것
어긋난 짝을 실제로 실행해 보지 않았다. 판정은 발행 메서드 본문에 대조 코드가 없다는 것에 근거한다.
<!-- body:end -->
@@ -0,0 +1,106 @@
---
kind: CASE
slug: trust-policy-lives-in-nginx-not-in-the-code
title: forwarded 헤더 신뢰 판정이 Nginx에만 있고 Java 정책 421 LOC은 배선되지 않았다
topic: duplicate-mechanisms
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:trust-policy-lives-in-nginx-not-in-the-code
evidenceCapturedOn: 2026-09-01
assets:
- key: trust-policy-lives-in-nginx-not-in-the-code
file: ../../../final/evidence/rendered/trust-policy-lives-in-nginx-not-in-the-code.svg
evidence:
- ../../../final/evidence/raw/trust-policy-lives-in-nginx-not-in-the-code.txt
source:
- 원본 분석 절은 final/document.md#3-1 · analysis/14 §32.2 이다.
---
# forwarded 헤더 신뢰 판정이 Nginx에만 있고 Java 정책 421 LOC은 배선되지 않았다
웹 리프의 프록시 패키지는 421 줄로 신뢰 프록시 정책과 헤더 정화기와 정규화 타입을 갖는다. 실제 신뢰 판정은 Nginx 설정이 하고, 그 설정은 들어온 forwarded 헤더를 원격 주소로 교체한다.
## 관계
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
이 사례가 그 규칙의 인프라 판이다.
- **요청 식별자를 클라이언트가 고를 수 없다는 정책이 다른 필터에서 뒤집힌다**
같은 리프에서 같은 계열의 사례다.
## 문제
웹 리프의 프록시 패키지는 네 파일로 421 줄이다.
TrustedProxyPolicy 161 줄
NormalizedForwardedHeaders 158 줄
ForwardedHeaderSanitizer 72 줄
UntrustedForwardedHeaderException 30 줄
이 코드가 하는 일은 어떤 프록시를 신뢰할지 정하고 forwarded 헤더를 정규화하는 것이다.
문제는 이것이 실제 판정 경로인가다.
## 결론
Nginx 설정이 그 판정을 대신한다.
프록시 헤더 설정 파일의 주석이 자기 지위를 명시한다. 이것이 권위 있는 forwarded 헤더이며 모든 location 에서 include 된다는 것이다.
설정 내용은 교체다.
Host 를 host 변수로 설정
X-Real-IP 를 remote_addr 로 설정
X-Forwarded-For 를 remote_addr 로 설정
X-Forwarded-Host 를 이 배포의 공개 이름으로 설정
주석이 모든 줄이 SET 이고 ADD 가 아니라고 못 박는다. 클라이언트가 보낸 X-Forwarded-For 는 remote_addr 로 교체되고 X-Forwarded-Host 는 이 배포의 공개 이름으로 교체된다.
즉 애플리케이션에 도달하는 시점에 그 헤더들은 이미 신뢰할 수 있는 값이다. Java 정책이 판정할 것이 남아 있지 않다.
Java 쪽 참조 수도 그것과 맞는다.
ForwardedHeaderSanitizer : main 참조 0
TrustedProxyPolicy : main 참조 1, test 참조 2
UntrustedForwardedHeaderException : main 참조 1, test 참조 1
NormalizedForwardedHeaders : main 참조 2
같은 설정 파일의 주석이 왜 include 방식인지도 적는다. Nginx 의 배열 지시어 상속 규칙이 병합이 아니라 교체이기 때문이다. location 안의 proxy_set_header 하나가 server 수준에서 상속된 모든 proxy_set_header 를 버린다. 보안 헤더를 server 수준에 두고 location 마다 하나씩 추가하는 설정은 보안 헤더를 하나도 보내지 않으며, 유일한 증상은 애플리케이션이 조용히 클라이언트를 다시 신뢰하는 것이다.
그 주석이 이 사례의 위험을 정확히 서술한다. 신뢰 판정이 인프라에 있으면 인프라 설정 실수가 애플리케이션의 신뢰 정책을 조용히 되돌린다. 그리고 그때 되돌아갈 Java 정책은 배선되어 있지 않다.
## 검증 환경
Nginx 설정 : 웹 리프의 nginxProxyTest 소스셋 아래 proxy_headers.conf
확인 방식 : 파일 LOC 계수와 타입별 참조 계수, 설정 파일 대조
소스 수정 : x
## 재현 조건
1. 웹 리프의 proxy 패키지 파일과 줄 수를 센다. 네 파일 421 줄이다.
2. 각 타입의 main 참조와 test 참조를 센다.
3. Nginx 프록시 헤더 설정을 읽는다. 모든 지시어가 SET 이다.
4. 그 설정의 주석에서 include 방식을 택한 이유를 읽는다.
## 본문
<!-- body:start -->
forwarded 헤더를 어디까지 믿을지 판정하는 Java 정책이 421 LOC 작성돼 있고 배선되지 않는다. 실제 판정은 Nginx 설정이 한다.
## 판정을 실제로 하는 곳
:::evidence key="trust-policy-lives-in-nginx-not-in-the-code" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 리뷰가 닿지 않는 자리로 정책이 옮겨졌다
두 곳이 어긋나면 코드 리뷰가 잡을 수 없고, Java 쪽을 고쳐도 동작이 바뀌지 않는다.
## 확인하지 못한 것
이 Nginx 설정이 실제 배포에서 쓰이는 설정과 같은지 확인하지 않았다. 확인한 파일은 웹 리프의 테스트 소스셋 아래에 있다. 별도로 인프라 디렉터리에 파일서버용 Nginx 설정이 있고 그것은 다른 파일이다.
Java 정책이 어떤 경로에서 호출되는지 그 한 건씩의 참조를 추적하지 않았다.
<!-- body:end -->
@@ -0,0 +1,54 @@
---
kind: PROJECT_DECISION
slug: one-audit-mechanism-per-entity
title: 감사 메커니즘은 엔티티당 정확히 하나여야 한다
topic: duplicate-mechanisms
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: decision:one-audit-mechanism-per-entity
decisionStatus: ADOPTED
decidedOn: 2026-08-30
source:
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/audit
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers
- analysis/05-adapter-outbound-persistence-jpa.md
---
# 감사 메커니즘은 엔티티당 정확히 하나여야 한다
## 결정문
한 엔티티의 감사 기록은 하나의 메커니즘이 소유하고, 둘 이상이 같은 엔티티를 기록하지 않는다.
## 판단 이유
감사 메커니즘이 둘이면 같은 변경이 두 번 기록되거나, 두 기록이 서로 다른 내용을 담거나, 둘 중 하나만 도는데 어느 쪽인지 알 수 없게 된다.
세 결과 모두 감사의 목적을 무너뜨린다. 감사 기록은 나중에 사람이 판단의 근거로 쓰는 것이고, 근거가 둘이면 판단이 서지 않는다.
그리고 감사는 조용히 실패하는 계열이다. 기록이 남지 않아도 업무 트랜잭션은 성공하므로, 두 메커니즘 중 하나가 꺼져 있어도 증상이 없다.
그래서 소유권을 엔티티 단위로 정한다. 어떤 엔티티가 어떤 메커니즘의 소유인지가 한 곳에서 결정되고, 그 결정이 코드로 확인 가능해야 한다.
## 영향
감수하는 것
메커니즘마다 다른 능력을 갖는데 엔티티는 하나만 고를 수 있다. 이력 조회가 필요한 엔티티와 변경 시각만 필요한 엔티티가 같은 선택지를 공유하지 않는다.
메커니즘을 바꾸면 그 엔티티의 과거 기록과 새 기록이 다른 형태가 된다.
얻는 것
한 변경에 대한 감사 기록이 정확히 하나다.
메커니즘 하나가 배선되지 않았을 때 그 엔티티의 기록이 통째로 비므로, 부분적으로만 기록되는 상태보다 발견하기 쉽다.
## 근거
- **같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다**
이 결정이 속한 계열의 규칙이다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
중복이 이미 있을 때의 확인 절차다.
@@ -0,0 +1,56 @@
---
kind: REFERENCE
slug: atomic-type-is-not-atomicity
title: Atomic* 타입의 존재는 원자성의 증거가 아니다
topic: duplicate-mechanisms
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:atomic-type-is-not-atomicity
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# Atomic* 타입의 존재는 원자성의 증거가 아니다
## 목적
Atomic 계열 타입이 쓰였다는 것을 그 연산이 원자적이라는 증거로 읽어, 여러 연산에 걸친 경합을 놓치는 것을 막는다.
## 규칙
1. 원자성의 단위는 타입이 아니라 연산 묶음이다
각각의 읽기와 쓰기가 원자적이어도 둘을 잇는 판단은 원자적이지 않다.
2. 검사 후 행동 형태를 찾는다
값을 읽고 그 값으로 분기해 다시 쓰는 코드는 Atomic 타입을 써도 경합한다. compareAndSet 이나 accumulate 계열이 필요하다.
3. 여러 필드에 걸친 불변식은 Atomic 타입으로 표현되지 않는다
두 값이 함께 바뀌어야 하면 락이나 단일 값으로의 병합이 필요하다.
4. 컬렉션은 원소가 아니라 구조를 본다
원소가 Atomic 이어도 목록 자체의 추가와 순회는 동기화되지 않는다.
5. 단일 스레드 가정이면 그 가정을 적는다
가정이 적혀 있지 않으면 다음 사람이 그 클래스를 다중 스레드 경로에 놓는다.
## 적용 조건
동시 접근이 가능한 모든 상태
인터셉터나 필터처럼 요청마다 호출되는 컴포넌트의 필드
## 예외
단일 카운터의 증감처럼 연산 하나로 끝나는 경우는 Atomic 타입으로 충분하다.
## 예시
동기화 없이 ArrayList 에 추가하고 조회하는 클래스가 있고, 단일 스레드 사용 가정으로 보이지만 그 가정이 서술되어 있지 않다. 그 클래스가 서버 인터셉터 경로에 쓰이면 다중 스레드가 된다.
## 관계
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
같은 계열의 확인 규칙이다.
- **커밋 증거 프레임을 두 주인이 pop해서 바깥 트랜잭션의 실패가 익명이 됐다**
소유권과 동시성이 함께 얽힌 사례다.
@@ -0,0 +1,66 @@
---
kind: REFERENCE
slug: check-which-duplicate-is-wired
title: 중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다
topic: duplicate-mechanisms
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:check-which-duplicate-is-wired
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다
## 목적
같은 일을 하는 구현이 둘 있을 때 정교한 쪽을 읽고 판정해서, 실제로 도는 약한 쪽을 놓치는 것을 막는다.
## 규칙
1. 조립된 쪽을 먼저 찾는다
어느 쪽이 스테레오타입을 갖는지, 어느 쪽이 빈으로 만들어지는지, 어느 쪽이 설정에서 지목되는지를 본다.
2. 만들어지는 것과 호출되는 것을 나눠 센다
빈이 있다는 것과 그것을 주입받아 부르는 코드가 있다는 것은 다르다. 세 단계로 나눈다. 만들어지는가, 주입되는가, 호출되는가.
3. 약한 쪽이 도는 경우가 흔하다
정교한 구현은 늦게 만들어지고 배선이 뒤따르지 않는 경우가 많다. 그동안 원래 있던 사본이 계속 돈다.
4. 인프라 설정도 중복의 한쪽이다
같은 판정이 애플리케이션 코드와 프록시 설정에 각각 있으면, 도는 것은 대개 프록시 쪽이다.
5. 정본을 정하는 것이 수정이다
둘 다 살려 두면 다음 사람이 어느 쪽을 고쳐야 하는지 모른다.
## 적용 조건
같은 개념의 구현이 둘 이상 발견되는 모든 경우
플랫폼과 애플리케이션이 한 저장소에 함께 있는 경우 특히
## 예외
의도적으로 계층별로 다른 강도를 두는 경우가 있다. 그때는 어느 계층이 무엇을 보장하는지가 문서에 있어야 하고, 약한 쪽이 강한 쪽을 우회하는 경로가 없어야 한다.
## 예시
전체 트랜잭션 재시도 코디네이터는 빈으로 만들어지고 주입받아 호출하는 코드가 0 이다.
웹푸시 구독 값은 클라이언트가 준 엔드포인트를 자기 private 검사로만 확인하고, 플랫폼의 목적지 정책을 지나지 않는다.
forwarded 헤더 신뢰 판정은 421 줄의 Java 정책과 Nginx 설정 양쪽에 있고, 도는 것은 Nginx 쪽이다.
outbox 는 두 스택으로 구현되어 있고 출하되는 것은 application-core 쪽이다.
## 관계
- **재시도 코디네이터는 빈이지만 그것을 어디에도 적용하지 않는다**
두 번째 규칙이 필요한 사례다.
- **클라이언트가 준 엔드포인트가 SSRF 가드가 아니라 약한 private 사본을 지났다**
세 번째 규칙의 사례다.
- **forwarded 헤더 신뢰 판정이 Nginx에 있고 Java 정책 421 LOC은 대부분 참조되지 않는다**
네 번째 규칙의 사례다.
- **조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다**
같은 계열의 상위 규칙이다.
@@ -0,0 +1,55 @@
---
kind: REFERENCE
slug: two-vocabularies-for-one-concept
title: 같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다
topic: duplicate-mechanisms
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:two-vocabularies-for-one-concept
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다
## 목적
같은 개념을 가리키는 이름이 둘 남아 있어, 다음 사람이 어느 쪽이 정본인지 모른 채 둘 다 유지하거나 잘못된 쪽을 고치는 것을 막는다.
## 규칙
1. 정본을 명시한다
두 어휘 중 어느 쪽이 앞으로 쓰일 이름인지 코드나 문서에 적는다.
2. 죽은 쪽에 표시를 남긴다
지울 수 없다면 그것이 유지되지 않는 이름이라는 것과 언제 지울 수 있는지를 적는다.
3. 표시가 없으면 둘 다 살아 있는 것으로 읽힌다
이름이 남아 있는 것 자체가 의도로 읽힌다.
4. 이름이 다른 계층에 걸쳐 있으면 매핑을 한 곳에 둔다
변환이 여러 곳에 흩어지면 그중 하나가 뒤처진다.
## 적용 조건
리팩터링이나 계층 재배치 뒤 남은 옛 이름
같은 도메인 개념을 어댑터와 애플리케이션이 다르게 부르는 경우
## 예외
외부 계약이 옛 이름을 요구하면 그 이름은 죽은 것이 아니라 경계 어휘다. 그 사실이 경계 지점에 적혀 있어야 한다.
## 예시
이 저장소에는 미완성 상태에 이름을 붙이고 기동에서 거절하며 그 이름이 언제 목록에서 빠지는지까지 적은 선례가 있다. 그 항목은 자기 전송이 존재하는 날 이 맵을 떠난다고 적혀 있다.
반대로 구현 계획에만 남은 클래스 이름이 ADR 의 강제 절에 그대로 인용되어, 그 절을 읽으면 존재하지 않는 것이 강제하고 있다고 읽힌다.
## 관계
- **재시도 코디네이터는 빈이지만 그것을 어디에도 적용하지 않는다**
문서에만 남은 이름이 강제 수단으로 인용된 사례다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
같은 계열의 확인 규칙이다.