본문 바로가기

HTTP/2

HTTP/1.1이 연결 하나에서 요청을 하나씩만 처리해 생긴 병목과 그 우회 기법을 정리하고, HTTP/2가 메시지를 프레임으로 나누어 여러 스트림을 섞어 보내는 방식을 프레임 헤더를 직접 읽으며 확인합니다. 흐름 제어와 우선순위, HPACK 헤더 압축의 동적 표, 서버 푸시의 퇴장과 TCP 위에 남은 HOL 블로킹도 함께 봅니다.

9장에서 본 HTTP/1.1은 웹을 크게 키웠지만, 웹 페이지 하나가 수십에서 수백 개의 파일을 받는 시대가 되자 그 구조가 병목이 되었습니다. HTTP/2(RFC 9113)는 메서드와 헤더, 상태 코드의 의미는 그대로 두고, 메시지를 연결 위에 싣는 방식만 바꾸어 이 문제를 풀었습니다. 이 장에서는 HTTP/2, HTTP/3, WebSocket처럼 HTTP를 나르는 방식이 어떻게 발전해 왔는지 차례로 봅니다.

HTTP/1.1이 막힌 곳

HTTP/1.1의 연결 하나는 한 번에 요청 하나를 처리합니다. 요청을 미리 여러 개 보내 두는 파이프라이닝(pipelining)도 있었지만, 응답은 요청한 순서대로 와야 해서 앞의 응답 하나가 느리면 뒤의 응답이 모두 기다렸습니다. 이 HTTP 수준의 HOL 블로킹과 중간 장비의 잘못된 구현 때문에 브라우저들은 파이프라이닝을 쓰지 않았고, 대신 서버 하나에 연결을 여섯 개 정도씩 열어 병렬로 받았습니다. 연결마다 TCP와 TLS의 핸드셰이크를 하고, 연결마다 혼잡 제어가 따로 천천히 속도를 올리므로 비용이 큽니다.

개발자들은 요청의 수를 줄이는 우회 기법을 만들었습니다.

기법하는 일대가
이미지 스프라이트작은 이미지 여러 개를 한 장으로 합친다하나만 바뀌어도 전체를 다시 받는다
파일 합치기JS와 CSS 파일을 하나로 묶는다캐시를 파일 단위로 나누지 못한다
인라이닝작은 CSS와 JS를 HTML 안에 넣는다여러 페이지가 같은 내용을 따로 받는다
도메인 샤딩자원을 여러 도메인에 나누어 연결 수를 늘린다DNS 조회와 핸드셰이크가 늘어난다

HTTP/2는 연결 하나에서 여러 요청을 동시에 처리하므로 이런 기법의 필요를 크게 줄였습니다. 특히 도메인 샤딩은 HTTP/2에서 오히려 연결을 나누어 손해가 됩니다. 다만 파일 합치기나 인라이닝은 압축 효율이나 첫 화면의 속도 때문에 HTTP/2에서도 상황에 따라 씁니다.

프레임과 스트림

HTTP/2는 메시지를 텍스트 줄로 보내지 않고 프레임(frame)이라는 바이너리 단위로 나눕니다. 요청과 응답 하나는 스트림(stream) 하나를 차지하고, 스트림마다 번호가 붙습니다. 헤더는 HEADERS 프레임에, 본문은 DATA 프레임에 실리며, 서로 다른 스트림의 프레임은 한 연결 위에서 자유롭게 섞여 오갑니다. 클라이언트가 여는 스트림은 홀수, 서버가 여는 스트림은 짝수 번호이고, 0번은 연결 전체의 설정과 제어에 씁니다.

한 연결에서 교차하는 HTTP/2 응답 프레임

각 스트림의 응답 헤더 블록을 끝냅니다. 스트림 1의 나머지 본문도 끝냅니다. 각 단계의 값과 전환 이유를 비교합니다.

HTTP/2 · 스트림 다중화

같은 연결에서 응답을 섞어 보내고, 스트림 3이 먼저 끝납니다

