이 글은 On The Merits of QUIC for HTTP의 번역글입니다. 엉성한 부분이 많고 오역이 있을 수 있으나 차차 수정하도록 하겠습니다.

저는 HTTP/2 (RFC 7540)가 2년도 되지 않았을 때 왜 인터넷 HTTP 커뮤니티가 QUIC 기반의 IETF를 작업중인지에 대한 질문을 자주 받곤 했습니다. 이 질문에 대한 좋은 답이 있습니다. QUIC은 근본적으로 더 빠른 속도, 신뢰도, 보안, 반응성, 아키텍쳐를 위한 혁신적인 계획의 두번째 파트입니다. 이러한 필요는 HTTP/2가 표준으로 자리잡았을 때 인정됐지만 커뮤니티는 큰 변화를 다음 버전으로 미루기로 결정했습니다. 그리고 그 다음 버전은 QUIC으로 다가오고 있습니다.

HTTP/2 개발에 대한 회상

HTTP/2 개발에는 두 가지 제약이 걸려있었습니다. 하나는는 HTTP/1의 형태를 유지하는 것이었고, 또 하나는 기존의 전통적 TLS/TCP 스택 내부에서 개발을 끝내는 것이었습니다.

HTTP/2에서 TLS/TCP를 사용하는 선택은 예측된 수순이 아니었습니다. pre-HTTP/2 설계 과정에서 SCTP/UDP/(D)TLS와 같은 다른 스택을 사용하자는 이야기도 꽤 많이 나왔었습니다.

끝내 사람들은, 기존에 HTTP를 운영했던 경험과 코드들이 다음 HTTP 개정판을 만드는데 가장 중요한 부분이 되었다는 점을 깨달았습니다. 그리고 이는 곧 TCP/TLS 스택을 선택한 이유가 되었습니다. 기존의 스택을 이용하여 가능성을 높이고 배포 시간을 단축시키며 HTTP2가 중점으로 두었던 다중화(multiplexing)와 우선 순위(priority)를 손쉽게 구현할 수 있습니다.

TCP/TLS 1.2로 해결할 수 없는 문제들은 개발 범위의 밖으로 여겨졌습니다. ALPN과 Alternative Services(alt-svc)의 매커니즘은 차후에 나올 프로토콜을 손쉽게 적용하기 위해 만들어졌습니다.

제 생각으로 우리들의 기존 웹 서버 운용 경험은 HTTP/2의 선택이 좋은 결정임을 증명했다고 생각합니다. 이것은 많은 케이스에 성공적으로 적용되었으며, 지금은 가장 많이 사용되는 HTTPS 프로토콜입니다. 그리고 저는 이러한 제약 속에서 이보다 더 좋게는 할 수 없을 것이라고 생각합니다.

HTTP/2를 계승하는 프로토콜은 QUIC으로 밝혀졌습니다. QUIC 선언문이 명시적으로 HTTP/2를 새로운 생태계로 이끌려고 하는 것은 HTTP/2의 활성화를 나타내는 좋은 신호입니다. QUIC은 HTTP/2를 정의할 때 예견했던 것들을 정확하게 계승하고 있습니다.

이 글은 QUIC이 HTTP 생태계에 적용되었을 때의 이점을 설명하는 것을 목표로 합니다. 저는 이미 QUIC 프로토콜 매커니즘을 이해하고 있는 사람들에게도 이 글이 유용했으면 좋겠습니다. 하지만 이 글은 QUIC에 대한 모든것을 설명하려 하지 않습니다. 이를 위해서는 The IETF editor's draftsJana's recent tutorial이 더 좋을 것이라 생각합니다.

TCP In-Order 문제 고치기

TCP In-Order: TCP는 무조건 보낸 순서대로 데이터를 받게 설계되어 있습니다.

HTTP/2의 가장 큰 성능적 불만은 패킷 손실이 평소보다 더 클 때 나타납니다. TCP의 in-order 특성은 다중화(multiplexed)된 HTTP 메시지의 순서를 바로잡습니다. 한 메시지에서 패킷 손실이 발생한다면 이 패킷 손실이 고쳐지기 전까지 다른 메시지 요청은 전달되지 않습니다. 이것은 TCP가 모든 스트림에서 in-order delivery를 보장하기 위해 고의적으로 응답을 지연시키기 때문입니다.

간단히 설명해서 이미지 A와 B가 있다고 상상해봅시다. 이 두 이미지는 A1, A2, B1, B2 순서로 전달됩니다. 만약 A1 한 개가 패킷 손실을 겪는다면 TCP는 A2, B1, B2의 전달을 지연시킬 겁니다. 이미지 A가 패킷 손실로 인해 불가피하게 손상되었다면 이미지 B의 데이터는 온전히 전달되었더라도 이미지 B는 영향을 받습니다.

