- Authors
- Name
- 들어가며
- 1. SSL/TLS 동작 원리
- 2. 인증서 종류
- 3. Let's Encrypt + Certbot 실전
- 4. Nginx SSL 설정 (A+ 등급)
- 5. AWS ACM 인증서 관리
- 6. 인증서 자동 갱신
- 7. 인증서 디버깅 명령어 모음
- 8. 트러블슈팅
- 9. 고급 설정
- 10. 운영 체크리스트
- 마무리
들어가며
HTTPS는 이제 선택이 아닌 필수입니다. 브라우저는 HTTP 사이트에 "안전하지 않음" 경고를 표시하고, 검색 엔진은 HTTPS를 랭킹 시그널로 활용합니다. 하지만 SSL/TLS 인증서를 제대로 이해하고 운영하는 것은 생각보다 복잡합니다. 인증서 종류 선택, 올바른 설정, 자동 갱신, 체인 구성 등 놓치기 쉬운 부분이 많습니다.
이 글에서는 SSL/TLS의 동작 원리부터 실전 운영까지 한 번에 정리합니다.
1. SSL/TLS 동작 원리
대칭 암호화 vs 비대칭 암호화
SSL/TLS는 두 가지 암호화 방식을 함께 사용합니다.
| 구분 | 대칭 암호화 | 비대칭 암호화 |
|---|---|---|
| 키 | 하나의 키로 암호화/복호화 | 공개키(암호화) + 개인키(복호화) |
| 속도 | 빠름 | 느림 (100~1000배) |
| 알고리즘 | AES-256, ChaCha20 | RSA, ECDSA, Ed25519 |
| 용도 | 실제 데이터 전송 | 키 교환, 인증서 서명 |
| 키 길이 | 128/256 bit | 2048/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-RTT Handshake: TLS 1.2의 2-RTT에서 1-RTT로 단축
- 0-RTT Resumption: 재접속 시 추가 지연 없이 데이터 전송 가능
- 강화된 보안: 취약한 암호 스위트 제거 (RC4, 3DES, CBC 모드 등)
- Forward Secrecy 필수: 모든 키 교환에 ECDHE 또는 DHE 사용
인증서 검증 흐름
[브라우저] -> 서버 인증서 수신
-> 인증서 유효기간 확인
-> 도메인명 일치 확인
-> 인증서 체인 검증 (서버 인증서 → 중간 CA → 루트 CA)
-> OCSP/CRL로 폐기 여부 확인
-> 검증 성공 → 자물쇠 아이콘 표시
2. 인증서 종류
검증 레벨별 분류
| 종류 | 검증 수준 | 발급 시간 | 비용 | 용도 |
|---|---|---|---|---|
| DV (Domain Validation) | 도메인 소유권만 확인 | 수 분 | 무료~저가 | 개인 블로그, 소규모 서비스 |
| OV (Organization Validation) | 조직 실재 확인 | 1~3일 | 중간 | 기업 웹사이트 |
| EV (Extended Validation) | 법적 실체 확인 | 1~2주 | 고가 | 금융, 전자상거래 |
적용 범위별 분류
| 종류 | 적용 범위 | 예시 | 비용 |
|---|---|---|---|
| 단일 도메인 | 하나의 FQDN | www.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 ACM | Let'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일 유효기간 정책은 자동화를 강제하는 좋은 관행입니다. 이 글에서 다룬 내용을 바탕으로 안전하고 자동화된 인증서 관리 체계를 구축하시기 바랍니다.
핵심 정리:
- TLS 1.3을 기본으로 사용하되, 호환성을 위해 TLS 1.2도 허용
- Let's Encrypt + Certbot으로 무료 인증서 자동 발급 및 갱신
- Nginx 설정은 SSL Labs A+ 등급을 목표로 최적화
- 자동 갱신은 systemd timer로 설정하고 모니터링 필수
- 트러블슈팅은
openssl s_client명령어로 시작