본문 바로가기

HTTP/3와 QUIC

HTTP/3가 HTTP의 요청과 응답을 QUIC 스트림에 옮긴 방식과 가변 길이 정수, 스트림의 종류를 정리하고, HPACK을 QUIC에 맞게 바꾼 QPACK이 왜 필요했는지 봅니다. 서버가 HTTP/3를 알리는 방법과 0-RTT, 연결 이동의 경로 확인, QUIC이 암호화하지 못하는 부분, 도입할 때의 판단 기준과 확인 방법을 함께 다룹니다.

앞 글에서 HTTP/2가 HTTP 수준의 HOL 블로킹을 없앴지만 TCP 위에 있는 한 TCP의 HOL 블로킹은 남는다고 했습니다. HTTP/3(RFC 9114)는 HTTP의 의미를 TCP 대신 QUIC 위에 옮겨 이 문제를 줄였습니다. QUIC이 스트림을 어떻게 다루고 연결을 어떻게 맺는지는 7장 3편에서 보았으므로, 이 글에서는 HTTP/3가 QUIC의 스트림을 어떻게 쓰는지와, 실제로 도입하고 운영할 때 알아야 할 것에 집중합니다.

HTTP/3는 QUIC 스트림 위의 HTTP

HTTP/2는 스트림을 자기가 직접 만들어 TCP의 바이트 스트림 하나에 섞었습니다. HTTP/3는 스트림을 만드는 일을 QUIC에 맡깁니다. QUIC은 연결 안에 서로 독립적인 스트림을 여러 개 열 수 있고, 스트림마다 따로 순서를 맞추고 손실을 복구하므로, HTTP/3는 요청 하나에 QUIC 스트림 하나를 쓰면 됩니다. 그래서 HTTP/3의 프레임에는 HTTP/2처럼 스트림 번호가 없고, 프레임의 종류와 길이만 적힙니다.

QUIC의 스트림 번호는 아래쪽 두 비트로 누가 열었는지와 방향을 나타냅니다(RFC 9000 §2.1).

아래 두 비트스트림의 종류HTTP/3에서의 쓰임
0x0클라이언트가 연 양방향 스트림(0, 4, 8, ...)요청 하나와 그 응답
0x1서버가 연 양방향 스트림HTTP/3에서는 쓰지 않는다
0x2클라이언트가 연 단방향 스트림제어 스트림(SETTINGS 등), QPACK의 인코더와 디코더 스트림
0x3서버가 연 단방향 스트림서버의 제어 스트림, QPACK 스트림

요청과 응답은 0, 4, 8번처럼 클라이언트가 연 양방향 스트림에 HEADERS와 DATA 프레임으로 실리고, 연결 전체의 설정은 양쪽이 하나씩 여는 단방향 제어 스트림의 SETTINGS 프레임으로 주고받습니다. HTTP/2의 0번 스트림이 하던 일을 별도의 스트림이 맡은 것입니다.

QUIC과 HTTP/3는 스트림 번호나 프레임의 길이 같은 정수를 가변 길이 정수(variable-length integer)로 적습니다. 첫 바이트의 위쪽 두 비트가 전체 길이를 1, 2, 4, 8바이트 가운데 하나로 알려 주고, 나머지 비트가 값입니다(RFC 9000 §16). 다음 코드는 RFC에 실린 예제 값들을 풀어 봅니다.

def decode_varint(data):
    first = data[0]
    length = 1 << (first >> 6)                       # 앞 두 비트가 길이: 1, 2, 4, 8바이트
    value = first & 0x3F                             # 첫 바이트의 나머지 여섯 비트부터 값이다
    for b in data[1:length]:
        value = (value << 8) | b
    return value, length

for hexstr in ["25", "4025", "7bbd", "9d7f3e7d", "c2197c5eff14e88c"]:
    value, length = decode_varint(bytes.fromhex(hexstr))
    print(f"{hexstr:<16} -> {value} ({length}바이트)")
25               -> 37 (1바이트)
4025             -> 37 (2바이트)
7bbd             -> 15293 (2바이트)
9d7f3e7d         -> 494878333 (4바이트)
c2197c5eff14e88c -> 151288809941952652 (8바이트)

