ws 서버: 브로드캐스트와 끊긴 연결 감지(하트비트)

ws 서버: 브로드캐스트와 끊긴 연결 감지(하트비트)

ws로 서버를 만들다 보면 연결된 모든 클라이언트에게 같은 메시지를 뿌리는 브로드캐스트를 자주 구현하게 돼요. 또 클라이언트가 갑자기 끊겨도 서버는 모를 수 있는데, 이때 ping/pong을 이용한 하트비트로 끊긴 연결을 찾아내야 하죠. 이 글에서는 ws 서버에서 브로드캐스트를 하는 방법과 하트비트로 연결 수명주기를 관리하는 패턴을 살펴볼게요.

출처: websockets/ws - GitHub

본문

서버 브로드캐스트

연결된 모든 WebSocket 클라이언트(자신 포함)에게 메시지를 보내려면 wss.clients를 돌면서 OPEN 상태인 클라이언트에게만 전달하면 돼요. 받은 데이터가 텍스트인지 바이너리인지에 따라 { binary: isBinary } 옵션을 함께 넘기는 게 포인트예요.

import WebSocket, { WebSocketServer } from 'ws';

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

wss.on('connection', function connection(ws) {
  ws.on('error', console.error);

  ws.on('message', function message(data, isBinary) {
    wss.clients.forEach(function each(client) {
      if (client.readyState === WebSocket.OPEN) {
        client.send(data, { binary: isBinary });
      }
    });
  });
});

자기 자신을 제외한 다른 클라이언트에게만 보내고 싶으면 client !== ws 조건을 추가해요.

import WebSocket, { WebSocketServer } from 'ws';

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

wss.on('connection', function connection(ws) {
  ws.on('error', console.error);

  ws.on('message', function message(data, isBinary) {
    wss.clients.forEach(function each(client) {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(data, { binary: isBinary });
      }
    });
  });
});

끊긴 연결 감지와 하트비트

서버와 클라이언트 사이의 링크가 (예를 들어 케이블을 뽑는 것처럼) 끊겨도 양쪽이 그 상태를 모를 수 있어요. 이때 ping 메시지를 써서 상대가 여전히 응답하는지 확인할 수 있습니다. ws는 명세에 따라 ping에 대한 pong을 자동으로 보내 줘요.

서버는 주기적으로(setInterval) 각 클라이언트에 ping()을 보내고, pong 이벤트(heartbeat)가 오면 isAlive를 다시 true로 표시해요. 다음 주기에도 isAlive === false인 클라이언트는 연결이 끊어진 것으로 보고 ws.terminate()로 정리합니다.

import { WebSocketServer } from 'ws';

function heartbeat() {
  this.isAlive = true;
}

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

wss.on('connection', function connection(ws) {
  ws.isAlive = true;
  ws.on('error', console.error);
  ws.on('pong', heartbeat);
});

const interval = setInterval(function ping() {
  wss.clients.forEach(function each(ws) {
    if (ws.isAlive === false) return ws.terminate();

    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('close', function close() {
  clearInterval(interval);
});

클라이언트 쪽도 연결이 끊긴 걸 모를 수 있으니 ping 리스너를 달아 주는 게 좋아요. WebSocket#close()는 닫기 타이머를 기다리지만, WebSocket#terminate()는 연결을 즉시 파괴한다는 차이가 있어요. 여기서는 지연 시간을 보수적으로 잡아 ping 주기(30초)에 1초를 더한 30000 + 1000을 타임아웃으로 씁니다.

import WebSocket from 'ws';

function heartbeat() {
  clearTimeout(this.pingTimeout);

  // Use `WebSocket#terminate()`, which immediately destroys the connection,
  // instead of `WebSocket#close()`, which waits for the close timer.
  // Delay should be equal to the interval at which your server
  // sends out pings plus a conservative assumption of the latency.
  this.pingTimeout = setTimeout(() => {
    this.terminate();
  }, 30000 + 1000);
}

const client = new WebSocket('wss://websocket-echo.com/');

client.on('error', console.error);
client.on('open', heartbeat);
client.on('ping', heartbeat);
client.on('close', function clear() {
  clearTimeout(this.pingTimeout);
});

외부 HTTP/S 서버와 함께 쓰기

자체 HTTP 서버 없이 기존 HTTPS 서버에 WebSocket을 얹으려면 서버의 인증서(cert, key)를 읽어 createServer로 만든 뒤, 그 serverWebSocketServer에 넘겨주면 돼요.

import { createServer } from 'https';
import { readFileSync } from 'fs';
import { WebSocketServer } from 'ws';

const server = createServer({
  cert: readFileSync('/path/to/cert.pem'),
  key: readFileSync('/path/to/key.pem')
});
const wss = new WebSocketServer({ server });

wss.on('connection', function connection(ws) {
  ws.on('error', console.error);

  ws.on('message', function message(data) {
    console.log('received: %s', data);
  });

  ws.send('something');
});

server.listen(8080);

더 알아보기