콘텐츠로 이동

HTTP/2 & HTTP/3

HTTP/2 & HTTP/3: 요청 병렬성의 병목을 어디서 풀 것인가

섹션 제목: “HTTP/2 & HTTP/3: 요청 병렬성의 병목을 어디서 풀 것인가”

HTTP/2는 HTTP(HyperText Transfer Protocol)의 요청과 응답을 바이너리 프레임으로 쪼개 하나의 TCP(Transmission Control Protocol) 연결 안에서 여러 스트림을 동시에 처리하게 만든 버전이다. HTTP/3는 같은 HTTP 의미론을 유지하되 전송 계층을 TCP에서 UDP(User Datagram Protocol) 기반 QUIC으로 바꾸어, 한 스트림의 패킷 손실이 다른 스트림까지 멈추는 문제를 줄인 버전이다. QUIC은 현재 표준에서 약어를 풀어 쓰기보다 고유한 프로토콜 이름으로 사용한다.

이 문서의 핵심 질문은 “요청이 많아질 때 병렬성을 커넥션 수로 해결할 것인가, 스트림으로 해결할 것인가, 아니면 전송 계층 자체를 바꿀 것인가”다.

필수 전제 지식: L1의 http-basics.mdx에서 HTTP 요청/응답 구조와 상태 코드를, L2의 tcp-udp-internals.mdx에서 TCP 3-way handshake와 UDP의 특성을, L7의 tcp-flow-congestion.mdx에서 RTT·손실·혼잡 제어 감각을, L7의 tls-https.mdx에서 TLS(Transport Layer Security) 1.3과 0-RTT의 보안 경계를 다뤘다.

선택 참고: websocket-grpc-basics.mdx는 WebSocket·gRPC와 HTTP/2 stream의 용도를 비교할 때, dns-basics.mdx는 domain sharding의 DNS 조회 비용을 더 깊게 확인할 때만 돌아가면 된다. 두 문서는 HTTP/2·3 첫 회독의 필수 선수지식이 아니며, gRPC 세부도 선택 부록 A에서 필요한 만큼 설명한다.

첫 회독에서는 설정을 따라 치기보다 병목이 이동하는 순서를 잡는다. 아래 여섯 단계만 이어 읽으면 본문 결론에 도달한다.

3절 HTTP/1.1 한계
→ 4절 binary framing과 stream multiplexing
→ 5절 HPACK
→ 7절 TCP HOL, QUIC, QPACK
→ 8.5절 선택 기준
→ 9절 실패 신호

4-3절의 우선순위, 6절의 Server Push, 7-3~7-4절의 0-RTT·패킷 구조는 핵심 메커니즘의 경계를 넓히는 심화다. 처음에는 이름과 결론만 확인하고 넘어가도 된다. gRPC 헤더 구조, ALB/Nginx 설정, Chrome·curl·nghttp 관찰 절차는 본문 결론 뒤의 선택 부록 A·B로 이동했다. 부록을 펼치지 않아도 HTTP/1.1의 한계에서 HTTP/3의 선택과 실패 신호까지 설명할 수 있어야 한다.


2. 왜 중요한가: 버전 이름보다 병목 위치를 읽어야 한다

섹션 제목: “2. 왜 중요한가: 버전 이름보다 병목 위치를 읽어야 한다”
  • 웹 성능의 핵심 변수: HTTP 버전은 요청 병렬성, 핸드셰이크 RTT, 헤더 중복, 패킷 손실 회복 방식을 바꾼다. 따라서 “Nginx에서 http2 켜기”가 효과적인지 보려면 단순 평균 응답시간보다 Protocol, Waiting for server response, 재전송률, 연결 수를 함께 봐야 한다.
  • gRPC의 기반: gRPC는 RPC(Remote Procedure Call)를 Protobuf(Protocol Buffers, 스키마 기반 바이너리 직렬화 규약)와 HTTP/2 스트림 위에 올린 프레임워크다. unary는 요청 메시지 하나에 응답 메시지 하나를 돌려주는 호출이고, streaming은 한 호출의 stream에서 메시지를 여러 번 주고받는 방식이다. 수신자가 느리면 flow-control window가 줄어 송신 속도가 제한되는데, 이 압력을 backpressure라고 한다. 응답 본문 뒤에 오는 trailers는 호출 종료 메타데이터를 담는 마지막 헤더 블록이며, 그 안의 **grpc-status**가 HTTP 상태 코드와 별개인 RPC 성공·실패 결과를 나타낸다. 따라서 HTTP/2 200이어도 grpc-status가 0이 아니면 RPC는 실패다.
  • 인프라 설계 직결: ALB(Application Load Balancer)의 클라이언트 구간 HTTP/2, 백엔드 target group의 HTTP1/HTTP2/GRPC, CloudFront의 HTTP/3 정책, Nginx의 http2 onlisten 443 quic은 모두 서로 다른 경계의 설정이다.
  • 디버깅 능력: DevTools Network 탭의 Protocol 컬럼이 h2라고 해서 모든 요청이 효율적으로 병렬화된 것은 아니다. Waterfall 모양, ALPN(Application-Layer Protocol Negotiation) 협상, Alt-Svc(Alternative Services) 헤더, UDP 443 경로까지 함께 봐야 한다. Alt-Svc는 현재 origin이 “다음에는 h3 같은 다른 전송 경로도 시도할 수 있다”고 클라이언트에 광고하는 응답 헤더다.

2.5 선행 기술의 한계에서 HTTP/2·3가 나온 이유

섹션 제목: “2.5 선행 기술의 한계에서 HTTP/2·3가 나온 이유”

HTTP/1.x의 병목은 “HTTP 문법이 텍스트라서 느리다”보다 동시성을 커넥션 수로 해결해야 한다는 구조에 있었다. RFC 9113은 HTTP/1.0이 TCP 커넥션 하나에 outstanding request를 1개만 둘 수 있었고, HTTP/1.1 pipelining도 application-layer HOL(Head-of-Line) Blocking을 완전히 풀지 못해 클라이언트가 여러 TCP 커넥션을 열었다고 설명한다. 여기서 HOL Blocking은 줄 맨 앞 작업이 막히면 뒤 작업이 준비되어 있어도 진행하지 못하는 현상이다.

HTTP/2는 이 한계를 하나의 TCP 커넥션 안에 여러 stream을 섞어 보내는 binary framing으로 풀었다. stream은 하나의 요청-응답 쌍을 담는 논리적 흐름이고, frame은 그 흐름을 네트워크로 보낼 수 있게 잘라낸 작은 단위다. 같은 HTML이 style.css, app.js, hero.jpg를 동시에 요구할 때 HTTP/1.1은 보통 브라우저의 도메인별 연결 한도에 걸리지만, HTTP/2는 각 요청을 별도 stream으로 나누고 HEADERS/DATA frame에 stream ID를 붙여 순서 독립적으로 재조립한다.

하지만 HTTP/2도 TCP 위에서 동작하므로 패킷 하나가 사라지면 TCP byte stream 전체가 재전송을 기다리는 문제는 남는다. 이것이 TCP-level HOL Blocking이다. RFC 9113도 TCP HOL Blocking은 HTTP/2가 해결하지 않는다고 명시한다. HTTP/3가 QUIC으로 이동한 이유가 여기에 있다. RFC 9114의 HTTP/3는 request-response pair를 QUIC stream 하나에 매핑하고, 한 stream의 손실이 다른 stream 진행을 막지 않도록 설계한다.

계보를 한 줄로 잡으면 다음과 같다.

HTTP/1.1: 커넥션 여러 개로 application HOL을 우회
HTTP/2: 한 TCP 연결 안에서 stream을 다중화해 application HOL을 제거
HTTP/3: QUIC stream으로 전송 계층 손실의 파급 범위를 줄임

3. 등장한 문제: HTTP/1.1은 왜 커넥션을 많이 열었나

섹션 제목: “3. 등장한 문제: HTTP/1.1은 왜 커넥션을 많이 열었나”

퀴즈

HTTP/2가 해결한 HOL Blocking과 HTTP/3가 줄이려는 HOL Blocking은 같은 문제인가?

힌트: 막히는 위치가 HTTP 요청 순서인지, TCP 패킷 재전송인지 구분한다.

정답 보기

아니다. HTTP/2는 HTTP/1.1의 요청-응답 순서 때문에 생기던 application-level HOL을 stream multiplexing으로 해결한다. HTTP/3는 TCP byte stream에서 패킷 손실 하나가 모든 HTTP/2 stream을 막는 TCP-level HOL을 QUIC stream으로 줄인다.

3-1. 커넥션 하나에 요청 하나: HOL Blocking

섹션 제목: “3-1. 커넥션 하나에 요청 하나: HOL Blocking”

HTTP/1.1은 기본적으로 하나의 TCP 커넥션에서 요청-응답이 순차적으로 처리된다. 브라우저가 HTML을 받은 후 CSS, JS, 이미지 등 수십 개의 리소스를 요청해야 할 때:

