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

HTTP/2의 핵심인 바이너리 프레이밍 계층을 해부합니다. 9바이트 프레임 헤더 구조, 주요 프레임 타입, 스트림 생명주기 상태 머신, 스트림 ID 규칙, 그리고 멀티플렉싱이 프레임 인터리빙으로 어떻게 구현되는지 정리합니다.

· 13 min read · PALDYN Team

지난 글에서 하나의 TCP 연결로 여러 요청·응답을 동시에 주고받는 멀티플렉싱을 살펴봤다. 그런데 “동시에 주고받는다”는 말은 추상적이다. 실제로 한 연결 위에서 어떻게 여러 메시지가 뒤섞이지 않고 흘러갈까. 그 답이 바로 바이너리 프레이밍 계층(binary framing layer)이다. HTTP/1.1이 텍스트 한 줄 한 줄로 메시지를 표현했다면, HTTP/2는 모든 통신을 작고 독립적인 프레임(frame)으로 쪼개고, 각 프레임을 스트림(stream)이라는 가상 채널에 태운다. 이번 글은 이 프레임과 스트림의 구조를 바닥까지 들여다본다.

텍스트에서 바이너리로

HTTP/1.1 메시지는 사람이 읽을 수 있는 텍스트다. GET / HTTP/1.1\r\nHost: ... 같은 형식이라 디버깅은 편하지만, 파서 입장에서는 줄바꿈을 찾고 공백을 세고 경계를 추측해야 한다. 경계가 모호하니 한 연결에 여러 메시지를 끼워 넣기도 어렵다.

HTTP/2는 이 계층을 통째로 바꿨다. 모든 메시지가 길이가 명시된 바이너리 프레임으로 인코딩된다. 길이를 미리 알기 때문에 파서는 “이 프레임은 여기서 시작해 여기서 끝난다”를 즉시 안다. 경계가 명확하니 여러 스트림의 프레임을 한 연결에 자유롭게 섞을 수 있다.

HTTP/2 통신의 계층 구조

  요청/응답 (메시지)


  스트림 (양방향 가상 채널, ID로 식별)


  프레임 (통신의 최소 단위, 바이너리)


  TCP 연결 (단 하나)

핵심 용어부터 정리하자.

  • 프레임(frame): HTTP/2 통신의 가장 작은 단위. 9바이트 헤더와 가변 길이 페이로드로 구성된다.
  • 메시지(message): 하나의 요청 또는 응답. 하나 이상의 프레임으로 이루어진다.
  • 스트림(stream): 연결 안의 양방향 바이트 흐름. 하나 이상의 메시지를 나르며, 고유한 정수 ID로 식별된다.

9바이트 프레임 헤더

모든 프레임은 정확히 9바이트(72비트)의 고정 헤더로 시작한다. RFC 9113이 정의하는 레이아웃은 다음과 같다.

HTTP/2 프레임 헤더 9바이트 레이아웃

각 필드의 의미는 이렇다.

  • Length (24비트): 페이로드의 바이트 길이. 기본 최대값은 16,384바이트(16KB)이며 SETTINGS_MAX_FRAME_SIZE로 협상해 키울 수 있다.
  • Type (8비트): 프레임 종류. DATA, HEADERS 등 아래에서 다룰 타입을 가리킨다.
  • Flags (8비트): 타입별 부가 플래그. 예를 들어 END_STREAM, END_HEADERS가 여기 들어간다.
  • R (1비트): 예약 비트. 항상 0으로 설정하며 무시한다.
  • Stream Identifier (31비트): 이 프레임이 속한 스트림 ID. 0은 연결 전체를 가리키는 특별한 값이다.

9바이트 헤더 뒤에 Length 만큼의 페이로드가 따라온다. 받는 쪽은 항상 헤더 9바이트를 먼저 읽어 Length를 확인한 뒤, 정확히 그 길이만큼을 페이로드로 읽으면 된다. 경계 추측이 사라지는 지점이다.

주요 프레임 타입

HTTP/2는 10가지 프레임 타입을 정의한다. 역할에 따라 묶어서 보자.

