refactor: 문서 개선 중

This commit is contained in:
donghyeon-ka
2026-09-21 14:30:55 +09:00
parent c93cdea150
commit 805a18f486
1497 changed files with 525837 additions and 59152 deletions
@@ -0,0 +1,70 @@
---
kind: CASE
slug: a-boundary-that-leaks-only-under-load
title: 부하 아래에서 지키라고 만든 경계가 부하 아래에서만 샌다
topic: duplicate-mechanisms
topicName: 중복 장치 — 조립된 쪽이 약한 쪽일 때
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-boundary-that-leaks-only-under-load
source:
- final/document.md#8-2
- final/document.md#a20
- final/document.md#5-5
- final/document.md#8-2 항목 6
- final/document.md#a20 §7
---
# 부하 아래에서 지키라고 만든 경계가 부하 아래에서만 샌다
같은 가족의 구현 중 하나가 제한값 검사와 증가를 분리해서 수행하고, 다른 구현은 CAS loop로 두 동작을 하나의 원자적 갱신으로 묶는다. 정적 코드 비교로 경계 차이는 확인했지만 실제 경쟁 부하는 재현하지 않았다.
## 관계
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
같은 목적의 구현이 둘일 때 실제 호출 경로가 어느 쪽인지 확인하는 기준이다.
- **CAS tuple과 update count**
경쟁 상태에서 읽기와 쓰기를 분리하지 않는 상태 전이 규칙을 설명한다.
## 문제
제한값을 읽어 “아직 여유가 있다”고 확인한 뒤 별도 연산으로 값을 증가시키면 두 worker가 같은 이전 값을 동시에 읽을 수 있다. 각 worker는 개별적으로는 검사를 통과하지만 합산 결과는 경계를 넘을 수 있다.
같은 가족에는 CAS loop로 읽은 revision과 기대 값을 WHERE 조건에 포함해 한 worker만 갱신하도록 만든 구현이 있다.
## 결론
이 경계는 단일 worker 테스트만으로는 충분히 검증되지 않는다. 제한 확인과 증가가 하나의 원자적 상태 전이가 아니면 경쟁 부하에서만 초과가 나타날 수 있다.
현재 판정은 두 구현의 코드 구조 비교다. 실제 concurrent load를 걸어 초과를 재현하지 않았으므로 발생 빈도나 임계 동시성은 주장하지 않는다.
## 검증 환경
sourceRevision : 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
현재 source repository 재대조 : UNVERIFIABLE
확인 방식 : 동일 가족 구현의 정적 비교
## 재현 조건
1. 제한 확인과 증가가 분리된 구현의 read/write 순서를 확인한다.
2. 같은 가족의 CAS 구현이 기대 값과 revision을 갱신 조건에 포함하는지 확인한다.
3. source repository가 있는 환경에서는 두 worker 이상으로 같은 경계를 동시에 갱신해 실제 초과 여부를 측정한다.
## 본문
<!-- body:start -->
## 단일 요청에서 보이지 않는 이유
worker 하나만 실행하면 “읽기 → 검사 → 증가” 사이에 다른 쓰기가 끼어들지 않는다. 따라서 기능 테스트는 정상 범위만 관측할 수 있다.
## CAS 구현과 비교한다
대조 구현은 현재 값을 읽은 뒤 기대 revision을 포함한 갱신을 시도한다. 경쟁자가 먼저 값을 바꾸면 update count가 0이 되고 다시 읽어 판정한다. 이 차이가 두 구현의 concurrency 보장 차이다.
## 확인하지 못한 것
실제 부하에서 경계를 넘기는 실행은 이번 검토에서 재현하지 않았다. 코드 구조상 race 가능성을 확인한 상태다.
<!-- body:end -->
@@ -26,7 +26,7 @@ source:
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
이 사례가 그 규칙을 요구하는 형태다.
- **클라이언트가 준 엔드포인트가 SSRF 가드가 아니라 약한 private 사본을 지났다**
같은 형태가 알림 어댑터에서 나타난 사례다.
알림 어댑터에서도 앞 단계가 정한 정책을 뒤 단계가 다시 덮어써 실제 관측값이 달라졌다.
- **sanitize가 아니라 reject가 기본이다**
두 필터가 서로 다른 답을 내는 규칙이다.
@@ -38,7 +38,7 @@ source:
## 결론
관측 가능한 자리에 도달하는 값은 전부 뒤 필터의 것이다. 응답 헤더와 MDC 와 접근 로그가 그렇다.
최종 응답 헤더·MDC·접근 로그에는 앞 필터가 만든 값이 아니라 뒤 필터가 다시 쓴 값이 기록된다.
앞 필터가 남긴 요청 속성을 쓰는 곳이 없다. 접근자는 있는데 부르는 곳이 0 이다.
@@ -76,9 +76,9 @@ Spring Boot : 4.0.8
앞 필터는 자동설정이 조립한다. 서블릿 웹 애플리케이션 조건이 붙어 있고, 자동설정 등록 파일에 이름이 올라가 있다.
자동설정은 설정값을 그대로 넘긴다. 설정 레코드의 압축 생성자가 널을 거짓으로 접으므로, 아무것도 설정하지 않은 배포는 신뢰가 꺼진 상태로 돈다. 그 자리 주석이 이유를 적는다 — 자기 요청 id 고를 수 있는 호출자는 서로 다른 두 요청이 하나의 신원을 공유하게 만들 수 있고, 그것이 지원 조사가 남의 교신을 읽게 되는 경로라는 것이다.
자동설정은 설정값을 그대로 넘긴다. 설정 record의 compact constructor는 null을 false로 바꾸므로 아무것도 설정하지 않은 배포에서는 신뢰 기능이 꺼진다. 해당 주석은 호출자가 요청 id를 직접 고르게 두면 서로 다른 두 요청이 하나의 신원을 공유 수 있고, 지원 조사에서 다른 요청의 교신을 같은 요청으로 묶을 수 있다고 설명한다.
같은 기본값을 넘기는 무인자 생성자도 있지만 부르는 쪽은 테스트뿐이다.
같은 기본값을 넘기는 무인자 생성자는 테스트에서만 호출된다.
## 같은 헤더 이름이 두 파일에 따로 있다
@@ -98,7 +98,7 @@ Spring Boot : 4.0.8
순서를 선언하지 않은 필터 빈에 Spring Boot 가 매기는 기본 순서는 가장 낮은 우선순위다. 분석 문서가 뒤 필터의 순서를 그 이름으로 적는 근거가 이것이다. 그래서 뒤 필터가 나중에 돌고, 두 필터가 쓰는 응답 헤더 설정은 덮어쓰기다.
## 관측 가능한 자리에는 뒤 필터만 도달한
## 응답 헤더·MDC·접근 로그에는 뒤 필터 값이 기록된
앞 필터는 자기 값을 요청 속성과 응답 헤더에 쓴다. 뒤 필터는 정화한 클라이언트 값을 같은 응답 헤더와 MDC 에 쓴다.
@@ -27,7 +27,7 @@ source:
## 관계
- **재시도 단위는 statement가 아니라 유스케이스 전체다**
코디네이터가 구현하는 결정이고, 그 결정이 도는 자리는 다른 구현이다.
코디네이터가 재시도 결정을 구현하지만 실제 배선된 호출 경로에서는 다른 구현이 실행된다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
이 사례가 그 확인 절차를 요구한 형태다.
- **@Bean이 있다는 것은 조립 증거가 아니다**
@@ -59,9 +59,9 @@ source:
지워진 인터셉터 이름을 건드린 커밋은 넷이다. 하나가 설계 문서에 이름을 넣었고, 하나가 인터셉터와 그 테스트를 더했고, 하루 뒤 909 파일을 건드린 커밋이 둘 다 지웠다. 지운 커밋의 제목에는 삭제가 드러나지 않는다.
어드바이스를 얹을 자리가 없어서가 아니다. 같은 트리의 배출기와 정리기 이미 트랜잭션 애너테이션으로 프록시되고, 인바운드 쪽 이 애플리케이션이 직접 어드바이저를 갖고 있다. 빠진 것은 경로가 아니라 코디네이터를 그 경로에 얹는 클래스 하나다.
AOP를 적용할 기반이 없는 것은 아니다. 같은 트리의 배출기와 정리기 이미 트랜잭션 애너테이션으로 프록시되고, 인바운드 쪽에는 이 애플리케이션이 직접 만든 어드바이저 있다. 빠진 것은 retry coordinator를 실제 호출 경로에 연결하는 advisor 또는 wrapper다.
판정은 P1 이다. 안정 등급으로 선언한 능력의 구현이 도달 불가이고, 그 자리에서 실제로 도는 것은 다른 값과 좁은 정책을 가진 다른 구현이다.
판정은 P1이다. 안정 등급으로 선언한 retry coordinator에는 확인한 프로덕션 호출 경로가 없고, 실제 wired path에서는 다른 설정값과 좁은 정책을 가진 구현이 실행된다.
## 검증 환경
@@ -86,7 +86,7 @@ OpenJDK : 21.0.12
<!-- body:start -->
선언적 재시도 애너테이션이 지워졌고, 그 자리에 이유를 적은 문단이 남아 있다.
선언적 재시도 애너테이션은 삭제됐고, 인접한 javadoc에는 왜 애너테이션 기반 재시도를 쓰지 않는지 이유가 적혀 있다.
## 삭제는 사고가 아니었다
@@ -31,7 +31,7 @@ source:
- **sanitize가 아니라 reject가 기본이다**
강한 쪽 함수가 따르는 규칙이다.
- **요청 식별자를 클라이언트가 고를 수 없다는 정책이 뒤에 도는 필터에 뒤집혔다**
같은 형태가 웹 어댑터에서 나타난 사례다.
웹 어댑터에서도 더 강한 공용 검증기가 있었지만 실제 호출 경로는 더 약한 private 검증을 사용했다.
## 문제
@@ -47,7 +47,7 @@ source:
값 타입은 그 함수를 부르지 않는다. 같은 파일의 약한 공개 함수도 부르지 않는다. 약한 쪽과 같은 논리를 private 메서드로 다시 썼고, 루프백 호스트 집합까지 같다. 그래서 약한 함수 이름으로 검색해도 이 호출처는 나오지 않는다.
이웃 호출처는 같은 결함을 이미 고쳤다. 웹훅 쪽 주석이 고치면서 무엇이 남았는지까지 적는다. 강한 가드는 바로 그 호출처를 위해 쓰였고 한동안 아무 데서도 불리지 않았으며, 테스트 통과하고 있었다는 것이다.
이웃 호출처는 같은 결함을 이미 고쳤다. 웹훅 쪽 주석에는 공용 guard가 그 호출처를 위해 추가됐지만 한동안 실제 호출되지 않았 테스트 통과했다는 이력이 적혀 있다.
호출처 도달을 고정하려고 만든 테스트가 있는데, 그 클래스의 머리글이 대상을 웹훅과 SES 둘로 적는다. 가드 자신의 javadoc 이 적은 둘은 웹푸시와 웹훅이다. 두 목록이 어긋나 있고, 그 테스트 파일에 웹푸시를 언급하는 줄은 0 이다.
@@ -86,7 +86,7 @@ OpenJDK : 21.0.12
## 그 검사는 자기 목적에 대해서는 옳다
메서드 javadoc 에는 다루는 위협이 경로상의 도청이고 루프백 엔드포인트에는 그 경로가 없다고 적혀 있다. 예외를 루프백으로 좁힌 것은 계약 시험이 진짜 소켓을 수 있게 하려는 것이다.
메서드 javadoc은 경로상의 도청을 막기 위해 HTTPS를 요구하고, loopback endpoint는 그 위협 모델에서 제외한다고 적는다. loopback만 예외로 둔 덕분에 계약 테스트는 실제 소켓을 사용할 수 있다.
전송 보안 규칙으로서 이 판단은 유지된다. 다만 같은 모듈의 이웃 파일이 그 논리로 남은 결과를 주석에 적어 뒀고, 그 내용은 뒤에서 본다.
@@ -105,14 +105,14 @@ a server-side request forgery primitive
값 타입은 두 공개 함수 중 어느 것도 부르지 않는다. 약한 쪽과 같은 논리를 private 으로 다시 썼고, 루프백 호스트 집합 `127.0.0.1`, `::1`, `localhost` 까지 같다. 약한 함수 이름으로 검색하면 이 호출처는 드러나지 않는다.
호출처 도달을 고정하려고 만든 테스트도 있다. 클래스 머리글 대상을 웹훅 대상과 SES 엔드포인트 둘로 적는다. 가드 javadoc 이 적은 둘과 한 자리가 다르고, 그 파일에 웹푸시를 언급하는 줄은 0 이다.
호출 경로를 고정하려고 만든 테스트도 있다. 클래스 머리글 대상을 웹훅 target과 SES endpoint로 적고, 공용 guard javadoc의 대상 목록과 하나가 다르다. 해당 테스트 파일에서는 web push를 언급하지 않는다.
## 내부망 주소를 넣으면 실제로 통과한다
:::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 여야 한다는 것이다.
컴파일된 값 타입에 여덟 입력을 넣었다. HTTPS인 여섯 입력은 모두 통과했고, 그 안에는 클라우드 메타데이터 주소 둘·사설 대역·사설 IPv6·`user:pw@`가 포함됐다. 거절된 둘은 HTTP였으며 메시지는 loopback 밖에서는 HTTPS를 요구했다.
이웃 호출처의 주석이 같은 결함을 고치며 무엇이 남았는지 적는다.
@@ -130,7 +130,7 @@ for kept the weaker check.
그래서 지금 성립하는 것은 계약이다. 이 레코드의 표준 생성자가 이 값의 유일한 검증 지점이고, 포크가 구독 등록 엔드포인트를 붙이는 순간 — 그것이 이 모듈의 존재 이유다 — 검증은 이미 통과되어 있다.
고칠 자리는 두 모듈 사이가 아니라 값 타입 생성자이다. 컴포지션 루트 편의 표건에 배선 지점이 없다고 따로 적어 둔다.
수정은 두 모듈의 wiring을 바꾸는 일이 아니라 값 타입 생성자가 공용 guard와 같은 검증을 사용하도록 만드는 쪽이다. 컴포지션 루트 편의 표문제에 별도 배선 지점이 없다고 적는다.
## 확인하지 못한 것
@@ -1,7 +1,7 @@
---
kind: CASE
slug: trust-policy-lives-in-nginx-not-in-the-code
title: forwarded 헤더 신뢰 판정이 Nginx에만 있고 Java 정책 421 LOC은 배선되지 않았다
title: 테스트 Nginx 설정은 forwarded 헤더를 교체하지만 운영 경로는 확인하지 않았다
topic: duplicate-mechanisms
project: clean-architecture-backend-template
status: 게시 전
@@ -17,16 +17,16 @@ source:
- 원본 분석 절은 final/document.md#3-1 · final/document.md#a14 §32.2 이다.
---
# forwarded 헤더 신뢰 판정이 Nginx에만 있고 Java 정책 421 LOC은 배선되지 않았다
# 테스트 Nginx 설정은 forwarded 헤더를 교체하지만 운영 경로는 확인하지 않았다
웹 리프의 프록시 패키지는 421 줄로 신뢰 프록시 정책과 헤더 정화기와 정규화 타입을 갖는다. 실제 신뢰 판정은 Nginx 설정이 하고, 그 설정은 들어온 forwarded 헤더를 원격 주소로 교체한다.
웹 리프의 프록시 패키지는 421줄로 신뢰 프록시 정책과 헤더 정화기와 정규화 타입을 갖는다. 이번 evidence에서 확인한 `nginxProxyTest` 설정은 들어온 forwarded 헤더를 authoritative value로 교체한다. 이 테스트 설정이 실제 운영 배포의 trust boundary인지까지는 확인하지 않았다.
## 관계
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
이 사례가 그 규칙의 인프라 판이다.
- **요청 식별자를 클라이언트가 고를 수 없다는 정책이 다른 필터에서 뒤집힌다**
같은 리프에서 같은 계열의 사례다.
같은 리프에서 Java 정책과 프록시 설정이 중복되어 실제 신뢰 경계를 어느 쪽이 결정하는지 다시 확인해야 했다.
## 문제
@@ -37,15 +37,15 @@ NormalizedForwardedHeaders 158 줄
ForwardedHeaderSanitizer 72 줄
UntrustedForwardedHeaderException 30 줄
이 코드가 하는 일은 어떤 프록시를 신뢰할지 정하고 forwarded 헤더를 정규화하는 것이다.
이 코드는 신뢰할 프록시를 정하고 forwarded 헤더를 정규화다.
문제는 이것이 실제 판정 경로인가다.
## 결론
Nginx 설정이 그 판정을 대신한다.
확인한 테스트용 Nginx 설정은 Java 앞단에서 forwarded 헤더를 교체한다.
프록시 헤더 설정 파일의 주석이 자기 지위를 명시한다. 이것이 권위 있는 forwarded 헤더이며 모든 location 에서 include 된다는 것이다.
프록시 헤더 설정 파일의 주석은 이 파일을 forwarded 헤더의 authoritative 설정으로 두고 모든 location에서 include하도록 요구한다.
설정 내용은 교체다.
@@ -56,7 +56,7 @@ X-Forwarded-Host 를 이 배포의 공개 이름으로 설정
주석이 모든 줄이 SET 이고 ADD 가 아니라고 못 박는다. 클라이언트가 보낸 X-Forwarded-For 는 remote_addr 로 교체되고 X-Forwarded-Host 는 이 배포의 공개 이름으로 교체된다.
애플리케이션에 도달하는 시점에 그 헤더들은 이미 신뢰할 수 있는 값이다. Java 정책이 판정할 것이 남아 있지 않다.
이 테스트 구성에서는 애플리케이션에 도달하기 전에 forwarded 헤더가 교체된다. 하지만 운영 배포가 같은 설정을 사용한다는 evidence는 없으므로 실제 운영에서 Java 정책이 불필요하다고 단정하지 않다.
Java 쪽 참조 수도 그것과 맞는다.
@@ -65,9 +65,9 @@ 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 마다 하나씩 추가하는 설정은 보안 헤더를 하나도 보내지 않으며, 유일한 증상은 애플리케이션이 조용히 클라이언트를 다시 신뢰하는 것이다.
같은 설정 파일의 주석 Nginx 배열 지시어가 병합되지 않고 교체기 때문에 include 방식을 쓴다고 설명한다. location 안에서 `proxy_set_header`를 하나라도 다시 선언하면 server 수준에서 상속받던 같은 계열 지시어를 잃을 수 있다. 따라서 forwarded 헤더 설정을 location마다 부분적으로 재정의하면 애플리케이션이 받는 신뢰 입력이 달라질 수 있다.
그 주석이 이 사례의 위험을 정확히 서술한다. 신뢰 판정이 인프라에 있으면 인프라 설정 실수가 애플리케이션의 신뢰 정책을 조용히 되돌린다. 그리고 그때 되돌아갈 Java 정책은 배선되어 있지 않다.
이 테스트 설정이 운영에서도 trust boundary라면 인프라 설정 실수가 애플리케이션이 받는 forwarded 헤더 의미를 바꿀 수 있다. 다만 이번 searched direct-reference evidence만으로는 운영 시 fallback이 될 Java trust policy의 실제 framework/lifecycle wiring을 확정하지 못했다.
## 검증 환경
@@ -86,16 +86,16 @@ Nginx 설정 : 웹 리프의 nginxProxyTest 소스셋 아래 proxy_headers.conf
<!-- body:start -->
forwarded 헤더를 어디까지 믿을지 판정하는 Java 정책이 421 LOC 작성돼 있고 배선되지 않는다. 실제 판정은 Nginx 설정이 한다.
forwarded 헤더를 다루는 Java 정책이 421 LOC 있고 searched direct reference 기준으로 사용 지점이 매우 적다. 별도로 `nginxProxyTest` 설정은 forwarded 헤더를 authoritative value로 교체한다. 두 사실을 확인했지만 이 테스트 설정이 운영 배포의 실제 trust boundary인지와 Java 정책의 모든 framework/lifecycle wiring 부재까지는 확인하지 않았다.
## 판정을 실제로 하는 곳
:::evidence key="trust-policy-lives-in-nginx-not-in-the-code" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 리뷰가 닿지 않는 자리로 정책이 옮겨졌
## Java 코드와 프록시 설정을 함께 봐야 한
두 곳이 어긋나면 코드 리뷰가 잡을 수 없고, Java 쪽을 고쳐도 동작이 바뀌지 않는다.
운영 배포가 이 프록시 설정을 실제로 사용한다면 Java 코드만 검토해서는 forwarded 헤더 교체 정책을 검증할 수 없다. 반대로 운영 설정을 확인하지 않은 상태에서는 Java 변경이 동작에 영향을 주지 않는다고 단정할 수도 없다.
## 확인하지 못한 것