이전 포스팅에서 Zoom의 핵심 기능인 영상 및 음성 스트림 기능을 구현해보았습니다.

그렇다면 이제 서로간의 통신을 해봐야겠죠?

 

※ 본 내용은 노마드코더 줌 클론코딩의 3.3~3.5 까지의 수업 내용을 담고 있습니다!

 


0. WebRTC

먼저 좋은 레퍼런스가 존재하기 때문에, 이 글을 읽기 전에 참조하시면 좋습니다. (반드시 읽어야 하는 것은 아닙니다 ㅋㅋㅋㅋ)

 

 

WebRTC API - Web API | MDN

WebRTC(Web Real-Time Communication)은 웹 애플리케이션과 사이트가 중간자 없이 브라우저 간에 오디오나 영상 미디어를 포착하고 마음대로 스트림할 뿐 아니라, 임의의 데이터도 교환할 수 있도록 하는

developer.mozilla.org

 

 

 

WebRTC

An open framework for the web that enables Real-Time Communications (RTC) capabilities in the browser.

webrtc.org

 

WebRTC를 사용하는 가장 큰 이유는, webSocket이나 Socket.io에서 실현하지 못한 Peer-to-Peer 통신을 실현할 수 있기 때문입니다!!

이전에 채팅을 구현할 때 내 채팅을 서버에 보내고 서버에서 브로드캐스팅하는 형태였지만! WebRTC는 서버를 거치지 않고 바로 데이터를 전송할 수 있는 것이죠.

 

 

그렇다고 서버가 아예 필요하지 않은 것은 아닙니다.

위 사진을 보면 Signaling이라는게 존재하죠?

Peer-to-Peer로 연결하기 전에 사용자가 어디서 접속하는지 확인해야 할 필요가 있습니다.

서로의 IP주소가 public인지, 방화벽은 열려있는지, 어떤 포트를 사용할 수 있을지 확인하기 위해 서버가 중개자로 동작하게 됩니다.

이렇게 한번 Signaling이 끝나고 나면 서로의 위치를 파악하고 Peer-to-Peer로 직접 연결되게 되는 것이죠.

서버는 이 때만 동작하게 됩니다.

실제로 구현해보면서 더 자세하게 알아보도록 하죠!

 


1. 방 만들기

채팅에서 만들었던 방처럼, Video call을 처리하기 위해서 room이 다시 필요합니다.

사람들은 방에 들어와서 서로 통신할 거니까요!!

 

home.pug에 방에 참가할 수 있는 부분을 추가합니다.

 

 

이제 app.js를 고쳐야하는데, 우리가 원하는 것은 방에 접속하기 전에는 div#call 아래 부분이 보여지면 안되겠죠?

그렇기 때문에 getMedia를 통해 모든 내용이 시작되는 것을 고쳐야 합니다.

getMedia();를 지워줍니다!

 

그리고 welcome, call을 가져온 후에 call부분은 hidden처리해줍니다.

카메라가 없어졌다..!

 

그 다음에는 지금까지 했던 내용의 반복입니다.

app.js에서 room name에 적었던 자료들을 서버로 보내는 작업을 해줍니다.

 

 

server.js에서는 받아온 input_value를 이용해서 소켓에 접속합니다.

반복이기 떄문에 자세한 설명은 생략합니다!!

 

 

방에 접속했을 때 welcome을 안보이게 하고 call을 보이게 하는 부분이 추가되어야겠죠?

이전에 사용했던 것처럼 emit할 때 함수를 보내도록 app.js와 server.js에 내용을 추가해봅시다.

 

app.js
server.js

 

이제 방에 접속하면 화면이 잘 뜨게 됩니다!!

 

 

추가로 가독성을 위해 welcome 부분을 따로 정리해주었습니다.

 

 

접속한 방 이름을 사용할 일이 있을 수도 있겠죠?