타입            ID    역할
─────────────────────────────────────────────
DATA           0x0   요청·응답 본문 데이터
HEADERS        0x1   헤더(HPACK 압축), 스트림 시작
PRIORITY       0x2   스트림 우선순위(현재 비권장)
RST_STREAM     0x3   특정 스트림 즉시 종료
SETTINGS       0x4   연결 파라미터 협상
PUSH_PROMISE   0x5   서버 푸시 예고
PING           0x6   왕복 시간 측정·연결 생존 확인
GOAWAY         0x7   연결 전체 종료 통보
WINDOW_UPDATE  0x8   흐름 제어 윈도우 갱신
CONTINUATION   0x9   HEADERS 블록 이어붙이기

자주 쓰이는 것들을 조금 더 풀면 다음과 같다.

  • HEADERS / DATA: 실질적인 메시지를 나르는 한 쌍. HEADERS가 헤더 블록을 싣고 스트림을 열며, DATA가 본문을 나른다. 두 타입 모두 END_STREAM 플래그로 메시지의 끝을 표시할 수 있다.
  • SETTINGS: 연결 시작 시 양쪽이 교환하는 설정. 최대 동시 스트림 수, 초기 윈도우 크기, 최대 프레임 크기 등을 담는다. 스트림 ID는 항상 0이다.
  • RST_STREAM: 특정 스트림 하나만 즉시 끊는다. HTTP/1.1에서 연결 전체를 끊어야 했던 취소 동작을, HTTP/2는 스트림 단위로 처리한다.
  • PING / GOAWAY: PING은 연결이 살아 있는지와 RTT를 확인하고, GOAWAY는 연결을 정상 종료하며 어디까지 처리했는지 마지막 스트림 ID를 알린다. 둘 다 스트림 ID 0을 쓴다.
  • WINDOW_UPDATE: 흐름 제어용. 다음 글에서 자세히 다룬다.
  • CONTINUATION: 헤더가 너무 커서 HEADERS 한 프레임에 다 못 담길 때 뒤이어 붙인다.
  • PUSH_PROMISE: 서버가 클라이언트 요청 없이 리소스를 미리 보내겠다고 예고할 때 쓴다.

스트림의 생명주기

스트림은 단순한 ID 꼬리표가 아니라 상태 머신이다. 각 스트림은 idle에서 시작해 정해진 전이를 거쳐 closed로 끝난다. 이 상태를 양쪽이 똑같이 추적하기 때문에, 어떤 프레임을 보내고 받을 수 있는지가 명확해진다.

HTTP/2 스트림 생명주기 상태 머신

상태별 의미는 다음과 같다.

  • idle: 아직 사용되지 않은 초기 상태. 모든 스트림 ID는 처음에 idle이다.
  • open: HEADERS 프레임을 보내거나 받으면 열린다. 양방향으로 자유롭게 DATA를 주고받을 수 있다.
  • half-closed: 한쪽이 END_STREAM 플래그를 보내 자기 방향을 닫은 상태. 보낸 쪽은 더 이상 데이터를 보내지 않지만, 반대 방향으로는 여전히 받을 수 있다. 송신자 기준 half-closed (local), 수신자 기준 half-closed (remote)로 나뉜다.
  • closed: 양쪽 방향이 모두 닫힌 종료 상태. RST_STREAM을 받으면 어느 상태에서든 곧장 closed로 간다.

한 번 closed가 된 스트림 ID는 재사용되지 않는다. 그래서 요청을 보낼 때마다 새 ID가 필요하다.

스트림 ID 규칙

스트림 ID는 아무렇게나 정해지지 않는다. 클라이언트와 서버가 동시에 스트림을 열 때 ID가 충돌하면 안 되기 때문이다. 규칙은 단순하다.

  • 클라이언트가 시작하는 스트림은 홀수 ID (1, 3, 5, …)
  • 서버가 시작하는 스트림은 짝수 ID (2, 4, 6, …) — 주로 PUSH_PROMISE
  • ID 0은 연결 전체 제어용. SETTINGS, PING, 연결 레벨 WINDOW_UPDATE 등에 쓴다.
  • ID는 단조 증가해야 하며, 한 번 쓴 번호보다 작은 번호로 새 스트림을 열 수 없다.

