
WebSocket: 새로고침 지옥에서 해방하라
HTTP는 무전기(오버) 방식이지만, 웹소켓은 전화기(여보세요)입니다. 채팅과 주식 차트가 실시간으로 움직이는 기술적 비밀.

HTTP는 무전기(오버) 방식이지만, 웹소켓은 전화기(여보세요)입니다. 채팅과 주식 차트가 실시간으로 움직이는 기술적 비밀.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

옛날 웹에서 채팅을 구현한다고 상상해보면, 진짜 답답했을 것이다. HTTP는 요청-응답 모델이라 클라이언트가 먼저 물어봐야만 서버가 대답한다. 친구가 카톡을 보냈는지 확인하려면? 직접 새로고침(F5) 버튼을 눌러야 했다. 서버는 수동적이라서, 클라이언트가 물어보기 전까진 입을 꾹 다물고 있었다.
이걸 조금이라도 자동화하려고, 나는 처음에 setInterval로 1초마다 "메시지 있어?"라고 서버에 요청을 보내는 폴링(Polling) 방식을 써봤다. 그런데 이건 정말 비효율적이다. 메시지가 없어도 계속 요청을 보내니까 서버 자원이 낭비된다.
// 나의 흑역사: 1초마다 폴링
setInterval(() => {
fetch('/api/messages')
.then(res => res.json())
.then(data => console.log(data));
}, 1000);
"나 왔어?" (아니) "나 왔어?" (아니) "나 왔어?" (어, 왔어)
이게 100명이 동시에 접속하면? 서버는 1초에 100번씩 의미 없는 질문에 답해줘야 한다. 트래픽 낭비의 끝판왕이다.
그래서 폴링의 개선판으로 롱 폴링(Long Polling)이 나왔다. 이건 클라이언트가 요청을 보내면, 서버가 응답을 바로 주지 않고 새 데이터가 생길 때까지 연결을 붙잡고 있는 방식이다. 데이터가 생기면 그때 응답을 주고, 클라이언트는 응답을 받자마자 또 다시 요청을 보낸다.
// 롱 폴링 패턴
function longPoll() {
fetch('/api/messages/long-poll')
.then(res => res.json())
.then(data => {
console.log(data);
longPoll(); // 응답 받으면 즉시 재요청
});
}
longPoll();
이건 폴링보다는 낫지만, 여전히 HTTP 요청-응답 사이클을 반복해야 하고, 연결을 계속 맺고 끊는 오버헤드가 크다. 진짜 실시간이라기보다는 "유사 실시간" 정도다.
내가 이해한 웹소켓(WebSocket)의 핵심은 이거다: "연결을 한 번 맺으면, 끊어질 때까지 계속 뚫어놓는다."
HTTP는 볼일 끝나면 전화를 끊어버리는 단기 알바 같다면, 웹소켓은 한번 연결하면 계속 통화 상태를 유지하는 정직원이다. 이 비유가 나한테는 제일 와닿았다.
HTTP (무전기, Walkie-Talkie):
WebSocket (전화기, Phone):
이 차이를 받아들이고 나니, "실시간"이라는 게 왜 웹소켓으로 구현되는지 이해가 됐다.
웹소켓도 처음엔 HTTP로 시작한다. 이게 좀 신기했다. 클라이언트가 HTTP 요청을 보내면서 "나 웹소켓으로 갈아타고 싶어"라고 말하면, 서버가 "오케이"하고 프로토콜을 바꿔준다. 이게 핸드셰이크(Handshake) 과정이다.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
서버 응답:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
101 Switching Protocols라는 상태 코드가 핵심이다. "이제부터 HTTP 아니고 WebSocket으로 대화한다"는 뜻이다. 이 순간부터 연결은 ws:// (혹은 암호화된 wss://) 프로토콜로 전환되고, 연결이 끊어질 때까지 파이프가 계속 열려 있다.
이 핸드셰이크 메커니즘을 이해하고 나서, "웹소켓이 HTTP 위에 올라간다"는 말이 무슨 뜻인지 정리가 됐다. 처음엔 HTTP를 쓰다가, 중간에 프로토콜을 갈아타는 거였다.
브라우저에서 웹소켓을 쓰는 건 정말 간단하다.
// 웹소켓 연결 생성
const socket = new WebSocket('ws://localhost:8080');
// 연결이 열렸을 때
socket.addEventListener('open', (event) => {
console.log('WebSocket 연결 성공');
socket.send('안녕, 서버!'); // 서버로 메시지 전송
});
// 서버로부터 메시지를 받았을 때
socket.addEventListener('message', (event) => {
console.log('서버가 보낸 메시지:', event.data);
});
// 에러 발생 시
socket.addEventListener('error', (event) => {
console.error('WebSocket 에러:', event);
});
// 연결이 닫혔을 때
socket.addEventListener('close', (event) => {
console.log('WebSocket 연결 종료', event.code, event.reason);
});
이 코드를 처음 봤을 때, "이게 끝이야?"라고 생각했다. HTTP fetch처럼 요청-응답을 반복할 필요 없이, 그냥 socket.send()로 보내고 message 이벤트로 받으면 된다. 서버도 클라이언트도 언제든지 먼저 메시지를 보낼 수 있다.
Node.js에서 웹소켓 서버를 만들 때는 ws 라이브러리를 많이 쓴다.
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
console.log('새 클라이언트 연결됨');
// 클라이언트로부터 메시지를 받았을 때
ws.on('message', (message) => {
console.log('받은 메시지:', message);
// 모든 연결된 클라이언트에게 브로드캐스트
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(`브로드캐스트: ${message}`);
}
});
});
// 연결 종료
ws.on('close', () => {
console.log('클라이언트 연결 종료');
});
// 환영 메시지 전송
ws.send('서버에 연결되었습니다!');
});
console.log('WebSocket 서버가 8080 포트에서 실행 중');
이 코드를 직접 돌려보고 나서, 웹소켓이 "연결 기반"이라는 게 와닿았다. connection 이벤트가 발생하면 그 연결(ws)을 객체로 받아서 계속 들고 있다가, 나중에 메시지를 보낼 수 있다. HTTP에서는 응답을 보내고 나면 연결이 끊기지만, 웹소켓은 연결 객체가 살아있는 한 계속 통신할 수 있다.
HTTP와 HTTPS의 관계처럼, 웹소켓도 암호화 여부에 따라 두 가지 프로토콜이 있다.
실제 프로덕션 환경에서는 당연히 wss://를 써야 한다. 중간에 데이터가 가로채일 위험이 있기 때문이다. 특히 인증 토큰이나 민감한 정보를 웹소켓으로 주고받는다면 필수다.
웹소켓 연결은 크게 4가지 이벤트를 거친다:
내가 이해한 핵심은, 연결이 열려 있는 동안에는 언제든지 send()로 메시지를 보낼 수 있다는 점이다. HTTP처럼 매번 새 요청을 만들 필요가 없다. 연결이 끊기면 재연결 로직을 직접 구현해야 한다.
웹소켓 연결은 TCP 기반이라서, 한쪽이 갑자기 죽어도 상대방은 모를 수 있다. 예를 들어 클라이언트가 노트북 덮개를 닫아버리면, 서버는 그 연결이 죽은 줄 모르고 계속 들고 있을 수 있다. 이걸 방지하려고 Heartbeat(하트비트) 또는 Ping-Pong 메커니즘을 쓴다.
서버가 주기적으로 Ping 프레임을 보내고, 클라이언트가 Pong으로 응답하지 않으면 "이 연결 죽었구나" 판단하고 정리한다.
// 서버 측 Ping-Pong 예제
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
return ws.terminate(); // 응답 없으면 종료
}
ws.isAlive = false;
ws.ping(); // Ping 보냄
});
}, 30000); // 30초마다
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true; // Pong 받으면 살아있다고 표시
});
});
이 패턴을 받아들이고 나니, "왜 웹소켓 연결이 이상하게 끊겼다가 다시 붙지?"라는 의문이 풀렸다. 하트비트가 없으면 좀비 연결이 쌓여서 서버 자원이 낭비될 수 있다.
처음 웹소켓을 배울 때, "Socket.io가 웹소켓이야"라고 착각했다. 하지만 정확히는, Socket.io는 웹소켓을 사용하는 라이브러리지만, 폴백(Fallback) 기능이 있어서 웹소켓을 지원하지 않는 환경에서는 롱 폴링으로 자동 전환된다.
Socket.io의 장점:
네이티브 WebSocket의 장점:
내가 정리해본 기준은, 간단한 실시간 기능이면 네이티브 WebSocket, 복잡한 채팅/알림 시스템이면 Socket.io다.
웹소켓만 실시간 기술은 아니다. SSE(Server-Sent Events)라는 게 있는데, 이건 서버 → 클라이언트 단방향 푸시만 지원한다. 클라이언트는 HTTP 요청으로 연결을 맺고, 서버가 계속 이벤트를 스트리밍으로 보낸다.
// 클라이언트 (브라우저)
const eventSource = new EventSource('/events');
eventSource.onmessage = (event) => {
console.log('새 이벤트:', event.data);
};
// 서버 (Node.js + Express)
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const interval = setInterval(() => {
res.write(`data: ${new Date().toISOString()}\n\n`);
}, 1000);
req.on('close', () => {
clearInterval(interval);
});
});
SSE는 HTTP 위에서 동작하고, 자동 재연결도 지원한다. 단, 클라이언트 → 서버 방향은 일반 HTTP 요청을 따로 보내야 한다. 그래서 주식 시세 같은 "서버가 푸시만 하면 되는" 경우에 적합하다. 양방향 통신이 필요하면 웹소켓을 써야 한다.
웹소켓의 가장 큰 문제 중 하나가 스케일 아웃(Scale-Out)이다. 서버를 여러 대로 늘리면, 클라이언트 A는 서버 1번에 연결되고, 클라이언트 B는 서버 2번에 연결될 수 있다. 이 상태에서 A가 B한테 메시지를 보내려면? 서버 1번이 서버 2번한테 전달해줘야 한다.
로드 밸런서가 같은 클라이언트는 항상 같은 서버로 라우팅하도록 설정하는 방법이다. 클라이언트 IP나 쿠키를 기준으로 고정한다. 하지만 이건 스케일링의 장점을 반쯤 죽인다. 서버 1번이 죽으면 그 서버에 붙어있던 클라이언트들은 다 튕긴다.
더 나은 방법은 Redis Pub/Sub 패턴이다. 각 서버가 Redis 채널을 구독(Subscribe)하고, 메시지를 발행(Publish)한다. 클라이언트 A가 서버 1번에 메시지를 보내면, 서버 1번이 Redis에 Publish하고, 서버 2번(클라이언트 B가 연결된)이 Subscribe해서 받아간다.
// 서버 측 (Redis Pub/Sub 예제)
const redis = require('redis');
const publisher = redis.createClient();
const subscriber = redis.createClient();
subscriber.subscribe('chat');
subscriber.on('message', (channel, message) => {
// 이 서버에 연결된 모든 클라이언트에게 브로드캐스트
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
});
wss.on('connection', (ws) => {
ws.on('message', (msg) => {
// Redis로 발행 (다른 서버들이 받아감)
publisher.publish('chat', msg);
});
});
이 패턴을 이해하고 나니, "왜 채팅 서버는 Redis를 같이 쓰는지" 의문이 풀렸다. 웹소켓은 Stateful하고 서버에 붙어있기 때문에, 서버 간 메시지 동기화가 필수다.
이게 제일 중요한 포인트인데, 대부분의 경우 웹소켓은 필요 없다. REST API로 충분하다.
웹소켓을 쓰면 안 되는 경우:
웹소켓을 써야 하는 경우:
나는 처음에 "실시간 = 웹소켓"이라고 무조건 적용하려다가, 서버 자원만 낭비했다. 정말 서버 푸시가 필요한지 먼저 고민해야 한다. 예를 들어, 뉴스 피드처럼 "1분에 한 번 업데이트돼도 괜찮은" 경우는 그냥 폴링이나 SSE로도 충분하다.
웹소켓은 "전화기를 끊지 않는 기술"이다. HTTP는 볼일 끝나면 바로 끊지만, 웹소켓은 연결을 계속 유지한다. 핸드셰이크로 HTTP에서 WebSocket으로 프로토콜을 업그레이드하고, 그 뒤로는 양방향 통신이 가능하다.
브라우저에서는 new WebSocket()으로 간단하게 쓸 수 있고, 서버는 ws 라이브러리나 Socket.io로 구현한다. ws://는 평문, wss://는 암호화다. Heartbeat로 좀비 연결을 정리하고, Redis Pub/Sub으로 여러 서버 간 메시지를 동기화할 수 있다.
하지만 무조건 웹소켓을 쓸 필요는 없다. 서버 푸시가 정말 필요한지, SSE로도 충분하지 않은지 먼저 고민해야 한다. REST API로 해결되는 문제에 웹소켓을 쓰면 복잡도만 올라간다.
이제는 채팅 구현할 때 setInterval로 1초마다 폴링하지 않는다. 웹소켓으로 연결 한 번 맺고, 메시지 왔을 때만 받는다. 이게 내가 이해한 "실시간"의 비밀이다.