Skip to content
Published on

SSL/TLS 인증서 완벽 가이드: 발급부터 자동 갱신, 트러블슈팅까지

Authors
  • Name
    Twitter

들어가며

HTTPS는 이제 선택이 아닌 필수입니다. 브라우저는 HTTP 사이트에 "안전하지 않음" 경고를 표시하고, 검색 엔진은 HTTPS를 랭킹 시그널로 활용합니다. 하지만 SSL/TLS 인증서를 제대로 이해하고 운영하는 것은 생각보다 복잡합니다. 인증서 종류 선택, 올바른 설정, 자동 갱신, 체인 구성 등 놓치기 쉬운 부분이 많습니다.

이 글에서는 SSL/TLS의 동작 원리부터 실전 운영까지 한 번에 정리합니다.

1. SSL/TLS 동작 원리

대칭 암호화 vs 비대칭 암호화

SSL/TLS는 두 가지 암호화 방식을 함께 사용합니다.

구분대칭 암호화비대칭 암호화
하나의 키로 암호화/복호화공개키(암호화) + 개인키(복호화)
속도빠름느림 (100~1000배)
알고리즘AES-256, ChaCha20RSA, ECDSA, Ed25519
용도실제 데이터 전송키 교환, 인증서 서명
키 길이128/256 bit2048/4096 bit (RSA)

TLS 1.3 Handshake 과정

TLS 1.3에서는 handshake가 1-RTT로 줄었습니다.

Client                                           Server
  |                                                 |
  |--- ClientHello (supported ciphers, key share) ->|
  |                                                 |
  |<-- ServerHello (chosen cipher, key share,       |
  |    EncryptedExtensions, Certificate,            |
  |    CertificateVerify, Finished) ---------------|
  |                                                 |
  |--- Finished (encrypted) ---------------------->|
  |                                                 |
  |<========= Application Data (encrypted) =======>|

TLS 1.3의 주요 개선점:

  1. 1-RTT Handshake: TLS 1.2의 2-RTT에서 1-RTT로 단축
  2. 0-RTT Resumption: 재접속 시 추가 지연 없이 데이터 전송 가능
  3. 강화된 보안: 취약한 암호 스위트 제거 (RC4, 3DES, CBC 모드 등)
  4. Forward Secrecy 필수: 모든 키 교환에 ECDHE 또는 DHE 사용

인증서 검증 흐름

[브라우저] -> 서버 인증서 수신
    -> 인증서 유효기간 확인
    -> 도메인명 일치 확인
    -> 인증서 체인 검증 (서버 인증서 → 중간 CA → 루트 CA)
    -> OCSP/CRL로 폐기 여부 확인
    -> 검증 성공 → 자물쇠 아이콘 표시

2. 인증서 종류

검증 레벨별 분류

종류검증 수준발급 시간비용용도
DV (Domain Validation)도메인 소유권만 확인수 분무료~저가개인 블로그, 소규모 서비스
OV (Organization Validation)조직 실재 확인1~3일중간기업 웹사이트
EV (Extended Validation)법적 실체 확인1~2주고가금융, 전자상거래

적용 범위별 분류

종류적용 범위예시비용
단일 도메인하나의 FQDNwww.example.com가장 저렴
와일드카드같은 레벨의 모든 서브도메인*.example.com중간
SAN/멀티도메인여러 도메인을 하나의 인증서에example.com, example.org도메인당 추가 비용

와일드카드 인증서 주의사항:

# *.example.com이 커버하는 범위
www.example.com    # O 커버됨
api.example.com    # O 커버됨
example.com        # X 커버 안 됨 (SAN에 별도 추가 필요)
sub.api.example.com # X 커버 안 됨 (2단계 서브도메인)

3. Let's Encrypt + Certbot 실전

Certbot 설치

# Ubuntu/Debian
sudo apt update
sudo apt install -y certbot python3-certbot-nginx

# CentOS/Rocky Linux
sudo dnf install -y epel-release
sudo dnf install -y certbot python3-certbot-nginx

# macOS (개발용)
brew install certbot

# Docker
docker run -it --rm \
  -v /etc/letsencrypt:/etc/letsencrypt \
  -v /var/lib/letsencrypt:/var/lib/letsencrypt \
  certbot/certbot certonly --help

인증서 발급 방식

Webroot 방식 (서비스 중단 없음)

# Nginx가 실행 중인 상태에서 발급
sudo certbot certonly --webroot \
  -w /var/www/html \
  -d example.com \
  -d www.example.com \
  --email admin@example.com \
  --agree-tos \
  --no-eff-email

# Nginx 설정에 .well-known 경로 추가 필요
# location /.well-known/acme-challenge/ {
#     root /var/www/html;
# }

