← 문제로

17. WebSocket 프로토콜: 핸드셰이크, 프레임 구조, 게임 트래픽에서의 트레이드오프

난이도 중
내 답안
모범답안

모범답안 — WebSocket 프로토콜: 핸드셰이크, 프레임 구조, 게임 트래픽에서의 트레이드오프

난이도: 중

1. HTTP 업그레이드 핸드셰이크

  • WebSocket 은 일반 HTTP 요청으로 시작한다. 클라이언트는 다음과 같은 헤더를 포함한 GET 요청을 보낸다.
    GET /ws HTTP/1.1
    Host: game.example.com
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    Sec-WebSocket-Version: 13
    
    • Upgrade: websocket / Connection: Upgrade — "이 연결을 HTTP 가 아니라 WebSocket 프로토콜로 전환해달라"는 요청. 이는 원래 HTTP/1.1 이 프로토콜을 전환하는 표준 메커니즘(Upgrade 헤더)을 재사용한 것이다.
    • Sec-WebSocket-Key — 클라이언트가 생성한 임의의 16바이트를 Base64 인코딩한 값(nonce). 요청마다 다르다.
  • 서버는 WebSocket 업그레이드를 수락하면 101 Switching Protocols 로 응답하고, Sec-WebSocket-Accept 헤더에 다음을 계산해 담는다.
    Sec-WebSocket-Accept = Base64( SHA1( Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" ) )
    
    즉 클라이언트가 보낸 키에 고정된 GUID(magic string) 를 이어붙여 SHA-1 을 구하고 Base64 로 인코딩한다. 클라이언트는 응답의 Sec-WebSocket-Accept 값이 자신이 로컬에서 같은 방식으로 계산한 값과 일치하는지 검증한다.
  • 이 메커니즘이 막으려는 것:
    • 이 nonce+magic-string 계산은 암호학적 보안을 위한 것이 아니라(GUID 는 공개돼 있다), 서버가 실제로 WebSocket 프로토콜을 이해하고 응답했다는 것을 확인하기 위한 것이다. 예를 들어 아무 HTTP 서버나 오래된 캐싱 프록시가 Upgrade 헤더를 무시하고 요청을 그대로 에코하거나 캐시된 응답을 돌려주는 경우, Sec-WebSocket-Accept 를 올바르게 계산해 넣지 못하므로 클라이언트가 핸드셰이크 실패를 감지할 수 있다.
    • 또한 이 과정은 일반 HTTP 클라이언트가 실수로, 혹은 악의적인 웹페이지의 스크립트가 임의 TCP 소켓을 여는 용도로 WebSocket API 를 오용하지 못하게 막는 효과도 있다 — 서버가 WebSocket 핸드셰이크 규약을 정확히 따르지 않으면 연결이 열리지 않으므로, 순수 TCP 에 HTTP 흉내만 낸 요청을 보내 방화벽/프록시 뒤의 내부 서비스를 공격하는 것(cross-protocol scripting)이 어려워진다.

2. 프레임 구조

WebSocket 은 핸드셰이크 이후 데이터를 프레임(frame) 단위로 주고받는다. 기본 프레임 헤더는 다음 필드로 구성된다.

  • FIN (1비트) — 이 프레임이 메시지의 마지막 프레임인지 여부. 1 이면 메시지 종료, 0 이면 뒤에 continuation 프레임이 더 온다(조각화, 3번 참고).
  • opcode (4비트) — 페이로드의 종류를 나타낸다.
    • 0x0 continuation(조각 이어붙이기), 0x1 text(UTF-8), 0x2 binary,
    • 0x8 close(연결 종료), 0x9 ping, 0xA pong(하트비트/연결 확인용 제어 프레임).
  • MASK (1비트) — 페이로드가 마스킹돼 있는지 여부. 클라이언트→서버 프레임은 항상 1(3번 참고).
  • Payload length — 가변 길이 인코딩.
    • 7비트 값이 0~125 면 그 값이 곧 실제 길이.
    • 값이 126 이면, 이어지는 16비트 필드가 실제 길이(최대 65535바이트).
    • 값이 127 이면, 이어지는 64비트 필드가 실제 길이(초대형 메시지 지원). 이렇게 작은 메시지는 헤더 오버헤드를 최소화하고, 큰 메시지는 확장 필드로 표현하는 가변 길이 설계다.
  • 이후 MASK=1 이면 4바이트 마스킹 키, 그리고 (마스킹된) 페이로드 데이터가 따른다.

3. 클라→서버 마스킹이 필수인 이유

  • WebSocket 표준(RFC 6455)은 클라이언트가 보내는 모든 프레임은 반드시 마스킹하도록 강제하고, 서버가 보내는 프레임은 마스킹하지 않는다. 이 비대칭은 암호화가 아니라 프록시/캐시 오염 공격 방지가 목적이다.
  • 배경: WebSocket 연결은 기존 HTTP 인프라(프록시, 캐시 서버, 로드밸런서) 위를 지나간다. 만약 페이로드가 마스킹 없이 그대로 노출되면, 공격자가 제어하는 웹페이지의 자바스크립트가 특정 바이트 패턴을 가진 WebSocket 메시지를 만들어 전송함으로써, 이 트래픽을 HTTP 요청으로 잘못 해석하는 오래된/취약한 중간 프록시나 캐시를 속여 캐시 오염(cache poisoning)이나 요청 스머글링 같은 공격을 시도할 수 있었다(초기 WebSocket 설계에서 실제로 지적된 취약점).
  • 브라우저가 생성하는 트래픽(클라이언트→서버)은 공격자가 페이로드 내용을 선택할 수 있는 경로이므로 마스킹을 강제해, 프록시가 페이로드 내용을 예측 가능한 패턴으로 인식하지 못하게(마스킹 키가 매 프레임 랜덤) 만든다. 반대로 서버가 보내는 데이터는 신뢰된 서버가 생성하므로 이런 위협이 없어 마스킹을 요구하지 않는다.
  • 마스킹 키는 4바이트 XOR 키일 뿐이며 비밀로 취급되지 않는다(누구나 프레임에서 읽을 수 있다). 따라서 기밀성을 제공하지 않으며, TLS(wss://)를 대체하지 않는다 — 순전히 프록시 오작동 방지용 난독화다.

4. 메시지 조각화(fragmentation)

  • 하나의 논리적 메시지(예: 큰 JSON 페이로드, 이미지)를 여러 프레임으로 나눠 보낼 수 있다.
    • 첫 프레임: opcode = 0x1(text) 또는 0x2(binary), FIN = 0.
    • 중간 프레임들: opcode = 0x0(continuation), FIN = 0.
    • 마지막 프레임: opcode = 0x0(continuation), FIN = 1.
    • 수신자는 FIN=1 이 올 때까지 continuation 프레임들의 페이로드를 순서대로 이어붙여 원래 메시지를 재구성한다.
  • 왜 필요한가:
    • 전체 메시지 크기를 사전에 몰라도(예: 스트리밍 생성 데이터) 점진적으로 전송할 수 있다.
    • 큰 메시지를 하나의 거대한 프레임으로 보내면 그 프레임이 전송되는 동안 다른 긴급한 데이터(예: ping/pong control 프레임)를 끼워 보낼 수 없다. 조각화하면 조각 사이에 제어 프레임을 인터리빙할 수 있어(제어 프레임은 조각화될 수 없고 항상 완결된 단일 프레임), 단일 TCP 연결이라는 제약 속에서도 최소한의 응답성을 확보할 수 있다.
    • WebSocket 은 HTTP/2 같은 다중 논리 스트림이 없는 단일 스트림 프로토콜 이므로, 조각화는 "하나의 연결을 여러 목적에 나눠 쓰는" 유일한 수단이다.

5. TCP 기반이라는 특성과 게임 트래픽에서의 트레이드오프

  • 지연(latency): WebSocket 은 TCP 위에서 동작하므로 TCP 의 특성을 그대로 물려받는다. 연결 수립에 TCP 3-way handshake + WebSocket HTTP 핸드셰이크(추가 RTT)가 필요하고, wss:// 라면 TLS 핸드셰이크까지 더해져 최초 연결 지연이 raw UDP 보다 크다.
  • 헤드오브라인(HOL) 블로킹: TCP 는 바이트 스트림의 순서를 엄격히 보장한다. 패킷 하나가 유실되면, 그 뒤에 이미 도착한 데이터(다른 WebSocket 메시지/프레임 포함)라도 유실된 패킷이 재전송되어 도착할 때까지 애플리케이션에 전달되지 않는다. 실시간 게임에서는 이것이 치명적이다 — 최신 입력/스냅샷이 이미 도착했어도 커널 소켓 버퍼에 갇혀 지연 스파이크를 만든다. UDP 기반 게임 프로토콜은 유실된 패킷을 그냥 버리고 최신 상태만 반영하는 전략(state snapshot + 최신 값 우선)을 쓸 수 있지만, WebSocket/TCP 는 이 선택지가 없다. 또한 재전송 대기 중 혼잡 제어가 개입해 전송 속도를 낮추므로, 순간적인 패킷 유실이 이후 처리량에도 영향을 준다.
  • UDP/QUIC 대비 트레이드오프:
    • raw UDP: HOL 블로킹이 없고 지연에 최적화돼 있지만, 신뢰성·순서·혼잡제어를 애플리케이션이 직접 구현해야 하고, 브라우저 자바스크립트에서는 애초에 raw UDP 소켓을 열 수 없다.
    • QUIC/WebTransport: UDP 위에서 스트림별 독립 신뢰성을 제공해 HOL 블로킹을 스트림 단위로 완화하고, 브라우저에서도(WebTransport API로) 점차 지원되고 있지만, 아직 WebSocket 만큼 보편적으로 지원되거나 인프라(프록시/방화벽) 호환성이 검증되지 않았다.
    • WebSocket: HOL 블로킹과 TCP 지연 특성을 감수하는 대신, 모든 브라우저가 표준 지원하고 기존 HTTP 인프라(포트 443, 프록시, 로드밸런서, WAF)를 그대로 통과한다.
  • 그럼에도 브라우저 게임이 WebSocket 을 선택하는 이유:
    1. 플랫폼 제약: 브라우저 자바스크립트 환경은 raw TCP/UDP 소켓 API 를 제공하지 않는다(보안상 의도적으로 막혀 있다). WebSocket 은 브라우저가 제공하는 사실상 유일한 지속적 양방향 소켓 추상화다(WebTransport 은 아직 지원이 제한적).
    2. 방화벽/프록시 통과성: WebSocket 은 HTTP(S) 표준 포트(80/443)와 HTTP 업그레이드 절차를 사용하므로, 기업 방화벽·프록시·NAT 를 UDP 기반 프로토콜보다 훨씬 안정적으로 통과한다. UDP 는 사내망/공용 와이파이에서 차단되는 경우가 흔하다.
    3. 구현 단순성과 생태계: 거의 모든 언어/프레임워크가 성숙한 WebSocket 라이브러리를 제공하고, 로드밸런서·CDN·모니터링 도구의 지원도 두텁다. 캐주얼/턴제/보드게임처럼 프레임 단위 정밀도가 필요 없는(초 단위, 수십~ 수백 ms 지연에 크게 민감하지 않은) 장르에서는 TCP 의 HOL 블로킹 비용보다 "어디서나 동작한다"는 이점이 더 크다.
    • 결론적으로, 프레임 단위 정밀도가 필요한 하드코어 FPS/액션 게임은 네이티브 클라이언트 + UDP 를 쓰고, 브라우저 기반·비교적 지연에 관대한 게임은 WebSocket 의 호환성과 단순성을 택하는 것이 합리적인 트레이드오프다.