HTTPS·TLS — 엿보기·변조·사칭을 막다
지금까지 본 HTTP는 평문이다 — 요청과 응답이 케이블을 따라 그대로 흐른다. 중간의 라우터·공용 Wi-Fi·통신사 누구든 내용을 읽고, 몰래 바꾸고, 심지어 다른 서버인 척 가로챌 수 있다. 이걸 막는 게 HTTPS — 곧 HTTP를 TLS라는 보안 계층 위에 얹은 것이다. TLS가 보장하는 건 세 가지다 — 기밀성(엿봐도 못 읽음), 무결성(바뀌면 들킴), 인증(상대가 진짜인지 확인). 이 셋을 어떻게 동시에 이루는지를 본다.
빠른 자물쇠와 느린 자물쇠 — 대칭과 비대칭
암호에는 성격이 다른 두 종류가 있다. 대칭키는 같은 키로 잠그고 푼다 — 빠르지만 그 키를 어떻게 상대에게 안전하게 전달하느냐가 문제다(키를 보내다 도청당하면 끝). 비대칭키는 공개키로 잠그면 개인키로만 풀린다(반대도) — 키 전달 문제가 없지만 수십~수백 배 느리다. TLS는 둘을 영리하게 조합한다. 느린 비대칭으로 대칭키를 안전하게 합의하는 악수(핸드셰이크)만 하고, 그 뒤 실제 데이터는 빠른 대칭키로 암호화한다. (출처: Cloudflare — TLS Handshake.)
CS 연결 — "비대칭은 안전한데 왜 안 그걸로 다 안 하나?"의 답은 비용이다. 비대칭(RSA·ECC)은 큰 수의 모듈러 지수승 같은 무거운 연산이라 CPU를 많이 먹는다. 그래서 키 교환·인증이라는 한 번의 비싼 일에만 쓰고, *대량 데이터는 싼 대칭(AES-GCM·ChaCha20)*에 맡긴다 — 비싼 자원은 꼭 필요한 한 번만이라는, 이 노트 전체에 흐르는 발상이다.
진짜 그 서버가 맞나 — 인증서와 CA
암호화만으론 부족하다 — 공격자와 안전하게 암호화 통신해 봐야 소용없다. 그래서 상대가 진짜인지 확인하는 인증이 필요하다. 서버는 **인증서**를 내미는데, 거기엔 서버의 공개키와 신뢰할 수 있는 기관(CA)의 디지털 서명이 들어 있다.
브라우저는 미리 신뢰하는 루트 CA 목록을 들고 있어, 서버 인증서가 그 루트까지 이어지는 체인인지, 만료되지 않았는지, 호스트명이 맞는지, 폐기되지 않았는지를 검증한다. 결정적으로 — 서버는 핸드셰이크를 자기 개인키로 서명해 보낸다. 브라우저가 인증서의 공개키로 그 서명을 확인하면, "이 서버가 인증서에 적힌 개인키를 진짜 갖고 있다"가 증명된다 — 남의 인증서를 훔쳐 흉내 내는 것을 막는 핵심이다. (출처: Certificate chain validation.)
더 빠르게 — TLS 1.3과 0-RTT
핸드셰이크는 *왕복(RTT)*을 잡아먹어 첫 응답을 늦춘다. TLS 1.3은 이를 1-RTT로 줄였고(클라이언트가 키 교환 파라미터를 첫 메시지에 바로 보내), *순방향 비밀성(forward secrecy)*을 필수로 만들었다 — 나중에 개인키가 털려도 과거 통신은 못 푼다. 더 나아가 재접속에는 **0-RTT**가 있다 — 이전에 합의한 *사전 공유 키(PSK)*로 첫 메시지부터 암호화된 요청을 바로 보낸다(왕복 0번).
실무에서는 — 0-RTT의 그늘과 인증서 운영. 0-RTT는 빠르지만 *순방향 비밀성이 없고, 같은 요청이 *재전송(replay)될 위험이 있다. 그래서 표준은 0-RTT를 멱등 요청(GET)에만 허용하라고 권한다 — 여기서도 멱등성(같은 요청을 두 번 처리해도 안전함)이 안전의 열쇠다(HTTP 편 연장). 그리고 운영에서 가장 흔한 보안 장애가 인증서 만료다 — 어느 날 갑자기 모든 사용자가 "안전하지 않음" 경고를 본다. 그래서 Let's Encrypt 같은 자동 갱신과 만료 모니터링이 필수다. HSTS로 브라우저가 항상 HTTPS만 쓰게 강제하고, 서비스 간 통신엔 양쪽이 서로 인증서를 검증하는 mTLS(서버만이 아니라 클라이언트도 인증)를 쓰기도 한다.
실제로 눈으로 보기
핸드셰이크와 인증서 사슬
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | head -20
# CONNECTED(00000003)
# ---
# Certificate chain
# 0 s:CN = example.com ← 서버 인증서(리프)
# i:C = US, O = DigiCert Inc, CN = DigiCert TLS RSA SHA256 2020 CA1
# 1 s:C = US, O = DigiCert Inc, CN = DigiCert TLS RSA SHA256 2020 CA1
# i:C = US, O = DigiCert Inc, CN = DigiCert Global Root CA ← 루트로 이어진다
# ---
# New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
s:(subject)와 i:(issuer)가 사슬로 이어지는 것이 요점이다. 0번의 발급자가 1번의 주체이고, 1번의 발급자가 루트다. 브라우저는 이 사슬을 따라 올라가 자기가 이미 신뢰하는 루트에 닿는지 확인한다.
가장 흔한 실패는 사슬이 끊긴 것이다
openssl s_client -connect broken.example.com:443 </dev/null 2>&1 | grep -E 'Verify|verify error'
# verify error:num=20:unable to get local issuer certificate
# Verify return code: 21 (unable to verify the first certificate)
이 오류는 대개 서버가 중간 인증서를 안 보낸 것이다. 서버는 리프와 중간을 함께 보내야 하는데 리프만 보내면 사슬이 끊긴다.
고약한 점은 브라우저에서는 되는데 서버끼리는 안 되는 경우가 생긴다는 것이다. 브라우저는 중간 인증서를 캐시하거나 알아서 받아 오지만, 서버 측 클라이언트는 그러지 않는다. "웹에서는 잘 되는데 API 호출만 실패한다" 가 이 모양이다.
만료와 이름 불일치
echo | openssl s_client -connect example.com:443 2>/dev/null \
| openssl x509 -noout -dates -subject -ext subjectAltName
# notBefore=Jan 15 00:00:00 2026 GMT
# notAfter=Apr 15 23:59:59 2026 GMT
# subject=CN = example.com
# X509v3 Subject Alternative Name:
# DNS:example.com, DNS:www.example.com
요즘 브라우저는 CN 을 안 본다. subjectAltName에 그 이름이 있어야 통과한다. api.example.com으로 접속하는데 SAN 에 없으면 실패한다 — 인증서를 발급할 때 서브도메인을 빠뜨리면 여기서 걸린다.
어느 이름으로 물어보느냐가 응답을 바꾼다
# SNI 를 주면 그 사이트의 인증서가 온다
openssl s_client -connect 93.184.216.34:443 -servername example.com </dev/null 2>/dev/null | grep 'subject='
# SNI 없이 IP 로만 붙으면 기본 인증서가 온다
openssl s_client -connect 93.184.216.34:443 </dev/null 2>/dev/null | grep 'subject='
한 IP 에 사이트가 여러 개 있을 수 있으므로, 클라이언트가 "나는 이 이름으로 왔다" 고 먼저 알려 줘야(SNI) 서버가 맞는 인증서를 고른다. SNI 는 암호화 전에 평문으로 나가므로, 어느 사이트에 접속하는지는 중간에서 보인다.
1.3 이 무엇을 줄였나
openssl s_client -connect example.com:443 -tls1_2 </dev/null 2>/dev/null | grep -E 'Protocol|Cipher'
openssl s_client -connect example.com:443 -tls1_3 </dev/null 2>/dev/null | grep -E 'Protocol|Cipher'
TLS 1.2 ClientHello → ServerHello → 키교환 → Finished = 왕복 2회
TLS 1.3 ClientHello(키 추측 포함) → ServerHello+Finished = 왕복 1회
1.3 은 클라이언트가 키 공유를 미리 얹어 보내 왕복을 하나 줄였다. RTT 100ms 면 100ms 를 그냥 번다. 그리고 안전하지 않은 알고리즘을 협상 목록에서 아예 없애 잘못 고를 여지를 제거했다 — 선택지를 줄이는 것이 보안이라는 접근이다.
정리하면, HTTPS는 평문 HTTP를 TLS 위에 얹어 — 기밀성·무결성·인증을 더한다. 느린 비대칭으로 대칭키를 안전하게 합의하고 빠른 대칭으로 데이터를 암호화하며, CA가 서명한 인증서와 개인키 서명으로 진짜 서버임을 증명한다. TLS 1.3은 핸드셰이크를 1-RTT로 줄이고 순방향 비밀성을 보장하며, 0-RTT는 빠르지만 멱등 요청에만 안전하다 — 실무에선 인증서 자동 갱신·HSTS·mTLS가 그 위에서 보안을 떠받친다. 이제 이 모든 프로토콜을 코드에서 실제로 다루는 창구 — 소켓을 볼 차례다.