ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [node.js] WebRTC로 화상회의 만들기 (예제 코드 포함)
    node.js 2023. 10. 18. 22:04
    728x90
    반응형

    안녕하세요 나홀로전세집입니다.

     

    오늘은 WebRTC에 대해 알려드리겠습니다.

     

    WebRTC란?

    WebRTC는 Web Real-Time Communication의 줄임말로 웹 실시간 통신을 말합니다.

    기존 HTTP 통신 방식은 Request, Response를 사용해 서버에 요청을 보내면 서버에서 응답을 해주는 방식입니다.

    그러나 WebRTC는 중간에 서버 없이 peer-to-peer(P2P)를 사용해 통신하는 방식이라고 생각하면 됩니다.

     

    이와 같이 기존 HTTP 통신은 서버를 중심으로 통신을 하지만 Peer-to_Peer 통신은 서버 없이 직접 통신을 하는 것을 볼 수 있습니다.

     

     

    실시간 통신의 구성 요소

    P2P 이외에도 실시간 통신의 구성 요소가 있습니다. 크게 P2P, SFU, MCU 로 구분됩니다.

     

    P2P (Peer-to-Peer)

    P2P 통신은 두 클라이언트 간의 직접적인 연결을 합니다. 두 클라이언트 직접 통신하는 것을 의미합니다.
    P2P는 연결 속도가 빠르며, 클라이언트 간의 데이터 전송이 효율적입니다.


    SFU (Selective Forwarding Unit)

    SFU는 "선택적 전송 장치"로, 화상 회의에서 사용되는 중요한 구성 요소 중 하나입니다.
    SFU는 화상 회의에 참여하는 여러 참가자 간의 오디오 및 비디오 스트림을 관리합니다.
    클라이언트에서 보낸 스트림 중에서 필요한 스트림만 전송하므로 대역폭을 절약하고 품질을 최적화합니다.


    MCU (Multipoint Control Unit)

    MCU는 "다중점 제어 장치"로, 더 복잡한 화상 회의 시나리오에서 사용됩니다.
    MCU는 여러 클라이언트 간의 오디오 및 비디오 스트림을 수신하고 혼합, 인코딩, 다시 전송합니다.
    MCU는 화상 회의를 모든 참가자에게 동일한 화면을 보여주는 것과 같은 다중 포인트 화상 회의를 가능하게 합니다.

     

    WebRTC의 작동 원리

    나의 오디오와 비디오 입력 장치로부터 스트림을 캡처한 후 통신하려는 상대와 P2P 연결을 하여 데이터를 교환합니다.

     

    ICE (Interactive Connectivity Establishment)

    ICE는 WebRTC에서 사용되는 네트워크 연결 설정 기술의 약자입니다.
    ICE는 다양한 프로토콜 및 기술을 사용하여 클라이언트 간의 연결을 설정합니다. ICE는 가능한 모든 네트워크 경로 및 프로토콜을 조사하여 최적의 연결을 찾아냅니다.


    ICE Candidate (ICE 후보)

    ICE Candidate는 P2P 연결 설정에서 사용되는 네트워크 경로의 후보 목록을 나타냅니다.
    각 클라이언트는 자신의 네트워크 구성 및 주소 정보를 수집하여 ICE 후보 목록을 생성합니다. 이 목록에는 호스트 주소, 서버 주소, reflexive 주소(공인 IP 주소를 참조한 주소), relay 주소(중계 서버 주소) 등이 포함됩니다.

     

     

     ◎ getUserMedia API를 사용하여 사용자의 오디오 및 비디오의 정보를 가져옵니다.

     ◎  ICE를 통해 연결 가능한 네트워크와 프로토콜을 저장합니다.
     ◎  ICE Candidate를 통해 네트워크와 프로토콜을 다른 클라이언트에게 보냅니다.

     ◎ RTCPeerConnection라는 객체를 사용하여 사용자의 데이터를 담을 수 있습니다.

     ◎ RTCDataChannel를 사용하여 데이터 통신을 해줍니다.

     

    위 방법이 WebRTC의 큰 틀입니다.

     

     

    STUN, TURN 서버

    WebRTC로 화상 회의를 만들기 위해선 꼭 필요한 것이 있습니다. 

    STUN서버와 TURN 서버가 필요합니다.

    STUN 서버와 TURN 서버를 알기 전에 NAT이라는 것도 알아야합니다.

     

    NAT

    NAT Network Address Translation의 줄임말로 네트워크에서 IP를 변환해주는 기술입니다. 

    이로 인해 여러 IP가 하나의 IP 주소를 공유하여 통신할 수 있도록 해줍니다.

     

    STUN 서버

    STUN 서버는 Session Traversal Utilities for NAT의 줄임말로 클라이언트가 NAT를 통과하고 공용 IP 주소와 포트를 알아내는 데 도움을 줍니다.

    NAT와 방화벽 뒤에 있는 클라이언트가 다른 IP 주소를 가진 클라이언트와 통신하기 위해 공용 IP 주소 및 NAT 형태를 확인하는 데 사용된다고 생각하면 됩니다.

    그런데 STUN서버로 IP주소를 발견한다고 해도 모두가 연결이 가능한 것은 아닙니다. 그래서 TURN 서버를 사용합니다.

     

    TURN 서버

    TURN 서버는 Traversal Using Relays around NAT의 줄임말로 NAT 뒤에 있는 클라이언트 간의 데이터 통신을 중계(relay)하고, 방화벽을 통과하는 데 사용됩니다.

    NAT와 방화벽 뒤에 있는 클라이언트 간의 데이터 통신을 중계하고, 어려운 네트워크 환경에서 P2P 연결을 가능하게 해준다고 생각하면 됩니다.

    TURN 서버는 방화벽과 NAT를 우회하기 때문에 어려운 네트워크 환경에서도 통신이 가능하게 해줍니다.

     

     

     

    다음은 WebRTC를 node.js를 사용해 만든 화상회의 예제를 보여드리겠습니다.

     

    아래 코드는 server.js 라는 서버 코드입니다.

    import http from "http";
    import SocketIO from "socket.io";
    import express from "express";
    import wrtc from "wrtc";
    
    const app = express();
    app.set("view engine", "pug");
    app.set("views", __dirname + "/views");
    app.use("/public", express.static(__dirname + "/public"));
    app.get("/", (_, res) => res.render("home"));
    app.get("/*", (_, res) => res.redirect("/"));
    
    const httpServer = http.createServer(app);
    const wsServer = SocketIO(httpServer);
    
    // Client의 recvPeerMap에 대응된다.
    // Map<sendPeerId, Map<recvPeerId, PeerConnection>>();
    let sendPeerMap = new Map();
    
    // Client의 SendPeer에 대응된다.
    // Map<peerId, PeerConnection>
    let recvPeerMap = new Map();
    
    // 특정 room의 user Stream을 처리하기 위한 Map
    // Map<roomName, Map<socketId, Stream>>(); Stream = data.streams[0]
    let streamMap = new Map();
    
    function getUserRoomList(socket) {
      let rooms = socket.rooms;
      rooms.delete(socket.id);
      return [...rooms];
    }
    
    wsServer.on("connection", (socket) => {
      socket.on("join_room", (roomName) => {
        let room = wsServer.sockets.adapter.rooms.get(roomName);
        let idList = room ? [...room] : [];
    
        console.log(idList);
        socket.emit("user_list", idList);
    
        console.log("join_room id = " + socket.id);
        socket.join(roomName);
      });
    
      socket.on("recvOffer", async (offer, sendId) => {
        console.log(`got recvOffer from ${socket.id}`);
    
        // recvPeer에 대응하여 sendPeer를 생성한다.
        createSendPeer(sendId);
        createSendAnswer(offer, sendId);
      });
    
      socket.on("recvCandidate", async (candidate, sendId) => {
        if (candidate) {
          sendPeerMap.get(sendId).get(socket.id).addIceCandidate(candidate);
        }
      });
    
      socket.on("sendOffer", (offer) => {
        console.log(`got sendOffer from ${socket.id}`);
    
        createRecvPeer();
        createRecvAnswer(offer);
      });
    
      socket.on("sendCandidate", async (candidate) => {
        if (candidate) {
          recvPeerMap.get(socket.id).addIceCandidate(candidate);
        }
      });
    
      socket.on("disconnecting", () => {
        let rooms = getUserRoomList(socket);
        let id = socket.id;
    
        console.log(`${id} left room`);
        console.log(rooms);
    
        if (sendPeerMap.has(id)) {
          sendPeerMap.get(id).forEach((value, key) => {
            value.close();
          });
    
          sendPeerMap.delete(id);
        }
    
        if (recvPeerMap.has(id)) {
          recvPeerMap.get(id).close();
          recvPeerMap.delete(id);
        }
    
        rooms.forEach((room) => {
          socket.to(room).emit("bye", id);
    
          if (streamMap.has(room)) {
            streamMap.get(room).delete(id);
          }
        });
      });
    
      function createRecvPeer() {
        let recvPeer = new wrtc.RTCPeerConnection({
          iceServers: [
            {
              urls: ["turn:13.250.13.83:3478?transport=udp"],
              username: "YzYNCouZM1mhqhmseWk6",
              credential: "YzYNCouZM1mhqhmseWk6",
            },
          ],
        });
    
        recvPeer.addEventListener("icecandidate", (data) => {
          console.log(`sent sendCandidate to client ${socket.id}`);
          socket.emit("sendCandidate", data.candidate);
        });
    
        recvPeer.addEventListener("track", (data) => {
          console.log("recvPeer track");
          let rooms = getUserRoomList(socket);
          console.log(rooms);
    
          if (!streamMap.has(rooms[0])) {
            streamMap.set(rooms[0], new Map());
          }
    
          if (streamMap.get(rooms[0]).has(socket.id)) {
            return;
          }
    
          // Stream 정보를 추가하고 다른 클라에게 알린다.
          streamMap.get(rooms[0]).set(socket.id, data.streams[0]);
          socket.to(rooms[0]).emit("newStream", socket.id);
        });
    
        recvPeerMap.set(socket.id, recvPeer);
      }
    
      async function createRecvAnswer(offer) {
        let recvPeer = recvPeerMap.get(socket.id);
    
        recvPeer.setRemoteDescription(offer);
        const answer = await recvPeer.createAnswer({
          offerToReceiveVideo: true,
          offerToReceiveAudio: true,
        });
        recvPeer.setLocalDescription(answer);
    
        console.log(`sent the sendAnswer to ${socket.id}`);
        socket.emit("sendAnswer", answer);
      }
    
      function createSendPeer(sendId) {
        let sendPeer = new wrtc.RTCPeerConnection({
          iceServers: [
            {
              urls: ["turn:13.250.13.83:3478?transport=udp"],
              username: "YzYNCouZM1mhqhmseWk6",
              credential: "YzYNCouZM1mhqhmseWk6",
            },
          ],
        });
    
        sendPeer.addEventListener("icecandidate", (data) => {
          console.log(`sent recvCandidate to client ${socket.id}`);
          socket.emit("recvCandidate", data.candidate, sendId);
        });
    
        let rooms = getUserRoomList(socket);
        let stream = streamMap.get(rooms[0]).get(sendId);
    
        stream.getTracks().forEach((track) => {
          sendPeer.addTrack(track, stream);
        });
    
        if (!sendPeerMap.has(sendId)) {
          sendPeerMap.set(sendId, new Map());
        }
    
        sendPeerMap.get(sendId).set(socket.id, sendPeer);
      }
    
      async function createSendAnswer(offer, sendId) {
        let sendPeer = sendPeerMap.get(sendId).get(socket.id);
    
        sendPeer.setRemoteDescription(offer);
        const answer = await sendPeer.createAnswer({
          offerToReceiveVideo: false,
          offerToReceiveAudio: false,
        });
        sendPeer.setLocalDescription(answer);
    
        console.log(`sent the recvAnswer to ${socket.id}`);
        socket.emit("recvAnswer", answer, sendId);
      }
    });
    
    const handleListen = () => console.log(`Listening on http://localhost:3000`);
    httpServer.listen(3000, handleListen);

    사용자와 peer가 연결할 수 있도록 도와줍니다.

     

     

    아래 코드는 app.js인 클라이언트 코드입니다.

    const socket = io();
    
    const myFace = document.getElementById("myFace");
    const muteBtn = document.getElementById("mute");
    const cameraBtn = document.getElementById("camera");
    const camearsSelect = document.getElementById("cameras");
    const streamDiv = document.querySelector("#myStream");
    
    let myStream;
    let isMuted = true;
    let isCameraOn = false;
    let roomName;
    
    // 서버에서 넘겨주는 Downlink를 처리하기 위한 Map
    // Map<socketId, PeerConnection>
    let recvPeerMap = new Map();
    
    // 서버에 미디어 정보를 넘기기 위한 Peer
    let sendPeer;
    
    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.lable) {
            option.selected = true;
          }
          camearsSelect.appendChild(option);
        });
    
        console.log(cameras);
      } catch (e) {
        console.log(e);
      }
    }
    
    async function getMedia(deviceId) {
      const initialConstraint = {
        audio: true,
        video: { facingMdoe: "user" },
      };
    
      const cameraConstraints = {
        audio: true,
        video: { deviceId: { exact: deviceId } },
      };
    
      try {
        myStream = await navigator.mediaDevices.getUserMedia(
          deviceId ? cameraConstraints : initialConstraint
        );
        myFace.srcObject = myStream;
        if (!deviceId) {
          await getCameras();
        }
      } catch (e) {
        console.log(e);
      }
    }
    
    function handleMuteClick() {
      myStream.getAudioTracks().forEach((track) => {
        track.enabled = !track.enabled;
      });
      if (isMuted) {
        muteBtn.innerText = "UnMute";
        isMuted = false;
      } else {
        muteBtn.innerText = "Mute";
        isMuted = true;
      }
    }
    
    function handleCameraClick() {
      myStream.getVideoTracks().forEach((track) => {
        track.enabled = !track.enabled;
      });
    
      if (isCameraOn) {
        cameraBtn.innerText = "Turn Camera Off";
        isCameraOn = false;
      } else {
        cameraBtn.innerText = "Turn Camera On";
        isCameraOn = true;
      }
    }
    
    async function handleCameaChange() {
      await getMedia(camearsSelect.value);
      if (sendPeer) {
        const videoTrack = myStream.getVideoTracks()[0];
        const videoSender = sendPeer
          .getSenders()
          .find((sender) => sender.track.kind === "video");
    
        videoSender.replaceTrack(videoTrack);
      }
    }
    
    muteBtn.addEventListener("click", handleMuteClick);
    cameraBtn.addEventListener("click", handleCameraClick);
    camearsSelect.addEventListener("input", handleCameaChange);
    
    // Welcome Form (join a room)
    const welcomeDiv = document.getElementById("welcome");
    const callDiv = document.getElementById("call");
    
    callDiv.hidden = true;
    
    async function initCall() {
      callDiv.hidden = false;
      welcomeDiv.hidden = true;
      await getMedia();
    }
    
    async function handleWelcomeSubmit(event) {
      event.preventDefault();
      const input = welcomeForm.querySelector("input");
      await initCall();
      socket.emit("join_room", input.value);
      roomName = input.value;
      input.value = "";
    }
    
    const welcomeForm = welcomeDiv.querySelector("form");
    welcomeForm.addEventListener("submit", handleWelcomeSubmit);
    
    // Socket code
    socket.on("user_list", (idList) => {
      console.log("user_list = " + idList.toString());
    
      // 아이디 정보를 바탕으로 recvPeer를 생성한다.
      idList.forEach((id) => {
        createRecvPeer(id);
        creatRecvOffer(id);
      });
    
      // sendPeer를 생성한다.
      createSendPeer();
      createSendOffer();
    });
    
    socket.on("recvCandidate", async (candidate, sendId) => {
      console.log("got recvCandidate from server");
      recvPeerMap.get(sendId).addIceCandidate(candidate);
    });
    
    socket.on("sendCandidate", async (candidate) => {
      console.log("got sendCandidate from server");
      sendPeer.addIceCandidate(candidate);
    });
    
    socket.on("newStream", (id) => {
      console.log(`newStream id=${id}`);
      createRecvPeer(id);
      creatRecvOffer(id);
    });
    
    async function createSendOffer() {
      console.log(`createSendOffer`);
      const offer = await sendPeer.createOffer({
        offerToReceiveVideo: false,
        offerToReceiveAudio: false,
      });
    
      sendPeer.setLocalDescription(offer);
      socket.emit("sendOffer", offer);
    }
    
    function createSendPeer() {
      sendPeer = new RTCPeerConnection({
        iceServers: [
          {
            urls: ["turn:13.250.13.83:3478?transport=udp"],
            username: "YzYNCouZM1mhqhmseWk6",
            credential: "YzYNCouZM1mhqhmseWk6",
          },
        ],
      });
    
      sendPeer.addEventListener("icecandidate", (data) => {
        console.log(`sent sendCandidate to server`);
        socket.emit("sendCandidate", data.candidate);
      });
    
      if (myStream) {
        myStream.getTracks().forEach((track) => {
          sendPeer.addTrack(track, myStream);
        });
    
        console.log("add local stream");
      } else {
        console.log("no local stream");
      }
    }
    
    function createRecvPeer(sendId) {
      recvPeerMap.set(
        sendId,
        new RTCPeerConnection({
          iceServers: [
            {
              urls: ["turn:13.250.13.83:3478?transport=udp"],
              username: "YzYNCouZM1mhqhmseWk6",
              credential: "YzYNCouZM1mhqhmseWk6",
            },
          ],
        })
      );
    
      recvPeerMap.get(sendId).addEventListener("icecandidate", (data) => {
        console.log(`sent recvCandidate to server`);
        socket.emit("recvCandidate", data.candidate, sendId);
      });
    
      recvPeerMap.get(sendId).addEventListener("track", (data) => {
        handleTrack(data, sendId);
      });
    }
    
    async function creatRecvOffer(sendId) {
      console.log(`createRecvOffer sendId = ${sendId}`);
      const offer = await recvPeerMap.get(sendId).createOffer({
        offerToReceiveVideo: true,
        offerToReceiveAudio: true,
      });
    
      recvPeerMap.get(sendId).setLocalDescription(offer);
    
      console.log(`send recvOffer to server`);
      socket.emit("recvOffer", offer, sendId);
    }
    
    socket.on("sendAnswer", async (answer) => {
      console.log("got sendAnswer from server");
      sendPeer.setRemoteDescription(answer);
    });
    
    socket.on("recvAnswer", async (answer, sendId) => {
      console.log("got recvAnswer from server");
      recvPeerMap.get(sendId).setRemoteDescription(answer);
    });
    
    socket.on("bye", (fromId) => {
      // 나간 유저의 정보를 없앤다.
      console.log("bye " + fromId);
      recvPeerMap.get(fromId).close();
      recvPeerMap.delete(fromId);
    
      let video = document.getElementById(`${fromId}`);
      streamDiv.removeChild(video);
    });
    
    // RTC code
    function handleTrack(data, sendId) {
      let video = document.getElementById(`${sendId}`);
      if (!video) {
        video = document.createElement("video");
        video.id = sendId;
        video.width = 100;
        video.height = 100;
        video.autoplay = true;
        video.playsInline = true;
    
        streamDiv.appendChild(video);
      }
    
      console.log(`handleTrack from ${sendId}`);
      video.srcObject = data.streams[0];
    }

     

    서버에 접속한 사용자의 Media와 ICE를 가져온 후 서버로 값을 전달합니다.

     

    아래 코드는 클라이언트를 예쁘게 보여주는 home.pug 코드입니다.

    doctype html
    html(lang="en")
        head
            meta(charset="UTF-8")
            meta(name="viewport", content="width=device-width, initial-scale=1.0")
            title GMOVIE
            link(rel="stylesheet", href="https://unpkg.com/mvp.css")
        body 
            header
                h1 GMOVIE
            main
                div#welcome
                    form
                        input(placeholder="room name", required, type="text")
                        button Enter Room 
                div#call
                    div#myStream
                        video#myFace(autoplay, playsinline, width="100", height="100")
                        button#mute Mute 
                        button#camera Turn Camera Off
                        select#cameras 
            script(src="/socket.io/socket.io.js") 
            script(src="/public/js/app.js")

    제가 올린 코드는 Map을 사용하여 Peer to Peer을 여러 명이 한 회의방에 들어올 수 있게 만든 코드입니다.

     

     

    오늘은 WebRTC에 대해 알아보았습니다.

     

    오늘도 즐코딩 하시고 좋은 하루 되세요~

     

    728x90
    반응형
Designed by Tistory.