Zoom Clone Coding

Adapter을 이용해서 server에 존재하는 room 개수를 구하고, room에 접속해있는 client 개수를 구할 수 있다. 또한 socket.io 에서는 admin-ui panel을 제공해서 전체적인 server, client을 관리할 수 있다.

javascript의 navigator.MediaDevices을 이용하면 현재 브라우져 연결된 카메라, 오디오 장비의 현황을 추출할 수 있다. 이를 통해 직접적인 장비의 조작이 가능하다.

Adapter

adapter Adapter은 serverside에 동작하면서 server와 client간에 발생하는 event를 관장한다.

//io.sockets.adapter 객체의 일부분이다.
 rooms: Map(4) {
    'dphIvduRoqcRPamkAAAF' => Set(1) { 'dphIvduRoqcRPamkAAAF' },
    '8AjVVPxb8XCZtKogAAAH' => Set(1) { '8AjVVPxb8XCZtKogAAAH' },
    'UxuOM5WuRuVonnPNAAAJ' => Set(1) { 'UxuOM5WuRuVonnPNAAAJ' },
    '123' => Set(1) { '8AjVVPxb8XCZtKogAAAH' }
  },
  sids: Map(3) {
    'dphIvduRoqcRPamkAAAF' => Set(1) { 'dphIvduRoqcRPamkAAAF' },
    '8AjVVPxb8XCZtKogAAAH' => Set(2) { '8AjVVPxb8XCZtKogAAAH', '123' },
    'UxuOM5WuRuVonnPNAAAJ' => Set(1) { 'UxuOM5WuRuVonnPNAAAJ' }
  }

위와 같이 해당 서버에 연결된 socket의 id를 저장하는 sids나 room을 저장하는 rooms가 있다. 그래서, adapter을 이용하면 서버 내에 연결된 방의 개수나, 방 안에 존재하는 유저 수를 구할 수 있다.

Room Count

const io=SocketIO();
io.socket.adapter.rooms

rooms 객체의 entry 개수를 구하면 된다.

server.js

function publicRooms(){
    /* 
    const sids=io.sockets.adapter.sids;
    const rooms=io.sockets.adapter.rooms; 
    */
    const {
        sockets:{
            adapter:{
                sids,rooms
            }
        }
    }=io;
    //public room만을 가져오기 위해 sid에 등록된 id값과 같은 room id는 제외시킨다.
    const public_rooms={};
    rooms.forEach((_,key)=>{
        if(sids.get(key) === undefined){
            public_rooms[key]=rooms.get(key)?.size;
        }
    })
    return public_rooms;
}

추가로, socket ID 별로 서버와 client간에 private 연결이 유지되고 있다 따라서, rooms 객체를 보면 sids에 등록된 ID에 해당하는 값들이 rooms에도 포함되어 있음을 확인 할 수 있다. 따라서 이러한 private room은 제거하고 나머지 방들에 대한 정보를 가져와야 한다.

User Count

rooms: Map(4) {
    'dphIvduRoqcRPamkAAAF' => Set(1) { 'dphIvduRoqcRPamkAAAF' },
    '8AjVVPxb8XCZtKogAAAH' => Set(1) { '8AjVVPxb8XCZtKogAAAH' },
    'UxuOM5WuRuVonnPNAAAJ' => Set(1) { 'UxuOM5WuRuVonnPNAAAJ' },
    '123' => Set(1) { '8AjVVPxb8XCZtKogAAAH' }
  },

또한, rooms 객체를 보면 아래와 같이 key 에 대한 value로 SET가 등록되어 있는데, 이는 해당 room에 접속된 client 집단을 의미한다. 이를 이용하면 room 내부에 user수를 쉽게 구할 수 있다.

server.js

function countRoom(roomName){
    return io.sockets.adapter.rooms.get(roomName)?.size;
}

추가적으로 생각해야되는 부분은 user가 접속하고, 나갈때의 변화(event)를 감지해서 room 정보를 변경시켜줘야한다.

server.js

socket.to(roomName).emit("welcome",socket.nickname,countRoom(roomName));

socket.on("disconnecting",()=>{
            socket.rooms.forEach(room => socket.to(room).emit("bye",socket.nickname,countRoom(room)-1));
            }       
        );

유저가 들어오고 나가게 되면 해당 방에 접속되어 있는 유저들에게 변경된 방의 정보를 넘겨줘야한다.

socket.emit("room_change",publicRooms())
io.sockets.emit("room_change",publicRooms());
socket.on("disconnect",()=>io.sockets.emit("room_change",publicRooms()));

유저가 처음 접속했을 때는 server내에 있는 room list을 볼 수 있어야 한다.

app.js

