이전 포스팅에서 막판에 발생한 오류들이 있었죠?

이번 포스팅에서는 그 오류들을 고쳐서 보다 완벽한 통신이 이루어지도록 만들어보겠습니다!!

 

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

 

목차


    0. Sender

    내가 카메라를 바꿀 때 다른 브라우저에서도 그 내용이 반영이 되어야 한다고 말했었죠?

    하지만 저번 포스팅까지의 내용에서는 그 부분이 반영이 되지 않았습니다.

    이걸 바꾸기 위해서는 handleCameraChange부분을 다시 수정할 필요가 있습니다!

     

    우리가 카메라를 바꾸게 되면, getMedia 함수를 통해 새로운 stream을 만들게 됩니다.

    그 stream의 변경사항을 peer에게도 전달해야 카메라가 바뀌었다는 사실이 peer의 화면에서도 적용이 되겠죠?!!

     

    myPeerConnection에는 senders라는 요소가 존재합니다.

    이걸 직접 확인해보도록 하죠.

     

     

    위와 같은 내용을 추가하고 서버를 돌린 후에 카메라를 바꿔보면? Senders 내용이 콘솔창에 뜨게 됩니다.

     

     

    이 내용을 좀 더 자세히 뜯어보면, 우리가 바꾸길 원하는 부분은 이 RTC Sender에서도 track 부분입니다.

    분명 카메라를 바꿨는데, 여전히 로지텍 웹캠이군요...

     

     

    우리는 카메라를 바꿀 때 stream을 통째로 바꿔버리는데, user에게 보내는 track은 바꾸고 있지 않다는 것을 확인할 수 있습니다.

     

    그래서 우리가 할 내용은 kind가 video인 sender를 찾아서 getSender 하는 것입니다.

    handleCameraChange함수를 아래와 같이 바꾼 후에 다시 서버를 실행해봅시다.

     

     

    그럼 우리가 원하는 video부분만 뽑혀나오게 됩니다!

     

     

    이제 sender에 대해 보다 자세하게 알아봅시다!

    sender우리의 peer로 보내진 media stream track을 컨트롤 할 수 있게 해줍니다.

     

    그리고 우리가 해야할 내용은 바로 track을 바꾸는 것이겠죠?

    이 부분은 replaceTrack을 사용하면 가능합니다!

     

    보다 자세한 내용에 대해서는 아래 레퍼런스를 참조하세요!

     

     

    RTCRtpSender - Web APIs | MDN

    The RTCRtpSender interface provides the ability to control and obtain details about how a particular MediaStreamTrack is encoded and sent to a remote peer.

    developer.mozilla.org

     

    handleCameraChange 함수에서 replaceTrack을 사용해봆디ㅏ.

    우리는 getMedia를 통해서 새롭게 stream을 생성합니다.

    그리고 여기서 새롭게 변경된 VideoTrack을 videoTrack에 저장하고, 그 내용을 replaceTrack의 매개변수로 넣어서 Peer에게 보내는 Stream에 반영되도록 바꿔줍니다!

     

     

    이제 서버를 돌리면? 카메라를 바꾸면 다른 브라우저의 화면에도 적용됩니다!

     

     


    1. 모바일 테스트

    정말 엄청난 일들을 해냈지만, 아직 완벽하게 해결되지 못한 문제가 있습니다.

    모바일에서 우리가 만든 애플리케이션이 아직 정상적으로 작동하지 않는다는 점인데요..!

     

    현재 로컬에서 돌아가고 있는 우리의 애플리케이션을 휴대폰에서 돌리기 위해 localtunnel을 설치합니다.

    npm i -g localtunnel 을 입력해서 전역에 설치합니다.

     

    localtunnel서버를 전 세계와 공유하게 해줍니다. (물론 영원히 무료로 공유하게 놔두지는 않습니다 ㅠㅠ)

    우리가 만든 서버로 접속할 수 있는 url을 제공해주고, 이 url을 통해서 휴대폰에서 접근할 수 있게 되는 것이죠!

     

     

    이제 lt를 입력하면 우리가 사용할 수 있는 옵션들이 뜹니다. (lt는 localtunnel의 약자입니다!!)

     

     

    우리는 3000번 포트를 사용중이기 때문에 lt --port 3000을 입력해줍니다.

    그럼 url이 뚝딱하고 생성됩니다!!

    (로컬서버가 켜진 상태여야 정상적으로 서버가 작동합니다!)

     

     

    해당 url로 접속하면 localtunnel과 관련된 메시지가 뜨는데, continue를 눌러줍니다.

    그리고 휴대폰으로 같은 url에 접속해보면?

    접속은 잘 되지만, 스트림이 전송이 안되고 아래와 같이 오류가 뜨게 됩니다...

     

     

    이것은 바로 STUN Server가 연결이 되어있지 않기 때문입니다.

    (만약 같은 Wifi 환경이라면 정상적으로 출력됩니다!!)

     

    STUN Server컴퓨터가 공용 IP주소를 찾게 해줍니다.

    장치에 공용 주소를 알려주는 서버죠.

    서로 다른 네트워크에서 서로 다른 브라우저를 찾기 위해서는 STUN Server가 필요합니다.

     

    이걸 해결하기 위해서는 사용하고자 하는 STUN 서버의 리스트를 추가해야 합니다.

     

     

    이제 오류 없이 정상적으로 영상이 전송됩니다!

     

     

    다시 강조하지만, STUN 서버는 영상 및 음성을 전달하는 용도가 아니라 각 브라우저의 네트워크를 연결하기 위한 용도라는 점을 꼭 기억해야합니다!!

     


    2. Data Channel

    음성이나 영상이 아닌 다른 요소들을 전달할 때 사용할 수 있는 아주 훌륭한 기능인 Data Channel에 대해서 추가로 설명하겠습니다!

     

    아래 레퍼런스를 참고하시면 조금이나마 이해에 도움이 될 수 있습니다.

     

     

    WebRTC data channel 사용하기 - Web API | MDN

    RTCPeerConnection 인터페이스를 사용하여 WebRTC Peerconnction을 연결하면 이제 두 Peer간의 커넥션을 통하여 미디어 데이터를 주고 받을수 있게됩니다. 그뿐아니라 WebRTC로 할수 있는 일은 더 있습니다.

    developer.mozilla.org

     

    먼저 데이터채널 저장용 변수를 하나 만듭니다.

     

     

    그리고 처음 접속했을 때 myPeerConnection의 createDataChannel을 통해 데이터채널을 만들어줍니다.

    먼저 접속한 브라우저가 데이터 채널을 만드는 것이죠.

    그리고 메시지가 오면 콘솔창에 띄우는 이벤트리스너도 생성합니다.

     

    나중에 접속한 브라우저는 offer를 받기 전에 새롭게 datachannel이 들어오면 발생할 이벤트리스너를 생성합니다.

    브라우저의 myDataChannel을 받아온 event에서의 channel로 설정하고 메시지가 오면 콘솔창에 띄우도록 설정하는 것이죠.  

     

     

    이제 각각의 브라우저에서 myDataChannel.send()를 통해 메시지를 보내보면?

    상태의 콘솔창에 보낸 메시지가 뜨는 모습을 확인할 수 있습니다.

    아주 쉽죠?!! 이걸 통해서 채팅 이외의 다양한 요소들도 Peer-to-Peer로 전송할 수 있습니다!

     

     


    3. 정리

    우리가 WebRTC로 만든 Zoom은 충분히 훌륭하지만, 아직 미흡한 점도 많습니다.

    특히 WebRTC로 영상이나 음성을 전송할 때 조심해야 할 부분이 바로 peer 수와 관련된 부분입니다.

    peer이 늘어나면 그만큼 느려지기 시작하는 것이죠.

    우리가 사용하는 Full Mesh방식은 연결된 사람의 수가 늘어나게 된다면 기하급수적으로 데이터 전송횟수가 많아지게 됩니다.

    점점 비효율적이 되는 것이죠....

     

    mesh architecture의 예시

     

    이렇게 peer가 늘어나는 경우에는 WebRTC 대신 SFU라는 기술을 사용합니다.

    하나의 서버에 의존하는 방식이죠.

    이 방식을 사용하면 업로드는 1번만 해도 되고, 그래도 다운로드는 n번 해야하지만, 서버에서 압축된 내용을 받기 때문에 훨씬 안정적입니다. (대신 중요한 상황이 아니라면 저사양의 스트림을 받게 됩니다.)

     

     

    따라서, WebRTC를 이용해서 Full Mesh방식으로 영상과 음성을 전송하는 경우 최대 3명정도까지가 적당하고, 그 이상으로 넘어가면 큰 부하가 생기게 됩니다.

    완벽한 Zoom을 구현하기 위해서는 SFU방식이 필연적인 것이죠.

    그래서 Zoom은 정말 큰 서버를 소유하고 있다고 합니다.

     


    4. 소스 코드

    // 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을 공유하기 위한 변수
    let myDataChannel;
    
    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); // video device의 새로운 id로 또 다른 stream을 생성
        if(myPeerConnection){
            const videoTrack = myStream.getVideoTracks()[0];
            const videoSender = myPeerConnection
                .getSenders()
                .find(sender => sender.track.kind === "video");
            videoSender.replaceTrack(videoTrack);
        }
    }
    
    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 initCall(){
        welcome.hidden = true;
        call.hidden = false;
        await getMedia();
        makeConnection();
    }
    
    async function handleWelcomeSubmit(event){
        event.preventDefault();
        const input = welcomeForm.querySelector("input");
        await initCall();
        socket.emit("join_room", input.value, ); // 서버로 input value를 보내는 과정!! initCall 함수도 같이 보내준다!
        roomName = input.value; // 방에 참가했을 때 나중에 쓸 수 있도록 방 이름을 변수에 저장
        input.value = "";
    }
    
    welcomeForm.addEventListener("submit", handleWelcomeSubmit);
    
    
    // Socket Code
    
    socket.on("welcome", async () => {
        myDataChannel = myPeerConnection.createDataChannel("chat"); // offer를 만드는 peer가 DataChannel을 만드는 주체
        myDataChannel.addEventListener("message", event => console.log(event.data)); // 메세지를 받는 곳
        console.log("made data channel");
        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", async (offer) => {
        myPeerConnection.addEventListener("datachannel", event => { // offer를 받는 쪽에서는 새로운 DataChannel이 있을 때 eventListener를 추가한다
            myDataChannel = event.channel;
            myDataChannel.addEventListener("message", event => console.log(event.data)); // 메세지를 받는 곳
        });
        console.log("received the offer");
        myPeerConnection.setRemoteDescription(offer); // 다른 브라우저의 위치를 myPeerConnection에 연결해 주는 과정
        const answer = await myPeerConnection.createAnswer();
        myPeerConnection.setLocalDescription(answer); // 현재 브라우저에서 생성한 answer를 현재 브라우저의 myPeerConnection의 LocalDescription으로 등록!!
        socket.emit("answer", answer, roomName);
        console.log("sent the answer");
    })
    
    socket.on("answer", answer => {
        console.log("received the answer");
        myPeerConnection.setRemoteDescription(answer);
    });
    
    socket.on("ice", ice => {
        console.log("received candidate");
        myPeerConnection.addIceCandidate(ice);
    })
    
    // RTC code
    
    function makeConnection() {
        myPeerConnection = new RTCPeerConnection({ // 구글의 STUN 서버를 빌려서 사용!! 테스트용도로만 쓸 것!!
            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",
                    ]
                },
            ],
        }); // peerConnection을 각각의 브라우저에 생성 https://developer.mozilla.org/ko/docs/Web/API/RTCPeerConnection 참조
        myPeerConnection.addEventListener("icecandidate", handleIce);
        myPeerConnection.addEventListener("addstream", handleAddStream);
        myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream)); // 영상과 음성 트랙을 myPeerConnection에 추가해줌 -> Peer-to-Peer 연결!!
    }
    
    function handleIce(data){
        console.log("sent candidate");
        socket.emit("ice", data.candidate, roomName);
    }
    
    function handleAddStream(data){
        const peersFace = document.getElementById("peersFace");
        peersFace.srcObject = data.stream;
    }
    // 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) => {
            socket.join(roomName);
            socket.to(roomName).emit("welcome"); // 특정 룸에 이벤트 보내기
        });
    
        socket.on("offer", (offer, roomName) => { // offer이벤트가 들어오면, roomName에 있는 사람들에게 offer 이벤트를 전송하면서 offer를 전송한다.
            socket.to(roomName).emit("offer", offer);
        });
    
        socket.on("answer", (answer, roomName) => {
            socket.to(roomName).emit("answer", answer);
        });
    
        socket.on("ice", (ice, roomName) => {
            socket.to(roomName).emit("ice", ice);
        })
    });
    //- 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
                        video#peersFace(autoplay, playsinline, width="400", height="400")
                
            //- 프론트엔드에 웹소켓 설치
            script(src="/socket.io/socket.io.js") 
            script(src="/public/js/app.js")

     


    5. 참고 자료

     

    RTCRtpSender - Web APIs | MDN

    The RTCRtpSender interface provides the ability to control and obtain details about how a particular MediaStreamTrack is encoded and sent to a remote peer.

    developer.mozilla.org

     

    WebRTC data channel 사용하기 - Web API | MDN

    RTCPeerConnection 인터페이스를 사용하여 WebRTC Peerconnction을 연결하면 이제 두 Peer간의 커넥션을 통하여 미디어 데이터를 주고 받을수 있게됩니다. 그뿐아니라 WebRTC로 할수 있는 일은 더 있습니다.

    developer.mozilla.org

     


    먼저 정말 고생하셨습니다!

    수업에서 다루지 않은 내용은 3명 이상의 Peers, Peer가 나갔을 때의 처리, 데이터채널의 더 많은 사용, CSS 개선 등이 있습니다.

    이 내용들을 직접 적용해가면서 WebRTC에 대한 흥미와 실력을 늘려가실 수 있었으면 좋겠습니다 :)

    마지막으로 제가 정리해놓은 자료들을 올려놓은 깃허브 주소를 남겨드립니다!

     

     

    GitHub - alittlekitten/zoomCC: 노마드코더 줌 클론코딩

    노마드코더 줌 클론코딩. Contribute to alittlekitten/zoomCC development by creating an account on GitHub.

    github.com

     

    다시 한 번 긴 포스팅 읽어주셔서 감사합니다!!

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