TCP 커넥션 1:
→ GET /style.css ← 기다림...
← 응답 ← 이제 다음 요청 가능
→ GET /app.js ← 또 기다림...
← 응답

앞의 요청이 느리면 뒤의 요청 전부가 막힌다. 이것이 HTTP/1.1의 Head-of-Line (HOL) Blocking이다.

HTTP/1.1 스펙에는 Pipelining이라는 기능이 있다. 응답을 기다리지 않고 여러 요청을 미리 보내는 방식이다.

클라이언트 → GET /a, GET /b, GET /c (연속 전송)
서버 ← /a 응답, /b 응답, /c 응답 (순서대로 응답)

문제는 서버가 반드시 요청 순서대로 응답을 보내야 한다는 점이다. /b가 빠르게 처리되더라도 /a 응답이 완료될 때까지 기다려야 한다. 즉 pipelining은 요청을 미리 보낼 뿐, 응답 순서가 고정된 application-level HOL을 제거하지 못한다. 이 제약 때문에 대부분의 브라우저가 Pipelining을 기본 비활성화했다.

3-3. 커넥션 수 제한과 도메인 샤딩

섹션 제목: “3-3. 커넥션 수 제한과 도메인 샤딩”

브라우저는 HOL Blocking을 우회하기 위해 도메인당 6개의 병렬 TCP 커넥션을 열었다.

브라우저 ──커넥션1──→ GET /style.css
──커넥션2──→ GET /app.js
──커넥션3──→ GET /logo.png
──커넥션4──→ GET /font.woff2
──커넥션5──→ GET /analytics.js
──커넥션6──→ GET /hero.jpg
← 7번째 요청은 커넥션이 생길 때까지 대기

이 문제를 우회하기 위해 static1.example.com, static2.example.com처럼 여러 도메인으로 리소스를 분산하는 도메인 샤딩(Domain Sharding) 기법이 유행했다. 하지만:

  • DNS 조회 비용이 늘어난다
  • TLS 핸드셰이크 비용이 도메인마다 발생한다
  • HTTP/2에서는 오히려 역효과가 난다 (뒤에서 설명)

HTTP/1.1 헤더는 텍스트이고 매 요청마다 전체를 다시 전송한다.

GET /api/users HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...
Accept: application/json
Accept-Encoding: gzip, deflate, br
Accept-Language: ko-KR,ko;q=0.9
Cookie: session=abc123; _ga=GA1.2.xxx

이 헤더들이 동일한 도메인에 수십 번 요청할 때마다 반복된다. Authorization, User-Agent, Cookie는 거의 변하지 않는데도 매번 전송한다. 요청당 수백 바이트에서 수 킬로바이트의 낭비가 발생한다.

작은 숫자로 보면 체감이 쉽다. API 요청 40개가 한 화면에서 발생하고, 각 요청의 공통 헤더가 1.2KB라면 헤더만 약 48KB를 반복 전송한다. 응답 바디가 2KB 안팎인 작은 JSON API에서는 payload보다 헤더가 더 큰 구간도 생긴다. HTTP/2가 헤더 압축을 별도 핵심 기능으로 넣은 이유가 여기에 있다.


4. 핵심 철학: 커넥션을 늘리지 말고 메시지를 쪼개 섞는다

섹션 제목: “4. 핵심 철학: 커넥션을 늘리지 말고 메시지를 쪼개 섞는다”

HTTP/2는 2015년 RFC 7540으로 표준화됐다. 기반은 Google의 SPDY 프로토콜이다.

HTTP/2의 철학은 세 가지다.

  1. HTTP 의미론은 유지한다. 메서드, 경로, 상태 코드, 헤더의 의미는 HTTP/1.1과 같다.
  2. 전송 형식은 바꾼다. 사람이 읽는 텍스트 라인을 기계가 처리하기 쉬운 binary frame으로 바꾼다.
  3. 병렬성의 단위를 커넥션에서 stream으로 내린다. 커넥션 하나를 오래 유지하고, 그 안에서 많은 요청을 섞어 보낸다.

따라서 HTTP/2를 “빠른 HTTP”라고 외우면 부족하다. 더 정확한 모델은 “HTTP 메시지를 frame으로 잘라 stream ID를 붙이고, 단일 TCP 연결 안에서 interleaving하는 프로토콜”이다.

HTTP/1.1이 텍스트 기반이라면 HTTP/2는 바이너리 프레임(Binary Frame) 기반이다. 프레임은 HTTP/2 커넥션에서 주고받는 최소 단위다. 각 프레임은 길이, 타입, 플래그, stream ID, payload를 가진다.

HTTP/1.1 요청 (텍스트):
GET /api/data HTTP/1.1\r\n
Host: example.com\r\n
\r\n
HTTP/2 요청 (바이너리 프레임):
┌──────────────────────────────────────────┐
│ Length (3 bytes) │ Type (1 byte) │
│ Flags (1 byte) │ Stream ID (4 bytes) │
│ Payload... │
└──────────────────────────────────────────┘

프레임 타입:

  • HEADERS: HTTP 헤더를 전달 (요청/응답 시작)
  • DATA: 바디 데이터를 전달
  • SETTINGS: 커넥션 설정 교환
  • WINDOW_UPDATE: 흐름 제어
  • PING: 커넥션 유지 확인
  • RST_STREAM: 스트림 강제 종료
  • GOAWAY: 커넥션 종료 알림

바이너리로 파싱하면 텍스트 파싱에 비해 모호성이 줄고 처리 속도가 안정적이다. 더 중요한 효과는 frame마다 stream ID가 붙기 때문에, 서로 다른 요청의 조각을 하나의 TCP 연결 위에 섞어 보낼 수 있다는 점이다.

HTTP/2의 핵심은 하나의 TCP 커넥션 안에서 여러 요청-응답을 동시에 처리할 수 있다는 점이다. 이것이 **멀티플렉싱(Multiplexing)**이다.

스트림(Stream): 하나의 TCP 커넥션 내에서 독립적인 양방향 바이트 흐름. 각 스트림은 고유한 ID를 가진다.

TCP 커넥션 1개
├── 스트림 1: GET /style.css (HEADERS 프레임 → DATA 프레임)
├── 스트림 3: GET /app.js (HEADERS 프레임 → DATA 프레임)
├── 스트림 5: GET /logo.png (HEADERS 프레임 → DATA 프레임)
└── 스트림 7: GET /font.woff2 (HEADERS 프레임 → DATA 프레임)
모두 동시에 진행 중, 순서 보장 없음

클라이언트는 홀수 스트림 ID(1, 3, 5…), 서버 푸시는 짝수 스트림 ID(2, 4, 6…)를 사용한다.

시간 →
TCP 커넥션:
[S1:HEADERS][S3:HEADERS][S5:HEADERS][S1:DATA...][S3:DATA...][S5:DATA...]
↑ ↑ ↑
CSS 요청 JS 요청 이미지 요청 → 섞여서 전송되지만 스트림 ID로 구분

결과: HTTP/1.1에서 6개의 TCP 커넥션이 필요했던 작업을 HTTP/2는 1개의 TCP 커넥션으로 처리한다. TCP 핸드셰이크 비용이 한 번으로 줄어든다.

worked example으로 보면 차이가 더 분명하다.

상황: HTML 1개가 CSS 2개, JS 3개, 이미지 4개를 추가 요청한다.
HTTP/1.1:
- 브라우저가 같은 origin에 보통 6개 안팎의 연결을 연다.
- 7번째 이후 요청은 빈 연결이 생길 때까지 기다린다.
- 각 연결마다 TCP slow start와 TLS 상태가 따로 관리된다.
HTTP/2:
- origin당 연결 1개가 오래 유지된다.
- 9개 리소스가 각 stream으로 분리된다.
- 큰 이미지 DATA frame 사이에 CSS/JS DATA frame을 끼워 보낼 수 있다.

이 구조에서는 “파일 수를 무조건 줄인다”는 HTTP/1.1 시대의 직관이 약해진다. 다만 파일을 아주 잘게 쪼개도 parsing, scheduling, compression dictionary, cache invalidation 비용은 남는다. HTTP/2는 파일 수 비용을 없앤 것이 아니라, 커넥션 대기 비용을 크게 낮춘 것이다.

4-2-1. 흐름 제어: stream은 독립적이어도 수신자는 유한하다

섹션 제목: “4-2-1. 흐름 제어: stream은 독립적이어도 수신자는 유한하다”

HTTP/2 multiplexing은 “무제한 병렬 전송”이 아니다. 수신자는 처리 가능한 만큼만 받아야 하므로 HTTP/2에는 flow control(흐름 제어)이 있다. flow control은 송신자가 수신자 버퍼를 넘치게 하지 않도록 WINDOW_UPDATE frame으로 전송 가능량을 조절하는 메커니즘이다.