작은 값은 1바이트로, 큰 값은 8바이트까지 늘려 적으므로, 대부분이 작은 수인 스트림 번호나 길이를 짧게 담을 수 있습니다. 25와 4025가 모두 37인 것처럼 같은 값을 더 길게 적을 수도 있지만, 보통은 가장 짧은 표현을 씁니다.

HPACK 대신 QPACK

HTTP/2의 HPACK은 연결 하나의 동적 표를 헤더 블록이 도착한 순서대로 고쳐 나갑니다. 이 방식을 QUIC에 그대로 가져오면 문제가 생깁니다. 스트림 4의 헤더 블록이 동적 표에 항목을 더했는데 그 패킷이 사라졌다면, 그 항목을 가리키는 스트림 8의 헤더 블록은 스트림 4의 재전송을 기다려야 합니다. 스트림끼리 막지 않게 하려고 QUIC을 썼는데, 헤더 압축 때문에 다시 서로를 막게 되는 것입니다.

QPACK(RFC 9204)은 동적 표를 고치는 명령을 요청 스트림이 아니라 별도의 단방향 인코더 스트림으로 보냅니다. 각 헤더 블록에는 자기를 풀려면 표에 몇 번째 항목까지 들어와 있어야 하는지가 적혀 있어서, 받는 쪽은 필요한 항목이 아직 오지 않은 블록만 잠시 기다리게 하고 나머지는 바로 풉니다. 보내는 쪽이 받는 쪽이 이미 확인한 항목만 가리키도록 조심하면 기다림을 아예 없앨 수도 있고, 얼마나 많은 스트림이 기다려도 되는지는 SETTINGS로 정합니다. 압축률을 조금 덜 얻는 대신 스트림의 독립성을 지키는 설계입니다.

더 깊이: 기본값으로는 동적 표를 쓰지 않는 QPACK

QPACK의 동작은 받는 쪽이 HTTP/3의 SETTINGS 프레임으로 알리는 두 값이 정합니다. 동적 표의 최대 크기인 SETTINGS_QPACK_MAX_TABLE_CAPACITY와 기다려도 되는 스트림의 수인 SETTINGS_QPACK_BLOCKED_STREAMS입니다. RFC 9204는 두 값의 기본값을 모두 0으로 정하므로, 받는 쪽이 따로 값을 알리지 않은 연결에서는 동적 표 없이 정적 표와 값을 직접 적는 방식만 씁니다. HTTP/2에서 같은 역할을 하는 SETTINGS_HEADER_TABLE_SIZE의 처음 값이 4,096바이트인 것과 다른 점입니다.

막힌 스트림의 한도는 보내는 쪽이 언제나 지켜야 하는 약속이어서, 받는 쪽은 약속한 수보다 많은 스트림이 막히면 이를 QPACK_DECOMPRESSION_FAILED 연결 오류로 처리합니다. 그래서 QPACK의 압축률은 구현의 선택만이 아니라 양쪽이 알린 설정에 따라 달라지며, HTTP/3로 바꾼 뒤 헤더의 크기가 HTTP/2 때보다 크다면 이 설정부터 확인해 볼 수 있습니다. 정적 표의 번호도 HPACK은 1부터, QPACK은 0부터 매긴다는 차이가 있습니다.

서버가 HTTP/3를 알리는 방법

HTTP/3는 UDP를 쓰므로, 브라우저는 처음부터 HTTP/3로 접속해도 되는지 알 수 없습니다. 그래서 서버가 먼저 알려 줍니다. 가장 흔한 방법은 HTTP/1.1이나 HTTP/2 응답에 Alt-Svc: h3=":443"; ma=86400 같은 헤더를 붙여 "UDP 443번에서 HTTP/3도 된다"고 광고하는 것이고, 브라우저는 다음 연결부터 HTTP/3를 시도합니다. DNS의 HTTPS 레코드(RFC 9460)에 alpn="h3"를 적어 두면 첫 연결부터 HTTP/3를 시도할 수 있습니다.

연결을 맺을 때는 QUIC 핸드셰이크 안의 TLS ALPN으로 h3를 협상합니다. UDP가 막힌 망에서는 QUIC이 실패하므로, 브라우저는 7장 3편의 연습 문제에서 본 것처럼 TCP와 QUIC을 함께 시도하거나 실패한 망을 기억해 두는 방식으로 손해를 줄입니다.

