하나의 TCP 연결에서 양방향으로 메시지를 주고받는 프로토콜(RFC 6455). HTTP의 "클라이언트가 먼저 묻는다"는 전제를 정면으로 깬다.
시작은 평범한 HTTP다
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== ← 무작위 16바이트를 base64로
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols ← 200이 아니다
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
101은 "지금부터 이 TCP 연결에서 HTTP를 쓰지 않겠다" 는 뜻이다. 이 응답 이후로는 요청도 응답도 없고, 양쪽이 대등하게 프레임을 주고받는다.
이 설계의 실용적 이점은 기존 인프라를 그대로 쓴다는 것이다 — 80·443 포트, 방화벽, 프록시가 처음에는 평범한 HTTP 요청으로 보기 때문에 별도 포트를 열 필요가 없다.
Sec-WebSocket-Accept는 보안 장치가 아니다
서버는 클라이언트가 보낸 키에 고정된 문자열 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 을 이어 붙이고 SHA-1로 해시한 뒤 base64로 인코딩해 돌려준다.
SHA-1(클라이언트키 + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") → base64
그 문자열이 규격에 공개돼 있으니 아무나 계산할 수 있다. 목적은 인증이 아니라 확인이다 — 중간의 캐시나 프록시가 이 요청을 평범한 HTTP로 착각해 엉뚱한 응답을 돌려주는 사고를 걸러 낸다.
프레임 구조 — 헤더가 2바이트다
| 필드 | 뜻 |
|---|---|
| FIN(1비트) | 메시지의 마지막 조각인가(긴 메시지는 쪼개 보낸다) |
| opcode(4비트) | 0x1 텍스트 · 0x2 바이너리 · 0x8 종료 · 0x9 ping · 0xA pong |
| MASK(1비트) | 페이로드가 마스킹돼 있는가 |
| 길이(7 / 7+16 / 7+64비트) | 0~125는 그대로, 126이면 뒤 16비트, 127이면 뒤 64비트를 길이로 |
최소 헤더가 2바이트다. HTTP 요청마다 수백 바이트씩 붙던 헤더와 비교하면, 짧은 메시지를 자주 주고받을 때 차이가 크다.
마스킹 — 이것도 암호화가 아니다
클라이언트가 보내는 프레임은 반드시 마스킹해야 한다(4바이트 키로 페이로드를 XOR). 키가 프레임 안에 그대로 들어 있으니 비밀은 없다. 목적은 공격자가 회선에 나타날 바이트를 마음대로 고르지 못하게 하는 것 — WebSocket을 모르는 중간 프록시가 그 바이트열을 HTTP 요청으로 잘못 읽어 캐시를 오염시키는 공격을 막는다.
실무에서 반드시 걸리는 세 가지
① 유휴 연결이 끊긴다. 로드밸런서·프록시는 대개 유휴 연결을 수 분 뒤 닫는다. ping/pong 제어 프레임을 그 타임아웃보다 짧은 주기로 보내야 조용한 연결이 살아남는다.
② 스케일아웃이 간단하지 않다. 연결이 특정 인스턴스에 묶이므로(stateful), 다른 인스턴스에 붙은 사용자에게 보내려면 Redis Pub/Sub 같은 브로커가 필요하다.
③ 인증에 헤더를 못 쓴다. 브라우저 WebSocket API는 커스텀 헤더를 붙일 수 없어 Authorization을 그대로 못 쓴다. 쿠키·일회용 티켓·연결 직후 첫 메시지 인증 중 하나를 쓴다. 또 동일 출처 정책이 적용되지 않으므로 서버가 Origin을 직접 검증해야 한다(Cross-Site WebSocket Hijacking).
HTTP/2·HTTP/3 위에서는
위 핸드셰이크는 HTTP/1.1의 Upgrade에 기대는데 HTTP/2에는 그 개념이 없다. RFC 8441이 확장 CONNECT로 HTTP/2 위 WebSocket을 정의했고 RFC 9220이 HTTP/3까지 넓혔지만, 2026년 기준 HTTP/3 위 프로덕션 구현은 주요 브라우저·서버에 아직 없다.
면접 함정
- ❌ "실시간이면 무조건 WebSocket" → 단방향이면 SSE가 더 단순하고 재연결이 규격에 있다.
- ❌ "
Sec-WebSocket-Accept가 보안을 담당" → 고정 문자열 기반의 확인 절차다.
클라이언트 코드
const ws = new WebSocket("wss://api.example.com/chat");
ws.onopen = () => ws.send(JSON.stringify({ type: "auth", token }));
ws.onmessage = (e) => render(JSON.parse(e.data));
ws.onclose = () => setTimeout(connect, backoff()); // 재연결은 직접 만든다
onclose 줄이 핵심이다 — 재연결·지수 백오프·놓친 메시지 복구를 전부 손으로 써야 한다. SSE라면 이 줄이 필요 없다.
헤더를 못 붙인다는 제약
// ❌ 브라우저 WebSocket API 에는 헤더 옵션이 없다
new WebSocket(url, { headers: { Authorization: "Bearer ..." } });
// ✅ 쿠키(같은 도메인) · 짧은 수명의 일회용 티켓 · 연결 후 첫 메시지 인증
new WebSocket(`wss://api.example.com/chat?ticket=${oneTimeTicket}`);
티켓은 한 번 쓰면 폐기되고 수명이 수십 초여야 한다. 장기 토큰을 쿼리 파라미터에 넣으면 액세스 로그·리퍼러에 그대로 남는다.