이전 내용을 통해 채팅 시스템을 구현하는 것에 대해 알아보았습니다.

이번에는 Zoom의 핵심 기능인 영상 및 음성 스트림 기능을 구현해보도록 하겠습니다!

 

※ 본 내용은 줌 클론코딩의 3.0~3.2 까지의 수업 내용을 담고 있습니다!

 

목차


    0. 사용자로부터 영상받아 띄우기

    시작하기 전에 채팅에 대한 내용을 싹 다 지우고 새롭게 시작합니다.

     

    // 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 프로토콜 모두 이해할 수 있게 된다!
    // app.js
    
    const socket = io(); // io function은 알아서 socket.io를 실행하고 있는 서버를 찾을 것이다!
    //- 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
            
            //- 프론트엔드에 웹소켓 설치
            script(src="/socket.io/socket.io.js") 
            script(src="/public/js/app.js")

     

    먼저 home.pug에서 메인 아래에 video 클래스를 하나 만들어줍니다.

    이곳에서 myFace라는 아이디를 통해 사용자로부터 영상을 받아오려고 합니다.

     

    매개변수로 autoplay, playsinline, width, height를 받는데, autoplay와 width, height는 말하지 않아도 쉽게 자동재생과 크기라는 것을 알 수 있는데, playsinline은 좀 생소하죠?

    playsinline모바일에서 영상이 전체화면이 되지 않도록 하는 기능을 합니다.

     

     

    이제 app.js로 넘어와서 myFace 변수에 document에 있는 myFace를 가져와서 담습니다.

    그리고 getMedia 함수를 만들어서 유저의 유저미디어 string을 받아옵니다.

    async를 이용해서 비동기로 받기 때문에, try catch문을 사용해야 합니다! (비동기 처리 부분의 설명도 생략합니다!!)

    만든 함수를 마지막에 실행시켜주는 것을 잊지 맙시다!

    (console.log(myStream)은 확인을 위한 부분입니다!)

     

     

    여기까지 만든 내용을 저장하고 npm run dev를 통해 서버를 실행하게 되면 마이크와 카메라 권한을 요청하게 됩니다.

    최초 실행시에는 권한 여부를 모르기 때문에 권한을 요청하고, 한번 허용이나 차단을 하게 되면 따로 찾아서 권한을 바꿔줄 수 있습니다.

    (favicon의 리액트 표시는... 리액트를 하다와서 그렇습니다...)

     

     

    허용하게 되면, 콘솔창에 아까 찍어둔 myStream이 출력됩니다.

    그리고 크롬 탭에 녹화중 표시가 뜨는 것도 확인할 수 있습니다!!

     

     

    잘 나오는 것을 확인했으니 console.log로 찍은 부분을 myFace에 넣어봅시다.

    myFace.srcObject = myStream;을 해주면 됩니다.

     

     

    여기까지 만든 내용을 저장하고 서버를 실행하면!!!

    웹캠과 마이크에서 받아온 정보들을 화면에 뿌려줍니다!!

     

    이제 화면이 출력됩니다!

     


    1. 영상/음성 조작 버튼 만들기

    1.0. 버튼 생성

    기능을 구현하기 전에 먼저 버튼부터 만들어줍시다!

    먼저 video부분을 따로 myStream이라는 id를 가진 div에 넣어주고, 그 아래에 진행하도록 하겠습니다.

    home.pug에 버튼을 구현해줍니다.

     

     

    버튼을 클릭했을 때 상태변화를 감지하기 위한 이벤트를 만들어주고, 상태를 담고 있는 변수도 만들어줍니다.

    이 내용은 이전 포스팅에서 많이 다뤘기 때문에 자세하게 설명하지 않고 넘어가겠습니다.

     

     

    아래와 같이 버튼이 생성되고, 클릭했을 때 변경되면 완료입니다!!

     

     

    1.1. 버튼 기능 구현

    이제 myStream과 연결을 해줍시다.

    myStream의 getAudioTracks 함수를 통해 오디오 부분을 가져올 수 있습니다.

    실제로 console.log를 이용해 찍어보면 상태와 마이크명도 확인할 수 있습니다.

     

     

     

    우리가 다뤄야 할 부분은 enabled를 false로 만들고, 다시 true로 만드는 부분이 되겠죠?

    아래와 같이 작성함으로써 클릭할 때마다 속성을 변경시켜줄 수 있습니다.

     

     

    여기까지 만들고 Trun Camera Off 버튼을 누르면??

    화면이 꺼집니다!!!

    (물론 Mute 버튼도 잘 동작합니다 ㅎㅎ)

     

     


    2. 사용자 장치 받아오기

    이번엔 사용자로부터 장치를 받아오도록 해보겠습니다.

    getCameras 함수를 만들어서 카메라 목록을 불러오려고 합니다.

    getCameras 역시 비동기로 동작하기 때문에 try catch문을 사용해줍니다.

    그리고 navigator.mediaDevices.enumerateDevices()를 통해 장치 리스트를 가져올 수 있습니다.

    console.log로 찍어보면 현재 제가 사용중인 장치도 확인할 수 있죠.

    참 많네요 ;;;

     

     

    여기서 우리는 비디오 부분만 빼오고 싶습니다.

    filter 함수를 이용해서 videoinput 속성의 장치만 빼옵니다!!

     

     

     

    그리고 지금 만든 getCameras를 실행시키기 위해 getMedia 함수 내에서 getCameras를 실행해줍니다.

    await으로 실행해서 비동기로 동작하게 해야한다는 점 잊지 마세요!

     

     

     

    이 내용들을 사용자에게 보여준 후에 사용할 카메라를 선택하게 해 줘야겠죠?

    home.pug에 select div를 하나 만들어주고, option을 넣어줍니다.

     

     

    그리고 app.js에서 값들을 넣어줍니다.

    각 항목에 대한 설명은 주석으로 달아두었습니다.

    value에는 카메라의 고유 값을 지정해주고, 사용자에게 보이는 선택화면에는 label을 달아서 보기 편하게 만들었습니다.

    label이 보기는 좋지만 카메라의 고유한 정보를 담진 않기 떄문에 반드시 카메라의 deviceId가 value로 들어가야 합니다.

     

     

    여기까지 완료하면, 드롭박스가 잘 생성된 것을 확인할 수 있습니다.

     

     


    3. 영상 장치 변경하기

    이제 드롭박스에서 장치를 변경하면 새로운 장치에서 받아온 자료를 화면에 뿌려주는 부분을 구현해보겠습니다.

     

    먼저 장치가 변경되었음을 감지하기 위해 새로운 이벤트리스너를 등록해줘야합니다.

    camerasSelect의 input이 변경되면, getMedia함수를 실행시켜서 새롭게 영상을 출력할 수 있도록 만들어줍니다.

     

    그런데 뭔가 이상한 부분이 있죠?

    getMedia는 원래 매개변수가 없었는데, camerasSelect의 value가 매개변수로 들어갑니다.

    camerasSelect.value가 뭘까요?

    우리는 아까 이 value에 카메라의 고유한 값인 deviceId를 저장해두었습니다.

    이 값에 해당하는 장치의 영상을 받아오기 위해 deviceId를 매개변수로 보내주었고, 이걸 getMedia에서 사용할 수 있습니다.

     

     

    매개변수를 바꿔주었으니, getMedia 함수에서도 수정해야겠죠?

     

    아까 설명을 생략했는데, getUserMedia에는 constraints라는 객체가 하나 들어갑니다.

    이 객체 안에는 오디오와 비디오의 정보가 들어가죠.

     

    출처 : https://developer.mozilla.org/ko/docs/Web/API/MediaDevices/getUserMedia

     

    우리는 매개변수인 constraints의 video의 값을 설정해주면 됩니다.

     

    이제는 선택한 카메라를 실행시켜주기 위해 deviceId를 받아오는 부분이 추가가 되었죠?

    해당하는 deviceId를 가진 장치로 데이터를 받아오기 위해서 exact라는 key값을 사용합니다.

    이걸 사용하면 해당 deviceId를 선택하여 그 장치로부터 받은 데이터를 뿌려주게 됩니다.

     

    그런데 맨 처음에는 deviceId를 받지 않죠?

    그래서 initialConstraints값을 통해서 카메라를 자동으로 받아오게 합니다.

    그리고 initialConstraints의 값이 getUserMedia의 매개변수로 들어가게 해야합니다.

    여기서 facingMode: "user"가 들어갔는데, 모바일의 경우 전면 카메라를 찾아서 출력하게 하기 위함입니다.

    컴퓨터의 경우 전후면 카메라가 있는 것이 아니기 때문에 알아서 카메라를 찾게 됩니다.

     

    그리고 우리가 드롭다운메뉴를 통해 변경하게 되면 deviceId가 들어가게 되기 때문에 cameraConstraints의 값이 getUserMedia의 매개변수로 들어가게 해야 합니다.

    cameraConstraints의 video 키값에는 deviceId라는 객체가 들어가게 되고, exact로 매개변수로 받은 deviceId를 사용하게 되는 것이죠.

     

    이 말을 간단하게 표현하면 deviceId를 안받는 맨 처음에는 initialConstraints, deviceId를 받는 그 외 상황에는 cameraConstraints를 getUserMedia의 매개변수로 들어가게 하면 되는 것입니다.

    그리고 이걸 삼항연산자(? 연산자)를 통해 간단하게 구현할 수 있습니다.

     

     

    여기까지 만들고 저장한 후에 실행하면, 드롭다운메뉴로 선택된 카메라가 화면에 뜨게 됩니다!

     

     


    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");
    
    
    // stream받기 : stream은 비디오와 오디오가 결합된 것
    let myStream;
    
    let muted = false; // 처음에는 음성을 받음
    let cameraOff = false; // 처음에는 영상을 받음
    
    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);
        }
    }
    
    getMedia();
    
    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);
    // 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 프로토콜 모두 이해할 수 있게 된다!
    //- 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
                //- 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")

     


    5. 참고 자료

     

    MediaDevices.getUserMedia() - Web API | MDN

    MediaDevices 인터페이스의 getUserMedia() 메서드는 사용자에게 미디어 입력 장치 사용 권한을 요청하며, 사용자가 수락하면 요청한 미디어 종류의 트랙을 포함한 MediaStream (en-US)을 반환합니다.

    developer.mozilla.org

     

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