Learning HTTP/2

HTTP/2에 대해 배우고, HTTP 발전 과정과 웹 최적화 기법들을 자세히 살펴보는 글입니다.

Contents


읽게된 계기

마음먹고 서점에서 소설책을 하나 사려고 했었는데, 사려고 했던 책이 마침 품절 상태였다. 빈손으로 서점에서 오기 아쉬워서 책구경을 하던 중에, 이 책을 발견하게 되었다. 대학생 시절, 네트워크 시간에 HTTP2 에 대해 잠깐 훑어보기만 했었다. UDP 기반의 HTTP3 도 웹 표준으로 등록된 상황에서, HTTP2 스펙에 대해서도 공부해봐야겠다는 생각이 들어서 구매해서 읽게 되었다.



HTTP의 발전과정

Evolution of HTTP 페이지에서는 HTTP 의 발전 과정에 대해 상세히 설명되어 있다.

HTTP/0.9

엄밀히 말하자면, HTTP/0.9 는 존재하지 않는 프로토콜이다. 왜냐하면 HTTP 프로토콜이 처음 세상에 나왔을 때에는, 버전 번호가 붙어있지 않았기 때문이다. HTTP/1.0 이 나온 이후, 이전 버전과 구분하기 위해 HTTP/0.9 라는 이름으로 불리게 되었다. HTTP/0.9 의 스펙은 한 페이지의 문서 로 정리될 만큼 스펙이 간단했다. HTTP/0.9 에서는 단 하나의 메서드(GET) 만 사용할 수 있었고, 어떠한 헤더도 없었으며, 텍스트만 다룰 수 있는 HTML 만 처리할 수 있도록 설계되었다. 1990년대 초반까지만 해도, 인터넷이 문서 열람의 성격을 지니고 있었다고도 볼 수 있겠다. 또 하나 눈여겨 볼 점은, HTTP 요청은 멱등(idempotent)하다 라는 구절이 있는데, 이러한 점이 HTTP/1.0 에 크게 영향을 미친 것 같다. 각 요청들은 상태값을 지니지 않는다는 사실은 안전한 통신을 하는 데에는 도움이 되었지만, 후대에는(HTTP2) 성능 최적화를 위해 점점 상태값을 지니게 바꿔나갔다.

HTTP/1.0

HTTP/0.9 가 발표된 이후, HTTP의 사용이 계속 증가하였다. 1995년에는 80번 포트에서 HTTP 트래픽을 처리하는 서버가 18,000대를 넘어섰다. 이에 따라, 1996년에는 HTTP/1.0 이 RFC 1945 로 발표되었다. HTTP/0.9 가 한 페이지 분량으로 정리될 수 있었던 데에 반해, HTTP/1.0 은 60페이지 정도에 달했다. HTTP/1.0 에는 다음의 개념들이 도입되었다.

HTTP/1.0 은 HTTP/0.9 의 부족한 기능들을 많이 채워 주었지만, 다음과 같은 문제들은 여전히 남아있었다.


HTTP/1.1

1999년에, RFC 2616 이 발표되었다. 이 프로토콜은 지금까지 20년 넘게 사용되고 있다. 1.1 버전에서는 위에 적었던 문제점들이 많이 해결되었다.