0-RTT를 쓸 때

QUIC은 전송과 TLS 1.3의 핸드셰이크를 합쳐, 새 연결에서는 한 번의 왕복 뒤에 요청을 보내고, 예전에 접속한 서버에 다시 연결할 때는 0-RTT로 첫 패킷부터 요청을 실을 수 있습니다. RTT가 80ms라면 새 연결의 첫 요청은 약 80ms 뒤에 나가고, 0-RTT라면 바로 나갑니다. 다만 0-RTT로 보낸 요청도 서버까지 가고 응답이 돌아오는 시간은 그대로 걸리며, 서버가 0-RTT 데이터를 거절하면 핸드셰이크 뒤에 다시 보내야 합니다.

0-RTT의 데이터는 재전송 공격을 막지 못하므로, 같은 요청이 두 번 처리되어도 괜찮은 요청에만 씁니다. 메서드가 GET이라는 것만으로는 판단할 수 없고, 그 요청이 서버에 어떤 효과를 남기는지를 봐야 합니다. 서버는 받아들일 0-RTT 요청을 정해 두고 나머지는 핸드셰이크가 끝날 때까지 미루거나 425 Too Early로 거절합니다.

연결 이동은 경로를 확인한 뒤에 이어진다

QUIC은 연결을 Connection ID로 구별하므로, 스마트폰이 와이파이에서 LTE로 옮겨 주소가 바뀌어도 같은 연결을 이어 갈 수 있습니다. 하지만 새 주소에서 온 패킷을 그대로 믿고 그쪽으로 데이터를 쏟아 보내면, 공격자가 남의 주소를 출발지로 적어 보내는 것만으로 트래픽을 그 주소로 몰아줄 수 있습니다. 그래서 서버는 새 경로로 보내기 전에 그 주소가 정말 응답하는지 확인합니다.

주소가 바뀌어도, 새 경로 검증을 거쳐 연결을 이어갈 수 있습니다

핸드셰이크가 확인되고 이동이 허용된 연결입니다. 같은 경로에서 돌아온 동일한 값을 대조합니다. 각 단계의 값과 전환 이유를 비교합니다.

HTTP/3 · 연결 이동

주소가 바뀌어도, 새 경로 검증을 거쳐 연결을 이어갈 수 있습니다

QUIC은 IP·포트만으로 연결을 구분하지 않습니다. Connection ID로 기존 연결을 찾고 새 경로를 검증하지만, 서버·로드 밸런서·UDP 경로의 지원 조건도 필요합니다.

새 주소의 QUIC 경로 검증 교환핸드셰이크confirmed·이동허용·새ConnectionID사용가능을가정한성공경로입니다. 새주소클라이언트가패킷을보내고 서버가새주소로PATH_CHALLENGE,클라이언트가같은값의PATH_RESPONSE를보냅니다. 서버의값대조는challenge를보낸경로를검증하며 사용자로그인은별개입니다. 검증전데이터전송전면금지나무손실전환을뜻하지않습니다.클라이언트이동 후 새 주소같은 서버연결 상태 유지새 주소의 패킷Connection ID로 연결 찾기PATH_CHALLENGE서버가 보낸 8 B 값PATH_RESPONSE클라이언트가 같은 값 반환서버가 보낸 값과 대조해 새 경로의 도달 가능성을 확인
새 주소의 QUIC 경로 검증 교환핸드셰이크confirmed·이동허용·새ConnectionID사용가능을가정한성공경로입니다. 새주소클라이언트가패킷을보내고 서버가새주소로PATH_CHALLENGE,클라이언트가같은값의PATH_RESPONSE를보냅니다. 서버의값대조는challenge를보낸경로를검증하며 사용자로그인은별개입니다. 검증전데이터전송전면금지나무손실전환을뜻하지않습니다.클라이언트서버새 주소의 패킷CID로 연결 찾기PATH_CHALLENGE서버의 8 B 값PATH_RESPONSE같은 값 반환서버: 보낸 값과 대조challenge를 보낸 경로도달 가능성 확인