Standalone 방식 (80번 포트 필요)

# 80번 포트를 사용하는 서비스를 잠시 중단해야 함
sudo systemctl stop nginx
sudo certbot certonly --standalone \
  -d example.com \
  -d www.example.com
sudo systemctl start nginx

DNS 방식 (와일드카드 인증서 발급 시 필수)

# DNS TXT 레코드로 검증
sudo certbot certonly --manual \
  --preferred-challenges dns \
  -d "*.example.com" \
  -d example.com

# 자동화를 위한 DNS 플러그인 사용 (Cloudflare 예시)
sudo pip install certbot-dns-cloudflare

# API 토큰 파일 생성
cat > /etc/letsencrypt/cloudflare.ini << 'EOF'
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
EOF
chmod 600 /etc/letsencrypt/cloudflare.ini

# Cloudflare DNS로 자동 발급
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "*.example.com" \
  -d example.com

발급된 인증서 파일 구조

/etc/letsencrypt/live/example.com/
├── cert.pem       # 서버 인증서
├── chain.pem      # 중간 CA 인증서
├── fullchain.pem  # cert.pem + chain.pem (Nginx에서 사용)
├── privkey.pem    # 개인키
└── README

4. Nginx SSL 설정 (A+ 등급)

기본 SSL 설정

# /etc/nginx/sites-available/example.com
server {
    listen 80;
    server_name example.com www.example.com;

    # HTTP -> HTTPS 리다이렉트
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    # 인증서 설정
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # SSL 프로토콜 (TLS 1.2, 1.3만 허용)
    ssl_protocols TLSv1.2 TLSv1.3;

    # 암호 스위트 (서버 우선)
    ssl_prefer_server_ciphers on;
    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';

    # ECDH 커브
    ssl_ecdh_curve X25519:prime256v1:secp384r1;

    # SSL 세션 캐시
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # OCSP Stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
    resolver 8.8.8.8 8.8.4.4 valid=300s;
    resolver_timeout 5s;

    # DH 파라미터 (2048비트 이상)
    ssl_dhparam /etc/nginx/dhparam.pem;

    # 보안 헤더
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;

    root /var/www/example.com;
    index index.html;
}

DH 파라미터 생성

# 2048비트 DH 파라미터 생성 (약 1~2분 소요)
sudo openssl dhparam -out /etc/nginx/dhparam.pem 2048

# 4096비트 (더 안전하지만 5~10분 소요)
sudo openssl dhparam -out /etc/nginx/dhparam.pem 4096

SSL Labs 테스트

# 설정 검증
sudo nginx -t

# 설정 적용
sudo systemctl reload nginx

# SSL Labs에서 A+ 등급 확인
# https://www.ssllabs.com/ssltest/analyze.html?d=example.com

# CLI로 확인
curl -sS https://api.ssllabs.com/api/v3/analyze?host=example.com | jq '.endpoints[0].grade'

5. AWS ACM 인증서 관리

ACM 인증서 발급

# AWS CLI로 인증서 요청
aws acm request-certificate \
  --domain-name example.com \
  --subject-alternative-names "*.example.com" \
  --validation-method DNS \
  --region ap-northeast-2

# 검증 상태 확인
aws acm describe-certificate \
  --certificate-arn arn:aws:acm:ap-northeast-2:123456789012:certificate/abc-123 \
  --query 'Certificate.DomainValidationOptions'

Terraform으로 ACM + Route53 자동화

# ACM 인증서 리소스
resource "aws_acm_certificate" "main" {
  domain_name               = "example.com"
  subject_alternative_names = ["*.example.com"]
  validation_method         = "DNS"

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Environment = "production"
    ManagedBy   = "terraform"
  }
}

# Route53 DNS 검증 레코드 자동 생성
resource "aws_route53_record" "cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      type   = dvo.resource_record_type
      record = dvo.resource_record_value
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = data.aws_route53_zone.main.zone_id
}

# 인증서 검증 완료 대기
resource "aws_acm_certificate_validation" "main" {
  certificate_arn         = aws_acm_certificate.main.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}

# ALB에 인증서 연결
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.main.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate_validation.main.certificate_arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.main.arn
  }
}

ACM vs Let's Encrypt 비교

항목AWS ACMLet's Encrypt
비용무료 (AWS 서비스 연동 시)무료
유효기간13개월 (자동 갱신)90일 (자동 갱신 필요)
와일드카드지원지원 (DNS 검증 필수)
사용 범위AWS ALB, CloudFront, API GW어디서든 사용 가능
인증서 내보내기불가 (Private CA 제외)가능
관리 부담거의 없음Certbot 관리 필요

6. 인증서 자동 갱신

Cron을 이용한 갱신

# Certbot 갱신 테스트
sudo certbot renew --dry-run

