HTTP1.1, HTTP2, QUIC
웹 성능과 프로토콜의 관점에서 알아본다.
웹/네트워크에서 성능 하락의 가장 큰 요인
대역폭(Bandwidth)와 지연시간(latency)에 대하여 2010년에 구글의 Mike Belshe라는 사람이 실험을 하였는데
대역폭의 경우는 1Mbps에서 2Mbps로 갈 때에는 페이지 로드 타임이 절반으로 줄지만, 그 이상에서는 큰 차이가 없고
지연시간은 감소될 때 마다 페이지 로드 타임이 계속해서 감소하는 것을 볼 수 있는 것을 볼 수 있었다.
이를 통해
대역폭보다는 지연시간을 줄이는 것이 성능향상에 중요하다
는 사실을 알 수 있다.
HTTP
OSI7계층에서 HTTP는 어플리케이션 계층에 포함되어 있다.
그리고 이는 TCP위에서 작동을 하게 되는데, http의 표준 명세에서 전송계층의 프로토콜로 TCP만을 명시하지는 않지만 사실상 TCP위에서 동작하는 것이 거의 표준이다.
TCP
Transmission Control Protocol(전송 제어 프로토콜)
데이터 전송에 신뢰성을 가지는 프로토콜인데, 비 신뢰성을 가지는 IP위에서 동작하기 때문에 신뢰성을 확보하기 위해서 여러 장치를 갖고 있다.
- 3-way handshake
3-way handshake
client - server가 서로의 데이터를 주고받을 합의를 하는 과정이다.
그런데 http에서는 한 번의 연결마다 한 번의 통신이 이루어진다.
따라서 여러 개의 리소스를 요청하기 위헤서는 커넥션을 여러 번 만들어야 한다.
즉, 매 번의 연결마다 레이턴시를 만들어 낸다는 것이다.
또한 TCP는 네트워크에서 안전한 데이터 전송을 위해 느린 시작
이라는 것을 수행한다.느린 시작
이란, 처음 연결이 수립된 tcp커넥션이 전송할 수 있는 최대 데이터의 크기 한계를 두고 여러 번 데이터 송수신을 왔다갔다 하면서 자신이 한 번에 보낼 수 있는 데이터 최대크기를 찾아가는 과정이다.
이를 통해 혼잡한 네트워크 환경에서 패킷 손실을 최소화 하면서 안전한 데이터 전송이 가능해졌다.
여기서 알 수 있는 것은 처음 연결이 수립된 TCP에서는 크기에 제한이 있다는 것이다.
이 사진에서 보이듯 데이터 전송을 할 때에 느린 전송으로 보낼 수 있는 크기에 한계가 있다 보니, 데이터 전송에만 3번의 통신이 발생하게 된다.
이렇게 매 데이터 요청마다 통신을 만들어 주는것은 매우 비효율적이고 지연 시간을 증가시키게 된다.
keep alive
위의 매 연결마다 3handshake의 통신을 진행하며 커넥션을 만드는 문제를 해결하기 위해 제안되었다.
HTTP1.0에 있는 헤더이며, TCP커넥션을 서버와 클라이언트가 한번에 끊어버리는 것이 아니라 계속 연결을 하겠다는 의미이다.
이를 통해 TCP 커넥션의 재활용이 가능했다.
다만, keep alive의 경우 HTTP1.0의 표준으로 존재하던 것이 아니기 때문에 프록시 벤더마다 지원을 하기도 하고 안하기도 하는 문제가 발생했다.
여기서 멍청한 프록시
라는 문제가 발생했는데 간단하게 설명하면
멍청한 프록시
- 웹 클라이언트 요청에 Keep Alive Connection 헤더가 있는 경우 클라이언트는 현재 연결중인 TCP커넥션을 계속 유지한다.
- 만약 프록시가 이 Connection헤더에 대해 이해하지 못한다면?
- 프록시가 Connection헤더를 웹 서버에 전송하고 서버는 응답에 다시 Connection을 실어서 보낸다.
- 그리고 프록시가 이를 그대로 클라이언트에 보낸다.
- 서버는 프록시로부터 Connection 요청과 응답을 주고받았기 때문에 TCP를 유지한다.
- 프록시는 서버가 TCP연결을 끊을 때까지 계속 대기한다.
- 이 상황에서 클라이언트가 다시 요청을 보내는 경우 하나의 TCP커넥션에 두개의 요청이 들어와서 이를 무시하게 된다.
- 2번때 요청이 서버로 오지 않아서 클라이언트는 Timeout이 되기 전까지 무응답 처리된다.
이런 것이다. 이 멍청한 프록시를 해결하는 방법이 여러 가지 제안되었지만, 이후 도입된 HTTP1.1에서는 그냥 이를 사용하지 않기로 했다.
HTTP1.1
여기서는 keep alive 대신에 지속 커넥션
을 지원한다.
이 지속 커넥션은 keep alive와 같은 목적을 위해 사용되는데, 이와는 반대로 기본적으로 연결을 유지한 상태에서 연결을 해제하고 싶을 때에 Connection:close를 보낸다.
기본적으로 지속 커넥션을 사용하여 클라이언트에서는 TCP커넥션을 계속해서 재활용할 수 있게 되었다.
그리고 이를 통해 3-way handshake에서 발생했던 비용을 감소 시켰는데 이 외에도 성능적으로 개선시켜야 하는 것들이 보였다.
이는 HTTP의 특성상 요청과 응답의 순서가 같아야 한다는 것
이었는데, 클라이언트가 A->B라고 보내면 이에 응답도 A->B의 순서대로 돌아와야 한다는 것이다.
그런데 이는 HTTP가 처음부터 설계상에서 고려하지 않았던 점이다.
그렇기에 HTTP에서는 지속 커넥션을 제공하고 있지만 데이터를 정기적으로 가져올 수 밖에 없었다.(클라이언트에서 FIFO방식으로 A전송 -> A수신 -> B전송 -> B수신.... 하며 관리하는 방식)
이는 데이터 요청이 레이턴시로 이어지고, 즉 성능의 하락이 발생하게 되었다.
이를 해결하기 위해 제안된 것이 파이프라인
인데,
기존에 클라이언트에서 관리하던 FIFO 큐를 서버에서 관리하게 되며 클라이언트는 아래 그림처럼 Transaction 1~4를 연속으로 전송한다.
서버에서는 이 전송된 Transaction1~4의 순서를 가지고 있고, 관련 작업은 알아서 막 한다.
그리고 다시 그 결과를 클라이언트로 전송할 때 저장된 Transaction순서대로 보낸다.
그런데 이 방식도 여러 문제가 있는데,
- Head Of Line Blocking
- 요청 A를 서버에 전송하고 이어서 요청 B를 전송한다고 가정해 보았을 때 멀티쓰레드 환경에서 동시에 작업을 처리하였을 때 요청 B의 작업이 굉장히 빨리 끝나고 요청 A의 작업은 아주 오래 걸린다고 생각하면 B가 먼저 작업이 끝나도 A가 끝나기 전까지는 답장할 수 없을 것이다.
- 리소스 낭비
- 서버에서 병렬 처리 시 응답을 메모리에 적재하고 있어야 한다.
- 응답 실패 시 클라이언트가 모든 리소스에 대한 요청을 처음부터 다시 보내야 한다.
- 또 중계자가 존재할 때 파이프라인 호환성 문제가 발생한다.
위와 같은 문제 때문에 파이프라인 방식을 실제로 쓰는 경우는 굉장히 적었다.
이를 해결하기 위해 사람들은 또 다른 방식을 제안하였다.
TCP 커넥션을 여러개 생성 (HTTP/2)
애초부터 TCP커넥션을 여러개 생성한다(멀티플렉싱)
일단 멀티플렉싱이 뭐냐면 여러 데이터를 한꺼번에 보내는데 파이프라인과는 다르게 요청과 응답의 순서에 구애받지 않고 병렬적 처리가 가능한 것을 말한다.
-> 사실 여러개의 TCP연결을 만들어내는것이 아니라 단일 연결 내에서 여러 데이터가 섞이지 않게 보내는 기법이다.
이를 통해 아예 TCP커넥션을 병렬적으로 받아올 수 있게 한다.
현재 많은 브라우저에서 사용하고 있고 기본적으로 여섯 개의 커넥션을 생성한다.
이게 병렬적으로 만든다고 꼭 응답을 받아오는 속도가 빠르지는 않다.
근데 이거 왜쓸까?
사용자들이 여러 개의 리소스가 한번에 띄어지는게 더 빠르다고 느껴서라고 한다..
근데 이것도 문제가 있는데, 다수의 커넥션을 사용하면서 클라이언트와 서버 간에 오버헤드가 발생하게 된다.
또한 대역폭 경쟁이 심해지게 된다.
HTTP 2.0 - Stream
전송되는 데이터에 특별한 식별자를 붙여준 방식이다.
기존의 HTTP1.1에서는 멀티플렉싱을 위해 여러개의 TCP커넥션을 만들었다.
이제 2.0에서는 이 Stream을 통해 하나의 TCP커넥션을 통해 여러 데이터의 병렬처리가 가능해졌다.
즉 이때부터 진정한 멀티플렉싱이 가능해졌다!
Head of Line Blocking
TCP프로토콜 자체에 존재하는 문제이다.
TCP는 안정성이 있는 데이터 송수신을 지향하는데,
- 여기서 보면 Receiver가 Sender로부터 3개의 segments를 받아야 하는데
- 어떤 문제가 발생해서 p1을 전송받지 못하게 되면
- Receiver가 Sender에게 다시 보내달라 요청하고
- Sender는 p1을 다시 전송해줌
근데 TCP는 데이터의 순서를 보장해주어야 한다.
그렇기 때문에 앞의 p1데이터가 처리될 때 까지 p2, p3데이터는 전송되지 못하는 병목 현상이 생기는데, 이를 Head of Line Blocking
이라고 한다.
이 문제는 HTTP2.0에서는 스트림으로 분리했지만 TCP에서는 그걸 그냥 byte로 확인하기 때문에 발생하는 문제이다.
QUIC
위에서 발생한 문제들은 TCP를 사용하기 때문이다.
그래서 그냥 UDP를 사용해서 알아서 커스터마이징 하자.
라고 구글 칭구들이 생각했다고 한다.
그래서 UDP기반 신뢰성 있는 QUIC을 제안한다.
그런데 UDP는 당연하게도 신뢰성을 보장하지 않는데, 구글이 그냥 신뢰성 로직을 어플리케이션 계층에 그냥 구현했다.
그러면 이제 여기서 위에서 발생한 Head of Line Blocking 문제를 어떻게 해결했을까??
독립 stream
- HTTP2.0의 스트림
기존 HTTP2.0에서 제안된 스트림에서는 하나의 TCP chain에 데이터들이 엮인 상태에서 전송된다.
- QUIC의 스트림
QUIC에서는 각자의 스트림이 다른 chain을 갖는다.
따라서 하나의 스트림에서 병목 현상이 생기더라도 전체 chain이 아닌 해당 chain만이 멈추게 된다.
이걸 통해 또 위의 레이턴시를 줄여 성능 향상을 이루어 낼 수 있었다.