실시간 통신 — WebSocket·gRPC 스트리밍·WebRTC
HTTP/2 — 연결 하나에 요청을 겹쳐 싣다
한 줄 요약
HTTP/2 는 연결 하나에 요청 여럿을 스트림으로 겹쳐 싣고 헤더를 압축하지만, 그 대가로 모든 스트림이 연결 하나의 흐름 제어 창과 TCP 바이트 흐름을 나눠 씁니다.
왜 이게 필요했나
HTTP/1.1 의 연결은 한 번에 요청 하나만 처리합니다. 명세에는 응답을 기다리지 않고 요청을 이어 보내는 파이프라이닝이 있지만, 응답은 요청 순서대로만 돌아와야 해서 앞의 느린 응답이 뒤를 모두 붙잡습니다. 이것이 애플리케이션 수준의 머리 막힘(head-of-line blocking)입니다. 그래서 브라우저와 클라이언트 라이브러리는 파이프라이닝 대신 연결을 여러 개 여는 쪽을 택했고, 연결마다 TCP 핸드셰이크와 TLS 핸드셰이크, 혼잡 창의 느린 출발을 따로 치렀습니다. 요청마다 똑같이 반복되는 쿠키와 인증 헤더도 매번 평문으로 다시 보냈습니다.
RFC 9113 의 HTTP/2 는 이 두 가지 낭비를 겨냥합니다. 연결은 하나로 두고 그 위에 독립된 스트림을 여럿 열며, 헤더는 연결 단위의 상태를 가진 압축(RFC 7541 HPACK)으로 줄입니다. gRPC 는 이 위에서 돌고, 스트리밍 응답이 갑자기 멈추는 장애의 상당수가 여기서 설명하는 흐름 제어 창에서 납니다.
어떻게 동작하나
HTTP/2 의 모든 것은 프레임입니다. 4.1절의 프레임 머리는 9바이트로 고정되어 있습니다.
| 필드 | 크기 | 뜻 |
|---|---|---|
| Length | 24비트 | 머리를 뺀 본문 길이. 기본 상한은 16,384(SETTINGS_MAX_FRAME_SIZE) |
| Type | 8비트 | DATA 0x0, HEADERS 0x1, RST_STREAM 0x3, SETTINGS 0x4, PING 0x6, GOAWAY 0x7, WINDOW_UPDATE 0x8 … |
| Flags | 8비트 | END_STREAM, END_HEADERS 처럼 종류마다 뜻이 다르다 |
| R + Stream Identifier | 1 + 31비트 | 예약 비트는 보낼 때 0, 받을 때 무시한다 |
클라이언트가 여는 스트림은 홀수, 서버가 여는 스트림은 짝수 번호이고, 0번은 연결 전체의 제어(SETTINGS·PING·GOAWAY)에 씁니다. 요청 하나는 HEADERS 프레임으로 시작해 END_STREAM 플래그로 끝나고, 서로 다른 스트림의 프레임은 한 연결 안에서 얼마든지 섞여 흐릅니다. 연결은 늘 클라이언트의 서문(PRI * HTTP/2.0 으로 시작하는 24바이트)과 양쪽의 SETTINGS 로 시작하고, HTTP/2 를 쓰기로 정하는 방법은 TLS 에서는 ALPN 의 h2, 평문에서는 3.3절의 사전 지식(prior knowledge)입니다.
동시에 열 수 있는 스트림 수는 받는 쪽이 SETTINGS_MAX_CONCURRENT_STREAMS 로 알립니다. 6.5.2절은 이 값의 초깃값이 무제한이고, 100 보다 작게 두지 말 것을 권합니다. 여기서 흔한 함정이 나옵니다. 상대의 첫 SETTINGS 가 도착하기 전에는 한도를 모르므로, 서문을 보내자마자 요청을 몰아 보내면 한도를 넘길 수 있습니다.
흐름 제어(5.2절, 6.9절)는 DATA 프레임에만 걸립니다. 창은 스트림마다 하나, 연결 전체에 하나 있고, 둘 다 65,535 바이트로 시작합니다. 보내는 쪽은 두 창 가운데 작은 쪽만큼만 보낼 수 있고, 받는 쪽이 WINDOW_UPDATE 로 창을 돌려줘야 더 보냅니다. SETTINGS_INITIAL_WINDOW_SIZE 로 바꿀 수 있는 것은 스트림 창의 초깃값뿐이고, 연결 창은 WINDOW_UPDATE 로만 넓어집니다. 그래서 스트림 창을 100,000 으로 알려도 첫 응답은 65,535 바이트에서 멈춥니다. 창을 돌려주지 않는 클라이언트 하나가 있으면 큰 응답 하나가 연결 창을 다 쓰고, 같은 연결의 3바이트짜리 응답까지 기다리게 됩니다.
HPACK 은 61개 항목의 정적 테이블과, 연결마다 하나씩 쌓이는 동적 테이블(기본 4,096 바이트)을 씁니다. 같은 연결에서 같은 헤더를 두 번째 보낼 때는 테이블 번호 하나로 줄어듭니다. 연결을 새로 열면 테이블도 비어서 처음부터 다시 커집니다. 압축의 이득은 연결을 오래 쓰는 데서 옵니다.
현장에서 만나는 모습
HTTP/2 로 바꾸자 느린 페이지는 빨라졌는데, 모바일 망에서는 오히려 나빠졌다는 보고가 흔합니다. HTTP/1.1 의 연결 여섯 개는 서로 독립이라 하나에서 패킷이 사라져도 나머지 다섯은 흐릅니다. HTTP/2 의 연결 하나는 스트림이 몇 개든 TCP 입장에서는 바이트 흐름 하나라서, 세그먼트 하나를 다시 보내는 동안 모든 스트림이 섭니다. 다음 모듈이 이 이야기를 이어 갑니다.
또 하나는 gRPC 스트림이 몇 초씩 멈추는 장애입니다. 받는 쪽 애플리케이션이 메시지를 늦게 꺼내면 라이브러리가 창을 늦게 돌려주고, 보내는 쪽은 창이 닫혀 기다립니다. 네트워크는 한가한데 처리량이 바닥인 모습으로 보입니다.
다음 실습에서 할 것
h2 라이브러리로 프레임 머리를 읽고, HPACK 크기를 재고, 한 연결에 요청을 겹쳐 싣는 클라이언트를 만듭니다. 그다음 동시 스트림 한도를 지키게 고치고, 창이 닫히는 바이트 수를 16,384·임의 값·100,000 세 경우로 재서 연결 창의 존재를 숫자로 확인합니다. 마지막에는 TCP 가 한 번 멈추는 중계기를 두고 HTTP/2 와 HTTP/1.1 의 작은 응답이 언제 끝나는지 견줍니다.