[HTTP 완벽가이드] 10. HTTP/2.0

HTTP의 성능 문제를 개선하기 위해 HTTP/2.0을 만들고 있다.

 

HTTP/2.0의 등장 배경

HTTP/1.1의 메시지 포맷은 구현의 단순성과 접근성에 주안점을 두고 최적화되었기에, 성능을 어느정도 희생시켜야 했다. 커넥션 하나를 통해 요청 하나를 보내고 그에 대한 응답 하나만을 받는 HTTP의 메시지 교환 방식은 단순함 면에서는 우수했지만, 응답을 받아야만 그 다음 요청을 보낼 수 있기 때문에 심각한 회전 지연(latency)가 발생했다. 

이 문제를 회피하기 위해 병렬 커넥션, 파이프라인 커넥션이 도입되었지만 성능 개선에 대한 근본적인 해결책이 되지 못했다.

 

구글은 웹을 더 빠르게 하겠다는 목표 아래 SPDY 프로토콜을 내놓았다. SPDY는 헤더를 압축하여 대역폭을 절약했고, 하나의 TCP 커넥션에 여러 요청을 동시에 보내 회전 지연을 줄이는 것이 가능했으며 클라이언트가 요청을 보내지 않아도 서버가 능동적으로 리소스를 푸시하는 기능도 갖추고 있다. 

2012년 10월 3일, HTTP 작업 그룹은 SPDY를 기반으로 HTTP/2.0 프로토콜을 설계하기로 결정하였음을 밝혔다. 

 

개요

HTTP/2.0은 서버와 클라이언트 사이의 TCP 커넥션 위에 동작한다. 이 때 TCP 커넥션을 초기화하는 것은 클라이언트다.

 

HTTP/2.0 요청과 응답은 길이가 정의된 한 개 이상의 프레임에 담기며, 헤더는 압축되어 담긴다. 

프레임들에 담긴 요청과 응답은 스트림을 통해 보내지며, 한 개의 스트림이 한 쌍의 요청과 응답을 처리한다. 하나의 커넥션 위에 여러 개의 스트림이 동시에 만들어질 수 있으므로, 여러 개의 요청과 응답을 동시에 처리하는 것도 가능하다.

HTTP/2.0은 이들 스트림에 대한 흐름 제어와 우선순위 부여 기능도 제공한다.

 

HTTP/2.0은 기존 요청-응답과는 다른 새로운 상호작용 모델인 서버 푸시를 도입했다. 서버는 클라이언트에게 필요하다고 생각하는 리소스라면 그에 대한 요청을 명시적으로 받지 않더라도 능동적으로 클라이언트에게 보내줄 수 있다.

 

기존 웹 애플리케이션과의 호환성을 최대한 유지하기 위해 HTTP/2.0은 요청과 응답 메시지의 의미를 HTTP/1.1과 같도록 유지하고 있지만 이를 표현하는 문법은 변경되었다.

 

 

 

HTTP/2.0과 HTTP/1.1의 동작 방식

HTTP/2.0도 HTTP/1.1처럼 TCP 위에서 동작한다. HTTP는 애플리케이션 계층 프로토콜로, TCP(전송 계층 프로토콜)를 기반으로 데이터를 주고 받는다. 따라서 HTTP/1.1과 HTTP/2.0 모두 TCP 위에서 동작한다는 점에서 동일하다.
하지만, HTTP/2.0은 TCP를 더 효율적으로 사용하는 방식으로 개선되었다.

1. HTTP/1.1의 TCP 동작 방식
a. 직렬 요청 처리
HTTP/1.1에서는 기본적으로 하나의 TCP 연결이 한 번에 하나의 요청과 응답만 처리한다.
예: 브라우저가 요청을 보냈을 때, 서버가 응답을 완료해야 다음 요청을 처리할 수 있다. 이를 헤드-오브-라인 블로킹(Head-of-Line Blocking)이라고 한다.
b. HTTP 지속 연결(persistent connection)
HTTP/1.1에서는 Connection: keep-alive 헤더를 통해 하나의 TCP 연결을 여러 요청에 재사용할 수 있다. 그러나 이 경우에도 동시에 여러 요청을 처리할 수는 없다.
c. 병렬 연결
여러 요청을 동시에 처리하려면 여러 개의 TCP 연결을 만들어야 한다. 브라우저는 한 도메인에 대해 최대 6개의 TCP 연결을 생성하도록 제한되어 있다. 이 방식은 네트워크 리소스를 비효율적으로 사용한다.(다수의 연결 관리 필요)

