CHAPTER5 - HTTP/2 프로토콜-3

2019. 3. 5. 12:16Learning HTTP2


5.5. 서버 푸시

특정 개체의 성능을 개선하는 가장 좋은 방법은 그 개체가 요청되기 전에 브라우저의 캐시에 미리 가져다 두는 것이다. 이것은 바로 HTTP/2의 서버 푸시 기능의 목표다. 서버는 가까운 미래에 특정 개체가 필요하리라는 것을 알 수 있기 때문에 푸시를 통해 클라이언트에 그 개체를 미리 전송할 수 있다. 서버가 임의로 클라이언트에 개체를 전송하도록 허용하면, 성능과 보안을 포함한 여러 면에서 문제가 발생할 수 있으므로 이것은 단순히 푸시를 하는 것에 관한 문제가 아니라, 어떻게 푸시를 잘 할 것인가에 관한 문제다.


5.5.1 개체 푸시하기

서버는 개체를 푸시(RFC에는 '응답을 푸시한다'고 표현되어 있다)하기로 결정하면 PUSH_PROMISE 프레임을 구성한다. 이 프레임에는 다음과 같은 여러 중요한 속성이 있다.

  • PUSH_PROMISE 프레임 헤더의 스트림 ID는 응답과 쌍을 이루는 요청의 스트림 ID다. 푸시된 응답은 클라이언트가 이미 보낸 요청과 항상 연관되어 있다. 예를 들어, 브라우저가 기준 HTML 페이지를 요청하면, 서버는 그 HTML 페이지의 자바스크립트 개체에 대한 PUSH_PROMISE 프레임을 해당 요청의 스트림 ID로 구성한다.
  • PUSH_PROMISE 프레임은 클라이언트가 개체를 요청할 때 전송하는 것과 동일한 헤더 블록을 가진다. 이를 통해 클라이언트는 전송받을 콘텐츠에 무결성 검사를 할 수 있다.
  • 전송할 개체는 캐싱할 수 있어야 한다.
  • :METHOD 헤더 필드는 안전해야 한다. 여기서 안전하다는 것은 멱등적 이어야 한다는 의미로, 무언가로 인해 상태가 변하지 않는다는 것을 고급스럽게 표현한 말이다. 예를 들어, GET 요청은(일반적으로) 개체를 가져오기만 하므로 멱등적인 반면에, POST 요청은 서버측 상태가 변경될 수 있으므로 비멱등적이다.
  • 이상적으로, PUSH_PROMISE 프레임은 푸시될 개체에 대한 정보를 담은 DATA 프레임을 클라이언트가 수신하기 전에 전송이 완료되어야 한다. 예를 들어, 서버가 PUSH_PROMISE 프레임 전송하기 전에 전체 HTML을 전송하게 되면, 클라이언트는 PUSH_PROMISE를 수싢사기도 전에 그 개체에 대한 요청을 이미 전송해 버렸을 수도 있다. h2 프로토콜은 이런 상황을 자연스럽게 처리할 수 있을 만큼 견고하지만, 무의미한 노력과 기회가 소모된 것은 돌이킬 수 없다.
  • PUSH_PROMISE 프레임에는 다음에 전송될 응답의 스트림 식별자가 지정된다.


NOTE_ 클라이언트가 스트림 식별자를 선택할 때는 1부터 시작해 새 스트림마다 2씩 증가한다. 서버가 PUSH_PROMISE에 지정된 새 스트림을 개시할 때는 2부터 시작해 짝수를 사용한다. 이는 클라이언트와 서버 사이에서 스트림 ID의 경쟁 상황을 피하고, 어떤 개체들이 푸시되었는지를 쉽게 식별하기 위한 것이다. 스트림 0은 전반적인 연결 제어 메시지용으로 예약되어 있으며 새 스트림에는 사용할 수 없다.
 

앞서 나열한 PUSH_PROMISE의 조건이 충족되지 않으면, 거부 사유에 따라 클라이언트는 새 스트림을 재설정(RST_STREAM 프레임을 전송)하거나 PROTOCOL_ERROR를 전송(GOAWAY 프레임)할 수 있다. 한 가지 흔한 경우가 바로 해당 개체가 캐시에 이미 있는 경우다. 오류 응답은 안전하지 않은 메서드를 사용한 경우처럼 PUSH_PROMISE의 프로토콜 수준에서 문제가 발생했거나 클라이언트가 SETTINGS 프레임에 푸시를 허용하지 않겠다고 지정했는데도 서버가 푸시를 전소안 경우에 발생한다. 서버는 PUSH_PROMISE를 전송한 즉시 스트림을 시작할 수 있으므로, 전송 중인 푸시를 취소하더라도 이미 많은 자원이 전송되어 버렸을 수도 있다는 점에 주목할 필요가 있다. 올바른 것만 푸시해야 성능상 중요한 기능을 할 수 있다.