이미 요청을 받은 서버의 허용 전송 순서 예입니다. 각 HEADERS에서 헤더 블록이 끝났다고 가정하며, END_STREAM은 DATA에 붙인 플래그입니다. 프레임 경계는 TCP 패킷 경계와 다릅니다.

한 연결에서 교차하는 HTTP/2 응답 프레임이미 요청을 받은 서버가 스트림 1과 3의 응답 프레임을 교차해 전송하는 허용 순서의 예시입니다. 스트림 3이 먼저 끝나며 END_STREAM은 DATA 프레임에 붙인 플래그입니다. TCP 패킷 경계나 필수 전송 순서를 뜻하지 않습니다.HEADERS · 스트림 1응답 헤더HEADERS · 스트림 3응답 헤더DATA · 스트림 1본문의 일부DATA · 스트림 3END_STREAM = 1 · 응답 끝DATA · 스트림 1END_STREAM = 1 · 응답 끝서버클라이언트
한 연결에서 교차하는 HTTP/2 응답 프레임시간은 위에서 아래로 향합니다. 서버가 HEADERS1,HEADERS3,DATA1,END_STREAM이붙은DATA3,END_STREAM이붙은DATA1을 차례로 보냅니다. 각HEADERS에서블록종료를가정한허용순서이며 스트림3이먼저끝나도1은계속됩니다. 각화살표는응답프레임이며 TCP패킷경계나고정시간을뜻하지않습니다.서버클라이언트HEADERS · 1응답 헤더HEADERS · 3응답 헤더DATA · 1본문의 일부DATA · 3END_STREAM = 1응답 끝DATA · 1END_STREAM = 1응답 끝

위에서 아래로 시간 순서입니다. 세로 점선은 서버·클라이언트의 흐름, 가로 화살표는 응답 프레임입니다. 청록색은 스트림 3의 먼저 끝나는 응답이며, 필수 스케줄이나 실제 시간 간격을 뜻하지 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 두 헤더부터 스트림 1 끝까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명시간은 위에서 아래로 향합니다. 서버가 HEADERS1, HEADERS3, DATA1, END_STREAM이 붙은 DATA3, END_STREAM이 붙은 DATA1을 차례로 보냅니다. 각 HEADERS에서 블록 종료를 가정한 허용 순서이며 스트림 3이 먼저 끝나도 1은 계속됩니다. 각 화살표는 응답 프레임이며 TCP 패킷 경계나 고정 시간을 뜻하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
두 헤더

각 스트림의 응답 헤더 블록을 끝냅니다.

  • 스트림·프레임 1: HEADERS1→HEADERS3
  • 스트림·프레임 2: 각 END_HEADERS 1
  • 스트림 1과 3의 본문을 보낼 준비
스트림 1 일부

스트림 1의 첫 본문 일부를 보냅니다.

  • 스트림·프레임 1: DATA1 100B
  • 스트림·프레임 2: stream1 계속
  • 스트림 1은 계속 열림; 다른 스트림 프레임과 교차 가능
스트림 3 끝

스트림 3의 본문과 END_STREAM을 보냅니다.

  • 스트림·프레임 1: DATA3 50B/END_STREAM1
  • 스트림 3만 종료; 연결과 스트림 1은 유지
스트림 1 끝

스트림 1의 나머지 본문도 끝냅니다.

  • 스트림·프레임 1: DATA1 150B/END_STREAM1
  • 스트림·프레임 2: 총 5 프레임 385B
  • 총 5프레임·385B; 각 헤더 블록 20B를 가정하고 TLS·TCP·IP 제외

한 연결에서 교차하는 HTTP/2 응답 프레임

각 스트림의 응답 헤더 블록을 끝냅니다. 스트림 1의 나머지 본문도 끝냅니다. 각 단계의 값과 전환 이유를 비교합니다.

HTTP/2 · 스트림 다중화

같은 연결에서 응답을 섞어 보내고, 스트림 3이 먼저 끝납니다

이미 요청을 받은 서버의 허용 전송 순서 예입니다. 각 HEADERS에서 헤더 블록이 끝났다고 가정하며, END_STREAM은 DATA에 붙인 플래그입니다. 프레임 경계는 TCP 패킷 경계와 다릅니다.