2. HTTP/2.0에서 TCP 동작 방식
a. 멀티플렉싱
HTTP/2.0에서는 단일 TCP 연결에서 여러 요청과 응답을 동시에 처리할 수 있는 멀티플렉싱 기능을 도입했다. 하나의 연결 위에 여러 개의 스트림(stream)을 만들어 병렬로 데이터를 주고 받는다. 요청과 응답 데이터는 프레임으로 쪼개져 전송되며, 스트림 ID로 구분된다.
b. 헤드-오브-라인 블로킹 제거
HTTP/1.1의 경우, 하나의 요청이 지연되면 같은 연결에 있는 다른 요청도 지연되는 문제가 있었다. HTTP/2.0은 멀티플렉싱 덕분에 이러한 문제를 해결했다.
c. TCP 효율성
여러 요청과 응답을 하나의 연결에서 처리하므로, TCP 연결 생성 및 관리 비용이 줄어든다. 연결이 적어져 네트워크 리소스를 더 효율적으로 사용한다.

HTTP/1.1 과의 차이점

프레임

HTTP/2.0 에서 모든 메시지는 프레임에 담겨 전송된다. 모든 프레임은 8 바이트 크기의 헤더로 시작하며, 뒤이어 최대 16383 바이트 크기의 payload가 온다.

 

프레임의 구조

프레임 헤더의 각 필드 구조

  • R
    예약된 2비트 필드. 값의 의미가 정의되어 있지 않으며 반드시 0이어야 한다.
    받는 쪽에서는 이 값을 무시해야 한다.
  • 길이
    페이로드의 길이를 나타내는 14비트 unsigned 정수. 이 길이에 프레임 헤더는 포함되지 않는다.
  • 종류
    프레임의 종류
  • 플래그
    8비트 플래그. 플래그 값의 의미는 프레임의 종류에 따라 다르다.
  • R
    예약된 1비트 필드. 첫 번째 R과 마찬가지로 값의 의미가 정의되어 있지 않으며, 반드시 0이어야 한다.
    받는 쪽에서는 이 값을 무시해야 한다.
  • 스트림 식별자
    31비트 스트림 식별자. 특별히 0은 커넥션 전체와 연관된 프레임임을 의미한다.

 

스트림과 멀티 플렉싱

스트림은 HTTP/2.0 커넥션을 통해 클라이언트와 서버 사이에 교환되는 프레임들의 독립된 양방향 시퀀스다.

 

한 쌍의 HTTP 요청과 응답은 하나의 스트림을 통해서 이뤄진다. 클라이언트는 새로운 스트림을 만들어 HTTP 요청을 보내고, 요청을 받은 서버는 그 요청과 같은 스트림으로 응답을 보낸다. 그 후, 스트림은 닫히게 된다.

 

HTTP/1.1에서는 하나의 TCP 커넥션을 통해 요청을 보냈을 때, 그에 대한 응답이 도착하고 나서야 같은 TCP 커넥션으로 다시 요청을 보낼 수 있다. 따라서 웹 브라우저들은 회전 지연을 줄이기 위해 여러 개의 TCP 커넥션을 만들어 동시에 여러 개의 요청을 보내는 방법을 사용한다. 그러나 TCP 커넥션의 생성 개수에 제한을 두기 때문에 회전 지연이 발생할 수 밖에 없다. 이를 개선하기 위해 파이프라인 커넥션을 사용할 수 있지만 널리 구현되어 있지 않는 방법이다.

 

HTTP/2.0에서는 하나의 커넥션에 여러 개의 스트림이 동시에 열릴 수 있다. 따라서 하나의 HTTP/2.0 커넥션을 통해 여러 개의 요청이 동시에 보내질 수 있으므로 문제는 해결된다.

또한, 스트림은 우선순위도 가질 수 있다. 예를 들어, 네트워크 대역폭이 충분하지 않아 프레임 전송이 느린 경우, 웹 브라우저는 보다 중요한 리소스를 요청하는 스트림에게 더 높은 우선순위를 부여할 수 있다. (다만 우선순위에 따라 처리하는 것이 의무사항은 아니므로, 우선순위대로 처리된다는 보장은 없다)

 

