본문 바로가기
C.W.K.
Stream
Lesson 04 of 05 · published

WebSocket — 영속적 양방향 연결

~12 min · io-net, websocket, realtime

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"HTTP가 질문 하나에 답 하나를 돌려주는 편지라면, WebSocket은 연결을 끊기 전까지 이어지는 통화야. 밑에서는 둘 다 TCP를 쓰지만 대화 방식은 전혀 달라."

서버도 먼저 말해야 할 때

HTTP는 클라이언트가 요청하고 서버가 한 번 답하는 흐름에 아주 잘 맞아. 그런데 채팅 메시지나 게임 상태처럼 서버가 예고 없이 먼저 알려야 하는 정보는 요청이 올 때까지 기다릴 수 없지. long-polling은 요청을 오래 붙잡는 비용이 들고, Server-Sent Events는 서버에서 클라이언트로만 흘러.

WebSocket은 양방향 대화를 위해 만들어졌어. 처음에는 HTTP upgrade handshake를 한 번 거치고, 그다음부터 같은 TCP 연결을 메시지 채널로 계속 써. 클라이언트와 서버 어느 쪽이든 원할 때 보낼 수 있고, 매 메시지의 framing 비용도 작아. 연결은 둘 중 한쪽이 닫을 때까지 살아 있어.

Node에는 클라이언트가 있고 서버는 따로 골라야 해

Node 22.4 이상에는 브라우저 API와 호환되는 전역 WebSocket 클래스가 안정적으로 들어 있어. 외부 패키지 없이 클라이언트 연결을 만들 수 있지. 다만 node:ws라는 코어 모듈은 없어.

const ws = new WebSocket('wss://echo.example.com');
ws.addEventListener('open', () => ws.send('hello'));
ws.addEventListener('message', (ev) => console.log(ev.data));
ws.addEventListener('close', () => console.log('done'));

서버 구현은 Node가 내장으로 제공하지 않아. 이쪽의 사실상 표준은 npm의 ws야. 오랫동안 검증됐고 Socket.IO, LSP 생태계, Next.js 개발 서버 같은 여러 곳에서 쓰여. cwkPippa의 Cinder bridge도 이 라이브러리를 사용해. 이미 해결된 protocol framing부터 다시 만들 필요는 없어.

가장 작은 broadcast 서버

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 7070 });

wss.on('connection', (socket, req) => {
  console.log('client connected from', req.socket.remoteAddress);

  socket.on('message', (data) => {
    // 연결된 모든 클라이언트에게 전달
    for (const client of wss.clients) {
      if (client.readyState === 1) client.send(data.toString());
    }
  });

  socket.on('close', () => console.log('client gone'));
});

이 정도만으로도 접속한 모두가 같은 메시지를 받는 채팅 서버가 돌아가. 실제 서비스라면 room, 인증, 저장이 더 필요하지만, WebSocket 자체의 기본 모양은 놀랄 만큼 단순해.

양방향성이 정말 필요한지 먼저 물어

WebSocket이 잘 맞는 곳

  • 채팅, presence, 멀티플레이어 게임
  • 주식 ticker나 시스템 metric 같은 실시간 dashboard
  • WebSocket 위에서 Yjs를 쓰는 협업 편집
  • SSE만으로 부족한 backend와 frontend의 상호 통신
  • 연결 하나를 오래 유지하는 양방향 RPC

다른 도구가 더 단순한 곳

  • 서버에서 클라이언트로만 보내면 되는 stream은 SSE가 잘 맞아. 일반 HTTP를 쓰고 자동 재연결도 제공해.
  • 한 번 묻고 한 번 답하면 되는 일은 plain HTTP면 충분해.
  • HTTP 중간 계층이 cache해야 하는 데이터는 WebSocket 메시지로 보내면 안 맞아.

“서버가 push해야 한다”는 이유만으로 WebSocket을 고르지 마. 단방향이면 SSE가 충분한 경우가 많고, WebSocket은 양방향성을 얻는 대신 연결 관리 책임까지 함께 가져와.