connection window: 이 TCP 연결 전체에서 더 받을 수 있는 바이트 수
stream window: 특정 stream에서 더 받을 수 있는 바이트 수
둘 중 하나가 0에 가까워지면 송신자는 DATA frame 전송을 멈춘다.

worked example: 느린 클라이언트 하나가 gRPC stream을 늦게 읽는 경우

connection window = 65,535 bytes
stream A: 서버가 32KB 메시지를 빠르게 전송
stream B: 서버가 32KB 메시지를 빠르게 전송
stream C: 클라이언트가 처리하지 못해 WINDOW_UPDATE를 늦게 보냄

stream A와 B는 독립적으로 보이지만, connection window는 같은 TCP 연결 전체에서 공유된다. C가 받은 데이터를 애플리케이션이 늦게 소비하면 C의 stream window가 줄고, 동시에 connection window도 회복이 늦어진다. 이 상태에서 서버가 A와 B로도 계속 DATA frame을 보내려 하면 “HTTP/2인데 왜 한 연결 전체가 답답하지?”라는 증상이 생긴다.

그래서 gRPC streaming이나 SSE(Server-Sent Events)처럼 긴 stream이 섞인 서비스에서는 다음을 함께 본다.

관찰 신호의미먼저 확인할 것
특정 stream만 오래 열려 있음느린 소비자 또는 긴 응답클라이언트 처리 속도, backpressure
연결 전체 throughput이 낮음connection window 회복 지연 가능stream별 소비 속도와 window update
서버 CPU는 낮고 응답만 밀림애플리케이션 병목보다 전송/소비 병목프록시 buffering, client read 속도

여기서 backpressure는 느린 수신자나 느린 처리 단계가 앞단 송신 속도를 늦추게 만드는 압력이다. 네트워크에서는 window가 줄어 DATA frame 전송이 멈추고, 애플리케이션에서는 stream 소비가 늦어 큐와 메모리가 늘어나는 식으로 나타난다.

HTTP/2의 핵심 장점은 “여러 요청을 하나의 연결에 섞을 수 있다”이지, 수신자 버퍼와 애플리케이션 처리 속도를 무시해도 된다는 뜻이 아니다.

이 개념은 gRPC streaming에서 중요하다. 서버가 빠르게 메시지를 밀어도 클라이언트가 처리하지 못하면 stream window가 줄고, 결국 backpressure가 걸린다. 따라서 HTTP/2 지연을 볼 때는 “stream이 많다”만 보지 말고, 특정 stream이 window 부족이나 느린 소비자 때문에 전체 대역폭을 오래 점유하는지도 함께 봐야 한다.

4-3. 스트림 우선순위 (Stream Prioritization)

섹션 제목: “4-3. 스트림 우선순위 (Stream Prioritization)”

모든 리소스가 동등하지 않다. HTML > CSS > JS > 이미지 순으로 중요하다. HTTP/2는 스트림 간 **의존성과 가중치(weight)**를 설정할 수 있다.

HEADERS 프레임:
Stream-Dependency: 0 ← 부모 스트림 없음 (root)
Weight: 256 ← 높은 우선순위
HEADERS 프레임:
Stream-Dependency: 1 ← 스트림 1에 의존
Weight: 16 ← 낮은 우선순위

실무에서는 브라우저가 자동으로 우선순위를 설정한다. 개발자가 직접 제어하기보다 “우선순위 메커니즘이 있다”는 사실을 알고 서버(Nginx, h2o)가 이를 올바르게 구현했는지 확인하는 것이 중요하다. 단, RFC 9113은 RFC 7540의 tree 기반 priority signaling을 deprecated 처리했다. 따라서 LCP(Largest Contentful Paint, 첫 화면의 주요 콘텐츠 표시 시점) 개선을 priority weight 하나에 기대하면 안 된다. nghttp -nv https://example.com/PRIORITY/HEADERS 순서를 확인했는데도 DevTools Waterfall에서 CSS보다 대형 이미지가 먼저 대역폭을 점유한다면, preload, critical CSS, 이미지 lazy loading 같은 리소스 힌트와 번들 전략을 먼저 조정한다.

주의: HTTP/3(QUIC)에서는 스트림 우선순위 메커니즘이 PRIORITY_UPDATE 프레임으로 변경됐고, 구현체마다 지원 수준이 다르다.


5. 해결 메커니즘: HPACK 헤더 압축

섹션 제목: “5. 해결 메커니즘: HPACK 헤더 압축”

HTTP/2는 HPACK이라는 전용 헤더 압축 알고리즘을 도입해 반복되는 헤더를 효율적으로 전송한다. HTTP payload는 gzip이나 brotli로 압축할 수 있었지만, HTTP 헤더는 요청마다 반복되고 인증·쿠키·언어·브라우저 정보처럼 길어지기 쉬웠다. HPACK은 이 반복을 정적 테이블, 동적 테이블, 허프만 코딩으로 줄인다.

자주 쓰이는 헤더 이름과 값 조합 61개가 미리 정의된 테이블이다.

Index Header Name Header Value
1 :authority
2 :method GET
3 :method POST
4 :path /
5 :path /index.html
6 :scheme http
7 :scheme https
8 :status 200
13 :status 404
...
32 content-type application/x-www-form-urlencoded

GET / 요청은 인덱스 2(:method: GET)와 인덱스 4(:path: /)로 각각 1바이트씩 표현 가능하다.

커넥션 수명 동안 실제 전송된 헤더를 누적한다. 한 번 전송한 헤더는 이후 요청에서 인덱스 하나로 참조할 수 있다.

첫 번째 요청:
authorization: Bearer eyJhbGci... → 전체 전송 + 동적 테이블에 추가 (인덱스 62)
두 번째 요청:
authorization: Bearer eyJhbGci... → 인덱스 62만 전송 (1바이트)

자주 등장하는 문자를 짧은 비트열로 인코딩하는 무손실 압축이다. HPACK 인코딩 시 선택적으로 적용된다. HTTP 헤더에 자주 쓰이는 ASCII 문자를 기준으로 최적화된 허프만 트리를 사용한다.

실제 절감 효과: 반복 요청이 많은 API 서버에서 헤더 크기가 85-90% 감소하는 사례가 보고된다.

작은 예시로 계산해보자.

첫 요청:
authorization: Bearer <900B token>
user-agent: <180B>
accept-language: ko-KR,ko;q=0.9
cookie: session=<300B>
→ 공통 헤더 약 1.4KB 전송, 동적 테이블에 일부 등록
다음 요청 30개:
반복 헤더 대부분을 index로 참조
→ 매번 1.4KB를 다시 보내는 대신 수십 바이트 수준으로 축소 가능

단, HPACK은 마법이 아니다. 요청마다 JWT가 매번 바뀌거나, 쿠키가 계속 커지거나, origin이 여러 도메인으로 쪼개져 동적 테이블을 공유하지 못하면 압축 이점은 줄어든다. 이 때문에 HTTP/2 환경에서 domain sharding은 커넥션 재사용뿐 아니라 HPACK 동적 테이블 측면에서도 불리하다.


6. 경계 사례: 서버 푸시(Server Push)는 왜 실패했나

섹션 제목: “6. 경계 사례: 서버 푸시(Server Push)는 왜 실패했나”

HTTP/2 서버 푸시는 클라이언트가 요청하기 전에 서버가 먼저 리소스를 보내는 기능이다.

클라이언트 → GET /index.html
서버 ← PUSH_PROMISE (스트림 2: /style.css)
서버 ← PUSH_PROMISE (스트림 4: /app.js)
서버 ← 스트림 1: index.html 본문
서버 ← 스트림 2: style.css 본문 ← 클라이언트가 요청하기 전에 이미 전송 중
서버 ← 스트림 4: app.js 본문

이론적으로는 HTML 파싱 전에 CSS, JS가 이미 도착해 있으므로 왕복 지연(RTT)을 줄일 수 있다.

캐시 문제: 브라우저가 이미 캐시에 해당 리소스를 가지고 있어도 서버는 그 사실을 모른다. 불필요한 데이터를 전송하게 된다.

서버: "style.css 미리 줄게" ←── 브라우저는 이미 캐시에 있음
브라우저: RST_STREAM ←── 필요 없다고 거절, 하지만 이미 대역폭은 소모됨

복잡한 구현: 어떤 리소스를 푸시할지 서버가 판단해야 하는데, 잘못 판단하면 오히려 성능이 나빠진다.

브라우저 지원 철회: Chrome은 2022년 출시된 Chrome 106부터 HTTP/2 Server Push를 기본 비활성화했다. 실제 성능 개선 효과가 미미하고 대역폭 낭비가 컸기 때문이다.

