이전 포스팅에서 연결의 맛보기를 보여줬다면, 이번 포스팅에서는 Peer-to-Peer 통신을 좀 더 맛깔나게 구현해보도록 하겠습니다!

 

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

 

목차


    0. answer 전송하기

    본 내용은 이전 포스팅의 offer 전송하기와 연결됩니다! (너무 길어서 잘랐어요 ㅠ)

     

    다시 다이어그램을 보겠습니다.

    이전 포스팅에서 이미 방에 접속한 브라우저가 새로 접속한 브라우저에 offer를 날리는 부분까지 구현했었죠?

    그렇다면 이제 새로 접속한 브라우저에서 offer를 처리하는 부분을 구현해야 합니다!

     

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

     

    새롭게 접속한 브라우저에서 offer를 받으면, 그 offer는 Local이 아닌 Remote에 위치해야 합니다.

    그렇기 때문에 offer는 myPeerConnection에서 setRemoteDescription에 들어가야 합니다.

    이 부분이 위 연결 과정에서 setRemoteDescription() 부분에 해당합니다.

     

    그리고 잘 받았음을 확인하기 위해서 Answer를 만들어줍니다. (createAnswer() 부분입니다!!)

    중간에 있는 getUserMedia와 addStream은 브라우저가 방에 접속할 때 작동하기 때문에 굳이 만져줄 필요가 없습니다!

     

    만든 answer는 당연히 새로 접속한 브라우저의 정보가 담겨있겠죠?

    그렇기 때문에 answer는 브라우저의 LocalDescription에 넣고, answer 이벤트를 발생시켜서 answer를 서버로 보냅니다.

     

     

    다시 아까와 비슷한 과정입니다.

    answer를 받으면 방에 있는 모든 브라우저에게 answer를 전송해야 합니다.

    server.js에서 answer 이벤트를 처리할 수 있는 부분을 만들어줍니다.

     

     

    마지막으로 answer 이벤트를 브라우저에서 받으면 RemoteDescription에 넣어줘야겠죠?

    이 부분을 처리할 수 있도록 app.js에 해당 코드를 넣어줍니다.

     

     

    여기까지 하면 연결의 절반이 끝난 것입니다!!

     

     


    1. IceCandidate 전송하기

    위에서 만든 핑퐁작업이 완료되면 IceCandidate 작업이 필요합니다.

    IceCandidate인터넷 연결 생성의 약자입니다.

    IceCandidate는 WebRTC에 필요한 프로토콜을 의미하는데, 이걸 통해 브라우저가 서로 소통할 수 있게 만들어줍니다.

    보다 자세한 내용은 아래 레퍼런스를 참고하세요!

     

     

    RTCIceCandidate - Web API | MDN

    RTCIceCandidate 인터페이스는 WebRTC API의 한 종류로서, RTCPeerConnection을 구축 할 때 사용되기도하는 Internet Connectivity Establishment (ICE (en-US))의 후보군 (candidate)를 말합니다.

    developer.mozilla.org

     

    그렇다면 도대체 이 IceCandidate는 언제 작동하는 걸까요?

    이걸 알아보기 위해서 위에서 만든 요소들의 각 단계에 console.log()를 이용하여 찍어보겠습니다.

     

     

    여기까지 만들고, makeConnection 과정에서 icecandidate 이벤트가 발생하면 데이터를 찍어보도록 만들었습니다.

     

     

    직접 서버를 돌려보면 보내고 받을 때 ice candidate가 발생함을 확인할 수 있습니다!!

     

     

    이제 이렇게 발생한 candidate들을 다른 브라우저로 보내야 합니다.

    위에서 candidate는 브라우저가 서로 소통할 수 있게 해 주는 것이라고 했죠?

    그런데 이 candidate들은 아직 다른 브라우저로 전송되고 있지 않습니다.

     

    이걸 해결하기 위해서는 새로운 이벤트를 만들어줘야 합니다!!

    아까와 같은 방식으로 data를 보내줍니다.

    그런데 우리가 필요한 내용은 data의 candidate 부분이기 때문에, 이것만 보내주면 됩니다!

    그리고 특정 방 안에 있는 모든 사용자에게 전송하기 위한 roomName까지 보내주면 되는 것이죠.

    마지막으로 각 브라우저에서 ice를 받아서 처리하는 부분까지 만들어주면 끝입니다!

     

    app.js
    server.js
    app.js

     

    이제 확인을 위한 console.log()를 작성하고 서버를 돌려서 확인해보면 아래와 같이 잘 작동하는 모습을 눈으로 확인할 수 있습니다!

    (sent the answer 뒤에 candidate를 받았다는 console.log가 떴지만.. 아주 빠른 시간 내에 작동하다보니 이렇게 된 것 같습니다! 동작에는 문제가 없습니다 ㅎㅎ)

     

    (좌) 먼저 접속해있던 브라우저 (우) 새롭게 접속한 브라우저

     

    여기까지 우리가 구현한 부분은 다이어그램에서 아래 부분과 같습니다!

     

     


    2. addstream 이벤트 등록하기

    다이어그램의 마지막 작업으로!! addstream 이벤트를 등록해보겠습니다.

    makeConnection함수에 addstream 이벤트를 추가해줍시다.

    그리고 어떤 정보를 보여주는지 눈으로 확인하기 위한 console.log를 작성해줍니다.

     

     

    그리고 서버를 돌리면??

    서로의 stream id가 뜹니다!!!

    어느 브라우저에서는 자신의 id였던 것이 다른 브라우저에서는 상대의 id로 뜨고 있죠?!!!!!

     

     

    다른 사람의 stream정보를 받아오는 것을 확인했으니, 다른 사람의 stream을 띄울 공간도 마련해줘야겠죠?!

    home.pug에 아래 내용을 작성해줍니다.

     

     

    그리고 app.js에서 받아온 stream을 peersFace에 넣어줍니다!

     

     

    여기까지 만들고 서버를 돌리면?!!!!!!!

    서로의 화면을 확인할 수 있습니다!! (무려 4분할!!!)

    camera off와 mute 버튼도 잘 작동합니다!!

    (좌) 화면 4분할!! (우) turn camera off 버튼 작동!!

     

    하지만 아직 완전하게 작동하지는 않습니다.

    카메라를 바꾸게 되면 상대 브라우저에서는 인식하지 못하는 문제가 발생합니다.

    그리고 휴대폰에서는 작동하지 않는 문제가 있죠.

     

    이 부분은 다음 포스팅에서 다뤄보도록 하겠습니다!!

     


    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 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 () => {
        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) => {
        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(); // 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")

     


    4. 참고 자료

     

    RTCIceCandidate - Web API | MDN

    RTCIceCandidate 인터페이스는 WebRTC API의 한 종류로서, RTCPeerConnection을 구축 할 때 사용되기도하는 Internet Connectivity Establishment (ICE (en-US))의 후보군 (candidate)를 말합니다.

    developer.mozilla.org

     

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