Skip to content

클라이언트에 따라 HTTPS가 실패할 때 체크할 것 — 끊어진 TLS 인증서 체인 ​

  • 브라우저로는 멀쩡하게 통신되는데 앱·서버에서만 TLS가 터지는 경우가 있는데요, 대개 인증서 체인이 끊어진 것 입니다.
  • 클라이언트 종류 따라서 HTTPS 통신이 실패할때 확인 해보면 좋을 "인증서 체인이 끊어져 발생하는 통신불가 증상의 진단과 해결·검증까지 정리해 보는 글 입니다.

목차 ​

  1. 인트로
  2. 의심해볼 만한 사례 예시
  3. 인증서 체인이란
  4. 왜 브라우저는 되고 앱은 터지나
  5. 진단
  6. 해결
  7. 검증 — 이중화까지
  8. 체크리스트
  9. 번외: 로컬에서 인증서 파일로 실습

인트로 ​

  • 특정 서비스가 웹 브라우저에서는 멀쩡한데, 서버나 모바일 앱 에서 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·Java HttpClient로 붙는데 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.pemleaf (우리 도메인 인증서)-----BEGIN CERTIFICATE-----
chain.pemintermediate(들)-----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장 명령으로 실제 노드에서 최종 확인한다.