화살표는 한 성공 사례의 메시지 방향이고 위에서 아래로 순서를 읽습니다. 기존 Wi-Fi 경로는 생략했습니다. 마지막 문장은 서버의 값 대조이며 새로운 메시지가 아닙니다. 경로 검증 전의 전송·증폭 제한과 경로 MTU는 별도 조건입니다.

  1. 기존 경로 · Wi-Fi

    현재 주소와 포트로 QUIC 데이터를 주고받습니다.

    유지할 상태: 같은 연결의 스트림·암호화 상태입니다.

  2. 새 경로 · 이동통신망

    IP 주소나 포트가 달라집니다. Connection ID로 서버의 기존 연결 상태를 찾을 수 있습니다.

    구분: 이동 때 사용할 Connection ID는 바뀔 수도 있습니다.

  3. 경로 검증 · 이어서 전송

    새 주소에 실제로 도달할 수 있는지 검증하고, 조건이 맞으면 같은 연결을 새 경로에서 이어갑니다.

    조건: 이동 허용 설정, 연결 상태 라우팅, NAT·방화벽의 UDP 허용을 확인합니다.

개념 순서이며 모든 패킷 교환이나 즉시·무손실 전환을 보장하지 않습니다. Connection ID는 사용자 신원 인증 수단이 아니고, 재연결을 완료한 다른 연결과도 구분합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 새 주소부터 응답 검증까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명핸드셰이크 confirmed·이동 허용·새 Connection ID 사용 가능을 가정한 성공 경로입니다. 새 주소의 클라이언트가 패킷을 보내고 서버가 새 주소로 PATH_CHALLENGE, 클라이언트가 같은 값의 PATH_RESPONSE를 보냅니다. 서버의 값 대조는 challenge를 보낸 경로를 검증하며 사용자 로그인은 별개입니다. 검증 전 데이터 전송의 전면 금지나 무손실 전환을 뜻하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
새 주소

핸드셰이크가 확인되고 이동이 허용된 연결입니다.

  • 연결·새 경로 1: handshake confirmed
  • 연결·새 경로 2: migration 허용
  • 연결·새 경로 3: 새 CID 가능
  • 새 주소에서 기존 연결의 패킷을 보냄; 새 CID를 사용할 수도 있음
연결 찾기

서버가 CID로 기존 연결 상태를 찾습니다.

  • 연결·새 경로 1: stream/암호상태 유지
  • 스트림·암호 상태 유지; CID 자체는 사용자 신원이 아님
경로 challenge

서버가 새 경로로 8바이트 challenge를 보냅니다.

  • 연결·새 경로 1: PATH_CHALLENGE 서버→클라이언트
  • 예측하기 어려운 값을 PATH_CHALLENGE로 전송
응답 검증

같은 경로에서 돌아온 동일한 값을 대조합니다.

  • 연결·새 경로 1: PATH_RESPONSE 동일 8B
  • 연결·새 경로 2: 도달 가능성 확인
  • 새 경로 도달 가능성 확인; 즉시·무손실 이동을 보장하지 않음

주소가 바뀌어도, 새 경로 검증을 거쳐 연결을 이어갈 수 있습니다

핸드셰이크가 확인되고 이동이 허용된 연결입니다. 같은 경로에서 돌아온 동일한 값을 대조합니다. 각 단계의 값과 전환 이유를 비교합니다.

HTTP/3 · 연결 이동

주소가 바뀌어도, 새 경로 검증을 거쳐 연결을 이어갈 수 있습니다

QUIC은 IP·포트만으로 연결을 구분하지 않습니다. Connection ID로 기존 연결을 찾고 새 경로를 검증하지만, 서버·로드 밸런서·UDP 경로의 지원 조건도 필요합니다.