한 연결에서 교차하는 HTTP/2 응답 프레임이미 요청을 받은 서버가 스트림 1과 3의 응답 프레임을 교차해 전송하는 허용 순서의 예시입니다. 스트림 3이 먼저 끝나며 END_STREAM은 DATA 프레임에 붙인 플래그입니다. TCP 패킷 경계나 필수 전송 순서를 뜻하지 않습니다.HEADERS · 스트림 1응답 헤더HEADERS · 스트림 3응답 헤더DATA · 스트림 1본문의 일부DATA · 스트림 3END_STREAM = 1 · 응답 끝DATA · 스트림 1END_STREAM = 1 · 응답 끝서버클라이언트
한 연결에서 교차하는 HTTP/2 응답 프레임시간은 위에서 아래로 향합니다. 서버가 HEADERS1,HEADERS3,DATA1,END_STREAM이붙은DATA3,END_STREAM이붙은DATA1을 차례로 보냅니다. 각HEADERS에서블록종료를가정한허용순서이며 스트림3이먼저끝나도1은계속됩니다. 각화살표는응답프레임이며 TCP패킷경계나고정시간을뜻하지않습니다.서버클라이언트HEADERS · 1응답 헤더HEADERS · 3응답 헤더DATA · 1본문의 일부DATA · 3END_STREAM = 1응답 끝DATA · 1END_STREAM = 1응답 끝

위에서 아래로 시간 순서입니다. 세로 점선은 서버·클라이언트의 흐름, 가로 화살표는 응답 프레임입니다. 청록색은 스트림 3의 먼저 끝나는 응답이며, 필수 스케줄이나 실제 시간 간격을 뜻하지 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 두 헤더부터 스트림 1 끝까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명시간은 위에서 아래로 향합니다. 서버가 HEADERS1, HEADERS3, DATA1, END_STREAM이 붙은 DATA3, END_STREAM이 붙은 DATA1을 차례로 보냅니다. 각 HEADERS에서 블록 종료를 가정한 허용 순서이며 스트림 3이 먼저 끝나도 1은 계속됩니다. 각 화살표는 응답 프레임이며 TCP 패킷 경계나 고정 시간을 뜻하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
두 헤더

각 스트림의 응답 헤더 블록을 끝냅니다.

  • 스트림·프레임 1: HEADERS1→HEADERS3
  • 스트림·프레임 2: 각 END_HEADERS 1
  • 스트림 1과 3의 본문을 보낼 준비
스트림 1 일부

스트림 1의 첫 본문 일부를 보냅니다.

  • 스트림·프레임 1: DATA1 100B
  • 스트림·프레임 2: stream1 계속
  • 스트림 1은 계속 열림; 다른 스트림 프레임과 교차 가능
스트림 3 끝

스트림 3의 본문과 END_STREAM을 보냅니다.

  • 스트림·프레임 1: DATA3 50B/END_STREAM1
  • 스트림 3만 종료; 연결과 스트림 1은 유지
스트림 1 끝

스트림 1의 나머지 본문도 끝냅니다.

  • 스트림·프레임 1: DATA1 150B/END_STREAM1
  • 스트림·프레임 2: 총 5 프레임 385B
  • 총 5프레임·385B; 각 헤더 블록 20B를 가정하고 TLS·TCP·IP 제외

모든 프레임은 9바이트의 헤더로 시작합니다(RFC 9113 §4.1). 페이로드의 길이 24비트, 프레임 종류 8비트, 플래그 8비트, 예약 비트 하나와 스트림 번호 31비트입니다. 다음 코드는 그림의 다섯 프레임을 만들어 하나의 바이트열로 이은 뒤, 받는 쪽처럼 앞에서부터 읽어 냅니다. 압축된 헤더 블록은 각각 20바이트, 본문은 그림의 순서대로 100, 50, 150바이트로 정했습니다.

import struct

TYPES = {0x0: "DATA", 0x1: "HEADERS"}
END_STREAM, END_HEADERS = 0x1, 0x4

