HTTP/2 흐름 제어와 우선순위
멀티플렉싱이 만들어 낸 새로운 문제, 즉 한 스트림의 버퍼 독점과 자원 분배를 HTTP/2가 어떻게 푸는지 정리합니다. WINDOW_UPDATE 기반 흐름 제어, 연결·스트림 두 단계 윈도우, 그리고 PRIORITY 의존성 트리와 그것이 RFC 9113에서 비권장된 이유까지 다룹니다.
지난 글에서 HTTP/2가 모든 통신을 프레임으로 쪼개고, 여러 스트림의 프레임을 한 연결에 인터리빙해 멀티플렉싱을 구현한다는 것을 봤다. 그런데 한 연결에 여러 스트림을 욱여넣는 순간 두 가지 새로운 문제가 생긴다. 하나는 빠른 송신자가 느린 수신자의 버퍼를 넘치게 하는 문제, 다른 하나는 여러 스트림 사이에 한정된 대역폭을 어떻게 나눌지의 문제다. 전자를 푸는 것이 흐름 제어(flow control), 후자를 다루려던 것이 우선순위(priority)다. 이번 글은 이 두 메커니즘을 들여다본다.
멀티플렉싱이 흐름 제어를 부른다
HTTP/1.1에는 애플리케이션 레벨 흐름 제어가 없었다. 연결 하나에 요청 하나가 순서대로 흘렀으니, TCP 자체의 흐름 제어에 맡기면 그만이었다. 하지만 HTTP/2는 한 TCP 연결 위에 수십 개의 스트림이 동시에 흐른다. 여기서 TCP 흐름 제어만으로는 부족하다.
문제 상황을 그려 보자. 클라이언트가 큰 파일 다운로드(스트림 1)와 작은 API 응답(스트림 3)을 동시에 받고 있다. 만약 스트림 1의 DATA가 수신 버퍼를 가득 채워 버리면, TCP 레벨에서는 연결 전체가 막힌다. 그러면 스트림 3의 작은 응답까지 함께 멈춘다. 멀티플렉싱으로 없앤 HOL 블로킹이 버퍼 차원에서 되살아나는 것이다.
그래서 HTTP/2는 스트림마다 독립적인 흐름 제어 윈도우를 둔다. 수신자가 “스트림 1은 더 받을 여유가 없다”고 말해도, 스트림 3의 윈도우는 멀쩡하니 작은 응답은 계속 흐른다. 한 스트림이 공용 버퍼를 독점하지 못하게 막는 장치다.
WINDOW_UPDATE와 두 단계 윈도우
흐름 제어의 단위는 윈도우(window)다. 윈도우는 “상대가 ACK 없이 받아 줄 수 있는 바이트 수”를 뜻한다. 송신자는 윈도우 크기만큼만 DATA를 보낼 수 있고, 보낸 만큼 윈도우가 줄어든다. 윈도우가 0이 되면 더 보내지 못하고 멈춘다.
수신자가 받은 데이터를 처리해 버퍼에 여유가 생기면, WINDOW_UPDATE 프레임을 보내 윈도우를 회복시킨다. 송신자는 늘어난 윈도우만큼 다시 보낼 수 있다. 이 주고받음이 흐름 제어의 전부다.
중요한 점은 윈도우가 두 단계로 존재한다는 것이다.
- 연결 레벨 윈도우: 연결 전체의 모든 DATA를 합산한 한도. WINDOW_UPDATE의 스트림 ID가 0이면 이 윈도우를 갱신한다.
- 스트림 레벨 윈도우: 개별 스트림 하나에 대한 한도. WINDOW_UPDATE의 스트림 ID가 양수면 해당 스트림 윈도우를 갱신한다.
송신자는 두 윈도우를 모두 만족해야 DATA를 보낼 수 있다. 스트림 윈도우가 남아도 연결 윈도우가 0이면 보낼 수 없고, 그 반대도 마찬가지다. 흐름 제어는 DATA 프레임에만 적용된다 — HEADERS, SETTINGS, PING 같은 제어 프레임은 윈도우와 무관하게 흐른다.
초기 윈도우와 SETTINGS_INITIAL_WINDOW_SIZE
연결을 막 열었을 때 스트림 윈도우의 기본 초기값은 65,535바이트(64KB - 1)다. 이 값은 1990년대 TCP 감각으로는 넉넉했지만, 현대의 고대역폭·고지연(BDP가 큰) 환경에서는 금세 작아진다. 윈도우 65KB로는 RTT가 한 번 돌 동안 64KB밖에 못 보내니, 대역폭이 남아도 윈도우를 기다리느라 처리량이 깎인다.
그래서 양쪽은 연결 초기 SETTINGS 교환에서 SETTINGS_INITIAL_WINDOW_SIZE로 이 기본값을 바꾼다. 새로 열리는 모든 스트림의 초기 윈도우가 이 값으로 시작한다.
# 클라이언트가 보내는 SETTINGS 프레임 (개념적 표현)
SETTINGS
SETTINGS_INITIAL_WINDOW_SIZE = 6291456 # 스트림당 6MB로 상향
SETTINGS_MAX_CONCURRENT_STREAMS = 100
# 이후 연결 레벨 윈도우도 WINDOW_UPDATE로 키운다
WINDOW_UPDATE <stream_id=0> increment=15663105
연결 레벨 윈도우의 초기값은 항상 65,535로 고정이라, SETTINGS로는 못 바꾼다. 대신 연결 직후 스트림 0에 대한 WINDOW_UPDATE를 보내 키운다. 실제로 nghttp2, Go, Chrome 같은 구현은 연결을 열자마자 큰 WINDOW_UPDATE를 보내 윈도우를 수 MB까지 끌어올린다.
윈도우가 어떻게 움직이는지 직접 관찰할 수도 있다.
# 프레임 흐름에서 WINDOW_UPDATE를 관찰
nghttp -nv https://nghttp2.org/ 2>&1 | grep -i window
# 출력 예
# recv SETTINGS frame (INITIAL_WINDOW_SIZE=65535)
# send WINDOW_UPDATE frame <stream_id=0> (window=2147418112) ← 연결 레벨
# recv WINDOW_UPDATE frame <stream_id=13> (window=32768) ← 스트림 레벨
윈도우 튜닝은 흐름 제어 성능의 핵심이다. 너무 작으면 BDP를 못 채워 처리량이 떨어지고, 너무 크면 흐름 제어가 사실상 무력화돼 메모리 압박과 스트림 간 공정성 문제가 생긴다.
우선순위 — 한정된 대역폭 나누기
흐름 제어가 “넘치지 않게”라면, 우선순위는 “먼저 줄 것을 먼저”다. 한 연결에 여러 스트림이 동시에 흐를 때, 중요한 리소스(예: 화면에 바로 필요한 CSS)를 덜 중요한 리소스(예: 화면 밖 이미지)보다 먼저 보내고 싶다.
HTTP/2의 원래 명세(RFC 7540)는 이를 위해 의존성 트리(dependency tree) 모델을 정의했다. 각 스트림은 다른 스트림에 의존할 수 있고, 같은 부모를 공유하는 형제끼리는 가중치(weight)에 비례해 대역폭을 나눈다.
- 부모-자식 의존성: 자식 스트림은 부모가 완료되거나 멈추기 전까지는 자원을 양보한다. “B는 A에 의존한다”면 A를 먼저 처리하라는 뜻이다.
- 가중치(1~256): 같은 부모를 가진 형제 스트림들은 가중치 비율대로 자원을 나눈다. 가중치 32와 16이면 대략 2:1로 대역폭을 배분한다.
- PRIORITY 프레임: 이 의존성과 가중치를 설정·변경한다. HEADERS 프레임 안에 우선순위 정보를 함께 실을 수도 있다.
위 그림에서 root 아래 가장 중요한 HTML이 오고, 그 자식으로 CSS와 JS가 가중치에 따라 대역폭을 나눠 가지며, 이미지는 더 낮은 우선순위로 트리 끝에 매달린다. 이론적으로는 우아한 모델이다.
우선순위는 왜 사라졌나
문제는 이 우아한 트리가 실무에서 거의 동작하지 않았다는 점이다.
RFC 7540 우선순위가 실패한 이유
────────────────────────────────────────
1. 구현 복잡도: 동적으로 바뀌는 의존성 트리를
서버가 정확히 유지·재계산하기 어렵다.
2. 구현 편차: 서버마다 트리 해석이 제각각이라
같은 우선순위 설정이 서버에 따라 다르게 동작.
3. 브라우저 편차: 브라우저들의 우선순위 부여
전략도 서로 달라 일관된 결과가 안 나옴.
4. 결국 다수 서버가 우선순위를 그냥 무시함.
상호 운용성이 무너진 채로 거의 쓰이지 못했고, 결국 RFC 9113(HTTP/2 개정판)은 의존성 트리 기반 우선순위를 비권장(deprecated)으로 격하했다. 명세에는 호환을 위해 남아 있지만, 새 구현이 이 모델을 적극 따를 것을 권하지 않는다.
대신 등장한 것이 훨씬 단순한 우선순위 힌트(Priority Hints)다. 복잡한 트리 대신 urgency(긴급도 0~7)와 incremental(점진 렌더 여부) 두 값만 담은 Priority 헤더로 표현한다.
# 클라이언트가 리소스의 우선순위를 단순하게 힌트
GET /css/app.css HTTP/2
Priority: u=0 # urgency 0 (가장 높음)
GET /img/below-fold.jpg HTTP/2
Priority: u=5, i # urgency 5, incremental
HTML에서도 <link rel="preload" fetchpriority="high">나 <img fetchpriority="low">로 같은 의도를 표현한다. 트리를 짜는 대신 “이건 급함 / 이건 안 급함”만 알려 주는, 훨씬 현실적인 모델이다. 이 방식(RFC 9218, Extensible Prioritization Scheme)은 HTTP/2와 HTTP/3 양쪽에서 공통으로 쓰인다.
요약하면 우선순위의 역사는 이렇다. 복잡한 트리(RFC 7540) → 실무 실패 → 비권장(RFC 9113) → 단순한 힌트로 대체(RFC 9218). 흐름 제어가 견고하게 살아남은 것과 대조적이다.
정리
- 멀티플렉싱은 두 가지 새 문제를 낳는다. 버퍼 독점(흐름 제어로 해결)과 자원 분배(우선순위로 다루려 함).
- 흐름 제어는 윈도우 기반이다. 송신자는 윈도우만큼만 DATA를 보내고, 수신자가 WINDOW_UPDATE로 윈도우를 회복시킨다. DATA 프레임에만 적용된다.
- 윈도우는 연결 레벨(stream 0)과 스트림 레벨 두 단계로 존재하며, 둘 다 만족해야 전송할 수 있다. 스트림 초기값 기본 65,535는
SETTINGS_INITIAL_WINDOW_SIZE로 키운다. - 우선순위는 원래 의존성 트리+가중치 모델이었으나, 구현 복잡도와 편차로 실무에서 실패해 RFC 9113에서 비권장됐다.
- 대체재는
Priority헤더 기반의 우선순위 힌트(urgency·incremental)다. 단순함이 상호 운용성을 살렸다.
다음 글에서는 HEADERS 프레임이 싣는 헤더가 어떻게 압축되는지를 다루는 HPACK 헤더 압축과 서버 푸시로 이어진다.
지난 글: HTTP/2 스트림과 프레임 — 바이너리 프레이밍 계층
다음 글: HPACK 헤더 압축과 서버 푸시
읽어주셔서 감사합니다. 😊