# Cron 작업 등록
sudo crontab -e

# 매일 새벽 3시에 갱신 확인 (30일 이내 만료 시 자동 갱신)
0 3 * * * /usr/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx"

Systemd Timer를 이용한 갱신 (권장)

# /etc/systemd/system/certbot-renewal.service
cat > /etc/systemd/system/certbot-renewal.service << 'EOF'
[Unit]
Description=Certbot Renewal
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx"
ExecStartPost=/bin/bash -c 'echo "Certbot renewal completed at $(date)" >> /var/log/certbot-renewal.log'
EOF

# /etc/systemd/system/certbot-renewal.timer
cat > /etc/systemd/system/certbot-renewal.timer << 'EOF'
[Unit]
Description=Run certbot renewal twice daily

[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=3600
Persistent=true

[Install]
WantedBy=timers.target
EOF

# 활성화
sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renewal.timer

# 타이머 상태 확인
sudo systemctl list-timers | grep certbot

갱신 모니터링 스크립트

#!/bin/bash
# /usr/local/bin/check-ssl-expiry.sh

DOMAINS=("example.com" "api.example.com" "admin.example.com")
ALERT_DAYS=14
SLACK_WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"

for domain in "${DOMAINS[@]}"; do
    expiry_date=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null \
        | openssl x509 -noout -enddate 2>/dev/null \
        | cut -d= -f2)

    if [ -z "$expiry_date" ]; then
        echo "ERROR: Cannot connect to $domain"
        continue
    fi

    expiry_epoch=$(date -d "$expiry_date" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$expiry_date" +%s)
    current_epoch=$(date +%s)
    days_left=$(( (expiry_epoch - current_epoch) / 86400 ))

    echo "$domain: $days_left days until expiry ($expiry_date)"

    if [ "$days_left" -lt "$ALERT_DAYS" ]; then
        curl -s -X POST "$SLACK_WEBHOOK" \
            -H 'Content-type: application/json' \
            -d "{\"text\":\"SSL 인증서 만료 경고: $domain - ${days_left}일 남음\"}"
    fi
done
# 실행 권한 부여 및 cron 등록
chmod +x /usr/local/bin/check-ssl-expiry.sh
echo "0 9 * * * /usr/local/bin/check-ssl-expiry.sh" | sudo crontab -

7. 인증서 디버깅 명령어 모음

# 인증서 정보 확인
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -text -noout

# 만료일만 확인
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -enddate

# 원격 서버 인증서 확인
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
    | openssl x509 -noout -text

# 인증서 체인 확인
echo | openssl s_client -showcerts -servername example.com -connect example.com:443 2>/dev/null

# 인증서와 개인키 매칭 확인
openssl x509 -noout -modulus -in cert.pem | openssl md5
openssl rsa -noout -modulus -in privkey.pem | openssl md5
# 두 값이 같으면 매칭됨

# 인증서 SAN 확인
openssl x509 -in cert.pem -noout -ext subjectAltName

# PEM → PFX(PKCS12) 변환
openssl pkcs12 -export -out cert.pfx \
    -inkey privkey.pem -in cert.pem -certfile chain.pem

# PFX → PEM 변환
openssl pkcs12 -in cert.pfx -out cert.pem -nodes

8. 트러블슈팅

문제 1: 인증서 만료 (ERR_CERT_DATE_INVALID)

# 현재 만료일 확인
echo | openssl s_client -connect example.com:443 2>/dev/null \
    | openssl x509 -noout -dates

# 즉시 갱신
sudo certbot renew --force-renewal --cert-name example.com
sudo systemctl reload nginx

문제 2: 인증서 체인 불완전 (ERR_CERT_AUTHORITY_INVALID)

# 체인 검증
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt \
    /etc/letsencrypt/live/example.com/fullchain.pem

# Nginx에서 fullchain.pem을 사용하고 있는지 확인
# 잘못된 설정:
# ssl_certificate /etc/letsencrypt/live/example.com/cert.pem;
# 올바른 설정:
# ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;

문제 3: Mixed Content 경고

# HTML에서 http:// 리소스 찾기
grep -rn "http://" /var/www/html/ --include="*.html" --include="*.js" --include="*.css"

# CSP 헤더로 자동 업그레이드
# Nginx 설정에 추가:
# add_header Content-Security-Policy "upgrade-insecure-requests;" always;

문제 4: SSL Handshake 실패

# 지원하는 프로토콜 확인
nmap --script ssl-enum-ciphers -p 443 example.com

# 특정 TLS 버전으로 연결 테스트
openssl s_client -connect example.com:443 -tls1_2
openssl s_client -connect example.com:443 -tls1_3

# 클라이언트가 지원하지 않는 암호 스위트 문제
# → ssl_ciphers 설정에 호환성 있는 스위트 추가

문제 5: HSTS 관련 이슈

# HSTS 헤더가 설정되어 있으면 HTTP로 돌아갈 수 없음
# 테스트 시에는 max-age를 짧게 설정
add_header Strict-Transport-Security "max-age=300" always;

# 프로덕션에서는 충분히 길게
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

문제 6: Let's Encrypt Rate Limit

# Rate Limit 확인
# - 동일 도메인: 주당 5회 발급 (갱신 제외)
# - 중복 인증서: 주당 5회
# - 계정당: 3시간에 300개 요청

# 테스트 시에는 staging 서버 사용
sudo certbot certonly --staging \
  --webroot -w /var/www/html \
  -d test.example.com

# staging 인증서 삭제 후 본 발급
sudo certbot delete --cert-name test.example.com

9. 고급 설정

mTLS (상호 TLS 인증)

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate     /etc/nginx/ssl/server.crt;
    ssl_certificate_key /etc/nginx/ssl/server.key;

    # 클라이언트 인증서 요구
    ssl_client_certificate /etc/nginx/ssl/ca.crt;
    ssl_verify_client on;
    ssl_verify_depth 2;

    location / {
        # 클라이언트 인증서 정보를 백엔드로 전달
        proxy_set_header X-Client-Cert-DN $ssl_client_s_dn;
        proxy_set_header X-Client-Cert-Serial $ssl_client_serial;
        proxy_pass http://backend;
    }
}