def frame(ftype, flags, stream_id, payload_len):
    length = struct.pack("!I", payload_len)[1:]                    # 길이는 24비트
    return length + struct.pack("!BBI", ftype, flags, stream_id & 0x7FFFFFFF) + b"\x00" * payload_len

stream = b"".join([
    frame(0x1, END_HEADERS, 1, 20),
    frame(0x1, END_HEADERS, 3, 20),
    frame(0x0, 0, 1, 100),
    frame(0x0, END_STREAM, 3, 50),
    frame(0x0, END_STREAM, 1, 150),
])

offset = 0
while offset < len(stream):
    length = int.from_bytes(stream[offset:offset + 3], "big")
    ftype, flags, sid = struct.unpack("!BBI", stream[offset + 3:offset + 9])
    ends = " END_STREAM" if ftype == 0x0 and flags & END_STREAM else ""
    print(f"{TYPES[ftype]:<7} 스트림 {sid}  페이로드 {length:>3}바이트{ends}")
    offset += 9 + length                                           # 다음 프레임의 시작
print(f"모두 {len(stream)}바이트")
HEADERS 스트림 1  페이로드  20바이트
HEADERS 스트림 3  페이로드  20바이트
DATA    스트림 1  페이로드 100바이트
DATA    스트림 3  페이로드  50바이트 END_STREAM
DATA    스트림 1  페이로드 150바이트 END_STREAM
모두 385바이트

받는 쪽은 길이 필드만 보고 프레임의 끝을 찾으므로, 11장 2편의 길이 헤더 방식과 같은 원리입니다. 프레임 헤더 5 × 9 = 45바이트, 헤더 블록 40바이트, 본문 300바이트로 모두 385바이트이며, 여기에 TLS와 TCP, IP의 헤더는 들어 있지 않습니다. 스트림 3은 50바이트짜리 DATA에 END_STREAM 플래그를 붙여 먼저 끝났고, 스트림 1은 그 뒤에도 계속 이어졌습니다. END_STREAM은 따로 오는 프레임이 아니라 그 스트림의 이쪽 방향이 끝났다는 플래그이며, 스트림 하나가 끝나도 연결은 계속됩니다.

섞어 보낼 수 없는 경우가 하나 있습니다. 헤더 블록이 커서 HEADERS 하나에 다 들어가지 않으면 같은 스트림의 CONTINUATION 프레임으로 이어 보내는데, 블록의 끝을 알리는 END_HEADERS 플래그가 나올 때까지는 다른 스트림의 프레임을 끼워 넣을 수 없습니다(RFC 9113 §4.3). 헤더 블록은 아래에서 볼 압축 상태와 얽혀 있어서 순서대로 한 번에 풀어야 하기 때문입니다.

흐름 제어와 우선순위

한 연결에 여러 스트림이 있으므로, HTTP/2는 TCP와 별도로 자기의 흐름 제어를 둡니다. 받는 쪽은 스트림마다, 그리고 연결 전체에 대해 더 받을 수 있는 양을 WINDOW_UPDATE 프레임으로 알리며, 처음 크기는 65,535바이트입니다. 큰 파일 하나를 받는 스트림이 버퍼를 다 차지해 다른 스트림이 막히지 않게 하는 장치입니다.

어느 스트림을 먼저 보낼지 정하는 우선순위도 있습니다. 처음의 HTTP/2는 스트림 사이의 의존 관계를 나무 모양으로 표현하는 복잡한 방식을 정의했지만, 구현마다 제각각이어서 RFC 9113에서 쓰지 않는 것으로 바뀌었습니다. 지금은 RFC 9218의 Priority 헤더로 긴급도(u=0부터 u=7)와 조금씩 나누어 보내도 되는지(i)만 간단히 알립니다. 화면에 바로 필요한 CSS는 높게, 아래쪽 이미지는 낮게 두는 식입니다.

더 깊이: 동시 스트림의 한도와 Rapid Reset 공격

