infra
Platform

모듈 맵

[Infra Ops] Let's Encrypt 인증서 발급과 Nginx SSL 설정

0 / 52 완료

펼치기
0 / 52 완료0%

인프라 운영 & SRE · 14 / 52

[Infra Ops] Let's Encrypt 인증서 발급과 Nginx SSL 설정

Let's Encrypt와 Certbot으로 무료 SSL 인증서를 발급하고 Nginx에 적용하는 실무 과정 전체를 다룹니다. 인증서 자동 갱신과 HSTS 설정까지 프로덕션 수준으로 구성합니다.

🚨INCIDENT ALERT
HIGH

배포한 서비스에 처음으로 도메인을 연결하고 나면 브라우저가 "이 연결은 안전하지 않습니다"라는 경고를 띄웁니다. 사용자 입장에서 HTTP 사이트는 이미 신뢰할 수 없는 곳입니다. 게다가 구글 검색 순위도 HTTPS를 우선합니다. 팀장은 "오늘 안에 HTTPS로 전환해줘"라고 말하는데, 어디서부터 시작해야 할지 막막합니다.

Let's Encrypt를 쓰면 무료로, 그것도 10분 안에 해결할 수 있습니다. 이 모듈에서 처음부터 끝까지 같이 해봅니다.

이번 챕터에서 배울 것
  • 1SSL/TLS 인증서가 무엇인지, CA와 인증서 체인이 왜 필요한지 설명할 수 있다
  • 2Certbot으로 Let's Encrypt 인증서를 발급하고 Nginx에 자동 적용할 수 있다
  • 3HTTP → HTTPS 리다이렉트와 HSTS를 Nginx에 설정할 수 있다
  • 4인증서 자동 갱신을 cron 또는 systemd timer로 구성할 수 있다
  • 5openssl 명령어로 인증서 만료일을 확인하고 현장에서 즉시 점검할 수 있다
실습 환경 준비
도메인이 서버 IP로 연결됐는지 확인 (A 레코드)
dig +short yourdomain.com
80/443 포트 방화벽 오픈 여부 확인
sudo ss -tlnp | grep -E ':80|:443'
Certbot 설치 (Ubuntu/Debian)
sudo apt-get update && sudo apt-get install -y certbot python3-certbot-nginx
Certbot 설치 (RHEL/CentOS 9+)
sudo dnf install -y certbot python3-certbot-nginx
설치 확인
certbot --version

SSL/TLS 기초 개념

💡개념

인증서와 CA: 브라우저가 신뢰하는 구조

HTTPS의 자물쇠 아이콘은 단순히 "암호화됐다"는 표시가 아닙니다. "이 서버가 주장하는 도메인의 진짜 소유자임을 제3자가 서명했다"는 의미입니다. 이 서명 체계가 없다면 공격자가 example.com을 사칭하는 서버를 만들어도 브라우저는 알아채지 못합니다. CA(인증기관)와 인증서 체인은 브라우저가 신뢰할 서버와 그렇지 않은 서버를 구분하는 공개키 기반 신뢰 인프라의 핵심입니다.