현재 대안: <link rel="preload"> 헤더 또는 103 Early Hints 상태 코드가 서버 푸시를 대체하고 있다. 핵심 차이는 “서버가 리소스를 강제로 보내는가”와 “클라이언트가 빨리 요청하도록 힌트를 주는가”다. 후자가 캐시 상태와 브라우저 스케줄러를 더 잘 존중한다.

# 103 Early Hints (서버 푸시의 현실적인 대안)
location / {
add_header Link "</style.css>; rel=preload; as=style";
add_header Link "</app.js>; rel=preload; as=script";
}

반례를 하나 기억하면 된다.

서버 판단:
/index.html에는 항상 /app.js가 필요하니 push한다.
실제 클라이언트:
/app.js는 이미 cache hit이고, 지금 필요한 것은 CSS font preload다.
결과:
서버는 필요 없는 DATA frame을 먼저 보내고,
브라우저는 RST_STREAM으로 거절하거나 대역폭을 낭비한다.

따라서 Server Push는 “RTT를 줄인다”는 아이디어는 좋았지만, “클라이언트 캐시와 우선순위를 서버가 정확히 안다”는 가정이 약했다.


6.5 중간 점검: 병목 위치 지도로 읽기

섹션 제목: “6.5 중간 점검: 병목 위치 지도로 읽기”

HTTP/2까지 읽었을 때 가장 중요한 공부 포인트는 병목의 위치를 구분하는 것이다. 같은 “느림”이어도 막히는 위치가 다르면 해결책이 다르다.

병목 위치대표 현상HTTP/2가 줄이는가남는 경계
HTTP 응답 순서앞 요청 응답이 느려 뒤 요청이 대기stream 우선순위 구현과 서버 처리 병목
커넥션 수도메인당 연결 한도 때문에 7번째 요청부터 대기origin 분리, DNS/TLS 비용
헤더 반복쿠키·인증 헤더가 작은 API마다 반복동적 테이블 공유 불가, 큰 쿠키
수신자 처리 속도한쪽이 빠르게 보내도 반대편 버퍼가 부족부분적flow control window와 애플리케이션 소비
TCP 패킷 손실한 패킷 재전송 때문에 모든 stream이 멈춤아니오HTTP/3/QUIC이 다루는 영역
프로토콜 협상 실패기대한 h2/h3가 아니라 http/1.1로 동작아니오ALPN, Alt-Svc, 프록시·로드밸런서 설정 확인

따라서 HTTP/2 장애를 볼 때 “HTTP/2를 켰는가”보다 “어느 병목이 실제로 남아 있는가”를 먼저 묻는다. DevTools에 h2가 보여도 JavaScript가 순차적으로 await fetch()를 실행하면 Waterfall은 계단처럼 보일 수 있다. 반대로 protocol이 http/1.1이어도 요청 수가 적고 payload가 큰 다운로드라면 체감 차이가 작을 수 있다.

(참고: RFC 9113 - HTTP/2, RFC 9114 - HTTP/3, RFC 9000 - QUIC)

HTTP/2가 해결하지 못한 근본적인 문제가 있었다: TCP 자체의 HOL Blocking.

7-1. HTTP/2에서도 남아 있는 TCP HOL Blocking

섹션 제목: “7-1. HTTP/2에서도 남아 있는 TCP HOL Blocking”

HTTP/2는 애플리케이션 레벨의 HOL Blocking을 멀티플렉싱으로 해결했다. 그러나 TCP 레벨에서는 여전히 문제가 있다.

HTTP/2 스트림 멀티플렉싱:
스트림 1, 3, 5가 동시에 TCP 패킷으로 전송됨
TCP 패킷 손실 발생:
패킷 3 (스트림 3의 일부) 손실됨
TCP는 패킷 3이 재전송될 때까지 패킷 4, 5, 6 전달을 중단
스트림 1, 5는 자신의 데이터가 정상이지만 대기

HTTP/2의 여러 스트림이 하나의 TCP 커넥션을 공유하기 때문에, 하나의 패킷 손실이 모든 스트림을 멈춘다. 파편화된 HTTP/1.1의 개별 TCP 커넥션보다 오히려 나쁠 수 있다. 무선망·국제망처럼 손실이 있는 경로에서 HTTP/3를 검토할 때는 같은 URL을 HTTP/2와 HTTP/3로 반복 측정해 p95 time_starttransfer, 실패율, fallback 비율을 같이 본다. HTTP/3의 p95가 줄지 않거나 실패율이 높으면 UDP 경로, CDN QUIC 설정, Alt-Svc 캐시를 먼저 의심한다.

여기서 중요한 반례가 있다. HTTP/3가 항상 빠른 것은 아니다. 손실이 거의 없는 유선망에서 1개의 큰 파일을 내려받는 경우라면 HTTP/2와 HTTP/3의 차이가 작거나, 구현체와 경로에 따라 HTTP/2가 더 안정적일 수 있다. HTTP/3의 장점은 “모든 바이트가 더 빨라진다”가 아니라 “여러 stream이 동시에 진행될 때 한 stream의 손실이 다른 stream을 덜 막는다”에 가깝다.

7-2. QUIC: UDP 위의 신뢰성 있는 전송

섹션 제목: “7-2. QUIC: UDP 위의 신뢰성 있는 전송”

HTTP/3는 TCP 대신 QUIC 위에서 동작한다. QUIC은 UDP를 기반으로 하되 신뢰성, 혼잡 제어, 스트림 다중화, TLS를 사용자 공간 전송 프로토콜 안에 구현한다.

HTTP/1.1: [ HTTP/1.1 ] → [ TLS ] → [ TCP ] → [ IP ]
HTTP/2: [ HTTP/2 ] → [ TLS ] → [ TCP ] → [ IP ]
HTTP/3: [ HTTP/3 ] → [ QUIC (TLS 1.3 내장) ] → [ UDP ] → [ IP ]

QUIC의 특징:

독립 스트림: QUIC는 스트림 단위로 독립적인 전달을 보장한다. 스트림 3의 패킷이 손실되더라도 스트림 1, 5는 계속 진행된다.

QUIC 스트림 멀티플렉싱:
스트림 1: [패킷A] [패킷B] ──→ 정상 도착, 즉시 전달
스트림 3: [패킷X] [???] ──→ 패킷 손실, 스트림 3만 재전송 대기
스트림 5: [패킷P] [패킷Q] ──→ 정상 도착, 즉시 전달

Connection ID: QUIC는 IP 주소 + 포트 번호 대신 Connection ID로 커넥션을 식별한다. Wi-Fi에서 LTE로 전환되어 IP 주소가 바뀌어도 같은 커넥션을 유지할 수 있다 → 커넥션 마이그레이션(Connection Migration).

다만 Connection ID가 “IP가 바뀌어도 항상 무중단”을 보장하는 것은 아니다. 새 경로가 실제로 통신 가능한지 path validation이 필요하고, 회사망·통신사망·방화벽이 UDP/443을 막으면 HTTP/3는 HTTP/2로 fallback될 수 있다. 그래서 migration을 평가할 때는 성공한 연결만 보지 말고 fallback 비율과 재연결 시간을 함께 본다.

TLS 1.3 통합: QUIC은 TLS 1.3 핸드셰이크를 전송 핸드셰이크에 통합한다. TLS 핸드셰이크가 사라지는 것이 아니라, TCP 연결을 만든 뒤 TLS를 별도로 시작하던 순차 왕복을 합치는 것이다.

HTTP/1.1 + TLS의 연결 수립 지연을 비교해보자.

HTTP/1.1 + TLS 1.2 (첫 연결):
1. TCP SYN → (1 RTT)
2. TCP SYN-ACK ←
3. TCP ACK + TLS CH → (1 RTT)
4. TLS SH + Cert ←
5. TLS Finished → (1 RTT)
6. HTTP Request →
7. HTTP Response ←
= 총 3 RTT 후 첫 번째 바이트 수신
QUIC (첫 연결, 1-RTT):
1. Initial + CRYPTO → (1 RTT, TCP + TLS 핸드셰이크 동시 처리)
2. Handshake + CRYPTO ←
3. HTTP Request →
4. HTTP Response ←
= 총 1 RTT 후 첫 번째 바이트 수신
QUIC (재연결, 0-RTT):
이전 세션 키(Session Ticket) 사용
1. Initial + 0-RTT HTTP Request → (즉시 데이터 전송)
2. HTTP Response ←
= RTT 없이 데이터 전송 가능

0-RTT는 이전 방문에서 캐시된 세션 정보를 재사용하는 것이다. 주의할 점은 0-RTT 데이터가 재전송 공격(Replay Attack)에 취약하다는 것이다. 따라서 origin은 메서드 이름만 보지 말고, replay되어도 부작용이 없는 요청만 0-RTT에서 허용해야 한다. 그 안전성을 입증하지 못한 요청은 1-RTT 핸드셰이크 완료 후 처리한다.