새 주소의 QUIC 경로 검증 교환핸드셰이크confirmed·이동허용·새ConnectionID사용가능을가정한성공경로입니다. 새주소클라이언트가패킷을보내고 서버가새주소로PATH_CHALLENGE,클라이언트가같은값의PATH_RESPONSE를보냅니다. 서버의값대조는challenge를보낸경로를검증하며 사용자로그인은별개입니다. 검증전데이터전송전면금지나무손실전환을뜻하지않습니다.클라이언트이동 후 새 주소같은 서버연결 상태 유지새 주소의 패킷Connection ID로 연결 찾기PATH_CHALLENGE서버가 보낸 8 B 값PATH_RESPONSE클라이언트가 같은 값 반환서버가 보낸 값과 대조해 새 경로의 도달 가능성을 확인
새 주소의 QUIC 경로 검증 교환핸드셰이크confirmed·이동허용·새ConnectionID사용가능을가정한성공경로입니다. 새주소클라이언트가패킷을보내고 서버가새주소로PATH_CHALLENGE,클라이언트가같은값의PATH_RESPONSE를보냅니다. 서버의값대조는challenge를보낸경로를검증하며 사용자로그인은별개입니다. 검증전데이터전송전면금지나무손실전환을뜻하지않습니다.클라이언트서버새 주소의 패킷CID로 연결 찾기PATH_CHALLENGE서버의 8 B 값PATH_RESPONSE같은 값 반환서버: 보낸 값과 대조challenge를 보낸 경로도달 가능성 확인

화살표는 한 성공 사례의 메시지 방향이고 위에서 아래로 순서를 읽습니다. 기존 Wi-Fi 경로는 생략했습니다. 마지막 문장은 서버의 값 대조이며 새로운 메시지가 아닙니다. 경로 검증 전의 전송·증폭 제한과 경로 MTU는 별도 조건입니다.

  1. 기존 경로 · Wi-Fi

    현재 주소와 포트로 QUIC 데이터를 주고받습니다.

    유지할 상태: 같은 연결의 스트림·암호화 상태입니다.

  2. 새 경로 · 이동통신망

    IP 주소나 포트가 달라집니다. Connection ID로 서버의 기존 연결 상태를 찾을 수 있습니다.

    구분: 이동 때 사용할 Connection ID는 바뀔 수도 있습니다.

  3. 경로 검증 · 이어서 전송

    새 주소에 실제로 도달할 수 있는지 검증하고, 조건이 맞으면 같은 연결을 새 경로에서 이어갑니다.

    조건: 이동 허용 설정, 연결 상태 라우팅, NAT·방화벽의 UDP 허용을 확인합니다.

개념 순서이며 모든 패킷 교환이나 즉시·무손실 전환을 보장하지 않습니다. Connection ID는 사용자 신원 인증 수단이 아니고, 재연결을 완료한 다른 연결과도 구분합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 새 주소부터 응답 검증까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명핸드셰이크 confirmed·이동 허용·새 Connection ID 사용 가능을 가정한 성공 경로입니다. 새 주소의 클라이언트가 패킷을 보내고 서버가 새 주소로 PATH_CHALLENGE, 클라이언트가 같은 값의 PATH_RESPONSE를 보냅니다. 서버의 값 대조는 challenge를 보낸 경로를 검증하며 사용자 로그인은 별개입니다. 검증 전 데이터 전송의 전면 금지나 무손실 전환을 뜻하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
새 주소

핸드셰이크가 확인되고 이동이 허용된 연결입니다.

  • 연결·새 경로 1: handshake confirmed
  • 연결·새 경로 2: migration 허용
  • 연결·새 경로 3: 새 CID 가능
  • 새 주소에서 기존 연결의 패킷을 보냄; 새 CID를 사용할 수도 있음
연결 찾기

서버가 CID로 기존 연결 상태를 찾습니다.

  • 연결·새 경로 1: stream/암호상태 유지
  • 스트림·암호 상태 유지; CID 자체는 사용자 신원이 아님
경로 challenge

서버가 새 경로로 8바이트 challenge를 보냅니다.

  • 연결·새 경로 1: PATH_CHALLENGE 서버→클라이언트
  • 예측하기 어려운 값을 PATH_CHALLENGE로 전송
응답 검증

같은 경로에서 돌아온 동일한 값을 대조합니다.

  • 연결·새 경로 1: PATH_RESPONSE 동일 8B
  • 연결·새 경로 2: 도달 가능성 확인
  • 새 경로 도달 가능성 확인; 즉시·무손실 이동을 보장하지 않음