모든 스트림은 31비트의 무부호 정수로 된 고유한 식별자를 갖는다. 스트림이 클라이언트에 의해 초기화되었다면 이 식별자는 반드시 홀수여야 하며, 서버라면 짝수여야 한다. 또한, 새로 만들어지는 스트림의 식별자는 이전에 만들어졌거나 예약된 스트림의 식별자보다 커야 한다. 이 규칙을 어긴 식별자를 받았다면 에러코드가 PROTOCOL_ERROR인 커넥션 에러로 응답해야 한다.

서버와 클라이언트는 스트림을 상대방 협상없이 일방적으로 만들며, 이는 스트림을 만들 때 협상을 위해 TCP 패킷을 주고받느라 시간을 낭비하지 않아도 됨을 의미한다.

HTTP/2.0 커넥션에서 한 번 사용한 식별자는 다시 사용할 수 없다. 커넥션을 오래 사용한 경우, 스트림에 할당할 수 있는 식별자가 고갈되기도 하는데, 이런 경우 커넥션을 다시 맺으면 된다.

또한, 여러개의 스트림을 하나의 커넥션에서 사용하면 스트림이 블록될 우려가 있을 수 있는데 HTTP/2.0은 WINDOW_UPDATE 프레임을 이용한 흐름 제어를 통해, 스트림이 서로 간섭해서 망가지는 것을 막아준다.

 

좀 더 자세히 알아보자

HTTP/2.0에서는 리소스 하나당 스트림(Stream) 하나가 생성되고, 이 스트림들이 하나의 TCP 커넥션 위에서 모두 처리된다.

[1. HTTP/2.0의 스트림(Stream) 개념]
스트림은 HTTP/2.0에서 요청과 응답을 주고받는 논리적인 단위다.각 요청과 응답은 하나의 스트림에서 처리된다.
예시
HTML 요청은 스트림 #1, CSS 요청은 스트림 #2, 이미지 요청은 스트림 #3 등으로 각각 독립적으로 처리되며, 하나의 TCP 커넥션 위에서 여러 스트림이 병렬로 동시에 열릴 수 있다.

[2. 스트림과 프레임(Frame)의 관계]
HTTP/2.0의 요청과 응답 데이터는 프레임(Frame)이라는 작은 단위로 나뉘어 전송된다. 각 프레임은 특정 스트림에 속하며, 고유한 스트림 ID로 구분된다.
예시
스트림 #1: HTML 요청/응답
프레임스트림 #2: CSS 요청/응답
프레임스트림 #3: 이미지 요청/응답 프레임
모든 프레임은 하나의 TCP 커넥션 위에서 동시에 전송되지만, 어떤 스트림에 속하는지 구분된다.

[3. 하나의 TCP 커넥션으로 리소스 처리]
HTTP/1.1에서는 요청마다 별도의 TCP 커넥션을 만들어야 하는 경우가 많았다(또는 직렬 처리)
HTTP/2.0에서는 하나의 TCP 커넥션 위에서 모든 스트림이 독립적으로 동작하므로, 멀티플렉싱(Multiplexing)이 가능해진다. 요청과 응답이 서로 간섭하지 않고 동시에 이루어진다.
예시 웹 페이지 로드

클라이언트는 index.html, style.css, script.js, image.png를 요청한 경우, HTTP/2.0에서는 각 리소스가 하나의 스트림으로 생성됩니다:
스트림 #1: index.html
스트림 #2: style.css
스트림 #3: script.js
스트림 #4: image.png
모든 스트림의 데이터가 TCP 커넥션 위에서 프레임 단위로 병렬 전송된다.

[4. 흐름 제어와 우선순위]
HTTP/2.0은 스트림 간 데이터 전송을 효율적으로 관리하기 위해 다음과 같은 일을 해준다.
- 흐름 제어: 각 스트림에 대해 데이터 전송 속도를 조절합니다.
- 우선순위(priority): 중요한 리소스(예: HTML 문서)가 더 빨리 전송되도록 설정할 수 있습니다.

예시 우선순위 설정
브라우저가 index.html을 우선 처리하고 싶다면, 스트림 #1에 높은 우선순위를 부여.
CSS와 JS 파일은 다음으로 처리.
이미지 파일은 상대적으로 낮은 우선순위로 전송.

 

