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
+118
@@ -0,0 +1,118 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-circuit-breaker-permit-that-leaks-on-local-rejection
|
||||
title: 로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다
|
||||
topic: http-failure-classification
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a-circuit-breaker-permit-that-leaks-on-local-rejection
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a-circuit-breaker-permit-that-leaks-on-local-rejection.body.md
|
||||
assets:
|
||||
- key: a-circuit-breaker-permit-that-leaks-on-local-rejection
|
||||
file: ../../../final/evidence/rendered/a-circuit-breaker-permit-that-leaks-on-local-rejection.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a-circuit-breaker-permit-that-leaks-on-local-rejection.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/11 §22 이다. permission 획득·반환 경로의 전수 추적은 그 분석이 인용하는 원시 증거 170 번 §8.1 에 있다.
|
||||
---
|
||||
|
||||
# 로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다
|
||||
|
||||
회로 브레이커 permission을 얻은 뒤 rate limiter나 bulkhead가 요청을 거부하면 그 permission이 반환되지 않는다. HALF_OPEN 상태에서는 시험 슬롯이 영구히 소비되어, 회복한 업스트림에 대해 회로가 닫히지 않을 수 있다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **전송 실패의 단계와 범주 — AttemptStage와 FailureCategory**
|
||||
이 파이프라인이 그 분류를 만들기 전에 지나는 승인 계층이다.
|
||||
|
||||
## 문제
|
||||
|
||||
요청 하나가 실행되기 전에 세 가드를 차례로 지난다. 회로 브레이커가 permission 을 주고, rate limiter 가 토큰을 주고, bulkhead 가 슬롯을 준다.
|
||||
|
||||
뒤의 두 가드가 거부하면 그 경로는 예외를 던지고 끝난다. 그 사이에 이미 받아 둔 회로 permission 을 돌려주는 호출이 없다.
|
||||
|
||||
## 결론
|
||||
|
||||
경로 확인으로 확정한 결함이다. 회로가 반쯤 열린 상태에서만 발생한다.
|
||||
|
||||
업스트림 장애로 회로가 열린다. 대기 후 반쯤 열린 상태로 바뀌고, 트래픽이 돌아온다. 그 순간 평상시 부하에 맞춰 사이징된 로컬 rate limiter 나 bulkhead 가 거부하기 시작하고, 거부마다 시험 슬롯 하나가 사라진다.
|
||||
|
||||
거부가 허용된 시험 호출 수만큼 쌓이면 브레이커는 성공도 실패도 못 본 채 그 상태에 머문다. 회복한 업스트림에 대해 회로가 닫히지 않는다.
|
||||
|
||||
수정하려면 인터페이스에 permission 반환 연산을 추가하고, rate limiter 와 bulkhead 가 요청을 거부하는 두 경로에서 그 연산을 호출해야 한다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
Spring Boot : 4.0.8
|
||||
Resilience4j : 2.2.0
|
||||
확인 방식 : 파이프라인 진입부의 예외 경로 추적, 인터페이스 연산 전수 확인, 반환 연산 이름 검색
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. AttemptResiliencePipeline.execute 의 83행부터 97행까지를 읽고, 세 가드의 거부 경로에서 회로 브레이커 연산이 호출되는지 확인한다.
|
||||
2. AttemptCircuitBreaker 의 추상 연산을 전수 확인한다. 넷이며 반환 연산이 없다.
|
||||
3. releasePermission 을 코드베이스에서 검색한다. Java 매치가 0 이다.
|
||||
4. AttemptResiliencePipelineTest 에서 회로 permission 반환을 단언하는 테스트가 있는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`AttemptResiliencePipeline.execute` 진입부에서 세 가드가 차례로 실행된다. 83행이 회로 permission 을 얻고, 88행이 rate limiter 를, 93행이 bulkhead 를 본다.
|
||||
|
||||
83행을 통과한 뒤 88행과 93행이 거부하면 그 두 경로는 예외를 던지고 끝난다. 회로 permission 이 돌아오지 않는다.
|
||||
|
||||
## 반환할 연산 자체가 인터페이스에 없다
|
||||
|
||||
:::evidence key="a-circuit-breaker-permit-that-leaks-on-local-rejection" alt="코드베이스에서 AttemptResiliencePipeline 의 가드와 반납 지점, AttemptCircuitBreaker 의 추상 연산 전수, releasePermission 검색 결과를 뽑은 출력 26줄. 인터페이스의 연산 넷과 Java 코드 매치 0, 그리고 그 이름이 설계 문서에만 남아 있다는 것이 그 출력에 그대로 보인다." caption="세 가드 · 정상 경로 반납 · AttemptCircuitBreaker 연산 넷 · releasePermission 검색 — 26줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`AttemptCircuitBreaker` 의 추상 연산은 넷이다 — `tryAcquirePermission` · `onSuccess` · `onError` · `state`. 획득은 있고 반환은 없다.
|
||||
|
||||
`releasePermission` 은 `src` 아래 Java 파일에서 매치가 0 이다. 저장소 전체로 넓히면 한 곳에 나오는데, 그것은 이 능력의 설계 문서다. 즉 이름이 설계 단계에서는 존재했고 구현에는 들어오지 않았다.
|
||||
|
||||
로컬 거부 경로가 부르는 것을 잊은 것이 아니라, 부를 수 있는 연산이 없다.
|
||||
|
||||
## 같은 거부 경로에서 rate 토큰은 돌려준다
|
||||
|
||||
94행이 이 판정을 뒷받침한다. bulkhead 가 거부하는 경로는 `rateLimiter.onCompleted()` 를 불러 rate 토큰을 명시적으로 반환한다. 저자가 permit 반환을 의식하고 있었다는 증거다.
|
||||
|
||||
정상 경로에도 같은 의식이 보인다. `releaseAttemptPermits()` 가 `bulkhead.release()` 와 `rateLimiter.onCompleted()` 를 함께 부른다 — 자료의 120행이 그 두 번째 호출이다. 세 가드 중 둘은 정상 경로에서도 거부 경로에서도 반납되고, 회로만 어느 쪽에서도 반납되지 않는다.
|
||||
|
||||
## 이 결함은 HALF_OPEN 에서만 값을 갖는다
|
||||
|
||||
Resilience4j 의 `tryAcquirePermission()` 은 반쯤 열린 상태에서 허용된 시험 호출 수 중 하나를 소비한다. 그 슬롯은 `onSuccess` · `onError` · `releasePermission` 중 하나로만 돌아온다. 아무것도 부르지 않으면 슬롯은 영구히 소비된다.
|
||||
|
||||
닫힌 상태에서는 permission 이 계수를 소비하지 않으므로 같은 코드가 무해하다. 그래서 이 결함은 코드가 아니라 상태에 걸려 있고, 평상시 테스트로는 드러나지 않는다.
|
||||
|
||||
## 조건들이 우연히 겹치지 않는다
|
||||
|
||||
시험 슬롯이 열리는 시점은 업스트림이 회복을 시작한 시점이고, 트래픽이 돌아오는 시점도 같다. 평상시 부하에 맞춰 사이징된 로컬 가드는 그 순간에 거부하기 시작한다.
|
||||
|
||||
거부마다 슬롯 하나가 사라진다. 허용된 시험 호출 수만큼 거부가 나면 브레이커는 성공도 실패도 관측하지 못한 채 그 상태에 머문다. `maxWaitDurationInHalfOpenState` 기본값이 0 — 무한 대기 — 이므로 시간이 그것을 풀어 주지도 않는다.
|
||||
|
||||
## 테스트가 그 공백을 그대로 보여 준다
|
||||
|
||||
인접한 두 성질에는 테스트가 있다.
|
||||
|
||||
- `openCircuitDoesNotConsumeRateOrBulkheadPermit` — 회로가 거부할 때 뒤의 둘을 소비하지 않는다
|
||||
- `bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError` — bulkhead 거부가 rate 를 돌려준다
|
||||
|
||||
둘째 테스트가 단언하는 이벤트 순서는 `circuit-enter` · `rate-enter` · `bulkhead-reject` · `rate-exit` 다. 회로를 돌려주는 이벤트가 그 목록에 없다. 88행의 rate limiter 거부 경로에는 테스트가 아예 없다.
|
||||
|
||||
## 수정
|
||||
|
||||
`AttemptCircuitBreaker` 에 `releasePermission()` 을 더해 Resilience4j 의 같은 이름 연산에 위임하고, `alwaysClosed()` 구현에서는 아무것도 하지 않게 둔다. 그리고 두 로컬 거부 경로에서 그것을 부른다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
시험 슬롯 고갈을 반복 호출로 재현해 보지는 않았다. 회로가 닫히지 않는 상태를 런타임에서 관측한 것은 아니다.
|
||||
|
||||
Resilience4j 의 상태별 permission 회계와 무한 대기 기본값은 그 라이브러리의 문서화된 동작을 근거로 삼았고, 이 회차에 라이브러리 코드를 실행해 확인하지는 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-red-test-misread-as-a-product-defect
|
||||
title: 붉은 테스트를 제품 결함으로 읽은 오진 — 듀얼스택 localhost가 TLS 실패를 버린다
|
||||
topic: http-failure-classification
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a-red-test-misread-as-a-product-defect
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a-red-test-misread-as-a-product-defect.body.md
|
||||
assets:
|
||||
- key: a-red-test-misread-as-a-product-defect
|
||||
file: ../../../final/evidence/rendered/a-red-test-misread-as-a-product-defect.svg
|
||||
- key: a-red-test-misread-as-a-product-defect-host-control
|
||||
file: ../../../final/evidence/rendered/a-red-test-misread-as-a-product-defect-host-control.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a-red-test-misread-as-a-product-defect.txt
|
||||
- ../../../final/evidence/raw/a-red-test-misread-as-a-product-defect-host-control.txt
|
||||
source:
|
||||
- 분석 문서는 httpclient 어댑터 편 §51 이다. 그 절이 앞선 P1 진단을 철회하고 교체하며, 근거로 측정 기록 EVD-332 를 든다. 강등이 패밀리별 실패 양상이 다를 때만 일어난다는 완화 조건과, 저장소가 IP 스택 선호를 고정하지 않아 듀얼스택에서 항상 실패한다는 판정도 그 절의 하위 항목이다. 교차 스코프 문서 §1 과 §5 가 이 번복을 저장소 전체 관점과 측정 방법 양쪽에서 다시 다룬다.
|
||||
---
|
||||
|
||||
# 붉은 테스트를 제품 결함으로 읽은 오진 — 듀얼스택 localhost가 TLS 실패를 버린다
|
||||
|
||||
mTLS 계약 테스트 3건이 실패한다. 앞선 판정은 이것을 P1 제품 결함으로 올렸지만, 예외 사슬을 실제로 출력해 보니 원인은 테스트 픽스처가 쓰는 호스트명이었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **전송 실패의 단계와 범주 — AttemptStage와 FailureCategory**
|
||||
이 사례가 다루는 분류 모델이다. 여기서 두 축을 채우는 정보가 어떻게 사라지는지 보인다.
|
||||
- **분류기는 엔진이 남긴 것만 볼 수 있다**
|
||||
이 사례에서 끌어낸 규칙이다. 분기 순서가 아니라 사슬에 무엇이 남았는가가 결정한다.
|
||||
- **런타임의 모양에 대한 주장은 런타임에서 확인한다**
|
||||
이 오진 자체에서 끌어낸 규칙이다. 코드를 읽어 얻은 사슬 모양은 가설이었다.
|
||||
- **재시도 안전성은 증거에 기반해 판정한다**
|
||||
이 사례가 그 결정의 취약점을 보여 준다. 증거를 만드는 계층이 정보를 버리면 판정이 틀린 입력 위에 선다.
|
||||
|
||||
## 문제
|
||||
|
||||
httpclient 어댑터의 테스트가 283건 중 3건 실패한다. 셋 다 같은 줄에서 같은 메시지로 실패한다.
|
||||
|
||||
expected: TLS_HANDSHAKE
|
||||
but was: CONNECT
|
||||
|
||||
분류가 곧 재시도 안전성인 모듈이다. 영구 TLS 실패는 재시도되지 않고 연결 실패는 재시도된다.
|
||||
|
||||
앞선 판정에서 이 건은 P1 제품 결함 자리에 있었다. 근거는 분류기의 분기 순서였다. Apache 가 TLS 실패를 연결 예외로 감싸면 앞선 분기가 뒤의 분기를 가린다는 것이다. 그 판정은 사슬을 읽어서가 아니라 분기 순서를 읽어서 나왔다.
|
||||
|
||||
## 결론
|
||||
|
||||
제품 결함이 아니다. 원인은 테스트 픽스처가 돌려주는 호스트명이다.
|
||||
|
||||
호스트명은 이 컨테이너에서 두 주소로 풀리고, 목 서버는 IPv4 루프백에만 바인딩한다. Apache 의 연결 오퍼레이터는 해석된 주소를 순회하면서 마지막이 아닌 주소의 실패를 버리고 다음으로 넘어간다.
|
||||
|
||||
첫 주소에서 핸드셰이크가 깨지고 그 예외가 버려진다. 듣는 소켓이 없는 두 번째 주소에서 거부가 나고, 뒤에 남은 주소가 없어 그 거부가 최종 결과가 된다. 분류기에 도달하는 유일한 예외가 그것이다.
|
||||
|
||||
접속 호스트만 IPv4 주소로 바꾸면 세 건 모두 영구 TLS 실패로 분류된다.
|
||||
|
||||
남는 판정은 둘이다. 픽스처가 호스트명을 쓰는 것은 P3 이다. 다중 주소 호스트에서 패밀리별 실패 양상이 다르면 영구 TLS 실패가 재시도 가능한 범주로 강등되는 성질은 P2 기록이며, 이 모듈에서 고칠 수 없다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
Spring Boot : 4.0.8
|
||||
Apache HttpClient : httpclient5 5.5.2, httpcore5 5.3.6
|
||||
MockWebServer : okhttp 4.12.0, okhttp-tls 4.12.0
|
||||
/etc/hosts : 127.0.0.1 localhost 와 ::1 localhost 양쪽 존재
|
||||
preferIPv4Stack 설정 : x
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. httpclient 어댑터의 테스트를 모듈 전체로 실행한다. 283건 중 3건이 같은 줄에서 실패한다.
|
||||
2. 그 줄이 단언 블록의 몇 번째인지, 그 앞줄이 통과하는지 확인한다.
|
||||
3. 픽스처의 URI 메서드가 무엇을 돌려주는지, 그 호스트명이 몇 개의 주소로 풀리는지 확인한다.
|
||||
4. 같은 서버와 같은 신뢰 재료로 접속 호스트만 바꿔 세 경우를 각각 실행하고, 예외 사슬과 분류 결과를 비교한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
httpclient 어댑터의 mTLS 계약 테스트 다섯 건 중 세 건이 실패한다. 신뢰할 수 없는 CA, 만료된 인증서, 호스트명 불일치다.
|
||||
|
||||
## 멈추는 자리는 범주가 아니라 단계다
|
||||
|
||||
:::evidence key="a-red-test-misread-as-a-product-defect" alt="이 리비전에서 모듈 전체 테스트를 실행해 283건 중 3건이 같은 줄에서 실패하는 것을 보인 출력과, 멈추는 자리가 두 번째 단언임을 실제 행 번호로 보인 단언 블록, 픽스처가 호스트명을 돌려주는 메서드, 이 컨테이너의 hosts 항목과 자바가 그 호스트명에서 얻는 주소 둘과 IP 스택 선호 설정 수, 그리고 영구 범주 목록과 재시도 엔진의 가드 순서를 뽑은 출력 55줄. 실패가 범주가 아니라 단계 단언에서 나고 호스트명이 주소 둘로 풀린다는 것이 그 출력에 보인다." caption="모듈 전체 283 중 3 실패 · 멈추는 자리는 168행 단계 단언 · 픽스처의 호스트명 · 주소 둘 · 영구 범주 목록과 가드 순서 — 55줄" zoom="true"
|
||||
:::
|
||||
|
||||
단언 블록은 잡은 예외를 분류기에 넣고 넷을 차례로 확인한다. 첫 줄은 통과한다 — 증거가 "보내지 않음"인 것은 맞다.
|
||||
|
||||
두 번째 줄에서 멈춘다. 단계가 TLS 핸드셰이크여야 하는데 연결이다. 범주 단언은 실행되지도 않는다.
|
||||
|
||||
분류가 곧 재시도 안전성인 모듈이라 이 차이는 크다. 영구 범주는 거부되고, 연결 범주는 허용된다.
|
||||
|
||||
## 분기 순서를 읽어서는 답이 나오지 않는다
|
||||
|
||||
앞선 판정은 이것을 제품 결함으로 올렸다. 근거는 분류기가 원인 사슬을 바깥에서 안으로 훑으며 첫 매치를 돌려준다는 것, 그리고 연결 규칙이 TLS 규칙보다 앞에 있다는 것이었다.
|
||||
|
||||
그 추론은 사슬의 모양을 가정한다.
|
||||
|
||||
## 사슬에 SSL 예외가 없었다
|
||||
|
||||
떠 보니 원인 사슬은 둘뿐이었다. 접근 예외와 연결 거부다.
|
||||
|
||||
분류기는 자기가 받은 것을 정확히 분류했다. 정보는 도착하기 전에 이미 사라졌다.
|
||||
|
||||
## 사라진 자리는 이름 해석이다
|
||||
|
||||
픽스처의 URI 메서드가 호스트명 localhost 를 돌려준다. 이 컨테이너의 hosts 파일은 그 이름을 IPv4 와 IPv6 양쪽에 주고, 자바도 두 주소를 돌려준다. 저장소 어디에도 IP 스택 선호를 고정하는 설정이 없다.
|
||||
|
||||
목 서버는 IPv4 루프백에만 바인딩한다. Apache 의 연결 오퍼레이터는 해석된 주소를 차례로 시도하는데, 마지막 주소가 아니면 실패를 로그로만 남기고 다음으로 넘어간다. 그 자리 로그 문구가 두 갈래로 나뉜다 — 마지막이면 "작업을 종료한다", 아니면 "다음 주소로 연결을 재시도한다".
|
||||
|
||||
첫 주소에서 TCP 는 붙고 핸드셰이크가 깨진다. 그 실패가 버려진다. 두 번째 주소에는 듣는 소켓이 없어 TCP 가 거부되고, 마지막이므로 승격된다.
|
||||
|
||||
## 접속 호스트만 바꾸면 셋 다 영구가 된다
|
||||
|
||||
:::evidence key="a-red-test-misread-as-a-product-defect-host-control" alt="같은 서버와 같은 신뢰 재료로 접속 호스트만 호스트명과 IPv4 주소로 바꿔 세 경우를 각각 실행한 결과. 호스트명 쪽은 세 건 모두 연결 거부 두 단계로 끝나 재시도 가능으로 분류되고, IPv4 주소 쪽은 세 건 모두 핸드셰이크 실패가 사슬에 남아 영구로 분류된다." caption="접속 호스트만 바꾼 대조 — 호스트명 셋은 CONNECT · IPv4 셋은 TLS_PERMANENT" zoom="true"
|
||||
:::
|
||||
|
||||
같은 서버와 같은 신뢰 재료로 URI 의 호스트만 바꿨다. 호스트명 쪽은 세 건 모두 연결 거부 두 단계로 끝나고 재시도 가능으로 분류된다. IPv4 주소 쪽은 세 건 모두 핸드셰이크 실패가 사슬에 남고 영구로 분류된다.
|
||||
|
||||
사슬의 모양은 셋이 같지 않다. 신뢰할 수 없는 CA 와 만료 인증서는 PKIX 검증 실패가 다섯 단계로 남고, 만료 쪽 끝에는 만료 시각이 붙는다. 호스트명 불일치는 세 단계이고 PKIX 가 아니라 주체 대체 이름 불일치다.
|
||||
|
||||
## 통과하는 두 건은 서로 다른 이유로 통과한다
|
||||
|
||||
클라이언트 인증서를 제시하는 건은 첫 주소에서 핸드셰이크가 성공하고 루프가 즉시 반환한다. 두 번째 주소를 시도하지 않는다.
|
||||
|
||||
클라이언트 인증서가 없는 건은 다르다. TLS 1.3 에서 클라이언트가 자기 몫을 끝낸 뒤 서버가 거절하므로 실패가 연결 루프 밖에서 터지고 핸드셰이크 예외가 그대로 남는다. 그리고 그 테스트는 예외의 종류가 아니라 예외가 났는지만 단언한다.
|
||||
|
||||
처음 눈에 띈 차이는 실패하는 세 건만 클라이언트 인증을 요구하지 않는 픽스처를 쓴다는 점이었다. 그것은 원인이 아니라 상관이었다.
|
||||
|
||||
## 픽스처를 고치는 것만으로는 부족하다
|
||||
|
||||
테스트 실패 자체는 환경에 달려 있다. localhost 가 IPv4 로만 풀리는 환경에서는 세 건 모두 통과한다. 저장소가 IP 스택 선호를 고정하지 않으므로, 듀얼스택 컨테이너나 CI 에서는 산발이 아니라 항상 빨갛다.
|
||||
|
||||
고칠 것은 픽스처가 호스트명을 돌려준다는 점이고 이쪽은 P3 다. 다만 IPv4 리터럴을 돌려주는 것만으로는 안 된다. 서버 인증서의 주체 대체 이름이 localhost 하나뿐이라, 주소를 거기 함께 넣지 않으면 지금 통과하는 mTLS 건이 이름 불일치로 빨개진다.
|
||||
|
||||
## 남는 성질은 이 모듈에서 고칠 수 없다
|
||||
|
||||
듀얼스택 업스트림에서 한쪽 주소 패밀리가 TLS 를 영구 거절하고 다른 쪽이 연결을 거부하면, 절대 검증되지 않을 인증서에 대한 호출이 연결 실패로 분류된다.
|
||||
|
||||
강등은 패밀리별 실패 양상이 다를 때만 일어난다. 인증서 자체가 신뢰 불가면 보통 두 패밀리 모두 TLS 에서 실패하고, 그때는 마지막 주소의 예외도 핸드셰이크 예외라 올바르게 분류된다.
|
||||
|
||||
강등이 일어나면 영구 범주였을 때 즉시 거부됐을 호출이 재시도 대상이 된다. 엔진의 가드 순서상 최대 시도 횟수와 예산이 먼저 자르므로 무한히 도는 것은 아니다. 그 한도까지는 돈다.
|
||||
|
||||
분류기가 볼 수 있는 정보를 Apache 의 다중 주소 루프가 이미 버렸기 때문에, 이 모듈 안에서 막을 방법이 없다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
나머지 두 분류기는 같은 절차로 확인하지 못했다. 앞선 판정이 남긴 "다른 두 분류기도 같은 문제가 있는가"라는 항목은 전제가 반증되어 소멸하지만, 두 분류기 자체를 측정한 것은 아니다.
|
||||
|
||||
ReactorFailureClassifier 와 JdkFailureClassifier 는 같은 방식으로 실행해 보지 않았다. 위 대조는 세 건을 각각 두 호스트로 실행한 것이고, 목 서버가 IPv4 에만 바인딩한다는 것은 그 결과에서 역추론했다.
|
||||
|
||||
<!-- body:end -->
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: transport-failure-stage-and-category
|
||||
title: 전송 실패의 단계와 범주 — AttemptStage와 FailureCategory
|
||||
topic: http-failure-classification
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:transport-failure-stage-and-category
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: transport-failure-stage-and-category
|
||||
file: ../../../final/evidence/rendered/transport-failure-stage-and-category.svg
|
||||
- key: transport-failure-stage-and-category-diagram
|
||||
file: ../../../final/assets/diagrams/transport-failure-stage-and-category.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/transport-failure-stage-and-category.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#8-1 · analysis/11 §51 이다.
|
||||
---
|
||||
|
||||
# 전송 실패의 단계와 범주 — AttemptStage와 FailureCategory
|
||||
|
||||
HTTP 전송 실패를 "어디까지 갔는가"와 "무엇이 실패했는가" 두 축으로 나눠 기록하고, 그 둘이 함께 재시도 안전성 판정의 입력이 된다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **붉은 테스트를 제품 결함으로 읽은 오진**
|
||||
이 두 축이 실제로 어떻게 채워지는지, 그리고 축을 채우는 정보가 사라지면 어떻게 되는지 보여 주는 사례다.
|
||||
- **분류기는 엔진이 남긴 것만 볼 수 있다**
|
||||
이 모델의 입력 한계를 규칙으로 옮긴 것이다.
|
||||
- **재시도 가능성은 멱등성과 실패 범주를 함께 봐야 정해진다**
|
||||
이 모델의 출력이 재시도 결정으로 이어지는 규칙이다.
|
||||
- **재시도 안전성은 증거에 기반해 판정한다**
|
||||
이 모델을 채택한 프로젝트 결정이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
HTTP 전송 실패를 "어디까지 갔는가"(`AttemptStage`: pool·DNS·CONNECT·TLS·전송·응답)와 "무엇이 실패했는가"(`FailureCategory`)의 두 축으로 모델링하는 구조의 설명이다.
|
||||
|
||||
## 두 축이 만드는 판정
|
||||
|
||||
:::evidence key="transport-failure-stage-and-category-diagram" alt="단계 축과 범주 축의 조합에서 notSent 와 maybeSent 두 갈래가 나온다" caption="두 축이 만드는 판정" zoom="false"
|
||||
:::
|
||||
|
||||
두 축이 함께 `notSent`/`maybeSent` 판정을 만들고, 그것이 재시도 결정표의 입력이 된다 — `TLS_PERMANENT`는 `permanent()`로 절대 차단되고 `CONNECT`는 멱등성과 무관하게 재시도된다.
|
||||
|
||||
## AttemptStage 참조 위치
|
||||
|
||||
:::evidence key="transport-failure-stage-and-category" alt="코드베이스에서 AttemptStage 를 검색한 출력 23줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AttemptStage 코드베이스 검색 — 23줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 분류가 곧 재시도 안전성이다
|
||||
|
||||
분류의 입력은 엔진이 남긴 예외 사슬이므로, 사슬 순회 순서와 **사슬에 무엇이 남았는가**가 함께 그 분류를 정한다.
|
||||
|
||||
:::note
|
||||
|
||||
없음 — 분류기와 결정표를 코드로 확인했다
|
||||
|
||||
:::
|
||||
|
||||
## 두 축
|
||||
|
||||
`AttemptStage`는 한 번의 물리적 시도가 어디까지 진행했는지를 담는 단조 진행 표식이다.
|
||||
|
||||
```java
|
||||
public enum AttemptStage {
|
||||
VALIDATION(0, true),
|
||||
AUTHENTICATION(1, true),
|
||||
POOL_ACQUIRE(2, true),
|
||||
DNS(3, true),
|
||||
CONNECT(4, true),
|
||||
TLS_HANDSHAKE(5, true),
|
||||
PROXY_CONNECT(6, true),
|
||||
REQUEST_HEADERS(7, false),
|
||||
REQUEST_BODY(8, false),
|
||||
RESPONSE_HEADERS(9, false),
|
||||
RESPONSE_BODY(10, false),
|
||||
COMPLETE(11, false);
|
||||
```
|
||||
|
||||
두 번째 인자가 `provesNotSent`다. `REQUEST_HEADERS` 앞의 일곱 단계는 전부 `true`이고, 요청 쓰기가 시작된 뒤로는 전부 `false`다. 첫 인자인 `order`는 명시적 진행 순위이며, javadoc이 그 이유를 적는다 — "the progress tracker forbids regression and the evidence classifier reads the rank, so neither depends on enum declaration ordinals."
|
||||
|
||||
`FailureCategory`는 무엇이 실패했는지를 담는 전송 중립 어휘 23개다. `permanent()`가 그중 여덟을 영구로 표시한다.
|
||||
|
||||
```java
|
||||
public boolean permanent() {
|
||||
return this == CONFIGURATION
|
||||
|| this == TARGET_REJECTED
|
||||
|| this == TLS_PERMANENT
|
||||
|| this == SERIALIZATION
|
||||
|| this == RESPONSE_TOO_LARGE
|
||||
|| this == REDIRECT_REJECTED
|
||||
|| this == DEADLINE_EXCEEDED
|
||||
|| this == CANCELLED;
|
||||
}
|
||||
```
|
||||
|
||||
## 세 번째 값: 증거
|
||||
|
||||
두 축의 결과는 `TransportFailure` 레코드로 합쳐지고, 거기에 `ExecutionEvidence`가 함께 실린다.
|
||||
|
||||
```java
|
||||
public record TransportFailure(
|
||||
AttemptStage stage, ExecutionEvidence evidence, FailureCategory category, String safeReason) {
|
||||
|
||||
public static TransportFailure notSent(
|
||||
AttemptStage stage, FailureCategory category, String safeReason) {
|
||||
return new TransportFailure(stage, ExecutionEvidence.NOT_SENT, category, safeReason);
|
||||
}
|
||||
|
||||
public static TransportFailure sentNoResponse(
|
||||
AttemptStage stage, FailureCategory category, String safeReason) {
|
||||
return new TransportFailure(stage, ExecutionEvidence.SENT_NO_RESPONSE, category, safeReason);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ExecutionEvidence`는 `NOT_SENT`, `SENT_NO_RESPONSE`, `RESPONSE_RECEIVED`, `PARTIAL_RESPONSE` 넷이고, javadoc이 승격을 금지한다 — "NOT_SENT is only used when a stage failure proves the request never reached the server. A generic engine I/O failure is never upgraded to NOT_SENT."
|
||||
|
||||
`safeReason`은 고정 어휘 토큰이며 엔진 메시지가 아니다. 이유도 javadoc에 있다 — 엔진 메시지에는 전체 URL이나 해석된 주소가 들어갈 수 있다.
|
||||
|
||||
## 왜 분류가 곧 재시도 안전성인가
|
||||
|
||||
`DefaultRetryEligibilityEngine`이 이 세 값을 그대로 읽는다.
|
||||
|
||||
```java
|
||||
if (context.failureCategory().permanent()) {
|
||||
return RetryDenied.permanentFailure(context.failureCategory().name());
|
||||
}
|
||||
...
|
||||
case CONNECT -> RetryAllowed.of("CONNECT");
|
||||
```
|
||||
|
||||
같은 실패가 `TLS_PERMANENT`로 분류되면 절대 재시도되지 않고, `CONNECT`로 분류되면 멱등성과 무관하게 재시도된다. 두 경로 사이에 정책이 개입할 여지가 없다. 분류가 곧 결정이다.
|
||||
|
||||
## 이 모델의 입력 한계
|
||||
|
||||
분류의 입력은 엔진이 호출자에게 남긴 예외 사슬이다. `ApacheFailureClassifier.classify`는 사슬 전체를 훑는다.
|
||||
|
||||
```java
|
||||
for (Throwable cause : chain(failure)) {
|
||||
TransportFailure recognized = recognize(cause, lastObservedStage);
|
||||
if (recognized != null) {
|
||||
return recognized;
|
||||
}
|
||||
}
|
||||
return conservative(lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
|
||||
```
|
||||
|
||||
주석이 그렇게 하는 이유를 적는다 — Spring이 엔진 예외를 감싸고, 감싸인 `ConnectException`도 요청이 전송되지 않았음을 증명하므로 바깥 타입만 보면 증명 가능한 `NOT_SENT`를 모호한 `SENT_NO_RESPONSE`로 낮추게 된다.
|
||||
|
||||
그러나 사슬 순회는 사슬에 있는 것만 볼 수 있다. 엔진이 원인을 버리면 이 모델은 그 원인을 분류할 수 없고, 그때는 순회 순서를 어떻게 짜도 결과가 달라지지 않는다. 그 경계가 실제로 어떻게 드러났는지는 관련 Case에 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: retry-safety-is-decided-by-evidence
|
||||
title: 재시도 안전성은 증거에 기반해 판정한다
|
||||
topic: http-failure-classification
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: decision:retry-safety-is-decided-by-evidence
|
||||
decisionStatus: ADOPTED
|
||||
decidedOn: 2026-08-30
|
||||
source:
|
||||
- src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/TransportFailure.java
|
||||
- src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/api/operation/ExecutionEvidence.java
|
||||
- src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/DefaultRetryEligibilityEngine.java
|
||||
- docs/adr/ADR-GRPC-003-three-axis-execution-evidence.md
|
||||
- analysis/11-adapter-outbound-httpclient.md
|
||||
---
|
||||
|
||||
# 재시도 안전성은 증거에 기반해 판정한다
|
||||
|
||||
## 결정문
|
||||
|
||||
재시도 여부는 무엇이 실패했는지가 아니라 무엇이 관측됐는지로 판정한다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
같은 예외라도 요청이 서버에 닿았는지에 따라 재시도의 의미가 완전히 달라진다. 닿지 않았으면 재시도는 첫 시도이고, 닿았을지도 모르면 재시도는 중복이다. 예외 타입은 그 구분을 담지 않으므로, 관측된 진행 정도를 별도의 값으로 기록하고 그것을 판정 입력으로 삼는다.
|
||||
|
||||
이 판정을 보수적으로 유지하는 것이 핵심이다. 전송되지 않았다는 판정은 단계 실패가 그것을 증명할 때만 쓰고, 일반적인 엔진 입출력 실패는 결코 그 판정으로 승격되지 않는다. 모호한 것을 전송되지 않음으로 추측하는 것이 타임아웃을 중복 결제로 바꾸는 경로다.
|
||||
|
||||
같은 원칙이 이 저장소의 gRPC 쪽에도 별도 ADR 로 채택되어 있다. 거기서는 전송 증거와 업무 증거와 스트림 증거를 세 축으로 나누고 축 사이의 추론을 금지한다. HTTP 쪽의 이 결정은 그 셋 중 전송 축에 해당한다.
|
||||
|
||||
## 영향
|
||||
|
||||
감수하는 것
|
||||
|
||||
재시도 가능한 실패를 재시도하지 않는 경우가 생긴다. 증거가 모호하면 안전한 쪽으로 판정하므로, 실제로는 전송되지 않았는데 재시도를 포기하는 호출이 있다.
|
||||
|
||||
분류기마다 이 증거를 정확히 채워야 한다. 엔진별 분류기가 늘어날 때마다 같은 판정 품질을 유지해야 하는 비용이 붙는다.
|
||||
|
||||
증거를 만드는 계층이 정보를 버리면 판정 전체가 틀린 입력 위에 선다. 이 취약점은 실제로 관측되었다. 다중 주소 호스트에서 연결 루프가 앞선 주소의 실패를 버리면, 영구 실패가 재시도 가능한 범주로 내려온다. 분류기 안에서는 고칠 수 없다.
|
||||
|
||||
얻는 것
|
||||
|
||||
같은 실패에 대해 Apache JDK Reactor Netty Jetty 가 동일한 재시도와 관측 동작을 낸다.
|
||||
|
||||
재시도 정책이 HTTP 메서드 같은 간접 신호에 기대지 않는다.
|
||||
|
||||
## 근거
|
||||
|
||||
- **전송 실패를 단계와 범주 두 축으로 모델링한다**
|
||||
이 결정이 만들어 내는 자료 구조다. TransportFailure 의 notSent 와 sentNoResponse 팩토리, 그리고 ExecutionEvidence 의 네 값이 결정의 구현체다.
|
||||
- **재시도 가능성은 멱등성과 실패 범주를 함께 봐야 정해진다**
|
||||
이 결정을 재시도 결정표로 옮긴 규칙이다.
|
||||
- **붉은 테스트를 제품 결함으로 읽은 오진**
|
||||
이 결정의 취약점이 실제로 드러난 사례다.
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: a-classifier-sees-only-what-the-engine-kept
|
||||
title: 분류기는 엔진이 남긴 것만 볼 수 있다
|
||||
topic: http-failure-classification
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: reference:a-classifier-sees-only-what-the-engine-kept
|
||||
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
|
||||
---
|
||||
|
||||
# 분류기는 엔진이 남긴 것만 볼 수 있다
|
||||
|
||||
## 목적
|
||||
|
||||
예외를 자기 범주로 번역하는 계층이 무엇을 분류할 수 있는지에 상한이 있다는 것을 잊고, 분기 순서를 고쳐서 해결하려 드는 것을 막는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 분류 가능한 것의 상한은 사슬에 남은 것이다
|
||||
번역 계층은 그 아래 엔진이 버리지 않고 남긴 원인만 볼 수 있다. 사슬에 없는 원인은 어떤 순회 순서로도 분류되지 않는다.
|
||||
|
||||
2. 이름 하나가 여러 엔드포인트로 풀리면 마지막 것만 남을 수 있다
|
||||
Apache HttpClient 5 의 다중 주소 연결 루프는 마지막이 아닌 주소의 실패를 삼킨다. 호스트명이 여러 주소로 풀리면 호출자에게 도달하는 것은 마지막 주소의 오류뿐이다.
|
||||
|
||||
3. 분류를 고치기 전에 입력을 먼저 확인한다
|
||||
잘못된 분류를 보면 분기 순서부터 의심하게 된다. 순서를 바꾸기 전에 사슬을 출력해서 원인이 실제로 거기 있는지 본다.
|
||||
|
||||
4. 테스트는 주소를 고정한다
|
||||
실패 분류를 검증하는 테스트가 호스트명을 쓰면, 그 테스트는 이름 해석이라는 통제되지 않은 변수를 함께 검증한다. 루프백 주소를 직접 쓰거나 주소 패밀리를 고정한다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
이름 하나가 여러 엔드포인트로 풀리는 모든 클라이언트 : DNS A 와 AAAA, 서비스 디스커버리, 다중 브로커 부트스트랩
|
||||
|
||||
실패 분류가 재시도 안전성이나 보안 판정으로 이어지는 곳 : 특히 중요
|
||||
|
||||
## 예외
|
||||
|
||||
모든 주소가 같은 이유로 실패하면 마지막 오류가 대표성을 가지므로 문제가 되지 않는다. 강등은 패밀리별 또는 엔드포인트별 실패 양상이 다를 때만 일어난다.
|
||||
|
||||
## 예시
|
||||
|
||||
인증서가 신뢰 불가면 두 주소 패밀리 모두 TLS 에서 실패하고, 마지막 오류도 핸드셰이크 예외라 분류가 맞는다.
|
||||
|
||||
한 패밀리는 TLS 를 거절하고 다른 패밀리는 연결이 거부되면, 영구 실패가 일시적 연결 실패로 보고된다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **붉은 테스트를 제품 결함으로 읽은 오진**
|
||||
이 규칙을 끌어낸 사례다. 사슬을 출력하기 전까지는 분기 순서가 원인으로 보였다.
|
||||
- **원인 사슬은 가장 구체적인 분류가 이기도록 순회한다**
|
||||
이 규칙과 짝을 이룬다. 그 규칙은 사슬 안에서의 선택을, 이 규칙은 사슬 자체의 한계를 다룬다.
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: retryability-needs-both-idempotency-and-category
|
||||
title: 재시도 가능성은 멱등성과 실패 범주를 함께 봐야 정해진다
|
||||
topic: http-failure-classification
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: reference:retryability-needs-both-idempotency-and-category
|
||||
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
|
||||
---
|
||||
|
||||
# 재시도 가능성은 멱등성과 실패 범주를 함께 봐야 정해진다
|
||||
|
||||
## 목적
|
||||
|
||||
한 축만 보고 재시도를 정해서, 비멱등 요청을 반복하거나 영구 실패를 무한히 되풀이하는 것을 막는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 두 입력의 곱이다
|
||||
범주가 재시도 가능하고 동시에 요청이 재시도 안전할 때만 재시도한다. 범주만 보면 비멱등 요청을 재시도하고, 멱등성만 보면 영구 실패를 반복한다.
|
||||
|
||||
2. 전송되지 않았다는 증거는 멱등성 요구를 완화한다
|
||||
아무것도 서버에 닿지 않았음이 증명되면 그 시도는 없던 일이므로 멱등성을 묻지 않아도 된다. 다만 그 증거는 증명일 때만 쓴다.
|
||||
|
||||
3. 증거는 승격하지 않는다
|
||||
일반적인 엔진 입출력 실패를 전송되지 않음으로 올리지 않는다. 모르는 것은 모르는 채로 둔다. 추측해서 올린 판정이 중복 결제를 만든다.
|
||||
|
||||
4. 응답이 전달되기 시작했으면 재시도하지 않는다
|
||||
첫 바이트가 호출자에게 전달된 뒤에는 범주와 무관하게 재시도가 막힌다.
|
||||
|
||||
5. 정책의 화이트리스트로 terminal 판정을 되살리지 않는다
|
||||
화이트리스트는 어떤 범주가 재시도될 수 있는지를 넓히지, 이 실패에 대한 판정을 뒤집지 않는다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
HTTP gRPC 메시징 클라이언트의 재시도 결정
|
||||
|
||||
멱등성 여부가 요청마다 다른 경로
|
||||
|
||||
## 예외
|
||||
|
||||
분류기가 terminal 로 표시한 실패는 정책으로 되살릴 수 없다.
|
||||
|
||||
## 예시
|
||||
|
||||
이 저장소는 전송되지 않음 증거를 별도 축으로 두어 완화를 표현한다. 재시도 컨텍스트에 HTTP 메서드가 없고, 멱등성 키가 전송 여부까지 요구하며, 첫 바이트 전달은 되돌릴 수 없는 래치다.
|
||||
|
||||
부분 응답은 안전하게 멱등인 요청에 대해서만 재시도된다. 그 외에는 원격 결과 불명으로 남긴다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **전송 실패를 단계와 범주 두 축으로 모델링한다**
|
||||
이 규칙의 입력을 만드는 모델이다.
|
||||
- **재시도 안전성은 증거에 기반해 판정한다**
|
||||
이 규칙을 채택한 프로젝트 결정이다.
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: verify-runtime-shape-at-runtime
|
||||
title: 런타임의 모양에 대한 주장은 런타임에서 확인한다
|
||||
topic: http-failure-classification
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: reference:verify-runtime-shape-at-runtime
|
||||
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
|
||||
---
|
||||
|
||||
# 런타임의 모양에 대한 주장은 런타임에서 확인한다
|
||||
|
||||
## 목적
|
||||
|
||||
코드를 읽어 얻은 추론이 관측으로 굳어지는 것을 막는다. 특히 실패하는 테스트를 결함의 증거로 읽는 순간을 막는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 벤더 라이브러리의 런타임 동작은 읽어서 알 수 없다
|
||||
이 라이브러리는 예외를 이렇게 감쌀 것이다, 이 게이트가 이 값을 읽을 것이다, 이 경로가 프로덕션 기본값이다. 이런 문장은 전부 가설이며 확인 전까지 판정의 근거가 될 수 없다.
|
||||
|
||||
2. 붉은 테스트는 조사의 시작점이지 결론이 아니다
|
||||
실패하는 테스트는 무언가 어긋났다는 사실만 말한다. 무엇이 어긋났는지는 별도 측정이다. 테스트 이름이 가리키는 대상이 곧 원인인 경우가 오히려 드물다.
|
||||
|
||||
3. 대조는 변수 하나만 바꾼다
|
||||
같은 픽스처에서 호스트 문자열만 바꾸는 식의 대조가 가장 값싸고 결정적이다. 두 가지를 함께 바꾸면 결과를 해석할 수 없다.
|
||||
|
||||
4. 소스를 고치지 않고 확인할 방법이 대개 있다
|
||||
테스트 런타임 클래스패스를 얻어 jshell 로 재현하고, 리플렉션으로 private 메서드를 부르고, 조건만 바꿔 두 번 돌린다. 애플리케이션 소스를 건드리지 않고도 관측이 된다.
|
||||
|
||||
5. 확인 비용은 분 단위다
|
||||
이 프로젝트에서 판정 번복 한 건과 자기 교정 세 건이 전부 이 형태였고, 넷 다 측정 하나로 갈렸다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
프레임워크 드라이버 클라이언트 라이브러리의 런타임 동작에 의존하는 모든 판정
|
||||
|
||||
실패하는 테스트를 근거로 결함을 보고하려는 순간
|
||||
|
||||
관측 없이 심각도를 P1 으로 올리려는 순간
|
||||
|
||||
## 예외
|
||||
|
||||
소스가 저장소 안에 있고 그 경로가 테스트로 고정돼 있으면 읽기로 충분하다. 이 규칙은 벤더 코드의 동작에 대한 것이다.
|
||||
|
||||
## 예시
|
||||
|
||||
분기 순서를 읽고 예외 사슬의 모양을 단정했다. 사슬을 출력하니 달랐다.
|
||||
|
||||
레지스트리에 검사가 없다고 단정했다. 빌드 파일의 주석이 가리키는 세 곳을 따라가니 있었다.
|
||||
|
||||
전역 승인 게이트가 임계값을 읽을 것이라고 가정했다. 그 가드는 해당 필드를 읽지 않았다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **붉은 테스트를 제품 결함으로 읽은 오진**
|
||||
이 규칙을 끌어낸 사례다. 네 건 중 가장 비쌌던 것이다.
|
||||
- **분류기는 엔진이 남긴 것만 볼 수 있다**
|
||||
같은 사례에서 나온 짝 규칙이다. 이쪽은 방법을, 저쪽은 대상을 다룬다.
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: walk-the-cause-chain-most-specific-wins
|
||||
title: 원인 사슬은 가장 구체적인 분류가 이기도록 순회한다
|
||||
topic: http-failure-classification
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: reference:walk-the-cause-chain-most-specific-wins
|
||||
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
|
||||
note: 이 규칙을 처음 끌어낸 사례는 나중에 철회되었다. httpclient 의 mTLS 테스트 3건이 실패한 원인은 분기 순서가 아니라 테스트 픽스처의 호스트명이었고, 그 저장소의 분류기는 이 실패의 사례가 아니다. 규칙 자체는 다른 근거로 유효하다.
|
||||
---
|
||||
|
||||
# 원인 사슬은 가장 구체적인 분류가 이기도록 순회한다
|
||||
|
||||
## 목적
|
||||
|
||||
라이브러리가 구체적 원인을 일반적 예외로 감쌌을 때, 바깥 타입만 보고 분류해서 정확한 판정을 잃는 것을 막는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 바깥 타입 하나로 분류하지 않는다
|
||||
Spring 이 엔진 예외를 감싸고 드라이버가 자기 예외를 다시 감싼다. 바깥 타입만 매칭하면 증명 가능한 판정이 모호한 판정으로 내려간다.
|
||||
|
||||
2. 기준은 순서가 아니라 구체성이다
|
||||
이 사슬에서 가장 구체적인 분류가 이기는가를 묻는다. 구현은 두 가지다. 구체적 분기를 앞으로 옮기거나, 사슬 전체를 훑어 최선의 매치를 고른다.
|
||||
|
||||
3. 사이클 안전을 확보한다
|
||||
이미 본 예외를 IdentityHashMap 으로 표시하거나 최대 깊이를 둔다. 순환 참조는 실제로 나타난다.
|
||||
|
||||
4. 벤더별 곁가지를 따라간다
|
||||
getCause 만으로는 부족하다. SQLException 의 getNextException 처럼 벤더가 따로 두는 연결 고리가 있다.
|
||||
|
||||
5. 이 규칙이 실패의 원인이 아닐 수도 있다
|
||||
잘못된 분류를 봤을 때 순서를 의심하기 전에, 그 원인이 사슬에 실제로 있는지 먼저 확인한다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
드라이버나 클라이언트 예외를 자기 범주로 번역하는 모든 분류기
|
||||
|
||||
특히 전송 계층 : 감싸기가 흔하다
|
||||
|
||||
## 예외
|
||||
|
||||
바깥 예외가 실제로 더 구체적인 경우가 있다. 그때는 순서가 아니라 우선순위 표가 필요하고, 그 표를 테스트로 고정해야 한다.
|
||||
|
||||
## 예시
|
||||
|
||||
이 저장소의 mongo 실패 추출기와 bulk 실패 추출기는 IdentityHashMap 으로 방문 표시를 두고 사슬을 훑는다.
|
||||
|
||||
jpa 의 SQL 상태 해석기는 getNextException 을 따라간다.
|
||||
|
||||
httpclient 의 분류기는 사슬 전체를 훑는다. 주석이 이유를 적는다. 감싸인 ConnectException 도 요청이 전송되지 않았음을 증명하므로, 바깥 타입만 매칭하면 증명 가능한 NOT_SENT 를 모호한 SENT_NO_RESPONSE 로 낮추게 된다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **분류기는 엔진이 남긴 것만 볼 수 있다**
|
||||
이 규칙의 상한을 정한다. 사슬에 없는 원인은 어떤 순회로도 분류되지 않는다.
|
||||
- **붉은 테스트를 제품 결함으로 읽은 오진**
|
||||
이 규칙을 잘못 적용한 기록이다.
|
||||
|
||||
Reference in New Issue
Block a user