그런 상황을 대비해서 roomName이라는 변수를 만들어준 후에 여기에다가 접속한 방 이름을 저장해주도록 합시다.

 

 


2. offer 전송하기

먼저 누군가가 접속했을 때 알림이 가도록 만들어보겠습니다.

이 작업은 WebRTC의 연결 과정에서 반드시 필요한 부분입니다.

두 대상의 연결은 offer라는 시그널을 대화 상대에게 날리는 것에서 시작하는데, 이 시그널을 직접 만들어보는 것이죠! 재미있겠죠?

 

WebRTC의 Peer-to-Peer까지 연결 과정

 

누군가 접속했을 때 someone joined를 콘솔에 찍도록 app.js를 바꿔줍니다.

 

 

그리고 서버에서 누군가가 접속했을 때 welcome 이벤트를 방 접속자에게 전달하도록 만들어줍니다.

 

 

구글 크롬과 파이어폭스 브라우저에서 동시에 hi라는 방에 접속을 하게 되면 먼저 접속한 브라우저의 콘솔창에 다른 브라우저가 접속할 때 someone joined라고 뜨게 됩니다! 신기하죠!!

 

 

연결의 시작을 잘 만들었으니, 이제 RTCPeerConnection이라는 함수를 이용해서 브라우저를 연결해봅시다.

 

myPeerConnection이라는 변수를 만들고, 연결이 되었을 때 작동하도록 해봅시다.

app.js에서 순서대로 변수를 만들고, makeConnection 함수를 만들고, 미디어가 시작될 때 makeConnection 함수가 작동하도록 만들어줍니다. (자세한 설명은 주석과 영상으로 갈음합니다!)

 

makeConnection 함수 내부에서 myPeerConenction에 RTCPeerConnection 인스턴스를 생성해서 넣어주고, getTracks를 통해 영상과 음성을 받아온 후에 각각을 myPeerConnection에 추가해줍니다.

 

 

그리고 나서 접속했을 때 offer를 만들어주는 부분을 추가해줍니다.

위에 올린 다이어그램에서 3번째 createOffer 부분이라고 보시면 됩니다!

offer초대장과 같은 기능이라고 보시면 됩니다. 현재 내 브라우저에 관한 내용들이 들어가있죠.

자세한 내용은 주석에 적어두었고, offer를 서버에 보내고 또 offer를 받을 수 있도록 구성하였습니다.

 

추가로 welcome 이벤트를 받았을 때 setLocalDescription(offer)를 통해서 내 브라우저의 myPeerConnection 변수에 offer를 등록해줍니다.

내 위치를 브라우저에 등록해주는 것이라고 보면 됩니다.

 

 

마지막으로 서버에서 offer를 처리할 수 있는 부분을 추가해줍니다.

offer이벤트가 들어오면 방에 있는 모든 사용자에게 offer를 전달할 수 있게 해줍니다.

 

 

여기까지 만들고 서버를 작동한 후에 두 브라우저에서 접속하게 되면, 다른 브라우저에서 접속하게 되면 그 브라우저에게 기존 접속자의 offer가 전송되는 것을 확인할 수 있습니다!

 

우리는 Signaling의 첫 단추를 꿴 것이죠!!

 

 


3. 소스 코드

// app.js

const socket = io(); // io function은 알아서 socket.io를 실행하고 있는 서버를 찾을 것이다!

const myFace = document.getElementById("myFace");
const muteBtn = document.getElementById("mute");
const cameraBtn = document.getElementById("camera");
const camerasSelect = document.getElementById("cameras");


const call = document.getElementById("call");

call.hidden = true;


// stream받기 : stream은 비디오와 오디오가 결합된 것
let myStream;

let muted = false; // 처음에는 음성을 받음
let cameraOff = false; // 처음에는 영상을 받음
let roomName;
let myPeerConnection; // 누군가 getMedia함수를 불렀을 때와 똑같이 stream을 공유하기 위한 변수