더 엄밀히는 GET이라고 해서 항상 안전한 것은 아니다. 조회 요청처럼 보이지만 서버에서 추천 노출 카운터, 쿠폰 사용 표시, 결제 준비 상태를 바꾸는 endpoint라면 replay가 부작용을 만들 수 있다. TLS 1.3 문서에서 봤듯이 0-RTT는 “메서드 이름”이 아니라 “origin이 같은 요청을 두 번 받아도 괜찮은가”로 판단한다.

QUIC 패킷 구조:
┌─────────────────────────────────────┐
│ QUIC Header (Connection ID, Packet#)│
├─────────────────────────────────────┤
│ QUIC Frame 1 (STREAM frame) │
│ Stream ID: 4 │
│ Offset: 0 │
│ Data: [payload] │
├─────────────────────────────────────┤
│ QUIC Frame 2 (ACK frame) │
│ Largest Acked: 12 │
│ ACK Ranges: [10-12] │
└─────────────────────────────────────┘
↓ 전체가 TLS 1.3으로 암호화됨
┌─────────────────────────────────────┐
│ UDP Header │
└─────────────────────────────────────┘

QUIC의 패킷 번호는 모노토닉하게 증가한다. 재전송 시에도 동일한 시퀀스 번호를 재사용하지 않으므로, TCP의 “재전송 모호성(Retransmission Ambiguity)” 문제가 없다.

7-5. QPACK: HTTP/3에서 헤더 압축을 다시 설계한 이유

섹션 제목: “7-5. QPACK: HTTP/3에서 헤더 압축을 다시 설계한 이유”

HTTP/3는 HPACK 대신 QPACK을 사용한다. QPACK은 QUIC 위에서 HTTP 헤더를 압축하기 위한 알고리즘이다. 왜 HPACK을 그대로 쓰지 않았을까?

HPACK의 동적 테이블은 “이전에 본 헤더를 index로 참조한다”는 순서 의존성이 있다. HTTP/2에서는 모든 frame이 하나의 TCP byte stream으로 순서대로 전달되므로, 수신자는 동적 테이블 업데이트와 헤더 참조의 순서를 자연스럽게 맞출 수 있다. HTTP/3는 QUIC stream이 독립적으로 도착한다. 어떤 stream의 헤더가 먼저 도착했는데, 그 헤더가 참조하는 동적 테이블 업데이트가 다른 stream에서 아직 도착하지 않았다면 blocking이 생길 수 있다.

QPACK은 encoder stream과 decoder stream을 분리하고, 헤더 블록이 아직 도착하지 않은 동적 테이블 항목에 과하게 의존하지 않도록 설계한다. 즉 QPACK은 “압축률”과 “stream 독립성” 사이의 trade-off를 다룬다.

HPACK 직관:
TCP 순서 보장에 기대어 동적 테이블 참조를 단순하게 유지
QPACK 직관:
QUIC stream 독립성을 해치지 않도록 테이블 업데이트와 헤더 해석을 분리

실무에서 QPACK을 직접 튜닝할 일은 드물다. 대신 HTTP/3에서 큰 쿠키와 매 요청마다 바뀌는 인증 헤더가 많으면 HTTP/2와 마찬가지로 압축 이점이 줄고, 일부 구현에서는 header blocking이나 메모리 사용량 신호로 나타날 수 있다는 정도를 기억하면 된다.

작은 수치 예시: 큰 Cookie는 압축으로 없어지지 않는다

상황:
한 화면에서 API 요청 40개
각 요청 Cookie 헤더 4KB
대부분 같은 origin으로 전송

HPACK/QPACK이 반복되는 Cookie 값을 잘 참조하면 두 번째 요청부터 wire byte는 크게 줄 수 있다. 하지만 “브라우저와 서버가 매 요청마다 4KB짜리 인증/실험/트래킹 상태를 의미적으로 다룬다”는 사실은 사라지지 않는다. 첫 요청, 동적 테이블이 깨진 새 커넥션, 다른 origin, 자주 바뀌는 토큰에서는 압축률이 급격히 낮아진다. 그래서 header size 경고가 보이면 “HTTP/3로 올리면 해결”이 아니라 “쿠키를 쪼개거나 줄일 수 있는가, 인증 토큰이 매 요청마다 바뀌는가, 정적 리소스까지 불필요한 Cookie가 붙는가”를 먼저 본다.


8. HTTP/2 vs HTTP/3: HOL Blocking 비교 정리

섹션 제목: “8. HTTP/2 vs HTTP/3: HOL Blocking 비교 정리”
HTTP/1.1 HTTP/2 HTTP/3 (QUIC)
────────────────────────────────────────────────────────────────────
App-level HOL 있음 없음 (멀티플렉싱) 없음
전송 계층 HOL TCP 전체 TCP 전체 같은 stream 안에 한정
연결 수립 TCP 뒤 TLS TCP 뒤 TLS 통합 1-RTT / 조건부 0-RTT
헤더 압축 없음 HPACK QPACK
IP 변경 시 재연결 재연결 조건부 유지 (Connection ID)
전송 레이어 TCP TCP UDP 기반 QUIC
암호화 TLS 조합 가능 TLS 조합 가능 TLS 1.3 통합

언제 HTTP/3가 특히 효과적인가:

  • 모바일 환경 (Wi-Fi ↔ LTE 전환이 잦음)
  • 고지연 / 패킷 손실이 있는 네트워크
  • 많은 소규모 병렬 요청

HTTP/2가 여전히 좋은 경우:

  • 안정적인 유선 네트워크 내부 통신 (데이터센터 내부)
  • UDP를 차단하는 방화벽 환경 (일부 기업 네트워크는 UDP 443을 막음)
  • gRPC 내부 통신 (HTTP/2 기반이 안정적)

결정 기준은 “HTTP/3가 최신이니 켠다”가 아니라 실패해도 HTTP/2로 자연스럽게 돌아오는가다. RFC 9114는 UDP 차단 같은 연결 문제가 있으면 TCP 기반 HTTP 버전을 시도해야 한다고 권고한다. 운영에서는 curl --http3-only https://api.example.com/health가 실패해도 curl --http2 https://api.example.com/health가 즉시 성공해야 하고, 응답에는 Alt-Svc: h3=":443"; ma=...가 있어야 한다. 이 조건이 깨지면 사용자는 “일부 네트워크에서만 느림/접속 안 됨”을 보고하고 서버 로그에는 정상 HTTP/2 요청만 남는 silent failure가 된다. 이때는 HTTP/3를 단독 경로로 만들지 말고 TCP 443 listener와 HTTP/2 fallback을 유지한 채 QUIC 실패율을 별도 지표로 본다.


8.5 선택 기준: HTTP/2와 HTTP/3를 언제 기대해야 하나

섹션 제목: “8.5 선택 기준: HTTP/2와 HTTP/3를 언제 기대해야 하나”

HTTP 버전 선택은 “새 버전이 더 좋다”가 아니라 “현재 병목이 그 버전의 해결 범위 안에 있는가”로 판단한다. 아래 표는 첫 가설을 세울 때 쓰는 기준이다.

상황기대할 버전이유반례
한 화면에서 작은 리소스가 많다HTTP/2stream multiplexing과 HPACK 이점이 크다요청을 JS가 순차 실행하면 Waterfall은 계속 계단식
모바일·국제망·손실 경로가 많다HTTP/3QUIC stream 독립성과 connection migrationUDP 443이 차단되면 fallback 지연이 생긴다
단일 대용량 파일 다운로드HTTP/2도 충분병렬 stream 이점보다 전송 대역폭이 지배적이동 중 네트워크 전환이 잦으면 HTTP/3가 유리할 수 있다
내부 gRPC 호출이 많다HTTP/2gRPC의 stream, trailers, flow control 기반공개 브라우저 API라면 gRPC-Web 프록시 부담이 생긴다
캐시 가능한 정적 리소스HTTP/2/3 모두protocol보다 cache hit와 preload가 더 큼domain sharding은 압축 테이블과 연결 재사용을 깨뜨린다

정량 감각도 필요하다. RTT(Round Trip Time)가 80ms인 경로에서 첫 방문을 단순화하면 다음처럼 보인다.

HTTP/1.1 + TCP + TLS 1.2:
TCP 1 RTT + TLS 2 RTT + request/response 1 RTT = 첫 바이트까지 약 320ms 이상
HTTP/2 + TCP + TLS 1.3:
TCP 1 RTT + TLS 1 RTT + request/response 1 RTT = 첫 바이트까지 약 240ms 이상
HTTP/3 + QUIC 1-RTT:
QUIC/TLS 통합 1 RTT + request/response 1 RTT = 첫 바이트까지 약 160ms 이상
HTTP/3 + QUIC 0-RTT:
재방문 safe request는 request를 먼저 보낼 수 있지만 replay 경계가 남음

이 숫자는 실제 인터넷의 DNS, CDN edge 위치, 서버 처리 시간, congestion control을 모두 생략한 모델이다. 그래도 “왜 handshake RTT가 작은 응답에서 크게 보이는가”를 설명하기에는 충분하다. 1MB 이미지처럼 전송 시간이 지배적인 요청에서는 위 차이가 전체 시간에 묻힐 수 있고, 5KB JSON API처럼 작은 응답에서는 첫 바이트 차이가 체감된다.

작은 선택 예시: HTTP/3 전환 후보인지 판단하기

서비스 A:
사용자 대부분이 사무실 유선망
응답은 2MB 파일 1개
손실률 낮음, HTTP/2 p95 안정
서비스 B:
모바일 사용자가 많음
한 화면에서 작은 JSON/이미지 요청 40개
이동 중 네트워크 전환과 packet loss가 잦음

서비스 A는 HTTP/3 전환보다 cache hit, range request, origin throughput이 먼저일 가능성이 크다. 서비스 B는 HTTP/3의 독립 stream, connection migration, handshake 단축을 측정해볼 가치가 있다. 둘 다 “HTTP/3 지원”이라는 같은 체크박스처럼 보이지만, 실제 선택 기준은 병목 위치가 다르다.

잘못된 결론을 피하려면 다음 반례를 같이 기억한다.

  • h2가 보인다고 자동으로 빠른 것은 아니다. 서버가 DB를 순차 조회하거나 클라이언트가 요청을 순차 생성하면 protocol은 병목이 아니다.
  • h3가 보인다고 항상 더 빠른 것도 아니다. UDP 차단, QUIC 구현, CDN edge 위치 때문에 p95가 더 나빠질 수 있다.
  • 0-RTT는 latency 최적화이지 정합성 최적화가 아니다. replay되어도 안전한 요청만 early data 후보가 된다.
  • HPACK/QPACK은 큰 쿠키 문제를 해결하지 않는다. 반복 전송을 줄일 뿐, 헤더 자체가 너무 큰 API 설계는 여전히 위험하다.
  • Server Push는 “서버가 더 잘 안다”는 가정이 약하다. preload와 Early Hints는 클라이언트 캐시와 우선순위를 더 잘 존중한다.

9. 실패 신호: 증상에서 병목 위치로 역추적하기

섹션 제목: “9. 실패 신호: 증상에서 병목 위치로 역추적하기”

운영 절차보다 먼저 볼 것은 “어느 계층의 약속이 깨졌는가”다. 아래 표는 명령어 모음이 아니라 원인 후보를 좁히기 위한 관찰 지도다. 실제 명령과 화면 조작은 선택 부록 B에 모았다.

증상먼저 의심할 위치의미짧은 확인
DevTools는 h2인데 Waterfall이 계단식애플리케이션 호출 순서HTTP/2는 켜졌지만 코드가 순차 요청을 만들 수 있다같은 origin 요청이 동시에 시작되는지 본다
gRPC가 ALB 뒤에서 502ALB target group protocolclient-ALB는 HTTP/2, ALB-backend는 HTTP/1.1일 수 있다target ProtocolVersionGRPC인지 본다
HTTP/2 200인데 RPC는 실패gRPC trailersHTTP status와 RPC status가 다른 계층이다grpc-status, grpc-message를 본다
대용량 응답이나 SSE에서 stream resetHTTP/2 stream 처리/프록시 버퍼특정 stream이 중단되고 connection은 살아있을 수 있다RST_STREAM, 응답 헤더, buffering 설정을 본다
HTTP/3 켠 뒤 일부 네트워크만 느림/실패UDP 443 경로와 fallbackQUIC 실패 후 HTTP/2 fallback이 지연될 수 있다Alt-Svc, --http3-only, --http2를 비교한다
HTTP/3 기대했는데 계속 h2Alt-Svc 캐시 또는 UDP 차단브라우저가 QUIC을 시도하지 않거나 실패했을 수 있다응답의 Alt-Svc와 네트워크 정책을 본다
큰 쿠키/JWT 이후 header 관련 오류헤더 크기와 압축 테이블HPACK/QPACK 문제가 아니라 헤더 자체가 과도할 수 있다cookie 크기와 서버의 header size 제한을 본다
LCP 개선을 priority 설정에만 기대브라우저/서버 priority 구현HTTP/2의 기존 tree 기반 priority 신호는 deprecated되었다preload, critical CSS, 이미지 lazy loading을 함께 본다

실패 신호를 버전별로 다시 묶으면 세 갈래다.

  1. h2인데 느리면 요청 생성 순서, 서버 처리, flow-control window를 본다. 멀티플렉싱은 애플리케이션과 수신자의 직렬 병목을 자동으로 없애지 않는다.
  2. h3를 기대했는데 h2이거나 일부 망에서만 실패하면 UDP 443, Alt-Svc, fallback을 본다. HTTP/3 지원 여부보다 실패 후 HTTP/2로 돌아오는 경계가 중요하다.
  3. HTTP status는 성공인데 RPC가 실패하면 gRPC trailers를 본다. 같은 연결 안에서도 HTTP 의미론과 RPC 의미론의 실패 신호는 다르다.

항목HTTP/1.1HTTP/2HTTP/3
전송 형식텍스트 메시지바이너리 프레임HTTP/3 frame over QUIC
멀티플렉싱없음, 여러 연결로 우회단일 TCP 연결의 stream단일 QUIC 연결의 독립 stream
애플리케이션 HOL있음stream 다중화로 제거없음
전송 계층 HOLTCP 연결 전체TCP 연결 전체손실 영향을 해당 stream으로 제한
헤더 압축없음HPACKQPACK
연결 수립TCP와 TLS를 순차 조합TCP와 TLS를 순차 조합QUIC/TLS 통합 1-RTT, 조건부 0-RTT
주소 변경 내성재연결재연결Connection ID와 path validation
대표 운영 경계연결 수와 pipelining HOLflow control와 TCP HOLUDP 경로, fallback, 같은 stream HOL

핵심 교훈:

  • HTTP/2는 binary framing과 stream multiplexing으로 동시성의 단위를 TCP 연결에서 stream으로 내렸다.
  • HPACK은 반복 헤더를 줄이지만 큰 쿠키나 계속 변하는 인증 상태 자체를 없애지는 않는다.
  • HTTP/3는 QUIC의 독립 stream으로 손실의 파급 범위를 줄이지만, UDP 차단과 fallback이라는 새 경계를 만든다.
  • 선택은 버전 번호가 아니라 요청 수, RTT·손실, 이동성, fallback, 관측 가능한 실패 신호를 기준으로 한다.
  • h2h3가 보이는 것은 시작점일 뿐이다. 애플리케이션 직렬 처리, flow control, gRPC status, 프록시 구간은 별도로 확인한다.

선택 부록 A: gRPC가 HTTP/2를 쓰는 이유

섹션 제목: “선택 부록 A: gRPC가 HTTP/2를 쓰는 이유”
gRPC의 stream, headers, trailers 적용 사례 펼치기

이 부록은 HTTP/2 메커니즘이 실제 RPC 프레임워크에 어떻게 쓰이는지 확인할 때 읽는다. HTTP/2·3 자체의 첫 회독에는 필수가 아니다.

gRPC는 Google이 설계한 고성능 RPC 프레임워크다. 내부적으로 HTTP/2를 전송 프로토콜로 사용한다.

gRPC 메시지 전송 구조:
┌──────────────────────────────────────┐
│ gRPC 레이어 │
│ - Protobuf 직렬화/역직렬화 │
│ - 서비스 정의 (proto 파일) │
├──────────────────────────────────────┤
│ HTTP/2 레이어 │
│ - HEADERS 프레임: gRPC 메타데이터 │
│ - DATA 프레임: Protobuf 페이로드 │
│ - Trailers: 상태 코드 │
├──────────────────────────────────────┤
│ TLS → TCP → IP │
└──────────────────────────────────────┘

양방향 스트리밍: HTTP/2의 스트림은 전이중(full-duplex) 통신을 지원한다. gRPC의 4가지 통신 패턴이 모두 가능하다.

// Unary: 단방향 요청-응답
rpc GetUser (UserRequest) returns (UserResponse);
// Server Streaming: 서버가 여러 번 응답
rpc ListUsers (ListRequest) returns (stream UserResponse);
// Client Streaming: 클라이언트가 여러 번 요청
rpc UploadFile (stream Chunk) returns (UploadResponse);
// Bidirectional Streaming: 양방향 스트리밍
rpc Chat (stream Message) returns (stream Message);

멀티플렉싱: 하나의 TCP 커넥션에서 수백 개의 gRPC 호출을 동시에 처리할 수 있다. REST/HTTP/1.1 대비 커넥션 관리 오버헤드가 현저히 낮다.

헤더 압축: Protobuf로 직렬화된 페이로드가 이미 작은데, HPACK으로 헤더까지 압축하면 전체 메시지 크기가 REST/JSON 대비 훨씬 작다.

Flow Control: HTTP/2의 흐름 제어 메커니즘으로 수신 측이 처리할 수 있는 만큼만 데이터를 받을 수 있다. 백프레셔(Backpressure) 구현에 필수다.

gRPC를 HTTP/2 관점으로 다시 쓰면 다음과 같다.

gRPC 요구사항HTTP/2가 제공하는 기능없으면 생기는 문제
동시에 많은 RPC 호출stream multiplexingHTTP/1.1 연결 수가 병목이 되고 HOL이 커진다
서버/클라이언트 스트리밍양방향 stream과 DATA frame 반복polling 또는 WebSocket 별도 설계가 필요하다
작은 metadata 반복HPACK 헤더 압축인증·trace header가 요청마다 반복된다
느린 수신자 제어stream/connection flow control빠른 송신자가 클라이언트 메모리를 밀어붙인다
상태 전달response trailers의 grpc-statusHTTP status만으로 RPC 성공/실패를 표현하기 어렵다

반대로 이 요구사항이 없다면 gRPC가 항상 이득은 아니다. 브라우저가 직접 호출하는 공개 API, 사람이 curl/Postman으로 자주 디버깅해야 하는 API, 1KB 미만의 단순 조회가 대부분인 API라면 REST/JSON이 운영 비용 면에서 더 단순할 수 있다.

gRPC 요청 헤더 (HEADERS 프레임):
:method: POST
:path: /helloworld.Greeter/SayHello
:scheme: https
:authority: api.example.com
content-type: application/grpc+proto
grpc-timeout: 30S
authorization: Bearer <token>
DATA 프레임 (Protobuf 바이너리):
[압축 플래그 1바이트][메시지 길이 4바이트][Protobuf 직렬화 데이터]
HEADERS 프레임 (Trailers-only):
grpc-status: 0 ← 0 = OK
grpc-message: ""

주의할 점은 HTTP status와 gRPC status가 같은 계층이 아니라는 것이다. 네트워크/프록시 레벨에서는 HTTP/2 200이지만, RPC 의미상 실패는 trailers의 grpc-status: 13 같은 값으로 전달될 수 있다. 이 차이를 모르면 “HTTP 200인데 클라이언트는 실패”처럼 보이는 silent failure를 놓친다.


선택 부록 B: ALB/Nginx 경계와 관찰 실습

섹션 제목: “선택 부록 B: ALB/Nginx 경계와 관찰 실습”
운영 설정과 Chrome/curl/nghttp 관찰 절차 펼치기

이 부록은 설정값을 외우기 위한 런북이 아니다. 먼저 본문 9절에서 실패 신호와 관측할 구간을 고른 뒤, 그 가설에 맞는 설정 또는 명령만 선택한다.

B-1. 운영 경계: ALB/Nginx 설정은 어디까지 의미가 있나

섹션 제목: “B-1. 운영 경계: ALB/Nginx 설정은 어디까지 의미가 있나”

이 절의 목적은 설정 파일을 외우는 것이 아니라, 어느 구간이 어떤 HTTP 버전으로 말하는지를 분리하는 것이다. HTTP/2와 HTTP/3는 보통 client-edge 구간에서 먼저 효과가 나고, backend-origin 구간은 로드밸런서나 프록시 설정에 따라 별도로 결정된다.

B-1-1. AWS ALB: client 구간과 target 구간을 분리한다

섹션 제목: “B-1-1. AWS ALB: client 구간과 target 구간을 분리한다”

ALB는 기본적으로 HTTP/2를 지원하지만, 동작 방식을 이해해야 한다.

클라이언트 ─── HTTP/2 ───→ ALB ─── HTTP/1.1 ───→ 타겟 그룹(EC2/ECS)

중요: ALB는 클라이언트와의 통신에서는 HTTP/2를 지원하지만, 백엔드(타겟 그룹)와의 통신은 기본적으로 HTTP/1.1을 사용한다. AWS 문서 기준으로 target group의 protocol version을 HTTP2 또는 GRPC로 지정해야 백엔드까지 HTTP/2/gRPC를 보낼 수 있고, gRPC 요청이 HTTP/1.1 target group으로 들어가면 오류 조합이다. 또한 ALB의 클라이언트 HTTP/2 연결당 최대 stream 수는 128이므로, gRPC fan-out이 큰 서비스는 채널 수와 target group connection 지표를 함께 본다.

최소 판단 기준은 아래와 같다.

경로적합한 설정학습 포인트
브라우저/API 클라이언트 → ALBHTTPS listener + HTTP/2 협상ALPN으로 h2가 선택되는지 본다
ALB → 일반 REST backendtarget group HTTP1client 구간만 HTTP/2여도 대개 충분하다
ALB → gRPC backendtarget group GRPCbackend까지 HTTP/2 의미론이 필요하다
ALB → HTTP/2 backendtarget group HTTP2gRPC가 아닌 HTTP/2 backend가 실제로 필요할 때만
# gRPC backend로 보낼 때의 핵심 필드만 남기면 이렇다.
resource "aws_lb_target_group" "grpc" {
protocol = "HTTP"
protocol_version = "GRPC"
port = 50051
health_check {
path = "/grpc.health.v1.Health/Check"
matcher = "0"
}
}

여기서 흔한 반례는 “ALB 앞에서는 h2로 보이는데 gRPC가 502를 낸다”는 상황이다. 이때 client-ALB 구간은 HTTP/2여도, ALB-target 구간이 HTTP1이면 gRPC 서버가 HTTP/1.1 요청을 받게 된다. 프로토콜 문제는 한 구간만 보고 판단하면 안 된다.

# Nginx 1.25.1+ 기준: client와 Nginx 사이 HTTP/2
server {
listen 443 ssl;
http2 on;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://backend;
proxy_http_version 1.1; # backend 구간은 별도 결정
}
location /grpc/ {
grpc_pass grpc://grpc_backend; # gRPC는 HTTP/2 backend가 필요
}
}

