들어가기 전에
HTTP/2 캡쳐 샘플은 아래 링크에서 받으실 수 있습니다.
https://wiki.wireshark.org/HTTP2
문제 제기
우리가 흔히 사용하는 대부분의 웹 사이트들은 HTTP/2를 사용한다.(구글의 경우 HTTP/3을 사용)
그렇지만 내가 진행한 프로젝트의 서버는 HTTP/1.1 을 사용하고, 별다른 설정이 없다면 HTTP/1.1 통신을 제공한다는 것을 알았다. HTTP/2 통신을 제공하기 전에, HTTP/1.1과 2는 어떤 차이가 있는지 궁금해서 알아보게 됐다.
이 포스팅에선 HTTP/1.1 이전과 HTTP/3에 대해선 알아보지 않는다.
또한, TLS같이 HTTP/1.x에서 발전한 내용들은 다루지 않는다.
HTTP/2 패킷 구성
프레임이란 단위로 구성되며, HTTP 프로토콜처럼 TCP단 위에서 돌아간다.
밑에서 설명하겠지만, HTTP/1.1통신에서 HTTP/2로 바꾸는 과정만 프레임이 HTTP 패킷 다음에 나오고
HTTP/2연결이 성립된 이후엔 HTTP 자리를 대신해 사용된다.
프레임은 여러 타입이 존재하고 프레임마다 목적이 다르다. 프레임의 종류와 용도에 대해선 이 포스팅에선 다루지 않는다.
HTTP/2 연결 과정
서버의 경우는 아직 구현을 해보지 않아서 건너 뛴다.
클라이언트는 TLS를 사용하지 않는 경우와 사용하는 경우 2가지로 나눌 수 있다.
TLS 사용 X
HTTP Upgrade 헤더에 h2c란 토큰을 넣어 HTTP/2로 통신한다는 요청을 보낸다.
서버에 요청하면서 클라이언트가 어떤 통신 환경을 구성할지 위의 Setting 헤더 3가지를 통해 서버에 요구할 수 있다.서버는 이 헤더를 보고 클라이언트의 환경을 수용하거나 바꿔서 제공할 수 있다.
개인적인 생각으로는 브라우저 내에 저장된 값을 보내고 서버가 이를 조율하는 형식인거 같다.
위 사진에선 아직 HTTP/1.X 로 통신하기 때문에 HTTP/2 프레임은 HTTP/1.X 헤더 다음에 나온다.
TLS 사용 O
TCP단에서 3-hand-shaking이 끝난 후, 통신 절차를 진행한다.
TLS 내부에 ALPN(Application_Layer_Protocol_Negotiation)에 h2 토큰을 전달하면서 이 통신이 HTTP/2 로 이루어진단 것을 서버에 알린다.
이후 HTTP/2를 사용해 통신을 한다.
정리하면, TLS가 없다면 어플리케이션 단에서 결정이 나고 TLS가 있다면 TCP와 어플리케이션 사이에서 결정이 된다.
하지만, 서버가 HTTP/2 통신환경을 제공하지 않는다면, 이 헤더는 무시된다. 즉, HTTP/1.X로 통신한다.
커넥션 관리
HTTP/1.X에선 커넥션 내부에서 큐를 사용해 패킷을 순차적으로 처리해야 했다.
통신을 원활하게 하기 위해 파이프라인 기법을 사용해 한번에 여러 데이터를 보내거나
커넥션을 여러 개 만들어서 더 많은 요청을 처리할 수 있게 했다.
하지만, 서버는 자원이 무한하지 않기 때문에 커넥션을 무한정 만들 수 없다.
서버 하나로는 웹 사이트의 요청을 모두 감당하기가 힘들었기 때문에
여러 서버 호스트를 만들어 api 주소만 바꾸는 식으로 여러 서버에 여러 커넥션을 만들어 통신했다.
이 기법을 sharding이라고 부른다.
예를 들자면 test.com 사이트에서 웹 페이지 정보를 불러오는 과정에서
test.com 내의 다른 서버 호스트들에 접근에 데이터들을 받아오는 과정이다.
이렇게 여러 커넥션을 관리하게 되면서 여러 요청이 한 커넥션을 두고 경쟁하는 상황이 발생했다.
이 현상을 Hol blocking(Head of line blocking)이라고 한다.
클라이언트 A, B의 요청이 커넥션 C로 보내는 상황에서 스위치가 A의 요청을 먼저 처리한 경우
B는 A의 요청이 끝날 때 까지 대기해야 한다.
일상 생활을 예로 들면, 내가 선 줄에서 일보고 있는 사람이 진상이라 일처리가 엄청 늦어지는 거다.
만약, A의 요청이 DB 접근이나 복잡한 비즈니스 로직을 처리해야 하는 경우면 B가 대기하는 시간은 더욱 길어진다.
여러 서버 호스트가 제공하는 다중 커넥션 간의 Hol-blocking 문제를 해결하기 위해
HTTP/2 에선 이 문제를 해결하기 위해 스트림 멀티플렉싱 기법을 사용한다.
클라이언트는 하나의 TCP 커넥션만 가지고, 그 사이의 여러 스트림을 통해 서버와 통신한다.
물론, 다중 커넥션을 가질 수 있다. 여기서 이 내용에 대해선 다루지 않는다.
이를 통해 클라이언트가 접근할 서버 호스트는 줄이고, 파이프라인을 쓰지 않으면서 빠르고 안정적으로 통신을 할 수 있다.
참고로 0번 스트림은 루트 스트림이라고 하며, HTTP/1.X를 HTTP/2로 바꾸는 요청은 1번 스트림으로 전달된다.
설명을 좀 어렵게 했는데 예시를 들면 다음과 같다.
test.com/a 로 접근을 해야 하는 경우
NGINX에서 클라이언트의 요청을 받아 URI를 분석 후, 요청을 처리할 upstream 서버로 보낸다.
커넥션 내의 스트림들은 다른 스트림들과의 가중치를 파악해 우선순위를 조절한다.
우선 순위를 지정하는 이유는 전송 용량이 제한된 상황에서 어떤 스트림을 통해 전송할지에 대해 정해야 하기 때문이다.
물론, 명시적으로도 스트림 우선순위를 정할 수는 있다. 다만, 이 우선순위대로 스트림의 순서를 조절하거나 특정 처리만 할 수 있게끔 하지는 못한다.
다만. 빠르게 HTTP/1.X 통신을 HTTP/2로 업그레이드 해야하는 1번 스트림만 예외로 치는거 같다.
이렇게 보면 스트림 우선 순위는 강제가 아닌 제안하는 정도의 느낌인 거 같다. 여기에 대해선 관련 개발을 해야 확실하게 말할 수 있을 거 같다.
request 간소화
server push
기존 HTTP/1.X에선 서버로부터 페이지를 받으면
이를 파싱하면서 페이지를 구성하는데 필요한.js나 .css파일들을 요청한다.
하지만 이 방법은 서비스를 사용하는 사람들이 많을 수록 서버에 부담이 증가한다.
따라서, 서버는 어떠한 요청이 오면 클라이언트가 다시 요청을 할 것들을 예측해 먼저 응답한다.
이를 통해 서버는 추가적인 request를 받지 않고 페이지 구성에 필요한 요소들을 전달할 수 있다.
spriting
여러 이미지 파일들을 하나의 이미지 파일로 통합해 js나 css를 통해 분할해 가져오는 기술
서버는 이미지 파일들을 찾느라 캐시나 메모리를 뒤적거릴 필요 없이 하나만 가져오면 되므로 응답 속도가 빨라진다.
다만, 이미지의 개수가 적으면 큰 효과를 못보고 캐시에서 교체되면 모든 이미지가 캐시 테이블에서 없어진다.
개인적인 생각으로는 확장성을 고려한다면 이정도 패널티는 감수할만 한 거 같다.
헤더 압축
쉽게 말해서 반복되는 헤더는 자동으로 만들어주는 기능이다. 기존 HTTP/1.1에선 이 기능이 없기 때문에
여러 헤더를 추가하면 그만큼 패킷의 크기가 늘어나고, 대역폭도 높아져야 했다.
이 문제를 해결하기 위해 HTTP/2에서는 HPACK을 이용해 각 헤더를 압축한다.
각 헤더를 name, value로 구분해 헤더 테이블을 구성하고
클라이언트와 서버는 동일한 헤더를 보낼 때 헤더 테이블의 인덱스를 보낸다.
쿠키의 경우, 여러 쿠키를 사용한다면 ; (0x3A, 0x20)을 통해 csv 형태로 만들어 관리한다.
예를 들면 이런 식이다
압축 전)
cookie: a =b
cookie: c = d
압축 후)
cooke: a=b; c=d;
이런 압축이 가능한 이유는 기존 HTTP/1.1에선 평문 기반 프로토콜이었기 때문에 평문으로 이루어진 헤더를 파싱하는것을 매우 힘든 작업이었다.
하지만, HTTP/2에선 바이너리 기반 프로토콜이기 때문에 파싱이 쉬워졌다.
이렇게 파싱된 각 헤더들은 하나의 헤더 블록으로 만들 수 있기 때문에 테이블 구성도 쉬워졌다.
헤더 테이블의 종류는 여기서 다루지 않는다.
이 헤더 블록들은 적절한 용도의 프레임으로 전달된다.
wireshark에서 200 response를 하는 HTTP/2 프레임의 헤더 정보를 보면 아래와 같다.
reference
https://datatracker.ietf.org/doc/html/rfc7540
https://www.rfc-editor.org/info/rfc7541
https://github.com/bagder/http2-explained