HTTP/3는 왜 TCP를 버리고 UDP를 선택했을까

HTTP 프로토콜 발전의 역사와 QUIC의 등장

HTTP/3는 왜 TCP를 버리고 UDP를 선택했을까
Photo by Markus Spiske / Unsplash

HTTP/3가 나왔다는 소식을 처음 듣고 알아보았을때, 지금까지 잘 써오던 TCP 대신 UDP로 넘어간다는 사실에 잠깐 멈칫한 기억이 있다. HTTP/1.1도 TCP 위에서 동작했고 HTTP/2도 마찬가지였는데, 왜 갑자기 HTTP/3는 UDP를 쓰는가 하는 의문이다. TCP가 지난 수십 년간 보장해온 순서 보장과 재전송 같은 신뢰성을 통째로 포기한 것처럼 보이기 때문이다. 하지만 실제로 일어난 일은 그 신뢰성을 처리하는 계층이 달라진 것에 가깝다. 즉, 커널이 담당하던 일을 애플리케이션 계층으로 끌어올린 결과인 QUIC 위에 HTTP 시맨틱을 얹은 것을 HTTP/3라고 볼 수 있다.

HTTP/1.1

초기의 HTTP/1.1은 하나의 TCP 커넥션 위에서 요청과 응답을 순서대로 주고받는 구조였다. Keep-Alive 덕분에 매 요청마다 새 커넥션을 맺을 필요는 없어졌지만, 근본적인 제약은 남아 있었다. 하나의 커넥션에서는 한 번에 하나의 요청만 처리할 수 있었다.

GET /style.css HTTP/1.1
Host: example.com

스펙상으로는 Pipelining이라는 기능을 통해 응답을 기다리지 않고 여러 요청을 연달아 보낼 수 있었다. 하지만 서버는 요청이 들어온 순서대로 응답을 돌려줘야 했기 때문에, 앞선 요청 하나가 느리면 뒤에 있는 요청들은 이미 처리가 끝났어도 순서를 기다리며 대기해야 했다. 이 문제 때문에 대부분의 브라우저는 Pipelining을 사실상 사용하지 않았다.

대신 브라우저들이 택한 우회로는 하나의 도메인에 여러 개의 TCP 커넥션을 동시에 열어두는 것이었다. 보통 도메인당 6개 정도의 커넥션을 병렬로 유지하며 요청을 분산시켰다. 이 방식은 어느 정도 효과가 있었지만 대가도 컸다. 커넥션마다 TCP Handshake와 TLS Handshake를 별도로 거쳐야 했고, 각 커넥션은 자신만의 혼잡 제어 상태를 처음부터(Slow Start) 다시 쌓아야 했다.

HTTP/2

HTTP/2는 이 문제를 커넥션을 늘리는 대신 프로토콜 구조 자체를 바꿔서 해결했다. 요청과 응답을 작은 단위의 Frame으로 쪼개고, 각 Frame에 Stream ID를 붙여 하나의 TCP 커넥션 위에서 여러 Stream을 동시에 주고받을 수 있게 만들었다. 서버 입장에서는 요청 A의 응답이 아직 준비되지 않았어도 요청 B의 응답을 먼저 흘려보낼 수 있었고, 클라이언트는 도착하는 대로 Stream ID를 보고 각 응답을 재조립했다. 이를 Multiplexing이라고 부른다.

이 변화로 HTTP/1.1의 고질적인 문제였던 애플리케이션 레벨의 Head-of-Line Blocking이 사라졌다. 더 이상 도메인 샤딩이나 다중 커넥션 같은 우회가 필요 없어졌고, 하나의 커넥션만 잘 관리하면 되니 혼잡 제어 효율도 개선되었다. HPACK이라는 헤더 압축 방식까지 더해지면서, HTTP/2는 등장 당시 HTTP/1.1 대비 명백한 성능 개선으로 받아들여졌다.

TCP의 본질적 한계