HTTP/1.1는 지금까지도 많이 사용되고 있는 프로토콜이지만, 분명히 남아있는 문제들이 있다.

  1. HOL 블로킹 현대의 일반적인 웹페이지의 경우, 특정 호스트에서 단 하나의 개체만 fetch 해오지 않는다. 특정 도메인에 모든 이미지를 넣어둔 웹페이지의 경우, HTTP/1 은 그 이미지들을 동시에 요청할 수 있는 메커니즘을 제공하지 않는다. 이를 해결하기 위해 HTTP/1.1 에서 Pipelining 기능을 추가하였지만, 브라우저에서는 여전히 전송된 순서대로 하나씩 응답을 수신한다. 현대화된 브라우저는 특정 호스트에 최대 6개의 연결을 열고 각 연결로 요층을 전송해 어느 정도 병렬 처리가 가능하다. 하지만 각 연결은 여전히 HOL블로킹의 영향을 받을 수 있다.

  2. TCP의 비효율적 사용 (혼잡 제어)

    TCP 는 데이터를 가장 빨리 전송하는 수단이라기보다는, 데이터를 가장 신뢰성 있게 보낼 수 있는 방법이다. TCP 의 패킷 전송 사이즈는 느린 시작(Slow Start) 를 통해 결정되는데, 첫 패킷 크기는 10으로 결정되고, 수신 측에서 문제가 없다면, 하나씩 패킷 수를 증가시킨다. 만약 수신 측에서 패킷 손실이 일어난다면, 패킷의 크기를 현재 크기의 절반으로 낮춘다. 이러한 방식을 AIMD(Additive Increase/Multiplicative Decrease)라 부른다. 이러한 전략은 안전한 통신을 보장하지만, TCP 연결 초기에는 실제로 가용할 수 있는 패킷의 양에 훨씬 미달하게 통신을 하게 된다. 재미있는 사실은, 이러한 이유로 인해, 프론트엔드 소스코드의 크기는 14.6KB 이하일 때 가장 쾌적하게 동작한다.

  3. 비대한 메시지 헤더 HTTP/1 프로토콜에서는, 헤더 크기를 압축할 수 있는 방법이 없다. HTTP아카이브에 따르면, 2016년 기준으로 요청 헤더의 평균 크기는 460byte 이다. 한 패킷의 전체 크기가 1500byte 라는 점을 생각해 볼 때, 패킷의 상당히 많은 부분이 헤더에 할당되고 있다는 점을 알 수 있다.

  4. 제한적인 우선순위 위에서 말했듯이, HTTP 요청 과정에서 HOL블로킹이 발생할 수 있다. 우리가 웹 브라우저를 사용하는 일반적인 상황을 상상해 보면, HOL블로킹은 꽤 짜증나는 상황을 불러올 수 있다. 구글에 검색을 했을 때, 특정 이미지가 네트워킹 과정에서 다운로드 되지 않아, 브라우저의 화면 자체가 렌더링되지 않는다면 매우 짜증날 것이다.

HTTP/1.1 가 발표된 이후, 한동안 새로운 프로토콜이 발표되지 않았다. 하지만 웹은 빠른 속도로 발전하고 있었고, 새로운 프로토콜에 대한 구상이 이루어지고 있었다.


SPDY

2009년, 구글의 마이크 벨시와 로베르토 페온은 HTTP의 대안으로 SPDY를 제시했다. HTTP를 대체하려는 제안은 이전에도 있었지만, 모두 HTTP/1.1 과의 호환성을 지켜야 한다는 점 때문에, 눈에 띄는 변화가 있지는 않았다. SPDY 는 HTTP/1.1 과의 호환성을 포기하고, 다양한 변화를 시도하였다. 다중화(multiplexing), 프레이밍(framing), 헤더 압축(header compression) 과 같은 새로운 시도가 있었다. 주요 브라우저들 대부분이 SPDY 프로토콜을 지원하기 시작했고, 많은 서버와 프락시도 이에 맞추어 SPDY 를 지원해야만 했다.


HTTP2

2012년 초, SPDY 의 개선점들을 참고하여 HTTP2 에 대한 워킹 그룹이 구성되었다. HTTP/2.0은 다음을 목표로 한다.


HTTP/1 와 대조되는 HTTP/2 만의 특징들을 간단하게 정리해보자면 다음과 같다.

GET / HTTP/1.1
Host: www.google.com:443
Proxy-Connection: keep-alive
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36

HTTP/1 은 텍스트 구분 기반의 프로토콜이다. crlf(\r ) 을 기준으로 데이터를 파싱한다. 텍스트 구분 기반의 프로토콜은 다음과 같은 문제점들이 있다.

반면에, 프레임을 사용하면 수신자는 무엇을 수신할 지 미리 알 수 있다. 예를 들어, 첫 3바이트를 읽으면 데이터의 크기를 미리 알아낼 수 있다. 또한 HTTP/2 의 프레임 유형은 4번째 바이트를 파싱하면 된다. (아래 그림 참조)

HTTP/2 프레임 헤더의 구조

프레임의 유형은 다음과 같다.

| 이름 | ID | 설명 | | --- | --- | --- | | DATA | 0x0 | 특정 스트림의 핵심 내용을 전송한다. | | HEADERS | 0x1 | HTTP 헤더를 포함하며, 선택적으로 우선순위를 포함할 수 있다. | | PRIORITY | 0x2 | 스트림 우선순위와 의존성을 표시 또는 변경한다. | | RSTSTREAM | 0x3 | 엔드포인트가 스트림을 닫도록 허용한다(보통, 오류가 발생한 경우) | | SETTINGS | 0x4 | 연결 초기에 매개변수를 주고받는다. | | PUSHPROMISE | 0x5 | 서버가 무언가를 보내려 핟나느 사실을 클라이언트에 알려준다. | | PING | 0x6 | 연결을 테스트하고 RTT를 측정한다. | | GOAWAY | 0x7 | 상대방이 새로운 스트림을 수신했음을 엔드포인트에 알려준다. | | WINDOW_UPDATE | 0x8 | 엔드포인트가 얼마나 많은 바이트를 수신할 수 있는지 주고받는다. (흐름 제어에 사용) | | CONTINUATION | 0x9 | HEADER 블록을 확장하는데 사용한다. |