socket.on("room_change",(rooms)=> { 
    const roomList=welcome.querySelector("ul");
    roomList.innerHTML="";

    if(Object.keys(rooms).length ===0){
        return;
    }
    for(let [roomName,count] of Object.entries(rooms)){
        const li=document.createElement("li");
        li.innerText=`Room ${roomName} (${count})`;
        roomList.appendChild(li);
        };
    }

또한 room 객체를 전달 받은 front 에서는 위와 같이 room 들을 list에 등록해주는 작업을 한다.

Admin-ui

socket.io 에서는 server에 등록된 socket, client에 대한 관리를 할 수 있는 관리페이지를 제공해준다.

  1. 이를 사용하기 위해 우선 @socket.io/admin-ui를 node로 설치해준다.
npm install @socket.io/admin-ui
  1. server에서 필요한 모듈들을 import 해준다.

    server.js

import express from "express";
import http from "http";
//admin-ui를 사용하기 위해 import 방식을 바꿔준다.
import {Server} from "socket.io";
import {instrument} from "@socket.io/admin-ui"
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 http://localhost:3000`);
//app.listen(3000,handleListen);

//http server => to get access to server
const server=http.createServer(app);

//아래와 같이 서버 등록 방식을 새롭게 한다.
const io=new Server(server,{
    cors:{
        origin:["https://admin.socket.io"],
        credentials:true,
    }
})
instrument(io,{
    auth:false
});
  1. 그런 다음 http://admin.socket.io에 접속한 후, 서버 url을 입력한 다음 들어가면 관리 페이지에 접속가능하다.

javascript을 이용하면 브라우져에 등록된 카메라, 오디오 장비에 대한 조작이 가능하다.

home.pug

video#myFace(autoplay,playsinline,width="400",height="400")

app.js

async function getMedia(){
    try{
        myStream= await navigator.mediaDevices.getUserMedia(
            {
                audio:true,
                video:true
            }
        )
        myFace.srcObject=myStream;
    }catch(e){
        console.log(e)
    }

}
getMedia();

위와 같이 navigator.mediaDevices.getUserMedia을 이용하여 현재 브라우져에 등록된 MediaStream 형태로 반환받으며 이를 video tag에 등록하면 현재 카메라 화면이 브라우져 띄워진다.

Camera

그러면 Camera 정보는 어떻게 받아올까?

home.pug

button#camera Turn Camera Off
select#cameras

app.js

async function getCameras(){
    try{
        const devices=await navigator.mediaDevices.enumerateDevices();
        const cameras=devices.filter(device => device.kind==="videoinput");
        const currentCamera=myStream.getVideoTracks()[0];
        cameras.forEach((camera)=>{
            const option=document.createElement("option");
            option.value=camera.deviceId;
            option.innerText=camera.label;
            if(currentCamera.label === camera.label){
                option.selected=true;
            }
            camerasSelect.appendChild(option);
        });
        console.log(cameras);

    }catch(e){
        console.log(e);
    }
}

이는 enumerateDevices()를 이용한다. 이를 이용하게 되면, 해당 브라우져에 연결된 모든 비디오,오디오 장비의 목록을 확인할 수 있다. 이중에서 카메라는 kind가 “videoinput”이다. 이에 해당하는 모든 카메라를 찾아 select에 등록 시켜준다.

const currentCamera=myStream.getVideoTracks()[0];

추가로, 아래와 같이 현재등록된 MediaStream의 첫번째 video track은 현재 설정된 카메라 값이다.

Camera ON/OFF

현재 등록된 MediaStream의 videotrack의 enabled property을 조작하면 카메라 on/off를 쉽게 할 수 있다.

app.js

function handleCameraClick(){
    myStream.getVideoTracks().forEach(track=>track.enabled= !track.enabled)
    if(!cameraOff){
        cameraButton.innerText="Turn Camera Off";
        cameraOff=true;
    }
    else{
        cameraButton.innerText="Turn Camera On";
        cameraOff=false;
    }
}
cameraButton.addEventListener("click",handleCameraClick);

Change Camera

app.js

async function getMedia(deviceId){
    const cameraConstraints={
        audio:true,
        video:{deviceId:{exact: deviceId}}
    };
    try{
        myStream= await navigator.mediaDevices.getUserMedia(
            cameraConstraints
        )
        myFace.srcObject=mySteam;
    }catch(e){
        console.log(e)
    }

}
function handleCameraSelectChange(){
    await getMedia(camerasSelect.value);
}
camerasSelect.addEventListener("input",handleCameraSelectChange);

위와 같이 getUserMedia에 들어가는 constraint 인자를 적절히 설정하면 특정 카메라로 MediaStream을 등록할 수 있다.

Audio

현재 등록된 MediaStream의 audio track의 enabled property을 조작하면 마이크 on/off를 쉽게 할 수 있다.

app.js

function handleMuteClick(){
    myStream.getAudioTracks().forEach(track=>track.enabled= !track.enabled)
    if(!muted){
        muteButton.innerText="Unmute"
        muted=true;
    }
    else{
        muteButton.innerText="Mute"
        muted=false;
    }
}

muteButton.addEventListener("click",handleMuteClick);

Appendix

JavaScript에서는 비동기처리를 위해 Promise, Async&Await 방식을 제공한다.

promise1 promise1 async_await

References

Link: nomadcoders

Link: Adapter

Link: MediaDevice

Link: Async&Await

댓글남기기