홀짝으로 영역을 나눠 두니 양쪽이 서로의 ID 공간을 침범하지 않고 독립적으로 스트림을 열 수 있다. 클라이언트의 첫 요청은 항상 스트림 1번에서 시작한다.

# nghttp으로 HTTP/2 프레임 흐름 관찰
# -v: verbose, 주고받는 프레임을 타입·스트림ID와 함께 출력
nghttp -nv https://nghttp2.org/

# 출력 예 (일부)
#  send SETTINGS frame <stream_id=0>
#  send HEADERS frame <stream_id=1>   ← 클라이언트, 홀수
#  recv SETTINGS frame <stream_id=0>
#  recv HEADERS frame <stream_id=1>
#  recv DATA frame <stream_id=1>

멀티플렉싱은 프레임 인터리빙이다

이제 지난 글의 멀티플렉싱이 어떻게 실제로 동작하는지 설명할 수 있다. 비결은 단순하다. 서로 다른 스트림의 프레임을 한 연결에 번갈아(interleave) 실어 보내는 것이다.

각 프레임의 헤더에 스트림 ID가 박혀 있으므로, 받는 쪽은 도착한 프레임을 ID별로 분류해 올바른 메시지로 재조립하면 된다. 스트림 1의 DATA, 스트림 3의 HEADERS, 스트림 1의 DATA가 섞여 도착해도 ID만 보면 누구의 조각인지 알 수 있다.

하나의 TCP 연결을 흐르는 프레임들 (시간 →)

[HEADERS s1][HEADERS s3][DATA s1][DATA s3][DATA s1][DATA s3]
     └─ 요청 A ─┘  └─ 요청 B ─┘    A    B    A    B

수신측 재조립:
  stream 1 (A) = HEADERS s1 + DATA s1 + DATA s1 ...
  stream 3 (B) = HEADERS s3 + DATA s3 + DATA s3 ...

이 인터리빙 덕분에 큰 응답 하나가 작은 응답들을 막지 않는다. 응답 A의 DATA 프레임 사이사이에 응답 B의 프레임을 끼워 넣을 수 있으니, 두 응답이 진짜로 동시에 진행되는 효과가 난다. HTTP/1.1의 HOL 블로킹(앞선 응답이 끝나야 다음 응답이 시작되는 문제)이 애플리케이션 레벨에서 사라지는 이유가 바로 이것이다.

물론 여기엔 새로운 고민이 따라온다. 한 스트림이 인터리빙을 독점해 다른 스트림을 굶기면 어떻게 되나. 또 빠른 송신자가 느린 수신자의 버퍼를 넘치게 하면. 이를 다루는 메커니즘이 흐름 제어와 우선순위이고, 정확히 다음 글의 주제다.

정리

  • HTTP/2는 텍스트 메시지를 버리고 바이너리 프레이밍 계층을 도입했다. 모든 통신은 길이가 명시된 프레임으로 인코딩된다.
  • 프레임은 9바이트 헤더(Length 24 / Type 8 / Flags 8 / R 1 / Stream ID 31)와 페이로드로 구성된다.
  • 10가지 프레임 타입이 데이터 전송(DATA/HEADERS), 연결 제어(SETTINGS/PING/GOAWAY), 스트림 관리(RST_STREAM/WINDOW_UPDATE) 등의 역할을 나눠 맡는다.
  • 스트림은 idle → open → half-closed → closed의 상태 머신이며, 클라이언트는 홀수·서버는 짝수 ID를, ID 0은 연결 제어를 맡는다.
  • 멀티플렉싱의 실체는 여러 스트림의 프레임을 한 연결에 인터리빙하는 것이다. 스트림 ID로 재조립하기에 메시지가 뒤섞이지 않는다.

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

다음 글: HTTP/2 흐름 제어와 우선순위


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