모든 스트림의 시작 윈도우 크기는 기본적으로 65,536(2^16-1)bytes이다. 클라이언트 엔드포인트 A가 이 기본값을 유지하고 상대측 B가 10,000bytes를 보낸다고 가정해보자. B는 윈도우 크기를 계속 추적한다(현재 55,535bytes). 이제, A가 5,000bytes를 처리한 후, 자신의 윈도우 크기가 이제 60,535bytes라고 알려주는 WINDOW_UPDATE 프레임을 보낸다. B는 이 프레임을 수신한 후, 큰 파일(4GB)을 보내기 시작한다. B는 현재 윈도우 크기(이 경우 60,535 bytes) 까지만 보낼 수 있으며, 그 후 A가 더 많은 바이트를 수신할 준비가 되었음을 알려주기를 기다려야 한다. 이런 방식으로, A는 B가 데이터를 보낼 수 있는 최대량을 제어할 수 있다.


HTTP/2 는 스트림 의존성을 통해 이를 해결한다. HEADERS, PRIORITY프레임을 사용하면, 클라이언트는 필요한 콘텐츠의 순서를 쉽게 주고받을 수 있다. 이는 의존성 트리와 그 트리 안에 상대적인 가중치를 선언하여 이루어진다.

요청 #1
    :authority: www.foo.com
    :method: GET
    :path: /
    :scheme: https
    accept: text/html,application/xhtml+xml
    aaccept-language: en-US,en:q=0.8
    cookie: foo=bar
요청 #2
    :authority: www.foo.com
    :method: GET
    :path: /style.css
    :scheme: https
    accept: text/html,application/xhtml+xml
    aaccept-language: en-US,en:q=0.8
    cookie: foo=bar1


두 번째 요청 중 많은 부분이 첫 번째 요청을 반복하고 있다. HPACK 방식을 사용하면, 첫 번째 요청에서 바뀐 부분만 요청으로 보낼 수 있다. RFC 7541 을 살펴보면, HPACK 에 대해 상세히 기술되어 있다.


Web Optimization

오늘날 사용되는 웹 최적화 기법은 HTTP/1 에서만 유효한 기법들도 있고, 심지어 어떤 부분들에 대해서는 HTTP/2 에서는 안티패턴으로 여겨지는 기법들도 있다. 한 번씩 읽어보았을 때에는, 웹 최적화를 위해 각종 꼼수들을 도입한 느낌이었는데, 특히 FE 는 프로젝트들에 적용시켜도 좋은 부분들도 있었다.

  1. DNS 조회 최적화 DNS 조회는 호스트와 연결이 수립되기 전에 이루어져야 하므로, 이 조회 절차는 가능한 한 빨라야 한다. *고유한 도메인/호스트이름의 수를 제한 *핸드쉐이크 후 Connection 수립을 위해 필요한 시간을 절약할 수 있다. *초기 HTML이나 응답에 대해 DNS prefetch 를 활용 *초기 HTML 을 내려받아 처리하는 동안 페이지에 있는 Host 이름들의 DNS 조회를 시작한다. <link rel="dns-prefetch" href="//ajax.googleapis.com">

  2. TCP 연결 최적화 새 Connection 을 여는 일은 시간이 오래 걸리는 절차일 수 있다. preconnect 를 활용한다면, 필요하기 전에 미리 연결을 수립함으로써 폭포수 임계 경로 연결시간을 제거해준다. <link rel="preconnect" href="//fonts.example.com" crossorigin>

  3. 리다이렉션을 지양

  4. 클라이언트에 캐싱 로컬에서 콘텐츠를 검색하면, 가장 속도가 빠르고 ISP나 CDN 공급자로부터 요금을 청구받을 일도 없다. 브라우저에 TTL 을 설정하여, 브라우저에서 자원을 캐싱할 수 있다. 클라이언트 캐싱 TTL 을 Cache-Control HTTP 헤더의 max-age (초) 키를 사용하거나 Expires 헤더를 사용해 설정할 수 있다.

  5. 네트워크 경계에 캐싱 네트워크 경계(Edge) 에 캐싱하면 모든 사용자가 클라우드 내의 공유 캐시의 혜택을 얻을 수 있으므로, 더 빠른 사용자 경험을 제공하고 인프라에서 많은 양의 트래픽을 덜어낼 수 있다. 캐싱할 수 있는 자원은 다음과 같다. *여러 사용자 간 공유 가능한 자원 *어느 정도 노후도를 수용할 수 있는 자원

  6. 조건부 캐싱 클라이언트가 서버에 그 개체가 변경되었으면 보내달라, 그렇지 않으면 그냥 동일하다고 알려달라 라고 요청하는 기능이다. 조건부 캐싱을 사용하는 방법은 다음과 같다. *If-Modified-Since HTTP 헤더를 요청에 포함한다. 서버는 최신 콘텐츠가 헤더에 명시된 시점 이후에 갱신된 경우에만 전체 콘텐츠를 반환하며, 그렇지 않으면 응답 헤더에 새 Timestamp Date 를 포함한 304 응답을 반환한다. *개체를 고유하게 식별하는 엔티티 태그, 즉 ETag를 요청에 포함한다. 서버는 ETag를 헤더에 포함하여 개체 자체와 함께 재공한다. 서버는 현재 ETag를 요청 헤더의 ETag와 비교한 후, 동일하면 304를 반환하고, 다르면 전체 콘텐츠를 반환한다.