async function getCameras(){
    try {
        const devices = await navigator.mediaDevices.enumerateDevices(); // 장치 리스트 가져오기
        const cameras = devices.filter(device => device.kind === "videoinput"); // 비디오인풋만 가져오기
        const currentCamera = myStream.getVideoTracks()[0]; // 비디오 트랙의 첫 번째 track 가져오기 : 이게 cameras에 있는 label과 같다면 그 label은 선택된 것이다!

        cameras.forEach(camera => {
            const option = document.createElement("option"); // 새로운 옵션 생성
            option.value = camera.deviceId; // 카메라의 고유 값을 value에 넣기
            option.innerText = camera.label; // 사용자가 선택할 때는 label을 보고 선택할 수 있게 만들기
            if(currentCamera.label === camera.label) { // 현재 선택된 카메라 체크하기
                option.selected = true;
            }
            camerasSelect.appendChild(option); // 카메라의 정보들을 option항목에 넣어주기
        })
    } catch (e) {
        console.log(e);
    }
}

// https://developer.mozilla.org/ko/docs/Web/API/MediaDevices/getUserMedia 사용 : 유저의 유저미디어 string을 받기위함
async function getMedia(deviceId){
    const initialConstraints = { // initialConstraints는 deviceId가 없을 때 실행
        audio: true,
        video: {facingMode: "user"}, // 카메라가 전후면에 달려있을 경우 전면 카메라의 정보를 받음 (후면의 경우 "environment")
    };
    const cameraConstraints = { // CameraConstraints는 deviceId가 있을 때 실행
        audio: true,
        video: {deviceId: {exact: deviceId}}, // exact를 쓰면 받아온 deviceId가 아니면 출력하지 않는다
    };

    try {
        myStream = await navigator.mediaDevices.getUserMedia(
            deviceId ? cameraConstraints : initialConstraints
        )
        myFace.srcObject = myStream;
        if (!deviceId) { // 처음 딱 1번만 실행! 우리가 맨 처음 getMedia를 할 때만 실행됨!!
            await getCameras();
        }
        
        
    } catch (e) {
        console.log(e);
    }
}

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

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

async function handleCameraChange() {
    await getMedia(camerasSelect.value);
}

muteBtn.addEventListener("click", handleMuteClick);
cameraBtn.addEventListener("click", handleCameraClick);

// 카메라 변경 확인
camerasSelect.addEventListener("input", handleCameraChange);



// Welcome Form (join a room)

const welcome = document.getElementById("welcome");
const welcomeForm = welcome.querySelector("form");

async function startMedia(){
    welcome.hidden = true;
    call.hidden = false;
    await getMedia();
    makeConnection();
}

function handleWelcomeSubmit(event){
    event.preventDefault();
    const input = welcomeForm.querySelector("input");
    socket.emit("join_room", input.value, startMedia); // 서버로 input value를 보내는 과정!! startMedia함수도 같이 보내준다!
    roomName = input.value; // 방에 참가했을 때 나중에 쓸 수 있도록 방 이름을 변수에 저장
    input.value = "";
}

welcomeForm.addEventListener("submit", handleWelcomeSubmit);


// Socket Code

socket.on("welcome", async () => {
    const offer = await myPeerConnection.createOffer(); // 다른 사용자를 초대하기 위한 초대장!! (내가 누구인지를 알려주는 내용이 들어있음!)
    myPeerConnection.setLocalDescription(offer); // myPeerConnection에 내 초대장의 위치 정보를 연결해 주는 과정 https://developer.mozilla.org/ko/docs/Web/API/RTCPeerConnection/setLocalDescription
    console.log("sent the offer");
    socket.emit("offer", offer, roomName);
})

socket.on("offer", offer => {
    console.log(offer);
})

// RTC code

