Socket I.O

merci·2023년 7월 9일
0

nodejs

목록 보기
2/3
post-thumbnail

Socket I.O

Socket I.O는 웹소켓을 기반으로 한 실시간 통신을 지원하는 라이브러리입니다.
Socket I.O는 JavaScript 라이브러리로 시작되었지만, 추가적인 기능과 구조를 제공하여 프레임워크적인 특징을 가지고 있습니다.
Socket I.O는 실시간, 양방향, 이벤트 통신등을 지원합니다.

Socket I.O가 이러한 기능들을 제공하는데 웹소켓을 이용할뿐 웹소켓이 아닙니다.
왜냐하면 클라이언트가 웹소켓을 지원하지 않으면 다른 기능을 선택해 지원하기 때문입니다. ( ex. polling )
FireWall, Proxy등이 있더라도 Socket I.O 는 통신 기능을 지원해줍니다.
또한 연결이 끊기더라도 스스로 재연결을 하려고 시도합니다.
하지만 여러기능을 지원하므로 ws라이브러리에 비해 무겁습니다.

이러한 특징을 가진 Socket I.O를 이용해서 채팅기능을 구현해봅시다.

먼저 설치를 해줍니다.

npm i socket.io

그리고 이전 포스팅 처럼 server.js에서 http서버를 이용해 SocketIO 서버를 만들겠습니다.

Server

SocketIO 서버를 만드는 기본적인 방법은 아래와 같습니다.

import { Server } from "socket.io";

const io = new Server(3000, {
  // options
});

io.on("connection", (socket) => {
  // ...
});

여기에 들어가는 옵션들은 https://socket.io/docs/v4/server-options/ 을 참고합니다.

그전에 만들어둔 서버가 있으므로 아래코드로 재사용 하도록 하겠습니다.

import SocketIO from "socket.io";
import http from "http";

const server = http.createServer(app);
const io = SocketIO(server);

// 다른 코드 삭제

server.listen(3000, handleListen);

또는 아래 코드를 이용할 수도 있습니다.

const httpServer = http.createServer(app);
const io = new Server();

io.attach(httpServer);

그리고 서버를 실행한뒤에 localhost:3000/socket.io/socket.io.js 로 들어가면 SocketIO가 지원하는 자바스크릡트 코드를 볼 수 있습니다.

SocketIO는 웹소켓만 있는것이 아니므로 브라우저가 이해하는 여러기능들을 자바스크립트 코드를 전달해 구현하게 됩니다.
클라이언트로 전달되는 app.js에서 위 URL을 import해서 클라이언트가 SocketIO의 자바스크립트 코드를 사용하도록 하면 통신이 구협됩니다.

	script(src="/socket.io/socket.io.js")

그리고 server.js 에서 io 커넥션을 만들어주고 나서 브라우저 콘솔에서 io를 입력하고 해당 함수를 확인할 수 있습니다.

const server = http.createServer(app);
const io = SocketIO(server);

io.on('connection', (socket) => {
    console.log(socket);
});

server.listen(3000, handleListen);

불러온것을 확인하면 app.js에서는 아래코드로 io함수를 불러와 사용할 수 있게 됩니다.

    const socket = io();

서버를 실행해보면 서버 콘솔에 아래의 내용들이 나오게 됩니다.

ws 라이브러리와 다른점은 Socket오브젝트와 WebSocket오브젝트의 차이입니다.
( 아래는 ws )


Socket

SocketIO 에서 Socket은 클라이언트와 상호작용하기 위한 기본 클래스 입니다.
Socket은 namespace에 속해 ( 기본값 / ) 클라이언트와 통신합니다.
Socket은 connect 또는 connection 이벤트를 듣고 새로운 소켓 객체를 생성하여 클라이언트를 서버와 연결시킵니다.
소켓 객체에는 emit, on, id, rooms등의 메소드가 있습니다.


socket.emit()

이번에는 채팅방을 만들어서 참가를 한 후에 채팅을 할 수 있도록 만들어 보겠습니다.
SocketIO에서는 이러한 room의 기능이 있으므로 활용해 보겠습니다.

먼저 간단한 화면을 만듭니다.

      main
          div#welcom
              form
                  input(placeholder="room name", required, type="text")
                  button Enter Room


이제 버튼을 눌렀을때의 코드를 작성합니다.

const socket = io();

const welcome = document.querySelector('welcome');
const form = document.querySelector('form');

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