이 TCP의 특성은 RFC 7540(HTTP/2)의 개발 과정에서 고려되었고 낮은 손실률을 보이는 연결을 선호하는 조건으로 식별했습니다. 커뮤니티는 최근 아카마이(Akamai)와 Fastly로부터 어떻게 이를 처리했는지에 대한 좋은 자료를 확인했습니다. 대부분의 HTTP/2 연결에 있어 이러한 방식(strategy)은 도움이 됐습니다, 하지만 in-order 문제로 높은 패킷 손실을 보이는 환경에서 HTTP/1보다 오히려 낮은 성능을 보이는 경우도 있었습니다.

QUIC은 이러한 문제를 한 연결을 이용해 통신하는 방법으로 해결했으며, 이러한 방식은 RFC 7540과 상당히 유사하나 QUIC은 여기에 몇가지 수정을 더했습니다. QUIC은 또한 TCP sequence number와 유사한 방식의 고유 컨텍스트를 각 스트림에 부여했습니다. 이러한 스트림은 애플리케이션에 독립적으로 전달될 수 있으며, 이는 QUIC에서 모든 커넥션 대신 각 스트림마다 in-order 특성을 적용했기 때문입니다.

저는 이 문제를 해결한 것이 QUIC의 가장 큰 특징이라고 생각합니다.

더 빠르게 시작하기

HTTP/2는 HTTP/1보다 훨씬 빠르게 시작합니다. 이는 HTTP/2가 여러 요청을 첫 RT(round-trip)에 포함시켜 보낼 수 있고, 기존보다 더 우수한 커넥션 관리로 인해 더 적은 연결을 맺기 때문입니다. 하지만 TCP/TLS 스택을 이용하여 새로운 커넥션을 성립하는 것은 HTTP/2 데이터가 전송되기 전에 여전히 2회 또는 3회의 RT를 발생시킵니다.

이 문제를 해결하기 위해 QUIC은 즉시 암호화된 애플리케이션 데이터를 전송할 수 있는 레이어를 선호하게 되어있습니다. QUIC은 여전히 보안 연결을 위해 TLS를 사용하고 있고 연결 컨셉(transport connection concept)을 갖고 있지만, 이러한 것들은 초기화를 위해 고유의 RT를 요구하는 레이어에게는 강요되지 않습니다. 세션 전송 대신, HTTP 요청과 TLS context는 하나로 묶여 세션이 재개될 때(예: 과거에 연결했던 서버에 데이터를 전송할 때) 첫 패킷에 같이 전송됩니다. 이것의 중요한 부분은 TLS1.3 연계와 0-RTT 핸드셰이크 기능입니다.

충분한 시간이 주어진 HTTP/2 세상에서 TLS 1.3과 TCP Fast Open을 사용한다면 동일한 이점을 얻을 수 있습니다. 이러한 동작 중 일부는 OS 설정과 구린 TCP 확장으로부터 가끔 발생하는 간섭에 의해 제대로 작동하지 않을 때도 있습니다.

하지만, TLS 1.3과 TCP Fast Open을 이용한 스택에도 이러한 방식은 QUIC에 비해 성능이 뒤쳐질 수 있습니다, Fast Open이 한 개의 TCP SYN 패킷에 약 1460 바이트만 전송할 수 있도록 제한하는 동안 QUIC은 데이터 전체를 첫 RT에 포함해 보낼 수 있기 때문입니다. 이 패킷은 또한 TLS Client Hello와 HTTP 설정도 포함해야 합니다. 이러한 동작은 한 개 이상의 요청 또는 메시지 본문을 함께 보내야 하는 경우 쉽게 공간 부족을 낳게 됩니다. 이는 결국 넘치는 데이터를 전송하기 위해 한 번의 RT를 기다려야 한다는 것을 의미합니다.

TLS와 어울리기

HTTP1, HTTP2를 사용할 때 TLS는 일반적으로 간단한 파이프로 작동합니다. 암호화를 진행할 때 바이트 내에 있는 평문 스트림은 한 쪽으로 이동하고 암호화된 바이트 스트림은 다른 한 쪽으로 이동하여 TCP로 전달되게 됩니다. 정반대의 과정은 복호화를 진행할 때 발생합니다. 불행하게도 TLS 레이어는 내부적으로 바이트 스트림 대신 멀티 바이트 레코드에서 작동하며 맞지 않는 부분(mismatch)은 매우 큰 성능 문제를 가져옵니다.

레코드는 최대 64KB의 크기를 가지며 실제로는 상황에 따라 매우 다양한 크기를 가집니다. 데이터의 온전함을 보장하기 위해, TLS의 핵심적 보안 특징중 한가지는 모든 레코드는 반드시 디코드되기 전에 수신되어야 한다는 점입니다. 만약 레코드가 여러 패킷을 재조합한다면 "TCP in-order 문제"와 비슷한 문제가 나타나게 됩니다.

