스트림 윈도우를 키워도 업로드가 그대로 막히는 이유 — HTTP/2 이중 흐름 제어

seonwooj0810·7일 전

1. 도입

업로드 API 서버의 SETTINGS_INITIAL_WINDOW_SIZE를 기본값 65,535옥텟에서 1MB로 올린 적이 있다. 큰 파일 하나를 올릴 때 흐름 제어에 자주 걸린다고 판단해서였는데, 정작 같은 커넥션으로 두 번째 업로드가 동시에 들어오자 둘 다 다시 느려졌다. 스트림 윈도우는 분명 넉넉한데 왜 막혔을까 — 답은 SETTINGS가 건드리지 못하는 윈도우가 하나 더 있다는 데 있었다.

2. 핵심 개념

HTTP/2 흐름 제어는 수신자가 WINDOW_UPDATE 프레임으로 크레딧(보낼 수 있는 옥텟 수)을 발급하는 credit-based 스킴이다(RFC 9113 §5.2). 송신자는 DATA 프레임을 하나 보낼 때마다 스트림 레벨 윈도우와 커넥션 레벨 윈도우 두 곳에서 동시에 그 크기만큼 차감되고, 둘 중 하나라도 부족하면 더 보낼 수 없다(§6.9.1-4). 이 AND 조건이 이번 글의 전부다.

TCP도 흐름 제어를 하지만 그건 커넥션(소켓) 전체에 대해서만 작동한다. HTTP/2는 하나의 TCP 커넥션 위에 여러 스트림을 얹으므로, "이 스트림에는 얼마나 보낼 수 있는가"는 TCP가 알 수 없는 상위 계층의 몫이다. HTTP/2가 굳이 자체 흐름 제어 계층을 다시 정의한 이유가 여기 있다.

3. 내부 동작

두 개의 독립된 윈도우

Connection window (기본 초깃값 65,535 octets)
┌───────────────────────────────────────────┐
│  Stream A window     Stream B window   ... │
│  (기본 초깃값 65,535)  (기본 초깃값 65,535)   │
└───────────────────────────────────────────┘

스트림 A로 DATA를 보내면 A의 스트림 윈도우와 커넥션 윈도우가 함께 줄어든다. 흐름 제어 대상은 DATA 프레임뿐이라, HEADERSWINDOW_UPDATE 같은 제어 프레임은 윈도우를 소비하지 않는다(§6.9-4) — 그렇지 않으면 윈도우 고갈 상황을 알리는 프레임 자체가 막히는 교착이 생긴다.

여기서 처음 질문으로 돌아가면, SETTINGS_INITIAL_WINDOW_SIZE스트림 레벨 초깃값만 정한다. 커넥션 레벨 윈도우는 이 설정과 무관하며, 늘리는 유일한 방법은 스트림 식별자 0번짜리 WINDOW_UPDATE뿐이다(§6.9.2-6, "SETTINGS 프레임은 커넥션 흐름 제어 윈도우를 변경할 수 없다"). 스트림 윈도우를 아무리 키워도 커넥션 윈도우가 그대로면 병목은 그대로 남는다.

숫자로 보면

커넥션 윈도우를 서버가 늘려주지 않는다고 가정하고, 65,535옥텟짜리 커넥션 윈도우 하나를 두 스트림이 나눠 쓰는 상황을 시간 순으로 보면 이렇다.

시점스트림 A 전송스트림 B 전송커넥션 윈도우 잔량
t0--65,535
t160,000 전송-5,535
t2대기(A 윈도우 여유 O)5,535까지만 전송 가능0
t3대기대기(B 스트림 윈도우는 아직 남았는데도)0 (WINDOW_UPDATE 대기)

t3에서 스트림 B는 자기 스트림 윈도우가 남아 있어도 커넥션 윈도우가 0이라 한 옥텟도 보낼 수 없다. 이게 "이중 구조 때문에 생기는 흐름 제어 계층의 head-of-line blocking"이다 — 같은 커넥션에 물린 다른 요청이 내 스트림 윈도우와 무관하게 나를 묶어버린다.

4. 예시

이 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가 따로 오는지, 그 값이 실제로 커넥션 병목을 풀어주는지를 비교하면 위 표의 상황이 실물로 재현된다.

5. 정리

커넥션 윈도우는 오직 스트림 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로 어떻게 다르게 풀었는지도 비교해볼 만하다.

참고 자료

0개의 댓글