form.addEventListener('submit', handleRoomSubmit);

SocketIO에서는 socket.emit() 함수를 이용합니다.
ws라이브러리에서 사용했던 socket.send()함수는 메세지를 전달할 수 있었습니다.
이를 위해서 간단한 텍스트부터 여러 데이터는 오브젝트안에 넣어서 json데이터로 변환 후 보냈습니다.

하지만 SocketIO를 사용한다면 string으로 변환해서 보내지 않고 바로 오브젝트를 보낼 수가 있습니다.
emit함수에는 1번째 argument로 특정한 이벤트 요소와, 2번째 argument로 보내고 싶은 오브젝트를 넣어 데이터를 보냅니다.
여기서 커스텀한 이벤트의 이름으로 enter_room을 이용한다면 서버 코드에서도 동일한 이벤트를 받아서 처리하면 됩니다.

const server = http.createServer(app);
const io = SocketIO(server);

io.on('connection', (socket) => {
    socket.on('enter_room', (msg) => console.log(msg)); // 커스텀 이벤트
});

on 함수로 특정 이벤트를 듣습니다.

다음과 같이 입력하면 서버로 오브젝트 데이터가 날라가게 됩니다.


이러한 특징으로 SocketIO를 이용하면 ws라이브러리와는 다르게
socket.on('message', ~ 처럼 message 이벤트만 받을 수 있는게 아니라
socket.on('enter_room' ~ 처럼 개발자가 작성한 다양한 커스텀 이벤트를 받을 수 있게 됩니다.

콜백

또한 emit() 함수는 마지막 argument자리에 콜백함수를 넣어서 처리할 수가 있습니다.

    socket.emit('enter_room', { payload: input.value }, 
        () => console.log('sever callback'));

그리고 서버 코드에서 마지막 argument를 임의로 지정한 뒤에 실행시키면 됩니다.

io.on('connection', (socket) => {
    socket.on('enter_room', (msg, done) => {
        console.log(msg);
        done();
    });
});

그리고 브라우저에서 Enter Room 버튼을 누르게 되면 콜백함수가 실행되어 콘솔에 내용이 출력됩니다.

emit() 함수는 백엔드에서 내용을 전달할 수도 있습니다

done('good');
function backendDone(msg){
    console.log(msg);
}

socket.emit('enter_room', { payload: input.value }, backendDone);

emit() 함수를 정확히 말하면 emit() 함수의 첫번째 argument인 이벤트 이름을 제외한 2번째 argument 부터 연결된 서버 코드의 on()함수의 두번째 argument 함수가 받는 argument가 연결됩니다.

    socket.emit('enter_room', 
        { 1}, 
        2(),
        3...
        );

    socket.on('enter_room', (1, 2, 3, ...) => {
        console.log(1, 3);
        2();
      	...
    });

따라서 3개로 제한된것이 아니라 개발자가 원하는만큼 연결시킬수가 있습니다.
중요한 점은 콜백 함수의 위치는 각 argument의 마지막 자리에 위치해야 한다는 것입니다.
그리고 콜백 함수를 실행 시키라고 프론트에 전달할뿐 백엔드에서는 함수가 실행되지 않습니다.


room

A와 B가 양방향 통신을 한다고 했을때 둘만을 위한 room이 필요하게 됩니다.
이러한 room은 여러 소켓들을 그룹화하는 개념이고 서버에서는 room을 이용해서 필요한 메세지만 전달해 리소스를 절약합니다.

이러한 room은 채팅룸 이외에도 서로의 위치를 볼 수 있는 앱이나, 게임을 진행하는 방같은 곳에서도 사용됩니다.
SocketIO는 이러한 room을 기본적으로 제공합니다.

소켓에는 id가 있고 socket.id를 통해 id를 알 수 있습니다.
아래 코드에서는 socket.join을 이용해서 room을 생성합니다.
그리고 socket.rooms를 이용하면 소켓이 어떤 room에 있는지 알 수 있습니다.

io.on("connection", (socket) => {
  console.log(socket.rooms); // Set {  <socket.id> }
  
  socket.join("room 237"); 
  
  console.log(socket.rooms); // Set { <socket.id>, "room 237" }

  socket.join(["room 237", "room 238"]);

  socket.to("room 237").emit("a new user has joined the room"); // broadcast to everyone in the room
  
  socket.leave("room 237");

  // to one room
  socket.to("others").emit("an event", { some: "data" });

  // to multiple rooms
  socket.to("room1").to("room2").emit("hello");

  // or with an array
  socket.to(["room1", "room2"]).emit("hello");

  // a private message to another socket
  socket.to(/* another socket id */).emit("hey");
  
  socket.to("room 237").emit(`user ${socket.id} has left the room`);
});

to 함수를 이용하면 room에 있는 모든 클라이언트에게 데이터를 전달합니다.
leave 함수를 이용해 room에서 나갈 수 있습니다.

계속 작성중인 코드를 수정해보면

io.on('connection', (socket) => {
    socket.on('enter_room', (roomName, done) => {
        console.log(socket.rooms);
        socket.join(roomName);
        console.log(socket.rooms);
        done('good');
    });
});

브라우저에서 room 2 를 입력했을때 아래의 정보들을 확인할 수 있습니다.

콘솔을 보면 join 함수를 실행하기 전에 이미 임의로 생성된 room에 소켓이 들어가 있는것을 알 수 있습니다.
그리고 Set(2)의 결과는 소켓이 2개의 room에 들어가 있습니다.
app.js 를 수정해서 실제로 12번 room으로 입장한다면 보기가 더 좋겠네요.

추가적으로 onAny 함수를 이용하면 특정한 이벤트를 로깅할 수 있습니다.

socket.onAny((event, ...args) => {
  console.log(`got ${event}`);
});

그러면 이제 room에 참가하면 room form을 제거하도록 만들겠습니다.

      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

app.js 에서도 몇 가지를 수정합니다.

const welcome = document.getElementById('welcome');
const form = document.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(event){
    event.preventDefault();
    const input = form.querySelector('input');
    socket.emit('enter_room', input.value, showRoom); 
    roomName = input.value;
    input.value = "";
}

서버에서도 콜백함수를 실행시킵니다.

    socket.on('enter_room', (roomName, done) => {
        socket.join(roomName);
        socket.to(roomName).emit(`user ${socket.id} has left the room`);
        done(); 
    }); 

room 에 입장하면 메세지를 보낼수 있는 form 을 불러옵니다.

메세지 전송

먼저 서버에서 이벤트를 발생시킵니다.

    done(); // showRoom
    socket.to(roomName).emit("welcome");

app.js 에서 이벤트리스너를 만듭니다.

function addMessage(msg){
    const ul = room.querySelector('ul');
    const li = document.createElement('li');
    li.innerText = msg;
    ul.appendChild(li);
}

socket.on("welcome", () => {
    addMessage("Someone Joined !");
});

누군가가 같은 room에 들어온다면 태그가 추가됩니다.

참가 메세지 말고 퇴장 메세지를 추가한다면

    socket.on("disconnecting", () => {
        socket.room.forEach(room => {
            socket.to(room).emit('bye') // set 이므로 forEach 가능
        });
    });

disconnecting 은 연결을 끊지 직전, disconnect 는 끊어진 후 입니다.

socket.on("bye", () => {
    addMessage("Someone left !");
});

방에서 나간다면 아래의 메세지가 출력됩니다.

이번에는 메세지를 입력해 join한 room에 메세지를 전달해보겠습니다.
showRoom 함수를 수정해서 room에 입장시 메세지 전송 리스너를 추가합니다.

function showRoom(){
    welcome.hidden = true;
    room.hidden = false;
    const h3 = room.querySelector('h3');
    h3.innerText = `Room - ${roomName}`;
    const form = room.querySelector('form');
    // 이벤트 리스너 추가
    form.addEventListener('submit', handleMessageSubmit);
}

메세지를 보낼때 사용할 handleMessageSubmit 함수를 만듭니다.

function handleMessageSubmit(event){
    event.preventDefault();
    const input = room.querySelector('input');
    const value = input.value; 
    // join한 room에만 메세지 전송
    socket.emit('new_msg', input.value, roomName, () => {
        addMessage(`You : ${value}`); // 내가 작성한 메세지 렌더링
    });
    input.value = "";
}

//

socket.on("new_msg", (msg) => {
    addMessage(msg);
})

서버에서는 new_msg 이벤트를 듣고 room에 메세지를 전송합니다.

    socket.on('new_msg', (msg, room, done) => {
        socket.to(room).emit("new_msg", msg);
        done();
    });

콜백함수를 실행했을 때 input.value는 "" 인 상태이므로 그 전에 값을 value에 저장해 두었다가 사용합니다.

닉네임 변경

이번에도 랜덤한 닉네임을 부여한 뒤에 닉네임을 변경해보도록 하겠습니다.

      div#room
          h3
          ul 
          form#name
              input(name="nickname", placeholder="nickname", required, type="text")
              button Save
          form#msg
              input(placeholder="message", required, type="text")
              button Send

pug 를 수정한 뒤에 app.js도 수정합니다.

function handleNickNameSubmit(event){
    event.preventDefault();
    const input = room.querySelector('#name input');
    socket.emit('nickname', input.value);
}

function showRoom(){
    welcome.hidden = true;
    room.hidden = false;
    const h3 = room.querySelector('h3');
    h3.innerText = `Room - ${roomName}`;
    const msgForm = room.querySelector('#msg');
    const nameForm = room.querySelector('#name');
    // 이벤트 리스너 추가
    msgForm.addEventListener('submit', handleMessageSubmit);
    nameForm.addEventListener('submit', handleNickNameSubmit);
    const nicknameInput = nameForm.elements['nickname'];
    nicknameInput.value = "";
}

socket.on("welcome", (user) => {
    addMessage(`${user} Joined!`);
});

socket.on("bye", (user) => {
    addMessage(`${user} left !`);
});

각자의 버튼에 이벤트 리스너를 붙이고 nickname 이벤트를 발생시켜서 사용자의 닉네임을 서버로 전달합니다.

import { v4 as uuidv4 } from 'uuid';

io.on('connection', (socket) => {
    const randomUUID = uuidv4();
    const shortenedUuid = randomUUID.replace(/-/g, '').substring(0, 12); // '-'문자 제거 후 
    socket['nickname'] = `User-${shortenedUuid}`;
  
    socket.on('enter_room', (roomName, done) => {
        socket.join(roomName);
        done(); // showRoom
        socket.to(roomName).emit("welcome", socket.nickname); 
    }); 
    socket.on("disconnecting", () => {
        socket.rooms.forEach(room => { // set 이므로 forEach 가능
            socket.to(room).emit('bye', socket.nickname) 
        });
    });
    socket.on("new_msg", (msg, room, done) => {
        console.log(`${socket.nickname}`);
        socket.to(room).emit("new_msg", `${socket.nickname} : ${msg}`);
        done();
    });
    socket.on('nickname', (nickname) => {
        socket['nickname'] = nickname; // socket의 nickname 프로퍼티 설정
    })
});

서버에서는 nickname 이벤트를 듣고 socket의 nickname 프로퍼티를 수정합니다.
그리고 서버에서 보내는 모든 데이터는 nickname 정보를 포함합니다.

이외에도 io.socketsJoin 함수를 이용해서 강제로 공지사항이나 그룹채팅 방을 만들어 초대할 수도 있습니다.

// make all Socket instances join the "room1" room
io.socketsJoin("room1");

// make all Socket instances in the "room1" room join the "room2" and "room3" rooms
io.in("room1").socketsJoin(["room2", "room3"]);

// this also works with a single socket ID
io.in(theSocketId).socketsJoin("room1");


Adapter

기본적으로 SocketIO는 각 서버 프로세스에 연결된 클라이언트의 목록을 메모리에 저장합니다.
각 서버에 in memory Adapter를 사용하고 서버 종료시 데이터는 사라집니다.
작은 규모의 서버가 메모리를 공유하는 구조일경우에는 잘 작동하지만 큰 규모에서 상태를 공유하는 구조일 경우 이러한 메모리 저장방식은 에러사항이 발생합니다.
( 예를 들어 여러 서버에 분산된 room에 메세지를 보내는 경우 )

이러한 경우 Adapter는 서버간 정보를 공유하고 모든 서버가 동일한 상태를 가지도록 돕습니다.
SocketIO에서는 이러한 Adapter 기능을 제공하기 위해 socket.io-redis 같은 기능을 제공합니다.
따라서 Adapter는 서버 클러스터 간의 상태를 공유하고 동기화하는 역할을 수행합니다.

redis를 이용한다면 아래의 구조의 형태이고

mongoDB를 이용한다면 아래의 구조를 가집니다.

이번 포스팅에서는 in memory Adapter만 사용해서 room과 id의 정보들을 가져오도록 하겠습니다.

어뎁터 정보를 보기 위해서 아래 코드를 추가해서 확인할 수 있습니다.

	console.log(io.sockets.adapter);


여기서는 각 소켓들이 연결된 room 정보와 id 정보를 확인할 수 있습니다. ( sids = socket id )

오픈 채팅 목록

아래 코드의 조건이 맞다면 콘솔에 출력되는 map의 key는 public room의 key가 됩니다.

rooms.forEach((key, _) => {
  if(sids.get(key) === undefined){
   	 console.log(key)
  }
})

따라서 다음 함수를 이용한다면 public room의 정보를 얻을 수 있습니다.

function publicRooms() {
    const { sockets: { adapter: { sids, rooms } } } = io;
    const publicRooms = [];
    rooms.forEach((_, key) => {
        if (sids.get(key) === undefined) { 
            publicRooms.push(key);
        }
    })
    return publicRooms;
}

이 함수를 이용해서 현재 생성되어 있는 public room을 볼 수 있게 만들어 봅시다

form 밑에 오픈된 채팅목록을 추가하도록 ul 태그를 추가합니다.

      div#welcome
          form
              input(placeholder="room name", required, type="text")
              button Enter Room
          h4 Open Rooms

그리고 서버에서 enter_room 이벤트와 disconnect 이벤트를 만들고 아래 코드를 추가합니다.
io.sockets.emit은 모든 연결된 소켓에 통신을 보냅니다.

    socket.on('enter_room', (roomName, done) => {
        socket.join(roomName);
        done(); 
        socket.to(roomName).emit("welcome", socket.nickname);
        io.sockets.emit('room_change', publicRooms());
    }); 
    socket.on("disconnect", () => {
        io.sockets.emit('room_change', publicRooms());
    }); 

app.js 에서 public room의 정보를 보여주기 위해서 아래 코드를 추가합니다.

socket.on("room_change", (rooms) => {
    const roomList = welcome.querySelector('ul');
    roomList.innerHTML = ""; // 매번 새롭게 렌더링
    if( rooms.length === 0 ){
        return;
    }
    rooms.forEach(room => {
        const li = document.createElement('li');
        li.style.listStyleType = "none";
        li.innerText = room;
        roomList.append(li);
    });
});

참가 인원 표시

room에 몇명이 있는지 표시를 해보겠습니다.

adapter에 접근해서 set의 size를 반환하는 함수를 만든 뒤 각 emit 함수에 추가해줍니다.

function roomCount(roomName){
    return io.sockets.adapter.rooms.get(roomName)?.size; // rooms 는 map, 내부는 set
}
    
  socket.to(roomName).emit("welcome", socket.nickname, roomCount(roomName)); 
  
  socket.to(room).emit('bye', socket.nickname, roomCount(room) - 1)

app.js 에서 카운트를 받아서 화면에 표시합니다

function showCount(count) {
    const h3 = room.querySelector('h3');
    h3.innerText = `Room - ${roomName} (${count})`;
}

socket.on("welcome", (user, newCount) => {
    addMessage(`${user} Joined!`);
    showCount(newCount)
});

socket.on("bye", (user, newCount) => {
    addMessage(`${user} left !`);
    showCount(newCount)
});


Admin UI

socketIO를 사용하는 백엔드를 위한 Admin UI가 socketIO v4.0.0에서 추가되었습니다.

Admin UI는 다음과 같은 기능들을 제공합니다.

  • 현재 연결된 소켓의 수
  • 각 네임스페이스에 대한 정보
  • 각 방(room)에 대한 정보
  • 각 소켓에 대한 정보 (연결된 시간, 연결된 방 등)

설정을 한다음 http://localhost:3000/admin/ 같은 주소로 들어가면 Admin UI를 사용할 수 있습니다.

먼저 설치부터 하겠습니다.

npm i @socket.io/admin-ui

공식문서를 차용해서 기존 코드를 수정하겠습니다.

import express from "express";
import http from "http";
import { Server } from "socket.io";
const { instrument } = require("@socket.io/admin-ui");

const app = express();

const server = http.createServer(app);

const io = new Server(server, {
    cors: {
        origin: ["https://admin.socket.io"],
        credentials: true
    },
});

instrument(io, {
  auth: false,
  mode: "development",
});

httpServer.listen(3000);

그런 다음 사이트로 가서 테스트 해볼 수 있습니다.
https://admin.socket.io/


래퍼런스 : https://socket.io/docs/v4

profile
작은것부터

0개의 댓글