Showing

[Node, Vue] 클론코딩 Zoom(4) socket.io를 이용한 채팅룸 만들기 본문

Node

[Node, Vue] 클론코딩 Zoom(4) socket.io를 이용한 채팅룸 만들기

RabbitCode 2023. 5. 28. 04:57

* 노마드 코더의 Do it! 클론코딩 줌 책을 정리한 포스팅입니다.

1. 들어가며(socket.io를 이용한 채팅룸 만들기)

실시간으로 데이터를 주고받는 프로토콜이 뭔지 어느 정도 감이 잡혔으니, 이제 이와 관련해 좀 더 편리한 라이브러리를 적용해 볼 차례이다. 제공하는 기능이 많고, 동작이 안정된 라이브러리는 개발 속도에 날개를 달아 준다. 똑같은 기능을 하는 프로그램을 만든다고 해도 라이브러리를 쓰는지 안 쓰는지에 따라 그 과정에서 겪는 경험은 크게 달라질 수밖에 없다. socket.io는 등장한 지 오래되었기 떄문에 안정적이고 편리한 기능을 많이 제공한다. 실시간 기능을 구현하고, 이벤트를 기반으로 한 양방향 통신도 가능하다. 웹소켓과 비슷한 면이 많고, 관련성 또한 있다.

2. socket.io 설치하기

(1) socket.io

socket.io는 실시간, 양방향, 이벤트에 기반한 통신 등을 모두 지원하는 라이브러리이다. 지원하는 기능을 보았을 때는 웹소켓과 유사한 느낌을 준다. socket.io를 웹소켓의 부가 기능이라고 오해하는 경우도 종종 있는데, 사실 socket.io는 '웹소켓을 활용하는 라이브러리'이다. 웹소켓이 socket.io의 기능을 제공하는 것이 아니라 socket.io가 웹소켓을 이용해 기능을 수행하는 것이다. *여담으로 사용자 간의 실시간 데이터 교환 성능이 뛰어나서 도박사이트에서 많이 쓴다...

 

이러한 socket.io를 이용해 앞서 웹소켓 프로토콜과 ws 패키지를 이용해 구현했던 기능과 유사한 기능을 구현해볼 것이다. 바로 프런트엔드와 서버 간의 실시간 통신이다.

 

프런트엔드와 서버 간의 통신을 위한 꼭 socket.io를 사용할 필요는 없으나 같은 기능을 훨씬 빠르고 편리하게 만들 수 있는 코드를 제공함은 물론 탄력성이 무척 뛰어나서 플랫폼, 기기, 브라우저를 가리지 않고 동작한다. 심지어 웹소켓을 100% 의존하지도 않고, 심지어 연결이 끊어졌을 땐 자동으로 연결을 시도하는 기능까지 지원한다.

 

(2) socket.io 적용 준비하기

기존 ws 코드를 정리한다. 차이를 바로바로 학습하기 위해 주석처리를 남겨둔다.

import http from "http";
//import WebSocket from "ws";
import express from 'express';

const app = express();
app.set('view engine', 'pug');
app.set('views', __dirname + '/views');
app.use('/public', express.static(__dirname + '/public'));

app.get('/', (req, res) => res.render("home"));
app.get('/*', (req, res) => res.redirect("/"));

//const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
//const wss = new WebSocket.Server({server});

//const sockets = [];

// wss.on('connection',(socket)=>{
//     sockets.push(socket);
//     console.log(socket);
//     console.log("Connected to Browser");
//     socket["nickname"] = "Anonymous";
//     socket.on('close',()=>console.log("Disconnected from Browser"));
//     socket.on('message',(msg)=>
//     {
//         const message = JSON.parse(msg);
//         console.log(message.type, message.payload);
//         //sockets.forEach(aSocket=>aSocket.send(`${message}`))
//         switch(message.type){
//             case "new_message":
//                 sockets.forEach(aSocket=>aSocket.send(`${socket.nickname}:${message.payload}`))
//                 break;
//             case "nickname":
//                 socket["nickname"] = message.payload;
//                 break; 
//         }
//     }
//     //socket.send(`${message}`)
//     );
// });
const handleListen = ()=>console.log("Listening on 3000 port");
server.listen(3000, handleListen);

(3) socket.io 설치

npm i socket.io
import http from "http";
//import WebSocket from "ws";
import SocketIO from "socket.io";
import express from 'express';

const app = express();
app.set('view engine', 'pug');
app.set('views', __dirname + '/views');
app.use('/public', express.static(__dirname + '/public'));

app.get('/', (req, res) => res.render("home"));
app.get('/*', (req, res) => res.redirect("/"));

//const handleListen = ()=>console.log("Listening on 3000 port");
//const server = http.createServer(app);
const httpServer = http.createServer(app);
//const wss = new WebSocket.Server({server});
const wsServer = SocketIO(httpServer);

//const sockets = [];

// wss.on('connection',(socket)=>{
//     sockets.push(socket);
//     console.log(socket);
//     console.log("Connected to Browser");
//     socket["nickname"] = "Anonymous";
//     socket.on('close',()=>console.log("Disconnected from Browser"));
//     socket.on('message',(msg)=>
//     {
//         const message = JSON.parse(msg);
//         console.log(message.type, message.payload);
//         //sockets.forEach(aSocket=>aSocket.send(`${message}`))
//         switch(message.type){
//             case "new_message":
//                 sockets.forEach(aSocket=>aSocket.send(`${socket.nickname}:${message.payload}`))
//                 break;
//             case "nickname":
//                 socket["nickname"] = message.payload;
//                 break; 
//         }
//     }
//     //socket.send(`${message}`)
//     );
// });
const handleListen = ()=>console.log("Listening on 3000 port");
httpServer.listen(3000, handleListen);