문제는 이 Multiplexing이 어디까지나 애플리케이션 레벨의 개념이라는 데 있었다. TCP는 Stream이라는 개념을 전혀 모른다. TCP에게 커넥션이란 그저 순서가 보장된 하나의 Byte Stream일 뿐이다.

이게 왜 문제가 되는지는 패킷 손실이 일어나는 순간 드러난다. HTTP/2 Stream 3에 해당하는 데이터를 싣고 있던 TCP Segment 하나가 유실됐다고 하자. 이 Segment가 재전송되어 도착하기 전까지, TCP는 그 뒤에 이미 도착해 있는 Segment들을 애플리케이션에 전달하지 않는다. Stream 1과 Stream 2의 데이터가 멀쩡히 도착해 있어도, TCP 입장에서는 전체 Byte Stream의 순서를 지켜야 하므로 그 데이터를 커널 버퍼에 붙잡아 둔다. 결국 논리적으로는 서로 무관한 Stream들이었는데도, 패킷 손실 하나 때문에 전부 함께 멈춰버린다.

이것이 TCP 레벨의 Head-of-Line Blocking이다. HTTP/1.1의 HOL Blocking을 애플리케이션 계층에서 해결했더니, 같은 문제가 한 층 아래인 전송 계층에서 다시 나타난 셈이다. 유선 환경처럼 손실률이 낮은 네트워크에서는 크게 체감되지 않았지만, 손실이 잦은 Wi-Fi나 이동통신망에서는 HTTP/2의 Multiplexing이 주는 이득을 상당 부분 깎아먹는 요인이 되었다.

UDP

그렇다면 TCP 자체를 고치면 되지 않냐는 질문이 자연스럽게 따라온다. 실제로 그런 시도는 여러 번 있었다. Multipath TCP나 TCP Fast Open처럼 TCP Header에 새로운 옵션을 추가하는 방식의 제안들이 표준화까지 갔지만, 실제 인터넷에서의 채택은 더뎠다.

이유는 TCP가 단순히 각 OS 커널에만 구현되어 있는 게 아니기 때문이다. NAT 장비, 방화벽, 기업용 프록시 같은 수많은 미들박스들이 TCP Header의 필드를 직접 들여다보고, 심지어 일부는 옵션을 임의로 제거하거나 재작성한다. 이 미들박스들은 지금의 TCP 동작 방식을 전제로 만들어졌기 때문에, TCP의 핵심 동작을 바꾸는 변경 사항은 전 세계에 흩어진 이 장비들이 함께 업데이트되어야 온전히 동작한다. 프로토콜이 화석화되었기에 이런 규모의 변화가 합리적인 시간 안에 이루어지기는 사실상 불가능하다.

UDP(User Datagram Protocol)는 TCP와 같은 전송 계층 프로토콜이지만, TCP가 제공하는 기능 대부분을 의도적으로 덜어낸 최소한의 프로토콜이다. Handshake 없이 곧바로 Packet을 전송하고, 도착 순서를 보장하지 않으며, Packet이 유실되어도 재전송하지 않는다. 혼잡 제어도 없어서 네트워크 상황과 무관하게 보내고 싶은 만큼 그대로 내보낸다. 헤더 크기도 8Byte에 불과해 TCP의 20Byte보다 훨씬 가볍다. 한마디로 "최대한 노력해서 보내긴 하지만, 그 이상은 책임지지 않는다"는 Best Effort 철학의 프로토콜이다.

UDP는 화석화 문제에서 상대적으로 자유롭다. 순서 보장도, 재전송도, 혼잡 제어도 없는 최소한의 프로토콜이라 미들박스들이 특별히 간섭할 만한 여지도 적다. 대부분의 미들박스는 UDP Packet을 그냥 통과시킨다. 이 특성 덕분에 UDP는 완전히 새로운 전송 프로토콜을 설계하기 위한 빈 도화지가 될 수 있었다. Google이 QUIC(Quick UDP Internet Connections)을 설계하며 택한 접근이 바로 이것이다. 신뢰성, 순서 보장, 혼잡 제어 같은 TCP의 역할을 커널이 아니라 브라우저와 서버에 포함되는 라이브러리, 즉 User Space 코드로 직접 구현한 것이다. 그 결과 QUIC은 OS 커널 업데이트나 미들박스의 협조를 기다릴 필요 없이, 브라우저 업데이트 주기만으로 개선하고 배포할 수 있게 되었다.