클라이언트 인증서 생성

# CA 키 및 인증서 생성
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
    -subj "/C=KR/ST=Seoul/O=MyOrg/CN=Internal CA"

# 클라이언트 키 및 CSR 생성
openssl genrsa -out client.key 2048
openssl req -new -key client.key -out client.csr \
    -subj "/C=KR/ST=Seoul/O=MyOrg/CN=client1"

# 클라이언트 인증서 발급
openssl x509 -req -days 365 \
    -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
    -out client.crt

# 테스트
curl --cert client.crt --key client.key https://api.example.com/

Certificate Transparency (CT) 로그 확인

# CT 로그에서 도메인 인증서 검색
# https://crt.sh/?q=example.com

# CLI로 확인
curl -s "https://crt.sh/?q=example.com&output=json" | jq '.[0:5] | .[] | {id, name_value, not_after}'

10. 운영 체크리스트

초기 설정 체크리스트

  • TLS 1.2 이상만 허용 (ssl_protocols TLSv1.2 TLSv1.3)
  • 강력한 암호 스위트만 사용 (ECDHE + AES-GCM/ChaCha20)
  • HSTS 헤더 설정 (Strict-Transport-Security)
  • HTTP → HTTPS 리다이렉트 (301)
  • OCSP Stapling 활성화
  • DH 파라미터 2048비트 이상 생성
  • SSL Labs에서 A+ 등급 확인
  • Mixed Content 없는지 확인

자동 갱신 체크리스트

  • Certbot 자동 갱신 설정 (cron 또는 systemd timer)
  • 갱신 테스트 (certbot renew --dry-run)
  • 갱신 후 Nginx reload hook 설정
  • 만료 모니터링 스크립트 배포
  • Slack/Email 알림 설정 (만료 14일 전)

정기 점검 체크리스트

  • 매월 인증서 만료일 확인
  • 분기별 SSL Labs 재점검
  • 새 보안 취약점 패치 확인 (OpenSSL, Nginx)
  • CT 로그에서 비정상 인증서 발급 모니터링
  • 보안 헤더 변경사항 점검

인시던트 대응 체크리스트

  • 인증서 만료 시 즉시 갱신 절차 문서화
  • 인증서 폐기(revoke) 절차 숙지
  • 긴급 인증서 재발급 절차 준비
  • 롤백 계획 수립 (이전 인증서 백업)
  • 인시던트 발생 시 알림 채널 지정

마무리

SSL/TLS 인증서 관리는 한 번 설정하면 끝이 아닙니다. Let's Encrypt의 90일 유효기간 정책은 자동화를 강제하는 좋은 관행입니다. 이 글에서 다룬 내용을 바탕으로 안전하고 자동화된 인증서 관리 체계를 구축하시기 바랍니다.

핵심 정리:

  1. TLS 1.3을 기본으로 사용하되, 호환성을 위해 TLS 1.2도 허용
  2. Let's Encrypt + Certbot으로 무료 인증서 자동 발급 및 갱신
  3. Nginx 설정은 SSL Labs A+ 등급을 목표로 최적화
  4. 자동 갱신은 systemd timer로 설정하고 모니터링 필수
  5. 트러블슈팅openssl s_client 명령어로 시작