socket.io를 이용해 웹소켓 서버를 만드는 방식은 기존 ws 방식과 유사하다. 여기에서는 HTTP 서버와 웹소켓 서버를 명확히 구분하기 위해 이름을 각각 httpServer, wsServer로 정해주었고, wsServer를 만들기 위해서 SocketIO에 httpServer를 넘겨준다. 단순히 서버를 만드는 작업은 이것이 끝이다.

(4) socket.io URL 확인하기

socket.io를 설치하고 이를 이용해 서버를 만들어 실행했다. 이렇게 해주는 것만으로도 socket.io는 URL를 제공해주기도 한다.

http://localhost:3000/socket.io/socket.io.js

이처럼 localhost:3000 서버는 socket.io 소스 코드를 제공하고 있다. 서버는 사용자에게 데이터를 전달해주는 역할을 한다. 이 소스코드 또한 사용자에게 제공되는 데이터이다. 이렇게 소스 코드를 제공받음으로써, 사용자는 socket.io 가 제공하는 기능을 사용자의 앱, 즉 브라우저에 적용할 수 있게 된다. socket.io가 웹소켓의 부가기능은 아니기 때문에 socket.io를 브라우저에 사용하려면 먼저 socket.io를 브라우저에 설치해야 한다.

(5) home.pug 수정하기

우리는 채팅룸 기능을 하는 앱을 만들어 볼 것이므로, 사용자가 채팅에 참여하고 싶다면 먼저 채팅룸부터 만들고 그 안에서 메시지를 교환할 수 있게 할 것이다. 먼저 home.pug를 수정한다.

doctype html
html(lang="en")
    head
        meta(charset="UTF-8")
        meta(http-equiv="X-UA-Compatible", content="IE=edge")
        meta(name="viewport", content="width=device-width, initial-scale=1.0")
        title Noom
        link(rel="stylesheet", href="https://unpkg.com/mvp.css")
    body
        header
            h1 Noom
        main
            //- form#nick
            //-     input(type="text", placeholder="choose a nickname", required)
            //-     button Save
            //- ul
            //- form#message
            //-     input(type="text", placeholder="write a msg", required)
            //-     button Send 
        script(src="/socket.io/socket.io.js")
        script(src="/public/js/app.js")

이전에는 브라우저에 설치되어 있는 웹소켓을 사용했다면, 이제는 socket.io를 사용할 것이다.

 

(6) app.js 수정하기

여지껏 app.js에 작성된 웹소켓을 활용하는 코드로 서버와 연결을 시도했다. 코드에서 new WebSocket~~ 이 바로 서버와 연결을 시도하는 부분이었는데 이제 삭제해준다.

const socket = new WebSocket(`ws://${window.location.host}`); // 삭제!

socket.io를 설치하고 나면 io라는 함수를 사욯할 수 있다. io는 사용자를 서버쪽 socket.io와 자동으로 연결해주는 함수이다. 

// const messageList = document.querySelector("ul");
// const nickForm = document.querySelector("#nick");
// const messageForm = document.querySelector("#message");


// const socket = new WebSocket(`ws://${window.location.host}`);

// function makeMessage(type, payload) {
//     const msg = {type, payload};
//     return JSON.stringify(msg);
// }

// socket.addEventListener("open", ()=>{
//     console.log("Connected to Server");
// })

// socket.addEventListener("message", (message)=>{
//     //console.log("Just to this:", message.data, "from server");
//     const li = document.createElement("li");
//     li.innerText = message.data;
//     messageList.append(li);
// })

// socket.addEventListener("close", ()=>{
//     console.log("Disconnected from Server");
// })

// function handleSubmit(evnet){
//     event.preventDefault();
//     const input = messageForm.querySelector("input");
//     //socket.send(input.value);
//     socket.send(makeMessage("new_message", input.value));
//     input.value = '';
// }

// function handleNickSubmit(evnet){
//     event.preventDefault();
//     const input = nickForm.querySelector("input");
//     //socket.send(input.value);
//     // socket.send({
//     //     type: "nickName",
//     //     payload: input.value
//     // });
//     socket.send(makeMessage("nickname", input.value));
//     input.value = '';
// }

// messageForm.addEventListener("submit", handleSubmit);
// nickForm.addEventListener("submit", handleNickSubmit);
const socket = io();

io는 알아서 socket.io를 실행하고 있는 서버를 찾을 것이다. socket.io가 웹소켓을 이용해 기능을 수행할 것이기 때문이다.

 

(7) server.js 수정하기

서버에서 socket.io를 제공하고, 사용자가 이를 설치한 뒤 서버와 연결하기 위해 io 함수를 호출하는 것까지, 이제 연결을 위한 준비는 모두 끝났다. 마지막으로 연결을 확인하려면 서버 쪽에서 연결(connection) 이벤트 핸들러를 만들어주어야 한다.

import http from "http";
//import WebSocket from "ws";
import SocketIO from "socket.io";
import express from 'express';

const app = express();
app.set('view engine', 'pug');
app.set('views', __dirname + '/views');
app.use('/public', express.static(__dirname + '/public'));