헤더 압축

HTTP/1.1에서 헤더는 아무런 압축 없이 그대로 전송되었다. 과거에는 웹페이지 하나를 방문할 때 요청이 많지 않았으므로 헤더의 크기가 문제가 되지 않았지만 최근에는 웹 페이지 하나를 보기 위해 수십~수백 개의 요청을 보내기 때문에 헤더의 크기가 회전 지연과 대역폭에 영향을 끼치게 되었다. 

이를 개선하기 위해 HTTP/2.0에서 HTTP 메시지의 헤더를 압축하여 전송한다. 헤더는 HPACK 명세에 정의된 헤더 압축 방법으로 압축된 뒤 헤더 블록 조각 들로 쪼개져서 전송되며, 수신자는 이 조각을 이은 뒤 압축을 풀어 원래의 헤더 집합으로 복원한다.

 

HPACK은 헤더를 압축하고 해제할 때, '압축 콘텍스트'를 사용하므로, 오동작을 방지하기 위해서는 항상 올바른 압축 콘텍스트를 유지해야 한다. 이 압축 콘텍스트는 수신한 헤더의 압축을 풀면 이에 영향을 받아 변경된다. 송신 측은 수신 측이 헤더의 압축을 풀었으며, 그에 따라 압축 콘텍스트가 변경되었다고 가정하므로 헤더를 받은 수신측은 어떤 경우에도 반드시 압축 해제를 수행해야 한다. 만약 압축 해제를 수행할 수 없다면 COMPRESSION_ERROR와 함께 커넥션을 끊어야 한다.

 

압축 콘텍스트란
[1. 압축이란]
데이터 압축은 데이터를 더 작은 크기로 변환하는 과정이며, 효율적인 저장과 전송을 위해 사용된다. 
압축 알고리즘은 데이터 내의 중복성, 패턴을 찾아 제거하거나 더 효율적인 방법으로 표현한다.

[2. 압축 콘텍스트란]
압축 알고리즘이 데이터를 처리할 때 참고하는 현재 상태나 환경을 의미한다.
데이터의 맥락을 고려하여 압축 효율을 높이는데 사용된다.
특정 데이터가 이전에 어떻게 처리되었는지 어떤 패턴이나 빈도가 나타났는지를 기반으로 다음 데이터를 압축하는 방식을 최적화한다.

 

좀 더 자세히 알아보자
[1. HPACK의 압축 콘텍스트란?]
압축 콘텍스트는 HPACK이 헤더 데이터를 압축하거나 해제할 때 사용하는 동적 테이블과 정적 테이블을 포함한 상태 정보를 의미한다.
- 동적 테이블: 이전에 전송된 헤더 필드들을 저장하여 이후 요청에서 중복을 줄이는 데 사용된다.
- 정적 테이블: 고정된 헤더 필드(예: Content-Type, User-Agent 등)를 미리 정의한 테이블
송신자와 수신자가 동일한 압축 콘텍스트를 유지해야 압축된 헤더 데이터를 정확히 해석할 수 있다.

[2. HPACK의 동작 방식]
송신자
- 헤더를 압축할 때, 현재 압축 콘텍스트를 참고하여 중복된 데이터를 줄이고, 이를 기반으로 압축된 헤더 프레임을 생성한다.
- 압축 작업으로 인해 압축 콘텍스트(동적 테이블)가 변경된다.


수신자
- 압축된 헤더를 수신하면, 압축 콘텍스트를 사용하여 이를 압축 해제하고 원래의 헤더로 복원한다. 이 과정에서 수신 측의 압축 콘텍스트도 변경된다.


[3. 압축 콘텍스트 유지의 중요성]
송신자와 수신자가 동일한 압축 콘텍스트를 공유해야, 압축된 데이터가 정확히 해석될 수 있다.
따라서, 수신자는 반드시 압축된 헤더를 해제하여 압축 콘텍스트를 업데이트해야 한다.
예시) 송신자가 "이전 헤더에서 참조된 값"을 기반으로 데이터를 압축했는데, 수신자의 압축 콘텍스트가 이를 알지 못하면 압축 해제가 실패한다.

[4. 압축 해제를 수행하지 못하는 경우]
수신자가 압축 해제를 하지 못하면, 압축 콘텍스트가 동기화되지 않아 이후의 모든 요청과 응답이 잘못 처리될 가능성이 있다.

