logo
Nostrss
Published on

HTTP/2·3 위의 WebSocket (RFC 8441 / 9220)

Authors
WebSocket

WebSocket 프로토콜 시리즈의 다섯 번째 레슨. 레슨 1101 Switching Protocols는 연결 전체 를 통째로 WebSocket으로 바꿨다. 그런데 HTTP/2에서 연결은 더 이상 한 개의 대화가 아니라 여러 스트림의 다발이다 — 통째로 바꿀 수가 없다. 그래서 스펙은 업그레이드를 버리고 다른 문을 만들었다.

핵심 답부터

세 문장으로 요약하면 이렇다:

  • HTTP/2는 Upgrade 메커니즘과 101 응답을 아예 제거했다. RFC 8441 §1이 이 사실에서 출발한다.
  • 대신 RFC 8441이 **Extended CONNECT**라는 새 문을 만들었다. 스트림 하나를 :protocol = websocket 으로 열면, 그 스트림 하나가 TCP 연결 행세를 하고, 그 위에서 레슨 2~4에서 배운 WebSocket 프로토콜이 그대로 돈다.
  • RFC 9220은 같은 메커니즘을 HTTP/3(QUIC)에 얹은 3쪽짜리 문서다.

이제 이 요약을 하나씩 풀어보자.

배경: HTTP 1.1 / 2 / 3은 무엇이 다른가

먼저 알아야 할 대전제가 있다. 세 버전에서 HTTP의 "의미"는 하나도 안 바뀌었다.

  • GET/POST 같은 메서드, 헤더, 상태 코드 — 전부 그대로다.
  • fetch로 보는 요청/응답의 모양도 그대로다.
  • 바뀐 것은 오직 하나: 그 요청/응답을 전선 위로 나르는 방식(전송 방식)이다 (MDN — Evolution of HTTP).

그래서 개발자 코드는 안 바뀌는데 성능 특성만 바뀐다. 버전별로 나르는 방식이 어떻게 달라졌는지 보면:

버전등장전송 방식해결한 문제
HTTP/1.1 (RFC 9112)1997텍스트. TCP 연결 하나에서 한 번에 요청 하나— (기준선)
HTTP/2 (RFC 9113)2015바이너리. TCP 연결 하나 위에 스트림 여러 개를 멀티플렉싱"한 번에 하나" 제약 제거
HTTP/3 (RFC 9114)2022TCP를 버리고 UDP 위의 QUIC패킷 유실이 전체를 막는 문제 제거

각 줄을 조금 더 풀면:

  • HTTP/1.1 — 연결 하나에서 앞 응답이 끝나야 다음 요청을 보낼 수 있다. 너무 느려서 브라우저가 오리진당 TCP 연결을 ~6개 병렬로 여는 것으로 우회했다. 이미지 스프라이트, 도메인 샤딩 같은 옛날 최적화 기법이 전부 이 제약의 우회책이다.
  • HTTP/2 — 연결 하나 위에 "스트림"이라는 가상의 통로를 여러 개 만들어, 요청 100개가 동시에 나간다. 헤더 압축(HPACK)도 추가됐다. 남은 약점: 여전히 TCP 위라서, 패킷 하나가 유실되면 모든 스트림이 재전송을 기다린다.
  • HTTP/3 — 아예 TCP를 버리고 UDP 위에 QUIC이라는 새 전송 계층을 만들었다. TLS가 내장돼 있고, 전송 계층이 스트림을 직접 이해한다. 패킷이 유실되면 해당 스트림만 멈추고 나머지는 계속 흐른다.

그럼 어느 버전을 쓸지는 누가 정할까? 개발자가 아니라 브라우저와 서버가 자동으로 협상한다.

  • HTTP/1.1이냐 2냐는 TLS 핸드셰이크의 ALPN 확장에서 정해진다.
  • HTTP/3은 서버가 Alt-Svc 헤더나 DNS로 "나 QUIC도 돼요"라고 광고하면, 브라우저가 다음부터 갈아탄다.
  • JS에서는 선택할 수 없다. DevTools Network 탭의 Protocol 열에서 결과만 볼 수 있다.

그래서 일반적인 환경은? 2026년 기준 상위 1천만 사이트의 약 80%가 HTTP/2를, 약 30%가 HTTP/3까지 지원한다 (대형 사이트일수록 HTTP/3 비율이 높다). 즉 여러분이 여는 웹페이지의 fetch/문서 트래픽은 대부분 h2, 점점 h3다.

