← posts/b.log()

blog92@web:~$ cat posts/websocket-11-subprotocols-and-extensions.md

NETWORK6 min read

WebSocket 완전 정복 11편 — 서브프로토콜과 확장: 버전 협상과 permessage-deflate

Sec-WebSocket-Protocol로 애플리케이션 프로토콜과 버전을 핸드셰이크 단계에서 협상하는 법과 협상이 어긋났을 때의 동작, 그리고 permessage-deflate 압축 확장이 주는 이득과 연결당 컨텍스트 메모리·CPU·CRIME류 위험이라는 대가를 다룹니다.

WebSocket 완전 정복 · 2부. 중급: 프로토콜 내부와 설계 패턴 · 16편 중 11편

이전 편: 10편 — 인증과 인가

10편까지는 연결을 여는 쪽과 지키는 쪽을 봤습니다. 이번 편은 그 연결 위에서 무엇을 말할지(서브프로토콜)와 얼마나 줄여서 보낼지(압축 확장)를 핸드셰이크 단계에서 정하는 방법입니다.

서브프로토콜 — Sec-WebSocket-Protocol

클라이언트가 목록을 보내면 서버가 하나를 선택. 애플리케이션 프로토콜·버전 협상용(graphql-transport-ws, mqtt, chat.v2). 목록에 없는 값을 응답하면 브라우저가 연결 실패 처리.

이름은 IANA의 WebSocket Subprotocol Name Registry에 등록할 수 있습니다. mqtt나 STOMP(v12.stomp)는 거기 등재된 이름이고, graphql-transport-ws는 등재 없이 쓰이는 이름입니다 — 등록이 연결의 조건은 아닙니다. RFC 6455는 충돌을 피하려면 chat.example.com처럼 만든 쪽의 도메인을 이름에 넣으라고 권합니다.

협상이 어긋나면 무슨 일이 일어나는가

협상은 Sec-WebSocket-Protocol 헤더 한 줄로 끝나지만, 결과는 서버가 무엇을 돌려보내느냐에 따라 갈립니다. 클라이언트 쪽에서 보이는 결과는 ws.protocol 하나입니다.

클라이언트 요청서버 응답결과
목록을 보냄목록 중 하나연결 성립. ws.protocol에 그 값
목록을 보냄목록에 없는 값연결 실패. RFC 6455가 클라이언트에게 실패 처리를 요구한다
목록을 보냄헤더 없음연결 실패. 브라우저(WHATWG 표준)와 ws 클라이언트 모두 요청한 서브프로토콜에 서버가 답하지 않은 것으로 본다
목록을 보내지 않음헤더 없음연결 성립. ws.protocol은 빈 문자열
목록을 보내지 않음헤더 있음연결 실패. 요청에 없던 값이므로 두 번째 줄과 같다

세 번째 줄이 함정입니다. RFC 6455의 서버 규칙은 합의할 이름이 없으면 헤더를 보내지 말라는 것인데, 그 응답을 받은 브라우저는 연결을 세우지 않습니다. 둘이 어긋나는 것은 아닙니다 — RFC 6455도 "클라이언트가 지정했다면, 연결이 성립하려면 서버가 같은 필드에 선택한 값 하나를 담아야 한다"고 적고, WHATWG가 그 결과를 클라이언트 쪽 MUST로 못 박았을 뿐입니다. 즉 클라이언트가 목록을 보냈다면 서버는 반드시 그중 하나를 골라야 하고, 고를 것이 없으면 연결은 거기서 끝납니다. 이것이 다음 절에서 서브프로토콜로 버전을 거를 때 기대는 동작입니다.

ws 서버의 기본 동작도 알아 둘 필요가 있습니다. handleProtocols 옵션을 주지 않으면 클라이언트가 보낸 목록의 첫 번째 값을 그대로 돌려줍니다. 서버가 그 프로토콜을 실제로 구현했는지는 보지 않으므로, 협상을 의미 있게 하려면 직접 골라야 합니다.

ts
import { WebSocketServer } from "ws";
 