QUIC란

QUIC은 UDP 위에서 동작하는 전송 계층 프로토콜이다. TCP가 IP 위에서 신뢰성, 순서 보장, 혼잡 제어를 직접 구현하듯, QUIC은 UDP라는 얇은 배달 계층 위에 그 기능들을 처음부터 다시 구현한 별도의 프로토콜이다. 계층으로 보면 QUIC은 UDP와 HTTP/3 사이에 위치하며, HTTP/3뿐 아니라 다른 애플리케이션 프로토콜도 그 위에 얹을 수 있도록 설계되었다.

QUIC은 원래 Google이 2012년 무렵부터 사내에서 개발해 자사 서비스와 Chrome 브라우저 사이에 실험적으로 적용하던 프로토콜이다. 초기에는 Quick UDP Internet Connections의 약자로 소개되었지만, 이후 IETF로 표준화 작업이 넘어가면서 QUIC은 약자가 아니라 그 자체로 고유한 프로토콜 이름이 되었다. IETF 표준화를 거쳐 2021년 RFC 9000으로 공식 발표되었고, 이 과정에서 Google의 초기 설계 중 상당 부분이 다시 다듬어졌다.

독립적인 Stream Multiplexing

QUIC은 Multiplexing을 애플리케이션이 아니라 전송 계층 자체에서 구현한다. 하나의 QUIC 커넥션 안에 여러 Stream이 존재하고, 각 Stream은 자신만의 순서 보장과 흐름 제어를 갖는다. 어떤 Stream의 Packet이 유실되어도, 재전송을 기다리는 동안 다른 Stream의 데이터는 도착하는 대로 애플리케이션에 곧바로 전달된다. HTTP/2가 TCP 위에서 되살려버린 전송 계층의 HOL Blocking이, QUIC에서는 애초에 전송 계층이 Stream을 인지하고 있기 때문에 발생하지 않는다.

통합된 핸드셰이크와 RTT 절감

TCP와 TLS를 함께 쓰는 기존 방식은 커넥션 하나를 맺기 위해 TCP Handshake와 TLS Handshake를 순차적으로 거쳐야 했다. TLS 1.3으로 넘어오면서 TLS Handshake 자체는 1-RTT로 줄었지만, 그 앞에 TCP Handshake가 별도로 붙어 있어 최소 2-RTT가 필요했다. QUIC은 전송 계층과 암호화 핸드셰이크를 하나로 통합해, 처음 맺는 커넥션도 1-RTT 만에 데이터를 주고받을 수 있다. 이전에 연결했던 서버와 다시 연결하는 경우에는 캐시된 파라미터를 이용해 0-RTT로 첫 요청을 곧바로 보낼 수도 있다. 다만 0-RTT 데이터는 재전송 공격에 취약할 수 있어, 상태를 변경하지 않는 요청 위주로 사용이 권장된다.

Connection ID와 커넥션 마이그레이션

TCP 커넥션은 출발지 IP, 출발지 Port, 목적지 IP, 목적지 Port로 이루어진 4-Tuple로 식별된다. 이동통신망에서 Wi-Fi로, 혹은 그 반대로 네트워크가 전환되면 클라이언트의 IP가 바뀌면서 이 4-Tuple도 달라지고, 기존 TCP 커넥션은 끊어진다. 이후 애플리케이션은 처음부터 새로 Handshake를 맺어야 한다. QUIC은 커넥션을 IP나 Port가 아니라 별도로 부여한 Connection ID로 식별한다. 그 덕분에 클라이언트의 네트워크가 바뀌어 Packet이 다른 IP에서 도착하더라도, 서버는 Connection ID를 보고 같은 커넥션임을 인식하고 통신을 이어갈 수 있다. 이동 중에도 끊김 없는 연결이 필요한 모바일 환경이 흔해진 지금은 QUIC가 TCP보다 훨씬 매력적으로 보인다.

