이미 만료됐다면
방문자로서 사이트에서 NET::ERR_CERT_DATE_INVALID(파이어폭스는 SEC_ERROR_EXPIRED_CERTIFICATE)를 봤다면 먼저 내 기기의 날짜와 시간을 확인하세요. 시계가 틀리면 멀쩡한 인증서도 만료된 것처럼 보입니다. 시계가 맞고 다른 기기에서도 오류가 난다면 사이트 인증서가 만료된 것이고, 운영자만 고칠 수 있습니다. 자세한 내용은 날짜 오류 가이드에 있습니다.
사이트 운영자라면 가장 빠른 복구 경로는 이렇습니다.
-
실패하는 바로 그 이름으로, 실제로 내려가는 인증서를 확인합니다.
openssl s_client -connect www.example.com:443 -servername www.example.com </dev/null 2>/dev/null \ | openssl x509 -noout -subject -issuer -enddatesubject=CN = www.example.com issuer=C = US, O = Let's Encrypt, CN = R11 notAfter=Oct 2 08:14:51 2026 GMT -
디스크에 더 새 인증서가 이미 있는지 봅니다. certbot이라면
certbot certificates가 인증서별 만료일을 보여 줍니다. 디스크의 파일이 내려가는 것보다 새것이라면 갱신은 됐고 리로드나 배포만 안 된 것입니다. 4단계로 넘어가세요. -
없다면 지금 갱신합니다.
certbot renew --cert-name example.com. 실패하면 오류 내용으로 검증 문제(포트 80, DNS, CAA)인지 다른 문제인지 알 수 있습니다. 원인은 아래에 정리했습니다. -
웹 서버만이 아니라 그 인증서를 쓰는 모든 서비스를 리로드합니다.
nginx -t && systemctl reload nginx, 그다음 메일, 프록시, 그 밖의 모든 것. -
모든 이름, 모든 엔드포인트를 바깥에서 다시 확인합니다(“인증서가 있는 모든 곳” 참고). SSL 점검은 인터넷에서 보이는 그대로 만료일과 체인을 보여 줍니다.
그다음 왜 이런 일이 생겼는지 고쳐야 합니다. 그대로 두면 또 생깁니다.
왜 점점 더 급해지나
오랫동안 공인 인증서의 최대 유효 기간은 398일이었고, 그래서 많은 팀이 달력 알림을 보고 1년에 한 번 손으로 갱신했습니다. 이제는 그게 통하지 않습니다.
CA와 브라우저 업체가 규칙을 정하는 CA/브라우저 포럼은 최대 유효 기간을 단계적으로 줄이기로 의결했습니다. 2026년 3월 15일부터 발급되는 인증서는 최대 200일입니다. 이어서 2027년에 100일, 2029년에 47일로 더 줄어듭니다. 상용 CA는 여전히 1년·다년 상품을 팔지만, 그 상품 안의 인증서는 이제 최소 200일마다, 나중에는 훨씬 더 자주 재발급하고 다시 배포해야 합니다.
바뀐 것이 두 가지 더 있습니다. Let’s Encrypt 인증서는 처음부터 90일이었고, Let’s Encrypt는 2025년에 만료 알림 메일을 중단했습니다. 그 메일에 기대고 있었다면 이제 아무것도 알려 주지 않습니다. 그리고 유효 기간이 짧아질수록 갱신이 잦아지므로 망가진 자동화가 더 빨리 드러납니다. 다만 무언가가 지켜보고 있을 때만 그렇습니다.
이 글의 나머지는 갱신을 지루한 일로 만드는 방법입니다. 자동으로 갱신하고, 모든 곳에 배포하고, 바깥에서 감시하는 것입니다.
명령줄로 만료일 확인
openssl x509 -checkend N은 “이 인증서가 N초 안에 만료되는가?”에 답합니다. 문장을 출력하고, 더 쓸모 있게는 종료 코드를 정합니다. 그 기간 안에 만료되지 않으면 0, 만료되면 1입니다.
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -enddate -checkend 1209600
notAfter=Oct 2 08:14:51 2026 GMT
Certificate will expire
1209600초는 14일입니다. 디스크의 파일은 이렇게 봅니다.
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate -checkend 1209600
이 둘을 비교하는 것이 “갱신 실패”와 “갱신은 됐는데 서버를 리로드하지 않음”을 가르는 가장 빠른 방법입니다.
다른 서버의 cron에서 돌릴 수 있는 최소한의 점검 스크립트입니다.
#!/bin/sh
# 14일 안에 만료되거나 인증서를 읽을 수 없는 엔드포인트를 보고
for target in example.com:443 www.example.com:443 api.example.com:443 mail.example.com:465; do
host=${target%:*}
if ! echo | openssl s_client -connect "$target" -servername "$host" 2>/dev/null \
| openssl x509 -noout -checkend $((14*86400)) >/dev/null 2>&1; then
echo "EXPIRING OR UNREACHABLE: $target"
fi
done
연결이 실패하면 openssl x509가 입력을 받지 못해 역시 0이 아닌 코드로 끝나므로, 접속할 수 없는 엔드포인트도 조용히 통과하지 않고 보고됩니다. 일부러 그렇게 만든 것입니다.
STARTTLS를 쓰는 메일 포트는 -starttls를 붙입니다.
openssl s_client -connect mail.example.com:587 -starttls smtp -servername mail.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -enddate
143번은 -starttls imap, 465·993·995번은 -starttls 없이 바로 연결합니다.
알림 기준 정하기
알림은 갱신이 분명히 실패했을 때, 그러면서도 차분히 고칠 시간이 남았을 때 울려야 합니다.
만료 약 30일 전에 자동 갱신되는 90일짜리 ACME 인증서라면, 남은 기간이 대략 20일 아래로 내려갔다는 건 갱신 작업이 일주일 넘게 실패하고 있다는 뜻입니다. 14일에 알림을 걸고 장애로 다루세요.
구매 요청, 결재, 검증 메일이 끼는 상용 인증서라면 훨씬 일찍, 30~45일 전에 알림을 거세요. 느린 부분은 기술이 아니라 절차입니다.
점검은 내 네트워크 밖에서 돌리세요. 같은 서버에서 도는 모니터는 서버와 같은 파일, 같은 로컬 DNS를 보므로 CDN이나 로드밸런서가 무엇을 내보내는지 알 수 없습니다.
자동 갱신이 실패하는 이유
갱신 실패는 대부분 아래 목록 안에 있습니다. 먼저 클라이언트 로그를 보세요. certbot은 /var/log/letsencrypt/letsencrypt.log, systemd 타이머로 돌리는 시스템이라면 journalctl -u certbot.service입니다.
타이머나 cron이 돌지 않음. 패키지로 설치하면 보통 certbot.timer(snap이면 snap.certbot.renew.timer)가 생깁니다. systemctl list-timers | grep certbot으로 확인하세요. 컨테이너나 직접 만든 이미지에는 스케줄러가 아예 없는 경우가 많습니다.
HTTP-01이 포트 80에 닿지 못함. 새 방화벽 규칙, 클라우드 보안 그룹, 혹은 이제 그 이름에 응답하는 CDN 때문입니다. Let’s Encrypt는 여러 위치에서 검증하므로, 포트 80의 해외 IP 차단이나 IP 허용 목록도 갱신을 깨뜨립니다. 국내 서비스에서 해외 접속을 막아 두었다면 특히 확인하세요. 포트 점검으로 80·443을 바깥에서 확인할 수 있습니다.
webroot 경로가 바뀜. 인증서를 만들 때는 --webroot -w /var/www/html이 맞았는데, 이후 사이트가 /srv/app/public으로 옮겨져 챌린지 파일이 nginx가 더 이상 서비스하지 않는 디렉터리에 써지는 경우입니다.
DNS-01 자격 증명 만료. API 토큰은 교체되고, 담당자가 떠나면 폐기되고, 엉뚱한 영역 권한으로 만들어지기도 합니다. DNS-01만 쓸 수 있는 와일드카드 인증서에서 흔합니다.
DNS가 옮겨 감. 도메인이 새 호스팅이나 CDN을 가리키는데, 예전 서버가 더 이상 트래픽을 받지 않는 이름을 계속 갱신하려는 경우입니다.
CAA 레코드가 CA를 막음. 누군가 CAA를 좁혔거나, 서브도메인에 내 CA가 빠진 레코드가 따로 생겼습니다. 오류는 CAA record ... prevents issuance입니다. CAA 레코드를 보세요.
발급 한도(rate limit). 망가진 설정으로 운영 CA에 계속 재시도하면 Let’s Encrypt 한도에 걸려 한동안 발급이 막힐 수 있습니다. 시험은 스테이징 환경을 쓰는 --dry-run으로 하세요.
certbot renew --dry-run은 검증을 포함한 전체 경로를 스테이징에서 돌립니다. 방화벽, DNS 호스팅, 웹 서버 구조, CAA를 바꿀 때마다 돌려 보세요.
갱신은 배포가 아니다: 훅
갱신은 새 파일을 쓸 뿐입니다. 서비스가 리로드하기 전까지는 아무도 그 파일을 읽지 않습니다. nginx, Apache, HAProxy, Postfix, Dovecot은 모두 시작하거나 리로드할 때 인증서를 읽어 메모리에 둡니다. Java 애플리케이션은 보통 시작할 때 키스토어를 한 번 읽습니다.
certbot은 이 문제를 deploy hook으로 풉니다. 갱신이 성공했을 때만 실행됩니다. /etc/letsencrypt/renewal-hooks/deploy/에 실행 파일을 두면 모든 인증서에 대해 실행됩니다.
#!/bin/sh
# /etc/letsencrypt/renewal-hooks/deploy/reload-services.sh (chmod +x)
set -e
nginx -t && systemctl reload nginx
systemctl reload postfix dovecot
인증서 하나에만 걸려면 --deploy-hook을 쓰고, certbot이 이를 /etc/letsencrypt/renewal/<이름>.conf에 저장합니다.
certbot reconfigure --cert-name example.com --deploy-hook "systemctl reload nginx"
reconfigure는 최근 certbot 버전에 있습니다. 예전 버전이라면 그 갱신 설정 파일의 [renewalparams] 아래에 renew_hook = systemctl reload nginx 줄을 넣으세요.
deploy hook 안에서 $RENEWED_LINEAGE는 인증서 디렉터리(예: /etc/letsencrypt/live/example.com), $RENEWED_DOMAINS는 인증서의 이름 목록입니다. 파일 형태가 다른 서비스에 필요한 것이 이것입니다. HAProxy는 보통 체인과 키를 한 PEM에 담아 씁니다.
cat "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" > /etc/haproxy/certs/example.com.pem
systemctl reload haproxy
acme.sh는 --install-cert로 같은 일을 합니다. 원하는 위치에 파일을 복사하고 리로드 명령을 저장해 둡니다.
acme.sh --install-cert -d example.com \
--key-file /etc/nginx/ssl/example.com.key \
--fullchain-file /etc/nginx/ssl/example.com.crt \
--reloadcmd "systemctl reload nginx"
cert.pem이 아니라 fullchain.pem을 배포하세요. 리프 인증서만 있어도 빠진 중간 인증서를 스스로 채우는 일부 브라우저에서는 되지만, curl, Java, 안드로이드 앱, 메일 클라이언트에서는 실패합니다. 갱신하면서 중간 인증서가 바뀔 수도 있으므로, 작년에 고정해 둔 체인 파일이 문제를 일으키기도 합니다. 체인 불일치를 보세요.
인증서가 있는 모든 곳
인증서 만료 사고 대부분은 웹 서버에서 나지 않습니다. 웹 서버에서는 갱신됐는데 다른 곳에서는 안 된 것입니다. 목록을 한 번 만들어 두고, deploy hook이나 운영 절차서가 모든 줄을 다루게 하세요.
| 위치 | 주의할 점 |
|---|---|
| 웹 서버(nginx, Apache, Caddy) | 갱신 후 리로드 필요. Caddy는 인증서를 스스로 관리 |
| CDN 엣지 | Cloudflare 자체 엣지 인증서는 자동 갱신. 직접 업로드한 사용자 인증서는 자동 갱신되지 않으므로 손이나 API로 교체 |
| CDN 뒤의 원 서버 | 별도 인증서, 별도 일정. Cloudflare Full (strict)에서 원 서버 인증서가 만료되면 브라우저 인증서 오류가 아니라 526 에러로 나타남 |
| 클라우드 로드밸런서 | AWS ACM은 DNS 검증으로 발급한 인증서를 검증용 CNAME이 DNS에 남아 있는 한 자동 갱신. 메일 검증 인증서는 누군가 승인해야 하고, 가져온(import) 인증서는 절대 자동 갱신되지 않음 |
| 서버 풀의 모든 노드 | www가 IP 여러 개로 풀리거나 로드밸런서 뒤 여러 백엔드가 TLS를 처리하면, 한 대만 낡아도 간헐적 오류가 남. IP마다 확인(아래) |
| 메일 서버 | Postfix(SMTP 25/465/587)와 Dovecot(IMAP 993, POP3 995)는 보통 경로가 따로 있고 리로드도 따로 필요. 만료되면 웹 오류가 아니라 메일 전송 실패와 메일 클라이언트 경고로 나타남 |
| Kubernetes | cert-manager는 Secret으로 갱신. 인그레스 컨트롤러는 알아서 반영하지만, 시작할 때 마운트된 파일을 읽은 애플리케이션은 재시작이 필요할 수 있음 |
| Java·.NET 애플리케이션 | 키스토어와 Windows 인증서 저장소에 사본이 있음. 가져오기와 보통 재시작이 필요 |
| 장비 | VPN 게이트웨이, 방화벽, NAS, 프린터, 관리 콘솔. 대개 제조사 화면에서 손으로 갱신 |
| 박제된 사본 | Docker 이미지, 인증서나 키를 고정(pinning)한 모바일 앱, 내 인증서를 고정한 협력사 시스템. certbot은 --reuse-key를 쓰지 않으면 갱신마다 새 키를 만들므로 키 고정이 깨짐 |
이름 하나 뒤의 IP를 모두 확인하려면:
for ip in $(dig +short A www.example.com | grep -E '^[0-9.]+$'); do
printf '%s ' "$ip"
echo | openssl s_client -connect "$ip:443" -servername www.example.com 2>/dev/null \
| openssl x509 -noout -enddate
done
그 이름이 CDN 뒤에 있다면 이 IP들은 CDN 엣지이고, 확인하는 것도 CDN 인증서입니다. 원 서버를 보려면 원 서버 IP에 같은 방식으로, -servername에는 공개 호스트명을 넣어 연결하세요.
갱신할 때마다 확인할 것
- 모든 공개 이름(루트,
www, API, 메일 호스트)이 바깥에서 새 만료일을 보여 주는가. 웹 쪽은 SSL 점검으로 충분합니다. - 체인이 검증되는가.
openssl s_client ... -verify_return_error가 깔끔하게 끝나고,-k없는 curl이 성공해야 합니다. - 리다이렉트를 포함해 HTTPS가 끝까지 응답하는가. HTTP 점검이 리다이렉트 체인과 최종 상태를 보여 줍니다.
- 이번 갱신이 실패했다면 모니터가 잡아냈을까. 만료 알림이 울리는 것을 한 번도 본 적이 없다면, 유효 기간이 짧은 인증서를 단 시험 엔드포인트에 한 번 걸어 보고 알림이 실제로 사람에게 닿는지 확인하세요.
갱신 뒤의 체인 문제는 일부 클라이언트에서만 NET::ERR_CERT_AUTHORITY_INVALID로 나타나고 다른 곳에서는 멀쩡합니다. 그리고 체인도 만료됩니다. 2021년 9월 Let’s Encrypt의 예전 루트가 만료됐을 때, 서버 인증서 자체는 유효했는데도 일부 오래된 클라이언트가 실패했습니다. 모니터는 리프 인증서의 날짜만이 아니라 체인 검증까지 확인해야 합니다.
내 도메인에서 바로 확인
무료, 가입 불필요. 이 가이드가 다루는 항목을 바로 검사하고 조치 방법을 알려드립니다.