TLS 인증서는 서버의 공개 키가 특정 이름(도메인)의 것이라는 사실을 CA가 서명으로 보증하는 문서입니다. 클라이언트는 서버가 보낸 인증서에서 출발해 중간 CA를 거쳐 자신이 이미 신뢰하는 루트 CA까지 서명을 따라 올라가고, 그 과정에서 유효 기간과 접속한 이름이 맞는지도 확인합니다. 그래서 인증서 장애는 대부분 중간 인증서 누락, 이름 불일치, 만료 중 하나로 나타납니다.
1. 인증서가 하는 일
HTTPS 연결에서 클라이언트가 확인하고 싶은 것은 두 가지입니다. 지금 대화하는 상대가 정말 내가 접속하려던 서버인지, 그리고 주고받는 데이터를 다른 사람이 볼 수 없는지입니다. 암호화는 키 교환으로 해결되지만, 키를 주고받는 상대가 진짜인지는 따로 증명해야 합니다. 인증서가 이 역할을 합니다.
인증서에는 서버의 공개 키와 그 키를 쓸 이름이 들어 있고, 인증 기관(CA)이 이 내용 전체에 서명합니다. 클라이언트가 그 CA를 신뢰한다면, 서명이 맞는 한 "이 공개 키는 이 이름의 것"이라는 내용도 믿을 수 있습니다. 인증서에서 운영자가 자주 보는 항목은 아래와 같습니다.
| 항목 | 의미 |
| Subject | 인증서 주인, 서버 인증서에서는 보통 도메인 이름 |
| Issuer | 이 인증서에 서명한 CA의 이름, 체인을 이을 때 사용 |
| SAN | Subject Alternative Name, 이 인증서로 접속할 수 있는 이름 목록 |
| Validity | 유효 기간 시작일(notBefore)과 종료일(notAfter) |
| Basic Constraints | CA 인증서인지 여부, CA만 다른 인증서에 서명할 수 있음 |
| Key Usage, EKU | 키의 용도, 서버 인증서는 serverAuth |
2. 인증서 체인과 신뢰 저장소
서버 인증서는 보통 루트 CA가 직접 서명하지 않습니다. 루트 CA는 중간 CA에만 서명하고, 실제 서버 인증서는 중간 CA가 발급합니다. 루트 CA의 개인 키는 유출되면 그 CA를 믿는 모든 클라이언트에 영향을 주기 때문에 평소에는 쓰지 않고 보관하기 위해서입니다.

서버는 자기 인증서와 중간 인증서를 보내고, 루트 CA는 클라이언트의 신뢰 저장소에 미리 들어 있습니다
클라이언트는 OS, 브라우저, Java나 Python 같은 런타임이 가진 신뢰 저장소(trust store)에 루트 CA 목록을 미리 갖고 있습니다. 서버는 핸드셰이크에서 자기 인증서를 맨 앞에, 그 인증서에 서명한 중간 인증서를 그다음에 보냅니다. TLS 1.3 표준(RFC 8446)은 보내는 쪽의 인증서가 반드시 첫 번째여야 하고, 상대가 이미 가진 루트 인증서는 생략해도 된다고 정하고 있습니다.
여기서 운영 장애가 가장 많이 생깁니다. 서버에 중간 인증서를 빼고 서버 인증서만 설정하면, 클라이언트는 서버 인증서에서 루트 CA까지 이어지는 고리를 찾지 못합니다. 일부 브라우저는 부족한 중간 인증서를 스스로 채워서 접속에 성공하기도 해서, 브라우저에서는 정상인데 curl이나 애플리케이션 서버에서만 실패하는 일이 생깁니다. 그래서 저는 인증서를 교체하면 브라우저가 아니라 openssl이나 curl로 체인을 확인합니다.
3. 핸드셰이크에서 인증서가 쓰이는 위치
TLS 1.3 핸드셰이크에서 인증서는 서버가 ServerHello 다음에 보내는 Certificate 메시지에 담깁니다. 그 뒤 서버는 CertificateVerify 메시지로 인증서의 공개 키에 짝이 되는 개인 키를 가지고 있다는 것을 증명합니다.

