Java PKIX path building failed 트러블슈팅

2026. 6. 18. 15:15ㆍ기타

  상황  

외부시스템(https://api.external-server.com)과 RestTemplate으로 연동하고 있던 상황에서

상대 서버에서 SSL 인증서 교체 작업을 진행한 뒤, 기존에 잘 되던 API 호출이 갑자기 아래와 같은 에러메세지를 내뱉으며 실패하기 시작하였습니다.

 

  에러메세지  

I/O error on POST request for "https://api.external-server.com/api/service":
PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target;
nested exception is javax.net.ssl.SSLHandshakeException: PKIX path building failed

 

 

  에러메세지 이해  

SSLHandshakeException: PKIX path building failed 에러는 SSL 인증서 유효성을 확인할 수 없을 때 발생하는 에러입니다.

우선, 이를 이해하기 위한 개념을 간단하게 알아보겠습니다.


HTTPS와 SSL

HTTP로 통신 시 데이터가 평문으로 오갑니다. 따라서 중간에 누군가가 네트워크 패킷을 가로채면 내용을 다 볼 수 있습니다.

이러한 문제 때문에 HTTPS를 사용하는데 HTTPS는 HTTP 위에 SSL/TLS라는 보안 계층을 추가하여 데이터를 암호화한 뒤 전송하는 방식입니다. 따라서 중간에서 패킷을 가로채더라도 내용을 해독하기 어려워집니다.

 

하지만, 단순히 데이터를 암호화하는 것만으로는 충분하지 않습니다.

제가 https://api.external-server.com에 접속했을 때, 정말 해당 서버와 통신하고 있는지도 매우 중요합니다.

만약 누군가 중간에서 가짜 서버를 만들어 놓고 "내가 api.external-server.com이다"라고 주장한다면 암호화된 통신을 하더라도 공격자와 통신하게 될 수 있습니다.

그래서 SSL 인증서(정확히는 TLS 인증서)가 필요한데, SSL/TLS 인증서는 특정 도메인의 소유자임을 증명하는 디지털 문서를 말합니다.

 

HTTPS 연결이 시작되면 서버는 자신의 인증서를 클라이언트에게 전달합니다.

클라이언트는 이 인증서가 신뢰할 수 있는 인증기관(CA)에 의해 발급되었는지 검증한 뒤 통신을 계속 진행합니다.

 

즉, HTTPS는 아래와 같은 두 가지 역할을 수행합니다.

  1. 데이터를 암호화하여 내용을 보호
  2. 인증서를 통해 서버의 신원을 검증

 

CA(Certificate Authority)

인증서를 아무나 만드는 건 의미가 없으니까 CA라는 공인된 기관이 발급합니다.

대표적으로는 Sectigo, DigiCert 같은 회사들이 있습니다.

서버 운영자가 CA에 인증서 발급을 요청하면 CA가 도메인 소유자인지 검증한 뒤에 인증서를 발급합니다.

 

인증서 체인

실제로는 Root CA가 직접 서버 인증서를 발급하지 않고 아래와 같은 계층적인 구조를 띄도록 발급합니다.

Root CA
  └─ 중간 CA (Intermediate CA A, 발급자: Root CA)
  	└─ 중간 CA (Intermediate CA B, 발급자: CA A)
		└─ 서버 인증서 (external-server.com, 발급자: CA B)

 

Root CA는 최상위 인증서이기 때문에 Root CA를 보호하기 위해 Intermediate CA를 두고 거기서 인증서를 발급합니다.

 

Java의 Truststore

클라이언트가 서버에 접속하면 서버가 인증서를 보여줍니다.

그럼 클라이언트는 이 인증서가 믿을만한 인증서인가를 확인하게 되는데 Java는 자체 Truststore인 cacerts 파일을 사용합니다.

이 파일에는 주요 Root CA 인증서가 미리 등록되어 있습니다.

 

cacerts 파일 위치는 $JAVA_HOME/lib/security/cacerts이며, 실제 경로는 아래 명령으로 확인할 수 있습니다.

readlink -f $(which java)
# 예: /usr/lib/jvm/java-11-amazon-corretto.x86_64/bin/java
# → cacerts: /usr/lib/jvm/java-11-amazon-corretto.x86_64/lib/security/cacerts

 

인증서 검증 순서

  1. 서버가 인증서를 보냄 (서버 인증서 + 중간 CA 인증서들)
  2. Java가 인증서 체인을 따라 올라가면서 각 인증서의 발급자(issuer)를 확인
  3. 최종적으로 도달한 CA가 cacerts에 등록된 신뢰할 수 있는 Root CA인지 확인

 

  환경  

  • AWS EC2 Amazon Linux
  • Java: Amazon Corretto 11
  • SpringBoot + RestTemplate

 

  트러블 슈팅 과정  

1단계: openssl로 상대 서버 인증서 확인

먼저 openssl로 상대 서버의 인증서를 확인했습니다.

openssl s_client -connect api.external-server.com:443 -showcerts

 

결과를 보니 아래처럼 자체 서명 인증서가 나왔습니다.

depth=0 CN = PARTNER-SSLVPN-CA
verify error:num=18:self signed certificate

 

 

 

2단계: cacerts에 자체 서명 인증서 추가

자체 인증서를 사용하는 걸 확인하고 인증서를 파일로 저장하고 cacerts에 등록했습니다.

echo | openssl s_client -connect api.external-server.com:443 2>/dev/null \
  | openssl x509 -outform PEM > partner.crt

 

echo | 로 빈 입력을 보내 연결을 즉시 종료시키고, 서버 인증서를 PEM 형식으로 변환 후 partner.crt로 저장합니다.

(2>/dev/null: 불필요한 에러 출력을 버림)

 

keytool -importcert -alias partner-cert \
  -file partner.crt \
  -keystore /etc/pki/ca-trust/extracted/java/cacerts \
  -storepass changeit

 

저장한 인증서를 partner-cert라는 별칭으로 cacerts에 등록합니다.

(changeit은 java cacerts의 기본값)

 

이렇게 등록 후 앱을 재시작 후에 확인했지만 여전히 동일한 에러가 발생했습니다.

 

3단계: SNI에 따라 다른 인증서

cacerts를 적용하기 위해서는 앱을 재시작해야 했기 때문에 조금 더 용이하게 원인을 파악하기 위해서 별도의 테스트 자바 클래스를 만들었습니다.

import javax.net.ssl.*;
import java.net.URL;

public class SSLTest {
    public static void main(String[] args) throws Exception {
        URL url = new URL("https://api.external-server.com");
        HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
        conn.connect();
        System.out.println("연결 성공!");
        conn.disconnect();
    }
}

 

그리고 -Djavax.net.debug=ssl:handshake옵션으로 SSL 핸드셰이크 과정의 상세 로그를 출력해 아래 명령어로 살펴보니 결정적인 단서가 있었습니다.

java -Djavax.net.debug=ssl:handshake SSLTest 2>&1 | grep -E "issuer|subject"

 

grep으로 issuer(발급자), subject(주체) 정보만 필터링했을 때 아래와 같은 로그가 나왔습니다.

"issuer"  : "CN=Sectigo Public Server Authentication CA DV R36, ..."
"subject" : "CN=external-server.com"
"issuer"  : "CN=Sectigo Public Server Authentication Root R46, ..."
"subject" : "CN=Sectigo Public Server Authentication CA DV R36, ..."

 

Java가 받고 있는 인증서는 이전에 확인했을 때와 다르게 자체 서명 인증서가 아닌 Sectigo에서 발급한 정상적인 인증서였습니다.

 

하나의 서버(같은 IP)에는 여러 도메인이 연결되어 있을 수 있는데 그 여러 도메인 중에 어떤 곳에 접속하지 알려주지 않으면 서버가 기본 인증서(이 경우에는 VPN용 자체 서명 인증서)를 내려줍니다.

그래서 서버에 어떤 도메인에 접속할 것인지 알려주기 위해 SNI(Server Name Indication)를 포함한 명령어를 입력했습니다.

echo | openssl s_client -connect api.external-server.com:443 \
  -servername api.external-server.com -showcerts 2>/dev/null | grep -E "s:|i:"

 

위 명령어를 통해 서버가 보내는 인증서 체인에서 발급자(i)와 주체(s)만 추출했습니다.

 

 

4단계: 진짜 원인 발견 - 중간 인증서 누락과 새 Root CA

0 s:/CN=external-server.com
  i:/CN=Sectigo Public Server Authentication CA DV R36
1 s:/CN=Sectigo Public Server Authentication CA DV R36
  i:/CN=Sectigo Public Server Authentication Root R46

 

확인했을 때 서버가 보내는 인증서는 총 2개뿐이었습니다.

1번 인증서(DV R36)의 발급자가 R46인데 R46인증서 자체는 서버가 보내주고 있지 않았습니다.

이때쯤, 외부시스템에서 별도로 전달받은 인증서 파일(CA 번들)을 열어보니 체인은 다음과 같이 구성되어 있었습니다.

USERTrust RSA (Root CA)
  └─ Sectigo Root R46 (발급자: USERTrust RSA)
      └─ Sectigo DV R36 (발급자: R46)
          └─ external-server.com (서버 인증서)

 

여기서 두 가지 사실을 알게 되었습니다.

  • Sectigo가 기존 다목적 Root에서 단일 목적 Root인 R46/E46으로 인증서 발급 계층을 전환했는데 이로 인해서 R46은 2022년 이후에 추가되었음. 따라서, JDK 버전에 따라 cacerts에 R46이 없을 수 있음.
  • Sectigo는 이런 호환성 문제 해결을 위해 R46을 기존 Root인 USERTrust RSA로 교차 서명한 버전을 별도로 제공.
    이 교차 서명은 발급자가 USERTrust RSA이므로 중간 인증서처럼 동작하여, R46을 모르는 클라이언트도 
    R46 → USERTrust RSA로 체인을 완성할 수 있게 해 줌.

이 사실을 알게 된 상태로 우선 cacerts에 등록된 CA를 확인해 보았습니다.

keytool -list -cacerts -storepass changeit | grep -i "usertrust"

 

확인해 본 결과 USERTrust RSA Root CA는 이미 등록되어 있었지만, R46은 등록되어있지 않았습니다.

그래서 첫 번째 원인은 JDK 버전이 오래되어서 R46이 cacerts에 없다는 게 문제라는 걸 확인했습니다.

 

그리고 아까 외부시스템에서 전달받은 CA번들을 확인했을 때는 교차 서명된 R46 인증서가 포함되어 있었습니다.

즉, 서버가 이 교차 서명을 함께 보내줬다면 R46 → USERTrust RSA로 체인이 이어져야 했습니다.

그래서 두 번째 원인은 서버에서 중간 인증서를 제대로 보내주지 않는다는 점이 문제였습니다.

 

5단계: CA를 cacerts에 등록해서 임시 해결

우선, 빠르게 애플리케이션이 정상적으로 동작해야 했으므로 임시방편으로 상대방 서버에서 보내주는 인증서 체인을 추출해 cacerts에 등록하기로 결정했습니다.

openssl s_client -connect api.external-server.com:443 \
  -servername api.external-server.com -showcerts 2>/dev/null </dev/null \
  | awk '/BEGIN/,/END/{print}' > chain.pem

 

서버가 보내는 인증서 체인 전체를 PEM 블록 (BEGIN ~ END)만 추출하여 chain.pem 파일로 저장합니다.

csplit -z chain.pem '/BEGIN CERTIFICATE/' '{*}' --prefix=cert_ --suffix-format='%02d.pem'

 

chain.pem 안에 들어있는 여러 인증서를 BEGIN CERTIFICATE를 기준으로 분리하여 cert_00.pem, cert_01.pem 형태의

개별파일로 분리합니다.

keytool -importcert -alias sectigo-dv-r36 \
  -file cert_01.pem \
  -keystore /etc/pki/ca-trust/extracted/java/cacerts \
  -storepass changeit

 

분리한 중간 인증서를 cacerts에 등록합니다.

이렇게 하면 Java가 cacerts에서 직접 찾아 체인을 완성할 수 있습니다.

 

테스트 클래스로 확인한 결과 연결에 성공했고, 앱 재시작 후 외부 서비스 연동도 정상적으로 동작하는 걸 확인했습니다.

 

 

  조금 더 나아가기  

이번에 cacerts에 직접 등록하는 방식은 결국 R46 → USERTrust RSA로 체인을 잇는 방식입니다.

즉, 오래된 USERTrust RSA Root에 의존하는 셈입니다.

 

CA/Browser Forum과 각 Root 프로그램(Chrome, Mozilla 등)의 정책 변화로, 기존 다목적 Root들이 단계적으로 신뢰를 잃게 됩니다. Sectigo 공식 자료에 따르면 USERTrust RSA는 아래와 같은 일정으로 TLS 신뢰가 제거될 예정입니다.

 

  • 2026년 6월 15일: Chrome SCTNotAfter 적용 시점 (이 시점 이후 발급된 인증서는 USERTrust RSA 체인으로 신뢰받지 못함)
  • 2027년 4월 15일: Chrome/Mozilla에서 USERTrust RSA 체인의 모든 인증서 신뢰 완전 제거

이 일정은 브라우저 기준이지만, USERTrust RSA에 의존하는 교차 서명 경로는 수명이 정해져 있다는 방향성은 분명합니다.

 

그리고 앞으로 외부시스템에서 또 인증서를 변경할 때, R46이 아닌 다른 CA로 변경될 수도 있습니다.

결과적으로 현재의 해결 방법은 임시적일 뿐 앞으로의 변경사항에 대해 대비된 해결방법은 아니라는 부분입니다.

 

따라서, 최신 JDK로 업데이트하면 교차 서명 없이도 이 문제가 해결될 수 있고 다른 최신 CA에 대해서도 대비가 되기 때문에

주기적으로 JDK 패치를 따라가며 업데이트를 해주도록 하였습니다.

corretto-11 github