클라이언트가 푸시를 거부하지 않으면, 서버는 PUSH_PROMISE에 지정한 새 스트림 식별자로 개체를 전송한다([그림 5-4] 참조).

그림 5-4 서버 푸시 메시지 흐름



5.5.2 푸시할 대상 선정하기

애플리케이션에 따라서는 푸시할 대상을 결정하기가 까다롭거나 매우 복잡할 수도 있다. 예를 들어, 간단한 HTML 페이지 하나를 생각해보자. 서버가 이 HTML 페이지에 대한 요청을 수신하면, 그 페이지의 개체들을 푸시할지, 클라이언트의 요청을 기다릴지 결정해야 한다. 이 결정 절차는 다음을 고려해야 한다.

  • 개체가 이미 브라우저의 캐시에 있을 가능성
  • 클라이언트의 관점에서 본 개체의 추정 우선순위(5.4.3절 '운선순위' 참조)
  • 푸시를 수신하는 클라이언트의 능력에 영향을 미칠 수 있는 가용 대역폭 등의 자원

서버가 올바로 결정한다면, 전반적인 페이지의 성능에 실질적으로 도움이 될 수 있지만, 잘못된 결정은 역효과를 가져올 수 있다. 이것이 아마도 SPDY가 이 기능을 도입한 지 5년이 넘은 오늘날에도 범용 푸시 솔루션이 그리 흔하지 않은 이유다.

h2로 통신하는 API나 애플리케이션 같은 특수한 경우에는 가까운 미래에 무엇이 필요할지와 클라이언트가 무엇을 캐싱하고 있는지 더 쉽게 판단할 수 있다. 애플리케이션에 업데이트를 전송하는 서버를 생각해보라. 이것이 단기적으로는 푸시의 혜택을 가장 많이 볼 수 있는 경우다.


5.6 헤더 압축(HPACK)

3.1.3절 'HTTP/1의 문제점의 비대한 메시지 헤더'에서 언급한 것처럼, 웹 페이지당 평균 요청 수는 약 140개, HTTP 요청당 평균 크기는 460 bytes로, 웹 페이지당 전체 요청의 크기는 총 63KB에 달한다. 이 때문에 최적의 환경에서도 상당한 지연이 발생하지만, 혼잡한 WiFi나 열악한 무선 연결 환경에서는 고통스러울 정도로 상황이 악화될 수 있다. 진짜 문제는 여러 요청 사이에는 완전히 새롭고 고유한 바이트는 거의 없다는 점이다. 이것이 압축이 필요한 이유다.

헤더 압축은 HTTP/2의 핵심이라고 처음부터 알려졌다. 그러나 어떻게 압축해야 하는가? 디플레이트 헤더 압축을 사용해 프레임을 암복호화하는 SPDY에서 사용하던 접근법은 제외되었다. GZIP에 필적하는 압축 성능을 가지면서도 CRIME 공격을 방어할 수 있는 메커니즘이 필요했다.

많은 혁신적인 아이디어를 검토한 끝에, HPACK이 제안되었다. HPACK은 테이블 참조 압축 알고리즘으로, 허프만 인코딩을 활용해 GZIP에 버금가는 압축률을 달성한다. HPACK의 동작 방식은 다음의 간단한 예제로 잘 이해할 수 있다.


CAUTION_ 헤더 압축에 단순히 GZIP을 사용하지 않고 HPACK을 사용하는 이유는 무엇일까? GZIP을 사용하면 분명히 많은 수고를 덜 수 있었을 것이다. 불행히도 GZIP도 CRIME 공격에 암호화된 정보가 노출되는 취약점이 있다. CRIME은 공격자가 요청 메시지에 특정 데이터를 추가한 후, 그 결과로 암호화된 압축 페이로드의 크기가 줄어드는지를 관찰하여 취약점을 찾아내는 방식으로 동작한다. 크기가 줄어든다면, 공격자는 추가한 문자가 보안 세션 쿠키 등 요청 메시지의 어딘가에 반복되어 있다는 것을 알 수 있다. 이러한 방식으로 비교적 짧은 시간 안에  암호화된 전체 페이로드를 해독할 수 있다. 따라서, 이미 나와 있는 압축 알고리즘은 제외되고, HPACK이 고안되엇다.
 