이 스니펫에서 핵심은 http2 on 자체가 아니라 location /location /grpc/의 차이다. 일반 HTTP API는 Nginx가 client 쪽 HTTP/2를 종료하고 backend로 HTTP/1.1을 보내도 된다. gRPC는 HTTP/2 frame과 trailers가 의미의 일부이므로 grpc_pass 경로를 분리해야 한다.

큰 쿠키나 JWT를 붙인 뒤 400·431 또는 header too large 오류가 보이면 요청 헤더 크기와 함께 Nginx의 large_client_header_buffers 설정을 확인한다. 다만 이 값을 먼저 늘리기보다 불필요한 Cookie가 정적 리소스까지 전송되는지, 인증 토큰이 계속 커지거나 매 요청마다 바뀌는지부터 확인한다.

server {
listen 443 quic reuseport; # UDP 443: HTTP/3
listen 443 ssl; # TCP 443: HTTP/2 fallback
http2 on;
add_header Alt-Svc 'h3=":443"; ma=86400';
location / {
proxy_pass http://backend;
}
}

Alt-Svc 헤더 역할: 브라우저는 첫 번째 HTTP/2 응답에서 Alt-Svc 헤더를 받아 다음 방문부터 HTTP/3를 시도한다.

Alt-Svc: h3=":443"; ma=86400
↑ ↑
HTTP/3 (h3) Max-Age: 86400초 동안 캐시
포트 443에서 사용 가능

