본문 바로가기
dev/javascript

[node.js] 웹소켓 채팅 서버 WebSocket chat

by 최연탄 2022. 9. 22.
728x90
반응형

웹소켓은 게임이나 공유작업 웹사이트 등 여러 응용프로그램에서 사용되고 있습니다. 이번 포스트에서는 실제로 작동하는 간단한 채팅 서버를 만들어 보겠습니다.

WebSocket을 사용하려면 백엔드 서버가 필요하고 이를 화면에 보여주는 프론트 클라이언트가 필요합니다. 백엔드는 node.js로 프론트는 자바스크립트를 사용하겠습니다. 웹소켓의 자세한 작동 방식에 대한 정보가 알고싶다면 Websockets 101을 참고 바랍니다. 예제 코드는 모두 macOS에서 작성했습니다.

채팅 기능

모든 사용자는 처음 접속 시 자신의 이름을 지정할 수 있고 서버는 임의의 색을 지정하며, 새로운 사용자가 접속했다는 시스템 메시지를 콘솔에 보여줍니다. 이후 사용자는 메시지를 전송할 수 있습니다. 채팅 후 사용자가 브라우저 창을 닫으면 서버는 사용자의 연결을 끊고 사용자가 끊어졌다고 시스템 메시지를 콘솔에 보입니다. 여기서 또 다른 사용자가 접속하면 이전에 채팅했던 전체 메시지 기록을 받게 됩니다.

client -> server, server -> client 통신

웹소켓의 가장 큰 장점은 양방향 통신이 가능하다는 것 입니다. 여기서 어떤 사용자가 서버로 메시지를(client -> server) 보낸 다음 서버가 연결된 모든 사용자에게 메시지를(server -> client) 보내는 상황을 "브로드캐스트"라고 합니다.

client -> server 통신은 특별히 복잡한 구조가 필요하지 않기 때문에 단순하게 문자열만 전송하기로 정하겠습니다.

server -> client 통신은 조금 복잡합니다. 이 프로젝트에서는 서버가 보내는 세 종류의 메시지 타입을 구분해야 합니다.

  • 사용자의 색상 할당
  • 사용자에게 이전 채팅 목록을 전달
  • 모든 사용자에게 메시지 브로드캐스팅하기

그러므로 서버는 모든 메시지를 JSON 으로 인코딩된 자바스크립트 객체로 보낼 것 입니다.

Node.js 서버

Node.js는 자체적으로 WebSocket을 지원하지 않지만 WebSocket 프로토콜을 구현한 플러그인이 있습니다. WebSocket-Node는 사용하기 쉽고 문서화도 잘 되어있습니다. 여기서는 이 패키지를 사용하겠습니다.

다음의 명령어를 입력해서 서버 디렉토리를 만들고 npm 프로젝트를 초기화 합니다.

$ mkdir server && cd server
$ npm init -y
$ npm install websocket

WebSocket 서버 템플릿

이제 server.js 라는 파일 이름으로 node.js 서버 코드를 작성할 파일을 만듭니다.

$ touch server.js

새로 만들어진 server.js 파일에 소스코드를 작성합니다. 다음은 웹소켓 서버의 기본 뼈대입니다.

const WebSocketServer = require('websocket').server;
const http = require('http');
const server = http.createServer();
const port = 8080;

server.listen(port, () => {
  console.log('server listen:', port);
});

const wsServer = new WebSocketServer({
  httpServer: server
});

wsServer.on('request', (request) => {
  const connection = request.accept(null, request.origin);

  connection.on('message', (message) => {
    if (message.type === 'utf8') {
      console.log('message:', message);
    }
  });

  connection.on('close', (connection) => {
    console.log('close:');
  });
});

서버의 포트 번호는 8080 으로 지정했습니다. 이제 다음의 명령어를 입력하여 서버를 실행합니다. 서버가 정상적으로 실행 되었으면 "server listen: 8080"이 출력됩니다.

$ node server.js

위 코드의 wsServer.on에 구현된 부분은 클라이언트에서 메시지를 보내거나 연결이 끊겼을 때의 처리를 담당하는 부분입니다.

WebSocket 서버 전체 소스코드

다음은 server.js 전체 코드입니다.

728x90
const WebSocketServer = require('websocket').server;
const http = require('http');
const port = 8080;

/**
 * 전역 변수
 */
const colors = ['red', 'green', 'blue', 'magenta', 'purple', 'plum', 'orange'];
const clients = [];
let history = [];

/**
 * HTTP 서버
 */
const server = http.createServer();

server.listen(port, () => {
  console.log('server listen:', port);
});

/**
 * WebSocket 서버
 */
const wsServer = new WebSocketServer({
  httpServer: server
});