서버는 새 주소로 예측할 수 없는 8바이트 값을 담은 PATH_CHALLENGE 프레임을 보내고, 클라이언트는 받은 값을 그대로 PATH_RESPONSE에 담아 돌려보냅니다(RFC 9000 §8.2). 같은 값이 돌아오면 서버는 그 주소에 정말 클라이언트가 있다는 것을 압니다. 주소를 위조한 공격자는 진짜 주소로 간 PATH_CHALLENGE를 볼 수 없으므로 같은 값을 돌려줄 수 없습니다. 확인이 끝나기 전에도 데이터를 조금은 보낼 수 있지만, 받은 양의 세 배를 넘지 않도록 제한해 반사 공격의 도구가 되지 않게 합니다.

새 경로의 확인이 실패했다고 연결이 끝나는 것은 아닙니다. 예전의 와이파이 경로가 아직 살아 있다면 그 경로를 계속 쓸 수 있습니다. 연결 이동이 실제로 되려면 서버가 이를 허용하고, 서버 앞의 로드 밸런서가 주소가 아닌 Connection ID를 보고 같은 서버로 패킷을 보내야 하며, 새 경로의 NAT와 방화벽이 UDP를 통과시켜야 합니다.

QUIC이 숨기지 못하는 것

QUIC은 TLS 1.3을 전송 프로토콜 안에 넣어, TCP와 TLS를 쓸 때보다 훨씬 많은 부분을 암호화합니다. TCP에서는 순서 번호와 ACK가 그대로 보였지만, QUIC에서는 패킷 번호까지 헤더 보호로 가려지고 ACK 같은 제어 정보도 암호화된 페이로드 안에 들어갑니다. 그래도 모든 것이 숨겨지지는 않습니다. 연결의 첫 Initial 패킷을 보호하는 키는 패킷에 적힌 Connection ID와 공개된 값으로 누구나 만들 수 있으므로(RFC 9001 §5), 그 안의 ClientHello와 SNI는 경로 위에서 읽을 수 있습니다. 바깥에 드러나는 Connection ID와 패킷의 크기, 시각도 남습니다.

도입할 때 따져 볼 것

HTTP/3는 주요 브라우저와 CDN이 이미 지원하지만, 실제로 쓰이는 비율은 측정하는 곳에 따라 다릅니다. 예를 들어 Cloudflare의 2025년 정리에 따르면 Cloudflare 네트워크가 받은 요청 가운데 HTTP/2가 50%, HTTP/1.x가 29%, HTTP/3가 21%였습니다. 웹 전체의 비율이 아니라 한 사업자의 망에서 본 값이라는 점을 기억해야 합니다.

상황따져 볼 것
모바일, 긴 RTT, 손실이 많은 망연결 이동과 스트림의 독립성이 이득이 되는지 같은 조건에서 비교한다
CDN을 쓰는 서비스CDN의 지원 여부와 함께, 사용자 쪽에서 UDP 443이 통과하는지와 실제 협상 결과를 본다
UDP가 막힌 망과 오래된 클라이언트HTTP/2로 넘어가는 길을 유지하고, 그 경로의 실패율을 따로 본다
데이터센터 안의 서비스지연이 짧고 손실이 적어 이득이 작을 수 있으므로 CPU와 처리량을 재고 정한다

실제로 HTTP/3가 쓰였는지는 다음처럼 확인합니다. --http3는 HTTP/3가 안 되면 이전 버전으로 넘어가므로, 넘어가지 않는 --http3-only나 실제로 쓴 버전을 찍는 %{http_version}으로 확인해야 합니다.

curl -sI https://www.example.com | grep -i '^alt-svc'                            # HTTP/3를 광고하는지
curl -s -o /dev/null -w '%{http_version}\n' --http3-only https://www.example.com    # HTTP/3로만 시도

Alt-Svc는 광고일 뿐이어서, 이 헤더가 있다고 이번 요청이 QUIC으로 오간 것은 아닙니다. 문제가 생기면 협상된 HTTP 버전, UDP가 막혔는지, QUIC 연결과 스트림의 오류를 나누어 기록합니다. QUIC은 패킷의 대부분이 암호화되어 캡처만으로는 손실과 재전송을 보기 어려우므로, 구현이 남기는 qlog와 서버의 기록을 함께 봅니다.