여기서 실패를 막는 핵심은 TCP 443 fallback을 유지하는 것이다. UDP 443이 차단된 기업망이나 VPN에서는 QUIC 연결이 실패할 수 있다. 이때 TCP 기반 HTTP/2 경로가 남아 있으면 사용자는 느려질 뿐 서비스는 계속 쓴다. 반대로 HTTP/3를 단독 경로처럼 운영하면 “일부 네트워크에서만 접속이 안 됨”이라는 재현 어려운 장애가 된다.

Nginx 공식 문서 기준으로 ngx_http_v3_module은 기본 빌드에 포함되지 않고 현재도 experimental로 표시된다. 따라서 이 스니펫은 모듈 포함 여부와 TLS 라이브러리 조건을 확인한 뒤 적용해야 하며, production 준비도를 listen 443 quic 한 줄만으로 판단하면 안 된다.


B-2. 브라우저와 curl에서 무엇을 볼 것인가

섹션 제목: “B-2. 브라우저와 curl에서 무엇을 볼 것인가”

처음 읽을 때는 이 절의 명령을 모두 실행하지 않아도 된다. Protocol, ALPN, Alt-Svc, grpc-status가 각각 어느 구간의 약속을 확인하는지만 잡고, 실제 장애 대응 절차는 필요한 시점에 돌아오면 충분하다.

B-2-1. Chrome DevTools Protocol 컬럼 읽기

섹션 제목: “B-2-1. Chrome DevTools Protocol 컬럼 읽기”

