Zoom Clone Coding

Web RTC Structure

Web RTC는 web real-time commucation 방식의 기술로 연결되어 있는 유저 끼리 비디오, 오디오, 등의 영상 미디어를 전송할 수 있다. 기존에 쓴 socket.io 방식과의 차이점은 WebRTC는 peer-to-peer 방식이라는 점이다. peer 간에 통신을 진행할 때 서버를 거치지 않고 바로 통신이 가능하기 때문에 그 만큼 latency가 작다는 장점이 있다.

다만, signaling process 과정에서는 socket을 통한 configuration 과정이 필요하고, 각각의 peer에 어디에 있는 지 알기 위해 server가 필요하며, 한 번 연결설정이 완료되면 그 이후에는 server가 필요하지 않다.

webrtc

Web RTC의 signaling process은 위와 같이 Offer, Answer, ICE-Candidate, Add Stream으로 이루어져 있다, 위 그림의 Peer A, Peer B를 이용해서 해당 과정을 이해보자.

RTCPeerConnection 생성

브라우져의 Media Stream을 받아오고 난 다음, peer-peer 간에 connection을 관장하는 RTCPeerConnection을 생성해서 브라우져에서 유지한다.

app.js

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

function makeConnection(){
    myPeerConnection=new RTCPeerConnection();
    //add Tracks 대신에 사용하는 함수, myStream은 이전에 생성한 MediaStream
    myStream.getTracks().forEach(
        track=> myPeerConnection.addTrack(track,myStream)
        );
}

Offer

연결하는 브라우져 쪽에서는 Offer을 생성하고, 이를 Local Description에 등록해야한다.

home.pug

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

app.js

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

async function handleWelcomeSubmit(event){
    event.preventDefault();
    const input=welcomeForm.querySelector("input");
    console.log(input.value);
    await startMedia();
    socket.emit("join_room",input.value);
    roomName=input.value;
    input.value="";
}
welcomeForm.addEventListener("submit",handleWelcomeSubmit)

Front 쪽에서는 room에 접속하겠다 ==> 연결을 하겠다 라고 생각하게 된다. 이때, 서버로 “join_room” event을 보내서 본격적으로 signaling process 작업을 진행한다.

server.js

socket.on("join_room",(roomName) => {
        socket.join(roomName);
        socket.to(roomName).emit("welcome");
    })

app.js

socket.on("welcome",async ()=> {
    const offer=await myPeerConnection.createOffer();
    myPeerConnection.setLocalDescription(offer)    
    socket.emit("offer",offer,roomName);
})

먼저 연결하고자 하는 Peer A 쪽에서는 offer을 생성하고, 이를 본인의 RTCPeerConnection의 LocalDescription에 등록하고, 이를 Peer B로 넘기기 위해 우선, 서버로 보낸다. AddStream은 과정은 myPeerConnection 생성을 담당하는 function에서 수행해주고 있다.

Answer

server.js

//offer
    socket.on("offer",(offer,roomName) =>{
        socket.to(roomName).emit("offer",offer);
    });

서버에서는 offer을 받으면 Peer B에게 넘겨준다.

app.js

socket.on("offer",async (offer)=>{
    myPeerConnection.setRemoteDescription(offer);
    const answer=await myPeerConnection.createAnswer();
    myPeerConnection.setLocalDescription(answer);
    socket.emit("answer",answer,roomName);
})

Offer을 받은 Peer B쪽에서는 Answer을 준비해서 Peer A에게 전달해야한다. 전달받은 offer을 자신의 RTCPeerConnection의 RemoteDescription으로 등록하고, Answer 객체를 생성한다. 생성한 Answer 객체는 RTCPeerConnection의 LocalDescription으로 등록하고 이를 Peer A로 보낸다.

server.js

socket.on("answer",(answer,roomName)=>{
        socket.to(roomName).emit("answer",answer);
    })

app.js

socket.on("answer",answer=>{
    myPeerConnection.setRemoteDescription(answer);
})

그리고 Peer A에서는 전달받은 Answer을 자신의 RemoteDescription으로 등록해야한다.

ICE Candidate

ICE 는 Internet Connectivity Establishment의 약자로 통신 간 운용할 수 있는 protocol들을 명시한 것이다. 각각의 Peer는 ICE Candidate을 서로의 원격 peer에게 전달해야한다. ICE Candidate는 RTCPeerConnection에서 자동적으로 보내기 때문에 이에 대한 event handler function을 등록시켜주기만 하면 된다.

