HTTP/1.1에서 여러 API와 정적 리소스를 동시에 받아야 하는 화면을 보면, 브라우저가 같은 origin에 여러 TCP 연결을 열어 병렬성을 확보하는 모습을 자주 보게 된다. 연결 하나에서 요청과 응답을 순서대로 기다리면 앞 요청이 늦어질 때 뒤 요청도 같이 밀리기 때문이다. 이 현상을 보통 Head-of-Line blocking, 줄여서 HOL blocking이라고 부른다.
HTTP/2를 공부할 때 가장 먼저 나오는 키워드는 multiplexing이다. "하나의 연결에서 여러 요청과 응답을 동시에 보낼 수 있다"는 설명은 익숙하지만, 여기서 헷갈리는 지점이 있다. HTTP/2가 HOL blocking을 없앤다고 말할 때, 정확히 어느 계층의 blocking을 말하는가?
RFC 9113은 HTTP/2가 HTTP semantics를 유지하면서 메시지를 frame과 stream 단위로 표현한다고 설명한다. 이 글에서는 그 구조를 따라가며 HTTP/2 multiplexing이 HTTP/1.1의 어떤 병목을 줄였고, 왜 TCP 위에서는 여전히 다른 형태의 HOL blocking이 남는지 정리했다.
HTTP/2는 HTTP 메시지 순서 때문에 생기는 대기 시간을 줄이지만, TCP 바이트 스트림의 순서 보장까지 바꾸지는 않는다.
HTTP/2에서 하나의 TCP connection 위에는 여러 stream이 올라간다. stream은 하나의 요청-응답 교환을 식별하는 논리적 통로로 이해하면 된다. 각 stream은 고유한 stream identifier를 가지고, 실제 데이터는 더 작은 frame으로 쪼개져 connection 위를 지나간다.
HTTP/1.1에서 메시지는 대체로 연결 위에 순서대로 흐르는 텍스트 기반 메시지였다. pipelining을 쓰면 요청을 연속으로 보낼 수는 있었지만, 응답은 요청 순서대로 와야 했다. 첫 번째 응답 생성이 오래 걸리면 두 번째 응답이 이미 준비됐더라도 앞 응답 뒤에 줄을 서야 하는 구조였다.
HTTP/2는 이 문제를 frame 계층에서 다르게 푼다. 서로 다른 stream의 frame들이 같은 connection 위에서 interleaving될 수 있다. 예를 들어 HTML, CSS, JavaScript, 이미지 요청이 각각 다른 stream이라면, 서버는 한 stream의 응답을 끝까지 보낸 뒤 다음 stream으로 넘어가지 않아도 된다.
간단히 그리면 다음과 같다.
HTTP/1.1 connection
REQ A -> REQ B -> REQ C
RES A --------> RES B -> RES C
B와 C는 A 뒤에서 대기
HTTP/2 connection
stream 1: A1 ---- A2 ---- A3
stream 3: B1 ---- B2
stream 5: C1 ---- C2
같은 TCP 연결 위에서 frame이 섞여 흐름
RFC 9113의 표현을 빌리면, HTTP/2 endpoint는 stream 생성, 요청과 응답 전송, flow control, 우선순위 등을 관리해야 한다. 즉 multiplexing은 단순히 "동시에 많이 보낸다"가 아니라, 여러 stream의 frame을 하나의 connection 위에 어떻게 배치할지 결정하는 전송 모델에 가깝다.
HOL blocking은 "앞에 선 작업이 늦어서 뒤 작업이 진행되지 못하는 상태"다. HTTP/1.1에서 이 문제는 두 군데에서 체감된다.
첫째, connection 하나에서 응답 순서가 묶일 때다. HTTP/1.1 pipelining은 요청을 여러 개 밀어 넣을 수 있지만, 응답은 같은 순서로 돌아와야 한다. 서버 입장에서 두 번째 요청 처리가 먼저 끝나도 첫 번째 응답이 아직 준비되지 않았다면 두 번째 응답을 먼저 보낼 수 없다. 그래서 실무에서는 pipelining이 널리 쓰이기보다 여러 connection을 여는 방식으로 병렬성을 확보하는 경우가 많았다.
둘째, connection 수 제한 때문에 queue가 생길 때다. 브라우저나 클라이언트는 무한정 connection을 열 수 없으므로, 이미 열린 connection들이 바쁜 상태라면 다음 요청은 빈 connection을 기다린다. 이 경우 HOL blocking은 프로토콜 메시지 순서뿐 아니라 클라이언트의 connection pool 운영에서도 나타난다.
HTTP/2 multiplexing은 이 두 문제를 상당히 줄인다. 여러 요청을 같은 connection 위의 별도 stream으로 만들 수 있고, 응답 frame을 stream별로 섞어 보낼 수 있기 때문이다. 특히 많은 작은 리소스를 가져오는 웹 페이지에서는 TCP/TLS handshake를 여러 번 반복하지 않아도 되고, connection마다 congestion window가 따로 놀지 않는다는 장점도 있다.
하지만 이 장점이 항상 "HTTP/2가 무조건 빠르다"는 뜻은 아니다. RFC 9113도 구현이 flow control, 우선순위, stream 생성 정책을 어떻게 선택하느냐에 따라 성능 특성이 달라질 수 있음을 전제한다. multiplexing은 병렬성의 기반을 제공하지만, 실제 지연 시간은 네트워크 상태와 구현 정책을 같이 봐야 한다.
HTTP/2가 주로 줄이는 것은 HTTP transaction 수준의 HOL blocking이다. 요청 A의 응답이 늦다고 해서 요청 B의 응답 frame을 반드시 기다리게 만들지는 않는다. 그런데 HTTP/2는 기본적으로 TCP 위에서 동작한다. TCP는 신뢰성 있는 순서 보장 바이트 스트림이다. 이 성질 때문에 다른 종류의 blocking이 남는다.
예를 들어 하나의 TCP connection 위에서 stream 1, 3, 5의 frame이 섞여 전송되고 있다고 하자. 중간에 특정 TCP segment가 유실되면, 수신 측 TCP는 그 뒤의 byte를 이미 받았더라도 애플리케이션에 순서대로 전달할 수 없다. 빠진 byte가 재전송되어 도착할 때까지 대기해야 한다.
문제는 HTTP/2 입장에서는 그 유실된 byte가 어떤 stream의 일부였든, 같은 TCP connection 위의 뒤따르는 모든 stream 전달이 같이 멈춘다는 점이다.
TCP byte stream
[S1-F1][S3-F1][S5-F1][S1-F2][S3-F2]
^ 이 구간이 유실됨
수신 측 애플리케이션 전달
[S1-F1][S3-F1] ... 대기
S1-F2나 S3-F2의 일부를 먼저 받았더라도 TCP 순서 보장 때문에 전달 불가
이 차이를 구분하지 않으면 "HTTP/2는 HOL blocking을 해결했다"와 "HTTP/2에도 HOL blocking이 있다"가 서로 모순처럼 보인다. 사실 둘 다 맞는 말에 가깝다. 다만 계층이 다르다.
아래 표처럼 나누면 덜 헷갈린다.
| 구분 | HTTP/1.1 | HTTP/2 |
|---|---|---|
| 요청/응답 순서 대기 | connection 하나에서 크게 발생 | stream multiplexing으로 완화 |
| connection 수 압박 | 여러 connection으로 병렬성 확보 | 보통 더 적은 connection으로 처리 |
| TCP packet loss 영향 | 해당 TCP connection에 영향 | 같은 HTTP/2 connection의 모든 stream에 영향 가능 |
| 해결 방향 | connection 병렬화 | HTTP/3/QUIC은 transport stream 단위로 개선 |
HTTP/3가 QUIC 위에서 다시 stream multiplexing을 설계한 이유도 여기와 맞닿아 있다. QUIC은 UDP 위에서 자체적으로 신뢰성과 stream을 제공하므로, 한 stream의 유실이 다른 stream의 전달을 같은 방식으로 막지 않도록 설계할 수 있다. 다만 이 글의 범위는 HTTP/2이므로, 여기서는 "HTTP/2의 multiplexing은 TCP의 순서 보장 모델을 넘어서지는 못한다" 정도로 정리해도 충분하다.
아래는 실제 HTTP/2 구현 코드는 아니고, 요청 스케줄링 감각을 보기 위한 의사 코드다. 핵심은 HTTP/1.1처럼 응답 전체를 순서대로 쓰는 모델과, HTTP/2처럼 stream별 frame을 번갈아 쓰는 모델의 차이다.
responses = {
"html": ["html-1", "html-2", "html-3"],
"css": ["css-1"],
"js": ["js-1", "js-2"],
}
def http1_like_write(order):
for name in order:
for chunk in responses[name]:
write_to_connection(chunk)
# 앞 응답이 끝나야 다음 응답을 쓸 수 있다
def http2_like_write(order):
active = {name: list(responses[name]) for name in order}
while active:
for name in list(active.keys()):
write_frame(stream=name, data=active[name].pop(0))
if not active[name]:
del active[name]
# 서로 다른 stream의 frame을 섞어 보낼 수 있다
이 예시에서 html 생성이 오래 걸리더라도 css나 js frame을 먼저 보낼 여지가 생긴다. 브라우저는 필요한 리소스를 더 빨리 받기 시작할 수 있고, 사용자는 전체 페이지가 다 끝나기 전에도 일부 렌더링 진척을 체감할 수 있다.
반대로 네트워크 packet loss가 심한 환경에서는 이야기가 달라질 수 있다. HTTP/2 connection 하나에 많은 stream을 몰아넣었는데 TCP segment가 유실되면, 그 connection의 stream들이 한꺼번에 멈춘 것처럼 보일 수 있다. 그래서 모바일 네트워크나 장거리 연결처럼 loss와 RTT가 커지는 환경에서는 HTTP/2의 장점과 한계를 같이 봐야 한다.
HTTP/2 multiplexing은 하나의 TCP connection 위에 여러 stream을 만들고, 각 stream의 frame을 interleaving해서 보낼 수 있게 한다. 이 덕분에 HTTP/1.1에서 응답 순서와 connection 수 제한 때문에 생기던 HOL blocking은 크게 줄어든다.
하지만 HTTP/2는 TCP 위에서 동작하므로, TCP의 순서 보장 바이트 스트림이 만드는 packet-level HOL blocking까지 없애지는 못한다. 한 TCP segment의 유실이 같은 connection 위의 여러 HTTP/2 stream 전달을 함께 지연시킬 수 있다.
결국 한 줄로 정리하면 이렇다.
HTTP/2는 HTTP 계층의 줄서기를 stream multiplexing으로 풀었지만, TCP 계층의 줄서기는 그대로 안고 간다.
다음에 더 파고들 주제로는 HTTP/2 flow control이 stream과 connection 단위로 어떻게 나뉘는지, 그리고 HTTP/3/QUIC이 이 문제를 어떤 방식으로 다시 설계했는지를 보면 좋겠다.