반면 WebSocket만은 예외다. 아래에서 보듯 서버·중간 장비 지원이 고르지 않아, 실전에서는 여전히 대부분 HTTP/1.1 Upgrade로 열린다. "사이트는 h3인데 소켓은 http/1.1" — 이게 오늘날의 표준 풍경이다.

왜 Upgrade가 불가능해졌는가

익숙한 계층 그림에 위치시키면 이유가 보인다:

HTTP/1.1:  TCPTLS → 연결 전체가 하나의 대화 → 101 이후 통째로 WebSocket
HTTP/2:    TCPTLSHTTP/2 프레이밍 → 스트림 1, 스트림 3, 스트림 5,HTTP/3:    UDPQUICHTTP/3 프레이밍 → 스트림들 (QUICTLS를 내장)

프론트엔드 실무로 매핑해보자:

  • HTTP/2에서는 같은 오리진으로 보내는 fetch 여러 개가 TCP 연결 하나를 스트림으로 나눠 쓴다 (멀티플렉싱).
  • 그런데 레슨 1의 Upgrade는 "지금부터 이 연결 전체는 WebSocket입니다"라는 선언이었다.
  • 이 선언을 HTTP/2 연결에 하면? 옆에서 달리던 다른 fetch 스트림들이 전부 죽는다.

업그레이드는 "연결 단위" 개념이라, "스트림 단위" 세계와 양립할 수 없다. 그래서 RFC 8441이 한 일을 한 문장으로 줄이면: 바꾸는 단위를 "연결"에서 "스트림"으로 내렸다. 이게 전부다.

메커니즘: Extended CONNECT (§3–5)

순서는 세 단계다. 각 단계에 레슨 1의 대응물이 있어서, 나란히 놓고 보면 이해가 쉽다:

단계HTTP/2 (RFC 8441)레슨 1의 대응물
① 서버의 능력 광고서버가 SETTINGS_ENABLE_CONNECT_PROTOCOL = 1 을 먼저 보낸다 (§3)(없음 — 새 개념)
② 요청CONNECT 메서드 + :protocol = websocket (§5)GET + Upgrade: websocket
③ 수락:status = 200 — 이후 이 스트림은 "TCP 연결인 것처럼" 사용101 Switching Protocols

이제 각 단계를 풀어 읽어보자.

① "능력 광고"란

HTTP/2 연결은 열리자마자 양쪽이 **SETTINGS 프레임**을 교환한다. "내가 동시에 받을 수 있는 스트림 수는 이만큼, 수신 버퍼는 이만큼…" 하는 설정값 목록 이다.

RFC 8441은 이 목록에 항목 하나를 추가했다:

  • 이름: SETTINGS_ENABLE_CONNECT_PROTOCOL (식별자 0x8)
  • 서버가 이 값을 1로 보내면 = "나는 Extended CONNECT를 이해한다"는 자기 능력의 사전 선언
  • 이것이 "능력 광고(capability advertisement)"다

핵심은 순서다. 클라이언트는 이 광고를 받은 뒤에만 Extended CONNECT를 시도할 수 있다. 레슨 1에서 Key/Accept가 막던 사고 — "WebSocket을 모르는 서버가 얼떨결에 동의한 것처럼 보이는 일" — 가 여기서는 원천적으로 불가능하다. 서버가 먼저 자격을 밝혔으니까.

심화: 광고의 타이밍, 바이트, 그리고 규칙 (§3)

  • 언제 오는가 — HTTP/2 연결의 첫 인사(connection preface)에 SETTINGS가 포함된다. 서버가 보내는 첫 프레임이 SETTINGS여야 한다(MUST, RFC 9113 §3.4). 즉 광고는 어떤 요청보다도 먼저 도착한다 — "사전" 선언이 성립하는 이유다.
  • 바이트로는 — SETTINGS의 각 항목은 식별자 2바이트 + 값 4바이트, 총 6바이트다. 이 광고의 실체는 00 08 00 00 00 01 여섯 바이트가 전부다 (식별자 0x8, 값 1).
  • 값은 0 아니면 1 (MUST).
  • 1을 받은 클라이언트는 시도해도 된다 (MAY — 의무가 아니라 허가).
  • 한번 1을 보냈으면 0으로 되돌리는 것 금지 (MUST NOT) — 광고는 편도 래치(latch)다. 이미 날아가고 있는 CONNECT 요청과의 경쟁을 없애기 위해서다.
  • 왜 서버 쪽 수신은 무의미한가 — "Receipt of this parameter by a server does not have any impact"(§3). Extended CONNECT를 시작하는 쪽은 항상 클라이언트이므로, 의미 있는 방향은 서버→클라이언트뿐이다.
  • 광고 없이 시도하면 — 미지원 서버는 :protocol 이 붙은 CONNECT를 기형 요청으로 보고 스트림 오류를 낸다(§5). "받은 뒤에만"이 예의가 아니라 오류 방지인 이유다.