wsServer.on('request', (request) => {
  const connection = request.accept();
  const index = clients.push(connection) - 1;
  let userName = false;
  let userColor = false;

  console.log('connection accepted:');

  if (history.length > 0) {
    connection.sendUTF(makeResponse('history', history));
  }

  connection.on('message', (message) => {
    if (message.type === 'utf8') {
      if (userName === false) {
        userName = htmlEntities(message.utf8Data);
        userColor = colors.shift();
        connection.sendUTF(makeResponse('color', userColor));

        console.log(`User is known as: ${userName} with ${userColor} color`);
      } else {
        console.log(`Received Message from ${userName}: ${message.utf8Data}`);

        const obj = {
          time: (new Date()).getTime(),
          text: htmlEntities(message.utf8Data),
          author: userName,
          color: userColor
        };

        history.push(obj);
        history = history.slice(-100);

        clients.forEach(client => client.sendUTF(makeResponse('message', obj)));
      }
    }
  });

  connection.on('close', (connection) => {
    if (userName !== false && userColor !== false) {
      console.log(`Peer ${connection.remoteAddress} disconnected`);

      clients.splice(index, 1);
      colors.push(userColor);
    }
  });
});

/**
 * 유틸
 */
const htmlEntities = (str) => String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const makeResponse = (type, data) => JSON.stringify({ type, data });

채팅 서버의 주요 기능들이 추가되었습니다.

wsServer.on('request', ...)는 클라이언트에서 접속할 때 마다 실행되는 이벤트 핸들러입니다. connection.sendUTF() 메소드는 연결되어있는 클라이언트에게 메시지를 보내는 작업을 합니다. 이를 이용해 처음 접속 시 history 배열에 저장된 이전의 채팅 메시지가 있다면 클라이언트에 보내주고 이 클라이언트가 메시지를 보내면 connection.on('message', ...) 이벤트 핸들러가 실행되고 connection.on(close, ...)는 클라이언트가 브라우저 닫기 등 접속을 끊을 경우 실행됩니다.

클라이언트에서 메시지를 받으면 이벤트 핸들러에서 사용자의 이름과 색상을 설정하고 접속된 다른 사용자에게 브로드캐스트 작업을 수행합니다.

여기서 주목할 점은 채팅방 이라는 개념이 없이 서버에 접속한 모든 사용자가 모두 채팅한다는 점 입니다. 채팅방을 따로 구현하고 싶다면 room을 관리하는 추가적인 작업을 해야합니다.

HTML + CSS

이제 프론트엔드 코드를 작성할 차례입니다. 새로운 디렉토리로 이동하여 다음의 명령어를 입력해 index.html을 추가합니다.

$ mkdir client && cd client
$ touch index.html

이제 index.html 파일에 다음의 코드를 작성합니다.

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <title>WebSockets - Simple chat</title>
  <style>
    * { font-family:tahoma; font-size:12px; padding:0px;margin:0px;}
    p { line-height:18px; }
    div { width:500px; margin-left:auto; margin-right:auto;}
    #content { padding:5px; background:#ddd; border-radius:5px;
        overflow-y: scroll; border:1px solid #CCC;
        margin-top:10px; height: 160px; }
    #input { border-radius:2px; border:1px solid #ccc;
        margin-top:10px; padding:5px; width:400px;
    }
    #status { width:88px;display:block;float:left;margin-top:15px; }
  </style>
</head>

<body>
  <div id="content"></div>

  <div>
    <span id="status">Connecting...</span>
    <input type="text" id="input" disabled="disabled" />
  </div>

  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
  <script src="./client.js"></script>
</body>

</html>

index.html은 단순합니다. 채팅 메시지를 보여줄 div를 하나 만들고 아래에 전송할 메시지를 입력하는 input을 추가했습니다. 클라이언트 코드는 jquery로 작성할 것이고 실제 자바스크립트 코드는 client.js에 작성할 계획입니다.

프론트엔드 템플릿

다음의 명령어를 입력하여 client.js 파일을 생성합니다.

$ touch client.js

다음의 프론트엔드 애플리케이션은 기본적으로 세 개의 콜백 메소드를 가집니다.

$(function () {
  window.WebSocket = window.WebSocket || window.MozWebSocket;

  var connection = new WebSocket('ws://127.0.0.1:8080');

  connection.onopen = function () {
  };

  connection.onerror = function (error) {
  };

  connection.onmessage = function (message) {
    try {
      var json = JSON.parse(message.data);
    } catch (e) {
      console.log('This doesn\'t look like a valid JSON:', message.data);
      return;
    }
  };
});

