HTTP/2 멀티플렉싱 — 하나의 연결로 동시에

HTTP/2의 핵심인 멀티플렉싱을 해설합니다. 하나의 TCP 연결에서 여러 요청·응답을 스트림으로 동시에 처리하는 원리, HTTP/1.1의 애플리케이션 레벨 HOL 블로킹을 어떻게 푸는지, 도메인 샤딩·스프라이트가 왜 불필요해지는지, ALPN 협상과 여전히 남는 TCP 레벨 HOL 블로킹까지 정리합니다.

· 13 min read · PALDYN Team

지난 글에서 HTTP/1.1은 지속 연결로 연결을 재사용하는 데까지는 성공했지만, 한 연결이 응답을 직렬로만 흘리는 애플리케이션 레벨 HOL 블로킹에 갇혀 있었다고 정리했다. 파이프라이닝은 순서 보장 요구 때문에 폐기됐고, 연결 6개·도메인 샤딩·스프라이트는 모두 한계를 우회하는 임시방편일 뿐이었다. HTTP/2는 이 모순을 워크어라운드가 아니라 프로토콜 구조로 푼다. 핵심 무기는 단 하나, 멀티플렉싱이다.

멀티플렉싱 — 하나의 연결, 여러 스트림

HTTP/2의 한 문장 요약은 이것이다. 하나의 TCP 연결 위에서 여러 요청·응답을 스트림으로 동시에 처리한다.

여기서 스트림(stream)은 하나의 연결 안에 존재하는 독립된 양방향 요청·응답 흐름이다. 연결 하나에 스트림이 수백 개 떠 있을 수 있고, 각 스트림은 고유한 스트림 ID를 가진다. 클라이언트가 요청 10개를 보낼 때 HTTP/1.1처럼 응답을 차례로 기다릴 필요가 없다. 요청 10개를 각각 별도 스트림으로 동시에 띄우고, 서버는 준비되는 대로 아무 순서로나 응답을 돌려보낸다.

하나의 연결 위에 여러 스트림이 동시에 흐른다

핵심은 인터리빙(interleaving)이다. 여러 스트림의 데이터 조각이 한 연결 위에서 잘게 쪼개져 번갈아 흐른다. 스트림 3의 데이터가 흐르는 도중에 스트림 5의 데이터가 끼어들고, 다시 스트림 1이 이어지는 식이다. 각 조각에는 자신이 속한 스트림 ID가 붙어 있으므로, 받는 쪽은 조각들을 스트림별로 다시 모아 원래 메시지로 조립한다.

HOL 블로킹을 어떻게 푸는가

HTTP/1.1의 애플리케이션 레벨 HOL 블로킹은 한 연결이 응답을 직렬로만 내보낼 수 있기 때문에 생겼다. 앞 응답이 느리면 뒤의 빠른 응답이 전부 묶였다.

HTTP/2는 응답마다 스트림 ID가 붙으므로 순서에 묶일 이유가 사라진다.

:method: GET
:path: /slow-api      # 스트림 1 — DB 쿼리 느림

:method: GET
:path: /fast.css      # 스트림 3 — 즉시 준비됨

HTTP/1.1이라면 /slow-api가 끝날 때까지 /fast.css는 한 연결에서 대기해야 했다. HTTP/2에서는 스트림 3의 응답이 먼저 준비되면 스트림 1을 기다리지 않고 바로 흘러나온다. 느린 응답이 빠른 응답을 막지 않는다. 이것이 멀티플렉싱이 애플리케이션 레벨 HOL 블로킹을 제거하는 방식이다.

도메인 샤딩도, 스프라이트도 필요 없다

HTTP/1.1 시대의 최적화 기법들은 전부 “요청 수를 줄이거나 연결 수를 늘려서” 동시성 한계를 우회하려는 시도였다. HTTP/2는 단일 연결에서 동시성을 직접 지원하므로, 그 우회책들이 불필요해지거나 오히려 해롭게 된다.

HTTP/1.1 연결 6개 vs HTTP/2 연결 1개 멀티플렉싱

  • 도메인 샤딩 — 한 연결로 무제한 동시 스트림이 가능하니 호스트를 쪼갤 이유가 없다. 오히려 호스트를 나누면 연결마다 핸드셰이크·TLS·혼잡 제어 워밍업이 중복돼 손해다. HTTP/2에서는 안티패턴이다.
  • CSS 스프라이트·번들링 — 요청 수를 줄이려고 작은 파일을 하나로 합치는 기법이었다. 요청 비용이 저렴해진 HTTP/2에서는 합치는 이득이 줄고, 파일 하나만 바뀌어도 전체를 다시 받아야 하는 캐시 비효율이 더 부각된다.

같은 자원을 받을 때 HTTP/1.1은 연결 6개를 병렬로 열고 각 연결 안에서 직렬 처리했지만, HTTP/2는 연결 하나에서 모든 요청을 동시에 띄운다. 핸드셰이크는 한 번, 혼잡 제어도 한 연결에 집중되어 대역폭을 더 효율적으로 쓴다.

바이너리 프로토콜로의 전환

이 멀티플렉싱을 가능하게 한 토대가 바이너리 프로토콜로의 전환이다. HTTP/1.1은 사람이 읽는 텍스트 기반이었다. GET /path HTTP/1.1\r\n 같은 줄을 줄바꿈으로 구분해 파싱했기에, 메시지를 잘게 쪼개 인터리빙하는 것이 사실상 불가능했다.