예시) 수신 측이 헤더 데이터를 해석하지 못하거나, 압축 콘텍스트가 손상된 경우
이러한 상황을 방지하기 위해, HPACK의 명세에서는 다음을 규정한다:
1. 수신자가 압축 해제를 수행할 수 없으면, COMPRESSION_ERROR를 반환해야 한다.
2. 그런 다음, TCP 커넥션을 종료하여 더 이상의 잘못된 데이터를 주고받지 않도록 해야 한다.

 

서버 푸시

HTTP/2.0은 서버가 하나의 요청에 대해 응답으로 여러 개의 리소스를 보낼 수 있도록 해준다. 이 기능은 서버가 클라이언트에서 어떤 리소스를 요구할 것인지 미리 알 수 있는 상황에서 유용하다. (예를 들면, HTML 문서를 요청받은 서버는 그 HTML 문서가 링크하고 있는 이미지, CSS 파일 등의 리소스를 클라이언트에게 푸시할 수 있다.)이는 클라이언트가 HTML 문서를 파싱해서 필요한 리소스를 다시 요청하여 발생하게 되는 트래픽과 회전 지연을 줄여준다.

 

리소스를 푸시하려는 서버는 먼저 클라이언트에게 자원을 푸시할 것임을 PUSH_PROMISE 프레임을 보내어 미리 알려주어야 하고, 클라이언트가 해당 프레임을 받게되면 해당 프레임의 스트림은 클라이언트 입장에서 "예약됨(원격)" 상태가 된다. 이 상태에서 클라이언트가 RST_STREAM 프레임을 보내게 되면 그 스트림은 즉각 닫히게 된다. 스트림이 닫히기 전까지 클라이언트는 서버가 푸시하려는 리소스를 요청해서는 안된다.

 

왜 PUSH_PROMISE 프레임을 먼저 보낼까?

서버가 푸시하려고 하는 자원을 클라이언트가 별도로 또 요청하게 되는 상황을 피하기 위함이다. 

 

서버 푸시를 사용할 때의 주의사항

  • 서버 푸시를 사용하기로 했더라도 중간의 프락시가 서버로부터 받은 추가 리소스를 클라이언트에게 전달하지 않을 수 있으며, 아무런 추가 리소스를 서버로부터 받지 않았음에도 클라이언트에게 추가 리소스를 전달할 수 있다.
  • 서버는 오직 안전하고, 캐시 가능하고, 본문을 포함하지 않은 요청에 대해서만 푸시할 수 있다.
  • 푸시할 리소스는 클라이언트가 명시적으로 보낸 요청과 연관된 것이어야 한다.
    서버가 보내는 PUSH_PROMISE 프레임은 원 요청을 위해 만들어진 스트림을 통해 보내어진다.
  • 클라이언트는 반드시 서버가 푸시한 리소스를 동일 출처 정책에 따라 검사해야한다.
  • 서버 푸시를 끄고 싶다면 SETTINGS_ENABLE_PUSH를 0으로 설정하면 된다.

알려진 보안 이슈

중개자 캡슐화 공격

HTTP/2.0 메시지를 중간의 프락시가 HTTP/1.1 메시지로 변환할 때, 메시지의 의미가 변질될 가능성이 있다.

HTTP/1.1과 달리 HTTP/2.0은 헤더 필드의 이름과 값을 바이너리로 인코딩한다. 이는 HTTP/2.0이 헤더 필드로 어떤 문자열이든 사용할 수 있게 해준다. 이는 정상적인 HTTP/2.0 요청이나 응답이 불법적인 HTTP/1.1 메시지로 번역되는 것을 유발할 수 있다.

(HTTP/1.1 메시지를 HTTP/2.0 메시지로 번역하는 과정에서는 이런 문제가 발생하지 않는다)

 

 

긴 커넥션 유지로 인한 개인정보 누출 우려

HTTP/2.0은 사용자가 요청을 보낼 때의 회전 지연을 줄이기 위해 클라이언트와 서버의 사이의 커넥션을 오래 유지하는 것을 염두에 두고 있는데, 이것이 개인정보의 유출에 악용될 가능성이 있다. HTTP 커넥션이 짧다면 위험이 적게 된다.