한 연결에 동시에 열 수 있는 스트림의 수는 받는 쪽이 SETTINGS 프레임의 SETTINGS_MAX_CONCURRENT_STREAMS로 정합니다. RFC 9113에 따르면 이 값은 처음에는 한도가 없고, 병렬성을 지나치게 막지 않도록 100보다 작지 않게 두기를 권합니다. 한도에 세는 것은 열린(open) 상태와 반쯤 닫힌(half-closed) 상태의 스트림뿐이어서, RST_STREAM으로 취소해 닫힌 스트림은 더는 세지 않습니다.

2023년 8월부터 10월 사이에 실제 공격에 쓰인 HTTP/2 Rapid Reset(CVE-2023-44487)은 이 점을 노렸습니다. 공격자는 스트림을 열어 요청을 보내자마자 RST_STREAM으로 취소하기를 빠르게 되풀이하는데, 취소한 스트림은 한도에 들지 않으므로 연결 하나로 한도보다 훨씬 많은 요청을 서버에 밀어 넣을 수 있습니다. 서버는 이미 받은 요청을 처리하는 데 자원을 쓰므로, CVE 기록의 설명대로 요청을 빠르게 취소하는 것만으로 서버의 자원을 바닥낼 수 있습니다. 프로토콜의 규칙을 어기지 않는 공격이라 동시 스트림의 한도만으로는 막을 수 없고, 서버 구현이 연결마다 취소되는 요청의 양을 따로 따져야 합니다.

HPACK 헤더 압축

HTTP/1.1에서는 같은 사이트에 요청할 때마다 User-Agent와 쿠키 같은 비슷한 헤더를 매번 텍스트로 되풀이해 보냈습니다. HTTP/2의 HPACK(RFC 7541)은 헤더를 번호로 가리켜 이를 줄입니다. 자주 쓰는 61개의 이름과 값은 명세에 미리 정해 둔 정적 표(static table)에 있고, 그 밖의 헤더는 한 번 보낼 때 연결마다 따로 두는 동적 표(dynamic table)에 넣어 두었다가 다음부터 번호로 가리킵니다.

다음 코드는 RFC 7541 부록 C.3에 실린 두 요청의 헤더 블록을 풀어 봅니다. 예를 단순하게 하려고 필요한 정적 표 항목만 옮겼고, 이 예의 값은 허프만 부호화를 쓰지 않았습니다.

STATIC = {1: (":authority", ""), 2: (":method", "GET"), 4: (":path", "/"),
          6: (":scheme", "http"), 24: ("cache-control", "")}     # RFC 7541 부록 A의 일부

def decode(block, dynamic):
    fields, i = [], 0
    while i < len(block):
        b = block[i]
        if b & 0x80:                                     # 1xxxxxxx: 표의 항목을 번호로 가리킨다
            index = b & 0x7F
            fields.append(STATIC[index] if index <= 61 else dynamic[index - 62])
            i += 1
        elif b & 0xC0 == 0x40:                           # 01xxxxxx: 값을 적고 동적 표에 더한다
            name = STATIC[b & 0x3F][0]
            length = block[i + 1]
            value = block[i + 2:i + 2 + length].decode()
            dynamic.insert(0, (name, value))             # 새 항목이 62번이 되고 나머지는 하나씩 밀린다
            fields.append((name, value))
            i += 2 + length
    return fields

dynamic = []
first = bytes.fromhex("828684410f7777772e6578616d706c652e636f6d")        # RFC 7541 C.3.1
second = bytes.fromhex("828684be58086e6f2d6361636865")                   # RFC 7541 C.3.2
for name, block in [("첫 요청", first), ("둘째 요청", second)]:
    print(f"{name} {len(block)}바이트:", decode(block, dynamic))
첫 요청 20바이트: [(':method', 'GET'), (':scheme', 'http'), (':path', '/'), (':authority', 'www.example.com')]
둘째 요청 14바이트: [(':method', 'GET'), (':scheme', 'http'), (':path', '/'), (':authority', 'www.example.com'), ('cache-control', 'no-cache')]

