HPACK 헤더 압축과 서버 푸시
HTTP/2가 반복되는 헤더를 인덱스로 치환하는 HPACK(RFC 7541)의 정적·동적 테이블과 허프만 인코딩, CRIME 공격을 막는 설계, 그리고 PUSH_PROMISE 서버 푸시가 왜 Early Hints로 대체됐는지를 해설합니다.
지난 글에서 HTTP/2가 하나의 연결을 여러 스트림으로 다중화하고, 흐름 제어와 우선순위로 그 대역폭을 나눠 쓰는 방식을 살펴봤다. 그런데 다중화로 요청을 아무리 많이 보낼 수 있게 됐어도, 요청마다 똑같은 헤더 뭉치를 반복해서 보낸다면 그 절약분은 헤더 오버헤드에 다시 까먹힌다. HTTP/2는 이 문제를 HPACK이라는 전용 헤더 압축으로 풀었고, 응답을 미리 밀어 보내는 서버 푸시라는 야심 찬 기능도 함께 들고나왔다. 이번 글은 HPACK의 작동 원리와, 서버 푸시가 왜 결국 Early Hints에 자리를 내줬는지를 다룬다.
왜 헤더 압축이 필요했나
HTTP/1.1은 본문(body)을 gzip으로 압축할 수 있었지만 헤더는 항상 평문으로 보냈다. 페이지 하나를 그리려고 수십~수백 개의 요청이 나가는 현대 웹에서, 그 요청들은 거의 같은 헤더를 매번 반복한다.
GET /style.css HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...
Accept: text/css,*/*;q=0.1
Cookie: session=ab12...; theme=dark; consent=1; _ga=GA1.2...
Referer: https://example.com/
여기서 User-Agent와 Cookie는 한 줄에 수백~수천 바이트에 달하고, 같은 페이지의 모든 후속 요청에 글자 하나 안 바뀐 채로 다시 실린다. 요청 100개면 같은 쿠키를 100번 보내는 셈이다. 본문이 작은 API 요청일수록 헤더가 전체 트래픽의 대부분을 차지하는 역전 현상이 벌어진다.
HPACK(RFC 7541)의 핵심 아이디어는 단순하다. “이미 보낸 헤더는 인덱스 번호로만 가리킨다.” 처음 한 번만 전체를 보내고, 다음부터는 정수 인덱스 한두 바이트로 치환한다.
HPACK의 세 가지 무기 — 정적 테이블, 동적 테이블, 허프만
HPACK은 세 가지 메커니즘을 조합한다.
1. 정적 테이블(Static Table) — RFC 7541에 못 박힌 61개의 공통 헤더 목록이다. 인덱스 2번은 :method: GET, 17번은 :scheme: https, 28번은 content-length 같은 식으로 자주 쓰는 헤더가 미리 번호를 받아 둔다. 송신·수신 양쪽이 같은 표를 갖고 태어나므로, :method: GET을 보내고 싶으면 그냥 인덱스 2 한 바이트만 보내면 된다.
2. 동적 테이블(Dynamic Table) — 정적 테이블에 없는 헤더(긴 쿠키, 커스텀 헤더 등)는 연결이 살아 있는 동안 동적 테이블에 차곡차곡 추가된다. 처음 등장한 Cookie: session=ab12...는 전체를 보내면서 인덱스 62번에 등록되고, 두 번째 요청부터는 그 인덱스 번호만 보내면 끝이다. 테이블은 연결마다 독립적이고, 크기 상한(SETTINGS_HEADER_TABLE_SIZE)을 넘으면 오래된 항목부터 밀려난다(FIFO).
3. 허프만 인코딩(Huffman) — 인덱스로 못 줄이는, 진짜로 처음 보내야 하는 문자열 값은 HPACK 전용 정적 허프만 코드로 한 번 더 압축한다. ASCII에서 자주 쓰는 문자에 짧은 비트열을 배정한 고정 코드표라서 별도 협상이 필요 없다.
각 헤더 필드는 다음 네 가지 형태 중 하나로 표현된다.
[1] 완전 인덱스 → 이름·값 모두 테이블에 있음 (예: :method GET = 인덱스 2)
[2] 이름만 인덱스+값 → 이름은 테이블, 값은 새로 (허프만 인코딩) + 동적 테이블 등록
[3] 등록 없는 리터럴 → 동적 테이블에 추가하지 않고 그대로 (민감 헤더용)
[4] 동적 테이블 크기 → 테이블 크기 변경 지시
CRIME 공격을 피해 간 설계
여기서 “헤더를 통째로 gzip 하면 더 간단하지 않나?”라는 의문이 든다. 실제로 SPDY 초기에는 헤더를 DEFLATE로 압축했다. 그런데 이 방식은 CRIME 공격에 뚫렸다.
압축의 원리는 “반복된 바이트열을 짧게 줄이는 것”이다. 공격자가 요청 본문에 추측 문자열을 끼워 넣고, 그 추측이 쿠키 같은 비밀 값과 겹칠 때 압축 결과 크기가 줄어드는 것을 관찰하면, 응답/요청 크기 변화만으로 비밀 값을 한 글자씩 알아낼 수 있다. 압축률이 곧 정보 누출 채널이 되는 것이다.
HPACK은 이 공격을 설계 단계에서 차단했다.
- 임의의 가변 길이 문자열 매칭(DEFLATE의 백레퍼런스)을 쓰지 않고, 헤더 단위의 인덱싱과 고정 허프만 코드표만 사용한다. 공격자가 주입한 문자열이 비밀 값과 우연히 부분 일치해도 압축 길이가 그에 비례해 줄어들지 않는다.
- 민감한 헤더(쿠키, 인증 토큰 등)는 위 표의
[3] 등록 없는 리터럴(Never Indexed)형태로 보내, 동적 테이블에 아예 올리지 않도록 명시할 수 있다. 중간 프록시가 이 값을 학습·재사용하지 못하게 막는 안전장치다.
즉 HPACK은 “압축은 하되, 압축률이 비밀 값에 의존하지 않게” 만든 보안 친화적 압축이다.
서버 푸시 — 묻기도 전에 응답하기
HTTP/2의 또 다른 간판 기능이 서버 푸시(Server Push)였다. 브라우저가 index.html을 요청하면, 서버는 그 HTML이 분명히 style.css와 app.js를 필요로 한다는 걸 안다. 그렇다면 클라이언트가 HTML을 파싱해서 그 둘을 다시 요청하길 기다릴 게 아니라, HTML과 함께 CSS·JS를 미리 밀어 보내자는 발상이다. 한 번의 왕복을 아낄 수 있다.
동작은 PUSH_PROMISE 프레임으로 이뤄진다. 서버는 “당신이 곧 요청할 /style.css를, 내가 스트림 4번으로 먼저 보내겠다”고 약속하는 프레임을 먼저 보내고, 이어서 그 리소스의 실제 데이터를 푸시한다. 클라이언트는 나중에 HTML을 파싱하다 style.css가 필요해지면, 네트워크로 새 요청을 내보내는 대신 이미 받아 둔 푸시 스트림을 쓴다.
:method: GET # 클라이언트 → 서버
:path: /index.html
# 서버 → 클라이언트
PUSH_PROMISE (stream 2 → promised stream 4)
:method: GET
:path: /style.css
HEADERS + DATA (stream 1) # index.html 본문
HEADERS + DATA (stream 4) # style.css 본문 (요청 없이 푸시)
좋은 아이디어가 왜 폐기됐나
이론은 매끄러웠지만 실전에서 서버 푸시는 득보다 실이 많았다.
- 캐시 무효 푸시 낭비 — 서버는 클라이언트가
style.css를 이미 캐시에 갖고 있는지 알 수 없다. 재방문자에게도 무조건 밀어 보내면, 이미 가진 파일을 또 받게 해 대역폭을 낭비한다. 푸시를 멈추라는RST_STREAM이 도착할 때쯤이면 이미 데이터가 상당량 전송된 뒤다. - 우선순위 충돌 — 푸시된 리소스가 정작 더 급한 리소스의 대역폭을 빼앗아, 페이지가 오히려 늦게 그려지는 경우가 잦았다.
- 구현·운영 복잡도 — 무엇을 언제 푸시할지 결정하는 로직이 까다롭고, 잘못 쓰면 성능이 나빠져 실제 채택률이 매우 낮았다.
결국 크롬은 2022년에 HTTP/2 서버 푸시를 기본 비활성화하고 deprecated 처리했다. 그 자리를 대신한 것이 Early Hints(103)다. 서버는 본응답을 준비하는 동안, 데이터를 떠밀지 않고 103 Early Hints 임시 응답으로 “이런 리소스를 미리 받아 두라”는 힌트만 보낸다.
HTTP/1.1 103 Early Hints
Link: </style.css>; rel=preload; as=style
Link: </app.js>; rel=preload; as=script
HTTP/1.1 200 OK
Content-Type: text/html
...
차이가 핵심이다. 서버 푸시는 서버가 일방적으로 데이터를 밀어붙였다. Early Hints는 힌트만 주고, 무엇을 실제로 받을지는 클라이언트가 캐시 상태를 보고 스스로 결정한다. 캐시 무효 푸시 낭비 문제가 원천적으로 사라진다. “미리 보내기”의 목표는 살리되, 결정권을 캐시 사정을 아는 클라이언트에게 돌려준 것이다.
정리
- HPACK은 정적 테이블(61개 공통 헤더), 연결별 동적 테이블, 허프만 인코딩을 조합해 반복되는 헤더를 인덱스로 치환한다. 매 요청 반복되던 거대한 쿠키·UA가 한두 바이트로 줄어든다.
- HPACK은 DEFLATE 기반 압축이 당한 CRIME 공격을 설계로 차단했다. 헤더 단위 인덱싱과 고정 허프만 코드, 그리고 민감 헤더의 Never Indexed 처리가 그 장치다.
- 서버 푸시는
PUSH_PROMISE로 요청 전에 리소스를 선제 전송하는 기능이었지만, 캐시 무효 푸시 낭비와 우선순위 충돌로 크롬이 2022년 비활성화·폐기했다. - 그 역할은 Early Hints(103)가 이어받았다. 데이터를 떠미는 대신
Link: rel=preload힌트만 주고, 최종 결정은 클라이언트에게 맡긴다.
다음 글에서는 HTTP/2가 끝내 풀지 못한 TCP 단의 한계를 짚고, 그것을 UDP 위에서 다시 설계한 HTTP/3와 QUIC로 이어진다.
지난 글: HTTP/2 흐름 제어와 우선순위
다음 글: HTTP/3와 QUIC — UDP 위에서 다시 짠 전송
읽어주셔서 감사합니다. 😊