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:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
+125
@@ -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 -->
|
||||
+219
@@ -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 -->
|
||||
+139
@@ -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 -->
|
||||
+97
@@ -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 -->
|
||||
+96
@@ -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 -->
|
||||
+106
@@ -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 -->
|
||||
Reference in New Issue
Block a user