첫 요청에서 :method: GET, :scheme: http, :path: /는 정적 표의 번호를 가리키는 한 바이트씩(0x82, 0x86, 0x84)이고, :authority는 값 15바이트를 직접 적으면서 동적 표의 62번에 넣었습니다. 둘째 요청은 헤더가 하나 더 많은데도 14바이트입니다. :authority가 동적 표 62번을 가리키는 한 바이트(0xbe)로 줄었기 때문입니다. 같은 사이트에 요청을 거듭할수록 헤더는 거의 번호로만 오가게 됩니다.

동적 표가 연결의 상태이기 때문에, 헤더 블록은 보낸 순서대로 풀어야 하고 중간에 다른 스트림의 블록이 끼어들면 안 됩니다. 앞에서 본 CONTINUATION의 규칙이 여기서 나옵니다. 또 압축은 보안의 위험이 될 수 있습니다. 공격자가 요청의 일부를 바꿔 가며 압축된 크기의 변화를 보면 쿠키 같은 비밀을 한 글자씩 짐작할 수 있다는 공격이 HTTP/2 이전에 알려졌으므로, HPACK은 비밀번호나 쿠키 같은 민감한 헤더를 동적 표에 절대 넣지 않는 표현(never indexed)도 제공합니다.

서버 푸시의 퇴장

HTTP/2에는 서버가 클라이언트의 다음 요청을 짐작해, 요청받기 전에 PUSH_PROMISE로 자원을 미리 보내는 서버 푸시(server push)가 있었습니다. HTML을 요청하면 그 HTML이 쓸 CSS까지 함께 보내는 식입니다. 하지만 브라우저가 이미 캐시에 가진 자원까지 보내 대역폭을 낭비하거나, 정작 중요한 응답보다 먼저 보내 오히려 느려지는 경우가 많아, Chrome은 106부터 서버 푸시를 기본으로 끄기로 했습니다. 지금은 서버가 본 응답보다 먼저 103 Early Hints로 "이 자원이 필요할 것"이라는 힌트만 보내고, 실제 요청은 캐시를 아는 브라우저가 판단하게 하는 방식을 씁니다.

TCP 위에 남은 문제

HTTP/2는 HTTP 수준의 HOL 블로킹을 없앴지만, 모든 스트림이 TCP 연결 하나 위에 있다는 점은 그대로입니다. 7장 3편에서 본 것처럼, 스트림 1의 프레임을 실은 세그먼트 하나가 사라지면 그 뒤에 도착한 스트림 3의 프레임도 TCP가 빈 곳을 메울 때까지 애플리케이션에 전해지지 않습니다. 연결을 하나로 모은 것이 손실이 많은 망에서는 오히려 HTTP/1.1의 여러 연결보다 불리해지기도 합니다.

버전나르는 방식헤더 압축남은 지연
HTTP/1.1TCP 위의 텍스트 메시지없음연결 하나에 요청 하나, TCP의 HOL
HTTP/2TCP 위의 바이너리 프레임과 스트림HPACK스트림끼리는 막지 않지만 TCP의 HOL이 남는다
HTTP/3QUIC 위의 스트림QPACK손실된 데이터가 속한 스트림만 기다린다

TLS 위의 HTTP/2는 핸드셰이크의 ALPN 확장에서 h2로 협상합니다. 브라우저는 TLS 없는 HTTP/2를 지원하지 않으며, 평문 HTTP/1.1 연결을 HTTP/2로 바꾸던 h2c 업그레이드 방식은 RFC 9113에서 없어졌습니다. 서버끼리는 처음부터 HTTP/2로 말하기로 미리 아는 경우에만 평문으로 씁니다. 실제로 쓰인 버전은 curl -s -o /dev/null -w '%{http_version}\n' https://example.com처럼 확인할 수 있습니다. TCP의 HOL 블로킹까지 없애려는 것이 다음 글의 HTTP/3입니다.