function makeConnection() {
    myPeerConnection = new RTCPeerConnection(); // peerConnection을 각각의 브라우저에 생성 https://developer.mozilla.org/ko/docs/Web/API/RTCPeerConnection 참조
    myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream)); // 영상과 음성 트랙을 myPeerConnection에 추가해줌 -> Peer-to-Peer 연결!!
}
// server.js

import http from "http"; // 이미 기본 설치되어있음
import WebSocket from "ws"; // 기본설치!
import express from "express"; // npm i express 설치
import { instrument } from "@socket.io/admin-ui";
import { Server } from "socket.io"; 

const app = express(); // app이라는 변수에 가져와서 사용

app.set("view engine", "pug"); // 뷰 엔진을 pug로 하겠다
app.set("views", __dirname + "/views"); // 디렉토리 설정
app.use("/public", express.static(__dirname + "/public")); // public 폴더를 유저에게 공개 (유저가 볼 수 있는 폴더 지정)
app.get("/", (req, res) => res.render("home")); // 홈페이지로 이동할 때 사용될 템플릿을 렌더
app.get("/*", (req, res) => res.redirect("/")) // 홈페이지 내 어느 페이지에 접근해도 홈으로 연결되도록 리다이렉트 (다른 url 사용 안할거라)

const handleListen = () => console.log(`Listening on http://localhost:3000`)
// app.listen(3000, handleListen); // 3000번 포트와 연결

const httpServer = http.createServer(app);
const wsServer = new Server(httpServer);
httpServer.listen(3000, handleListen); // 서버는 ws, http 프로토콜 모두 이해할 수 있게 된다!

wsServer.on("connection", socket => {
    socket.on("join_room", (roomName, done) => {
        socket.join(roomName);
        done();
        socket.to(roomName).emit("welcome"); // 특정 룸에 이벤트 보내기
    });
    socket.on("offer", (offer, roomName) => { // offer이벤트가 들어오면, roomName에 있는 사람들에게 offer 이벤트를 전송하면서 offer를 전송한다.
        socket.to(roomName).emit("offer", offer);
    });
});
//- 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
            div#welcome
                form
                    input(placeholder="room name", required, type="text") 
                    button Enter room
            //- video call에서 오는 모든 것들이 이 div 안으로 들어오게 된다!
            div#call
                //- playsinline은 모바일 브라우저가 필요로 하는 property
                //- 모바일 기기로 비디오를 재생하면 비디오가 전체화면이 되버림 -> playsinline을 하면 전체화면이 되지 않는다!
                div#myStream
                    video#myFace(autoplay, playsinline, width="400", height="400")
                    button#mute Mute
                    button#camera Turn Camera Off
                    select#cameras
                        option(value="device") Face Camera
            
        //- 프론트엔드에 웹소켓 설치
        script(src="/socket.io/socket.io.js") 
        script(src="/public/js/app.js")

 


4. 참고 자료

 

WebRTC API - Web API | MDN

WebRTC(Web Real-Time Communication)은 웹 애플리케이션과 사이트가 중간자 없이 브라우저 간에 오디오나 영상 미디어를 포착하고 마음대로 스트림할 뿐 아니라, 임의의 데이터도 교환할 수 있도록 하는

developer.mozilla.org

 

RTCPeerConnection - Web API | MDN

RTCPeerConnection 인터페이스는 로컬 컴퓨터와 원격 피어 간의 WebRTC 연결을 담당하며 원격 피어에 연결하기 위한 메서드들을 제공하고, 연결을 유지하고 연결 상태를 모니터링하며 더 이상 연결

developer.mozilla.org

 

RTCPeerConnection.setLocalDescription() - Web API | MDN

RTCPeerConnection.setLocalDescription() 메소드는 연결 인터페이스와 관련이 있는 로컬 설명 (local description)을 변경합니다. 로컬 설명은 미디어 형식을 포함하는 연결의 로컬 엔드에 대한 속성을 명시합

developer.mozilla.org

 

반응형
  • 네이버 블로그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기