TLS 1.3의 기본 핸드셰이크는 1-RTT다. 클라이언트가 ClientHello에 키 공유(key share)를 미리 담아 보내고, 서버가 그 알고리즘을 지원하면 한 번의 왕복으로 끝난다. 문제는 그 추측이 빗나갈 때다. 서버는 HelloRetryRequest(HRR)로 "다른 걸로 다시 보내라"고 요구하고, 왕복이 하나 더 붙는다.
RTT가 10ms인 같은 리전 안에서는 티가 안 난다. 하지만 국제 구간처럼 150ms가 걸리는 경로에서는 연결마다 150ms가 그대로 추가된다. 커넥션 재사용이 잘 안 되는 트래픽 패턴이라면 사용자 체감으로 직결된다.
이 문제를 실제로 겪은 건 해외 결제 게이트웨이를 붙였을 때였다. 응답이 가끔 느리다는 보고가 있었는데, 애플리케이션 로그에는 아무 흔적이 없었다. 패킷을 떠 보니 핸드셰이크에서 시간을 쓰고 있었다. 커넥션 풀이 얇은 시간대에 새 연결이 자주 생겼고, 그때마다 재시도가 발생했다.
HRR은 언제 발생하나
클라이언트는 ClientHello에 "내가 지원하는 그룹 목록"과 "그중 하나(또는 둘)에 대한 실제 키 공유"를 담는다. 목록에는 있지만 키 공유를 안 보낸 그룹을 서버가 고르면, 서버는 그 그룹의 키를 받기 위해 재시도를 요구한다.
# 정상 (1-RTT)
C → S: ClientHello { groups:[X25519MLKEM768, X25519], key_share: X25519MLKEM768 }
S → C: ServerHello { key_share: X25519MLKEM768 } + EncryptedExtensions + Finished
C → S: Finished + Application Data # 왕복 1회
# HRR 발생 (2-RTT)
C → S: ClientHello { groups:[X25519MLKEM768, X25519], key_share: X25519MLKEM768 }
S → C: HelloRetryRequest { selected_group: X25519 } # "그거 말고 이걸로"
C → S: ClientHello { key_share: X25519 }
S → C: ServerHello + ... + Finished
C → S: Finished + Application Data # 왕복 2회
서버 쪽에서 할 일
# nginx 예시
ssl_protocols TLSv1.3 TLSv1.2;
ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1;
ssl_prefer_server_ciphers off; # TLS 1.3에서는 클라이언트 선호 존중
# 세션 재개 (티켓)
ssl_session_tickets on;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m; # 공유 캐시로 워커 간 재사용
# OCSP 스테이플링 — 인증서 검증 왕복을 줄인다
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
세션 재개 — 왕복을 아예 없애는 쪽
첫 연결 이후의 재연결은 세션 재개로 크게 단축된다. TLS 1.3에서는 PSK(사전 공유 키) 기반 재개를 쓰며, 서버가 발급한 세션 티켓을 클라이언트가 보관했다가 제시한다.
| 방식 | 왕복 | 주의점 |
|---|---|---|
| 완전 핸드셰이크 | 1-RTT (HRR 시 2-RTT) | 기본 경로 |
| 세션 재개(PSK) | 1-RTT, 데이터 전송은 더 빠름 | 티켓 키 회전 필요 |
| 0-RTT early data | 0-RTT | 재전송 공격 위험 — 멱등 요청에만 |
ssl_early_data와 Early-Data 헤더 처리로 구분한다.# 0-RTT를 켜되 애플리케이션이 판단하도록
ssl_early_data on;
proxy_set_header Early-Data $ssl_early_data;
# 애플리케이션 측 (예: Express)
# if (req.headers['early-data'] === '1' && req.method !== 'GET') {
# res.status(425).end(); // 425 Too Early — 재전송 요청
# }
클라이언트 쪽 — 커넥션 재사용이 최선
사실 가장 효과적인 최적화는 핸드셰이크를 하지 않는 것이다. HTTP 커넥션 풀을 키우고 keep-alive 시간을 늘리면 핸드셰이크 자체가 줄어든다. 특히 서버 간 통신에서 매 요청마다 새 연결을 맺는 클라이언트 설정은 흔한 실수다.
측정 방법
추측하지 말고 재야 한다. 다행히 커맨드 한 줄로 확인할 수 있다.
# 1) 핸드셰이크에서 어떤 그룹으로 합의됐는지
openssl s_client -connect example.com:443 -tls1_3 </dev/null 2>&1 \
| grep -iE "negotiated|group|protocol"
# 2) HRR 발생 여부 (핸드셰이크 메시지 추적)
openssl s_client -connect example.com:443 -trace </dev/null 2>&1 \
| grep -i "hello_retry"
# 3) 단계별 소요 시간 (curl)
curl -o /dev/null -sS -w \
'dns:%{time_namelookup}s connect:%{time_connect}s tls:%{time_appconnect}s ttfb:%{time_starttransfer}s total:%{time_total}s\n' \
https://example.com/
# tls - connect 가 핸드셰이크 구간이다. 여기가 크면 조사 대상.
프로덕션에서는 p50이 아니라 p90·p99를 봐야 한다. 재시도는 일부 연결에서만 발생하므로 평균에는 잘 드러나지 않고 꼬리 지연에 몰린다.
우선순위 정리
- 커넥션 재사용부터 — 핸드셰이크 횟수 자체를 줄이는 게 가장 크다.
- 세션 재개 설정 확인 — 티켓 캐시가 워커·노드 간에 공유되는지 본다.
- 키 공유 그룹 정렬 — 실제 클라이언트 분포에 맞춘다.
- OCSP 스테이플링 — 인증서 검증 왕복 제거.
- 0-RTT는 마지막에, 그리고 멱등 요청에만.
자주 묻는 질문
HelloRetryRequest는 왜 생기나요?
클라이언트가 ClientHello에 미리 담아 보낸 키 공유 그룹을 서버가 선택하지 않았을 때 발생합니다. 서버가 다른 그룹으로 다시 보내라고 요구하면서 핸드셰이크 왕복이 한 번 더 추가됩니다.
HRR이 발생하는지 어떻게 확인하나요?
openssl s_client에 -trace 옵션을 주고 핸드셰이크 메시지에서 hello_retry가 나타나는지 확인하면 됩니다. 프로덕션 지표로는 핸드셰이크 소요 시간의 p90·p99를 관찰하는 것이 효과적입니다.
어떤 키 공유 그룹을 지원해야 하나요?
최신 클라이언트를 위해 X25519MLKEM768 같은 하이브리드 포스트양자 그룹을 앞에 두고, 호환성을 위해 X25519와 P-256 폴백을 남기는 구성이 일반적입니다. 실제 클라이언트 분포를 관측해 순서를 조정하세요.
0-RTT를 켜도 되나요?
멱등 요청에 한해서만 권장합니다. 0-RTT 데이터는 재전송 공격에 취약해, 결제나 주문처럼 부작용이 있는 요청에 쓰면 중복 처리 위험이 있습니다. 서버에서 Early-Data 표시를 확인해 비멱등 요청은 425로 거부하는 구성이 안전합니다.
핸드셰이크 최적화보다 효과적인 게 있나요?
커넥션 재사용입니다. HTTP 클라이언트의 커넥션 풀과 keep-alive 설정을 점검해 새 연결 자체를 줄이는 것이 가장 큰 효과를 냅니다. 요청마다 클라이언트 인스턴스를 새로 만드는 코드가 있는지 먼저 확인하세요.
하이브리드 키 교환을 켰더니 일부 연결이 실패합니다.
하이브리드 키 공유로 ClientHello가 커지면서 첫 패킷이 조각화되고, 일부 네트워크 장비가 이를 제대로 처리하지 못하는 사례가 보고돼 있습니다. 실제 경로에서 검증하고, 문제가 있으면 고전 곡선 폴백이 정상 동작하는지 확인하세요.

댓글 0