정리

  • HTTP/3는 요청 하나에 QUIC의 양방향 스트림 하나를 쓰고, 연결의 설정은 단방향 제어 스트림으로 주고받으므로 프레임에 스트림 번호가 없습니다.
  • QUIC과 HTTP/3는 첫 바이트의 위쪽 두 비트로 1, 2, 4, 8바이트의 길이를 정하는 가변 길이 정수를 씁니다.
  • QPACK은 동적 표의 갱신을 별도의 인코더 스트림으로 보내, 헤더 압축 때문에 스트림끼리 다시 막히는 일을 줄입니다.
  • 서버는 Alt-Svc 헤더나 DNS의 HTTPS 레코드로 HTTP/3를 알리고, 브라우저는 UDP가 막힌 경우에 대비해 TCP로 넘어가는 길을 둡니다.
  • 연결 이동은 PATH_CHALLENGE와 PATH_RESPONSE로 새 주소를 확인한 뒤에 이루어지며, 확인이 실패해도 예전 경로가 살아 있으면 연결은 계속됩니다.
  • QUIC도 Initial 패킷의 내용과 Connection ID, 크기와 시각은 숨기지 못하며, HTTP/3의 실제 사용은 %{http_version}이나 --http3-only로 확인합니다.

연습 문제

1. 0-RTT로 보내도 되는 요청

중복 적립을 막는 장치가 없는 포인트 적립 요청을 0-RTT로 보내려 합니다. 네트워크가 빠르고 다시 연결할 정보도 있다면 괜찮은 선택인가요?

해설 보기

괜찮지 않습니다. 0-RTT로 보낸 요청은 공격자가 복사해 다시 보낼 수 있고, 서버가 그 요청을 다시 처리하면 포인트가 두 번 적립됩니다. 첫 요청을 빨리 보낼 수 있다는 것과 요청이 정확히 한 번 처리된다는 것은 서로 다른 문제입니다. 이런 요청은 핸드셰이크가 끝난 뒤에 보내거나, 요청마다 고유한 ID를 붙여 서버가 같은 ID를 한 번만 처리하게 만든 뒤에야 0-RTT를 검토할 수 있습니다.

2. 새 경로의 확인이 실패하면

PATH_CHALLENGE에 대한 응답이 끝내 오지 않았지만, 예전의 와이파이 경로는 이미 확인되어 있고 아직 쓸 수 있습니다. 연결을 끝내고 새로 맺어야 하나요?

해설 보기

그럴 필요가 없습니다. 실패한 것은 새 경로일 뿐 연결 자체가 아니므로, 확인된 예전 경로를 계속 쓰면 됩니다. 쓸 수 있는 경로가 하나도 남지 않았을 때 비로소 연결을 끝낼지 판단합니다. Connection ID를 알고 있다는 것만으로는 새 주소에 클라이언트가 있는지 알 수 없으므로 확인 절차를 건너뛰어서도 안 됩니다. 이 확인은 주소에 닿을 수 있는지를 보는 것이고 사용자의 신원을 확인하는 것이 아니라는 점도 구별해야 합니다.

3. 가변 길이 정수로 적기

63, 64, 300을 QUIC의 가변 길이 정수로 가장 짧게 적으면 각각 몇 바이트, 어떤 값이 되나요?

해설 보기

1바이트 형식은 위쪽 두 비트가 00이고 남은 여섯 비트로 0부터 63까지 적을 수 있으므로, 63은 0x3f 한 바이트입니다. 64는 여섯 비트를 넘으므로 위쪽 두 비트가 01인 2바이트 형식을 써서 0x4000 | 64 = 0x4040, 곧 40 40이 됩니다. 300은 16진수로 0x12c이므로 0x4000 | 0x012c = 0x412c, 곧 41 2c 두 바이트입니다. 2바이트 형식은 14비트로 16,383까지 적을 수 있습니다.

다음 글 WebSocket과 실시간 통신에서는 HTTP의 요청과 응답을 벗어나 서버와 클라이언트가 아무 때나 메시지를 주고받는 WebSocket과, HTTP로 실시간을 흉내 내는 방법들을 비교합니다.