클라이언트에 따라 HTTPS가 실패할 때 체크할 것 — 끊어진 TLS 인증서 체인
- 브라우저로는 멀쩡하게 통신되는데 앱·서버에서만 TLS가 터지는 경우가 있는데요, 대개 인증서 체인이 끊어진 것 입니다.
- 클라이언트 종류 따라서 HTTPS 통신이 실패할때 확인 해보면 좋을 "인증서 체인이 끊어져 발생하는 통신불가 증상의 진단과 해결·검증까지 정리해 보는 글 입니다.
목차
인트로
- 특정 서비스가 웹 브라우저에서는 멀쩡한데, 서버나 모바일 앱 에서 HTTPS 통신이 실패한다면, 대게 서버가 중간 인증서(intermediate)를 안 보내주고 있는 것입니다.
- 인증서 자체가 잘못된 게 아니라 서버·로드밸런서에 leaf만 배포되고 fullchain이 아닌 설정 불일치인데요
- 브라우저는 빠진 조각을 스스로 메꿔주기 때문에 티가 안 날 뿐이며, 서버나 모바일앱의 클라이언트는 예외를 터트립니다
- 해결방법: 서버/LB에 leaf만 말고 fullchain(leaf + intermediate)을 배포하는 것 끝!
- 아래에 오는 내용은 조금 더 디테일하게 원리·진단·검증 방법에 대한 How-To 를 다룹니다.
의심해볼 만한 사례 예시
- 구체적으로 문제가 발생하는 사례는 다음과 같습니다.
- 크롬·사파리 등 브라우저로는 열리는데, 서버-투-서버 연동이나 앱에서만 TLS 에러가 난다.
- 에러가
unable to verify the first certificate/unable to get local issuer certificate/PKIX path building failed/x509: certificate signed by unknown authority류다. - LB 뒤 여러 노드 중 어떤 요청은 되고 어떤 요청은 실패하는 간헐적 장애다.
- 결제·알림 등 외부 업체 webhook이 자기네 쪽 SSL 검증 실패로 안 들어온다는 컴플레인. 정작 우리는 브라우저로 열려서 핑퐁만 함.
- 사내 배치·크론이
curl·JavaHttpClient로 붙는데SSLHandshakeException. 갱신했는데 또 터진다며 CA 의심. - 개발 PC·CI에서는 되는데 운영 서버·모바일에서만 터진다.
한 줄로 즉시 확인:
입력명령어
- 체크하고자 하는 접속도메인이
some.example.com일때
bash
DOMAIN=some.example.com
echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" -showcerts 2>/dev/null \
| grep -E '^ *[0-9]+ s:|Verify return code'출력예시1 : 체인실패 (끊어진 TLS 인증서 체인 상황)
txh
0 s:C=KR, ST=Gyeonggi-do, L=youngin-si, O=CCCP Corp., CN=*.example.in
i:C=US, O=DigiCert Inc, OU=www.digicert.com, CN=Thawte TLS RSA CA G1
Verify return code: 21 (unable to verify the first certificate)0 s: 만 나오고 Verify return code: 21 이면 → intermediate 누락 확정.
출력예시2 : 체인성공 (멀쩡한 체인)
txt
0 s:C=KR, ST=Gyeonggi-do, L=youngin-si, O=CCCP Corp., CN=*.example.in
i:C=US, O=DigiCert Inc, OU=www.digicert.com, CN=Thawte TLS RSA CA G1
1 s:C=US, O=DigiCert Inc, OU=www.digicert.com, CN=Thawte TLS RSA CA G1
i:C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert Global Root G2
Verify return code: 0 (ok)1 s:이 존재하고,Verify return code: 0 (ok)문구 존재시 성공
1. 인증서 체인이란
- TLS 인증서는 단독으로 신뢰되지 않는다. Root까지 이어지는 사슬이 완성되어야 신뢰된다.
- 자세한건 HTTPS 와 TLS 통신보안 이야기를 참고해주세요
Root CA (예: DigiCert Global Root G2) ← OS/브라우저에 내장 (신뢰 앵커)
└─ Intermediate (예: Thawte TLS RSA CA G1) ← 서버가 보내줘야 함
└─ Leaf (예: *.example.in) ← 실제 서버 인증서- 클라이언트는 leaf를 받고 "이걸 서명한 게 누구지?" 하며 상위 인증서로 거슬러 올라가,
leaf → intermediate → root가 이어지면 검증 성공이다. - 반면에 Root는 이미 신뢰하므로 가운데 intermediate가 사슬을 잇는 접착제다.
- 문제는 Root와 달리 intermediate는 클라이언트에 내장돼 있지 않다는 것. 그래서 서버가 매번 leaf와 함께 보내줘야 한다.
2. 왜 브라우저는 되고 앱은 터지나
- 같은 인증서인데 결과가 갈리는 건 클라이언트가 빠진 intermediate를 스스로 메꿀 수 있느냐의 차이다.
- 브라우저는 leaf 안의 AIA(Authority Information Access) URL로 intermediate를 자동으로 받아와서(AIA Fetching) 사슬을 메꾸고, 예전에 받아둔 intermediate를 캐시해 재사용한다.
- 대부분의 앱·라이브러리는 Java, Go, Python
requests, OpenSSL, curl 등은 기본적으로 AIA fetching을 하지 않는다. 서버가 준 것만으로 검증하므로 intermediate가 빠지면 그냥 실패한다. - 그래서 이 이슈는 치명적이다. 브라우저에서 잘 되니 넘어갔다가, 엄격한 클라이언트가 붙는 순간 handshake가 깨진다.
- 또한 자매품으로 같이 나오는게 특히 LB 뒤 일부 노드만 틀리면 "됐다 안 됐다" 하는 간헐 장애가 되어 추적이 곤란할때도 있다. (전체 트래픽의 25% 나 50% 만 실패하는 케이스..)
3. 진단
- 도메인 기준으로 체인 상태를 본다.
s:(subject)·i:(issuer) 라인이 몇 개인지가 핵심이다.
bash
DOMAIN=some.example.com
echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" -showcerts 2>/dev/null \
| grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'- 정상 (체인 완전) — leaf·intermediate 둘 다 나오고 검증 OK:
0 s:C=KR, O=CCCP Corp., CN=*.example.com
i:C=US, O=DigiCert Inc, CN=Thawte TLS RSA CA G1
1 s:C=US, O=DigiCert Inc, CN=Thawte TLS RSA CA G1
i:C=US, O=DigiCert Inc, CN=DigiCert Global Root G2
Verify return code: 0 (ok)문제 (intermediate 누락) — leaf 하나뿐, 검증 실패:
0 s:C=KR, O=CCCP Corp., CN=*.example.com
i:C=US, O=DigiCert Inc, CN=Thawte TLS RSA CA G1
Verify return code: 21 (unable to verify the first certificate)1 s: 라인이 없으면 = 서버가 intermediate를 안 보낸 것 = fullchain 미배포.
4. 해결
- 해결법은 서버/LB 인증서를 leaf + intermediate 순으로 이어붙인 fullchain으로 교체한다. (Root는 불필요.)
bash
cat leaf.crt intermediate.crt > fullchain.pem # 순서 중요: leaf 먼저- nginx 예시:
nginx
ssl_certificate fullchain.pem; # leaf + intermediate
ssl_certificate_key privkey.pem;- 로드밸런서를 쓴다면 모든 노드에 동일한 fullchain을 배포하는 게 포인트다. 한 노드만 누락돼도 간헐 장애가 난다.
인증서 파일이
key.pem/cert.pem/chain.pem로 나뉘어 왔거나, 배포 전 로컬에서 미리 검증하고 싶다면 → 번외 참고.
5. 검증 — LB이중화까지 고려한 검증법
- 위에서 언급한대로 인프라 담당자가 모든 LB 를 꼼꼼하게 다 바꿔주지 않으면 실패한다
- 바꾼 뒤 ① 도메인으로, ② 붙는 모든 IP에 각각 확인한다. 이중화 환경은 노드마다 설정이 다를 수 있어 IP별 확인이 필수다.
5.1. 도메인으로 검증
bash
DOMAIN=some.example.com
echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" -showcerts 2>/dev/null \
| grep -E '^ *[0-9]+ s:|Verify return code'0 s:·1 s: 가 둘 다 보이고 Verify return code: 0 (ok) 이면 정상.
5.2. 붙는 모든 IP에 각각 (이중화 대응) — SNI는 도메인 유지, 접속만 각 IP로:
bash
DOMAIN=some.example.com
for ip in $(dig +short "$DOMAIN"); do
echo "===== $ip ====="
echo | openssl s_client -connect "$ip:443" -servername "$DOMAIN" -showcerts 2>/dev/null \
| grep -E '^ *[0-9]+ s:|Verify return code'
done모든 IP 블록에서 1 s: 와 Verify return code: 0 (ok) 이면 전 노드 정상.
5.3. 엄격한 클라이언트로 재현해보기 — curl --resolve 로 도메인을 특정 IP에 고정해 노드별로 찍는다:
bash
DOMAIN=some.example.com
for ip in $(dig +short "$DOMAIN"); do
curl -sS -o /dev/null --resolve "$DOMAIN:443:$ip" "https://$DOMAIN/" \
&& echo "OK ($ip)" || echo "FAIL ($ip)"
done- 한 IP라도
FAIL이거나unable to get local issuer certificate가 나오면 그 노드만 fullchain 누락이다.
체크리스트
- [ ]
openssl s_client ... -showcerts출력에0 s:·1 s:두 줄이 다 나오는가 - [ ]
Verify return code: 0 (ok)인가 - [ ]
dig +short 도메인의 모든 IP에서 위가 성립하는가 (이중화 노드 전부) - [ ]
curl --resolve로 엄격한 클라이언트 관점에서도 전 노드가 통과하는가 - [ ] 모든 노드/LB에 동일한 fullchain이 배포됐는가
핵심 한 줄: 인증서가 틀린 게 아니라 서버가 intermediate를 안 보낸 것. 브라우저의 AIA fetching이 가려주던 문제이므로, fullchain 배포 + 전 노드 IP별 검증으로 마무리한다.
번외: 로컬에서 인증서 파일로 실습
CA·발급 도구에 따라 파일이 세 개로 나뉘어 오는 경우가 많다. 각 파일이 무엇을 담는지:
| 파일 | 내용 | 첫 줄 |
|---|---|---|
key.pem | 개인키 (외부 노출 금지) | -----BEGIN PRIVATE KEY----- |
cert.pem | leaf (우리 도메인 인증서) | -----BEGIN CERTIFICATE----- |
chain.pem | intermediate(들) | -----BEGIN CERTIFICATE----- |
- fullchain 조립 — 반드시 leaf 먼저, intermediate 나중 (
chain.pem에 intermediate가 여럿이면 그대로 이어짐):
bash
cat cert.pem chain.pem > fullchain.pem- 다만
인증서 본문(=cert.pem) 에 풀체인으로 담아서 주는 인증서도 더러 존재한다 - 서버 매핑: nginx는
ssl_certificate=fullchain.pem,ssl_certificate_key=key.pem. Apache는SSLCertificateFile=cert.pem,SSLCertificateKeyFile=key.pem,SSLCertificateChainFile=chain.pem.
배포 전 로컬 검증하는 방법
- ① 키-인증서 짝 확인 (modulus 해시 일치해야 함)
bash
diff <(openssl x509 -in cert.pem -noout -modulus | openssl md5) \
<(openssl rsa -in key.pem -noout -modulus | openssl md5) \
&& echo "MATCH" || echo "MISMATCH"- ② 사슬 연결 확인 (cert.pem: OK 나오면 정상)
bash
openssl verify -untrusted chain.pem cert.pem- 세 개 다 통과하면 서버에 올린 뒤 5장 명령으로 실제 노드에서 최종 확인한다.