app.get('/', (req, res) => res.render("home"));
app.get('/*', (req, res) => res.redirect("/"));

//const handleListen = ()=>console.log("Listening on 3000 port");
//const server = http.createServer(app);
const httpServer = http.createServer(app);
//const wss = new WebSocket.Server({server});
const wsServer = SocketIO(httpServer);

//const sockets = [];
wsServer.on('connection',(socket)=>{
    console.log(socket);
});
// wss.on('connection',(socket)=>{
//     sockets.push(socket);
//     console.log(socket);
//     console.log("Connected to Browser");
//     socket["nickname"] = "Anonymous";
//     socket.on('close',()=>console.log("Disconnected from Browser"));
//     socket.on('message',(msg)=>
//     {
//         const message = JSON.parse(msg);
//         console.log(message.type, message.payload);
//         //sockets.forEach(aSocket=>aSocket.send(`${message}`))
//         switch(message.type){
//             case "new_message":
//                 sockets.forEach(aSocket=>aSocket.send(`${socket.nickname}:${message.payload}`))
//                 break;
//             case "nickname":
//                 socket["nickname"] = message.payload;
//                 break; 
//         }
//     }
//     //socket.send(`${message}`)
//     );
// });
const handleListen = ()=>console.log("Listening on 3000 port");
httpServer.listen(3000, handleListen);

웹소켓 서버와 사용자가 연결되면 socket을 통해 사용자와 연결을 확인할 수 있다.

PS C:\Users\User\HelloStudyWorld\noom> npm i socket.io

added 20 packages, and audited 392 packages in 7s