레코드 내에 있는 한 패킷이 패킷 손실을 겪는다면 해당 레코드는 디코딩을 지연시키고 해당 손실이 복구되는동안 다른 정상적인 패킷을 전달합니다. 이 경우 손실된 일부 스트림 뿐만 아니라 전체 레코드에도 영향을 끼치기 때문에 문제는 약간 더 악화됩니다. 게다가 레코드의 첫 바이트 전송은 항상 마지막 바이트에 의존하기 때문에 간단한 직렬화(serialization) 딜레이나 일반적인 TCP 혼잡제어의 중단(TCP congestion-control stalls)은 패킷 손실이 전혀 없더라도 데이터 전송을 지연시킵니다.

각 패킷마다 독립적인 레코드를 분산(placing)시키는 이 수정은 TCP보다 QUIC에서 보다 더 잘 작동하는 것으로 드러났습니다. 이것은 TCP의 API가 간단한 바이트 스트림이기 때문입니다. TLS를 포함한 애플리케이션은 어디서 패킷이 시작해서 끝나는지에 대한 정보를 알 수 없었을 뿐더러 이를 안정적으로 제어할 수도 없었습니다. 더욱이, TCP 프록시나 HTTP 프록시조차 일반적으로 바이트 스트림을 그대로 둔 채 TCP 패킷을 다시 배치합니다. (E2E 무결성 보호의 가치 증명)

1바이트 레코드라는 불합리한 해법조차 작동하지 않습니다 왜냐하면 레코드의 오버헤드가 패킷의 경계를 다시 세우는 멀티바이트 시퀀스를 만들어내기 때문입니다. 이런 단순한 해법들은 또다시 오버헤드속으로 빠져들게 할겁니다.

QUIC은 전통적인 레이어 대신 자기 자신만의 컴포넌트 아키텍쳐를 사용하여 이런 부분에서 빛을 발휘합니다. QUIC의 전송계층(transport layer)은 애플리케이션으로부터 전송할 데이터를 받고, 패킷 번호, 최대 전송 단위(MTU), TLS 키 정보 등과 함께 직접 자기 자신만의 전송 방법을 설계합니다. QUIC은 이러한 모든 정보를 UDP 패킷 당 하나의 레코드와 동일한 암호화된 하나의 패킷으로 합칩니다. 같은 과정동안 전송 계층은 QUIC에 의해 무결성이 보장되기 때문에 중간자(intermediaries)들은 이 프레임에 간섭할 수 없습니다. 결국 패킷 손실이 발생해도 이는 손실된 패킷에만 영향을 끼치게 됩니다.

TCP RST(reset)로 인한 데이터 손실 제거

수많은 HTTP 개발자들이 이야기해왔습니다, TCP RST는 이미 존재하던 HTTP 생태계에서 가장 골치아픈 부분 중 하나입니다. 이 골칫거리는 굉장히 다양한 형태로 나타나며 이로 인한 데이터 손실은 정말 최악입니다.

OS는 상황에 따라 RST를 생성하고 어떻게 RST에 대응하는지에 대해서는 구현에 따라 달려있습니다. 한 일반적인 시나리오는 서버가 있는데 HTTP 요청을 아직 읽지도 않았고 왔는지 알지도 못했으면서 또다른 요청을 받는동안 연결을 닫으려는 경우입니다. 이 시나리오는 HTTP/1, HTTP/2 애플리케이션에게는 일반적인 시나리오입니다. 대부분의 커널은 클라이언트에게 RST를 보내 사용되지 않은 데이터를 포함한 소켓을 닫습니다.

이 RST는 수신될 때 망가진 채로 처리됩니다. 실제로 이것이 의미하는 것은 클라이언트가 서버에서 이미 보낸 데이터를 recv()로 받으려 하기 전 서버가 close()를 실행할 때, RST가 이미 도착했고 데이터가 아직 읽히지 않은 상태라면 클라이언트는 복구할 수 없는 오류를 겪게 됩니다. 이것은 커널이 TCP ack를 전송한 경우에도 동일합니다. 대부분의 경우 데이터의 마지막 비트가 전체 레코드를 디코드하기 위해 필요하기 때문에 큰 TLS 레코드 사이즈와 함께 사용한다면 최대 64KB의 데이터 손실을 초래하는 문제가 발생할 수도 있습니다.

QUIC에서의 RST는 애플리케이션 스트림의 순차적 종료를 요구하지 않으며 이미 처리된 데이터는 건드리지 않을 것으로 예상됩니다.

버퍼 관리를 통한 더 나은 반응성