정리

  • HTTP/1.1은 연결 하나에서 요청을 하나씩 처리해, 브라우저가 연결을 여러 개 열고 개발자가 요청 수를 줄이는 우회 기법을 썼습니다.
  • HTTP/2는 메시지를 9바이트 헤더가 붙은 프레임으로 나누고, 요청과 응답마다 스트림 번호를 붙여 한 연결 위에서 섞어 보냅니다.
  • END_STREAM 플래그는 한 스트림의 끝일 뿐 연결의 끝이 아니며, 헤더 블록이 CONTINUATION으로 이어지는 동안에는 다른 스트림을 끼워 넣을 수 없습니다.
  • HTTP/2는 스트림과 연결마다 흐름 제어를 두고, 우선순위는 이제 Priority 헤더로 간단히 알립니다.
  • HPACK은 정적 표와 연결마다의 동적 표로 헤더를 번호로 가리켜 반복되는 헤더를 거의 없애며, 민감한 헤더는 표에 넣지 않습니다.
  • 서버 푸시는 효과가 적어 물러났고, TCP 연결 하나에 모든 스트림이 실려 있어 TCP의 HOL 블로킹이 남습니다.

연습 문제

1. 끝나지 않은 헤더 블록

서버가 스트림 1의 HEADERS를 END_HEADERS 플래그 없이 보낸 직후, 스트림 3의 HEADERS를 보내려 합니다. 스트림은 서로 독립적이니 괜찮을까요? 그 사이에 스트림 1의 DATA를 보내는 것은 어떤가요?

해설 보기

둘 다 허용되지 않습니다. 스트림 1의 헤더 블록이 아직 끝나지 않았으므로, 서버는 남은 블록을 스트림 1의 CONTINUATION 프레임으로 이어 보내 마지막 프레임에 END_HEADERS를 붙여야 합니다. 그 전에는 다른 스트림의 프레임은 물론 같은 스트림의 DATA도 보낼 수 없습니다. 헤더 블록은 HPACK의 동적 표를 바꾸는 순서가 있는 데이터여서, 블록 하나를 끝까지 풀어야 다음 블록을 풀 수 있기 때문입니다.

2. 큰 본문을 나누기

HTTP/2의 기본 최대 프레임 크기는 16,384바이트입니다. 40,000바이트의 응답 본문을 이 크기 안에서 가능한 한 큰 DATA 프레임으로 나누면 프레임은 몇 개이고, 프레임 헤더까지 합친 DATA 프레임의 전체 크기는 몇 바이트인가요?

해설 보기

16,384 + 16,384 = 32,768바이트를 두 프레임에 싣고, 남은 40,000 − 32,768 = 7,232바이트를 세 번째 프레임에 실으므로 프레임은 세 개입니다. 마지막 프레임에 END_STREAM을 붙입니다. 프레임마다 9바이트의 헤더가 붙으므로 27바이트가 더해져 모두 40,027바이트입니다. 받는 쪽이 SETTINGS 프레임으로 더 큰 최대 크기를 알려 오면 프레임을 더 크게 만들 수 있습니다.

3. 세 번째 요청의 번호

본문의 두 요청을 처리한 뒤 동적 표에는 무엇이 어떤 번호로 들어 있나요? 같은 연결의 세 번째 요청이 다시 :authority: www.example.com을 보낸다면, 그 헤더는 어떤 번호, 어떤 바이트로 표현되나요?

해설 보기

새 항목은 언제나 62번으로 들어가고 기존 항목은 하나씩 밀리므로, 둘째 요청 뒤에는 cache-control: no-cache가 62번, :authority: www.example.com이 63번입니다. 세 번째 요청은 :authority를 63번으로 가리키면 되므로, 최상위 비트를 켠 0x80 | 63 = 0xBF 한 바이트가 됩니다. RFC 7541 부록 C.3.3의 세 번째 요청 예에서 실제로 bf가 나오는 것이 이 때문입니다. 같은 헤더라도 동적 표의 상태에 따라 번호가 바뀌므로, 보내는 쪽과 받는 쪽이 같은 순서로 블록을 처리해야 표가 어긋나지 않습니다.

다음 글 HTTP/3와 QUIC에서는 HTTP/2의 의미를 QUIC 위에 옮긴 HTTP/3의 구조와 QPACK, 서버가 HTTP/3를 알리는 방법, 연결 이동이 실제로 어떻게 확인되는지 살펴봅니다.