이전 포스팅에서 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

     

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