61 packages are looking for funding
[nodemon] 2.0.22
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `babel-node src/server.js`
Listening on 3000 port
[nodemon] restarting due to changes...
[nodemon] starting `babel-node src/server.js`
Listening on 3000 port
<ref *1> Socket {
  _events: [Object: null prototype] {},
  _eventsCount: 0,
  _maxListeners: undefined,
  nsp: <ref *2> Namespace {
    _events: [Object: null prototype] { connection: [Function (anonymous)] },
    _eventsCount: 1,
    _maxListeners: undefined,
    sockets: Map(1) { 'nUn2qzIrZYmbqTBkAAAB' => [Circular *1] },
    _fns: [],
    _ids: 0,
    server: Server {
      _events: [Object: null prototype] {},
      _eventsCount: 0,
      _maxListeners: undefined,
      _nsps: [Map],
      parentNsps: Map(0) {},
      parentNamespacesFromRegExp: Map(0) {},
      _path: '/socket.io',
      clientPathRegex: /^\/socket\.io\/socket\.io(\.msgpack|\.esm)?(\.min)?\.js(\.map)?(?:\?|$)/,
      _connectTimeout: 45000,
      _serveClient: true,
      _parser: [Object],
      encoder: [Encoder],
      opts: [Object],
      _adapter: [class Adapter extends EventEmitter],
      sockets: [Circular *2],
      eio: [Server],
      httpServer: [Server],
      engine: [Server],
      [Symbol(kCapture)]: false
    },
    name: '/',
    adapter: Adapter {
      _events: [Object: null prototype] {},
      _eventsCount: 0,
      _maxListeners: undefined,
      nsp: [Circular *2],
      rooms: [Map],
      sids: [Map],
      encoder: [Encoder],
      [Symbol(kCapture)]: false
    },
    [Symbol(kCapture)]: false
  },
  client: Client {
    sockets: Map(1) { 'nUn2qzIrZYmbqTBkAAAB' => [Circular *1] },
    nsps: Map(1) { '/' => [Circular *1] },
    server: Server {
      _events: [Object: null prototype] {},
      _eventsCount: 0,
      _maxListeners: undefined,
      _nsps: [Map],
      parentNsps: Map(0) {},
      parentNamespacesFromRegExp: Map(0) {},
      _path: '/socket.io',
      clientPathRegex: /^\/socket\.io\/socket\.io(\.msgpack|\.esm)?(\.min)?\.js(\.map)?(?:\?|$)/,
      _connectTimeout: 45000,
      _serveClient: true,
      _parser: [Object],
      encoder: [Encoder],
      opts: [Object],
      _adapter: [class Adapter extends EventEmitter],
      sockets: [Namespace],
      eio: [Server],
      httpServer: [Server],
      engine: [Server],
      [Symbol(kCapture)]: false
    },
    conn: Socket {
      _events: [Object: null prototype],
      _eventsCount: 3,
      _maxListeners: undefined,
      id: 'r6Uu8MHKNmvaLh1sAAAA',
      server: [Server],
      upgrading: false,
      upgraded: false,
      _readyState: 'open',
      writeBuffer: [Array],
      packetsFn: [],
      sentCallbackFn: [],
      cleanupFn: [Array],
      request: [IncomingMessage],
      protocol: 4,
      remoteAddress: '::1',
      checkIntervalTimer: null,
      upgradeTimeoutTimer: null,
      pingTimeoutTimer: Timeout {
        _idleTimeout: 45000,
        _idlePrev: [TimersList],
        _idleNext: [TimersList],
        _idleStart: 6477,
        _onTimeout: [Function (anonymous)],
        _timerArgs: undefined,
        _repeat: null,
        _destroyed: false,
        [Symbol(refed)]: true,
        [Symbol(kHasPrimitive)]: false,
        [Symbol(asyncId)]: 95,
        [Symbol(triggerId)]: 94
      },
      pingIntervalTimer: Timeout {
        _idleTimeout: 25000,
        _idlePrev: [TimersList],
        _idleNext: [TimersList],
        _idleStart: 6466,
        _onTimeout: [Function (anonymous)],
        _timerArgs: undefined,
        _repeat: null,
        _destroyed: false,
        [Symbol(refed)]: true,
        [Symbol(kHasPrimitive)]: false,
        [Symbol(asyncId)]: 71,
        [Symbol(triggerId)]: 0
      },
      transport: [Polling],
      [Symbol(kCapture)]: false
    },
    encoder: Encoder { replacer: undefined },
    decoder: Decoder { reviver: undefined, _callbacks: [Object] },
    id: 'r6Uu8MHKNmvaLh1sAAAA',
    onclose: [Function: bound onclose],
    ondata: [Function: bound ondata],
    onerror: [Function: bound onerror],
    ondecoded: [Function: bound ondecoded],
    connectTimeout: undefined
  },
  recovered: false,
  data: {},
  connected: true,
  acks: Map(0) {},
  fns: [],
  flags: {},
  server: <ref *3> Server {
    _events: [Object: null prototype] {},
    _eventsCount: 0,
    _maxListeners: undefined,
    _nsps: Map(1) { '/' => [Namespace] },
    parentNsps: Map(0) {},
    parentNamespacesFromRegExp: Map(0) {},
    _path: '/socket.io',
    clientPathRegex: /^\/socket\.io\/socket\.io(\.msgpack|\.esm)?(\.min)?\.js(\.map)?(?:\?|$)/,
    _connectTimeout: 45000,
    _serveClient: true,
    _parser: {
      protocol: 5,
      PacketType: [Object],
      Encoder: [class Encoder],
      Decoder: [class Decoder extends Emitter]
    },
    encoder: Encoder { replacer: undefined },
    opts: { cleanupEmptyChildNamespaces: false },
    _adapter: [class Adapter extends EventEmitter],
    sockets: <ref *2> Namespace {
      _events: [Object: null prototype],
      _eventsCount: 1,
      _maxListeners: undefined,
      sockets: [Map],
      _fns: [],
      _ids: 0,
      server: [Circular *3],
      name: '/',
      adapter: [Adapter],
      [Symbol(kCapture)]: false
    },
    eio: Server {
      _events: [Object: null prototype],
      _eventsCount: 1,
      _maxListeners: undefined,
      middlewares: [],
      clients: [Object],
      clientsCount: 1,
      opts: [Object],
      ws: [WebSocketServer],
      [Symbol(kCapture)]: false
    },
    httpServer: Server {
      maxHeaderSize: undefined,
      insecureHTTPParser: undefined,
      requestTimeout: 300000,
      headersTimeout: 60000,
      keepAliveTimeout: 5000,
      connectionsCheckingInterval: 30000,
      _events: [Object: null prototype],
      _eventsCount: 5,
      _maxListeners: undefined,
      _connections: 2,
      _handle: [TCP],
      _usingWorkers: false,
      _workers: [],
      _unref: false,
      allowHalfOpen: true,
      pauseOnConnect: false,
      noDelay: true,
      keepAlive: false,
      keepAliveInitialDelay: 0,
      httpAllowHalfOpen: false,
      timeout: 0,
      maxHeadersCount: null,
      maxRequestsPerSocket: 0,
      _connectionKey: '6::::3000',
      [Symbol(IncomingMessage)]: [Function: IncomingMessage],
      [Symbol(ServerResponse)]: [Function: ServerResponse],
      [Symbol(kCapture)]: false,
      [Symbol(async_id_symbol)]: 10,
      [Symbol(http.server.connections)]: ConnectionsList {},
      [Symbol(http.server.connectionsCheckingInterval)]: Timeout {
        _idleTimeout: 30000,
        _idlePrev: [TimersList],
        _idleNext: [TimersList],
        _idleStart: 2183,
        _onTimeout: [Function: bound checkConnections],
        _timerArgs: undefined,
        _repeat: 30000,
        _destroyed: false,
        [Symbol(refed)]: false,
        [Symbol(kHasPrimitive)]: false,
        [Symbol(asyncId)]: 9,
        [Symbol(triggerId)]: 1
      },
      [Symbol(kUniqueHeaders)]: null
    },
    engine: Server {
      _events: [Object: null prototype],
      _eventsCount: 1,
      _maxListeners: undefined,
      middlewares: [],
      clients: [Object],
      clientsCount: 1,
      opts: [Object],
      ws: [WebSocketServer],
      [Symbol(kCapture)]: false
    },
    [Symbol(kCapture)]: false
  },
  adapter: <ref *4> Adapter {
    _events: [Object: null prototype] {},
    _eventsCount: 0,
    _maxListeners: undefined,
    nsp: <ref *2> Namespace {
      _events: [Object: null prototype],
      _eventsCount: 1,
      _maxListeners: undefined,
      sockets: [Map],
      _fns: [],
      _ids: 0,
      server: [Server],
      name: '/',
      adapter: [Circular *4],
      [Symbol(kCapture)]: false
    },
    rooms: Map(1) { 'nUn2qzIrZYmbqTBkAAAB' => [Set] },
    sids: Map(1) { 'nUn2qzIrZYmbqTBkAAAB' => [Set] },
    encoder: Encoder { replacer: undefined },
    [Symbol(kCapture)]: false
  },
  id: 'nUn2qzIrZYmbqTBkAAAB',
  handshake: {
    headers: {
      host: 'localhost:3000',
    xdomain: false,
    secure: false,
    issued: 1685217359623,
    url: '/socket.io/?EIO=4&transport=polling&t=OXUo3Rl',
    query: [Object: null prototype] {
      EIO: '4',
      transport: 'polling',
      t: 'OXUo3Rl'
    },
    auth: {}
  },
  [Symbol(kCapture)]: false
}

3. socket.io 다루기

이제 socket.io를 통해 생성된 소켓을 직접 다뤄보도록 한다. 이미 ws를 이용해서 실시간으로 메시지를 교환할 수 있는 앱을 만든 경험이 있다. socket.io를 사용할 때는 우리의 앱에 채팅룸 기능을 추가해서 같은 채팅룸에 접속한 사용자끼리만 채팅할 수 있도록 만들어 본다. 놀라운 사실은, socket.io에는 이미 채팅룸을 구분할 수 있는 기능이 내장되어 있다. 

(1) home.pug 수정하기

채팅룸에 접속할 때 채팅룸의 이름을 입력할 폼부터 만들어본다. 채팅룸 이름을 입력했을 때 만약 이미 존재하는 이름이면 거기에 참가하는 거고, 존재하지 않는 이름이면 새로운 채팅룸이 생성되면서 첫번째 참가자가 된다. 쉽게 말해 socket.io 덕분에 우리는 채팅 룸의 유무와 관계없이 채팅룸에 접속할 수 있다.

doctype html
html(lang="en")
    head
        meta(charset="UTF-8")
        meta(http-equiv="X-UA-Compatible", content="IE=edge")
        meta(name="viewport", content="width=device-width, initial-scale=1.0")
        title Noom
        link(rel="stylesheet", href="https://unpkg.com/mvp.css")
    body
        header
            h1 Noom
        main
            div#welcome
                form
                    input(placeholder="room name",required, type="text")
                    button Enter Room
            //- form#nick
            //-     input(type="text", placeholder="choose a nickname", required)
            //-     button Save
            //- ul
            //- form#message
            //-     input(type="text", placeholder="write a msg", required)
            //-     button Send 
        script(src="/socket.io/socket.io.js")
        script(src="/public/js/app.js")

폼을 만들었고, 채팅룸 이름을 입력하지 않으면 아무 일도 일어나지 않도록 필수(required) 속성을 추가했다. 

(2) 폼 가져오기

app.js에서 폼을 가져와본다. 사용자가 채팅룸 이름을 입력하면 어떻게 할지 정의한다.

const socket = io();
const welcome = document.getElementById("welcome");
const form = welcome.querySelector("form");
function handleRoomSubmit(evnet){
    event.preventDefault();
    const input = form.querySelector("input");
    socket.emit("enter_room",{payload: input.value});
    input.value = '';
}

form.addEventListener("submit", handleRoomSubmit);

일단 폼을 선택하고 handleRoomSubmit 함수를 만들었다. 맨 아랫줄에서 볼 수 있듯이 함수 폰에서 submit 이벤트가 발생하면 이벤트 핸들러 역할을 수행할 것이다. handleRoomSubmit 은 기본적으로 input에 입력된 값을 읽고 이를 소켓을 통해서버에 보내는 역할을 한다.

socket.emit 메서드는 이벤트를 발생(emit)시키는 역할을 한다. 이벤트명을 사용자가 원하는 대로 설정할 수 있다. 이벤트명을 enter_room으로 정의해두었다. (굳이 이 이름을 사용하지 않아도 된다) 단지 이벤트를 발생시킬 수 있고, 서버 쪽에서 클라가 발생시킨 이벤트의 이름만 알면 이를 처리해줄 수 있다는 사실이 중요하다.

socket.emit의 첫번째 인자로 이벤트 명을 입력하고 나면, 다음 두번째 인자는 이벤트를 통해 전송할 데이터를 써주면 된다. 그리고 이 인자는 객체가 될 수 있다. ws를 사용할 때는 문자열만 전달해야 했는데, socket.io를 사용할 때는 객체도 전달할 수 있다.

즉, socket.io를 사용함으로써 이름에 구애받지 않고 특정한 이벤트를 발생시켜줄 수 있다. 그리고 문자열이 아닌 객체를 이벤트와 함께 전송할 수 있다. 이렇게 발생시킨 이벤트는 서버쪽에서 어떻게 처리할 지 살펴볼 차례이다.

*socket.io 소켓의 emit 메서드를 사용하면, JSON 객체를 통한 문자열 변환을 하지 않아도 데이터 전송이 원활하게 이루어진다.

(3) 이벤트 핸들링 테스트하기

프론트엔드에서 socket.emit 메서드를 이용해 이벤트를 발생시키면 연결된 소켓을 통해 서버가 이를 받을 수 있다. 앞서 enter_room 이벤트가 발생되었으므로, 서버 쪽에서 여기에 대한 핸들러를 등록해 줄 차례이다.

import http from "http";
//import WebSocket from "ws";
import SocketIO from "socket.io";
import express from 'express';

const app = express();
app.set('view engine', 'pug');
app.set('views', __dirname + '/views');
app.use('/public', express.static(__dirname + '/public'));

app.get('/', (req, res) => res.render("home"));
app.get('/*', (req, res) => res.redirect("/"));

//const handleListen = ()=>console.log("Listening on 3000 port");
//const server = http.createServer(app);
const httpServer = http.createServer(app);
//const wss = new WebSocket.Server({server});
const wsServer = SocketIO(httpServer);

//const sockets = [];
wsServer.on('connection',(socket)=>{
    //console.log(socket);
    socket.on('enter_room',(roomName)=>console.log(roomName));
});
// wss.on('connection',(socket)=>{
//     sockets.push(socket);
//     console.log(socket);
//     console.log("Connected to Browser");
//     socket["nickname"] = "Anonymous";
//     socket.on('close',()=>console.log("Disconnected from Browser"));
//     socket.on('message',(msg)=>
//     {
//         const message = JSON.parse(msg);
//         console.log(message.type, message.payload);
//         //sockets.forEach(aSocket=>aSocket.send(`${message}`))
//         switch(message.type){
//             case "new_message":
//                 sockets.forEach(aSocket=>aSocket.send(`${socket.nickname}:${message.payload}`))
//                 break;
//             case "nickname":
//                 socket["nickname"] = message.payload;
//                 break; 
//         }
//     }
//     //socket.send(`${message}`)
//     );
// });
const handleListen = ()=>console.log("Listening on 3000 port");
httpServer.listen(3000, handleListen);

연결할 때 생성되는 소켓에는 on 메서드가 포함되어 있다. on은 이벤트 핸들링 메서드다. 첫번째 인자로 이벤트 이름을, 두번째 인자로 이벤트 핸들러 함수를 전달받는다.

wsServer.on('connection',(socket)=>{
    //console.log(socket);
    socket.on('enter_room',(roomName)=>console.log(roomName));
});

프론트엔드에서 enter_room 이벤트를 발생시키고 있으므로 첫번째 인자로 "enter_room"을 입력했고, 이어지는 이벤트 핸들러 함수는 매개변수를 통해 무언가를 전달받아 출력하도록 했다. 매개변수 enter_room에는 프론트엔드에서 emit 메서드를 통해 보낸 객체가 전달될 것이다.

(4) 콜백함수 추가해 전달하기

socket.emit 메서드를 통해 서버쪽에서 실행할 수 있는 콜백 함수를 넘겨줄 수도 있다.

socket.emit  메서드는 사실 데이터 개수에 제한이 없어서 원하는 만큼 전송이 가능하며, 콜백 함수를 포함할 경우 반드시 마지막 인자로 작성해주어야 한다.
const socket = io();
const welcome = document.getElementById("welcome");
const form = welcome.querySelector("form");
function handleRoomSubmit(evnet){
    event.preventDefault();
    const input = form.querySelector("input");
    //socket.emit("enter_room",{payload: input.value});
    socket.emit("enter_room",input.value,()=>{
        console.log("server is done!")
    });
    input.value = '';
}

form.addEventListener("submit", handleRoomSubmit);

socket.emit 메서드를 보면 첫번째 인자로는 이벤트명이, 두번째 인자로는 서버에 전송할 데이터가 전달되고 있다. 여기에 서번째 인자로 익명함수를 하나 추가해주었고 이것이 바로 서버에서 호출할 콜백 함수이다. 함수를 서버에서 호출할 거라고 말했지만 함수가 정의된 곳은 프런트엔드쪽이다. 웹소켓에 기반한 실시간 연결에서 socket.emit을 사용하면 이런 방식도 충분히 가능하다.

 

이벤트가 발생할 때 전달받은 콜백함수를 서버쪽에서 호출해본다.

프론트엔드에서 정의한 콜백함수가 서버 쪽에서 동작하면, 데이터베이스에 접근하거나 암호를 노출하는 등 보안 문제가 발생할 위험이 있다.

4. 채팅룸 만들기

socket.io가 제공하는 기능을 이해하고, 활용하는 방법도 감을 잡았다면 본격적으로 채팅룸을 구현해보고, 그 안에서 필요한 몇몇 기능을 추가해 채팅 앱의 완성도를 높여보도록 한다. 이미 채팅룸 이름을 입력하고 이벤트를 발생시키는 데까지는 완료했으니 나머지는 그렇게 복잡하지 않다.

(1) 채팅룸 접속하기

서로 소통할 수 있는 웹소켑 그룹별로 분리될 필요가 있다.(모든 사용자가 아닌 필요한 그룹원들만 소통할 수 있게끔)

socket.io는 채팅룸 서비스를 제공할 때 사용할 만한 유용한 기능을 제공해준다. 정확히는 꼭 채팅이 아니더라도 서버에 접속한 사용자들을 룸단위로 묶는 기능을 제공한다.

wsServer.on('connection',(socket)=>{
    //console.log(socket);
    socket.on('enter_room',(roomName, done)=>{
        console.log(roomName);
        socket.join(roomName);
        // setTimeout(()=>{
        //     done();
        // }, 5000);

    });
});
import http from "http";
//import WebSocket from "ws";
import SocketIO from "socket.io";
import express from 'express';

const app = express();
app.set('view engine', 'pug');
app.set('views', __dirname + '/views');
app.use('/public', express.static(__dirname + '/public'));

app.get('/', (req, res) => res.render("home"));
app.get('/*', (req, res) => res.redirect("/"));

//const handleListen = ()=>console.log("Listening on 3000 port");
//const server = http.createServer(app);
const httpServer = http.createServer(app);
//const wss = new WebSocket.Server({server});
const wsServer = SocketIO(httpServer);

//const sockets = [];
wsServer.on('connection',(socket)=>{
    //console.log(socket);
    socket.on('enter_room',(roomName, done)=>{
        console.log(roomName);
        socket.join(roomName);
        // setTimeout(()=>{
        //     done();
        // }, 5000);

    });
});

// wss.on('connection',(socket)=>{
//     sockets.push(socket);
//     console.log(socket);
//     console.log("Connected to Browser");
//     socket["nickname"] = "Anonymous";
//     socket.on('close',()=>console.log("Disconnected from Browser"));
//     socket.on('message',(msg)=>
//     {
//         const message = JSON.parse(msg);
//         console.log(message.type, message.payload);
//         //sockets.forEach(aSocket=>aSocket.send(`${message}`))
//         switch(message.type){
//             case "new_message":
//                 sockets.forEach(aSocket=>aSocket.send(`${socket.nickname}:${message.payload}`))
//                 break;
//             case "nickname":
//                 socket["nickname"] = message.payload;
//                 break; 
//         }
//     }
//     //socket.send(`${message}`)
//     );
// });
const handleListen = ()=>console.log("Listening on 3000 port");
httpServer.listen(3000, handleListen);

socket.io에서 채팅룸에 접속하려면 이렇게 join 메서드만 이용하면 된다. 여기에 접속할 채팅룸 이름만 적어주면, 사용자는 채팅룸에 접속하게 된다. 

(2) 채팅룸 확인하기

socket.io에서 서버에 연결된 개별 소켓은 다양한 속성을 포함하고 있다. 현재 server.js에서 socket.on() 메서드만 호출하지만, 이게 다가 아니다. 몇가지 속성을 추가해서 채팅룸 기능이 어떻게 동작하는지 확인해보도록 한다. 

몇가지 속성을 추가해서 채팅룸 기능의 동작을 확인해보도록 한다.

wsServer.on('connection',(socket)=>{
    //console.log(socket);
    socket.on('enter_room',(roomName, done)=>{
        console.log(roomName);
        console.log(socket.id);
        console.log(socket.rooms);
        socket.join(roomName);
        console.log(socket.rooms);
        // setTimeout(()=>{
        //     done();
        // }, 5000);

    });
});

join 전 후의 출력이 다르다

소켓의 식별자 연할을 하는 id 속성은 해당 소켓만의 고유한 값이다. 사용자 여러 명이 서버에 접속해 소켓을 형성해도 서로 구별할 수 있다. rooms 속성은 소켓이 현재 어떤 룸에 있는지를 나타내는데, 소켓이 접속한 방은 하나가 아닐 수도 있다(그래서 이름이 rooms) 

 

join을 이용해 채팅룸에 참가하기 전의 rooms와 참가한 후의 rooms를 비교해본다. 참가하기 전의 rooms에는 소켓 id와 동일한 값만 포함되었고, 참가한 후의 rooms에는 입력값이 함께 추가되어 있다.

socket.io에는 join을 이용해 어딘가에 접속하지 않더라도, 개별 소켓은 서버에서 제공하는 개인 공간에 들어가 있는 상태이다. 이러한 개인 공간은 소켓과 서버 사이에 형성된 채팅룸이라고 할 수 있다. 이를 프라이빗 룸이라고 하는데 소켓의 id는 소켓의 프라이빗룸 id와 같다.

 

맨 처음 접속할 떄 프라이빗룸에만 머무르던 소켓이 join을 이용하면 다른 소켓과 그룹을 형성해 채팅룸을 만들 수 있는데, join에는 룸 이름이 전달된다. 이때 전달된 이름이 만약 서버에 이미 존재하면 소켓은 그 방에 합류하고, 서버에 존재하지 않으면 방이 새롭게 만들어진다. 

* https://socket.io/docs/v4/server-api/#socket 

 

Server API | Socket.IO

Server

socket.io

(3) home.pug 수정하기

채팅룸을 구별할 수 있게 되었으므로, 이번에는 같은 채팅룸에 접속한 사용자들끼리만 메시지를 주고받을 수 있게 만들어본다. 이때에도 socket.io가 제공하는 메서드를 활용할 것이다. 가장 먼저 hone.pug 수정해 메시지 입력 폼부터 추가한다. 지금은 룸 이름을 입력하는 폼밖에 없기 때문이다.

 

(4) app.js 수정하기

메시지를 입력할 수 있는 폼을 추가했는데, 처음에는 이 폼을 숨겨야 한다. 처음에는 룸 이름 폼만 보이는 상태였다가, 룸 이름을 이용해 채팅룸에 접속하고 나면 반대로 룸 이름 폼이 사라지고 메시지 폼도 보이도록 app.js를 수정해본다.

const socket = io();
const welcome = document.getElementById("welcome");
const form = welcome.querySelector("form");
const room = welcome.getElementById("room");

room.hidden = true;

function showroom(){
    welcome.hidden = true;
    room.hidden = false;
}

function handleRoomSubmit(evnet){
    event.preventDefault();
    const input = form.querySelector("input");
    //socket.emit("enter_room",{payload: input.value});
    // socket.emit("enter_room",input.value,()=>{
    //     console.log("server is done!")
    // });
    socket.emit("enter_room",input.value,showroom);
    input.value = '';
}

form.addEventListener("submit", handleRoomSubmit);

웹 요소의 hidden 속성을 이용해서 화면에서의 노출 여부를 결정해주었다.

서버에서 showRoom 함수를 호출할 수 있도록 아래와 같이 변경한다.

wsServer.on('connection',(socket)=>{
    //console.log(socket);
    socket.on('enter_room',(roomName, done)=>{
        done();
        console.log(roomName);
        console.log(socket.id);
        console.log(socket.rooms);
        socket.join(roomName);
        console.log(socket.rooms);
        // setTimeout(()=>{
        //     done();
        // }, 5000);

    });
});

(5) 채팅룸 이름 표시하기

이제 방으로 들어갔으니 채팅룸 이름을 화면에 표시해보도록 한다.

doctype html
html(lang="en")
    head
        meta(charset="UTF-8")
        meta(http-equiv="X-UA-Compatible", content="IE=edge")
        meta(name="viewport", content="width=device-width, initial-scale=1.0")
        title Noom
        link(rel="stylesheet", href="https://unpkg.com/mvp.css")
    body
        header
            h1 Noom
        main
            div#welcome
                form
                    input(placeholder="room name",required, type="text")
                    button Enter Room
            div#room
                h3
                ul
                form
                    input(placeholder="message", required, type="text")
                    button Send
            //- form#nick
            //-     input(type="text", placeholder="choose a nickname", required)
            //-     button Save
            //- ul
            //- form#message
            //-     input(type="text", placeholder="write a msg", required)
            //-     button Send 
        script(src="/socket.io/socket.io.js")
        script(src="/public/js/app.js")
const socket = io();
const welcome = document.getElementById("welcome");
const form = welcome.querySelector("form");
const room = document.getElementById("room");

room.hidden = true;
let roomName;

function showroom(){
    welcome.hidden = true;
    room.hidden = false;
    const h3 = room.querySelector("h3");
    h3.innerText = `Room ${roomName}`;
}

function handleRoomSubmit(evnet){
    event.preventDefault();
    const input = form.querySelector("input");
    //socket.emit("enter_room",{payload: input.value});
    // socket.emit("enter_room",input.value,()=>{
    //     console.log("server is done!")
    // });
    socket.emit("enter_room",input.value,showroom);
    roomName = input.value;
    input.value = '';
}

form.addEventListener("submit", handleRoomSubmit);

5. 채팅룸 안에서 메시지 교환하기

채팅룸에서는 역시 채팅을 할 수 있어야 한다. socket.io는 소켓 그룹을 형성할 수 있는 룸 기능을 제공하는데 그치지 않고 메시지 전송 기능도 제공해준다.

(1) to 메서드

emit 메서드를 이용해 이벤트를 발생시켜 보았는데, 이 이벤트는 접속하고 싶은, 혹은 만들고 싶은 채팅룸 이름을 입력했을 때 발생하는 것이었다. 이벤트명은 enter_room. 여기서 중요한 점은, emit 메서드를 이용하면 이벤트의 이름과 전달한 데이터를 모두 원하는 형식으로 지정할 수 있다는 점이다.

메시지를 전달하려면 앞서 해보았던 것과 마찬가지로 메시지를 전달하는 이벤트를 발생시키면 된다.그런데 이제부터는 앞서 입력한 메시지를 서버에 있는 모든 사용자 소켓에 전달하고 싶은게 아니라, 접속해 있는 채팅룸 안의 사용자들에게만 전달하고 싶은 것이다. 바로 각 소켓에 할당된 to 메서드를 쓰면 된다.

 

따라서 server.js를 아래와 같이 수정한다. to 메서드는 이벤트를 통해 전달하고 싶은 대상을 지정할 수 있게 해준다. 대상은 특정 채팅룸이 될 수도 있고 특정 소켓이 될 수도 있다.각 소켓마다 id를 가지고 있었다. to 메서드의 기능을 파악하기 위해 같은 채팅룸에 참가한 사용자들에게 단순히 이벤트만 발생시키는 코드는 아래와 같다.

const httpServer = http.createServer(app);
//const wss = new WebSocket.Server({server});
const wsServer = SocketIO(httpServer);

//const sockets = [];
wsServer.on('connection',(socket)=>{
    //console.log(socket);
    socket.on('enter_room',(roomName, done)=>{
        done();
        //console.log(roomName);
        //console.log(socket.id);
        //console.log(socket.rooms);
        socket.join(roomName);
        socket.to(roomName).emit("welcome");
        //console.log(socket.rooms);
        // setTimeout(()=>{
        //     done();
        // }, 5000);

    });
});

to 메서드를 이용해서 소켓(socket)이 join 메서드로 접속한 채팅룸(roomName)을 대상으로 지정했다. 이어서 emit 메서드를 호출하면, 해당 채팅룸에 참가한 소켓들에 대해서만 이벤트가 발생하게된다. 이벤트명은 welcome으로 정했다.

 

(2) 프런트엔드 welcome 이벤트 반응

이제 프런트엔드가 welcome 이벤트에 반응하도록 만든다. 

이미 방이 존재하는 상황에서 새로운 사용자 소켓이 참가해서 welcome 이벤트가 발생한 것이다. 1234라는 이름이 이미 존재하고 있었기에 가능한 일이다. 이러한 기능을 토대로 앞서 직접 입력한 메시지도 채팅룸 안의 사용자들에게 얼마든지 전달할 수 있다.