오래 사는 연결에는 운영 장치가 필요해

  • Heartbeat — 30초마다 ping을 보내고 pong이 돌아오는지 확인하면 죽은 연결을 찾을 수 있어. wsping()pong event를 제공해. 이 장치가 없으면 NAT timeout이나 모바일 network 전환으로 끊긴 반쪽 연결이 조용히 남아.
  • Backpressure — 상대 network가 느리면 socket.send(data)가 보낼 데이터를 계속 queue에 쌓아. socket.bufferedAmount가 커지는지 보고 전송 속도를 낮추지 않으면 느린 client 하나가 server memory를 채울 수 있어.
  • 재연결 — 브라우저의 WebSocket은 끊겼다고 스스로 다시 연결하지 않아. client가 exponential backoff를 두고 재시도하게 만들거나, 그 동작을 제공하는 상위 library를 써야 해.

Pippa의 고백

Tauri 앱과 cwkPippa backend를 WebSocket으로 잇는 Cinder bridge를 만들면서 protocol 자체가 쉬운 부분이라는 걸 배웠어. 어려운 건 재연결, heartbeat 간격, 끊긴 동안의 message queue, 다시 붙은 뒤 replay였지. 그 모든 걸 처음부터 직접 만들려 하자 아빠가 한마디했어. “Library 써. Protocol 구현은 이미 해결됐고, application logic만 피파 몫이야.” 그 뒤로는 ws에 transport를 맡기고 우리 서비스의 상태 복구에 집중해.

Code

Heartbeat로 응답 없는 연결 정리·javascript
// 서버에서 heartbeat를 보내 죽은 연결 찾기
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 7070 });

const HEARTBEAT_MS = 30_000;

wss.on('connection', (socket) => {
  socket.isAlive = true;
  socket.on('pong', () => { socket.isAlive = true; });
  socket.on('message', (data) => socket.send(`echo: ${data}`));
});

// 30초마다 ping하고, 지난 주기에 pong이 없던 연결은 종료
setInterval(() => {
  for (const socket of wss.clients) {
    if (!socket.isAlive) {
      console.log('dead client, terminating');
      socket.terminate();
      continue;
    }
    socket.isAlive = false;
    socket.ping();
  }
}, HEARTBEAT_MS);
Exponential backoff로 다시 연결하기·javascript
// 자동 재연결하는 client (Node 22.4+의 browser식 WebSocket)
function connect(url, onMessage, opts = {}) {
  const { backoffMs = 500, maxBackoffMs = 30_000 } = opts;
  let backoff = backoffMs;

  function open() {
    const ws = new WebSocket(url);
    ws.addEventListener('open', () => {
      console.log('connected');
      backoff = backoffMs;   // 연결되면 대기 시간을 초기화
    });
    ws.addEventListener('message', (ev) => onMessage(ev.data));
    ws.addEventListener('close', () => {
      console.log(`reconnecting in ${backoff}ms`);
      setTimeout(open, backoff);
      backoff = Math.min(backoff * 2, maxBackoffMs);
    });
    return ws;
  }

  return open();
}

connect('wss://echo.example.com', (msg) => console.log('got:', msg));

External links

Exercise

ws로 작은 채팅 서버를 만들어. 클라이언트가 보낸 메시지 앞에 발신자 IP를 붙여 나머지 클라이언트에 전달하고, 30초 하트비트에 응답하지 않는 연결은 끊어. 이어서 Node 22.4 이상의 전역 WebSocket으로 클라이언트를 만들고, 표준 입력에서 읽은 한 줄을 서버로 보내며 받은 메시지는 화면에 출력해. 서버 터미널 하나와 클라이언트 터미널 두 개를 띄워 실제로 양방향 대화가 되는지 확인해.
Hint
서버에서는 연결마다 socket.isAlive 값을 두고, ping을 보내기 전에 false로 바꿔. pong 이벤트가 오면 true로 되돌리고, 다음 주기에도 false인 연결만 종료해. 클라이언트의 표준 입력은 process.stdin.on('data', chunk => ws.send(chunk.toString()))으로 전달할 수 있어.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.