HTTP/2는 HOL blocking을 정말 없앴을까

seonwoo_jung·5일 전

1. 도입

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 바이트 스트림의 순서 보장까지 바꾸지는 않는다.

2. 핵심 개념: stream과 frame

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 위에 어떻게 배치할지 결정하는 전송 모델에 가깝다.

3. HTTP/1.1 HOL blocking은 어디서 생겼나

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은 병렬성의 기반을 제공하지만, 실제 지연 시간은 네트워크 상태와 구현 정책을 같이 봐야 한다.

4. TCP 위에 남는 HOL blocking

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.1HTTP/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의 순서 보장 모델을 넘어서지는 못한다" 정도로 정리해도 충분하다.

5. 작은 예시로 보는 체감 차이

아래는 실제 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 생성이 오래 걸리더라도 cssjs frame을 먼저 보낼 여지가 생긴다. 브라우저는 필요한 리소스를 더 빨리 받기 시작할 수 있고, 사용자는 전체 페이지가 다 끝나기 전에도 일부 렌더링 진척을 체감할 수 있다.

반대로 네트워크 packet loss가 심한 환경에서는 이야기가 달라질 수 있다. HTTP/2 connection 하나에 많은 stream을 몰아넣었는데 TCP segment가 유실되면, 그 connection의 stream들이 한꺼번에 멈춘 것처럼 보일 수 있다. 그래서 모바일 네트워크나 장거리 연결처럼 loss와 RTT가 커지는 환경에서는 HTTP/2의 장점과 한계를 같이 봐야 한다.

6. 정리

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이 이 문제를 어떤 방식으로 다시 설계했는지를 보면 좋겠다.

참고 자료

  • RFC 9113: HTTP/2

0개의 댓글