먼저 브라우저의 웹소켓을 설정하고 이전에 만들었던 소켓 서버에 접속합니다. 여기서 소켓 서버의 주소는 "ws://127.0.0.1:8080"이 됩니다. 웹소켓 콜백으로 onopen은 소켓 서버에 접속 성공한 경우 호출되고 onerror는 접속 또는 메시지 송수신 등 에러가 발생했을 때 호출됩니다. onmessage 콜백이 주요한 함수인데 서버로 부터 메시지를 받으면 이 콜백이 호출됩니다. 여기서 메시지를 해석하고 화면에 표시하는 코드를 작성할 것 입니다.

프론트엔드 전제 소스코드

$(function () {
  "use strict";

  var content = $('#content');
  var input = $('#input');
  var status = $('#status');

  var myColor = false;
  var myName = false;

  window.WebSocket = window.WebSocket || window.MozWebSocket;

  if (!window.WebSocket) {
    content.html($('<p>', { text:'Sorry, but your browser doesn\'t support WebSocket.'}));
    input.hide();
    $('span').hide();
    return;
  }

  var connection = new WebSocket('ws://127.0.0.1:8080');

  connection.onopen = function () {
    input.removeAttr('disabled');
    status.text('Choose name:');
  };

  connection.onerror = function (error) {
    content.html($('<p>', { text: 'Sorry, but there\'s some problem with your ' + 'connection or the server is down.' }));
  };

  connection.onmessage = function (message) {
    try {
      var json = JSON.parse(message.data);
    } catch (e) {
      console.log('Invalid JSON: ', message.data);
      return;
    }

    if (json.type === 'color') { 
      myColor = json.data;
      status.text(myName + ': ').css('color', myColor);
      input.removeAttr('disabled').focus();
    } else if (json.type === 'history') {
      for (var i = 0; i < json.data.length; i++) {
        addMessage(json.data[i].author, json.data[i].text, json.data[i].color, new Date(json.data[i].time));
      }
    } else if (json.type === 'message') {
      input.removeAttr('disabled'); 
      addMessage(json.data.author, json.data.text, json.data.color, new Date(json.data.time));
    } else {
      console.log('Hmm..., I\'ve never seen JSON like this:', json);
    }
  };

  input.keydown(function(e) {
    if (e.keyCode === 13) {
      var msg = $(this).val();

      if (!msg) {
        return;
      }

      connection.send(msg);

      $(this).val('');

      input.attr('disabled', 'disabled');

      if (myName === false) {
        myName = msg;
      }
    }
  });

  setInterval(function() {
    if (connection.readyState !== 1) {
      status.text('Error');
      input.attr('disabled', 'disabled').val('Unable to communicate with the WebSocket server.');
    }
  }, 3000);

  function addMessage(author, message, color, dt) {
    content.prepend('<p><span style="color:' + color + '">'
      + author + '</span> @ '
      + (dt.getHours() < 10 ? '0' + dt.getHours() : dt.getHours()) + ':'
      + (dt.getMinutes() < 10 ? '0' + dt.getMinutes() : dt.getMinutes()) + ': '
      + message + '</p>');
  }
});

onopen 핸들러가 실행되면 사용자의 이름을 이름을 입력하라고 화면에 표시를 해줍니다. 아래 부분에 보면 input에 엔터키를 눌렀을 때 메시지를 서버로 보내는 코드가 작성되어 있습니다. onerror 핸들러가 실행되었다는 것은 에러가 발생한 것 이므로 화면에 에러 발생했음을 표시합니다. onmessage 핸들러가 실행되면 서버로 부터 받은 JSON 객체를 가져와서 명령어 타입에 따라 각각의 처리를 하도록 작성되어있습니다. JSON 객체의 타입은 소켓 서버가 보낼 수 있는 타입의 목록 중 하나여야 하고 이외의 값은 에러로 간주합니다.

서버/클라이언트 실행

서버를 실행하려면 웹소켓 서버의 소스코드가 있는 디렉토리로 이동하여 다음의 명령어를 입력합니다.

$ node server.js

그 후 브라우저에서 클라이언트의 index.html을 실행하면 서버의 콘솔에 다음과 같은 메시지가 나타납니다.

index.html을 실행한 브라우저에는 다음과 같은 화면이 보입니다.

input에 이름을 입력하고 엔터를 누르면 서버로 이름을 등록하고 클라이언트 화면에는 본인의 이름과 색이 표시됩니다.

이제 다른 브라우저 탭을 새로 실행하여 다른 사용자(BBBBB)로 접속한 다음 각각 메시지를 입력하면 서로 메시지가 오가는 화면을 확인할 수 있습니다.

웹소켓 서버의 콘솔에서도 메시지가 오가는 것을 확인할 수 있습니다.

관련 글

POST 받는 서버 만들기

IP 주소 확인 방법

반응형

댓글