HTTP→HTTPS 전환과 TLS 핸드셰이크 — Nginx가 80에서 301로 HTTPS 리다이렉트 후 443에서 TLS 핸드셰이크 수행(ClientHello→ServerHello+인증서→인증서 검증(CA 체인, Let's Encrypt→ISRG Root)→세션 키 교환→Finished). HSTS로 이후 HTTP 자동 차단, ssl_protocols로 SSLv3·TLS1.0/1.1 비활성화하고 TLS1.2/1.3만 허용확대

HTTPS가 단순히 "암호화된 HTTP"라고 생각하면 절반만 맞습니다. 암호화만으로는 내가 통신하는 상대가 진짜 그 서버인지 알 수 없습니다. 그래서 인증서가 필요합니다. 인증서는 "이 서버가 정말 example.com이다"라는 제3자의 서명입니다.

이 제3자를 **CA(Certificate Authority, 인증기관)**라고 부릅니다. 브라우저와 OS에는 신뢰할 수 있는 CA 목록이 내장되어 있습니다. 서버 인증서가 신뢰할 수 있는 CA의 서명을 받았다면 브라우저는 초록 자물쇠를 표시합니다.

인증서는 체인 구조입니다: 브라우저 내장 루트 CA → 중간 CA(Let's Encrypt R11 등) → 서버 인증서(your-domain.com)

Let's Encrypt는 ISRG(Internet Security Research Group)이 운영하는 무료 CA입니다. 2016년 이후 주요 브라우저와 OS 모두에서 신뢰하며, 자동화 도구(Certbot)로 발급과 갱신을 처리할 수 있어 사실상 표준이 됐습니다.

인증서 파일 두 가지:

파일내용Nginx 설정
fullchain.pem서버 인증서 + 중간 CA 체인ssl_certificate
privkey.pem서버 개인키 (절대 외부 노출 금지)ssl_certificate_key

HTTPS 요청이 Nginx에서 복호화되는 단계

💡개념

암호문으로 도착한 요청이 Nginx에서 평문이 되어 백엔드로 가기까지 6단계

브라우저가 https://로 보낸 요청은 암호문으로 도착합니다. Nginx는 이 암호문을 받아 복호화하고(TLS 종료), 평문 HTTP로 백엔드에 넘긴 뒤, 백엔드 응답을 다시 암호화해 돌려보냅니다. 이 "복호화 지점" 역할을 단계로 알아야 인증서 만료, SNI 불일치, mixed content, 핸드셰이크 실패가 각각 어느 단계에서 터지는지 짚을 수 있습니다. 핸드셰이크 내부는 다음 개념에서 자세히 보고, 여기서는 요청 하나가 Nginx를 통과하는 전체 경로를 봅니다.

TEXT
[브라우저]  https://shop.example.com/order   (암호문)
   │
   ① ClientHello        지원 TLS 버전·cipher 목록 + SNI(접속하려는 도메인) 제시
   │
   ② 인증서 제시         SNI에 맞는 server 블록의 ssl_certificate(fullchain) 반환
   │
   ③ 키 교환·세션키       인증서 검증 후 양측이 대칭 세션키 합의            (여기까지가 핸드셰이크)
   │
   ④ 복호화(TLS 종료)     이후 도착하는 암호문을 세션키로 복호화 → 평문 HTTP 요청 확보
   │
   ⑤ 평문으로 백엔드 전달   location 매칭 → proxy_pass 로 평문 HTTP를 WAS에 전달
   │
   ⑥ 응답 암호화          백엔드 평문 응답을 세션키로 다시 암호화해 반환
   ▼
[브라우저]  200 (복호화해서 렌더링)

각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:

단계하는 일여기서 막히면
① ClientHello클라이언트가 TLS 버전·cipher 후보와 SNI(접속 도메인)를 보냄서버가 지원하는 ssl_protocols와 겹치는 버전 없음 → handshake failure(구형 클라이언트·TLS1.0만 등)
② 인증서 제시SNIserver 블록을 골라 그 블록의 ssl_certificate 반환SNI·server_name 불일치 → 엉뚱한·기본 인증서 반환 → 이름 불일치 경고 / 인증서 만료 → NET::ERR_CERT_DATE_INVALID
③ 키 교환·세션키클라이언트가 인증서 체인 검증 후 대칭 세션키 합의중간 CA 누락(leaf만) → 일부 클라이언트만 unable to get local issuer certificate(브라우저는 통과)
④ 복호화세션키로 이후 암호문을 복호화해 평문 HTTP 요청 확보(TLS 종료)listen 443ssl 누락 → 평문 취급 → ERR_SSL_PROTOCOL_ERROR·SSL_ERROR_RX_RECORD_TOO_LONG
⑤ 백엔드 전달location 매칭 후 proxy_pass로 평문 HTTP를 WAS에 전달백엔드가 절대 URL을 http로 생성 → 브라우저 mixed content 차단(X-Forwarded-Proto 전달로 방지)
⑥ 응답 암호화백엔드 평문 응답을 세션키로 재암호화해 반환페이지 내 리소스를 http로 링크 → mixed content 경고·차단

즉 HTTPS 문제는 핸드셰이크에서 깨졌나(①③), 복호화·연결에서 깨졌나(④⑤), 콘텐츠에서 깨졌나(⑥ mixed content) 로 갈립니다 — handshake failure·인증서 경고는 ①③(버전·SNI·체인·만료), ERR_SSL_PROTOCOL_ERROR는 ④(listen 443 ssl 확인), mixed content는 ⑤⑥(백엔드가 https를 인지하도록 X-Forwarded-Proto)입니다. 그래서 판정은 브라우저가 아니라 openssl s_client -connect 도메인:443 -servername 도메인으로 서버가 실제 보내는 인증서 체인과 Verify return code를 보고, listen 줄에 ssl이 있는지를 nginx -T로 확인하는 순서로 좁힙니다.

💡개념

TLS 핸드셰이크: 첫 연결에서 무슨 일이 벌어지나

HTTP는 연결하면 바로 데이터를 주고받습니다. HTTPS는 데이터 교환 전에 TLS 핸드셰이크가 먼저 일어납니다. 이 과정을 이해해야 ssl_session_cache 같은 설정이 왜 필요한지 납득이 됩니다.

TLS 설정 레이어 — cipher suite, 프로토콜 버전, 인증서 체인 구조확대

핸드셰이크는 대략 이렇게 동작합니다:

  1. 클라이언트 → 서버: "이런 암호화 방식을 지원해요" (ClientHello)
  2. 서버 → 클라이언트: "이 방식을 쓰자, 내 인증서야" (ServerHello + Certificate)
  3. 클라이언트: 인증서를 CA 체인으로 검증
  4. 클라이언트 ↔ 서버: 세션 키 교환 (대칭키 협상)
  5. 이후부터는 협상한 대칭키로 암호화 통신

CPU 비용이 있습니다. 핸드셰이크는 비대칭 암호 연산을 포함하기 때문에 일반 HTTP보다 비쌉니다. 그래서 Nginx에 세션 캐시를 설정해 재연결 시 핸드셰이크를 생략합니다.

Nginx
# 이 설정이 왜 있는지 이제 납득이 됩니다
ssl_session_cache shared:SSL:10m;   # 워커 공유 캐시 10MB
ssl_session_timeout 10m;            # 캐시 유지 시간

Certbot으로 인증서 발급

1Certbot Nginx 플러그인으로 인증서 발급 및 자동 설정

--nginx 플러그인은 인증서 발급 후 Nginx 설정 파일을 자동으로 수정합니다. 이메일 주소 입력(만료 알림용), 약관 동의, HTTP→HTTPS 자동 리다이렉트 여부를 순서대로 물어봅니다. 도메인이 서버 IP로 제대로 연결되어 있어야 합니다. Certbot이 80포트로 도전(challenge)을 받아 도메인 소유권을 확인하기 때문입니다.

sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
🔍실행 후 확인할 것
  • 먼저 "Successfully received certificate" 메시지 확인, 그 다음 ls /etc/letsencrypt/live/yourdomain.com/ 으로 fullchain.pem과 privkey.pem 파일 존재 확인 — 메시지가 있어도 파일이 없으면 발급 실패
  • 만료일 기준: Certificate is valid for 90 days 메시지에서 날짜 확인 — 발급 직후 89~90일이 정상, 30일 이하이면 자동 갱신 설정 점검 필요
  • "Successfully received certificate"인데 브라우저에서 자물쇠 아이콘이 안 보이면 → Nginx 설정에 ssl_certificate 경로가 신규 발급 경로와 다른 것 — cat /etc/nginx/sites-enabled/yourdomain.com | grep ssl_certificate 로 경로 대조
2Certbot이 수정한 Nginx 설정 확인 및 이해

--nginx 플러그인이 자동으로 수정한 설정 파일을 확인합니다. ssl_certificate, ssl_certificate_key가 추가됐고, 80포트 블록에 리다이렉트가 붙었는지 살펴봅니다. 자동 설정만 믿지 말고 실제 내용을 이해해야 이후에 문제가 생겼을 때 대응할 수 있습니다.

sudo cat /etc/nginx/sites-enabled/yourdomain.com
🔍실행 후 확인할 것
  • 먼저 listen 443 ssl; 블록 존재 여부 확인, 그 다음 ssl_certificate와 ssl_certificate_key 경로가 실제 파일과 일치하는지 확인 — 경로 불일치가 가장 흔한 SSL 설정 오류
  • 설정 항목 기준: listen 443 ssl 이 "listen 443;"(ssl 없음)이면 HTTPS 적용 안 됨, 80포트 블록에 return 301 이 없으면 HTTP로 직접 접근 가능 — 두 항목 모두 있어야 HTTPS 강제 적용
  • 80 listen에 return 301이 있는데 curl -I http://yourdomain.com 에서 301이 안 나오면 → nginx -t 후 systemctl reload nginx 실행 여부 확인 — reload 없이는 설정이 파일에만 있고 반영 안 됨

Nginx SSL 수동 설정 (플러그인 없이 직접 작성)

💡개념

Nginx SSL 설정 전체 구조 이해하기

Certbot 플러그인이 자동으로 해주는 것도 좋지만, 실제로 무슨 설정이 들어가는지 알아야 합니다. 직접 관리하는 서버에서는 설정의 각 줄이 무엇을 의미하는지 설명할 수 있어야 합니다.

Nginx
# /etc/nginx/sites-available/yourdomain.com

# 1. HTTP → HTTPS 리다이렉트
server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    # 301 영구 리다이렉트 — SEO와 브라우저 캐시 모두 HTTPS로 기억
    return 301 https://$host$request_uri;
}

# 2. HTTPS 메인 블록
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name yourdomain.com www.yourdomain.com;

    # 인증서 파일 경로
    ssl_certificate     /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # 권장 TLS 버전 (1.2, 1.3만 허용 — 1.0, 1.1은 취약)
    ssl_protocols TLSv1.2 TLSv1.3;

    # 강력한 암호화 스위트만 허용
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;  # TLS 1.3에서는 off 권장

    # 세션 캐시 — 재연결 시 핸드셰이크 생략
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    # HSTS — 브라우저가 이 도메인은 항상 HTTPS로 접근하도록 기억
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # 기타 보안 헤더
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options DENY always;

    root /var/www/yourdomain.com;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

HSTS 주의사항: max-age=31536000(1년)으로 설정하면 브라우저가 1년간 이 도메인을 HTTPS로만 접근합니다. HTTPS를 다시 HTTP로 내릴 일이 없다고 확신할 때만 설정하세요. 처음에는 max-age=300으로 시작해 문제 없으면 늘리는 게 안전합니다.

3설정 적용 및 Nginx 재로드

-t 플래그로 설정 문법을 먼저 검증합니다. 오류가 없으면 reload로 무중단 적용합니다. restart가 아닌 reload를 쓰는 이유는, restart는 서비스를 잠깐 멈추지만 reload는 기존 연결을 끊지 않고 설정만 다시 읽기 때문입니다.

sudo nginx -t && sudo systemctl reload nginx
🔍실행 후 확인할 것
  • nginx -t 두 줄 통과 확인 먼저, 그 다음 curl -I http://yourdomain.com 으로 301 리다이렉트 확인, 마지막으로 curl -I https://yourdomain.com 으로 200 응답 확인 — 이 세 단계 순서로 검증
  • 응답 코드 기준: HTTP 301+Location: https://=HTTP→HTTPS 리다이렉트 정상, HTTPS 200=SSL 인증서 적용 정상, HTTPS 응답에 Strict-Transport-Security 없으면 HSTS 미설정(브라우저 캐시 HTTP 허용 상태)
  • curl -I http://에서 301인데 curl -I https://에서 SSL 오류가 나면 → 인증서 경로 또는 도메인 불일치 — openssl s_client -connect yourdomain.com:443 으로 인증서 Subject CN 확인

인증서 자동 갱신

💡개념

자동 갱신 설정: cron vs systemd timer

Let's Encrypt 인증서는 90일마다 만료됩니다. 수동으로 갱신하다 깜빡하면 사이트가 "인증서 만료" 경고로 접근 불가가 됩니다. 자동 갱신은 선택이 아닌 필수입니다.

Certbot 설치 시 systemd timer가 자동으로 등록됩니다. Ubuntu 기준으로 확인해봅니다.

서버 터미널
# systemd timer 등록 여부 확인
sudo systemctl status certbot.timer
# certbot.timer - Run certbot twice daily
#   Active: active (waiting)
#   Trigger: Wed 2025-01-15 12:00:00 UTC

# 직접 갱신 테스트 (실제로 갱신하지 않고 시뮬레이션만)
sudo certbot renew --dry-run

systemd timer가 없는 환경(CentOS 7, 일부 구버전)이나 직접 통제하고 싶을 때는 cron을 씁니다.

로컬 터미널
# crontab 편집
sudo crontab -e

# 매일 새벽 2시 30분, 오후 2시 30분 두 번 실행
# certbot은 만료 30일 이내일 때만 실제 갱신 수행
30 2,14 * * * certbot renew --quiet --post-hook "systemctl reload nginx"

# --quiet: 성공 시 출력 없음 (메일 스팸 방지)
# --post-hook: 갱신이 실제로 일어났을 때만 nginx reload

왜 하루 두 번인가? Let's Encrypt 서버 장애나 네트워크 문제로 갱신이 실패할 경우를 대비합니다. 한 번 실패해도 몇 시간 후 재시도 기회가 있습니다.

인증서 점검 명령어

4인증서 만료일 및 상태 점검

현재 서버에 발급된 모든 인증서의 상태, 만료일, 경로를 한 번에 확인합니다. 온콜 대응 중 "인증서가 만료됐나요?"라는 질문을 받으면 이 명령 하나로 대답할 수 있습니다.

sudo certbot certificates
🔍실행 후 확인할 것
  • certbot certificates 출력에서 먼저 Certificate Name이 yourdomain.com 인지 보고, 그 다음 Expiry Date의 VALID: N days 숫자 확인 — 도메인이 다르면 다른 인증서가 적용 중인 것
  • 남은 일수 기준: 30일 이상=정상(자동 갱신 대기 중), 30일 미만=자동 갱신 설정 점검 필요, 0일 이하=인증서 만료 → 브라우저 접근 차단 상태이므로 certbot renew --force-renewal 즉시 실행
  • VALID: N days 가 충분한데 브라우저에서 "인증서 만료" 오류가 나면 → Nginx가 오래된 인증서 경로를 참조 중 — nginx -T | grep ssl_certificate 로 현재 적용 경로 확인 후 certbot certificates 경로와 대조
로컬 터미널
# 원격 서버 인증서를 외부에서 점검하는 방법 (스크립팅에 자주 씀)
echo | openssl s_client -connect yourdomain.com:443 2>/dev/null \
  | openssl x509 -noout -dates
# notBefore=Jan 10 00:00:00 2025 GMT
# notAfter=Apr 10 23:59:59 2025 GMT

# 만료까지 며칠 남았는지 숫자로 출력
echo | openssl s_client -connect yourdomain.com:443 2>/dev/null \
  | openssl x509 -noout -enddate \
  | awk -F= '{print $2}' \
  | xargs -I{} date -d "{}" +%s \
  | xargs -I{} sh -c 'echo $(( ({} - $(date +%s)) / 86400 )) days left'

# 로컬 파일로 인증서 만료일 확인
sudo openssl x509 -noout -dates \
  -in /etc/letsencrypt/live/yourdomain.com/fullchain.pem

Certbot이 standalone 모드나 webroot 검증 중에 80포트를 직접 사용하려다 Nginx가 이미 80포트를 점유해 충돌이 납니다. --nginx 플러그인은 Nginx를 멈추지 않고 .well-known/acme-challenge/ 경로를 직접 Nginx를 통해 처리하기 때문에 이 오류가 나지 않습니다.

standalone 모드를 굳이 써야 한다면:

서버 터미널
# 1. Nginx 임시 중단
sudo systemctl stop nginx

# 2. 인증서 발급 (standalone 모드)
sudo certbot certonly --standalone -d yourdomain.com

# 3. Nginx 재시작
sudo systemctl start nginx

또는 처음부터 --nginx 플러그인을 사용합니다.

로컬 터미널
# 권장 방법: nginx 플러그인 사용 (서비스 중단 없음)
sudo certbot --nginx -d yourdomain.com

이 오류는 443포트로 접속했는데 서버가 HTTP 응답(암호화 안 된 텍스트)을 보낼 때 발생합니다. 브라우저는 TLS 핸드셰이크 응답을 기대하는데 일반 HTTP 데이터가 오면 이 오류를 냅니다.

원인을 순서대로 확인합니다:

로컬 터미널
# 1. Nginx가 443에서 ssl로 리스닝 중인지 확인
sudo ss -tlnp | grep ':443'
# LISTEN  0  511  0.0.0.0:443  0.0.0.0:*  users:(("nginx",...))  ← 있어야 함

# 2. Nginx 설정에 ssl이 빠진 경우
# 잘못된 예시:
# listen 443;          ← ssl 키워드가 없음
# 올바른 예시:
# listen 443 ssl;

# 3. 설정 다시 확인
sudo nginx -T | grep -A 20 'listen 443'

# 4. 수정 후 reload
sudo nginx -t && sudo systemctl reload nginx

Certbot 자동 설정 후 수동으로 설정 파일을 편집하다가 ssl 키워드를 실수로 지우는 경우가 많습니다.

--post-hook "systemctl reload nginx"를 쓰지 않고 갱신 후 Nginx가 새 인증서를 읽지 못하는 경우, 그리고 UFW/firewalld가 80포트를 막아 ACME 도전(challenge)이 실패하는 경우입니다.

로컬 터미널
# 갱신 로그 확인
sudo tail -50 /var/log/letsencrypt/letsencrypt.log

# 방화벽 80포트 열려있는지 확인
sudo ufw status | grep '80\|443'
# 또는
sudo firewall-cmd --list-all | grep ports

# 80포트 열기 (UFW)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

# 방화벽 확인 후 dry-run 재시도
sudo certbot renew --dry-run

# renew hook 설정 확인
cat /etc/letsencrypt/renewal/yourdomain.com.conf
# 여기에 post_hook = systemctl reload nginx 가 있어야 함

심화 — 핸드셰이크에서 서버가 실제로 보내는 것

💡개념

심화: 체인 완결성과 OCSP — '브라우저는 됨'이 숨기는 것

앞의 핸드셰이크 그림에서 서버가 '내 인증서'를 보낸다고 했습니다. 정확히는 서버가 무엇을 보내고 클라이언트가 무엇으로 검증하는지를 알아야, '브라우저는 되는데 특정 클라이언트만 실패하는' 사고를 이해할 수 있습니다.

  • 서버는 leaf + 중간 CA를 함께 보내야 합니다: 서버 인증서(leaf) 하나만 보내는 게 아니라, 그 위의 중간 CA 인증서까지 묶어 보내야 클라이언트가 자기가 가진 루트 CA까지 체인을 이을 수 있습니다(루트는 클라이언트가 이미 보유). 이 묶음이 fullchain.pem입니다.
  • 왜 브라우저는 되고 다른 클라이언트는 안 되나: 서버가 leaf만 보내 체인이 불완전해도, 최신 브라우저는 중간 인증서를 캐시해 뒀거나 인증서의 AIA 필드를 보고 중간 인증서를 스스로 내려받아(AIA fetching) 체인을 메꿔 성공합니다. 반면 curl·Java(HttpClient)·Go·모바일 앱·모니터링/웹훅 발신자는 대개 그 보정을 하지 않아, 같은 서버에서 unable to get local issuer certificate 같은 체인 오류를 냅니다. 그래서 '내 브라우저는 되는데 왜 파트너 API만 실패하지?'가 생깁니다.
  • 폐기 확인과 OCSP stapling: 클라이언트는 인증서가 중간에 폐기됐는지도 확인합니다. 매 연결마다 CA의 OCSP 서버에 물으면 느리고 프라이버시 문제가 있어, 서버가 CA의 OCSP 응답을 미리 받아 핸드셰이크에 끼워 보내는 OCSP stapling(ssl_stapling on)을 씁니다. 다만 stapling이 잘못 설정돼 응답을 못 받아오면 첫 핸드셰이크가 느려지거나 경고가 남습니다.

정리하면 인증서 '적용됨'은 leaf 하나만으로도 브라우저에선 통과할 수 있어 조용히 넘어갑니다. 하지만 진짜 완결은 '모든 클라이언트가 체인을 검증할 수 있는가'입니다. 그래서 ssl_certificate에는 항상 fullchain.pem을 쓰고, 판정은 브라우저가 아니라 openssl s_client의 체인 출력과 Verify return code로 합니다.

상황: 웹 브라우저로 열면 자물쇠가 멀쩡하고 경고도 없습니다. 그런데 파트너사 서버가 우리 API를 curl·Java로 호출하면 unable to get local issuer certificate(또는 unable to verify the first certificate)로 실패하고, 일부 안드로이드 앱·웹훅 수신에서도 인증서 오류가 납니다. 인증서는 분명 만료 전이고 브라우저에선 아무 문제가 없습니다.

원인: 서버가 핸드셰이크에서 중간 CA 인증서를 함께 보내지 않아 체인이 불완전합니다. ssl_certificatefullchain.pem이 아니라 leaf만 든 cert.pem을 지정했거나, 수동으로 인증서를 합치다 중간 인증서를 빠뜨린 경우입니다. 브라우저는 중간 인증서를 캐시했거나 AIA fetching으로 스스로 메꿔 성공하지만, curl·Java·모바일·웹훅 발신자는 그 보정을 하지 않아 체인이 끊긴 채 실패합니다. '브라우저 됨'이 '체인 완결'을 뜻하지 않습니다.

진단: 외부에서 echo | openssl s_client -connect yourdomain.com:443 -servername yourdomain.com을 실행해 Certificate chain 섹션에 leaf만 있는지(중간 CA가 빠졌는지), 그리고 Verify return code0 (ok)가 아니라 20 (unable to get local issuer certificate)인지 확인합니다. nginx -T | grep ssl_certificate로 설정이 fullchain.pem을 가리키는지 대조하면 확정됩니다.

해결: ssl_certificate를 Let's Encrypt가 만들어 준 fullchain.pem(서버+중간)으로 바꾸고 reload합니다(개인키는 그대로 privkey.pem). 수동 인증서라면 CA가 준 중간 인증서를 leaf 뒤에 이어 붙인 파일을 씁니다. 재검증은 다시 openssl s_clientVerify return code: 0 (ok)와 체인에 중간 CA가 보이는지 확인합니다 — 판정 기준은 브라우저가 아니라 이 도구입니다(SSL/TLS 인증서 형식 변환과 갱신 절차).

💼
실무 맥락
현업 패턴

실무에서 HTTPS 전환 요청이 들어오면 대부분 Certbot으로 10분 만에 처리합니다. 하지만 그 이후가 진짜입니다.

프로덕션에서 자주 생기는 추가 작업:

로컬 터미널
# 1. 인증서 만료 모니터링 스크립트 (cron으로 매주 실행)
#!/bin/bash
DOMAIN="yourdomain.com"
DAYS_LEFT=$(echo | openssl s_client -connect ${DOMAIN}:443 2>/dev/null \
  | openssl x509 -noout -enddate \
  | awk -F= '{print $2}' \
  | xargs -I{} date -d "{}" +%s \
  | xargs -I{} sh -c 'echo $(( ({} - $(date +%s)) / 86400 ))')

if [ "$DAYS_LEFT" -lt 14 ]; then
  echo "경고: ${DOMAIN} 인증서 만료 ${DAYS_LEFT}일 남음" | mail -s "SSL 만료 경고" ops@company.com
fi

# 2. 여러 도메인 한 인증서로 묶기 (SAN 인증서)
sudo certbot --nginx \
  -d yourdomain.com \
  -d www.yourdomain.com \
  -d api.yourdomain.com \
  -d admin.yourdomain.com

# 3. 와일드카드 인증서 발급 (DNS 도전 방식 필요)
sudo certbot certonly --manual \
  --preferred-challenges=dns \
  -d "*.yourdomain.com" \
  -d yourdomain.com
# → DNS TXT 레코드 추가를 수동으로 해야 함 (자동화하려면 DNS 플러그인 필요)

온콜 체크리스트 — "사이트 SSL 오류 발생" 신고 받았을 때:

로컬 터미널
# 1. 만료 여부 즉시 확인
echo | openssl s_client -connect yourdomain.com:443 2>/dev/null \
  | openssl x509 -noout -dates

# 2. Nginx SSL 설정 유효성
sudo nginx -t

# 3. certbot 갱신 로그
sudo tail -30 /var/log/letsencrypt/letsencrypt.log

# 4. 수동 갱신 (만료가 원인인 경우)
sudo certbot renew --force-renewal
sudo systemctl reload nginx

인증서 자동 갱신은 설정 후 방치하는 것이 아닙니다. 갱신 성공 여부를 모니터링하고, 만료 2주 전 알림을 받는 구조를 만들어두는 것이 SRE 관점에서의 완성입니다.


명령어·단축키 빠른 참조

이 모듈에서 다룬 Let's Encrypt 발급·갱신·SSL 점검 명령과 지시어를 실전 옵션과 함께 모았습니다. "예" 열의 조합을 그대로 써도 됩니다.

명령어/단축키용도자주 쓰는 예
certbot --nginx -d yourdomain.com발급+Nginx 자동설정여러 도메인은 -d 반복(SAN)
certbot certonly --standalone -d …Nginx 없이 단독 발급발급 중 80 필요, 임시 systemctl stop nginx
certbot certificates발급 인증서 만료일·경로 확인VALID: N days 로 잔여일
certbot renew --dry-run갱신 시뮬레이션(실제 안 함)강제 갱신 certbot renew --force-renewal
systemctl status certbot.timer자동 갱신 타이머 확인cron certbot renew --post-hook "systemctl reload nginx"
openssl s_client -connect host:443원격 인증서·체인 점검-servername host, | openssl x509 -noout -dates
openssl x509 -noout -dates -in fullchain.pem로컬 인증서 만료일notBefore/notAfter 출력
nginx -t && systemctl reload nginx검증 후 무중단 반영restart 아닌 reload 원칙
nginx -T | grep ssl_certificate적용 중 인증서 경로 확인만료 오류 시 경로 불일치 대조
ssl_certificate / ssl_certificate_key인증서·개인키 경로반드시 fullchain.pem(체인 완결)
ssl_protocols TLSv1.2 TLSv1.3허용 TLS 버전1.0/1.1 비활성(취약)
ssl_session_cache shared:SSL:10m재연결 핸드셰이크 생략ssl_session_timeout 10m 과 세트
return 301 https://$host$request_uriHTTP→HTTPS 강제 리다이렉트강화 HSTS add_header Strict-Transport-Security …
ss -tlnp | grep ':443'443 SSL 리스닝 확인방화벽 ufw allow 443/tcp

관련 모듈로 더 깊이:

다음 모듈에서는 Nginx 성능 튜닝과 worker_processes, keepalive, gzip 압축 설정을 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

Let's Encrypt 인증서의 유효 기간은 90일입니다. Certbot 자동 갱신이 인증서를 실제로 갱신하는 시점은?

Q2

Nginx에서 HTTP(80포트) 요청을 HTTPS(443포트)로 강제 리다이렉트할 때 올바른 설정은?

Q3

HTTPS 서비스에서 모바일 사용자가 네트워크 전환(WiFi ↔ LTE) 후 재접속 시 응답이 느리다는 피드백이 있습니다. Nginx SSL 설정에서 재연결 TLS 핸드셰이크 비용을 줄이려면 어떤 설정을 추가해야 하는가?

Q4

openssl s_client 명령어로 인증서 만료일을 확인하는 올바른 방법은?

Q5

브라우저가 우리 사이트의 HTTPS 인증서를 '신뢰'하는 구조의 핵심은?

Q6

HTTP→HTTPS 리다이렉트는 첫 요청이 평문(80)으로 한 번 가는 틈이 있다. 이를 더 강화하려면?

Q7

[심화] Nginx에서 ssl_certificate에 fullchain.pem 대신 leaf 인증서만 든 파일을 지정했다. 브라우저는 정상인데 curl·Java·모바일 클라이언트에서만 인증서 검증 오류가 나는 이유는?

Q8

[심화] 브라우저는 자물쇠가 정상인데 파트너 API 호출(curl/Java)에서만 'unable to get local issuer certificate'가 난다. 체인 불완전이 원인인지 확정하기 위해 가장 유효한 확인은?

0 / 8 답변

🧪 실습으로 확인하기

SSL 인증서 갱신 운영 — 만료 진단부터 자동 갱신까지

중급

인증서 만료는 예고된 장애다. openssl로 현재 인증서의 만료일·체인·도메인을 진단하고, certbot으로 갱신한 뒤 웹서버에 무중단 반영하고, systemd timer(또는 cron)로 자동 갱신을 걸어 "만료"라는 사건 자체를 없앤다. 갱신 후 reload를 잊어 옛 인증서가 서빙되는 흔한 함정도 다룬다.

40📋 3단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요