② "Extended CONNECT"란

이름부터 정리하자. Extended CONNECT는 HTTP 버전의 이름이 아니라 요청 방식의 이름이다.

  • CONNECT 는 HTTP/1.1 시절부터 있던 메서드다. 원래 용도는 프록시에게 "저 호스트로 가는 터널을 뚫어달라"고 부탁하는 것 (회사 프록시를 통과해 HTTPS를 쓸 때 지금도 쓰인다).
  • 터널이 뚫리면 프록시는 내용을 해석하지 않고 바이트를 그대로 중계만 한다.
  • RFC 8441은 이 터널 개념을 **확장(extend)**했다: 목적지 호스트 대신, :protocol 이라는 새 의사 헤더로 "이 터널 안에서 말할 프로토콜"을 지정하게 한 것.
  • 그래서 이름이 Extended CONNECT다.

실제 요청은 이렇게 생겼다:

HTTP/2에는 "GET /chat HTTP/1.1" 같은 요청 줄이 없다 —
그 자리를 :이름 형태의 의사 헤더(pseudo-header)들이 대신한다.

:method = CONNECT
:protocol = websocket   ← RFC 8441이 추가한 다섯 번째 의사 헤더
:scheme = https
:path = /chat
:authority = server.example.com
sec-websocket-version = 13
origin = https://example.com   ← 살아남은 헤더들 (아래 절)

③ 왜 101이 아니라 200인가

두 가지 이유가 겹친다:

  • 형식적 이유 — HTTP/2에는 101이라는 상태 코드 자체가 없다. Upgrade와 함께 제거됐다.
  • 의미적 이유 — 101은 "연결 전체의 프로토콜을 바꾼다"는 선언이었다. 그런데 여기서는 아무것도 바뀌지 않는다. 연결은 계속 HTTP/2이고, 스트림 하나가 터널로 쓰이기 시작 할 뿐이다. CONNECT 요청이 성공했으니 평범한 "성공(200)"이면 충분하다.

수락 이후는 배운 그대로다:

  • WebSocket 프레임(레슨 2)이 HTTP/2 DATA 프레임의 페이로드에 실려 다닌다.
  • 종료 핸드셰이크(레슨 3)와 ping/pong(레슨 4)도 그 안에서 그대로 동작한다.
  • 클라이언트 마스킹도 여전히 적용된다 — RFC 8441은 WebSocket 프로토콜 자체를 한 글자도 고치지 않았다.
  • TCP 수준의 정상 종료는 스트림의 END_STREAM 플래그로, 강제 절단(RST)은 RST_STREAM 프레임으로 번역된다 (§5).

사라진 헤더, 살아남은 헤더 (§5)

§5는 명시한다: Extended CONNECT에서는 Sec-WebSocket-KeySec-WebSocket-Accept 를 쓰지 않는다.

레슨 1을 기억하면 당연한 귀결이다:

  • 그 둘의 존재 이유 = "WebSocket을 모르는 서버·중간 장비가 실수로 업그레이드에 동의한 것처럼 보이는 사고"의 방지였다.
  • Extended CONNECT는 서버가 SETTINGS로 먼저 능력을 광고해야만 시작된다. 오인의 여지 자체가 없다.
  • 자물쇠가 지키던 문이 사라졌으니, 자물쇠도 치운 것.

반면 협상용 헤더들은 살아남는다. Origin, Sec-WebSocket-Version, Sec-WebSocket-Protocol, Sec-WebSocket-Extensions 는 CONNECT 요청에 그대로 실린다 — 출처 확인, 버전·서브프로토콜·확장 협상이라는 역할은 전송 계층이 바뀌어도 필요하니까.

헤더 생존 판정 — 직접 풀어보기

레슨 1의 핸드셰이크 요청에 있던 각 항목이 Extended CONNECT에서도 쓰이는지, 답을 보기 전에 먼저 판정해보라. 판정 기준은 하나다:

역할이 전송 계층에 묶여 있는가 → 제거. 전송과 무관한 협상·보안인가 → 유지.

Upgrade: websocket · Sec-WebSocket-Key · Sec-WebSocket-Version · Origin · Sec-WebSocket-Accept · Sec-WebSocket-Protocol

정답 보기
헤더판정이유
Upgrade: websocket제거:protocol = websocket 의사 헤더로 대체 — Upgrade 자체가 HTTP/2에서 제거됐다
Sec-WebSocket-Key제거SETTINGS 사전 광고가 오인 방지를 대신하므로 처리하지 않는다 (§5)
Sec-WebSocket-Version유지버전 협상은 전송이 바뀌어도 필요 — CONNECT 요청에 그대로 실린다 (§5)
Origin유지출처 확인은 전송과 무관한 보안 요구 — 유지된다 (§5)
Sec-WebSocket-Accept제거Key가 사라졌으니 그 응답인 Accept도 함께 사라진다 (§5)
Sec-WebSocket-Protocol유지서브프로토콜 협상은 여전히 필요 — 유지된다 (§5)

HTTP/3은? — RFC 9220, 3쪽의 문서

RFC 9220(2022)은 "의사 헤더와 SETTINGS의 의미는 HTTP/2와 동일하다"(§3.2)고 선언하고, 전송 계층 용어만 번역한다:

  • 스트림 종료 → QUIC 스트림의 FIN 비트
  • 강제 절단 → H3_REQUEST_CANCELLED 스트림 오류 (§3.4)
  • 서버 미지원 → 501 Not Implemented 응답 (§3.3)

전송 계층을 갈아끼우는 데 3쪽이면 충분했다 — 설계가 계층을 잘 나눴다는 증거다.

프론트엔드에게 보이는 것: 아무것도 없다

중요한 반전이다. new WebSocket(url) API는 바뀌지 않는다.

  • 어떤 전송(HTTP/1.1 Upgrade냐 Extended CONNECT냐)을 쓸지는 전적으로 브라우저의 선택이다.
  • JS에서는 알 수도, 고를 수도 없다.
  • 확인하려면 DevTools Network 탭에서 해당 요청의 Protocol 열을 봐야 한다.

지원 현황 (2026년 기준):

  • HTTP/2 (RFC 8441) — Chrome·Firefox가 구현했다. 단, 조건부다: 해당 오리진으로 이미 열려 있는 HTTP/2 연결이 있고 + 서버가 SETTINGS를 광고했을 때, 그 연결에 편승하는 방식이다. 그래서 실전에서는 여전히 HTTP/1.1 Upgrade로 열리는 경우가 많다. 서버·CDN 쪽 지원도 고르지 않다 (nginx는 미지원).
  • HTTP/3 (RFC 9220) — 아직 주요 브라우저·서버 어느 쪽도 프로덕션 출시 전이다. HTTP/3 시대의 실시간 통신으로는 별도 API인 WebTransport가 경쟁 중이다.

수업 중 질문: 같은 서버인데 API는 h2/h3, 소켓은 http/1.1일 수 있나?

있다 — 그리고 그게 오늘날의 기본값이다. 이유는 세 겹이다:

  • 협상은 오리진당이 아니라 연결당. ALPN 협상은 연결을 새로 열 때마다 각각 일어난다. new WebSocket(url) 이 별도 연결을 열면 fetch의 h2 연결과 무관하게 자기 협상을 하고, 브라우저는 소켓 연결엔 확실히 통하는 HTTP/1.1 Upgrade를 보수적으로 고르는 경우가 많다.
  • h2 편승은 조건부. 이미 열린 h2 연결 + 서버의 SETTINGS 광고, 두 조건이 모두 맞아야 Extended CONNECT가 시도된다.
  • "같은 서버"가 같은 경로가 아니다. h2/h3을 종단하는 것은 보통 CDN·로드밸런서 앞단인데, 이 앞단이 WebSocket을 뒤로 넘길 때는 Extended CONNECT 미지원(nginx 등)이라 1.1 Upgrade로 처리한다. fetch의 h3은 브라우저↔CDN 구간의 이야기일 뿐이다.

h3 위의 소켓은 아직 브라우저 미출시이므로, 현재 소켓의 실제 선택지는 http/1.1 또는 (조건이 맞을 때) h2 둘뿐이다.

래퍼에 주는 함의. 전송이 무엇이든 JS에 보이는 동작(readyState, 이벤트, close code)은 동일하므로 래퍼 코드는 바꿀 것이 없다.

