Vol. 03 - SSL Full-chain Issue
안녕하세요. 제로백데브 백엔드 개발자 남종호입니다.
이번 포스팅에서는 상용 배포 후 안드로이드 앱에서만 API가 전혀 동작하지 않았던 경험을 공유하려 합니다. 원인을 찾는 과정에서 꽤 고생했는데, 저와 비슷한 상황을 겪고 있는 분께 도움이 됐으면 합니다!
갑자기 안드로이드에서 모든 API 요청이 막혔다.
상용 배포를 마치고 잠깐 숨을 돌리던 순간이었습니다.
ERR_CERT_AUTHORITY_INVALID CERTIFICATE_VERIFY_FAILED javax.net.ssl.SSLHandshakeException
배포 후 직접 안드로이드 기기로 확인해보니 로그인부터 시작해서 모든 API 요청이 실패했습니다. 앱 자체는 실행됐지만 서버와 통신이 전혀 이루어지지 않는 상태였습니다. 처음엔 네트워크 문제인가 싶었습니다. Wi-Fi도 바꿔봤고, 기기도 바꿔봤는데 다 안 됐습니다.
이상한 건 iOS는 멀쩡하게 잘 됐다는 점이었습니다.
같은 앱, 같은 API 서버, 같은 네트워크인데 플랫폼만 다르다고 이렇게 달라질 수 있나? 처음엔 React Native 코드 문제인가 싶어서 소스코드를 열심히 뒤졌습니다.
iOS는 되고 안드로이드는 안 되는 이유
이 문제의 핵심은 플랫폼별 SSL 인증서 검증 정책의 차이에 있습니다.
iOS의 ATS (App Transport Security)
Apple은 iOS 9부터 ATS라는 정책을 도입해서 HTTPS 통신을 강제하고 있습니다. 단, Apple은 인증서 체인 검증에서 시스템 루트 인증서 저장소를 좀 더 유연하게 활용합니다. 중간 CA(Intermediate CA) 인증서가 서버에서 제공되지 않더라도, 이미 알려진 CA라면 iOS 자체적으로 체인을 복원해서 검증을 통과시켜주는 경우가 있습니다.
즉, 인증서가 살짝 불완전해도 iOS는 "알아서 찾아서" 통과시켜 줍니다.
안드로이드의 엄격한 체인 검증
안드로이드는 다릅니다. 서버가 명시적으로 전체 인증서 체인(Full-chain)을 제공하지 않으면, 루트 CA까지 체인을 연결하지 못하고 검증에 실패합니다.
[클라이언트] → 서버 인증서만 받음
→ 중간 CA가 없어서 체인 연결 불가
→ ❌ SSL Handshake 실패안드로이드 앱은 javax.net.ssl.SSLHandshakeException이나 CERTIFICATE_VERIFY_FAILED를 뱉고 통신을 거부합니다.
문제의 원인: Full-chain이 아닌 인증서
우리 boneyo_api 서버의 Nginx에 SSL 인증서를 설정할 때, 도메인 인증서(domain cert)만 등록하고 중간 CA 인증서(Intermediate CA)를 포함하지 않았던 게 문제였습니다.
# 잘못된 설정 — 도메인 인증서만 등록 server { listen 443 ssl; ssl_certificate /etc/nginx/ssl/domain.crt; # 도메인 인증서만 ssl_certificate_key /etc/nginx/ssl/domain.key; }
인증서 발급 기관에서는 보통 아래와 같이 여러 파일을 줍니다.
domain.crt # 내 도메인 인증서 intermediate.crt # 중간 CA 인증서 root.crt # 루트 CA 인증서 (보통 생략 가능) fullchain.crt # 위를 하나로 묶은 것 (이게 Full-chain)
fullchain.crt는 domain.crt + intermediate.crt를 하나의 파일로 이어붙인 것입니다.
# Full-chain 파일 직접 만드는 방법 cat domain.crt intermediate.crt > fullchain.crt
해결: Nginx에 Full-chain 인증서 적용
수정은 간단했습니다. Nginx 설정에서 ssl_certificate를 fullchain.crt로 바꿔주기만 하면 됐습니다.
# 수정된 설정 — Full-chain 인증서 등록 server { listen 443 ssl; ssl_certificate /etc/nginx/ssl/fullchain.crt; # Full-chain 인증서 ssl_certificate_key /etc/nginx/ssl/domain.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; }
설정을 바꾸고 Nginx를 재시작하면 바로 적용됩니다.
sudo nginx -t # 설정 문법 검사 sudo nginx -s reload # Nginx 재시작
인증서 체인 확인하는 방법
배포 전에 인증서가 올바르게 구성됐는지 확인하는 가장 빠른 방법은 openssl 명령어를 사용하는 것입니다.
# 서버의 인증서 체인 확인 openssl s_client -connect your-domain.com:443 -showcerts # 출력에서 인증서 개수를 확인 — 2개 이상이면 OK # Certificate chain # 0 s:CN = your-domain.com ← 도메인 인증서 # 1 s:CN = Some Intermediate CA ← 중간 CA 인증서 ✅ # 2 s:CN = Some Root CA ← 루트 CA (선택)
인증서가 1개만 나온다면 Full-chain이 아닌 것입니다.
온라인 도구를 쓰고 싶다면 SSL Labs에서 도메인을 입력하면 체인 구성을 시각적으로 확인할 수 있습니다.
왜 개발 환경에서는 못 잡았을까?
나중에 돌이켜보니 이 문제가 상용 배포 전에 발견되지 못한 건 여러 조건이 동시에 맞물렸기 때문이었습니다.
첫째, Native → React Native 전환으로 SSL 인증서 체인 검증 기준이 달라졌습니다.
네이티브 앱은 HTTP 클라이언트를 직접 구성하는 경우가 많아서 인증서 검증이 상대적으로 느슨할 수 있습니다. 반면 React Native Android는 내부적으로 OkHttp를 사용하는데, OkHttp는 SSL 체인 검증이 기본적으로 매우 엄격합니다. 같은 서버, 같은 인증서인데도 네이티브에서는 통과했고 RN에서는 실패한 이유가 여기 있습니다.
네이티브 앱 → 커스텀 HTTP 클라이언트 → 체인 검증 느슨 → 통과
React Native → OkHttp (기본값) → 엄격한 체인 검증 → ❌ 실패둘째, 운영 엔드포인트를 미리 테스트할 수 없는 구조였습니다.
이번 배포는 클라이언트(앱)와 서버가 함께 나가야 하는 작업이었는데, 하위 호환이 되지 않는 변경이었기 때문에 서버를 먼저 운영에 올려두고 클라이언트만 따로 테스트하는 게 불가능했습니다. 결국 실제 운영 환경에서의 통합 테스트는 배포 당일에야 이루어졌고 그때 터진 것입니다.
셋째, 개발 API 서버의 인증서는 Full-chain이 정상적으로 구성되어 있었습니다.
로컬 개발 환경에서는 개발 서버를 바라보는데, 개발 서버의 인증서는 문제가 없었습니다. 운영 서버에 올린 인증서만 Full-chain이 빠진 상태였으니, 로컬에서 아무리 테스트해도 이 문제는 절대 재현되지 않는 상황이었습니다.
로컬 테스트 → 개발 서버 (Full-chain ✅) → 정상
운영 배포 후 → 운영 서버 (Full-chain ❌) → 안드로이드 전체 실패세 가지 조건이 겹치지 않았다면 훨씬 일찍 잡을 수 있었을 문제였습니다. 반대로 말하면, 이 중 하나라도 달랐다면 배포 당일 사고는 없었겠죠.
교훈은 두 가지입니다.
운영 서버의 인증서 체인은 배포 전 독립적으로 검증해야 한다. 개발 서버가 정상이라고 운영도 정상이라는 보장은 없다.
논리적으로 불가능한 경우가 아니라면, 백엔드 API는 항상 하위 호환성을 확보해서 클라이언트보다 먼저 운영에 배포하고 충분히 검증할 수 있게 해야 한다.
iOS가 통과했다고 안드로이드도 통과한다는 보장은 없고, 네이티브에서 됐다고 RN에서도 된다는 보장도 없습니다. 클라이언트와 서버를 동시에 배포해야 하는 구조라면, 운영 환경에서 사전 검증할 수 있는 여지 자체가 사라진다는 것도 이번에 다시 실감했습니다.
DigiCert, Sectigo 같은 상용 CA에서 인증서를 직접 발급받아 사용하는 경우에는 파일을 수동으로 이어붙여야 하기 때문에 특히 주의가 필요합니다. 작은 설정 하나가 상용 앱 전체의 통신을 막을 수 있다는 걸 직접 겪고 나서야 제대로 실감했습니다.