한입 CS

HTTP/1.1에서 HTTP/3까지: 무엇을 고쳤나

연결 하나에 요청 하나, 스트림 멀티플렉싱, TCP 손실과 QUIC

7장 · 네트워크 위의 약속 · 읽는 시간 약 18분

목차 · 진행 0%

    상품 상세 페이지에 썸네일·아이콘 수십 개가 있다. 개발자 도구 네트워크 탭의 워터폴(waterfall)을 열어 보면 막대가 계단처럼 몇 개씩 묶여 오른쪽으로 밀려 있고, 막대 앞부분에 "대기(Queued·Stalled, 연결이 빌 때까지 기다린 시간)"가 길게 붙어 있다. 같은 페이지를 다른 서버에 올리면 막대가 거의 한꺼번에 시작한다. 네트워크 탭의 프로토콜 열을 켜 보면 앞쪽은 http/1.1, 뒤쪽은 h2나 h3다.

    HTTP는 버전이 바뀔 때마다 "요청 여러 개를 어떻게 한꺼번에 나를까"라는 문제 하나를 다른 층에서 풀었다. 이 장은 그 세 단계를 모델로 돌려 본다. 캐시 헤더는 HTTP 캐싱, TLS는 HTTPS 노트에 있다.

    HTTP/1.1: 연결 하나에 요청 하나씩

    HTTP/1.1은 지속 연결(persistent connection)을 기본으로 쓴다. 응답이 끝나도 TCP 연결을 닫지 않고 다음 요청에 다시 쓴다(RFC 9112 §9.3). 하지만 한 연결 위에서는 요청 → 응답이 한 쌍씩 차례로 오간다. 응답을 기다리지 않고 요청을 이어 보내는 파이프라이닝(pipelining)도 명세에 있지만, 서버는 응답을 요청이 온 순서대로 보내야 한다(RFC 9112 §9.3.2). 앞 응답이 느리면 뒤 응답이 다 만들어져 있어도 기다린다. 이렇게 줄 맨 앞이 막혀 뒤가 모두 서는 현상을 HOL 블로킹(head-of-line blocking)이라고 한다. 버그가 있는 프록시와 이 문제 때문에 요즘 브라우저는 파이프라이닝을 기본으로 켜지 않는다(MDN: HTTP/1.x의 연결 관리).

    그래서 브라우저는 연결을 여러 개 연다. 명세는 최대 개수를 정하지 않고 "보수적으로 제한하라"고만 한다(RFC 9112 §9.4). MDN은 요즘 브라우저가 흔히 호스트 하나에 6개까지 연다고 적고 있다. 7번째 요청부터는 연결이 빌 때까지 줄을 선다. 워터폴의 계단이 이 줄이다.

    예측먼저 답해 보세요

    요청 6개가 연결을 차지하는 시간이 차례로 2, 1, 3, 1, 2, 1 RTT다. 연결 1개로 보내면 모두 받는 데 10 RTT가 걸린다. 연결 3개로 늘리면 연결 0·1·2에 요청 0·1·2가 먼저 들어가 각각 2, 1, 3 RTT에 빈다. 남은 요청 3·4·5(1, 2, 1 RTT)를 그때그때 가장 먼저 비는 연결에 넣으면 모두 받는 데 몇 RTT가 걸릴까?

    선택지와 해설 보기
    실험 연결 k개에 요청 배정하기
    실험연결 k개에 요청 배정하기

    HTTP/1.1 연결 하나는 응답 하나가 끝나야 다음 요청을 받습니다. 요청마다 가장 먼저 비는 연결에 넣고, 연결별로 언제 비는지(free)를 따라갑니다. 시간 단위는 RTT입니다.

    언어
    입력
    ▶ 다음에 실행할 줄(디버거가 멈춘 줄)
    방금 실행한 줄
    변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
    카드 안에서 ← → 키로도 넘길 수 있습니다.
    const durations = [2, 1, 3, 1, 2, 1]const k = 1const free = new Array(k).fill(0)let finish = 0for (let i = 0; i < durations.length; i++) {  let c = 0  for (let j = 1; j < k; j++) {    if (free[j] < free[c]) c = j  }  const start = free[c]  free[c] = start + durations[i]  if (free[c] > finish) finish = free[c]  console.log(`요청 ${i}: 연결 ${c}, ${start} → ${free[c]}`)}console.log(`모두 받은 시각: ${finish}`)
    변수
    durations
    [2, 1, 3, 1, 2, 1]
    출력
     
    1/41단계.
    • 해볼 것
    • '연결 1개' 입력을 끝까지 실행해 요청이 한 줄로 서는 것을 본다
    • '연결 3개' 입력을 끝까지 실행해 모두 받은 시각을 견줘 본다

    연결 1개일 때 연결 0이 비는 시각(free[0])은 2, 3, 6, 7, 9, 10으로 늘어난다. 요청 다섯 개가 앞 요청이 끝나기를 기다렸다. 연결 3개면 요청 0, 1, 2가 동시에 출발하고, 3번은 가장 먼저 빈 연결 1(시각 1)에 들어간다.

    연결 3개면 모두 받는 데 4 RTT다. 연결을 늘리면 줄은 짧아지지만, 연결마다 TCP·TLS 연결 맺기를 따로 하고 서버도 연결 수만큼 자원을 쓴다. 결과가 10 / 3(약 3.3)이 아니라 4인 이유는 요청 길이가 제각각이라 연결마다 끝나는 시각이 다르기 때문이다.

    HTTP/2: 연결 하나에 스트림 여럿

    HTTP/2는 연결을 늘리는 대신 연결 하나를 잘게 나눠 쓴다. 요청·응답 한 쌍을 스트림(stream)이라 부르고, 스트림마다 번호를 붙여 프레임(frame) 단위로 쪼갠 뒤 한 TCP 연결 위에 섞어 보낸다. 이것이 멀티플렉싱(multiplexing)이다. 한 스트림이 멈춰도 다른 스트림은 계속 오간다(RFC 9113 §1, §5). 그래서 클라이언트는 같은 호스트와 포트에 HTTP/2 연결을 하나만 여는 것이 원칙이다(RFC 9113 §9.1).

    리소스 12개를 받는 모델에서 HTTP/1.1(연결 6개)은 5.9 RTT, HTTP/2는 5.4 RTT에 끝난다.

    실험 프로토콜별로 리소스 받기 (모델)
    실험프로토콜별로 리소스 받기 (모델)

    실제 측정이 아니라 단순한 모델입니다. 회선은 한 번에 패킷 하나만 나르고, 리소스 하나는 패킷 2개입니다. 연결 맺기는 TCP + TLS 1.3이 2 RTT, QUIC이 1 RTT라고 둡니다. HTTP/1.1은 연결 6개를 씁니다. 막대는 요청을 보낸 시각부터 응답을 다 넘겨받은 시각까지입니다.

    프로토콜
    리소스
    1. #03.7 RTT
    2. #13.8 RTT
    3. #23.9 RTT
    4. #34 RTT
    5. #44.1 RTT
    6. #54.2 RTT

    모두 받은 시각은 4.2 RTT입니다.

    • 해볼 것
    • 리소스 12개에서 HTTP/1.1과 HTTP/2의 모두 받은 시각을 견줘 본다
    • HTTP/2에서 패킷 손실을 켜고 몇 개 리소스가 늦어지는지 본다
    • HTTP/3에서 패킷 손실을 켜고 HTTP/2와 견줘 본다

    모델의 규칙은 둘이다. 회선은 패킷 하나를 나르는 데 0.1 RTT를 쓰고(모든 연결이 회선 하나를 나눠 쓴다), 리소스 하나는 패킷 2개다. HTTP/2의 5.4는 연결 맺기 2 + 요청이 가서 첫 응답 패킷이 회선에 실리기까지 1 + 패킷 24개 × 0.1 = 2.4를 더한 값이다. 12개 요청을 처음에 한꺼번에 보내 회선을 쉬지 않고 쓴다. HTTP/1.1은 앞의 응답을 다 받은 연결에서야 다음 요청을 보낸다. HTTP/1.1도 연결 맺기 2 + 첫 응답 패킷이 실리기까지 1이라, 3.0부터 0.1 RTT마다 패킷이 하나씩 회선에 실린다. 실리는 순서는 리소스 0, 1, …, 5의 첫 패킷(3.0~3.5), 이어서 리소스 0, 1, …, 5의 둘째 패킷(3.6~4.1)이고, 실린 패킷은 0.1 RTT 뒤에 도착한다. 그래서 첫 응답(리소스 0)은 3.7 RTT에 다 받고 그 연결로 바로 다음 요청을 보내지만, 그 응답 패킷이 회선에 실리는 시각은 1 RTT 뒤인 4.7이다. 그동안 처음 6개의 남은 패킷을 나르느라 4.2까지는 회선이 바빴으니, 노는 시간은 4.2부터 4.7까지 0.5 RTT다. 5.4 + 0.5 = 5.9. 이 0.5 RTT가 두 프로토콜의 차이다.

    리소스가 6개면 두 프로토콜이 같다. 연결 6개로 줄을 서지 않고 다 보낼 수 있기 때문이다. 모델은 회선 속도를 고정했으므로 실제 차이는 리소스 수, 크기, RTT에 따라 다르다. 줄 서는 시간이 없어진다는 방향만 모델에서 가져가자.

    TCP 손실과 HTTP/3

    HTTP/2에서 스트림을 나눈 것은 HTTP 층의 일이다. 그 아래의 TCP는 스트림을 모른다. TCP는 바이트를 보낸 순서대로만 위로 넘겨 준다. 패킷 하나가 손실되면 그 뒤에 도착한 패킷이 다른 스트림 것이어도 재전송본(retransmission)이 올 때까지 넘겨주지 않는다. RFC 9114는 이를 두고 HTTP/2에서는 패킷 하나의 손실이나 순서 뒤바뀜(reordering)이 진행 중인 모든 요청을 멈추게 한다고 적는다(RFC 9114 §1.1). HOL 블로킹이 HTTP 층에서 TCP 층으로 내려간 것이다.

    2절 모델에서 HTTP/2로 리소스 6개를 받는데 패킷 손실(packet loss)이 나면, 리소스 0번의 마지막 패킷 하나가 사라졌을 뿐인데 리소스 6개가 모두 늦어진다. HTTP/1.1은 연결이 따로라서 그 연결의 리소스만 늦어진다.

    HTTP/3는 TCP 대신 QUIC 위에서 돈다. QUIC 패킷은 UDP 데이터그램(datagram)에 실려 다니고(RFC 9000 §1), 스트림을 전송 층(transport layer, TCP·UDP가 있는 층. HTTP는 그 위층이다)에서 직접 안다. 신뢰성(손실 복구와 순서 맞추기)을 스트림 단위로 제공해서 손실은 그 패킷이 속한 스트림만 멈춘다(RFC 9114 §1.2, §2). QUIC은 서로 다른 스트림의 바이트 사이에는 순서를 보장하지 않는다(RFC 9000 §2). 같은 모델을 HTTP/3로 바꾸면 손실이 있어도 늦어지는 리소스는 1개다.

    연결 맺기도 줄었다. TCP 위의 TLS 1.3은 TCP 연결을 맺은 뒤 1-RTT 핸드셰이크(handshake)를 한다(RFC 8446 §2). QUIC은 전송 핸드셰이크와 암호 핸드셰이크를 하나로 합쳐 연결 지연을 줄인다(RFC 9000 §7). 모델이 HTTP/2 연결 맺기를 2 RTT, HTTP/3를 1 RTT로 둔 이유다. 전에 연결한 서버라면 0-RTT로 첫 요청을 핸드셰이크와 함께 보낼 수도 있다(RFC 9000 §5). 다만 0-RTT 데이터는 같은 요청을 다시 보내는 공격(replay)을 막는 보장이 없다(RFC 8446 §2.3). 6장에서 본 멱등 요청(같은 요청이 두 번 처리돼도 괜찮은 조회)에만 맞는 기능이다.

    브라우저는 처음부터 HTTP/3로 붙지 않는다. 먼저 HTTP/1.1이나 HTTP/2로 받은 응답의 Alt-Svc 헤더에서 "이 출처는 QUIC으로도 받는다"는 광고를 보고 다음 연결부터 시도한다(RFC 9114 §3.1.1). 그래서 HTTP/3를 지원하는 사이트도 첫 요청은 h2로 보일 수 있다.

    HTTP/1.1 시절의 최적화 다시 보기

    연결 6개 제한을 피하려고 정적 파일을 img1, img2, img3처럼 여러 도메인에 나눠 담는 도메인 샤딩(domain sharding)이 유행했다. MDN은 이 기법을 더 쓰지 말라고 하고, HTTP/2에서는 오히려 성능을 해친다고 적는다(MDN: HTTP/1.x의 연결 관리). 도메인이 다르면 DNS 조회와 연결 맺기를 도메인마다 따로 하고, 멀티플렉싱으로 얻은 "연결 하나" 효과도 나뉜다. 여러 도메인이 한 CDN을 거친다면 CDN 쪽 설정으로 출처를 하나로 모으는 편이 낫다.

    함정 찾기 AI가 짠 갤러리 로딩 코드, HTTP/2 서버에서 어디가 느릴까?
    함정 찾기AI가 짠 갤러리 로딩 코드, HTTP/2 서버에서 어디가 느릴까?
    내가 AI에게 한 요청
    “갤러리 썸네일 수백 개를 최대한 빨리 받게 하고, 주문 요청은 연결이 유지되게 해 줘.”

    의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.

    확인 문제 장을 덮기 전에 떠올려 보기
    확인 문제장을 덮기 전에 떠올려 보기

    1. 파이프라이닝을 쓰지 않는 HTTP/1.1 연결 하나로 요청 3개를 보내면?

    2. HTTP/2 연결(스트림 10개 진행 중)에서 TCP 패킷 하나가 손실되면?

    3. HTTP/3가 스트림별로 손실을 따로 복구할 수 있는 이유는?

    더 풀어 보기(선택) · 손실 한 번에 몇 개 스트림이 기다릴까
    보너스 문제손실 한 번에 몇 개 스트림이 기다릴까

    한 연결에 스트림 4개가 있다. a는 큰 이미지(패킷 5개), b·c·d는 작은 파일(패킷 1개씩)이다. 패킷은 a a b c d a a a 순서로 나가고, i번째 패킷(0부터)은 시각 i + 1에 도착한다. 손실된 패킷만 재전송본이 4만큼 늦게 도착한다. 첫 패킷(a)이 손실될 때와 셋째 패킷(b)이 손실될 때, 스트림마다 다 받은 시각을 HTTP/2(TCP)와 HTTP/3(QUIC)로 각각 구하고 늦어진 스트림 수를 세어 보자.

    손실 없음: a 8, b 3, c 4, d 5
    a 손실, TCP: a ?, b ?, c ?, d ?
    풀 때 쓰는 것: 『개발 수학』 Unit 3. 증가 속도: n, n log n, n², 2ⁿ (손실 계산이 패킷 수에 비례하는 O(n))

    이 장을 언급한 노트

    다음8장. 데이터베이스: 인덱스와 트랜잭션다음 장은 요청이 도착한 서버 뒤의 데이터베이스입니다. 인덱스가 조회를 빠르게 하는 원리와 트랜잭션이 재고를 지키는 방법을 봅니다.

    『한입 CS』 7장. HTTP/1.1에서 HTTP/3까지: 무엇을 고쳤나 · younggeun0. Claude와 함께 썼고, 저자가 아직 전체 내용을 검수하지 않은 초안입니다. 사실 주장은 공식 문서로, 예제 코드는 실제로 실행해 확인했습니다.

    보기 옵션