안녕하세요, 자바파커입니다.
"HTTPS는 암호화되는 거잖아요. 그런데 왜 첫 연결만 유독 느리죠?"
주소창의 자물쇠는 그냥 붙지 않습니다. 브라우저와 서버가 여러 번 오간 뒤에야 붙습니다. 그리고 그 왕복 횟수가 그대로 첫 응답 지연이 됩니다.
결론부터 말씀드리면 — HTTPS는 HTTP를 대체한 프로토콜이 아니라, HTTP 밑에 TLS 한 층을 끼운 것입니다. 그래서 TCP 연결을 맺은 다음에 한 번 더 협상을 합니다. 인증서 오류가 나는 지점도, 첫 연결이 느린 이유도 전부 그 층에서 나옵니다.
HTTP는 적은 그대로 보낸다
먼저 왜 필요한지부터 봐야 합니다. HTTP로 로그인 요청을 보내면 이렇게 나갑니다.
POST /login HTTP/1.1
Host: javapark.kr
Content-Type: application/x-www-form-urlencoded
email=javapark@example.com&password=1234이게 그대로 흘러갑니다. 카페 와이파이의 다른 손님, 회사 프록시, 중간 경로의 라우터 — 지나가는 자리에 있는 누구든 읽을 수 있습니다. 암호화가 없으니 "읽지 못하게" 막을 방법이 없습니다.
HTTPS는 같은 요청을 보내되, 중간에서는 의미 없는 바이트로만 보이게 만듭니다. 그 상태를 만들기 위한 사전 협상이 핸드셰이크입니다.
순서 — TCP 다음에 TLS
핸드셰이크를 어렵게 느끼는 이유는 대개 TCP 연결과 TLS 협상을 한 덩어리로 보기 때문입니다. 둘은 별개이고 순서가 있습니다.
| 단계 | 무엇을 하나 | 이 시점의 상태 |
|---|---|---|
| ① TCP 3-way | 연결 자체를 만든다 | 평문 |
| ② ClientHello / ServerHello | 쓸 암호를 정한다 | 평문 |
| ③ 인증서 검증 | 서버가 맞는지 확인한다 | 평문 |
| ④ 키 교환 | 대칭키를 안전하게 넘긴다 | 여기부터 암호화 |
①~③은 아직 암호화되지 않은 구간입니다. 그래서 어떤 도메인에 접속했는지(SNI)는 중간에서 보입니다. 내용이 가려질 뿐, 접속 사실 자체가 숨겨지는 건 아닙니다.
① TCP 3-way 핸드셰이크
브라우저 → SYN → 서버 "연결할까요?"
브라우저 ← SYN-ACK ← 서버 "좋습니다"
브라우저 → ACK → 서버 "시작합니다"여기까지가 왕복 1회입니다. HTTP라면 이 다음에 바로 요청을 보냅니다. HTTPS는 여기서 한 번 더 갑니다.
② ClientHello / ServerHello — 쓸 암호를 맞춘다
브라우저 → ClientHello : 제가 쓸 수 있는 암호 목록입니다 (+ TLS 버전, 랜덤값, SNI)
브라우저 ← ServerHello : 이걸로 합시다 (+ 선택한 암호 스위트, 랜덤값, 인증서)브라우저와 서버가 지원하는 암호가 다를 수 있으니 교집합에서 하나를 고릅니다. 여기서 겹치는 게 하나도 없으면 연결이 실패합니다 — 오래된 서버에서 SSL_ERROR_NO_CYPHER_OVERLAP 같은 오류가 나는 이유입니다.
이때 서버가 인증서를 같이 보냅니다. 인증서는 서버의 신분증입니다.
③ 인증서 검증 — 브라우저가 혼자 한다
여기서 중요한 게 있습니다. 검증은 서버가 아니라 브라우저가 합니다. 서버는 인증서를 내밀 뿐이고, 믿을지 말지는 받는 쪽이 판단합니다.
브라우저가 확인하는 것은 크게 셋입니다.
- 발급기관을 믿을 수 있나 — 인증서에 서명한 CA가 OS·브라우저의 신뢰 목록에 있는지, 중간 인증서까지 체인이 이어지는지
- 도메인이 일치하나 — 접속한 주소가 인증서의 SAN(Subject Alternative Name)에 들어 있는지
- 유효기간이 남았나 —
notBefore~notAfter범위 안인지
셋 중 하나만 어긋나도 그 경고 화면이 뜹니다. 뒤에서 다룰 오류 코드가 정확히 이 셋에 대응합니다.
④ 키 교환 — 비대칭키는 여기까지만
암호화 방식은 두 종류입니다.
| 비대칭키 (공개키/개인키) | 대칭키 | |
|---|---|---|
| 속도 | 느림 | 빠름 |
| 키 전달 | 공개키는 공개해도 됨 | 전달이 문제 |
| 쓰는 곳 | 키를 넘기는 순간에만 | 이후 실제 통신 전부 |
대칭키는 빠르지만 "그 키를 어떻게 상대에게 안전하게 주느냐"가 문제입니다. 그래서 키를 넘기는 그 한 번만 비대칭키를 씁니다. 공개키로 잠근 것은 개인키로만 열리니, 중간에서 가로채도 못 엽니다.
키가 넘어간 다음부터는 전부 빠른 대칭키입니다. 비싼 계산은 첫 연결에만 있습니다.
왕복이 곧 첫 응답 지연이다
핸드셰이크를 "보안 절차"로만 보면 성능과 무관해 보이지만, 실제로는 지연 시간의 문제입니다.
RTT(왕복 시간) 50ms인 환경을 기준으로 하면 이렇게 됩니다.
| 연결에 필요한 왕복 | 첫 요청까지 | |
|---|---|---|
| HTTP | 1회 (TCP만) | 50ms |
| HTTPS · TLS 1.2 | 3회 (TCP 1 + TLS 2) | 150ms |
| HTTPS · TLS 1.3 | 2회 (TCP 1 + TLS 1) | 100ms |
| 재연결 (세션 재개) | 1회 | 50ms |
RTT가 커질수록 차이는 그대로 커집니다. 해외 리전에 붙어 RTT가 200ms라면 TLS 1.2에서는 첫 요청까지 600ms를 그냥 씁니다.
그래서 첫 연결을 줄이는 방법들이 있습니다.
- TLS 1.3 — 협상 왕복을 2회에서 1회로 줄였습니다. 서버 설정에서 가장 먼저 확인할 항목입니다.
- 세션 재개(Session Resumption) — 이전에 맺은 연결의 정보를 재사용해 협상을 건너뜁니다. TLS 1.3의 0-RTT는 첫 요청을 핸드셰이크와 같이 보내기까지 합니다(단, 재전송 공격 위험이 있어 멱등한 요청에만).
- 연결 재사용(Keep-Alive · HTTP/2) — 핸드셰이크는 연결당 한 번입니다. 연결을 계속 쓰면 그 비용은 첫 한 번으로 끝납니다.
- OCSP Stapling — 인증서 폐기 여부를 브라우저가 따로 물어보지 않게, 서버가 미리 받아서 같이 내줍니다.
- HSTS —
http://로 들어온 요청이 리다이렉트로 한 번 더 왕복하는 것을 없앱니다.
인증서 오류 코드별 원인과 해결
실무에서 핸드셰이크와 부딪히는 지점은 대부분 여기입니다. 오류 코드는 검증 3항목 중 어디서 걸렸는지를 그대로 알려줍니다.
| 오류 코드 | 걸린 항목 | 흔한 원인 |
|---|---|---|
ERR_CERT_AUTHORITY_INVALID |
발급기관 | 자체 서명 인증서, 사설 CA, 중간 인증서 누락 |
ERR_CERT_COMMON_NAME_INVALID |
도메인 | SAN에 접속 도메인이 없음 |
ERR_CERT_DATE_INVALID |
유효기간 | 인증서 만료, 서버 시계 오차 |
ERR_CERT_REVOKED |
폐기 여부 | 키 유출 등으로 CA가 폐기 처리 |
ERR_CERT_AUTHORITY_INVALID — 발급기관을 못 믿는다
가장 자주 보는 오류입니다. 원인이 둘로 갈립니다.
자체 서명 인증서를 쓴 경우 — 개발 환경이라면 정상입니다. 로컬 개발용 신뢰 인증서는 mkcert 같은 도구로 만드는 게 편합니다.
중간 인증서를 안 붙인 경우 — 이게 더 골치 아픕니다. 브라우저에서는 잘 되는데 서버 간 호출이나 모바일에서만 실패한다면 대개 이겁니다. 브라우저는 중간 인증서를 캐시해 두고 있어서 넘어가지만, 캐시가 없는 클라이언트는 체인을 잇지 못합니다.
# 체인이 제대로 붙어 있는지 확인
openssl s_client -connect javapark.kr:443 -servername javapark.kr | head -20출력의 Certificate chain에 서버 인증서와 중간 인증서가 둘 다 나와야 합니다. Nginx라면 ssl_certificate에 fullchain.pem을 지정했는지 확인하세요. cert.pem을 지정하면 이 증상이 나옵니다.
ERR_CERT_COMMON_NAME_INVALID — 도메인이 다르다
www.javapark.kr로 발급받고 javapark.kr로 접속하는 경우가 대표적입니다. 요즘 브라우저는 CN(Common Name)을 보지 않고 SAN만 봅니다. CN에만 있고 SAN에 없으면 실패합니다.
# SAN 목록 확인
openssl s_client -connect javapark.kr:443 -servername javapark.kr 2>/dev/null \
| openssl x509 -noout -text | grep -A1 "Subject Alternative Name"와일드카드 인증서(*.javapark.kr)는 한 단계만 커버합니다. a.b.javapark.kr은 포함되지 않습니다.
ERR_CERT_DATE_INVALID — 기간이 지났다
만료가 대부분이지만, 클라이언트 시계가 틀린 경우도 있습니다. 특정 사용자만 겪는다면 이쪽을 의심하세요.
Let's Encrypt는 90일짜리라 갱신 자동화가 필수입니다. 쿠버네티스라면 cert-manager로 자동 발급·갱신을 걸어두는 게 안전하고, Traefik은 ACME 설정만으로 처리됩니다.
만료 날짜는 이렇게 확인합니다.
echo | openssl s_client -connect javapark.kr:443 -servername javapark.kr 2>/dev/null \
| openssl x509 -noout -datesFAQ — 자주 묻는 질문
Q. localhost는 인증서가 없는데 왜 카메라·클립보드 API가 되나요?
브라우저가 localhost를 예외적으로 Secure Context로 취급하기 때문입니다. 네트워크를 타지 않아 도청 위험이 없다고 보는 겁니다. 다만 같은 코드를 사내 IP(http://192.168.0.10)에 올리면 그때부터 막힙니다. 이 주제는 Secure Context 완전 정리에서 따로 다뤘습니다.
Q. HTTPS면 서버까지 완전히 안전한가요?
아니요. 암호화 구간은 브라우저와 TLS를 종료하는 지점까지입니다. 로드밸런서나 API 게이트웨이에서 TLS를 종료하면, 그 뒤 내부망 구간은 설정하지 않는 한 평문입니다. 또한 서버에 도착한 데이터는 당연히 평문이라, 서버가 로그에 비밀번호를 찍으면 HTTPS는 아무 도움이 안 됩니다.
Q. 자체 서명 인증서로 운영해도 되나요?
내부 도구라도 권하지 않습니다. 경고 화면을 계속 넘기게 하면 사용자가 경고를 무시하는 습관이 생기고, 그러면 진짜 중간자 공격이 왔을 때도 그냥 넘깁니다. 공개 도메인이 있으면 Let's Encrypt로 무료 발급이 가능하고, 내부 전용이라면 사설 CA를 만들어 그 루트를 장비에 배포하는 쪽이 맞습니다.
정리
- HTTPS는 HTTP를 대체한 게 아니라 TCP와 HTTP 사이에 TLS를 끼운 것입니다
- 순서는 TCP 연결 → 암호 협상 → 인증서 검증 → 키 교환이고, 검증은 브라우저가 합니다
- 비대칭키는 대칭키를 넘길 때만 씁니다. 이후 통신은 전부 대칭키입니다
- 왕복 횟수가 그대로 첫 응답 지연입니다 — TLS 1.3과 연결 재사용이 가장 큰 레버입니다
- 인증서 오류 코드는 검증 3항목 중 어디서 걸렸는지를 알려줍니다
핸드셰이크를 "보안 기능"이 아니라 순서가 있는 협상으로 보면, 오류 화면이 났을 때 어디를 볼지가 바로 나옵니다.
응답이 돌아온 뒤의 이야기는 HTTP 응답 코드 정리에서, 그 요청이 서버 앞단에서 어떻게 갈라지는지는 API 게이트웨이가 하는 일에서 이어집니다.