업로드 API 서버의 SETTINGS_INITIAL_WINDOW_SIZE를 기본값 65,535옥텟에서 1MB로 올린 적이 있다. 큰 파일 하나를 올릴 때 흐름 제어에 자주 걸린다고 판단해서였는데, 정작 같은 커넥션으로 두 번째 업로드가 동시에 들어오자 둘 다 다시 느려졌다. 스트림 윈도우는 분명 넉넉한데 왜 막혔을까 — 답은 SETTINGS가 건드리지 못하는 윈도우가 하나 더 있다는 데 있었다.
HTTP/2 흐름 제어는 수신자가 WINDOW_UPDATE 프레임으로 크레딧(보낼 수 있는 옥텟 수)을 발급하는 credit-based 스킴이다(RFC 9113 §5.2). 송신자는 DATA 프레임을 하나 보낼 때마다 스트림 레벨 윈도우와 커넥션 레벨 윈도우 두 곳에서 동시에 그 크기만큼 차감되고, 둘 중 하나라도 부족하면 더 보낼 수 없다(§6.9.1-4). 이 AND 조건이 이번 글의 전부다.
TCP도 흐름 제어를 하지만 그건 커넥션(소켓) 전체에 대해서만 작동한다. HTTP/2는 하나의 TCP 커넥션 위에 여러 스트림을 얹으므로, "이 스트림에는 얼마나 보낼 수 있는가"는 TCP가 알 수 없는 상위 계층의 몫이다. HTTP/2가 굳이 자체 흐름 제어 계층을 다시 정의한 이유가 여기 있다.
Connection window (기본 초깃값 65,535 octets)
┌───────────────────────────────────────────┐
│ Stream A window Stream B window ... │
│ (기본 초깃값 65,535) (기본 초깃값 65,535) │
└───────────────────────────────────────────┘
스트림 A로 DATA를 보내면 A의 스트림 윈도우와 커넥션 윈도우가 함께 줄어든다. 흐름 제어 대상은 DATA 프레임뿐이라, HEADERS나 WINDOW_UPDATE 같은 제어 프레임은 윈도우를 소비하지 않는다(§6.9-4) — 그렇지 않으면 윈도우 고갈 상황을 알리는 프레임 자체가 막히는 교착이 생긴다.
여기서 처음 질문으로 돌아가면, SETTINGS_INITIAL_WINDOW_SIZE는 스트림 레벨 초깃값만 정한다. 커넥션 레벨 윈도우는 이 설정과 무관하며, 늘리는 유일한 방법은 스트림 식별자 0번짜리 WINDOW_UPDATE뿐이다(§6.9.2-6, "SETTINGS 프레임은 커넥션 흐름 제어 윈도우를 변경할 수 없다"). 스트림 윈도우를 아무리 키워도 커넥션 윈도우가 그대로면 병목은 그대로 남는다.
커넥션 윈도우를 서버가 늘려주지 않는다고 가정하고, 65,535옥텟짜리 커넥션 윈도우 하나를 두 스트림이 나눠 쓰는 상황을 시간 순으로 보면 이렇다.
| 시점 | 스트림 A 전송 | 스트림 B 전송 | 커넥션 윈도우 잔량 |
|---|---|---|---|
| t0 | - | - | 65,535 |
| t1 | 60,000 전송 | - | 5,535 |
| t2 | 대기(A 윈도우 여유 O) | 5,535까지만 전송 가능 | 0 |
| t3 | 대기 | 대기(B 스트림 윈도우는 아직 남았는데도) | 0 (WINDOW_UPDATE 대기) |
t3에서 스트림 B는 자기 스트림 윈도우가 남아 있어도 커넥션 윈도우가 0이라 한 옥텟도 보낼 수 없다. 이게 "이중 구조 때문에 생기는 흐름 제어 계층의 head-of-line blocking"이다 — 같은 커넥션에 물린 다른 요청이 내 스트림 윈도우와 무관하게 나를 묶어버린다.
이 AND 조건은 의사 코드로 옮기면 한 줄이다.
def can_send(frame_len, stream_window, conn_window):
# 두 윈도우 모두 만족해야 DATA를 보낼 수 있다
return frame_len <= stream_window and frame_len <= conn_window
실제 트래픽에서 이 동작을 확인하려면 nghttp2 패키지의 CLI로 프레임 로그를 뜨면 된다.
nghttp -nv https://nghttp2.org/ 2>&1 | grep -A2 "WINDOW_UPDATE\|SETTINGS_INITIAL_WINDOW_SIZE"
-v는 오가는 모든 프레임을 타입·플래그·페이로드와 함께 찍는다. SETTINGS_INITIAL_WINDOW_SIZE 값이 바뀐 뒤에도 스트림 ID 0번의 WINDOW_UPDATE가 따로 오는지, 그 값이 실제로 커넥션 병목을 풀어주는지를 비교하면 위 표의 상황이 실물로 재현된다.
커넥션 윈도우는 오직 스트림 ID 0번의
WINDOW_UPDATE로만 늘어난다 —SETTINGS_INITIAL_WINDOW_SIZE는 아무리 키워도 닿지 않는다.
동시 업로드/다운로드가 잦은 서비스라면 스트림 윈도우 튜닝보다 서버가 커넥션 레벨 WINDOW_UPDATE를 충분히·자주 보내는지부터 확인하는 편이 맞다. 다음으로 파고들 만한 건 스펙이 의도적으로 비워둔 부분이다 — "언제, 얼마나 WINDOW_UPDATE를 보낼지"는 RFC가 규정하지 않고 구현에 맡겨져 있고(§5.2.1), gRPC-Java나 Netty는 BDP(bandwidth-delay product) 추정으로 윈도우를 동적으로 키운다. QUIC/HTTP-3가 이 흐름 제어를 스트림별 MAX_STREAM_DATA/MAX_DATA로 어떻게 다르게 풀었는지도 비교해볼 만하다.