QUIC 심화 — 0-RTT, 연결 마이그레이션, 패킷 구조
QUIC 내부 메커니즘을 파고든다. 패킷과 프레임 구조, TLS 1.3 통합 1-RTT/0-RTT 핸드셰이크와 replay 위험, Connection ID 기반 연결 마이그레이션, packet number 단조 증가로 재전송 모호성을 없앤 손실 복구, 그리고 플로우·혼잡 제어까지 정리합니다.
지난 글에서 QUIC이 왜 TCP를 버리고 UDP 위에 신뢰성·순서·암호화를 새로 얹었는지, 그 큰 그림을 그렸다. 이번 글은 그 안으로 들어간다. QUIC이 바이트를 어떻게 패킷과 프레임으로 쪼개는지, 핸드셰이크가 어떻게 1왕복에 끝나고 재개 시엔 0왕복까지 줄어드는지, IP가 바뀌어도 연결이 끊기지 않는 비결은 무엇인지, 그리고 손실 복구와 혼잡 제어가 TCP와 어떻게 달라졌는지를 메커니즘 단위로 뜯어본다. 개요는 가볍게 짚고 넘어가니, 큰 그림이 필요하면 지난 글을 먼저 보고 오는 편이 좋다.
패킷과 프레임 — 두 단위를 분리한 설계
QUIC을 이해하는 첫 열쇠는 패킷(packet)과 프레임(frame)이 서로 다른 단위라는 점이다. TCP는 “세그먼트”라는 하나의 단위에 시퀀스 번호·플래그·데이터를 모두 욱여넣었지만, QUIC은 둘을 떼어 놓았다.
- 패킷은 전송·암호화·확인의 단위다. 패킷마다 고유한 packet number가 붙고, 통째로 암호화된다.
- 프레임은 의미의 단위다. 하나의 패킷 페이로드 안에 여러 종류의 프레임이 섞여 들어간다. STREAM 프레임은 응용 데이터를, ACK 프레임은 수신 확인을, CRYPTO 프레임은 TLS 핸드셰이크 메시지를 나른다.
이 분리가 주는 효과는 크다. 한 패킷에 “스트림 3번 데이터 + 스트림 5번 데이터 + 직전에 받은 패킷들의 ACK”를 한꺼번에 실을 수 있다. 또 데이터와 제어 정보가 같은 프레임 체계로 통일돼, 새로운 기능을 추가할 때 새 프레임 타입만 정의하면 된다.
Long header와 Short header
QUIC 헤더는 두 가지 형태다. Long header는 연결 초기, 즉 버전 협상과 핸드셰이크 단계에서 쓰인다. 버전 번호와 출발지·목적지 Connection ID를 모두 담아야 하므로 길다. 핸드셰이크가 끝나 양쪽이 서로의 Connection ID를 알게 되면, 이후 1-RTT 데이터 패킷은 Short header로 전환된다. Short header는 목적지 Connection ID와 packet number 정도만 남기고 군더더기를 덜어내 오버헤드를 줄인다.
Long header (handshake)
+--------+---------+------------+------------+
| 1|1|.. | Version | DCID | SCID| Packet Num | ...
+--------+---------+------------+------------+
Short header (1-RTT data)
+--------+------------+------------+
| 0|1|.. | DCID | Packet Num | ...
+--------+------------+------------+
주요 프레임 세 가지
STREAM 프레임은 Stream ID, Offset, Length, 그리고 실제 Data를 담는다. offset이 있다는 점이 핵심이다. 스트림은 바이트 스트림이고, 각 STREAM 프레임은 그 스트림의 특정 구간을 실어 나른다. 덕분에 스트림별로 독립적인 재조립이 가능하다.
ACK 프레임은 Largest Acked, ACK Delay, 그리고 ACK Ranges를 담는다. TCP의 단일 누적 ACK와 달리 QUIC은 수신한 packet number의 범위들을 여러 구간으로 알려줄 수 있어, 선택적 확인(SACK과 유사)이 프로토콜에 기본 내장돼 있다.
CRYPTO 프레임은 TLS 핸드셰이크 메시지 조각을 나른다. 여기서 QUIC과 TLS의 통합이 드러난다.
핸드셰이크 — TLS 1.3을 전송에 녹여 넣다
TCP + TLS 조합에서는 TCP 3-way 핸드셰이크(1 RTT)를 끝낸 뒤에야 TLS 핸드셰이크(TLS 1.3 기준 1 RTT)를 시작했다. 연결 수립과 암호화 협상이 직렬로 쌓여 보통 2~3 RTT가 들었다.
QUIC은 전송 핸드셰이크와 TLS 1.3 핸드셰이크를 하나로 합쳤다. ClientHello가 첫 패킷의 CRYPTO 프레임에 실려 나가고, 서버는 ServerHello와 인증서를 돌려보낸다. 이 한 번의 왕복으로 키 합의가 끝난다. 그래서 최초 연결도 1-RTT다.
0-RTT 재개와 replay 위험
한 번 연결했던 서버라면 클라이언트는 이전에 받은 세션 정보(PSK, pre-shared key)를 캐시해 둔다. 재접속할 때 클라이언트는 첫 패킷에 ClientHello와 함께 응용 데이터(early data)를 동봉해 보낸다. 키 합의가 끝나기를 기다리지 않고 첫 왕복부터 요청을 실어 보내는 것 — 이것이 0-RTT다. 지연이 한 왕복만큼 더 줄어든다.
문제는 0-RTT early data가 replay 공격에 취약하다는 점이다. early data는 아직 완전한 핸드셰이크 키로 보호되기 전 단계의 데이터라, 공격자가 패킷을 가로채 그대로 다시 보내면 서버가 같은 요청을 두 번 처리할 수 있다. 그래서 0-RTT에는 한 가지 철칙이 따른다.
- 멱등(idempotent) 요청만 0-RTT로 보낸다. GET처럼 여러 번 실행돼도 결과가 같은 요청만 허용한다.
- 결제·주문처럼 상태를 바꾸는 요청은 0-RTT로 보내지 않고, 핸드셰이크가 끝난 뒤 1-RTT 구간으로 미룬다(강등).
서버 측에서도 anti-replay 윈도우나 single-use 토큰으로 중복 처리를 막지만, 근본적으로 0-RTT는 “빠르지만 한 번 더 실행될 수 있는” 채널이라는 전제를 깔고 써야 한다.
연결 마이그레이션 — 4-튜플이 아닌 Connection ID
TCP 연결은 (출발지 IP, 출발지 포트, 목적지 IP, 목적지 포트) 4-튜플로 식별된다. 그래서 와이파이에서 LTE로 넘어가 IP가 바뀌면 4-튜플이 깨지고, 연결은 그대로 끊긴다. 새로 3-way 핸드셰이크와 TLS를 다시 해야 한다.
QUIC은 연결을 Connection ID로 식별한다. IP나 포트가 아니라 헤더 안에 든 별도의 식별자다. 그래서 클라이언트의 IP·포트가 바뀌어도 Connection ID만 같으면 서버는 같은 연결로 인식하고, 이미 합의된 키와 상태를 그대로 이어 쓴다. 네트워크가 바뀌어도 핸드셰이크 없이 연결이 살아남는다. 모바일에서 영상이 끊기지 않고 이어지는 경험이 여기서 나온다.
이 메커니즘은 NAT rebinding 문제도 자연스럽게 흡수한다. NAT 장비가 매핑을 갱신하면서 클라이언트의 외부 포트가 바뀌어도, TCP라면 미들박스가 연결을 잃지만 QUIC은 Connection ID 덕에 무사하다.
물론 공짜는 아니다. IP가 바뀌면 서버는 경로 검증(path validation)을 수행한다. 새 경로로 PATH_CHALLENGE 프레임을 보내고 PATH_RESPONSE를 받아, 그 주소가 실제로 응답하는지 확인한 뒤에야 본격적인 데이터를 흘려보낸다. 이는 공격자가 위조된 주소로 트래픽을 유도하는 amplification 공격을 막기 위한 안전장치다.
손실 복구 — packet number 단조 증가의 위력
QUIC 손실 복구의 핵심 아이디어는 한 문장으로 요약된다. packet number는 절대 재사용되지 않고 단조 증가한다.
TCP에는 고질적인 재전송 모호성(retransmission ambiguity) 문제가 있었다. 어떤 세그먼트를 재전송했는데 ACK가 돌아오면, 그 ACK가 원본에 대한 것인지 재전송본에 대한 것인지 구분할 수 없다. 둘 다 같은 시퀀스 번호를 쓰기 때문이다. 이 모호성 때문에 RTT 측정이 왜곡되고 RTO 계산이 부정확해진다.
TCP: seq=100 전송 ──┐
seq=100 재전송 ─┤ ACK(100) 도착
└─ 원본? 재전송본? 구분 불가
QUIC: pkt=5 (stream X, off 0) 전송 ──┐
pkt=9 (stream X, off 0) 재전송 ─┤ ACK(9) 도착
└─ 재전송본 확인 (명확)
QUIC에서는 같은 데이터를 재전송하더라도 새 packet number를 부여한다. 그래서 ACK를 보면 어느 패킷이 도착했는지 정확히 안다. RTT 측정이 깨끗해지고, 손실 판단이 빨라진다.
여기서 “데이터를 다시 보내는데 번호가 바뀌면 순서는 어떻게 보장하나?” 하는 의문이 생긴다. 답은 다시 패킷과 프레임의 분리다. 데이터의 순서는 패킷 번호가 아니라 STREAM 프레임의 offset으로 결정된다. packet number는 오직 “이 전송 시도”를 식별할 뿐이고, 응용 데이터의 위치는 stream offset이 책임진다. 그래서 재전송 시 패킷 번호를 바꿔도 데이터 재조립에는 아무 문제가 없다.
손실 판단도 더 공격적이다. 더 높은 번호의 패킷이 ACK됐는데 그보다 낮은 번호가 일정 임계치(packet/time threshold)를 넘도록 확인되지 않으면 손실로 간주한다.
플로우 제어와 혼잡 제어
QUIC의 플로우 제어는 두 단계로 작동한다.
- 스트림 단위 플로우 제어: 각 스트림마다 수신자가 받을 수 있는 최대 데이터 양을 광고한다(
MAX_STREAM_DATA). 한 스트림이 버퍼를 독점하지 못하게 막는다. - 연결 단위 플로우 제어: 연결 전체의 총량도 별도로 제한한다(
MAX_DATA). 스트림 한도의 합과 무관하게 연결 전체 메모리를 통제한다.
이 2단 구조 덕분에 한 스트림이 느린 소비자에게 막혀도 다른 스트림의 흐름을 갉아먹지 않는다.
혼잡 제어는 알고리즘 자체(예: NewReno, CUBIC, BBR)는 TCP의 것을 가져오되, QUIC만의 이점이 더해진다.
- 재전송 모호성이 없으니 RTT 추정이 정확해, 혼잡 제어가 더 정밀하게 동작한다.
- 혼잡 제어 로직이 커널이 아닌 유저 스페이스(애플리케이션 라이브러리)에 있어, 운영체제 업데이트를 기다리지 않고도 새 알고리즘을 배포·실험할 수 있다. TCP에서는 커널에 박혀 있어 갱신이 느렸던 부분이다.
정리
QUIC의 내부를 한 줄로 꿰면 이렇다. 패킷과 프레임을 분리하고, packet number를 단조 증가시키며, 연결을 Connection ID로 추상화한 것 — 이 세 결정이 1-RTT/0-RTT 핸드셰이크, 끊기지 않는 마이그레이션, 모호성 없는 손실 복구, 정밀한 혼잡 제어를 한꺼번에 가능하게 했다. TCP가 30년간 쌓아 온 제약을 전송 계층을 다시 짜서 풀어낸 셈이다. 그 대가로 0-RTT의 replay 위험 같은 새로운 운영 규칙도 따라오니, 빠름과 안전 사이의 선택은 여전히 설계자의 몫으로 남는다.
지난 글: HTTP/3와 QUIC — UDP 위에서 다시 짠 전송
다음 글: HTTP 인증 — Basic과 Bearer
읽어주셔서 감사합니다. 😊