Network 탭의 Protocol 컬럼은 각 요청이 어떤 HTTP 버전으로 처리됐는지 보여준다.

Protocol 컬럼 값:
http/1.1 → HTTP/1.1 (TCP, 텍스트 기반)
h2 → HTTP/2 (TCP, 바이너리 프레임)
h3 → HTTP/3 (QUIC, UDP)
(없음) → 이 컬럼만으로 프로토콜 판정 불가

Protocol 값이 비어 있으면 평문 HTTP/1.1로 단정하지 않는다. 먼저 요청 URL의 scheme이 http인지 https인지 보고, 해당 요청의 Headers·Timing 같은 상세 정보를 확인한 뒤, 필요하면 curl의 응답 버전이나 ALPN처럼 다른 관측 지점과 교차 확인한다.

Waterfall 차트 해석:

HTTP/1.1의 Waterfall:
style.css ━━━━━━━━━━━━━━━━
app.js ━━━━━━━━━━━━━━━━ ← 직렬 처리, 계단식
image.jpg ━━━━━━━━━━━━━━━━
HTTP/2의 Waterfall:
style.css ━━━━━━━━
app.js ━━━━━━━━━━━━━━ ← 병렬 처리, 겹침
image.jpg ━━━━━━━━━━━━━━━━━━━━━━━━━━

Protocol 컬럼이 h2인데 Waterfall이 계단식으로 보인다면:

  1. 서버 측 스트림 처리가 병목이거나
  2. JavaScript로 순차적 fetch를 하고 있거나
  3. HTTP/2 설정에 문제가 있는 것이다.

DevTools에서 Protocol 컬럼 활성화 방법:

1. Chrome DevTools → Network 탭 열기
2. 컬럼 헤더 우클릭 → Protocol 체크
3. 페이지를 새로고침하고 Protocol 컬럼에서 버전 확인
Terminal window
# HTTP/2 지원 확인
curl -I --http2 https://api.example.com/health
# 응답 헤더: HTTP/2 200
# HTTP/3 지원 확인 (curl 7.66.0+이며 HTTP/3 지원으로 빌드된 경우)
curl -I --http3 https://api.example.com/health
# 응답 헤더: HTTP/3 200
# 상세 연결 정보 확인
curl -v --http2 https://api.example.com/health 2>&1 | grep -E "HTTP/|ALPN|TLS"
# 예상 출력:
# * ALPN: offering h2
# * ALPN: server accepted h2
# < HTTP/2 200

ALPN (Application-Layer Protocol Negotiation): TLS 핸드셰이크 중에 HTTP 버전을 협상하는 TLS 확장이다. 클라이언트가 h2, http/1.1을 제안하고 서버가 지원 가능한 버전을 선택한다. RFC 9113은 cleartext TCP의 prior knowledge 경로도 정의하지만, 주류 브라우저의 웹 HTTP/2는 TLS와 h2 ALPN을 사용한다.

B-2-3. 리소스 분할 전략과 HTTP 버전의 관계

섹션 제목: “B-2-3. 리소스 분할 전략과 HTTP 버전의 관계”

HTTP/1.1 시대에는 파일 수가 곧 연결 대기 비용이었기 때문에 하나의 큰 bundle이 유리한 경우가 많았다. HTTP/2에서는 같은 origin의 여러 파일을 stream으로 섞어 받을 수 있으므로, 캐시 효율을 위해 vendor와 앱 코드를 나누는 전략이 더 자연스럽다.

하지만 “HTTP/2니까 아주 작은 파일을 무한히 쪼갠다”도 반례다. 파일이 너무 많으면 압축 사전, 모듈 초기화, 브라우저 스케줄링, 캐시 메타데이터 비용이 커진다. 프로토콜 관점의 판단 기준은 아래처럼 잡는다.

전략HTTP/1.1에서의 의미HTTP/2/3에서의 의미
큰 단일 bundle연결 대기를 줄인다변경 한 줄에도 전체 캐시가 깨질 수 있다
적절한 split연결 수 제한 때문에 과하면 불리stream 병렬성과 장기 캐시를 함께 얻는다
domain sharding도메인별 연결 한도를 늘리는 우회커넥션·TLS·HPACK/QPACK 테이블을 쪼개 불리
preload 힌트중요한 리소스를 빨리 요청하게 유도Server Push보다 캐시와 우선순위를 존중한다

즉 bundle 전략은 HTTP/2 기능을 “활용”하는 영역이지, HTTP/2의 핵심 개념은 아니다. 핵심은 여전히 stream multiplexing, header compression, transport-level HOL 경계다.


Terminal window
# 1. 현재 서비스 HTTP 버전 확인
curl -sI https://api.example.com | head -1
# HTTP/2 200 ← 정상
# HTTP/1.1 200 ← HTTP/2 미적용
# 2. ALPN 협상 확인
openssl s_client -connect api.example.com:443 -alpn h2 2>&1 | grep ALPN
# ALPN protocol: h2 ← HTTP/2 지원됨
# 3. Alt-Svc 헤더 확인 (HTTP/3 지원 여부)
curl -sI https://api.example.com | grep alt-svc
# alt-svc: h3=":443"; ma=86400

gRPC는 HTTP/2 stream과 trailers를 전제로 하므로 “ALB health check가 성공하는가”보다 “target group이 gRPC 의미론으로 요청을 보내는가”가 중요하다.

ALB target group:
protocol = HTTP
protocol_version = GRPC
health path = /grpc.health.v1.Health/Check
matcher = 0 # grpc-status 0 = OK
Terminal window
# HTTP/2 스트림 레벨 디버깅 (nghttp2 도구)
# brew install nghttp2
nghttp -v https://api.example.com/
# [ 0.123] Connected
# [ 0.234] send SETTINGS frame
# [ 0.345] recv SETTINGS frame
# [ 0.456] send HEADERS frame; END_STREAM
# :method: GET
# :path: /
# :scheme: https
# [ 0.567] recv HEADERS frame; END_STREAM
# :status: 200

이 출력에서 처음 볼 것은 frame 이름이다. SETTINGS가 오가면 HTTP/2 커넥션이 열렸다는 뜻이고, HEADERSDATA에 같은 stream ID가 붙으면 요청-응답 조각이 어떻게 재조립되는지 볼 수 있다. RST_STREAM이 반복되면 서버나 프록시가 특정 stream을 중단하고 있다는 신호다.


B-4. 실패 신호를 수치로 비교하는 최소 스니펫

섹션 제목: “B-4. 실패 신호를 수치로 비교하는 최소 스니펫”

본문 9절의 실패 신호에서 가설을 고른 뒤, protocol·연결 시간·첫 바이트와 fallback을 같은 조건으로 비교한다.

Terminal window
# protocol, connect, first byte를 같은 형식으로 비교
curl -o /dev/null -s -w '%{http_version} connect=%{time_connect} ttfb=%{time_starttransfer}\n' --http2 https://api.example.com/
curl -o /dev/null -s -w '%{http_version} connect=%{time_connect} ttfb=%{time_starttransfer}\n' --http3 https://api.example.com/
Terminal window
# ALPN 협상 확인
openssl s_client -connect api.example.com:443 -alpn h2 </dev/null 2>/dev/null | grep ALPN
Terminal window
# HTTP/3 광고 여부 확인
curl -sI https://api.example.com/ | grep -i alt-svc

예상 출력은 환경마다 다르지만 해석 기준은 짧게 잡을 수 있다.

정상 예:
2 connect=0.020 ttfb=0.085
ALPN protocol: h2
alt-svc: h3=":443"; ma=86400
의심 예:
--http3은 실패하지만 --http2는 성공
Alt-Svc가 없거나, h3 광고는 있는데 일부 네트워크에서만 timeout

이 스니펫으로 해결까지 자동화하려고 하면 다시 런북이 된다. 공부 단계에서는 “h2/h3가 실제로 협상됐는가”, “TTFB가 줄었는가”, “fallback이 남아 있는가”, “RPC status를 HTTP status와 분리해 봤는가”까지 확인하면 충분하다.



  • L2 연결: [TCP/UDP Internals] — QUIC이 왜 UDP를 선택했는지, TCP의 재전송 타이머와 QUIC의 손실 감지 비교
  • L7 연결: [TLS/HTTPS] — ALPN 협상 상세, TLS 1.3 0-RTT와 QUIC의 통합 암호화
  • L8 연결: [gRPC & Protobuf] — HTTP/2 스트림을 gRPC가 어떻게 활용하는지, 채널 관리, 백프레셔

실습 과제: 선택 부록 B의 관찰 스니펫 중 하나를 골라 Protocol, ALPN, Alt-Svc, target group protocol version 중 무엇을 확인하는지 먼저 적고 실행한다. 결과를 본 뒤 “이 서비스는 HTTP/2/3를 쓰는가”가 아니라 “어느 구간에서 어떤 프로토콜로 협상되는가”를 한 문장으로 설명한다.