Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/http-failure-classification/case/case-a-red-test-misread-as-a-product-defect.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

12 KiB

kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
kind slug title topic project status sourceRevision rootTreeNode evidenceCapturedOn body assets evidence source
CASE a-red-test-misread-as-a-product-defect 붉은 테스트를 제품 결함으로 읽은 오진 — 듀얼스택 localhost가 TLS 실패를 버린다 http-failure-classification clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a-red-test-misread-as-a-product-defect 2026-09-02 case-a-red-test-misread-as-a-product-defect.body.md
key file
a-red-test-misread-as-a-product-defect ../../../final/evidence/rendered/a-red-test-misread-as-a-product-defect.svg
key file
a-red-test-misread-as-a-product-defect-host-control ../../../final/evidence/rendered/a-red-test-misread-as-a-product-defect-host-control.svg
../../../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
분석 문서는 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. 같은 서버와 같은 신뢰 재료로 접속 호스트만 바꿔 세 경우를 각각 실행하고, 예외 사슬과 분류 결과를 비교한다.

본문

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 에만 바인딩한다는 것은 그 결과에서 역추론했다.