Certificate로 인증서를 보내고, CertificateVerify로 그 인증서의 개인 키를 가지고 있음을 증명합니다
| 메시지 | 역할 |
| ClientHello | 클라이언트가 지원하는 암호와 접속할 이름(SNI)을 보냄, 서버는 SNI로 보낼 인증서를 고름 |
| Certificate | 서버 인증서와 중간 인증서 목록 |
| CertificateVerify | 서버 개인 키로 지금까지의 핸드셰이크 전체에 서명, 인증서를 복사해 간 서버는 이 서명을 만들 수 없음 |
| Finished | 양쪽이 같은 키를 만들었고 중간에 메시지가 바뀌지 않았는지 확인 |
인증서 자체는 공개된 정보라 누구나 복사할 수 있습니다. 그래서 인증서만으로는 부족하고, CertificateVerify처럼 개인 키로 만든 서명이 함께 있어야 상대가 진짜라고 믿을 수 있습니다. 개인 키 파일 관리가 인증서 파일 관리보다 훨씬 중요한 이유입니다.
4. 클라이언트가 인증서를 검증하는 순서
클라이언트는 받은 인증서를 대략 아래 순서로 확인합니다. 하나라도 실패하면 연결을 끊습니다.

단계마다 실패하면 나오는 오류 문구가 달라서, 오류 문구만 보고도 어느 단계에서 막혔는지 알 수 있습니다
이름 확인에서 주의할 점은 SAN만 본다는 것입니다. 예전에는 Subject의 CN(Common Name)으로 이름을 확인하기도 했지만, 서비스 이름 검증 표준인 RFC 9525는 CN을 이름 확인에 쓰면 안 되고 SAN의 dNSName만 확인하도록 정하고 있습니다. 그래서 사설 인증서를 만들 때 CN에만 도메인을 넣고 SAN을 빠뜨리면 최신 클라이언트에서 이름 불일치 오류가 납니다.
이 밖에 폐기 여부(CRL, OCSP)와 용도(EKU) 확인도 있지만 클라이언트마다 확인 방식이 달라서, 이 글의 실습은 위 네 단계에 집중했습니다.
5. 사설 CA로 체인 만들어 보기
체인이 어떻게 이어지는지 직접 보기 위해 OpenSSL 3.0으로 루트 CA, 중간 CA, 서버 인증서를 만들었습니다. 키는 짧고 빠른 EC P-256을 썼습니다. 실습에서는 /etc/hosts에 app.example.internal과 api.example.internal을 127.0.0.1로 등록했습니다.
EC="-newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes"
# 1) 루트 CA: 스스로 서명, 클라이언트의 신뢰 저장소에 들어가는 인증서
openssl req -x509 -new $EC -keyout root.key -out root.crt -days 3650 \
-subj "/CN=Lab Root CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
# 2) 중간 CA: 루트 CA가 서명, 실제 서버 인증서를 발급하는 CA
openssl req -new $EC -keyout inter.key -out inter.csr -subj "/CN=Lab Intermediate CA"
printf "basicConstraints=critical,CA:TRUE,pathlen:0\n" > inter.ext
printf "keyUsage=critical,keyCertSign,cRLSign\n" >> inter.ext
openssl x509 -req -in inter.csr -CA root.crt -CAkey root.key -CAcreateserial \
-out inter.crt -days 1825 -extfile inter.ext
# 3) 서버 인증서: 중간 CA가 서명, SAN에 접속할 이름을 넣음
openssl req -new $EC -keyout server.key -out server.csr -subj "/CN=app.example.internal"
printf "subjectAltName=DNS:app.example.internal\n" > server.ext
printf "extendedKeyUsage=serverAuth\n" >> server.ext
openssl x509 -req -in server.csr -CA inter.crt -CAkey inter.key -CAcreateserial \
-out server.crt -days 90 -extfile server.ext
만든 인증서의 Subject와 Issuer를 보면 각 인증서가 누구에게 서명받았는지 이어지는 것을 확인할 수 있습니다.
for c in root inter server; do
echo "== $c.crt"
openssl x509 -in $c.crt -noout -subject -issuer -enddate
done
openssl x509 -in server.crt -noout -ext subjectAltName
== root.crt
subject=CN = Lab Root CA
issuer=CN = Lab Root CA
notAfter=Sep 24 03:58:57 2036 GMT
== inter.crt
subject=CN = Lab Intermediate CA
issuer=CN = Lab Root CA
notAfter=Sep 26 03:58:57 2031 GMT
== server.crt
subject=CN = app.example.internal
issuer=CN = Lab Intermediate CA
notAfter=Dec 26 03:58:57 2026 GMT
X509v3 Subject Alternative Name:
DNS:app.example.internal
서버 인증서의 Issuer는 중간 CA이고, 중간 CA의 Issuer는 루트 CA이며, 루트 CA는 Subject와 Issuer가 같은 자기 서명 인증서입니다. 이제 루트 CA만 신뢰한다고 가정하고 서버 인증서를 검증해 보았습니다.
# 중간 인증서 없이 검증
openssl verify -CAfile root.crt server.crt
# 중간 인증서를 함께 주고 검증
openssl verify -CAfile root.crt -untrusted inter.crt server.crt
CN = app.example.internal
error 20 at 0 depth lookup: unable to get local issuer certificate
error server.crt: verification failed
server.crt: OK
루트 CA만으로는 서버 인증서의 발급자인 중간 CA를 찾지 못해 error 20으로 실패하고, 중간 인증서를 함께 주면 OK가 나옵니다. 서버에서 중간 인증서를 빼먹었을 때 클라이언트가 겪는 상황이 바로 첫 번째 경우입니다.
6. 중간 인증서 누락 재현
같은 인증서로 두 서버를 띄웠습니다. 8443 포트는 서버 인증서만 보내고, 9443 포트는 중간 인증서를 함께 보냅니다.
# 8443: 서버 인증서만 보내는 서버 (중간 인증서 누락)
openssl s_server -accept 8443 -cert server.crt -key server.key -www -quiet &
# 9443: 서버 인증서와 중간 인증서를 함께 보내는 서버
openssl s_server -accept 9443 -cert server.crt -key server.key \
-cert_chain inter.crt -www -quiet &
curl로 두 서버에 접속하면 결과가 갈립니다.
for p in 8443 9443; do
echo "== port $p"
curl -sS -o /dev/null -w "HTTP %{http_code}\n" --cacert root.crt \
https://app.example.internal:$p/ 2>&1 | grep -E "^curl:|^HTTP"
done
== port 8443
curl: (60) SSL certificate problem: unable to get local issuer certificate
HTTP 000
== port 9443
HTTP 200
서버가 실제로 무엇을 보냈는지는 openssl s_client로 확인합니다. 0번은 서버 인증서, 1번은 중간 인증서입니다.
for p in 8443 9443; do
echo "== port $p"
openssl s_client -connect app.example.internal:$p -CAfile root.crt \
</dev/null 2>/dev/null | grep -E "^ [0-9] s:|^ i:|Verify return code"
done
== port 8443
0 s:CN = app.example.internal
i:CN = Lab Intermediate CA
Verify return code: 21 (unable to verify the first certificate)
== port 9443
0 s:CN = app.example.internal
i:CN = Lab Intermediate CA
1 s:CN = Lab Intermediate CA
i:CN = Lab Root CA
Verify return code: 0 (ok)
8443은 0번 인증서 하나만 보내서 검증 코드 21로 실패하고, 9443은 1번 중간 인증서까지 보내서 0(ok)이 나옵니다. Nginx 같은 웹 서버에서는 서버 인증서 파일 뒤에 중간 인증서를 이어 붙인 파일(fullchain)을 설정하면 두 번째 서버처럼 동작합니다. 저는 인증서를 교체한 뒤 이 명령으로 s:와 i:가 두 줄씩 나오는지부터 확인합니다.
7. 이름 불일치와 만료 재현
나머지 두 가지 대표 오류도 재현했습니다. 만료된 인증서는 openssl ca로 시작일과 종료일을 과거로 지정해 만들고 10443 포트로 띄웠습니다.
# 이미 만료된 서버 인증서: openssl ca로 시작일과 종료일을 직접 지정
mkdir -p ca && touch ca/index.txt && echo 1000 > ca/serial
cat > ca.cnf <<'CNF'
[ca]
default_ca = lab
[lab]
dir = ./ca
database = $dir/index.txt
serial = $dir/serial
new_certs_dir = $dir
default_md = sha256
policy = any
copy_extensions = copy
[any]
commonName = supplied
CNF
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout old.key -out old.csr -subj "/CN=app.example.internal" \
-addext "subjectAltName=DNS:app.example.internal"
openssl ca -batch -config ca.cnf -cert inter.crt -keyfile inter.key -in old.csr \
-out old.crt -startdate 20250101000000Z -enddate 20250401000000Z
openssl s_server -accept 10443 -cert old.crt -key old.key \
-cert_chain inter.crt -www -quiet &
그다음 SAN에 없는 이름(api.example.internal)으로 9443에 접속하고, 만료된 인증서를 쓰는 10443에 접속했습니다.
# SAN에 없는 이름(api)으로 접속
curl -sS -o /dev/null --cacert root.crt \
https://api.example.internal:9443/ 2>&1 | grep "^curl:" | fold -s -w 80
# 만료된 인증서를 쓰는 서버에 접속
curl -sS -o /dev/null --cacert root.crt \
https://app.example.internal:10443/ 2>&1 | grep "^curl:" | fold -s -w 80
curl: (60) SSL: no alternative certificate subject name matches target host
name 'api.example.internal'
curl: (60) SSL certificate problem: certificate has expired
| 오류 문구 | 원인과 조치 |
| unable to get local issuer certificate | 중간 인증서 누락이나 신뢰하지 않는 CA, 서버에 체인 전체를 설정하거나 클라이언트 신뢰 저장소에 CA 추가 |
| no alternative certificate subject name matches | 접속한 이름이 SAN에 없음, 올바른 이름으로 접속하거나 SAN에 이름을 넣어 재발급 |
| certificate has expired | 유효 기간 종료, 재발급 후 교체하고 서버 시간도 확인 |
만료 오류는 인증서가 정상이어도 클라이언트 시간이 틀리면 날 수 있습니다. 그래서 저는 만료 오류를 받으면 인증서 날짜와 함께 클라이언트 서버의 NTP 동기화 상태도 같이 봅니다.
8. 만료 점검 자동화
인증서 장애 중 가장 흔하고 가장 예방하기 쉬운 것이 만료입니다. 공인 인증서의 최대 유효 기간도 점점 짧아지고 있습니다. CA/Browser Forum 기준(Baseline Requirements)은 아래처럼 발급일에 따라 최대 유효 기간을 줄이도록 정하고 있습니다.
| 발급일 | 최대 유효 기간 |
| 2026-03-15 이전 | 398일 |
| 2026-03-15부터 | 200일 |
| 2027-03-15부터 | 100일 |
| 2029-03-15부터 | 47일 |
교체 주기가 짧아질수록 사람이 달력을 보고 교체하는 방식으로는 버티기 어렵습니다. 저는 ACME 같은 자동 발급을 먼저 적용하고, 자동 발급이 실패했을 때 알아챌 수 있도록 실제 서버에서 받은 인증서의 만료일을 따로 점검합니다. 파일이 아니라 서버에서 받은 인증서를 보는 이유는, 새 인증서를 받아 놓고 서버에 적용하지 않은 경우까지 잡기 위해서입니다.
#!/usr/bin/env bash
# 사용법: ./check_expiry.sh 이름:포트 ...
# 남은 일수가 WARN_DAYS 이하면 WARN, 이미 지났으면 EXPIRED
WARN_DAYS=${WARN_DAYS:-30}
for target in "$@"; do
host=${target%:*}; port=${target#*:}
end=$(openssl s_client -connect "$host:$port" -servername "$host" \
</dev/null 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null \
| cut -d= -f2)
if [ -z "$end" ]; then echo "ERROR $target 인증서를 받지 못함"; continue; fi
left=$(( ($(date -d "$end" +%s) - $(date +%s)) / 86400 ))
status=OK
[ "$left" -le "$WARN_DAYS" ] && status=WARN
[ "$left" -lt 0 ] && status=EXPIRED
printf "%-7s %-26s %5d일 남음 (%s)\n" "$status" "$target" "$left" "$end"
done
./check_expiry.sh app.example.internal:9443 app.example.internal:10443
WARN_DAYS=100 ./check_expiry.sh app.example.internal:9443
OK app.example.internal:9443 89일 남음 (Dec 26 03:58:57 2026 GMT)
EXPIRED app.example.internal:10443 -544일 남음 (Apr 1 00:00:00 2025 GMT)
WARN app.example.internal:9443 89일 남음 (Dec 26 03:58:57 2026 GMT)
기본 기준인 30일로는 9443이 OK이고, 기준을 100일로 올리면 같은 인증서가 WARN으로 바뀝니다. 이 스크립트를 cron이나 모니터링 도구에서 주기적으로 돌리고 WARN과 EXPIRED를 알림으로 보내면 됩니다. 인증서 파일만 가진 경우에는 openssl x509 -checkend로 종료 코드를 받아 쓸 수 있습니다.
# 파일로 가진 인증서는 -checkend로 N초 안에 만료되는지 확인
openssl x509 -in server.crt -noout -checkend $((30*86400)); echo "exit=$?"
openssl x509 -in old.crt -noout -checkend 0; echo "exit=$?"
Certificate will not expire
exit=0
Certificate will expire
exit=1
9. 운영에서 확인할 것
| 항목 | 확인하는 이유 |
| 체인 전체 설정 | 서버 인증서만 넣으면 브라우저 외 클라이언트에서 실패 |
| SAN | CN이 아니라 SAN에 접속 이름이 모두 있어야 함, 와일드카드는 한 단계 하위 이름만 포함 |
| 개인 키 보관 | 인증서는 공개 정보라 유출되어도 되지만 개인 키가 유출되면 폐기 후 재발급 |
| 사설 CA 배포 | 사내 CA를 쓰면 OS뿐 아니라 Java, Python 등 런타임의 신뢰 저장소에도 루트 CA를 넣어야 함 |
| 만료 알림 | 파일이 아니라 실제 서버에서 받은 인증서의 만료일로 점검 |
10. 정리
- 인증서는 공개 키와 이름을 묶어 CA가 서명한 문서이고, 개인 키 소유는 CertificateVerify로 증명합니다.
- 서버는 서버 인증서와 중간 인증서를 함께 보내야 하고, 루트 CA는 클라이언트의 신뢰 저장소에 있습니다.
- 클라이언트는 체인, 서명, 유효 기간, 이름(SAN)을 차례로 확인하며, 이름 확인에 CN은 쓰지 않습니다.
- 대표 오류 세 가지는 중간 인증서 누락, 이름 불일치, 만료이며 오류 문구로 구분할 수 있습니다.
- 공인 인증서 유효 기간이 짧아지므로 자동 발급과 서버 기준의 만료 점검을 함께 둡니다.
참고
'Network > 네트워크 기초' 카테고리의 다른 글
| FHRP란? HSRP, VRRP, GLBP 비교 (0) | 2021.08.06 |
|---|---|
| [네트워크] 통신사별·공용 DNS 서버 주소 정리 (0) | 2021.03.15 |
| 가상화 네트워크의 브리지 모드란? (0) | 2020.09.04 |
| [네트워크] 주요 TCP/UDP 포트 번호 정리 (0) | 2020.08.31 |
| [네트워크] MAC 주소와 유니캐스트, 브로드캐스트, 멀티캐스트 (0) | 2020.08.28 |