다만 디버깅 시나리오가 하나 늘었다. HTTP/2로 편승한 WebSocket은 같은 연결의 다른 트래픽과 운명을 공유한다 — 연결 수준 오류(GOAWAY 등)가 나면 옆 스트림의 fetch와 WebSocket이 동시에 끊긴다. "fetch랑 소켓이 같이 죽었다"는 증상을 보면 이 레슨을 떠올릴 것.

확인 퀴즈

Q1. HTTP/2에서 Upgrade 메커니즘을 쓸 수 없는 근본 이유는? (업그레이드는 연결 단위인데 HTTP/2는 스트림 단위라서 / 바이너리 프레이밍이 Upgrade 헤더를 파싱 못 해서 / TLS가 프로토콜 전환을 보안상 금지하고 있어서 / 서버 푸시 기능과 업그레이드가 서로 충돌해서)

정답 보기

업그레이드는 연결 단위인데 HTTP/2는 스트림 단위라서. 한 연결을 여러 스트림이 나눠 쓰므로 연결 전체를 WebSocket으로 바꾸면 다른 스트림이 전부 죽는다. HTTP/2는 아예 101과 Upgrade를 제거했다 (RFC 8441 §1).

Q2. 클라이언트가 Extended CONNECT를 시도할 수 있는 전제 조건은? (서버가 SETTINGS로 지원 능력을 먼저 광고했을 때 / 클라이언트가 Sec-WebSocket-Key를 먼저 전송했을 때 / 해당 스트림 번호가 홀수로 새로 열렸을 때만 / 이전 요청이 101 응답으로 성공한 이력이 있을 때)

정답 보기

서버가 SETTINGS로 지원 능력을 먼저 광고했을 때. SETTINGS_ENABLE_CONNECT_PROTOCOL = 1 수신 후에만 시도할 수 있다 (MAY, RFC 8441 §3). 오인 방지 장치가 Key/Accept에서 사전 광고로 바뀐 것.

Q3. Extended CONNECT에서 Sec-WebSocket-Key/Accept를 안 쓰는 이유는? (서버가 사전에 능력을 광고해 오인 여지가 없어서 / 의사 헤더 규칙상 Sec- 접두사가 금지되어서 / HTTP/2의 HPACK 압축이 그 헤더를 못 담아서 / 마스킹 키가 그 역할을 대신 수행하게 되어서)

정답 보기

서버가 사전에 능력을 광고해 오인 여지가 없어서. Key/Accept의 목적은 "모르고 동의한 업그레이드" 방지였다. SETTINGS 사전 광고 체제에서는 그 사고가 불가능하므로 자물쇠를 치웠다 (§5). 마스킹은 별개로 여전히 적용된다.

Q4. 수락 후 WebSocket 프레임은 어디에 실려 이동하는가? (해당 스트림의 HTTP/2 DATA 프레임 페이로드 안에 / 전용으로 새로 정의된 WEBSOCKET 프레임 타입에 / HTTP/2 프레이밍 없이 TCP 세그먼트에 직접 / 헤더 블록에 base64로 인코딩된 형태로 담겨)

정답 보기

해당 스트림의 HTTP/2 DATA 프레임 페이로드 안에. 스트림이 "TCP 연결인 것처럼" 쓰인다 (§5) — 레슨 2의 프레임이 그대로, DATA 프레임에 캡슐화되어 흐른다. WebSocket 프로토콜 자체는 무변경.

다음 단계

1차 자료 읽기. 오늘의 원문: RFC 8441 — 본문이 5쪽이다. 레슨 1~4를 소화한 지금이라면 처음으로 RFC 한 편을 통독할 수 있는 분량과 난이도다. 이어서 RFC 9220(3쪽)을 읽으면 "스펙이 스펙을 인용하는 방식"까지 보인다.

실무 확인: DevTools Network 탭에서 지금 쓰는 서비스의 WebSocket 요청을 열어 Protocol 열을 확인해보라 — http/1.1 이면 Upgrade, h2 면 방금 배운 Extended CONNECT다.


시리즈 참고: 레슨 1 — WebSocket은 HTTP인가? 열림 핸드셰이크 · 레슨 2 — 프레임 구조: opcode와 마스킹 · 레슨 3 — 종료 핸드셰이크와 close code · 레슨 4 — ping/pong과 keepalive · WebSocket 시리즈 용어집