HTTPS·TLS·인증서, openssl 과 curl 로 직접 읽기
TLS 핸드셰이크, 인증서 체인, SNI, HSTS 를 실제 openssl s_client·curl 출력으로 설명. 연결 시간 내역을 실측하고 curl (60) 오류를 전부 재현해 고치는 법까지 정리.
시리즈: HTTP 강좌 5/6 (고급). HTTP 헤더는 읽을 줄 아는 분이, 첫 헤더가 오기 전에 일어나는 일인 TLS 핸드셰이크를 디버깅할 수 있게 되는 회차입니다. 다 읽으면 openssl s_client 와 curl -v 출력을 한 줄씩 읽고, 인증서 체인과 이름을 확인하고, SNI 와 HSTS 를 설명하고, 연결 시간이 어디에 쓰이는지 나누어 보고, -k 없이 온갖 curl: (60) 을 고칠 수 있습니다.
이 글은 "브라우저에서는 자물쇠가 잘 뜨는데 서버의 크론 배치나 GitHub Actions 만 SSL certificate problem 으로 실패한다"는 문의를 받은 분, 또는 "개발 서버도 HTTPS 로 맞춰 주세요"라는 요청을 받은 분을 위한 글입니다. 밖에서 보면 같은 오류지만 원인은 제각각이라서 하나씩 재현합니다. 모두 Apple Silicon Mac(M1 Pro)에서 curl 8.7.1(LibreSSL 3.3.6 빌드), openssl s_client 용 OpenSSL 4.1.0, 127.0.0.1 로컬 HTTPS 서버용 Python 3.9.6 으로 실행했습니다. 원격 예시는 example.com 과 이 사이트에 보낸 몇 번의 읽기 전용 연결뿐입니다.
첫 바이트가 오기 전에 일어나는 일
HTTPS 요청은 네 단계이고, curl 은 -w 로 각 단계 시각을 찍을 수 있습니다.
- DNS 조회: 호스트 이름을 IP 주소로 바꾼다(
time_namelookup). - TCP 연결: 패킷 세 개로 연결을 연다. 왕복 한 번(
time_connect). - TLS 핸드셰이크: 키를 정하고 서버가 자기가 맞다는 것을 증명한다(
time_appconnect). - HTTP: 요청을 보내고 응답의 첫 바이트가 돌아온다(
time_starttransfer).
-w 값은 시작부터의 누적 초입니다. example.com 에 5번 보냈습니다.
F='%{http_version} dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\n'
for i in 1 2 3 4 5; do curl -s -o /dev/null -w "$F" https://example.com/; done
2 dns=0.002424 tcp=0.017864 tls=0.040317 ttfb=0.061982 total=0.062068 size=577
2 dns=0.002940 tcp=0.020906 tls=0.057755 ttfb=0.084294 total=0.084388 size=577
2 dns=0.002504 tcp=0.014979 tls=0.031247 ttfb=0.047049 total=0.047126 size=577
2 dns=0.002605 tcp=0.015017 tls=0.034790 ttfb=0.053917 total=0.054041 size=577
2 dns=0.002586 tcp=0.016089 tls=0.036742 ttfb=0.060710 total=0.061533 size=577
각 단계를 앞 단계와의 차이로 바꾸고 5회 중앙값을 내면 DNS 2.6 ms, TCP 13.5 ms, TLS 20.7 ms, 서버 처리와 첫 바이트까지 21.7 ms 입니다. TLS 가 TCP 보다 왕복 한 번 정도 더 길다는 점은 TLS 1.3 핸드셰이크가 왕복 한 번이라는 사실과 맞습니다(TLS 1.2 는 두 번이지만 여기서는 실행하지 않았습니다).
이 사이트에 세 번 보낸 결과는 왜 한 번만 재면 안 되는지 보여 줍니다.
2 dns=0.446026 tcp=0.462804 tls=0.628998 ttfb=0.669523 total=0.669712 size=3450
2 dns=0.002408 tcp=0.911410 tls=0.938718 ttfb=0.954015 total=0.954337 size=3450
2 dns=0.006323 tcp=0.022163 tls=0.044374 ttfb=0.079341 total=0.079685 size=3450
첫 번째는 DNS 캐시가 비어 있어서 446 ms. 두 번째는 TCP 연결에 909 ms 가 걸렸는데, SYN 패킷 하나가 사라져 재전송 타이머를 기다린 모습으로 보입니다(패킷을 캡처하지 않았으므로 추정입니다). 세 번째, 합계 80 ms 가 평소 값입니다.
curl -v 로 핸드셰이크 읽기
curl -v 는 TLS 메시지를 오가는 순서대로 보여 줍니다(IP 주소는 <ip> 로 바꿈).
* Connected to example.com (<ip>) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server accepted h2
* Server certificate:
* subject: CN=example.com
* start date: Sep 26 22:49:11 2026 GMT
* expire date: Dec 25 22:56:35 2026 GMT
* subjectAltName: host "example.com" matched cert's "example.com"
* issuer: C=US; O=SSL Corporation; CN=Cloudflare TLS Issuing ECC CA 3
* SSL certificate verify ok.
* using HTTP/2
- Client hello: 클라이언트가 지원하는 TLS 버전, 암호 스위트, 키 공유, 접속하려는 호스트 이름(아래의 SNI)을 보냅니다. ALPN 도 여기 들어 있고, curl 은
h2와http/1.1을 제안합니다. - Server hello: 서버가 버전과 암호를 고릅니다. TLS 1.3 에서는 여기부터 전부 암호화됩니다.
- Unknown (8): 8번 메시지는 EncryptedExtensions 입니다. 이 curl 의 LibreSSL 디버그 출력에 이름이 없어서 Unknown 으로 보일 뿐 오류가 아닙니다.
- Certificate 와 CERT verify: 서버가 체인을 보내고 개인 키를 가지고 있음을 증명합니다.
- 양쪽의 Finished 로 핸드셰이크가 끝나고,
ALPN: server accepted h2로 HTTP/2 를 쓰기로 정해집니다. subjectAltName: host "example.com" matched가 호스트 이름 확인,verify ok가 체인 확인입니다. 둘 다 통과해야 합니다.
암호도 보세요. curl(LibreSSL)은 ChaCha20-Poly1305 로 정해졌고, 같은 호스트에 openssl s_client 로 붙으면 TLS_AES_256_GCM_SHA384 였습니다. 클라이언트의 선호 순서로 정해지는 것이고 어느 쪽도 틀리지 않았습니다.
openssl s_client 로 인증서 체인 확인하기
openssl s_client -connect example.com:443 -servername example.com </dev/null
Certificate chain
0 s:CN=example.com
i:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
a:PKEY: EC, (prime256v1); sigalg: ecdsa-with-SHA256
v:NotBefore: Sep 26 22:49:11 2026 GMT; NotAfter: Dec 25 22:56:35 2026 GMT
1 s:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
i:C=US, O=SSL Corporation, CN=SSL.com TLS Transit ECC CA R2
…
Negotiated TLS1.3 group: X25519MLKEM768
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 0 (ok)
s: 는 주체(누구를 위한 인증서인지), i: 는 발급자(누가 서명했는지)입니다. 0번이 사이트의 리프(서버) 인증서, 그다음이 중간 CA, 마지막은 클라이언트가 원래 신뢰하는 루트로 이어집니다. 각 줄의 i: 가 다음 줄의 s: 와 같아야 합니다. 이 사이트에도 같은 명령을 실행했습니다.
0 s:CN=tlaboratory.net
i:C=US, O=Let's Encrypt, CN=YE1
v:NotBefore: Oct 6 09:53:38 2026 GMT; NotAfter: Jan 4 09:53:37 2027 GMT
1 s:C=US, O=Let's Encrypt, CN=YE1
i:C=US, O=ISRG, CN=Root YE
2 s:C=US, O=ISRG, CN=Root YE
i:C=US, O=Internet Security Research Group, CN=ISRG Root X2
3 s:C=US, O=Internet Security Research Group, CN=ISRG Root X2
i:C=US, O=Internet Security Research Group, CN=ISRG Root X1
Verify return code: 0 (ok)
두 리프 인증서 모두 유효 기간이 90일이라 갱신은 자동이어야 합니다. 인증서가 다루는 이름은 CN 이 아니라 subjectAltName 확장에 들어 있습니다.
openssl s_client -connect tlaboratory.net:443 -servername tlaboratory.net </dev/null 2>/dev/null \
| openssl x509 -noout -ext subjectAltName -dates
X509v3 Subject Alternative Name:
DNS:tlaboratory.net, DNS:www.tlaboratory.net
notBefore=Oct 6 09:53:38 2026 GMT
notAfter=Jan 4 09:53:37 2027 GMT
첫 출력의 X25519MLKEM768 도 눈여겨볼 만합니다. 두 서버 모두 기존 X25519 와 양자 내성 암호 ML-KEM 을 합친 하이브리드 키 교환으로 합의했습니다. 클라이언트에서 따로 설정한 것은 없고, OpenSSL 4.1.0 이 기본으로 제안했습니다.
SNI: IP 하나에 인증서 여러 개
공유 호스팅이나 CDN 은 IP 하나로 도메인 수천 개를 서비스합니다. 어떤 인증서를 내보낼지는 ClientHello 안의 SNI(Server Name Indication)를 보고 정합니다. 이것을 빼 보면 이렇습니다.
$ openssl s_client -connect example.com:443 -noservername </dev/null
…:error:0A000410:SSL routines:ssl3_read_bytes:tls alert handshake failure:…:SSL alert number 40
$ openssl s_client -connect tlaboratory.net:443 -noservername </dev/null
…:error:0A000438:SSL routines:ssl3_read_bytes:tlsv1 alert internal error:…:SSL alert number 80
서버가 다르니 경고도 다르지만 원인은 같습니다. 오래된 안드로이드 HTTP 클라이언트, 임베디드 기기, 직접 짠 소켓 코드가 CDN 에서만 실패하고 curl 은 된다면 SNI 누락을 먼저 의심합니다. OpenSSL 1.1.1 이후의 s_client 는 -connect 의 호스트 이름으로 SNI 를 자동으로 보내기 때문에, 여기서는 일부러 꺼야 했습니다.
루프백에서 자체 서명 인증서 만들고 종료 코드 60 전부 보기
로컬에서 HTTPS 를 시험하려고 localhost 와 127.0.0.1 용 30일짜리 인증서를 만듭니다.
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout key.pem -out cert.pem -days 30 -subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
keep-alive 를 지원하는 작은 HTTPS 서버(tls_server.py)입니다. 강좌 6에서도 씁니다.
# python3 tls_server.py PORT [cert.pem key.pem] -> HTTP/1.1 keep-alive server, HTTPS if cert given
import ssl, sys
from http.server import HTTPServer, BaseHTTPRequestHandler
BODY = b"hello over TLS\n"
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # keep-alive by default
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)
def log_message(self, fmt, *args):
pass
srv = HTTPServer(("127.0.0.1", int(sys.argv[1])), H)
if len(sys.argv) > 3:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(sys.argv[2], sys.argv[3])
srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
srv.serve_forever()
python3 tls_server.py 18160 cert.pem key.pem 으로 띄우고 흔한 실수를 하나씩 해 봤습니다.
$ curl -sS https://127.0.0.1:18160/; echo "exit=$?"
curl: (60) SSL certificate problem: self signed certificate
More details here: https://curl.se/docs/sslcerts.html
…
exit=60
$ curl -sS --cacert cert.pem https://127.0.0.1:18160/; echo "exit=$?"
hello over TLS
exit=0
$ curl -sS --cacert cert.pem --resolve wrong.example:18160:127.0.0.1 https://wrong.example:18160/
curl: (60) SSL: no alternative certificate subject name matches target host name 'wrong.example'
$ curl -sS --cacert old.pem https://localhost:18161/ # 2025-02-01 에 만료된 인증서
curl: (60) SSL certificate problem: certificate has expired
$ curl -sS http://127.0.0.1:18160/ # TLS 포트에 http://
curl: (56) Recv failure: Connection reset by peer
$ curl -sS https://127.0.0.1:18162/ # 평문 HTTP 포트에 https://
curl: (35) LibreSSL/3.3.6: error:1404B42E:SSL routines:ST_CONNECT:tlsv1 alert protocol version
openssl s_client 는 같은 상황을 Verify return code: 18 (self-signed certificate) 로 표현합니다. 만료된 인증서는 OpenSSL 의 -not_before 20250101000000Z -not_after 20250201000000Z 옵션으로 만들었습니다.
| 메시지 | 원인 | 해결 |
|---|---|---|
(60) … self signed certificate |
체인이 신뢰하는 CA 에 닿지 않음 | 테스트 CA 를 --cacert 로 지정, 운영 서버는 전체 체인 설치 |
(60) … unable to get local issuer certificate(여기서는 재현 안 함) |
서버가 중간 인증서를 안 보냄 | 서버에 풀체인 파일 설정 |
(60) … no alternative certificate subject name matches |
호스트 이름이 subjectAltName 에 없음 | 목록에 있는 이름을 쓰거나 이름을 넣어 재발급 |
(60) … certificate has expired |
NotAfter 지남(또는 기기 시계가 틀림) | 갱신, 기기 날짜 확인 |
(35) … alert protocol version / wrong version number |
평문 HTTP 포트에 TLS 로 말함 | 포트와 스킴 확인 |
(56) Recv failure / 빈 응답 |
TLS 포트에 평문 HTTP 로 말함 | https:// 사용 |
흔한 실수: "일단 -k 붙이기"
흔히 하는 일: 스크립트가 종료 코드 60 으로 실패하자 -k(--insecure)나 verify=False 를 붙였더니 된다. 실제로 일어나는 일: 통신은 여전히 암호화되지만 상대가 누구인지 아무도 확인하지 않습니다. 자체 서명 서버에 보낸 -k 요청은 hello over TLS, 종료 코드 0 으로 성공했는데, 중간에서 트래픽을 가로채는 아무 기계에 대해서도 똑같이 성공합니다. 게다가 "되니까" 운영 환경까지 그대로 갑니다. 해결: 특정 인증서를 신뢰하게 합니다(--cacert 나 언어별 CA 번들 설정). 공개 서버라면 서버를 고칩니다. 데스크톱 브라우저에서는 열리는데 curl 이나 앱에서 실패하는 가장 흔한 원인은 중간 인증서 누락입니다(브라우저는 중간 인증서를 캐시해 두었거나 스스로 받아 오기도 합니다).
HSTS: 첫 방문 이후 HTTPS 강제
http:// 를 https:// 로 리다이렉트해도 첫 평문 요청 한 번은 변조될 여지가 남습니다. HSTS(Strict-Transport-Security: max-age=<초>)는 "이 호스트에는 HTTP 를 시도하지 말고 처음부터 HTTPS 로 접속하라"고 클라이언트에 알려 줍니다. curl 은 --hsts <파일> 로 지원합니다. 로컬 서버가 Strict-Transport-Security: max-age=300 을 보내게 했습니다.
$ curl -s --cacert cert.pem --hsts hsts.txt -o /dev/null -D - https://localhost:18163/
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=300
…
$ grep -v '^#' hsts.txt
localhost "20261112 14:42:43"
$ curl -sv --cacert cert.pem --hsts hsts.txt http://localhost:18163/
* Switched from HTTP to HTTPS due to HSTS => https://localhost:18163/
…
hello over TLS
curl 은 5분 뒤 만료 시각(UTC)을 저장했고, 다음 http:// 요청을 보내기 전에 HTTPS 로 바꿨습니다. 글을 쓴 날 기준으로 example.com 도 이 사이트도 이 헤더를 보내지 않았고, 이 사이트는 http:// 에 301 Moved Permanently 로 https:// 를 안내했습니다. 도입한다면 짧은 max-age 부터 시작하세요. 브라우저가 긴 값을 한 번 기억하면 그 기간 동안 HTTP 로 되돌릴 수 없고, includeSubDomains 는 잊고 있던 서브도메인까지 전부 적용됩니다.
실측: 핸드셰이크 비용
"새 연결은 재사용에 비해 얼마나 비싼가"라는 같은 질문을 두 가지 방법으로 쟀습니다.
루프백, curl 프로세스 하나로 200건(https://localhost:18160/r[1-200]). 연결 재사용과 Connection: close 강제를 3세트 비교했습니다. 시간에는 curl 을 한 번 띄우는 시간이 포함됩니다.
| 방식 | 새 연결 수 | 합계(3세트) | 요청당 |
|---|---|---|---|
| HTTPS, 재사용 | 1 | 85 / 81 / 77 ms | 0.38~0.43 ms |
| HTTPS, Connection: close | 200 | 366 / 361 / 356 ms | 1.78~1.83 ms |
| 평문 HTTP, 재사용 | 1 | 75 / 83 / 68 ms | 0.34~0.42 ms |
| 평문 HTTP, Connection: close | 200 | 87 / 86 / 80 ms | 0.40~0.44 ms |
루프백에서 TCP 연결은 거의 공짜(요청당 약 0.05 ms)지만, TLS 핸드셰이크는 ECDSA P-256 인증서와 키 교환으로 한 번에 약 1.4 ms 의 CPU 를 썼습니다. 인터넷 너머로는 curl 프로세스 하나로 example.com 에 다섯 번 보냈을 때, 재사용하면 첫 요청 104 ms, 나머지 네 번은 18~23 ms 였고, Connection: close(HTTP/1.1)로는 매번 76~120 ms 였습니다. 강좌 6이 이 숫자에서 이어집니다.
정직한 한계와 버전 메모
- 실행한 것: curl 8.7.1(macOS 기본, LibreSSL 3.3.6),
s_client/req/x509용 OpenSSL 4.1.0, Python 3.9.6ssl서버. 원격 결과는 한 네트워크에서 하루에 몇 번 연결한 것뿐이라 왕복 시간은 환경마다 다릅니다. - 실행하지 않은 것: 브라우저(자체 인증서 저장소와 HSTS 프리로드 목록), TLS 1.2 핸드셰이크, 세션 재개와 0-RTT, OCSP/CRL 폐기 확인, 인증서 투명성(CT) 확인, 패킷 캡처.
- 명세: TLS 1.3 은 RFC 8446(2018년), SNI 는 RFC 6066, HSTS 는 RFC 6797(2012년), ALPN 은 RFC 7301 입니다. ML-KEM 하이브리드 키 교환은 OpenSSL 3.5 에서 들어왔지만 여기서는 4.1.0 에서만 확인했습니다.
openssl req의-not_before/-not_after도 새 옵션이며 4.1.0 에서만 확인했습니다. - 보여 준 유효 기간과 체인은 스냅숏입니다. 인증서는 몇 주마다 갱신됩니다.
연습문제
1. 서버의 크론 작업이 자사 API 에 대해 curl: (60) SSL certificate problem: unable to get local issuer certificate 로 실패합니다. 브라우저에는 자물쇠가 보입니다. 원인을 확인할 openssl s_client 출력의 어디를 보고, 어디를 고쳐야 할까요?
2. 느린 요청의 원인이 앱 코드가 아니라 TLS 라는 것을 -w 로 어떻게 보여 줄 수 있을까요?
정답
1. openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 을 실행해 Certificate chain 을 봅니다. 0번(리프)만 있고 Verify return code 가 20 이나 21(발급자를 가져오거나 검증할 수 없음)이면 서버가 중간 인증서를 보내지 않는 것입니다. 클라이언트가 아니라 서버에서 풀체인(리프 + 중간) 파일을 설정해 고칩니다.
2. time_connect, time_appconnect, time_starttransfer 를 출력합니다. TLS 시간은 time_appconnect − time_connect, 서버 쪽 시간은 time_starttransfer − time_appconnect 입니다. 이번 example.com 측정에서는 중앙값이 TLS 20.7 ms, 나머지 21.7 ms 로 어느 쪽도 튀지 않았습니다. 앱이 느리면 TLS 구간은 평범하고 마지막 구간만 커집니다.
다음 강좌
- 이전: HTTP 강좌 4/6: 캐시·ETag·Cache-Control
- 다음: HTTP 강좌 6/6: keep-alive·압축·HTTP/2·CORS
- 처음부터: 강좌 1/6: 요청·응답과 curl, 강좌 2/6: 상태 코드와 리다이렉트, 강좌 3/6: 헤더·쿠키·세션
- 실제 앱의 리다이렉트: Dart http 는 POST 의 301 을 따라가지 않는다
- 세션 쿠키에는 HTTPS 가 필요: PHP 보안: XSS·CSRF·세션
- 전체 시리즈 순서: 신입 개발자를 위한 프로그래밍 학습 로드맵
자주 묻는 질문
curl: (60) SSL certificate problem: self signed certificate 는 무슨 뜻인가요?
서버 인증서에서 curl 이 신뢰하는 인증기관(CA)까지 체인을 이을 수 없다는 뜻입니다. 로컬 테스트 서버라면 -k 로 검증을 끄지 말고 --cacert cert.pem 으로 그 인증서만 신뢰하게 합니다. 인증서의 subjectAltName 에 없는 호스트 이름, 만료된 인증서도 같은 종료 코드 60 이 나오며, SSL 뒤의 메시지로 구분합니다.
TLS 핸드셰이크는 시간이 얼마나 걸리나요?
우리 네트워크에서 example.com 까지 5회 중앙값으로 TCP 연결 13.5 ms 뒤에 TLS 1.3 핸드셰이크가 20.7 ms 더 걸렸습니다. 왕복 한 번 정도입니다. 루프백에서는 요청마다 새 TLS 연결을 연 200건이 합계 356~366 ms, 연결 하나를 재사용하면 77~85 ms 로, 핸드셰이크 한 번에 약 1.4 ms 였습니다.
openssl s_client 가 -servername 없이 실패하는 이유는 무엇인가요?
IP 하나로 여러 도메인을 서비스하는 서버는 ClientHello 의 SNI 를 보고 인증서를 고릅니다. -noservername 으로 SNI 를 빼면 example.com 은 TLS 경고 40(handshake failure), 이 사이트는 경고 80(internal error)을 보냈습니다. 요즘 curl 과 브라우저는 항상 SNI 를 보내지만 오래된 클라이언트나 직접 짠 소켓 코드는 안 보낼 수 있습니다.