WebSocket 프로토콜
HTTP는 요청-응답 모델이다. 클라이언트가 요청을 보내야만 서버가 응답할 수 있다. 서버가 먼저 클라이언트에게 데이터를 보내는 건 불가능하다. 채팅이나 실시간 알림처럼 서버가 능동적으로 데이터를 푸시해야 하는 상황에서 이 구조는 근본적으로 맞지 않는다.
HTTP 위에서 실시간을 흉내내는 방법들이 있었다. 폴링(polling)은 클라이언트가 일정 간격으로 계속 요청을 보내는 방식이다. 변경이 없어도 요청이 나간다. 롱 폴링(long polling)은 서버가 새 데이터가 있을 때까지 응답을 보류하는 방식인데, 커넥션이 유지되는 동안 서버 자원을 점유하고, 응답 후 다시 연결해야 하는 오버헤드가 있다.
WebSocket은 이 문제를 프로토콜 레벨에서 해결한다. HTTP 커넥션을 업그레이드해서 양방향 통신 채널을 만들고, 이후부터는 HTTP 없이 데이터를 주고받는다.
핸드셰이크
WebSocket 연결은 HTTP에서 시작한다. 클라이언트가 일반 HTTP 요청에 업그레이드 헤더를 붙여서 보낸다.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
핵심은 Upgrade: websocket이다. "이 HTTP 연결을 WebSocket으로 전환하고 싶다"는 뜻이다. Sec-WebSocket-Key는 클라이언트가 생성한 랜덤 값으로, 서버가 정상적인 WebSocket 서버인지 확인하는 데 쓰인다.
서버가 수락하면 101 상태 코드로 응답한다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Accept는 클라이언트가 보낸 Key에 고정 문자열(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)을 붙이고 SHA-1 해시한 뒤 Base64 인코딩한 값이다. 클라이언트는 이 값을 검증해서 상대가 진짜 WebSocket을 이해하는 서버인지 확인한다. 보안이 아니라 프로토콜 준수 확인 용도다.
이 핸드셰이크가 끝나면 TCP 연결은 그대로 유지되고, 프로토콜만 WebSocket으로 바뀐다. 이후 HTTP 헤더 없이 프레임 단위로 데이터가 오간다.
프레임 구조
WebSocket은 메시지를 프레임(frame) 단위로 전송한다. HTTP처럼 매번 헤더를 붙이는 게 아니라, 최소 2바이트짜리 경량 헤더만 사용한다.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Masking-key (0 or 4 bytes) | |
+-------------------------------------+ |
| Payload Data |
+---------------------------------------------------------------+
주요 필드를 보면:
- FIN: 이 프레임이 메시지의 마지막 조각인지 표시. 큰 메시지는 여러 프레임으로 나눠 보낼 수 있다.
- opcode: 프레임 종류. 0x1이면 텍스트, 0x2면 바이너리, 0x8이면 연결 종료, 0x9/0xA는 ping/pong.
- MASK: 클라이언트→서버 프레임은 반드시 마스킹해야 한다. 서버→클라이언트는 마스킹하지 않는다.
- Payload length: 125 이하면 그 자체가 길이, 126이면 다음 2바이트가 길이, 127이면 다음 8바이트가 길이.
마스킹은 보안이 아니라 프록시 캐시 오염 공격을 방지하기 위한 것이다. 중간 프록시가 WebSocket 프레임을 HTTP로 오인해서 캐시에 저장하는 걸 막는다.
연결 유지와 종료
WebSocket 연결은 한번 맺으면 명시적으로 닫을 때까지 유지된다. 연결이 살아있는지 확인하기 위해 ping/pong 프레임을 사용한다. 한쪽이 ping(opcode 0x9)을 보내면, 상대는 반드시 동일한 payload로 pong(opcode 0xA)을 응답해야 한다.
연결을 닫을 때는 close 프레임(opcode 0x8)으로 정상 종료한다. TCP의 FIN처럼 양쪽이 주고받는 구조다.
클라이언트 → 서버: Close (status: 1000, reason: "Normal closure")
서버 → 클라이언트: Close (status: 1000)
// 이후 TCP 연결 종료
상태 코드 1000은 정상 종료, 1001은 서버 종료/페이지 이동, 1006은 비정상 종료(close 프레임 없이 끊김)다.
HTTP와의 차이
| HTTP | WebSocket | |
|---|---|---|
| 방향 | 요청-응답 (단방향) | 양방향 (full-duplex) |
| 연결 | 매 요청마다 새로 (또는 keep-alive) | 한번 연결 후 유지 |
| 오버헤드 | 매 요청에 헤더 포함 | 핸드셰이크 이후 최소 2바이트 |
| 프로토콜 | HTTP/1.1, HTTP/2 | ws://, wss:// |
| 적합한 용도 | 문서 조회, API 호출 | 실시간 채팅, 게임, 주식 시세 |
WebSocket이 만능은 아니다. 단순 데이터 조회에는 HTTP가 낫고, 서버 인프라가 long-lived 커넥션을 감당할 수 있어야 한다. SSE(Server-Sent Events)처럼 서버→클라이언트 단방향만 필요한 경우에는 더 가벼운 대안도 있다.
브라우저에서의 사용
const ws = new WebSocket('wss://example.com/chat');
ws.onopen = () => {
console.log('연결됨');
ws.send('hello');
};
ws.onmessage = (event) => {
console.log('받은 데이터:', event.data);
};
ws.onclose = (event) => {
console.log(`종료: ${event.code} ${event.reason}`);
};
ws.onerror = (error) => {
console.error('에러 발생');
};
send()로 텍스트와 바이너리(ArrayBuffer, Blob) 모두 보낼 수 있다. readyState 프로퍼티로 현재 연결 상태를 확인할 수 있는데, 0(CONNECTING), 1(OPEN), 2(CLOSING), 3(CLOSED) 네 가지다.
핵심 정리
WebSocket은 HTTP의 요청-응답 한계를 넘어서 양방향 실시간 통신을 가능하게 하는 프로토콜이다. HTTP로 핸드셰이크한 뒤 TCP 연결을 그대로 재활용하고, 경량 프레임 구조로 오버헤드를 최소화한다. 폴링이나 롱 폴링의 비효율 없이 서버가 원할 때 클라이언트에게 데이터를 푸시할 수 있다는 게 핵심이다.