User-space 혼잡 제어

혼잡 제어 로직이 OS 커널이 아니라 QUIC 라이브러리, 즉 User Space에 있다는 점도 중요하다. BBR 같은 새로운 혼잡 제어 알고리즘을 적용하고 싶을 때, TCP였다면 커널 패치와 배포를 기다려야 했다. QUIC에서는 라이브러리 업데이트만으로 곧바로 적용하고 실험할 수 있다.

기본값이 된 암호화

TCP 위에 TLS를 얹을지는 선택 사항이었고, 지금도 암호화되지 않은 TCP 트래픽은 흔하다. QUIC은 처음부터 암호화를 전제로 설계되었다. 라우팅에 필요한 최소한의 필드를 제외한 거의 모든 QUIC Packet은 암호화된 상태로 전송되며, 평문 QUIC이라는 선택지 자체가 없다. 이 설계는 미들박스가 QUIC의 내부 동작에 간섭할 여지를 원천적으로 줄이는 효과도 함께 가져온다.

QUIC vs HTTP/3

QUIC은 TCP에 대응하는 범용 전송 프로토콜이고, HTTP/3는 그 QUIC 위에 HTTP의 시맨틱을 얹은 애플리케이션 계층 프로토콜이다. QUIC 자체는 HTTP Method나 Header 같은 개념을 전혀 모르고 Stream 단위로 신뢰성 있고 암호화된 바이트 전달을 제공할 뿐이다. 여기에서 우리가 실제로 웹에서 쓰는 HTTP를 구현한 것이 HTTP/3이다.

이 구조 때문에 HTTP/3의 세부 구현에도 기존에 비해 변화가 필요했다. HTTP/2의 HPACK은 송수신 양쪽이 공유하는 압축 테이블을 순서대로 갱신한다는 전제로 설계되었는데, QUIC의 Stream들은 서로 다른 순서로 도착할 수 있어 이 전제가 깨진다. 그래서 HTTP/3는 Stream 간 순서가 뒤섞여도 압축 테이블 동기화가 깨지지 않도록 설계된 QPACK이라는 새로운 헤더 압축 방식을 사용한다.

TCP가 HTTP뿐 아니라 SMTP, FTP 같은 다양한 애플리케이션 프로토콜을 실어 나르듯, QUIC도 HTTP/3 외의 다른 프로토콜을 얹을 수 있는 범용 전송 계층으로 설계되었다. 즉, SMTP, FTP 등을 QUIC 기반으로 구현할 수도 있다는 것이다.

마무리하며

HTTP/3를 처음 접하면 UDP를 쓴다는 사실 때문에 신뢰성을 포기한 프로토콜처럼 보인다. 하지만 조금만 더 들여다보면 TCP와 TLS, 그리고 HTTP/2가 커널과 애플리케이션 계층에 나누어 맡고 있던 책임들을 QUIC이라는 하나의 User Space 프로토콜로 재배치한 것이라는 것을 알 수 있다. 신뢰성 있는 전달, Stream 단위의 독립적인 순서 보장, 암호화, 혼잡 제어까지 모두 QUIC 안에 들어오면서, 커널이나 미들박스의 업데이트를 기다리지 않고도 프로토콜을 빠르게 개선할 수 있는 구조가 만들어졌다.

결국 QUIC은 TCP를 대체하기 위해 등장한 프로토콜이라기보다, TCP가 화석화되어 더 이상 빠르게 진화할 수 없게 된 지점에서 UDP라는 빈 도화지 위에 다시 그려낸 전송 계층이라고 보는 편이 더 정확하다고 할 수 있다.