const SUPPORTED = ["chat.v2", "chat.v1"]; // 서버가 말할 수 있는 것, 선호 순
 
const wss = new WebSocketServer({
  port: 8080,
  // protocols: 클라이언트가 보낸 목록(Set). 문자열을 돌려주면 그 값이 응답 헤더가 되고,
  // false를 돌려주면 헤더를 생략한다 → 목록을 보낸 클라이언트는 연결 실패로 처리한다.
  handleProtocols: (protocols) => SUPPORTED.find((p) => protocols.has(p)) ?? false,
});

버전 관리에 서브프로토콜을 쓸 때

8편 — 메시지 설계의 원칙 하나가 처음부터 버전을 관리하라는 것이었습니다 — 배포 중에는 구버전과 신버전이 반드시 섞이기 때문입니다. 버전을 실을 자리는 두 곳이고, 성격이 다릅니다.

서브프로토콜 이름 (chat.v1 / chat.v2)봉투의 v 필드
결정 시점핸드셰이크. 연결이 성립하기 전연결이 성립한 뒤. 메시지를 받아 봐야 안다
거절 비용헤더를 안 보내면 끝. 메시지 트래픽이 0연결과 인증까지 치른 뒤에야 거절할 수 있다
단위연결 하나에 버전 하나메시지마다 다를 수 있다
맞는 경우호환되지 않는 개편. 구버전 클라이언트를 아예 받지 않을 때호환되는 확장. 한 연결 안에서 메시지 종류별로 진화할 때

RFC 6455 자체가 이 구분을 권합니다. 호환되지 않는 변경은 이름을 바꿔(bookings.example.net → v2.bookings.example.net) 완전히 별개의 서브프로토콜로 취급하고, 호환되는 변경은 같은 이름을 유지한 채 프로토콜 안에서 확장하라는 것입니다. 앞 절의 handleProtocols 예제가 그대로 첫 번째 방식입니다 — chat.v1만 아는 오래된 클라이언트는 SUPPORTED에서 chat.v1을 빼는 순간 핸드셰이크에서 걸러집니다.

permessage-deflate — 압축 확장

메시지를 zlib 압축(RFC 7692). 반복 많은 JSON에 효과적이지만,

  • 연결마다 압축 컨텍스트(수십 KB) → 연결 수가 많으면 메모리 급증
  • CPU 사용
  • 비밀값과 공격자 통제값이 같은 컨텍스트에 섞이면 CRIME/BREACH류 위험

압축된 메시지는 프레임 헤더의 RSV1 비트로 표시합니다 — 6편 — 프레임 구조의 그 비트입니다.

위 목록의 첫 항목이 말하는 압축 컨텍스트는 DEFLATE가 최근에 처리한 입력을 담아 두는 LZ77 슬라이딩 윈도입니다. 이 윈도를 메시지 사이에도 이어 쓰는 것을 RFC 7692는 컨텍스트 테이크오버(context takeover)라고 부르고, 협상에 아무 파라미터도 붙이지 않으면 양쪽 모두 이어 쓰기를 허용한 것으로 봅니다. 앞 메시지에서 본 내용을 다음 메시지에서 참조하니 반복이 많은 JSON에 효과적인 대신, 연결이 살아 있는 동안 양쪽 모두 윈도를 잡고 있어야 합니다. 그래서 RFC 7692 §7.1은 연결당 자원을 제한하는 파라미터 네 개를 정의합니다.

파라미터뜻
server_no_context_takeover서버가 메시지 사이에 윈도를 이어 쓰지 않는다. 클라이언트는 메시지 사이에 압축 해제용 컨텍스트를 들고 있을 필요가 없어진다
client_no_context_takeover방향만 반대. 서버가 응답에 넣으면 서버가 연결마다 잡아 둘 메모리가 줄어든다
server_max_window_bits서버 압축기의 윈도 크기 상한. 값은 밑이 2인 로그로 8-15
client_max_window_bits클라이언트 압축기의 윈도 크기 상한. 마찬가지로 8-15

