WebSocket — HTTP를 이용해 HTTP를 벗어난다
앞의 HTTP 글들에서 미뤄둔 자리가 세 개 있다.
- HTTP — 무엇을 정하는 규약인가에서 "서버는 자기가 먼저 말을 걸 수 없다"고 했고, 그 제약을 다루는 선택지 중 하나로 "요청·응답 절차 자체를 버리고 다른 프로토콜로 넘어간다"는 한 줄만 적어뒀다
- HTTP의 특징에서 "무상태가 어긋나는 영역"의 첫 항목이 실시간 양방향 통신이었다
- HTTP 상태 코드에서
101 Switching Protocols의 상세를 이 글로 넘겼다
그 세 개의 빚을 여기서 회수한다. 그리고 회수하고 나면 한 문장이 남는다.
WebSocket은 HTTP를 개선한 것이 아니라, HTTP를 이용해 HTTP를 벗어나는 프로토콜이다.
HTTP를 사다리로 써서 올라간 뒤 사다리를 치운다. HTTP는 통과와 협상에만 쓰이고, 그 이후의 실제 통신은 HTTP가 아니다. 메서드도, 상태 코드도, 헤더도 더는 없다.
순서는 이렇다 — 왜 별도의 프로토콜이어야 했는지 보고(정의) → 핸드셰이크와 프레임의 설계 하나하나가 무엇을 막으려는 것인지 따지고(이유) → HTTP를 벗어난 대가로 무엇을 떠안는지 판단한다.
1. 정의 — 왜 별도 프로토콜이어야 했나
1-1. HTTP의 두 제약이 겹칠 때
채팅을 HTTP로 만든다고 하자. 부딪히는 제약은 두 개다.
① 서버가 먼저 말할 수 없다. 상대가 보낸 메시지가 서버에 도착해도, 서버는 그것을 알릴 방법이 없다. 클라이언트가 물어봐 줄 때까지 들고 기다려야 한다. 그래서 클라이언트는 계속 묻는다 — "새 메시지 있어요?"
② 매 요청이 자기를 증명해야 한다. 서버가 아무것도 기억하지 않으므로, 그 "물어보는" 요청 하나하나에 쿠키·인증 정보·각종 헤더가 전부 다시 붙는다.
두 제약이 겹치면 비용이 곱해진다. 숫자로 보면 이렇다.
// 폴링 — 3초마다 묻는다
setInterval(async () => {
// 요청 헤더(쿠키·인증 포함) ~500B + 응답 헤더 ~300B ≈ 왕복 800B
// 1분에 20회 × 60분 × 8시간 ≈ 9,600회 ≈ 사용자 한 명당 약 7.7MB
// 실제 메시지가 0건이어도 그렇다. 대부분은 "없어요"라는 답을 받는 데 쓴다
const res = await fetch(`https://api.example.com/messages?after=${lastId}`);
const messages = await res.json();
if (messages.length > 0) render(messages);
}, 3000);
정작 채팅 메시지 하나는 20바이트 남짓이다. 20바이트를 실어 나르기 위해 수백 바이트의 헤더가 반복되고, 그 반복의 대부분은 빈손으로 돌아온다. WebSocket의 프레임 헤더는 2~14바이트다. 연결이 이미 수립되어 있으므로 매번 자기소개를 할 필요가 없고, 메시지가 있을 때만 바이트가 흐른다.
HTTP의 특징 글에서 "무상태가 어긋나는 영역"이라며 넘긴 자리가 정확히 여기다. 관계를 유지하는 것 자체가 요구사항인 영역에서는, 관계를 붙들지 않는 것이 미덕인 프로토콜이 어긋난다. 그래서 이 영역은 프로토콜을 갈아탄다.
1-2. 그럼 TCP 소켓을 그냥 쓰면 안 되는가
여기서 당연한 질문이 나와야 한다. 실시간 양방향 통신이 필요하다면, TCP 소켓 자체가 이미 그런 물건이다. 연결을 유지하고, 양쪽이 아무 때나 보낼 수 있다. TCP가 원래 하는 일이다. 왜 그 위에 별도의 규격이 또 필요했는가.
답은 세 개다.
① 브라우저는 TCP 소켓을 열 수 없다.
JavaScript에는 임의의 호스트·포트로 TCP 연결을 여는 API가 없다. 빠뜨린 것이 아니라 보안상 의도적으로 막아둔 것이다. 브라우저는 신뢰할 수 없는 코드를 실행하는 환경이다. 웹페이지의 스크립트가 아무 서버에나, 아무 포트로나 임의의 바이트를 보낼 수 있다면 — 방문자의 브라우저를 빌려 사내망의 메일 서버나 DB 포트를 두드리는 일이 가능해진다. 페이지를 여는 것만으로 방문자가 공격 도구가 된다. 그래서 브라우저의 네트워크 접근은 항상 통제된 API(HTTP 요청, 그리고 이 글의 WebSocket)를 통해서만 열린다.
② 중간 장비가 통과시키지 않는다.
새 프로토콜을 만들어 새 포트로 통신하면 되지 않는가? 안 된다. HTTP 버전별 차이 글에서 다룬 프로토콜 경화(ossification) 와 같은 이야기다. 경로상의 방화벽·프록시·NAT은 80·443 포트의 HTTP 트래픽만 확실하게 통과시킨다. 낯선 포트의 낯선 프로토콜은 중간 어딘가에서 조용히 버려진다. QUIC이 UDP 위에 올라탄 것과 같은 이유로, WebSocket은 HTTP 핸드셰이크로 시작해서 기존 인프라를 통과한다. "HTTP를 사다리로 쓴다"는 말의 절반이 이것이다.
③ TCP에는 메시지 경계가 없다.
TCP는 바이트 스트림이다. send()를 두 번 호출했다고 상대가 두 번에 나눠 받는 것이 아니고, 붙어서 오거나 쪼개져서 온다. "어디까지가 한 메시지인가"는 TCP가 알려주지 않으므로, TCP 소켓을 직접 쓰려면 프레이밍 — 메시지 경계를 표시하는 규칙 — 을 스스로 설계해야 한다. 길이를 앞에 붙일 것인가, 구분자를 쓸 것인가, 텍스트와 바이너리는 어떻게 구별할 것인가.
이 문제, 낯익지 않은가. HTTP 버전별 차이 글의 첫 문장이 "HTTP의 진화는 매번 프레이밍을 다시 푸는 일이었다"였다. HTTP/1.1의 Content-Length, HTTP/2의 스트림 ID가 전부 같은 문제의 답이었다.
바이트 스트림 위에 무언가를 얹을 때마다 프레이밍 문제는 반드시 돌아온다. WebSocket은 그 답을 규격으로 제공한다 — 메시지 경계, 텍스트/바이너리 구분, 연결 종료 신호까지.
이것이 흔한 오해 하나를 정리해준다. WebSocket은 "브라우저에서 쓰는 TCP 소켓"이 아니다. TCP가 주는 것은 바이트 스트림이고, WebSocket이 주는 것은 메시지 단위의 채널이다. ws.send("hello")를 호출하면 상대의 onmessage에는 정확히 "hello"가 한 번 도착한다. 이 당연해 보이는 동작이 TCP에는 없고, WebSocket이 프레이밍으로 만들어주는 것이다.
정리하면 이렇게 된다.
WebSocket은 하나의 TCP 연결 위에서 양방향 메시지 채널을 제공하는 독립 프로토콜이고, 연결의 수립에만 HTTP를 쓴다. 브라우저에서 열 수 있고(①의 해결), 기존 인프라를 통과하며(②의 해결), 프레이밍이 규격에 들어 있다(③의 해결).
2. 이유 — 핸드셰이크와 프레임의 설계
이제 그 "HTTP를 사다리로 쓰는" 절차를 뜯어본다. 이 장의 관점은 하나다. 각 설계는 명세의 장식이 아니라, 구체적인 사고를 막으려고 그 모양이 됐다.
2-1. Upgrade 핸드셰이크와 101
연결은 평범한 HTTP 요청으로 시작한다.
GET /chat HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://app.example.com
Upgrade: websocket— 이 연결을 HTTP가 아닌 다른 프로토콜로 바꾸자는 제안이다Connection: Upgrade—Upgrade헤더가 이 홉의 연결 처리에 관한 것임을 표시한다Sec-WebSocket-Key— 브라우저가 매 연결마다 새로 만드는 16바이트 랜덤 값의 base64다. 무엇을 위한 것인지는 다음 절에서 길게 다룬다Sec-WebSocket-Version: 13— RFC 6455의 버전 번호다
서버가 동의하면 이렇게 응답한다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
HTTP 상태 코드 글에서 1xx 대역을 "진행 중 — 계속 기다려라"로 정리하고 101의 상세를 이 글로 넘겼다. 이제 답할 수 있다. 1xx는 "아직 최종 응답이 아니다"라는 대역이고, 101은 그중 "너의 제안을 받아들여 지금부터 다른 프로토콜로 말하겠다"는 뜻이다. 이 응답을 끝으로 이 연결 위에 HTTP 응답은 더 오지 않는다. "진행 중"인 것이 맞다 — 다만 이어지는 것이 HTTP가 아닐 뿐이다.
여기서 가장 중요한 사실은 헤더가 아니라 이것이다.
TCP 연결을 새로 맺지 않는다. 핸드셰이크에 쓰인 바로 그 TCP 연결을 그대로 쓰고, 그 위에 흐르는 규약만 바뀐다.
101 응답의 마지막 바이트까지가 HTTP이고, 그다음 바이트부터는 WebSocket 프레임이다. 같은 소켓, 같은 포트, 같은 TLS 세션 위에서 말이 바뀐다. 이 지점 이후로는 GET도 200도 Content-Type도 존재하지 않는다. 헤더 수백 바이트가 2~14바이트 프레임 헤더로 줄어드는 이유이고, "사다리를 치운다"는 말의 실체다.
2-2. Sec-WebSocket-Accept는 무엇을 막는가
응답의 Sec-WebSocket-Accept 값은 서버가 이렇게 계산한다.
base64( SHA-1( Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" ) )
코드로 쓰면 몇 줄이다. 직접 계산해볼 수 있다.
import { createHash } from "node:crypto";
// 이 GUID는 RFC 6455에 박힌 공개 상수다. 비밀이 아니다
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
function acceptFor(key: string): string {
return createHash("sha1").update(key + GUID).digest("base64");
}
acceptFor("dGhlIHNhbXBsZSBub25jZQ==");
// => "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" — 위 응답의 값과 일치한다
뒤에 붙는 GUID가 스펙에 적힌 상수라는 점을 보자. 누구나 안다. 즉 Key만 보면 누구든 올바른 Accept를 만들 수 있다. 그러므로 이것은 인증이 아니고, 당연히 암호화도 아니다. 그러면 Sec-라는 이름까지 붙은 이 값은 대체 무엇을 지키는가.
먼저 흔한 오해부터 정정하자. "악성 스크립트가 가짜 WebSocket 핸드셰이크를 위조하는 것을 막는다"는 설명이 돌아다니는데, 방향이 틀렸다. Sec-로 시작하는 헤더는 브라우저의 fetch·XMLHttpRequest에서 JavaScript가 설정할 수 없도록 금지된 헤더다. 스크립트가 Sec-WebSocket-Key를 붙여 위장 요청을 만드는 일은 애초에 불가능하다. 이 헤더는 브라우저 자신만 붙일 수 있고, 헤더 이름이 Sec-인 이유가 바로 그 보장이다.
실제로 의심받는 쪽은 클라이언트가 아니라 반대편 — 중간 장비와 서버다.
핸드셰이크 요청을 다시 보라. 겉보기엔 완벽하게 정상적인 HTTP GET 요청이다. WebSocket을 모르는 소프트웨어는 Upgrade를 모르는 헤더로 무시하고, 이것을 평범한 GET /chat으로 취급한다. 그때 무슨 일이 생기는가.
- 서버가 WebSocket을 모를 때 — 평범한 HTTP 서버는
Upgrade를 무시하고GET /chat에 대한200 OK를 성실하게 돌려준다. 브라우저가 이것을 "연결 성공"으로 착각하면, WebSocket을 전혀 모르는 HTTP 서버를 향해 WebSocket 프레임을 쏘기 시작한다. 서버는 그 바이트를 HTTP 요청으로 파싱하려 들 것이고, 무슨 일이 생길지는 아무도 보장하지 못한다 - 중간 프록시가 WebSocket을 모를 때 — 프록시가 요청을 그대로 서버에 전달했고 서버는 101로 답했는데, 프록시 자신은 여전히 이 연결을 HTTP로 취급하며 스트림을 HTTP 파서로 읽고 있을 수 있다. 연결은 "되는 것처럼" 보이지만 중간 참여자의 해석이 어긋나 있다
그래서 브라우저는 101을 받았다고 바로 믿지 않고 검증한다.
- 연결마다 16바이트 랜덤 키를 만들어
Sec-WebSocket-Key로 보낸다 - 같은 계산식으로 기대값을 스스로 계산해둔다
- 응답의
Sec-WebSocket-Accept가 기대값과 일치하는지 확인한다 - 일치하지 않으면 연결을 실패로 처리하고, 프레임을 단 하나도 보내지 않는다
올바른 Accept 값이 왔다는 것은 최소한 이것을 증명한다 — 저쪽 끝의 소프트웨어가 Upgrade 요청을 읽었고, RFC 6455의 GUID를 알고 있고, 그 계산을 수행했다. 즉 "지금부터 WebSocket으로 말해도 상대가 알아듣는다"는 확인이다.
Sec-WebSocket-Accept는 신원 인증이 아니라 프로토콜 인증이다. "너는 누구인가"가 아니라 "너는 지금부터 내가 할 말을 알아듣는가" 를 확인한다.
Key가 매번 랜덤이어야 하는 이유도 같은 선상에 있다. 고정된 값이라면 Accept도 항상 같은 값이 되고, 중간의 캐싱 프록시가 예전 101 응답을 저장해뒀다가 서버에 묻지도 않고 돌려줄 수 있다. 그러면 브라우저는 연결이 수립되지 않았는데 됐다고 착각한다. 키가 매번 다르면 캐시된 응답은 기대값과 일치할 수 없으므로 4번 단계에서 걸러진다.
2-3. 마스킹 — 같은 문제의 나머지 절반
핸드셰이크가 끝나면 프레임이 흐른다. 그런데 프레임 규칙에 이상한 비대칭이 하나 있다.
클라이언트 → 서버 프레임은 4바이트 랜덤 키로 페이로드 전체를 XOR(마스킹)해야 한다. 서버 → 클라이언트 프레임은 마스킹하면 안 된다.
암호화도 아닌 XOR을, 그것도 한 방향만 강제한다. 왜인가.
암호화가 아니라는 점부터 못 박자. 마스킹 키는 바로 그 프레임의 헤더에 평문으로 실려 온다. 스트림을 볼 수 있는 사람은 누구나 키를 읽어 원문을 복원할 수 있다. 비밀 유지가 목적이었다면 이 설계는 무의미하다. 목적은 다른 데 있다.
막으려는 것은 앞 절과 같은 상황 — "중간 장비가 이 스트림을 여전히 HTTP로 읽고 있을 때"다. 이번엔 핸드셰이크가 아니라 그 이후의 페이로드가 문제가 된다.
핸드셰이크는 통과했지만 WebSocket을 모르는 구형 프록시가 경로에 있다고 하자. 프록시는 여전히 이 연결의 바이트를 HTTP 파서로 읽는다. 이때 악성 스크립트가 이렇게 보낸다.
// 페이로드가 "HTTP 요청처럼 생긴" 바이트다
ws.send("GET /admin/delete-all HTTP/1.1\r\nHost: internal.example.com\r\n\r\n");
WebSocket 입장에서 이것은 그냥 텍스트 메시지다. 그러나 HTTP 파서로 읽고 있는 프록시에게는 진짜 HTTP 요청의 시작으로 보인다. 프록시가 이를 요청으로 오인해 내부망으로 전달하거나, 그에 대한 "응답"을 캐시에 저장해버릴 수 있다. 후자가 특히 심각하다 — 공격자가 고른 URL에 공격자가 고른 내용이 캐시되면, 같은 프록시를 쓰는 다른 사용자들이 그 오염된 캐시를 받는다. 캐시 포이즈닝이다.
1-2절에서 브라우저가 TCP 소켓을 막는 이유가 "스크립트가 임의 바이트를 보내게 둘 수 없어서"라고 했다. 그런데 WebSocket은 스크립트에게 임의 페이로드를 보낼 능력을 줬다. 브라우저가 절대 보내지 않을 요청을, WebSocket이라는 통로로 밀어넣는 우회로가 열린 것이다. 이 공격은 이론이 아니라 스펙이 확정되기 전 실제로 시연됐고, 그 결과로 마스킹이 규격에 들어갔다.
마스킹이 이것을 어떻게 끊는지 바이트로 보자.
원본 47 45 54 20 2F ... "GET /..." — HTTP 요청의 시작처럼 보인다
마스킹 키 3A 91 C5 78 프레임마다 브라우저가 새로 뽑는 4바이트
XOR 결과 7D D4 91 58 15 ... 어떤 HTTP 파서도 메서드로 읽지 않는 바이트
GET이라는 글자가 전선 위에서 사라졌다. 프록시의 HTTP 파서는 이 바이트열에서 요청의 시작을 찾지 못하고, 오인할 거리 자체가 없어진다.
왜 키가 랜덤이고 프레임마다 바뀌어야 하는가. 키가 고정이라면 공격자는 역산하면 된다. 키가 3A로 시작한다는 것을 알면 0x47 XOR 0x3A = 0x7D이므로, 0x7D를 보내면 전선 위에 0x47('G')이 나타난다. 원하는 바이트를 마음대로 그릴 수 있으니 마스킹이 무력화된다. 랜덤이면서 키를 뽑는 주체가 스크립트가 아니라 브라우저라는 점이 방어의 핵심이다 — 스크립트는 자기가 보낸 페이로드가 전선에서 어떤 바이트가 될지 통제할 방법이 없다.
왜 한 방향만 마스킹하는가. 방어가 필요했던 이유로 돌아가면 된다. 위협은 "신뢰할 수 없는 스크립트가 페이로드를 통제할 수 있다"는 것이었다. 서버가 보내는 프레임은 서버 자신의 코드가 만든 것이라 그 위협이 성립하지 않는다. 막을 것이 없는 방향에 XOR을 강제할 이유가 없고, 실리도 있다 — 서버 하나가 클라이언트 수만 개를 상대하므로, 어차피 하는 연산이라면 각자 한 번씩 하는 클라이언트 쪽에 몰아주는 편이 낫다.
<!-- IMAGE: 위쪽은 마스킹이 없을 때 — ws.send로 보낸 47 45 54가 "GET"으로 읽혀 구형 프록시가 내부망에 전달/캐시 오염하는 흐름. 가운데는 원본 바이트·마스킹 키(브라우저가 생성, 스크립트 통제 불가)·XOR 결과를 바이트 단위로 나열해 GET이라는 글자가 전선에서 사라지는 것. 아래쪽은 클라이언트→서버만 마스킹하는 이유 — 위협은 스크립트 쪽에만 있고, 연산 부담은 다수인 클라이언트에게 분산 -->마스킹은 암호화가 아니라 난독화다. 목적은 비밀 유지가 아니라, 중간 장비가 이 바이트를 HTTP로 오인하지 못하게 막는 것이다.
Sec-WebSocket-Accept가 "상대가 WebSocket을 알아듣는지"를 확인한다면, 마스킹은 알아듣지 못하는 장비가 남아 있어도 사고가 나지 않게 만든다. 같은 문제의 나머지 절반이다.
덧붙이면, wss://(TLS 위의 WebSocket)를 쓰면 중간 장비가 내용을 아예 볼 수 없으므로 이 오인 시나리오 자체가 성립하지 않는다. 실무에서 wss://가 사실상 필수인 이유 중 하나다. 마스킹은 평문 ws://가 여전히 존재하는 현실에 대한 방어책이고, 규격은 가장 나쁜 경로를 기준으로 설계된다.
3. 판단 — 무엇을 얻고 무엇을 떠안는가
3-1. 얻는 것
핸드셰이크 이후의 세계를 코드로 보면 얻는 것이 분명해진다.
// 브라우저 — 연결은 한 번, 이후엔 이벤트만
const ws = new WebSocket("wss://api.example.com/chat");
ws.onopen = () => {
ws.send(JSON.stringify({ type: "join", room: "general" }));
};
ws.onmessage = (event) => {
// 요청한 적 없는 데이터가 도착한다 — 서버가 먼저 말한 것이다
const msg = JSON.parse(event.data);
render(msg);
};
ws.onclose = (event) => {
// event.code 1000이면 정상 종료. 그 외라면 비정상 — 재연결 판단은 여기서 시작된다
};
ws.onerror = () => {
// 상세 정보는 주지 않는다(보안상 의도적). 이어지는 onclose에서 코드로 판단한다
};
// 서버 — 연결을 들고 있다가, 누가 말하면 나머지 전원에게 민다
import { WebSocketServer, WebSocket } from "ws";
const wss = new WebSocketServer({ port: 8080 });
const clients = new Set<WebSocket>();
wss.on("connection", (ws) => {
clients.add(ws);
ws.on("message", (data) => {
// HTTP였다면 불가능한 문장 — "요청하지 않은 클라이언트에게 보낸다"
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) client.send(data.toString());
}
});
ws.on("close", () => {
// 정리하지 않으면 죽은 연결이 Set에 쌓이고, 매 브로드캐스트마다 비용이 된다
clients.delete(ws);
});
});
- 서버가 먼저 말할 수 있다.
onmessage는 요청의 응답이 아니라 상대의 발화다. 1-1의 제약 ①이 사라졌다 - 헤더 오버헤드가 거의 없다. 매 메시지 수백 바이트가 아니라 2~14바이트다
- 인증을 반복하지 않는다. 핸드셰이크 시점에 한 번 확인하면, 이후의 프레임은 그 연결에 속한다는 사실 자체가 신원이다. 제약 ②도 사라졌다
3-2. 떠안는 것 — HTTP를 벗어난 대가
그런데 앞의 글들을 따라온 입장에서는 이 목록이 수상해야 한다. HTTP의 제약들은 결함이 아니라 값을 지불하고 산 성질들이었다. 그 제약을 벗어났다면, 제약이 사주던 것들도 함께 사라졌어야 한다. 실제로 그렇다.
① 상태를 갖게 된다.
HTTP의 특징 글에서 무상태의 가장 큰 배당은 "서버가 교체 가능하다"는 것이었다 — 어느 요청을 어느 서버가 받아도 되고, 그래서 수평 확장이 자연스러웠다. WebSocket 연결은 특정 서버 인스턴스에 물리적으로 붙어 있다. 서버가 3대라면 사용자 A의 연결은 그중 1대에만 존재하고, 다른 2대는 A에게 말을 걸 방법이 없다. 서버 2번에서 발생한 이벤트를 서버 1번에 붙은 사용자에게 어떻게 전달할 것인가, 배포로 서버를 내리면 그 서버에 붙어 있던 수만 개의 연결은 어떻게 되는가 — 무상태였다면 존재하지 않았을 문제들이다. 이 문제와 그 해법은 로드 밸런서 뒤의 세션 문제와 메시지 큐 쪽의 주제다.
② 중간 참여자를 잃는다.
메서드·상태 코드·URL이 없는 세계에서는 그것을 읽고 일하던 참여자들이 전부 일을 놓는다. 캐시가 성립하지 않고, 경로 기반 라우팅이 성립하지 않고, "5xx 비율이 튀었다"는 식의 상태 코드 기반 모니터링이 성립하지 않는다. REST와 GraphQL 글에서 반복해 등장한 그 목록이다. 다른 점이 있다면, WebSocket이 오가는 데이터는 애초에 실시간·개인화 데이터라 캐시할 것이 없는 경우가 대부분이라는 것 — 잃는다기보다 원래 해당 사항이 없던 쪽에 가깝다. 그러나 모니터링과 장애 추적은 실제로 아프고, 애플리케이션 계층에서 직접 만들어야 한다.
③ 연결이 끊겼는지 알기 어렵다.
TCP 연결은 조용히 죽을 수 있다. 상대가 정상 종료 절차 없이 사라지면 — 노트북을 덮었거나, 이동 중 네트워크가 바뀌었거나, 중간의 NAT이 유휴 연결을 테이블에서 지웠거나 — 양쪽 모두 한동안 그 사실을 모른다. 서버의 Set에는 죽은 연결이 살아 있는 것처럼 남고, 클라이언트는 오지 않는 메시지를 기다린다. HTTP에서는 매 요청이 곧 생존 확인이라 없던 문제다. 연결을 유지하기로 한 순간, "그 연결이 아직 살아 있는가"를 확인할 별도의 장치가 필요해진다. 그 장치(Ping/Pong)와, 바이트 통로 위에 메시지 규약을 얹는 문제(STOMP)는 다음 글의 주제다.
④ 인증이 한 시점에 고정된다.
3-1에서 "인증을 반복하지 않는다"를 이득으로 적었지만, 뒤집으면 연결 도중에 권한이 바뀌어도 반영할 자연스러운 지점이 없다는 뜻이다. 토큰이 만료되거나 권한이 회수돼도 연결은 이미 열려 있다. 매 요청마다 검증하는 HTTP에서는 다음 요청이 곧 재검증이었지만, 여기서는 연결 중 재검증을 애플리케이션이 직접 설계해야 한다. 토큰 수명과 갱신의 이야기는 로그인 유지의 계보 쪽과 이어진다.
3-3. 언제 쓰는가
기준은 대가 목록에서 나온다. 위의 네 가지를 떠안을 만한 상황은 이렇다.
양방향이 정말 필요하고, 메시지가 잦을 때. 채팅, 협업 편집, 실시간 게임, 양쪽이 수시로 말하는 모든 것.
반대로 서버가 클라이언트에게 밀어주기만 하면 되는 단방향이라면 — 알림, 시세, 진행률 — WebSocket은 과하다. 프로토콜을 갈아타지 않고 HTTP 안에서 해결하는 SSE가 있고, 그 이야기는 다음다음 글에서 다룬다. 폴링·롱폴링까지 포함한 네 방식의 종합 비교와 선택 기준은 이 시리즈의 마지막 글에서 정리한다.
하나 더 비교해둘 것이 있다. gRPC 글의 양방향 스트리밍도 "양쪽이 아무 때나 말하는 채널"이었다. 차이는 타입의 유무다. gRPC 스트림 위를 흐르는 것은 스키마로 정의된 메시지지만, WebSocket이 주는 것은 메시지 경계까지다 — 그 메시지 안에 무엇이 어떤 형식으로 들어가는지는 전적으로 양쪽이 알아서 정해야 한다. { type: "join", room: "general" } 같은 규약을 즉석에서 발명하게 되고, 참여자가 늘면 그 즉석 규약이 관리 문제가 된다. 그래서 WebSocket 위에 STOMP 같은 상위 규약을 얹게 되는데, 그것이 바로 다음 글의 주제다.
3-4. 정리
- HTTP의 두 제약 — 서버가 먼저 말할 수 없고, 매 요청이 자기를 증명해야 한다 — 이 겹치는 영역에서 WebSocket이 나왔다. 20바이트를 위해 수백 바이트를 반복하는 구조는 우회가 아니라 탈출이 필요했다.
- 그냥 TCP 소켓을 쓸 수 없는 이유가 WebSocket의 모양을 정했다. 브라우저가 임의 TCP를 막고(보안), 중간 장비가 낯선 프로토콜을 버리며(경화), TCP에는 메시지 경계가 없다(프레이밍). 그래서 HTTP로 시작하고, 규격화된 프레임으로 말한다.
- 핸드셰이크의 장치들은 전부 "상대가 이 프로토콜을 알아듣는가"를 겨냥한다.
Sec-WebSocket-Accept는 인증이 아니라 프로토콜 확인이고, 마스킹은 암호화가 아니라 알아듣지 못하는 장비의 오인을 막는 난독화다. - 101 이후는 HTTP가 아니다. 같은 TCP 연결 위에서 규약만 바뀌고, 메서드·상태 코드·헤더와 함께 그것을 읽던 중간 참여자들의 역할도 끝난다.
- 얻는 것의 뒷면이 그대로 대가다. 연결을 유지하므로 상태가 생기고, HTTP를 벗어났으므로 인프라를 잃고, 연결이 조용히 죽는 문제를 새로 얻는다. 양방향·고빈도가 아니라면 이 대가는 과하다.
처음의 문장으로 돌아가면 이렇게 된다.
WebSocket은 HTTP의 개선판이 아니라, HTTP를 사다리로 써서 HTTP 밖으로 나가는 프로토콜이다. 사다리를 치운 자리에서 서버는 먼저 말할 수 있게 되지만, 무상태가 사주던 것들 — 교체 가능성, 공짜 인프라, 요청마다의 생존 확인 — 을 전부 반납한다. 그러니 물어야 할 것은 "WebSocket이 더 현대적인가"가 아니라 "우리의 통신이 정말 양방향인가, 그리고 반납한 것들을 직접 만들 준비가 됐는가"다.
관련 글
- HTTP — 무엇을 정하는 규약인가 — "항상 클라이언트가 먼저 말한다"는 제약의 원문. 이 글이 그 제약을 걷어내는 자리다.
- HTTP의 특징 — "무상태가 어긋나는 영역"으로 넘긴 자리, 그리고 연결이 상태를 가지면서 교체 가능성이 깨지는 지점.
- HTTP 상태 코드 —
101 Switching Protocols가 속한 1xx 대역의 의미. - HTTP 버전별 차이 — 프레이밍 문제의 반복, 그리고 미들박스 경화. WebSocket이 HTTP로 시작하는 이유가 거기 있다.
- TCP와 UDP — TCP가 바이트 스트림이라 메시지 경계가 없다는 것.
- gRPC — 양방향 스트리밍과의 대비. 타입이 붙은 채널과 바이트 채널의 차이.
- Ping/Pong과 STOMP — 조용히 죽는 연결의 확인, 그리고 바이트 통로 위의 메시지 규약.
- SSE — 단방향이면 충분할 때의 선택지.
- 폴링 vs 롱폴링 vs SSE vs WebSocket — 네 방식의 종합 비교와 선택 기준.
- 로드 밸런서 뒤의 세션 문제 · 메시지 큐 — 연결이 인스턴스에 묶일 때 생기는 문제와 인스턴스 간 전파.