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: 13Upgrade: websocket/Connection: Upgrade— "이 연결을 HTTP 가 아니라 WebSocket 프로토콜로 전환해달라"는 요청. 이는 원래 HTTP/1.1 이 프로토콜을 전환하는 표준 메커니즘(Upgrade헤더)을 재사용한 것이다.Sec-WebSocket-Key— 클라이언트가 생성한 임의의 16바이트를 Base64 인코딩한 값(nonce). 요청마다 다르다.
- 서버는 WebSocket 업그레이드를 수락하면
101 Switching Protocols로 응답하고,Sec-WebSocket-Accept헤더에 다음을 계산해 담는다.
즉 클라이언트가 보낸 키에 고정된 GUID(magic string) 를 이어붙여 SHA-1 을 구하고 Base64 로 인코딩한다. 클라이언트는 응답의Sec-WebSocket-Accept = Base64( SHA1( Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" ) )Sec-WebSocket-Accept값이 자신이 로컬에서 같은 방식으로 계산한 값과 일치하는지 검증한다. - 이 메커니즘이 막으려는 것:
- 이 nonce+magic-string 계산은 암호학적 보안을 위한 것이 아니라(GUID 는
공개돼 있다), 서버가 실제로 WebSocket 프로토콜을 이해하고 응답했다는
것을 확인하기 위한 것이다. 예를 들어 아무 HTTP 서버나 오래된 캐싱 프록시가
Upgrade헤더를 무시하고 요청을 그대로 에코하거나 캐시된 응답을 돌려주는 경우,Sec-WebSocket-Accept를 올바르게 계산해 넣지 못하므로 클라이언트가 핸드셰이크 실패를 감지할 수 있다. - 또한 이 과정은 일반 HTTP 클라이언트가 실수로, 혹은 악의적인 웹페이지의 스크립트가 임의 TCP 소켓을 여는 용도로 WebSocket API 를 오용하지 못하게 막는 효과도 있다 — 서버가 WebSocket 핸드셰이크 규약을 정확히 따르지 않으면 연결이 열리지 않으므로, 순수 TCP 에 HTTP 흉내만 낸 요청을 보내 방화벽/프록시 뒤의 내부 서비스를 공격하는 것(cross-protocol scripting)이 어려워진다.
- 이 nonce+magic-string 계산은 암호학적 보안을 위한 것이 아니라(GUID 는
공개돼 있다), 서버가 실제로 WebSocket 프로토콜을 이해하고 응답했다는
것을 확인하기 위한 것이다. 예를 들어 아무 HTTP 서버나 오래된 캐싱 프록시가
2. 프레임 구조
WebSocket 은 핸드셰이크 이후 데이터를 프레임(frame) 단위로 주고받는다. 기본 프레임 헤더는 다음 필드로 구성된다.
- FIN (1비트) — 이 프레임이 메시지의 마지막 프레임인지 여부.
1이면 메시지 종료,0이면 뒤에 continuation 프레임이 더 온다(조각화, 3번 참고). - opcode (4비트) — 페이로드의 종류를 나타낸다.
0x0continuation(조각 이어붙이기),0x1text(UTF-8),0x2binary,0x8close(연결 종료),0x9ping,0xApong(하트비트/연결 확인용 제어 프레임).
- 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 을 선택하는 이유:
- 플랫폼 제약: 브라우저 자바스크립트 환경은 raw TCP/UDP 소켓 API 를 제공하지 않는다(보안상 의도적으로 막혀 있다). WebSocket 은 브라우저가 제공하는 사실상 유일한 지속적 양방향 소켓 추상화다(WebTransport 은 아직 지원이 제한적).
- 방화벽/프록시 통과성: WebSocket 은 HTTP(S) 표준 포트(80/443)와 HTTP 업그레이드 절차를 사용하므로, 기업 방화벽·프록시·NAT 를 UDP 기반 프로토콜보다 훨씬 안정적으로 통과한다. UDP 는 사내망/공용 와이파이에서 차단되는 경우가 흔하다.
- 구현 단순성과 생태계: 거의 모든 언어/프레임워크가 성숙한 WebSocket 라이브러리를 제공하고, 로드밸런서·CDN·모니터링 도구의 지원도 두텁다. 캐주얼/턴제/보드게임처럼 프레임 단위 정밀도가 필요 없는(초 단위, 수십~ 수백 ms 지연에 크게 민감하지 않은) 장르에서는 TCP 의 HOL 블로킹 비용보다 "어디서나 동작한다"는 이점이 더 크다.
- 결론적으로, 프레임 단위 정밀도가 필요한 하드코어 FPS/액션 게임은 네이티브 클라이언트 + UDP 를 쓰고, 브라우저 기반·비교적 지연에 관대한 게임은 WebSocket 의 호환성과 단순성을 택하는 것이 합리적인 트레이드오프다.