no_context_takeover가 위 목록의 첫 항목에 대한 직접적인 해법입니다. 메시지마다 컨텍스트를 버리게 하면 연결마다 붙들고 있어야 하는 메모리가 줄고, max_window_bits는 컨텍스트 하나의 크기 자체를 줄입니다(아무 값도 주지 않으면 최대 32,768바이트 윈도까지 받아 줘야 합니다). 대가는 압축률입니다 — 앞 메시지를 참조하지 못하니 비슷한 JSON을 반복해 보내는 워크로드에서는 그만큼 덜 줄어듭니다. 절감 폭은 워크로드마다 다르므로 여기서 숫자를 들지 않고, 다음 절의 순서대로 직접 재기를 권합니다.

압축을 켤지 말지 정하는 순서

ws에서는 기본 비활성. 켤 경우 threshold로 작은 메시지는 건너뛰기. 실측 후 결정.

앞 문장의 "기본 비활성"은 WebSocketServer 쪽 기본값입니다. 반대로 ws 클라이언트는 이 확장이 기본으로 켜져 있고 브라우저도 핸드셰이크에서 permessage-deflate를 제안하지만, 서버가 받아 주지 않으면 쓰이지 않습니다. 결정권은 서버에 있습니다. 켠다면 앞 절의 파라미터를 ws의 옵션 이름으로 넘깁니다.

ts
import { WebSocketServer } from "ws";
 
const wss = new WebSocketServer({
  port: 8080,
  perMessageDeflate: {
    serverNoContextTakeover: true, // 서버는 메시지마다 컨텍스트를 버린다
    clientNoContextTakeover: true, // 클라이언트에도 컨텍스트를 버리라고 응답한다
    serverMaxWindowBits: 10,       // 서버 압축 윈도 상한 (2의 10제곱 바이트)
    threshold: 1024,               // 컨텍스트를 이어 쓰지 않을 때, 이보다 작은 페이로드는 압축하지 않는다 (기본값)
  },
});

ws README 자체가 이 확장을 정말 필요할 때만 켜라고 하고, 동시성이 높은 환경에서 Node.js zlib이 겪는 메모리 단편화 문제를 경고하며 실제 워크로드를 본뜬 테스트를 권합니다. 켤지 말지는 아래 순서로 정합니다. 어느 단계에서든 답이 '아니오'이면 끄고 넘어가면 됩니다.

  1. 측정한다. 압축 없이 보낼 때의 메시지 크기와 대역폭을 먼저 잰다. 줄일 것이 없으면 여기서 끝이다.
  2. JSON의 반복률을 본다. 같은 키와 값이 메시지 안에서, 그리고 메시지 사이에서 얼마나 되풀이되는가. 반복이 적으면 CPU만 쓴다.
  3. 동시 연결 수 대비 메모리를 계산한다. 연결 하나의 컨텍스트 크기에 예상 동시 연결 수를 곱한다. 감당이 안 되면 no_context_takeover와 max_window_bits로 줄이고 다시 계산한다. 이 예산에는 압축 컨텍스트만이 아니라 13편 — 백프레셔와 흐름 제어에서 다루는 연결당 송신 버퍼도 함께 들어간다.
  4. 비밀값과 공격자 통제값이 같은 컨텍스트에 섞이는지 본다. 세션 토큰이 실린 메시지와 사용자가 입력한 문자열이 한 연결의 같은 윈도를 지나면 위 목록의 세 번째 위험이 그대로 성립한다. 섞인다면 끄거나, 비밀값을 그 연결에서 치운다. 나머지 점검 항목은 15편 — 보안 체크리스트에 있다.

더 깊이

  • 6편 — 프레임 구조: permessage-deflate가 압축 여부를 표시하는 RSV1 비트가 프레임 헤더 어디에 있는지.
  • 8편 — 메시지 설계: 서브프로토콜 이름으로 거르지 않은 나머지 버전 관리 — 봉투의 v 필드와 다섯 가지 원칙.
COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..