EVD-332 adapter-outbound-httpclient 듀얼스택 localhost 가 영구 TLS 실패를 재시도 가능한 CONNECT 로 강등시킨다 측정일 2026-08-31 · workmachine 컨테이너 ──────────────────────────────────────────────────────────────────────── 1. 관측: 이 저장소에서 이번 사이클 유일하게 실패한 테스트 ──────────────────────────────────────────────────────────────────────── $ ./gradlew :adapter:outbound:httpclient:test MutualTlsHandshakeContractTest > anUntrustedAuthorityIsAPermanentTlsFailure() FAILED org.opentest4j.AssertionFailedError at MutualTlsHandshakeContractTest.java:168 MutualTlsHandshakeContractTest > anExpiredCertificateIsAPermanentTlsFailure() FAILED org.opentest4j.AssertionFailedError at MutualTlsHandshakeContractTest.java:168 MutualTlsHandshakeContractTest > aHostnameMismatchIsAPermanentTlsFailure() FAILED org.opentest4j.AssertionFailedError at MutualTlsHandshakeContractTest.java:168 283 tests completed, 3 failed (모듈 전체) 5 tests completed, 3 failed (해당 클래스만) XML 리포트에서 뽑은 실제 메시지 — 세 건 모두 동일: expected: TLS_HANDSHAKE but was: CONNECT 실패 지점 MutualTlsHandshakeContractTest.java:164-171 private void assertPermanent(Throwable captured) { TransportFailure classified = new ApacheFailureClassifier().classify(captured, AttemptStage.TLS_HANDSHAKE); assertThat(classified.evidence()).isEqualTo(ExecutionEvidence.NOT_SENT); // 167 통과 assertThat(classified.stage()).isEqualTo(AttemptStage.TLS_HANDSHAKE); // 168 실패 assertThat(classified.category()).isEqualTo(FailureCategory.TLS_PERMANENT); assertThat(classified.category().permanent()).isTrue(); } 같은 클래스의 나머지 2건은 통과했다: aClientCertificateSatisfiesAServerThatRequiresOne (startTls(..., true), 핸드셰이크 성공) aMissingClientCertificateCannotReachTheUpstream (startTls(..., true), RuntimeException 여부만 단언) 실패한 3건만 startTls(..., false) 를 쓴다 — 이것이 처음 눈에 띈 차이였고, 오해의 출발점이었다. ──────────────────────────────────────────────────────────────────────── 2. 첫 가설과 그 기각 ──────────────────────────────────────────────────────────────────────── 가설 A: ApacheFailureClassifier.classify 가 원인 사슬을 바깥→안으로 훑으며 첫 매치를 반환(ApacheFailureClassifier.java:36-43)하므로, Apache 가 SSLHandshakeException 을 연결 예외로 감싸면 CONNECT 규칙(58-64행)이 TLS 규칙(65-72행)을 가린다. 기각. jshell 로 실제 예외 사슬을 떠보니 SSL 예외가 사슬에 아예 없었다: [0] ResourceAccessException :: ... Connect to https://localhost:56507 failed: Connection refused [1] HttpHostConnectException :: Connect to https://localhost:56507 failed: Connection refused 분류기는 자기가 받은 것을 정확히 분류했다. 정보는 도착 전에 이미 사라졌다. 가설 B: startTls(..., false) 일 때 MockWebServer 가 뜨지 않는다. 기각. 두 경우 모두 원시 소켓 접속이 성공한다: === requireClientAuth=TRUE port=49531 === raw 127.0.0.1: OK raw localhost: OK === requireClientAuth=FALSE port=37221 === raw 127.0.0.1: OK raw localhost: OK ──────────────────────────────────────────────────────────────────────── 3. 확정된 기전 — 접속 호스트만 바꾼 대조 실험 ──────────────────────────────────────────────────────────────────────── 동일 서버·동일 클라이언트 신뢰재료, baseUrl 의 호스트만 교체: === untrusted-authority @127.0.0.1 === [0] ResourceAccessException :: (bad_certificate) PKIX path validation failed: ... signature check failed [1] javax.net.ssl.SSLHandshakeException :: (bad_certificate) PKIX path validation failed [2] sun.security.validator.ValidatorException :: PKIX path validation failed [3] java.security.cert.CertPathValidatorException :: signature check failed [4] java.security.SignatureException :: Signature does not match. -> stage=TLS_HANDSHAKE category=TLS_PERMANENT permanent=true ← 테스트가 기대하는 값 === untrusted-authority @localhost === [0] ResourceAccessException :: Connect to https://localhost:40485 failed: Connection refused [1] HttpHostConnectException :: Connect to https://localhost:40485 failed: Connection refused -> stage=CONNECT category=CONNECT permanent=false ← 테스트가 본 값 === expired-certificate @127.0.0.1 === [1] SSLHandshakeException :: (certificate_expired) PKIX path validation failed: validity check failed [4] java.security.cert.CertificateExpiredException :: NotAfter: Sun Aug 30 13:52:15 UTC 2026 -> stage=TLS_HANDSHAKE category=TLS_PERMANENT permanent=true 환경: /etc/hosts 127.0.0.1 localhost ::1 localhost ip6-localhost ip6-loopback lo inet6 ::1/128 scope host InetAddress.getAllByName("localhost") -> [localhost/127.0.0.1, localhost/0:0:0:0:0:0:0:1] preferIPv4Stack / preferIPv6Addresses 설정: 저장소·모듈 어디에도 없음 기전: MockWebServer 는 IPv4 루프백에만 바인딩한다. MockHttpServer.uri() 는 호스트명 "localhost" 를 돌려준다(MockHttpServer.java:70-72). Apache HttpClient 5 의 연결 오퍼레이터는 해석된 주소 전체를 순회하며, 마지막 주소가 아니면 그 주소의 실패를 삼키고 다음으로 넘어간다. 127.0.0.1 → TCP 성공 → TLS 핸드셰이크 실패(진짜 실패) → 삼켜짐 ::1 → TCP 거부(듣는 소켓 없음) → 마지막 주소 → HttpHostConnectException 으로 승격 따라서 호출자에게 도달하는 유일한 예외는 두 번째 주소의 연결 거부이고, 진짜 원인인 SSLHandshakeException 은 사슬 어디에도 남지 않는다. 핸드셰이크가 성공하는 테스트가 통과하는 이유도 같은 루프다. 127.0.0.1 에서 성공하면 루프가 즉시 반환하므로 ::1 을 시도하지 않는다. 즉 startTls 의 boolean 은 원인이 아니라 상관관계였다 — 실패하는 케이스가 곧 두 번째 주소까지 가는 케이스다. ──────────────────────────────────────────────────────────────────────── 4. 판정 ──────────────────────────────────────────────────────────────────────── 테스트 실패 자체는 환경 의존이다. ApacheFailureClassifier 에 결함은 없다. localhost 가 IPv4 로만 풀리는 환경에서는 세 건 모두 통과한다. 프로덕션 코드를 고쳐서 고칠 문제가 아니며, 고쳐야 할 것은 픽스처가 호스트명을 돌려준다는 점이다. 다만 이 실패가 드러낸 런타임 성질은 테스트 잡음이 아니다: FailureCategory.java:34-44 TLS_PERMANENT.permanent() == true CONNECT.permanent() == false DefaultRetryEligibilityEngine.java:37-39 permanent() 이면 RetryDenied.permanentFailure DefaultRetryEligibilityEngine.failureDecision case CONNECT -> RetryAllowed.of("CONNECT") 듀얼스택 업스트림에서 한쪽 주소 패밀리는 TLS 를 영구 거절하고 다른 쪽은 연결이 거부되면, 절대 검증되지 않을 인증서에 대한 호출이 CONNECT 로 분류되어 예산·데드라인이 소진될 때까지 매 시도 재시도된다. 분류기가 볼 수 있는 정보 안에서는 이 강등을 막을 방법이 없다. Apache 의 다중 주소 루프가 앞선 주소의 실패를 버리기 때문이다. ──────────────────────────────────────────────────────────────────────── 5. 재현 절차 ──────────────────────────────────────────────────────────────────────── CP=<:adapter:outbound:httpclient 의 test 런타임 클래스패스, 존재하는 항목만> jshell --class-path "$CP" 로 아래를 실행: MockHttpServer s = MockHttpServer.startTls(TlsFixture.trusted().serverSocketFactory(), false); // baseUrl 호스트를 "localhost" / "127.0.0.1" 로 각각 바꿔 ApacheBlockingTransportProvider 로 GET // 잡은 예외의 getCause 사슬 전체와 ApacheFailureClassifier.classify(t, TLS_HANDSHAKE) 결과를 출력 클래스패스는 init 스크립트로 test.classpath 를 출력해 얻었다(저장소 미수정). 애플리케이션 소스는 한 줄도 건드리지 않았다.