예시로, S3 를 통해 캐싱이 되어있는 페이지의 경우, 브라우저에서 다음과 같은 요청을 보내게 된다.

#Request
GET /post/dev/ HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: max-age=0
Connection: keep-alive
Host: kim.heejae.info
If-Modified-Since: Fri, 01 Apr 2022 11:07:03 GMT
If-None-Match: W/"77588-17fe4cea828"
Referer: https://kim.heejae.info/post/dev/210
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36
sec-ch-ua: "Google Chrome";v="105", "Not)A;Brand";v="8", "Chromium";v="105"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"


#Response
HTTP/1.1 304 Not Modified
Server: nginx/1.18.0
Date: Sun, 11 Sep 2022 03:25:55 GMT
Connection: keep-alive
X-Powered-By: Express
Vary: Origin
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Fri, 01 Apr 2022 11:07:03 GMT
ETag: W/"77588-17fe4cea828"


실제 브라우저에서는(크롬 기준), Etag 헤더가 아닌 If-None-Match Header 헤더도 조건부 캐싱에 사용되는듯 하다. 재미있는 점은, 크롬 강제 새로고침(CMD+SHIFT+R) 을 하면 ETag 값을 변경시켜서 요청을 날릴 줄 알았는데, 실제로 해보니 브라우저가 Header 에 If-None-Match Header 를 빼고 요청을 보내고 있었다.

  1. 압축과 축소화 HTML 태그에서 인간의 가독성을 위해 도움이 되는 Code Formatting 을 없애고, 용량을 줄인 뒤 요청을 전송한다. 또한 gzip, deflate 등을 통해 요청을 더더욱 압축할 수 있다.


기타

~  $ curl -v --http2 https://www.wiselycompany.com
*   Trying 13.225.112.128:443...
* Connected to www.wiselycompany.com (13.225.112.128) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES128-GCM-SHA256
* ALPN, server accepted to use h2
* Server certificate:
*  subject: CN=*.wiselyshave.com
*  start date: Aug  9 00:00:00 2022 GMT
*  expire date: Sep  7 23:59:59 2023 GMT
*  subjectAltName: host "www.wiselycompany.com" matched cert's "*.wiselycompany.com"
*  issuer: C=US; O=Amazon; OU=Server CA 1B; CN=Amazon
*  SSL certificate verify ok.
* Using HTTP2, server supports multiplexing
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x126013a00)
> GET / HTTP/2
> Host: www.wiselycompany.com
> user-agent: curl/7.79.1
> accept: */*
>
* Connection state changed (MAX_CONCURRENT_STREAMS == 128)!
< HTTP/2 200
< content-type: text/html
< content-length: 5673
< date: Sun, 11 Sep 2022 03:20:25 GMT
< cache-control: no-cache
< last-modified: Thu, 08 Sep 2022 09:00:46 GMT
< etag: "32a04edf34ba6617cba67adf63f50115"
< server: AmazonS3
< vary: Accept-Encoding
< x-cache: Miss from cloudfront
< via: 1.1 d3e79189173c2fa1e1bfddff07afdfe4.cloudfront.net (CloudFront)
< x-amz-cf-pop: ICN54-C1
< x-amz-cf-id: vMG1YOKuj6mMQtrkyjjetqtXA4-Bqm5EZkqZVetOOWaL6PpRiieiWw==
<
<!DOCTYPE html><html lang="ko-KR"><head>
...





이것도 읽어보세요