웹 페이지와 그 의존 개체를 내려받는 데는 많은 요청이 필요하다. 이 요청 수는 웹 페이지 하나에 보통 수백 개에 달한다. 이 요청들은 대개 매우 유사한 경향이 있다. 예를 들어, 다음 두 개의 요청을 살펴보자. 이 두 요청은 전체 웹 페이지를 요청하느라 하나의 브라우저 세션에서 연이어 전송된다. 두 요청 메시지에서 차이가 나는 부분은 굵은 글자로 강조했다.

요청 #1:

 :authority: www.akamai.com

 : method: GET

 :scheme: https

 accept: text/html.application/xhtml+xml

 accept-language: en-US,en;q=0.8

 cookie: last_page=286A7F3DE

 upgrade-insecure-requests: 1

 user-agent: Awesome H2/1.0


요청 #2:

 :authority: www.akamai.com

 : method: GET

 :path: /style.css

 :scheme: https

 accept: text/html.application/xhtml+xml

 accept-language: en-US,en;q=0.8

 cookie: last_page=*398AB8E8F

 upgrade-insecure-requests: 1

 user-agent: Awesome H2/1.0

두 번째 요청 중 많은 부분이 첫 번째 요청을 반복하고 있음을 볼 수 있다. 첫 번째 요청은 약 220 bytes며, 두 번째는 약 230 bytes다. 그러나 단 20 bytes만 차이가 난다. 그 20bytes만 전송한다면 전송 바이트 수를 90% 정도 줄일 수 있을 것이다. 이것이 HPACK의 기본 동작 방식이다.

다음은 HPACK의 동작을 성명하기 위해 단순하게 만든 예다. 현실은 훨씬 더 삭막하고 이상향과는 거리가 멀다. HPACK의 동작을 설명하기 위해 단순하게 만든 예다. 현실은 훨씬 더 삭막하고 이상향과는 거리가 멀다. HPACK에 관해 더 많이 알고 싶다면, RFC 7541 "HPACK: Header Compression for HTTP/"를 읽어보길 바란다.

클라이언트가 다음 헤더를 순서대로 전송한다고 가정하자.

  • Header1:foo
  • Header:bar
  • Header3:bat

클라이언트는 요청을 전송할 때, 특정 헤더와 그 값을 색인해야 한다고 헤더 블록에 표시할 수 있다. 클라이언트는 다음과 같은 테이블을 생성한다.

색인 번호 

이름 

값 

62 

Header1 

foo 

63 

Header2 

bar 

64 

Header3 

bat 

서버는 그 헤더를 읽고, 동일한 테이블을 생성한다. 클라이언트는 다음 요청을 전송할 때, 헤더가 동일하다면 단순히 다음과 같은 헤더 블록만 전송할 수 있다.


62 63 64
 

그러면 서버는 테이블을 참조하여 각 색인 번호에 해당하는 전체 헤더로 확장한다.

여기에서의 중요한 의미 중 하나는 모든 연결은 상태를 유지한다는 점이며, 이는 h1 프로토콜은 지원하지 않는 특징이다.

실제 HPACK은 훨씬 더 복잡하다. 다음은 알아둘 필요가 있는 몇가지 사실이다.

  • 사실, 요청과 응답 양쪽에 각각 두 개의 테이블이 생성된다. 하나는 앞의 예와 비슷한 방식으로 생성되는 동적 테이블이다. 다른 하나는 가장 흔히 사용되는 61개의 헤더 이름과 값의 조합으로 구성된 정적 테이블이다. 예를 들어, :method: GET은 정적 테이블의 색인 번호 2에 있다. 정적 테이블은 61개의 항목으로 정의되어 있으며, 이것이 앞의 예에서 색인 번호가 62부터 시작한 이유다.
  • 항목을 색인하는 방식에는 다음과 같은 여러 방식이 있다.
    - 문자 값과 색인 번호를 전송(앞의 예와 동일)
    - 문자 값을 전송하고 색인하지 않음(일화성이거나 민감한 헤더인 경우)
    - 헤더 이름을 문자 값과 함께 전송하고 색인하지 않음(:path: /foo.html과 같이 값이 항상 변하는 경우)
    - 헤더와 값에 대한 색인 번호만 전송(앞의 예의 두 번째 요청)
  • 높은 압축 효율성을 위해 정수 압축 알고리즘을 사용한다.
  • 높은 문자열 압축률을 위해 허프만 코딩 테이블을 사용한다.

시럼 결과에 따르면, HPACK은 매우 잘 동작하며, 측히 대용량 헤더(쿠키 등)가 반복 전송되는 사이트에서 더 효율적인 것으로 밝혀졌다. 특정 웹사이트로 전송되는 많은 요청에서 헤더가 반복되기 때문에, HPACK의 테이블 참조 메커니즘은 이러한 반복 전송을 효율적으로 제거해준다.