app.js

function makeConnection(){
    myPeerConnection=new RTCPeerConnection();
    myPeerConnection.addEventListener("icecandidate",handleIce)
    //add Tracks 대신에 사용하는 함수
    myStream.getTracks().forEach(
        track=> myPeerConnection.addTrack(track,myStream)
        );

function handleIce(data){
    socket.emit("ice",data.candidate,roomName);
}

IceCandidate event가 발생할때마다 브라우져는 이를 서로의 peer에게 전달하게 된다.

server.js

socket.on("ice",(ice,roomName)=>{
        socket.to(roomName).emit("ice",ice);
    })

AddStreamEvent

Stream event을 받게 되면 이는 상대방의 Stream을 정상적으로 받았다는 것을 의미하며 이에 대해서 적절히 처리를 해준다.

app.js

function makeConnection(){
    myPeerConnection=new RTCPeerConnection();
    myPeerConnection.addEventListener("icecandidate",handleIce)
    myPeerConnection.addEventListener("addstreamevent",handleAddStream);
    //add Tracks 대신에 사용하는 함수
    myStream.getTracks().forEach(
        track=> myPeerConnection.addTrack(track,myStream)
        );

}

//받아온 Stream을 등록하면 상대방의 stream이 등록된다.
function handleAddStream(data){
    const peerFace=document.getElementById("peerFace");
    peerFace.srcObject=data.stream;
}

Camera Switch

본인이 이용하고 있는 카메라를 수정하게 되면, 본인의 화면은 변하지만, 상대방 측의 화면에서는 해당 변화가 발생하지 않는다. 이는 Stream 변화가 RTCPeerConnection에 정상적으로 반영되지 않기 때문이다. 따라서, Stream의 변화가 생길 떄 이를 RTCPeerConnection에 새롭게 등록시켜준다.

app.js

async function handleCameraSelectChange(){
    await getMedia(camerasSelect.value);
    //need to change new stream to peerConnection
    if(myPeerConnection){
        const videoSender=myPeerConnection
        .getSenders()
        .find(sender=> sender.track.kind="vide");
        videoSender.replaceTrack(myStream.getVideoTracks()[0])
    }
}

카메라를 수정했을 때 실행되는 event handler function에서 RTCPeerConnection에 새로운 Strea을 등록시켜주는 작업을 진행한다. 이때, Senders를 통해 수정을 진행하는데, Senders는 RTCPeerConnection에서 mediaStream Track를 담당하는 모듈이다.

LT & Stun Server

Local Tunnel 기술을 활용하게 되면 일시적으로 url로 접속할 수 있도록 허용해준다. 이를 통해 스마트폰을 통한 앱 실행이 가능하다.

하지만, 같은 Wifi 연결이 아닐때는 각각의 Peer의 공인 IP를 찾기 어렵다 –> 이때 Stun Server을 통해 공인 IP를 쉽게 찾아낼 수 있다.

app.js

myPeerConnection=new RTCPeerConnection({
        iceServers:[
            {
                urls:[
                    "stun:stun.l.google.com:19302",
                    "stun:stun1.l.google.com:19302",
                    "stun:stun2.l.google.com:19302",
                    "stun:stun3.l.google.com:19302",
                    "stun:stun4.l.google.com:19302",
                ]
            }
        ]
        }
    );

이렇게 무료로 제공해주는 stun-server을 활용하면 된다.

Data Channel

Data Channel을 통해 peer는 서로에게 message, video, file, 등의 데이터를 주고 받을 수 있다.

Peer A 에서 Data Channel을 생성하여 이를 Peer B에게 전달하고, Peer B 는 전달받은 Data Channel을 유지한다.

app.js

// Peer A 쪽 실행
socket.on("welcome",async ()=>{
myDataChannel=myPeerConnection.createDataChannel("chat");
    myDataChannel.addEventListener("message",console.log);

//Peer B에서 실행
socket.on("offer",async (offer)=>{
    myPeerConnection.addEventListener("datachannel",(event)=>{
        myDataChannel=event.channel;
        myDataChannel.addEventListener("message",console.log);
    });

References

Link: nomadcoders

Link: WebRTC

댓글남기기