PKIX path building failed 원인별 해결 — Java 인증서 오류 진단과 조치 (Tomcat·Spring·cacerts)
Java 애플리케이션이 외부 HTTPS API 를 부르다 이런 로그를 남기고 멈춘다.
javax.net.ssl.SSLHandshakeException: PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target
같은 주소가 브라우저에서는 열리기도 해서 더 헷갈린다. 검색하면 나오는 답은 대개
둘 중 하나다 — 서버 인증서를 cacerts 에 넣어라, 아니면 인증서 검증을 꺼라.
첫 번째는 원인에 따라 맞기도 하고 틀리기도 한다. 두 번째는 언제나 틀렸다.
이 메시지의 뜻은 하나다 — JVM 이 서버 인증서에서 출발해 자신이 신뢰하는 앵커까지 경로를 만들지 못했다. 서버가 체인을 덜 보냈을 수도, JVM 의 신뢰 목록이 모자랄 수도, 중간에서 다른 인증서가 끼어들었을 수도 있다. 어느 쪽인지 가르는 게 먼저다.
브라우저에서 같은 성격의 에러가 NET::ERR_CERT_AUTHORITY_INVALID 이고 원인 갈래도
상당 부분 겹친다. 브라우저 쪽은 NET::ERR_CERT_AUTHORITY_INVALID 원인별 해결에서 다뤘다.
이 글은 Java 쪽이다.
에러 메시지 해부
두 문장이 이어 붙은 형태다. 각각 OpenJDK 의 다른 클래스가 만든다.
| 조각 | 던지는 곳 | 뜻 |
|---|---|---|
PKIX path building failed: |
PKIXValidator |
CertPathBuilder 가 실패했을 때 붙이는 접두어 |
unable to find valid certification path to requested target |
SunCertPathBuilder |
신뢰 앵커까지 이어지는 경로를 찾지 못함 |
즉 경로 만들기 단계에서 실패한 것이다. 비슷해 보이지만 성격이 다른 메시지가 둘 있다.
| 메시지 | 실패 단계 | 이 글의 대상 |
|---|---|---|
PKIX path building failed + unable to find valid certification path |
경로 구성 | ✅ |
PKIX path validation failed |
경로는 만들었고 검증에서 실패 | ❌ |
TLS Server certificate issued after ... anchored by a distrusted legacy ... root CA |
CA distrust 정책 | ❌ |
PKIX path validation failed 는 같은 PKIXValidator 클래스에 따로 있는 메시지다.
대부분은 만료·폐지·알고리즘 제약 같은 검증 단계 문제라 신뢰 저장소에 루트를 더 넣어도
풀리지 않는다. 예외는 Path does not chain with any of the trust anchors 처럼 같은 이름의
옛 루트와 충돌하는 경우로, 올바른 루트로 갱신하면 풀린다. 원인은 메시지 뒤에 붙은 예외
설명에 적혀 있다.
distrust 메시지는 JDK 가 특정 CA 루트로 이어지는 일정 날짜 이후 발급분을 일부러 거부한 결과다. Oracle JDK 21 릴리스 노트 기준으로 Entrust 루트는 2024-11-11 이후 발급분(21.0.5), Chunghwa 레거시 루트는 2026-03-17 이후 발급분(21.0.11)이 대상이다. 메시지에 날짜와 CA 이름이 그대로 찍혀서 구분이 쉽다.
TLS Server certificate issued after 2026-03-17 and anchored by a distrusted legacy Chunghwa root CA: ...
이제 unable to find valid certification path 만 아래로 이어진다.
5분 진단 순서
세 단계로 본다. 뒤로 갈수록 출력이 많고, 대부분은 1~2단계에서 갈린다.
1) 서버가 실제로 보내는 체인 — openssl
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null
-showcerts 는 서버가 보낸 인증서만, 보낸 순서대로 보여 준다. 검증된 체인이 아니라
서버가 보낸 목록 그대로라는 점이 핵심이다. 출력 앞부분 Certificate chain 아래를 읽는다.
Certificate chain
0 s:CN = api.example.com
i:C = US, O = Example CA, CN = Example Intermediate
a:PKEY: ... (OpenSSL 3.x)
v:NotBefore: ...; NotAfter: ...
-----BEGIN CERTIFICATE-----
(PEM 생략)
-----END CERTIFICATE-----
1 s:C = US, O = Example CA, CN = Example Intermediate
i:C = US, O = Example CA, CN = Example Root
(이하 생략)
OpenSSL 버전에 따라 CN = api.example.com 처럼 공백이 들어가기도, CN=api.example.com 처럼
붙어 나오기도 한다.
몇 장을 보냈는지만 빨리 세려면:
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null \
| grep -c "BEGIN CERTIFICATE"
읽는 법:
| 보이는 것 | 판단 |
|---|---|
0 번 한 장뿐이고 i: 가 루트가 아님 |
중간 인증서 누락 (원인 1) |
i: 가 사내 CA 이름 |
사설 CA (원인 2) |
0 번의 s: 와 i: 가 같음 |
자체서명 (원인 2) |
| 공개 CA 체인이 온전함 | 서버는 정상. JVM 쪽을 본다 (원인 3·4·5) |
2) JVM 이 도는 자리에서 본 인증서 — keytool
openssl 은 보통 내 PC 에서 돌리지만 문제는 JVM 이 도는 서버의 네트워크에서 난다.
그 서버에서 keytool 로 다시 본다. -sslserver 는 JDK 8 keytool 에도 있다.
keytool -printcert -sslserver api.example.com:443
# 프록시를 거쳐 나가는 서버라면
keytool -J-Dhttps.proxyHost=proxy.corp -J-Dhttps.proxyPort=8080 \
-printcert -sslserver api.example.com:443
포트를 빼면 443 이다. -rfc 를 붙이면 PEM 블록만 출력하고 Owner·Issuer 줄은 찍지 않는다.
PEM 파일로 저장할 때만 쓴다. 여기서 보이는 발급자(Issuer)가
- 의 결과와 다르면 — 특히 보안 장비 이름이나 사내 CA 이름이면 — 중간에서 인증서를 바꿔 끼운 것이다 (원인 4).
3) JVM 내부 — 디버그 옵션
서버 쪽이 정상으로 보이면 JVM 이 무엇을 신뢰하고 있는지 본다.
java -Djavax.net.debug=ssl,handshake,trustmanager -jar app.jar
trustmanager 는 TrustManager 추적을 찍는다. 어떤 truststore 를 읽었는지, 서버에서
받은 체인이 무엇인지가 여기서 나온다. 원인 5 (잘못된 trustStore) 는 이 출력에서 가장
빨리 드러난다.
옵션 규칙은 이렇다.
- 구분자(
,:)와 순서는 상관없다 ssl은 data·packet·plaintext 를 뺀 SSL 디버그 전체다ssl하위 옵션:defaultctxhandshakekeygenkeymanagerpluggabilityrecordrespmgrsessionsessioncachesslctxtrustmanagerhandshake는data·verbose로,record는plaintext·packet으로 더 자세해진다all은 전부 켠다. 출력이 매우 많다-Djavax.net.debug를 값 없이 주면System.Logger의javax.net.ssl로 기록된다 (JDK 21 문서 기준)
경로를 만드는 과정 자체를 보려면 certpath 디버그를 쓴다.
java -Djava.security.debug=certpath -jar app.jar
PKIX CertPathBuilder·CertPathValidator 구현의 디버그를 켠다. OCSP 교환을 덤프하는
ocsp, 더 자세한 verbose 하위 옵션이 있다.
원인 1 — 상대 서버의 중간 인증서 누락
- 에서 서버가 자기 인증서 한 장만 보내는 경우다.
TLS 1.3 규격(RFC 8446)은 서버 인증서를 목록 맨 앞에 두고(MUST), 이어지는 인증서가 바로 앞 인증서를 인증하도록(SHOULD) 정한다. 생략해도 되는 것은 신뢰 앵커, 즉 루트뿐이다. 중간 인증서를 빼는 건 규격이 허용하는 생략이 아니다.
브라우저에서 열리는지는 판단 기준이 되지 않는다. 체인을 다루는 방식이 클라이언트마다
다르기 때문이다 — 이 차이는 크롬은 되는데 파이어폭스만 인증서 오류에서 다뤘다.
기준은 -showcerts 에 찍힌 목록이다.
Java 는 기본 설정으로 빠진 중간 인증서를 채우지 않는다. 인증서의 AIA 확장에 적힌
발급자 주소(caIssuers)를 따라가 내려받는 기능이 있지만 호환성 때문에 기본 꺼짐이고,
com.sun.security.enableAIAcaIssuers=true 로 켜야 한다.
여기에 제약이 하나 더 붙었다. Oracle JDK 21 은 21.0.10(2026-01-20)부터
com.sun.security.allowedAIALocations 필터를 도입했고, 기본값이 비어 있어 전부 거부다.
켜는 속성과 URI 허용 규칙이 둘 다 있어야 AIA 주소를 따라간다.
처리 — 서버 쪽 체인을 고친다.
- 우리 서버라면 — 서버 인증서와 중간 인증서를 함께 내보내도록 설정한다. nginx 는 같은 파일에 서버 인증서, 중간 인증서 순으로 넣는다 (Nginx 가이드). Tomcat 이 서버라면 Tomcat 가이드대로 fullchain 으로 keystore 를 만든다
- 상대(파트너·외부 API) 서버라면 —
-showcerts출력을 근거로 붙여 체인 수정을 요청한다. “우리만 안 된다”가 아니라 “서버가 한 장만 보낸다”로 말해야 빨리 고쳐진다 - 당장 못 고친다면 — 그 중간 인증서를
cacerts사본에 더한 앱 전용 truststore 로 버틸 수는 있다(중간 인증서만 든 파일을 지정하면 원인 5 가 된다). 상대가 갱신하면서 중간 인증서가 바뀌면 다시 깨진다. 기한을 정한 임시 조치로만 쓴다
원인 2 — 사설 CA·자체서명 인증서
사내 시스템, 파트너 전용 API, 개발 서버가 사설 CA 나 자체서명 인증서를 쓰는 경우다.
cacerts 에는 Oracle Java Root Certificate Program 에 든 CA 의 루트가 들어 있다. 목록에
없는 CA 를 신뢰하려면 그 CA 인증서를 신뢰 인증서로 직접 넣어야 한다. 사내 CA 가
실패하는 건 정상 동작이다.
넣기 전에 두 가지를 정한다. 무엇을 넣을지, 어디에 넣을지.
무엇을 — 지문부터 대조한다
keytool -printcert -file corp-root-ca.crt
출력된 SHA-256 지문을 발급처(사내 PKI 담당, 파트너사)와 다른 채널로 대조한다. Oracle keytool 문서가 신뢰 인증서 임포트 전에 명시적으로 요구하는 절차다. 메일로 받은 파일의 지문을 같은 메일 본문으로 확인하는 건 대조가 아니다.
keytool 은 DER(바이너리)과 PEM(-----BEGIN 으로 시작하는 Base64) 둘 다 받는다. keytool 에
넣을 때는 변환이 필요 없다(OS 시스템 트러스트는 다르다 — 아래 Debian·Ubuntu 참고).
넣을 것은 루트(또는 발급 CA) 인증서다. 서버 인증서(leaf)를 넣으면 당장은 되지만 상대가 인증서를 갱신하는 날 다시 깨진다. 자체서명 인증서는 그 자체가 앵커라 넣을 수밖에 없는데, 재발급 때마다 모든 클라이언트를 다시 손봐야 한다. 오래 쓸 시스템이면 사설 CA 로 옮기는 편이 낫다.
어디에 — 범위를 좁힌다
JVM 전역 cacerts 에 넣으면 그 JDK 를 쓰는 모든 앱이 이 CA 를 신뢰한다. 파트너 API
하나 때문이라면 범위가 너무 넓다. 좁은 순서로:
| 방법 | 영향 범위 | 적합한 경우 |
|---|---|---|
| Spring Boot SSL Bundle | 특정 HTTP 클라이언트 하나 | Spring Boot 3.1+ (RestClient 는 3.2+) 에서 특정 상대만 |
앱 전용 truststore (javax.net.ssl.trustStore) |
그 JVM 프로세스 | 배치·단독 앱 |
cacerts |
그 JDK 를 쓰는 모든 JVM | 사내 루트처럼 모두가 신뢰해야 할 때 |
| OS 시스템 트러스트 | 그 서버의 Java·OpenSSL 등 | 사내 루트 + 배포판 OpenJDK |
앱 전용 truststore 는 이렇게 만든다.
keytool -importcert -alias partner-ca -file partner-ca.crt \
-keystore app-truststore.p12 -storetype PKCS12 -storepass:env TRUSTSTORE_PASS
-storepass:env 는 환경변수에서 비밀번호를 읽는다(:file 은 파일에서). 셸 히스토리에
비밀번호가 남지 않는다. 비밀번호는 6자 이상이어야 한다.
cacerts 에 넣는 경우는 JDK 버전에 따라 명령이 다르다.
# JDK 9 이상
keytool -importcert -cacerts -alias corp-root-ca -file corp-root-ca.crt -storepass changeit
# JDK 8 (-cacerts 옵션 없음)
keytool -importcert -trustcacerts -alias corp-root-ca -file corp-root-ca.crt \
-keystore $JAVA_HOME/jre/lib/security/cacerts -storepass changeit
두 명령 모두 -noprompt 를 붙이지 않았다. keytool 은 임포트 전에 신뢰 경로를 만들어
보고(-trustcacerts 를 주면 cacerts 도 고려), 못 만들면 지문을 보여 주며 확인을 묻는다.
-noprompt 는 이 확인을 생략한다. 스크립트 편의로 건너뛰지 않는 게 요점이다.
원인 3 — 오래된 JDK 의 cacerts
서버는 공개 CA 인증서, 체인도 온전한데 특정 서버의 Java 만 실패하면 그 JDK 의
cacerts 를 의심한다.
새 루트는 JDK 업데이트를 통해 cacerts 에 추가된다. 업데이트가 멈춘 JDK 는 그 뒤에
등장한 루트를 모른다. 상대가 새 루트 체인의 인증서로 바꾸는 순간 오래된 JDK 만 끊긴다.
Oracle 릴리스 노트의 예:
| 추가된 루트 | alias | 추가된 버전 |
|---|---|---|
| Let’s Encrypt ISRG Root X2 (JDK-8317374) | letsencryptisrgx2 |
8u401, 17.0.10, 21.0.2 (2024-01-16) |
| WISeKey Global Root GB·GC CA (JDK-8372351) | wisekeyglobalrootgbca, wisekeyglobalrootgcca |
21.0.12 (2026-07-21) |
먼저 앱이 실제로 쓰는 JDK 를 확인한다. 셸의 java 와 Tomcat·서비스가 쓰는 java 가
다른 경우가 흔하다.
ps -ef | grep [j]ava # 실행 중인 java 바이너리 경로
/path/to/java -XshowSettings:properties -version 2>&1 | grep java.home
그 JDK 의 cacerts 에 필요한 루트가 있는지 본다. alias 가 아니라 주체 이름으로 찾는다.
OpenJDK 빌드의 cacerts 는 alias 뒤에 [jdk] 접미사를 붙이고(letsencryptisrgx2 [jdk]),
배포판 OpenJDK 는 alias 체계가 아예 다르다. 릴리스 노트의 alias 로 -alias 조회를 하면
루트가 있어도 “does not exist” 가 나올 수 있다.
# JDK 9 이상
keytool -list -v -cacerts -storepass changeit | grep -i "ISRG Root X2"
# JDK 8
keytool -list -v -keystore $JAVA_HOME/jre/lib/security/cacerts -storepass changeit \
| grep -i "ISRG Root X2"
더 오래된 JDK 는 사정이 더 나쁘다. OpenJDK 소스에 든 cacerts 는 JDK 10 전까지 비어
있었다. JEP 319 가 JDK 10 에서 기본 루트 세트를 넣었다. 빌드한 쪽이 따로 채우지 않은
구형 OpenJDK 는 공개 CA 도 신뢰하지 못한다.
처리 — 루트를 손으로 넣기보다 JDK 를 업데이트한다. 루트 추가·distrust 는 JDK 업데이트의 일부다. 손으로 루트 한 장을 넣으면 그 한 건만 풀리고, 이후의 추가·제거 조치는 계속 못 받는다.
원인 4 — TLS 검사 프록시 (사내 보안 장비)
사내 보안 게이트웨이·프록시가 HTTPS 를 복호화해 검사한 뒤 자기 쪽 인증서로 다시 암호화하는 구조다. JVM 이 받는 서버 인증서는 원래 서버의 것이 아니라 장비가 새로 만든 것이고, 그 발급자는 장비의 루트다.
사람이 쓰는 PC 의 OS 저장소에는 보통 그 루트가 배포돼 있어 브라우저는 조용하다. 하지만 시스템 저장소를 쓰지 않는 앱에는 루트를 따로 넣어야 하고, 넣지 않으면 앱이 연결을 거부한다 — 한 게이트웨이 벤더 문서에 그대로 적힌 내용이다. Java 가 그 대표다.
증상:
- 사내망 PC·서버에서만 실패하고 외부망에서는 된다
- 대상 서버를 가리지 않고 외부 HTTPS 호출이 전부 실패한다
- 진단 2) 에서 본 발급자가 1) 과 다르다
처리 — 장비의 루트 CA 를 JVM 이 신뢰하게 만든다. 루트 파일은 보안 담당에게 받고, 지문 대조는 원인 2 와 같다.
| 환경 | 방법 |
|---|---|
| 모든 OS 공통 | 해당 JDK 의 cacerts 에 임포트 (원인 2 명령) |
| Windows 개발 PC | -Djavax.net.ssl.trustStoreType=Windows-ROOT 로 OS 루트 저장소 사용 |
| macOS | 해당 JDK 의 cacerts 에 임포트. KeychainStore-ROOT(JDK 23+)는 Apple 기본 루트만 담고 있어 사내 장비 루트가 없다 |
| Linux 서버 (배포판 패키지 OpenJDK) | 시스템 트러스트에 추가 (아래 cacerts 실무 참고). tarball·벤더 JDK 는 해당 JDK cacerts 에 임포트 |
| 컨테이너 | 이미지 빌드 또는 기동 시 루트 주입 (아래 참고) |
java -Djavax.net.ssl.trustStoreType=Windows-ROOT -jar app.jar
Windows-ROOT 는 현재 사용자의 루트 저장소(Windows-ROOT-CURRENTUSER 와 같다)다. 서비스
계정으로 도는 앱이면 모든 계정이 공유하는 Windows-ROOT-LOCALMACHINE 을 쓴다. 이 타입은
JDK 19 에서 추가돼 17 등에는 업데이트로 들어갔고, JDK 8 문서에는 없다. 쓰는 JDK 의
Providers 문서에 있는지 확인한다.
특정 도메인을 장비의 검사 대상에서 빼는(bypass) 방법도 있다. 이건 보안 정책 판단이라 장비 담당과 정한다. 판별과 처리 방향은 ERR_CERT_AUTHORITY_INVALID 원인 4와 같다.
원인 5 — 잘못된 trustStore 지정
증상은 둘 중 하나다. 공개 CA 를 쓰는 멀쩡한 API 까지 한꺼번에 실패하거나, 사설 CA 를 넣은 truststore 를 지정했는데 사설 CA 서버만 여전히 실패한다. 어제 배포에서 JVM 옵션을 건드렸다면 이것부터 본다.
JSSE 의 기본 TrustManagerFactory 는 이 순서로 신뢰 저장소를 찾는다.
javax.net.ssl.trustStore시스템 속성java-home/lib/security/jssecacertsjava-home/lib/security/cacerts
먼저 찾은 하나만 쓴다. 여기서 함정 네 가지가 나온다.
| 함정 | JSSE 동작 | 결과 |
|---|---|---|
| trustStore 경로 오타·파일 없음 | (현행 JDK) 경고 없이 cacerts 로 대체 |
공개 서버는 되고 사설 CA 서버만 PKIX 오류 |
| 사설 CA 한 장만 든 파일을 지정 | cacerts 를 보충하지 않고 대체 |
사설 CA 서버만 되고 공개 서버는 전부 실패 |
누군가 만들어 둔 jssecacerts |
cacerts 보다 먼저 쓰임 |
cacerts 에 루트를 넣어도 반영 안 됨 |
비밀번호를 걸어 만든 PKCS12 에 trustStorePassword 를 안 줌 |
암호화된 인증서를 건너뛰고 읽음 | 신뢰 인증서 0개 — 빈 truststore |
첫 번째가 가장 허무하다. 경로 한 글자 오타에 에러 메시지는 “파일 없음”이 아니라, 넣었다고
생각한 사설 CA 서버의 PKIX 오류다. Oracle JSSE 가이드에는 “빈 keystore 로 만든다”고 적혀
있지만, 현재 유지보수 중인 OpenJDK 구현(TrustStoreManager)은 지정한 파일을 읽을 수 없으면
cacerts 로 넘어간다. 진단 3) 의 trustmanager 출력에 Inaccessible trust store: <경로> 가
찍힌다.
truststore 가 정말 비어 있으면(네 번째 함정 등) 에러가 PKIX 가 아니라
the trustAnchors parameter must be non-empty 로 바뀐다. 이 메시지가 보이면 trustStore
지정부터 본다.
타입과 비밀번호도 본다.
javax.net.ssl.trustStoreType기본값은KeyStore.getDefaultType()이다. JDK 9 부터 기본 keystore 타입이 PKCS12 다. 파일 형식과 헷갈리지 않게 항상 명시한다javax.net.ssl.trustStorePassword는 기본값이 없다. 없으면 JSSE 는 비밀번호 없이(null) 읽는데, keytool 이 기본 설정으로 만든 PKCS12 는 인증서를 비밀번호로 암호화해 두므로 하나도 읽히지 않는다
어디서 지정됐는지 찾는다.
# 실행 중인 JVM 명령행 (비밀번호 속성은 빼고 출력)
ps -ef | grep [j]ava | grep -oE 'javax\.net\.ssl\.trustStore(Type)?=[^ ]*'
# Tomcat 기동 스크립트
grep -n trustStore "$CATALINA_BASE/bin/setenv.sh"
# jssecacerts 가 있는지 (JDK 8 은 $JAVA_HOME/jre/lib/security)
ls "$JAVA_HOME/lib/security/"
컨테이너라면 이미지가 JAVA_TOOL_OPTIONS 로 trustStore 를 바꿔 끼우는 경우도 있다
(Temurin 이미지를 non-root 로 돌릴 때, 아래 참고). 확실한 확인은 진단 3) 의
trustmanager 출력이다.
처리 — 전용 truststore 를 쓰려면 공인 루트도 필요한지부터 따진다. 공개 API 도
부르는 앱이면 cacerts 사본에 사설 CA 를 추가하고 그 사본을 지정한다. 특정 상대만
사설 CA 라면 JVM 전역 설정 대신 Spring Boot SSL Bundle 로 그 클라이언트에만 적용한다.
cacerts 실무 — 버전별 차이
같은 명령이 JDK 버전에 따라 안 먹는다. 차이를 표로 정리한다.
| 항목 | JDK 8 | JDK 9 이상 |
|---|---|---|
| 경로 | $JAVA_HOME/jre/lib/security/cacerts |
$JAVA_HOME/lib/security/cacerts |
keytool -cacerts |
없음 (-keystore 로 경로 지정) |
있음 |
| 초기 비밀번호 | changeit |
changeit |
| 파일 형식 | JKS | JDK 17 까지 JKS, JDK 18 부터 비밀번호 없는 PKCS12 |
- 경로가 다른 이유 — JDK 8 에서
java.home은 JDK 안의jre디렉터리다. JDK 9 (JEP 220) 부터 JDK 이미지에jre하위 디렉터리가 없다 -cacerts—-keystore <cacerts 경로> -storetype <cacerts 타입>과 같다.-keystore나-storetype과 함께 쓰면 오류가 난다. JDK 9 에서 추가됐다(JDK-8162739)- JDK 18 이후 —
cacerts는 비밀번호 없는 PKCS12 다. 안의 인증서가 암호화돼 있지 않고 무결성용 MacData 도 없어서 어떤 비밀번호로도(없어도) 읽힌다. 다만 JDK 21 keytool 문서에는 초기 비밀번호가 여전히changeit으로 적혀 있다 - 권한 — Oracle 문서는 설치 후
cacerts비밀번호와 파일 접근 권한을 바꾸라고 권고한다
배포판 OpenJDK — cacerts 를 시스템이 관리한다
Linux 배포판 패키지로 설치한 OpenJDK 는 cacerts 를 시스템 트러스트가 만든다.
keytool 로 직접 넣으면 나중에 사라질 수 있다.
RHEL 계열 — 공유 시스템 인증서 저장소를 NSS, GnuTLS, OpenSSL, Java 가 함께 쓴다.
sudo cp corp-root-ca.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust extract
Red Hat 지식베이스에는 keytool 로 cacerts 에 넣은 인증서가 업데이트 설치 때마다
덮어써진다는 사례가 올라와 있다(로그인이 필요한 문서라 제목 기준으로만 확인했다).
RHEL 에서는 위 방법이 정석이다.
Debian·Ubuntu — ca-certificates-java 패키지가 ca-certificates 의 훅으로 Java
cacerts 를 갱신한다.
# DER 로 받았다면 먼저 PEM 으로 변환한다
openssl x509 -inform der -in corp-root-ca.der -out corp-root-ca.crt
sudo cp corp-root-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
keytool 과 달리 여기에 넣는 파일은 PEM 형식이어야 한다. -----BEGIN CERTIFICATE----- 로
시작하지 않으면 위처럼 변환한 뒤 .crt 확장자로 저장한다. 컨테이너의 /certificates 에
넣는 파일도 PEM 으로 맞춘다.
update-ca-certificates 는 /usr/local/share/ca-certificates 아래 .crt 확장자 파일을
포함하고, /etc/ca-certificates/update.d 의 훅을 실행한다. 확장자가 .crt 가 아니면 포함되지 않는다.
컨테이너
실행 중인 컨테이너 안에서 keytool 로 넣은 인증서는 컨테이너를 새로 만들면 사라진다. 손으로 넣는 방식은 성립하지 않는다.
eclipse-temurin 이미지는 /certificates 에 인증서를 마운트하고 USE_SYSTEM_CA_CERTS 를
설정하면 JVM truststore 와 시스템 CA 저장소 양쪽에 추가해 준다.
docker run -v $(pwd)/certs:/certificates/ -e USE_SYSTEM_CA_CERTS=1 eclipse-temurin:25
non-root 로 실행하면 cacerts 를 고칠 수 없으므로 별도 truststore 를 만들고
JAVA_TOOL_OPTIONS 를 늘려 JVM 이 그 파일을 쓰게 바꾼다. 이 상태에서 앱이 자기
trustStore 를 또 지정하면 원인 5 가 된다. 다른 베이스 이미지는 빌드 단계에서 루트를 넣는다.
Tomcat·Spring Boot·배치에 적용하기
Tomcat
여기서는 Tomcat 이 외부 API 를 호출하는 쪽이다. Tomcat 이 HTTPS 를 제공하는 쪽의 인증서 설정은 Tomcat 가이드에 있다. 둘을 헷갈려 서버용 keystore 를 만지는 경우가 많다.
JVM 옵션은 CATALINA_OPTS 로 주고, CATALINA_BASE/bin/setenv.sh(Windows 는 setenv.bat)에
두는 것이 Tomcat 문서의 권장이다.
# $CATALINA_BASE/bin/setenv.sh
CATALINA_OPTS="$CATALINA_OPTS -Djavax.net.ssl.trustStore=/opt/tomcat/conf/app-truststore.p12 -Djavax.net.ssl.trustStoreType=PKCS12"
이 예시는 비밀번호 없는 truststore 를 전제로 한다. 원인 2 의 명령처럼 -storepass 로
만든 PKCS12 를 그대로 지정하면 trustStorePassword 가 없어 신뢰 인증서를 하나도 읽지 못한다
(원인 5 네 번째 함정). 둘 중 하나로 맞춘다.
-Djavax.net.ssl.trustStorePassword를 추가한다. 명령행에 드러나는 문제는 아래 배치 절 참고- 처음부터 비밀번호 없는 truststore 로 만든다(JDK 18
cacerts와 같은 방식, JDK 12+ keytool)
keytool -importcert -alias partner-ca -file partner-ca.crt \
-keystore app-truststore.p12 -storetype PKCS12 -storepass:env TRUSTSTORE_PASS \
-J-Dkeystore.pkcs12.certProtectionAlgorithm=NONE -J-Dkeystore.pkcs12.macAlgorithm=NONE
Spring Boot — SSL Bundle
Spring Boot 3.1 에서 SSL Bundle 이 들어왔다. JKS·PEM 신뢰 자료를 프로퍼티로 정의하고 RestTemplate·WebClient 등에 일관되게 적용한다. JVM 전역을 건드리지 않고 특정 클라이언트에만 사설 CA 를 신뢰시킬 수 있어서, 파트너 API 하나 때문에 생긴 문제라면 이쪽이 가장 좁은 해법이다.
PEM 파일로:
spring.ssl.bundle.pem.partner.truststore.certificate=classpath:partner-ca.crt
keystore 파일로:
spring.ssl.bundle.jks.partner.truststore.location=classpath:partner-truststore.p12
spring.ssl.bundle.jks.partner.truststore.password=${TRUSTSTORE_PASS}
spring.ssl.bundle.jks.partner.truststore.type=PKCS12
RestClient·WebClient 는 ssl.fromBundle() 을 apply 한다. RestClient 와 RestClientSsl 은
Boot 3.2+ 에 있다(3.1 에서는 RestTemplate·WebClient 만).
@Bean
RestClient partnerClient(RestClient.Builder builder, RestClientSsl ssl) {
return builder.baseUrl("https://partner.example")
.apply(ssl.fromBundle("partner"))
.build();
}
RestTemplate 은 Boot 버전에 따라 메서드 이름이 다르다.
restTemplateBuilder.setSslBundle(sslBundles.getBundle("partner")).build(); // Boot 3.1~3.3
restTemplateBuilder.sslBundle(sslBundles.getBundle("partner")).build(); // Boot 3.4+ (4.x 포함)
버전 차이 정리:
| 항목 | Boot 3.x | Boot 4.x |
|---|---|---|
| RestTemplateBuilder 메서드 | 3.1~3.3: setSslBundle(...) / 3.4+: sslBundle(...) (setSslBundle 은 3.4 에서 deprecated) |
sslBundle(...) (setSslBundle 제거) |
RestTemplateBuilder 패키지 |
org.springframework.boot.web.client |
org.springframework.boot.restclient |
RestClientSsl 패키지 |
org.springframework.boot.autoconfigure.web.client |
org.springframework.boot.restclient.autoconfigure |
| 프로퍼티로 번들 지정 | 3.4+: spring.http.client.ssl.bundle (3.3 이하는 없음) |
spring.http.clients.ssl.bundle |
예제를 복사했는데 import 가 안 맞으면 이 패키지 차이부터 본다.
배치·단독 JVM
java -Djavax.net.ssl.trustStore=/opt/app/app-truststore.p12 \
-Djavax.net.ssl.trustStoreType=PKCS12 \
-Djavax.net.ssl.trustStorePassword=... \
-jar batch.jar
이 파일은 cacerts 를 대체한다(원인 5). 공개 API 도 부른다면 cacerts 사본에 추가한
파일을 지정한다.
JSSE 가이드는 비밀번호를 명령행처럼 다른 사용자가 볼 수 있는 곳에 두지 말라고 한다.
명령행은 ps 로 보인다. 비밀번호가 필요한 truststore 라면 앱이 시크릿 저장소나 설정에서
값을 읽어 첫 HTTPS 호출 전에 속성으로 넣게 하거나, Spring Boot 라면 SSL Bundle 의
password 를 환경변수로 받는다.
반영은 재시작 후
cacerts 나 truststore 를 바꾼 뒤에는 실행 중인 Java 를 재시작해야 반영된다(TLS 검사
게이트웨이 벤더 문서). “넣었는데 안 된다”면 재시작 여부부터 확인한다.
하지 말 것
검색 결과에 자주 보이지만 원인을 가리는 조치들이다.
- TrustAll·검증 끄기 — 모든 인증서를 받아들이는 TrustManager, 호스트 이름 검증 생략. CWE-295(부적절한 인증서 검증)에 해당한다. 공격자가 통신 경로에 끼어들어 신뢰된 상대를 위장할 수 있게 된다. 에러는 사라지지만 TLS 를 쓰는 이유도 같이 사라진다
-noprompt로 서버가 준 인증서를 바로 임포트 — 지금 그 경로에 있는 게 누구인지 확인하지 않은 채 신뢰하게 된다- 서버 인증서(leaf) 임포트 — 상대가 갱신하는 날 다시 깨진다
- AIA 옵션으로 덮기 — 버전마다 기본 동작이 다르다. 서버 체인을 고친다
- 사설 CA 한 장짜리 파일로 trustStore 지정 — 공개 서버가 전부 끊긴다
이런 코드가 보이면 누군가 이 에러를 덮은 흔적이다.
// 검증하지 않는 TrustManager — 지우고 원인을 찾는다
public void checkServerTrusted(X509Certificate[] chain, String authType) { }
판별 순서 정리
0. 메시지 확인
└ PKIX path validation failed → 검증 단계 문제 (이 글 대상 아님)
└ ... distrusted legacy ... root CA → CA distrust (상대 인증서 교체)
└ trustAnchors parameter must be non-empty → 빈 truststore (원인 5)
└ unable to find valid certification path → 1번으로
1. openssl s_client -showcerts (외부에서)
└ 서버 인증서 한 장뿐 → 중간 인증서 누락 (원인 1)
└ 사내 CA / 자체서명 → 사설 CA (원인 2)
└ 공개 CA 체인 온전 → 2번으로
2. keytool -printcert -sslserver (JVM 이 도는 서버에서)
└ 발급자가 1번과 다름 → TLS 검사 프록시 (원인 4)
└ 같음 → 3번으로
3. 실패 범위와 JVM 설정 (trustmanager 디버그로 읽은 truststore 확인)
└ 공개 서버까지 전부 실패 → trustStore 지정 (원인 5)
└ truststore 를 넣었는데 사설 CA 만 실패 → trustStore 경로 오타 (원인 5)
└ 특정 CA 의 공개 서버만 실패 → 오래된 cacerts (원인 3)
cacerts 임포트는 이 순서의 끝에서만 나온다. 중간 인증서 누락이면 서버를, 오래된
JDK 면 업데이트를, trustStore 오지정이면 JVM 옵션을 고치는 게 답이다. 에러 메시지만 보고
임포트부터 하는 습관이 같은 장애를 반복시킨다.
상대 서버의 인증서 상태를 먼저 훑어보고 싶으면 무료 진단 툴에 도메인을 넣으면
된다. 바깥에서 본 TLS 검증 결과와 만료일이 함께 나온다. 다만 거기 그려지는 체인은 CT 로그와
AIA 로 재구성한 것이라, 서버가 실제로 보내는 목록은 -showcerts 로 확인한다.
참조
- OpenJDK — PKIXValidator.java
- OpenJDK — SunCertPathBuilder.java
- OpenJDK 21u — TrustStoreManager.java
- OpenJDK — PKCS12KeyStore.java
- OpenJDK — GenerateCacerts.java (cacerts alias 접미사)
- Java SE 21 — JSSE Reference Guide
- Java SE 21 — Troubleshooting Security
- Java SE 21 — Java PKI Programmer’s Guide
- Java SE 21 — keytool
- Java SE 9 — keytool
- Java SE 8 — keytool
- JDK-8162739 — Create new keytool option to access cacerts file
- JEP 220 — Modular Run-Time Images
- JEP 319 — Root Certificates
- JDK 18 Release Notes — cacerts 비밀번호 없는 PKCS12 전환
- JDK 8u401 Release Notes — ISRG Root X2 추가
- JDK 21.0.2 Release Notes
- JDK 21 Release Notes (전체) — AIA 필터·distrust·루트 추가
- Java SE 21 — JDK Providers (SunMSCAPI)
- JDK 19 Release Notes — Windows 로컬 머신 키스토어
- Java SE 25 — JDK Providers (Apple KeychainStore-ROOT)
- JDK-8321045 — Load anchor certificates from Keychain keystore (KeychainStore-ROOT, JDK 23)
- OpenJDK — KeystoreImpl.m (KeychainStore-ROOT 가 여는 SystemRootCertificates.keychain)
- RFC 8446 §4.4.2 — Certificate
- nginx — ssl_certificate
- OpenSSL 3.0 — openssl-s_client
- RHEL 9 — Using shared system certificates
- Red Hat 지식베이스 — RHEL 에서 Java 용 인증서가 업데이트 때 덮어써지지 않게 넣는 법 (로그인 필요)
- Debian — update-ca-certificates(8)
- Debian — ca-certificates-java 패키지
- Ubuntu Server — Install a root CA certificate in the trust store
- Docker Official Images — eclipse-temurin
- TLS 검사 게이트웨이 벤더 문서 — 사용자 측 인증서 수동 배포
- Apache Tomcat 10.1 — RUNNING.txt
- Spring Boot 3.1 Release Notes
- Spring Boot — SSL
- Spring Boot 3.3 — Calling REST Services
- Spring Boot — Calling REST Services
- Spring Boot 3.4 API — RestTemplateBuilder (sslBundle, setSslBundle deprecated)
- Spring Boot 3.4 — HttpClientProperties.java (spring.http.client.ssl.bundle)
- Spring Boot 3.3 API — RestClientSsl (Since 3.2.0)
- CWE-295 — Improper Certificate Validation