HTTP/2는 모든 통신을 프레임(frame)이라는 고정 형식의 바이너리 단위로 주고받는다. 각 프레임 헤더에는 길이·타입·플래그·스트림 ID가 들어 있다. 메시지는 여러 프레임으로 쪼개지고, 각 프레임이 자기 스트림 ID를 달고 있으므로 한 연결 위에서 자유롭게 섞여 흐를 수 있다.

+-----------------------------------------------+
| Length (24)  | Type (8) | Flags (8)           |
+--------------+----------+---------------------+
| R | Stream Identifier (31)                    |
+-----------------------------------------------+
| Frame Payload ...                             |
+-----------------------------------------------+

스트림이라는 추상은 결국 이 프레임들이 모여 만들어진다. 프레임 헤더 구조와 스트림 생명주기는 워낙 핵심이라 다음 글에서 따로 깊이 다룬다.

h2 협상 — ALPN over TLS

그렇다면 클라이언트와 서버는 HTTP/2로 말할지 1.1로 말할지를 어떻게 정할까. 현실의 브라우저 HTTP/2는 사실상 항상 TLS 위에서 동작하고, 버전 선택은 ALPN(Application-Layer Protocol Negotiation)으로 이뤄진다.

ALPN은 TLS 핸드셰이크 안에 끼어든다. 클라이언트가 ClientHello를 보낼 때 “나는 h2도 되고 http/1.1도 된다”는 프로토콜 목록을 함께 싣고, 서버가 ServerHello에서 하나를 골라 답한다. TLS 핸드셰이크가 끝나는 순간 이미 어떤 프로토콜을 쓸지가 정해져 있다. 별도의 왕복이 필요 없다.

# 서버가 h2를 지원하는지 ALPN으로 확인
openssl s_client -connect example.com:443 -alpn h2 </dev/null 2>/dev/null \
  | grep -i 'ALPN'
# ALPN protocol: h2

# curl로 협상 결과 확인 (HTTP/2 사용 시 'HTTP/2 200')
curl -sI --http2 https://example.com/ | head -1

ALPN 식별자 h2는 “TLS 위 HTTP/2”를 뜻한다. 평문 위 HTTP/2(h2c)도 명세에는 있지만, 브라우저는 지원하지 않아 실무에서는 거의 쓰이지 않는다.

끝나지 않은 이야기 — TCP 레벨 HOL 블로킹

여기서 멈추면 HTTP/2가 HOL 블로킹을 완전히 끝낸 것처럼 보이지만, 사실은 그렇지 않다. 멀티플렉싱이 푼 것은 애플리케이션 레벨의 HOL 블로킹이다. 그보다 한 층 아래, TCP 레벨의 HOL 블로킹은 그대로 남는다.

[애플리케이션 레벨 HOL]  HTTP/2가 해결 ✓
  여러 스트림이 한 연결에서 동시에 흐름

[TCP 레벨 HOL]           여전히 남음 ✗
  하나의 TCP 패킷이 유실되면,
  뒤따라온 다른 스트림의 패킷까지
  재전송을 기다리며 전부 대기

원인은 TCP가 바이트 스트림을 순서대로 전달한다는 특성에 있다. HTTP/2는 한 TCP 연결에 모든 스트림을 몰아넣었는데, 도중에 패킷 하나가 유실되면 TCP는 그 패킷이 재전송될 때까지 뒤 데이터를 애플리케이션에 올려주지 않는다. 손실된 패킷이 스트림 1의 것이라도, 이미 도착해 있는 스트림 3·5의 데이터까지 TCP 버퍼에 묶여 대기한다. 스트림은 논리적으로 독립적이지만, 그것들을 실어 나르는 단일 TCP 연결이 병목이 되는 것이다.

역설적이게도 연결을 하나로 합친 멀티플렉싱의 장점이, 패킷 손실 상황에서는 단점으로 돌아온다. HTTP/1.1이 연결 6개를 쓸 때는 한 연결의 손실이 나머지 5개에 영향을 주지 않았지만, HTTP/2는 모든 스트림이 운명을 같이한다.

이 TCP 레벨 HOL 블로킹을 풀려면 전송 계층 자체를 갈아엎어야 한다. 그것이 TCP 대신 UDP 기반 QUIC 위에 HTTP를 올린 HTTP/3의 출발점이다. 그 이야기는 뒤에서 따로 다룬다.

정리

멀티플렉싱      | 한 TCP 연결에서 여러 스트림 동시 처리
애플리케이션 HOL | 스트림 ID로 순서 묶임 제거 → 해결 ✓
도메인 샤딩      | 불필요 (오히려 안티패턴)
스프라이트·번들  | 이득 감소
바이너리 프레이밍 | 인터리빙을 가능케 한 토대
h2 협상         | ALPN over TLS (추가 왕복 없음)
TCP 레벨 HOL    | 여전히 남음 → HTTP/3의 동기

HTTP/2의 멀티플렉싱은 HTTP/1.1을 옭아맸던 “연결당 직렬, 호스트당 6개”의 사슬을 끊었다. 하나의 연결로 모든 요청을 동시에 처리하면서 핸드셰이크와 혼잡 제어를 한곳에 모았고, 그 결과 1.1 시대의 우회책들을 대부분 불필요하게 만들었다. 다만 그 동시성은 스트림이라는 추상 위에서 돌아가고, 스트림은 다시 프레임이라는 바이너리 단위로 구현된다. 다음 글에서는 그 스트림과 프레임, 즉 HTTP/2의 바이너리 프레이밍 계층을 해부한다.


지난 글: HTTP/1.1 심화 — 지속 연결·파이프라이닝·HOL 블로킹

다음 글: HTTP/2 스트림과 프레임 — 바이너리 프레이밍 계층


읽어주셔서 감사합니다. 😊