Real-Time Communication — WebSocket, gRPC Streaming and WebRTC
Reading WebSocket byte by byte
한국어 원문으로 표시합니다.
한 줄 요약
WebSocket 은 HTTP 로 시작해 101 로 갈아탄 뒤, 클라이언트만 마스킹하는 작은 프레임으로 양방향 메시지를 나르고, 닫기 코드로 끊긴 이유를 남깁니다.
왜 이게 필요했나
HTTP 는 클라이언트가 묻고 서버가 답하는 모양이라, 서버가 먼저 말을 거는 기능은 오래 편법으로 채웠습니다. 긴 폴링은 응답을 붙잡아 두는 방식이라 메시지마다 요청을 새로 보냈고, 헤더가 메시지보다 컸습니다. RFC 6455 의 WebSocket 은 연결 하나를 열어 두고 양쪽이 아무 때나 메시지를 보내게 합니다. 그리고 기존 HTTP 인프라 — 80·443 포트, 프록시, 쿠키 — 를 그대로 지나가도록 HTTP 요청으로 시작합니다.
라이브러리가 이 모든 것을 대신해 주지만, 장애 기록에는 대개 "1006 으로 끊겼다" 같은 한 줄만 남습니다. 그 한 줄을 읽으려면 바이트가 어떻게 생겼는지 알아야 합니다.
어떻게 동작하나
열기 핸드셰이크(4절)는 평범한 HTTP/1.1 GET 입니다. 클라이언트는 Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Version: 13 과 무작위 16바이트를 base64 로 적은 Sec-WebSocket-Key 를 보냅니다. 서버는 그 키 문자열 뒤에 고정된 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 을 붙여 SHA-1 로 해시하고, 그 20바이트를 base64 로 만든 값을 Sec-WebSocket-Accept 에 담아 101 Switching Protocols 로 답합니다. 1.3절의 예시 키 dGhlIHNhbXBsZSBub25jZQ== 는 s3pPLMBiTxaQ9kYGzzhZRbK+xOo= 가 됩니다. 이 계산은 비밀을 지키려는 것이 아니라, 상대가 WebSocket 을 정말 이해하는 서버인지 확인하려는 것입니다. 버전이 맞지 않으면 서버는 426 과 함께 지원하는 버전을 알려 줍니다.
그다음부터는 프레임입니다(5.2절).
0 1 2 3
|F|R|R|R| opcode|M| 길이(7) | 확장 길이(0·2·8바이트) ...
|I|S|S|S| (4) |A| |
|N|V|V|V| |S| | 마스크 열쇠(마스킹할 때 4바이트) | 본문 ...
opcode 는 0(이어짐)·1(텍스트)·2(바이너리)·8(닫기)·9(ping)·10(pong) 입니다. 길이가 125 이하면 7비트에 그대로, 126 이면 뒤 2바이트, 127 이면 뒤 8바이트에 적고, 8바이트 길이의 최상위 비트는 0 이어야 합니다. RSV 비트는 확장(예: 압축의 permessage-deflate)을 합의했을 때만 켤 수 있고, 합의 없이 켜진 프레임을 받으면 연결을 실패시켜야 합니다.
클라이언트가 보내는 프레임은 반드시 마스킹하고, 서버가 보내는 프레임은 마스킹하지 않습니다(5.1절). 마스킹은 본문의 i 번째 바이트를 열쇠의 i mod 4 번째 바이트와 XOR 하는 것뿐이고, 열쇠는 프레임에 그대로 실려 갑니다. 목적은 비밀 유지가 아니라 10.3절이 설명하는 중간 캐시 오염 공격을 막는 것입니다. 공격자가 고른 바이트가 그대로 선로에 나가면, WebSocket 을 모르는 투명 프록시가 그것을 HTTP 요청과 응답으로 오해해 캐시에 담을 수 있기 때문입니다. 서버는 마스킹되지 않은 클라이언트 프레임을 받으면 연결을 닫아야 합니다.
제어 프레임(닫기·ping·pong)은 본문이 125바이트 이하이고 조각나지 않아야 하며, 조각난 데이터 메시지의 조각 사이에 끼어들 수 있습니다. pong 은 받은 ping 의 본문을 그대로 돌려줍니다. 텍스트 메시지는 UTF-8 이어야 합니다.
닫기(7절)는 2바이트 상태 코드와 선택적인 UTF-8 이유로 이루어집니다. 한쪽이 close 를 보내면 다른 쪽도 close 로 답하고, 그 뒤 서버가 TCP 를 먼저 닫습니다.
| 코드 | 뜻 |
|---|---|
| 1000 | 정상 종료 |
| 1001 | 가는 중(서버 종료, 페이지 이동) |
| 1002 | 프로토콜 오류 |
| 1006 | close 없이 끊김 — 받는 쪽이 붙이는 이름이고 보내면 안 된다 |
| 1007 | 메시지 내용이 형식에 맞지 않음(UTF-8 이 아닌 텍스트) |
| 1008 | 정책 위반 |
| 1009 | 메시지가 너무 큼 |
| 1011 | 서버 내부 오류 |
| 4000~4999 | 애플리케이션이 쓰도록 남겨 둔 구간 |
1012(서비스 재시작)와 1013(나중에 다시 시도)은 RFC 본문이 아니라 IANA 의 WebSocket 닫기 코드 등록부에 올라 있는 값입니다.
현장에서 만나는 모습
"프록시 뒤에서만 연결이 안 된다" 는 대개 프록시가 Upgrade 와 Connection 머리를 넘기지 않아 핸드셰이크가 400 이나 200 으로 끝나는 경우입니다. nginx 에서는 두 머리를 명시적으로 넘기는 설정을 해야 합니다. 브라우저의 WebSocket API 는 사용자 정의 요청 머리를 붙일 수 없어서, 인증은 쿠키나 첫 메시지, 또는 URL 의 일회용 토큰으로 합니다. 이 실습 플랫폼의 웹 터미널이 쿠키로 신원을 확인하는 것도 같은 이유입니다.
다음 실습에서 할 것
accept 키 계산부터 프레임 부호화·해석, 핸드셰이크 응답, 메아리 서버, 닫기 코드까지 표준 라이브러리로 만듭니다. 채점기는 날 소켓으로 붙어 서버 프레임의 MASK 비트와 닫기 코드를 바이트로 확인하고, 마지막에는 websockets 라이브러리 클라이언트가 여러분의 서버를 받아들이는지 봅니다.