HTTP/2의 주 목적은 한 연결의 다중화였으며 이는 우선 순위 지정이 필연적이었습니다. HTTP/1은 이 문제를 잘 보여줬습니다 - HTTP/1은 우선순위 없는 TCP 병렬화로 다중화를 시도했고 이는 일반적으로 좋지 않은 성능을 보였습니다. 마지막 RFC는 다중화와 우선 순위 매커니즘을 모두 포함한 내용을 지니고 있습니다.

하지만, 성공적인 우선 순위 매커니즘은 바이트 스트림을 TLS와 TCP로 직렬화하기 이전에 버퍼링을 필요로 합니다 왜냐하면 TCP로 전송된 이후로는 우선 순위가 높은 데이터가 생겨도 메시지를 다시 배치할 수 없기 때문입니다. 불행하게도 레이턴시가 높은 TCP는 그나마 빠르게 돌리기 위해서 소켓 계층에서 매우 큰 양의 버퍼링을 요구로 합니다. 이 두 가지 문제는 HTTP/2가 데이터를 전송하기 위해 얼마나 많이 버퍼링을 해야 하는지에 대한 판단을 어렵게 합니다. 이에 대해 몇 가지 힌트를 제공하는 일부 OS가 존재하기는 하지만 TCP 그 자체는 이에 관한 그 어떠한 정보도 제공하지 않습니다.

이 조합은 애플리케이션이 알아서 적정한 수준의 버퍼링을 하게끔 만들었고, 이는 애플리케이션이 회선 속도에 맞게 작동하기 위한 동작으로 때때로 오버버퍼로 이어졌습니다. 그 결과, 이미 버퍼링된 데이터로 인해 우선 순위 스케쥴링에 대한 나쁜 응답성을 보였고 서버는 각각의 스트림이 취소되는 것을 알 수가 없었습니다.

전송 계층과 애플리케이션 컴포넌트를 조합하는 것은 QUIC에게 더 나은 우선 순위 스케쥴링이 가능하도록 해줍니다. 이들은 전송 계층 밖에서 우선 순위에 대한 정보가 포함된 데이터를 버퍼링하여 이를 수행합니다. 결과로 이 방법은 높은 우선 순위를 가진 데이터가 뒤늦게 바인딩되는 것을 가능하게 해줍니다.

이와 관련하여, 데이터의 재전송이 필요할 때마다 QUIC은 TCP가 하는 것처럼 손실된 패킷을 복사해서 다시 전송하는 방법 대신 원래의 데이터를 하나 또는 그 이상의 패킷(새로운 패킷 번호와 함께)으로 보냅니다. 이것은 재전송동안 우선 순위를 다시 지정할 수 있도록 하는 기회를 제공하거나 취소된 스트림을 드랍할 수 있게 해줍니다. 이는 TCP와 비교되는 부분인데, TCP의 경우 그 자체가 가진 시퀀스 번호와 in-order 특성 때문에 가장 오래된 (아마 지금과는 상관없는)데이터부터 차례대로 보내게 되어있습니다.

UDP는 어느곳에나 디플로이(Universal DePloyment)될 수 있다는 것을 의미합니다

QUIC은 본질적으로 유저 공간(user space) 또는 커널 공간(kernel space)에 이미 존재하는 프로토콜이 아닙니다 - QUIC은 어느 곳에서나 사용될 수 있는 가능성을 가집니다. 그러나 UDP 기반의 애플리케이션은 종종 유저 공간에 올라가고 특별한 설정이나 권한을 요구하지 않습니다. QUIC 구현체에 기반한 다양한 유저 공간을 기대하는 것은 지나치지 않습니다.

시간이 문제를 해결해 주겠지만, 저는 openssl과 같이 지속적으로 유지보수가 잘 되는 라이브러리, 웹 서버, 브라우저와 같은 것들처럼 QUIC도 그렇게 되리라 예상합니다.

OS에서 TCP가 항상 해왔던 것들을 분리함으로 소프트웨어를 더 빠르게 만들고, 더 정기적으로 업데이트하며, 작은 루프 안에서 알고리즘을 반복할 수 있도록 할 수 있습니다. 아주 긴 운영체제의 교체 주기와 유지보수 스케쥴은 때로 10년까지도 소요됩니다, 이는 네트워킹에 대한 새로운 시도를 하는 것을 방해합니다.

이 새로운 자유는 QUIC 자체 뿐 아니라 전통적으로 적용하기 어려웠던 TCP 기술에 대응하는 임베디드 기술에도 또한 적용됩니다. 유저 공간의 사용으로 패킷 조율(flow control), 빠른 연결, RACK과 같은 손실 감지와 같은 큰 향상을 얻을 수 있게 됐습니다.

유저 공간은 네트워킹의 더 빠른 발전과 최상의 결과를 보이는 훌륭한 배포를 의미하게될 것입니다.