[WebRTC] 1:1 실시간 미디어 스트리밍 구현 (바닐라 JS + Node.js)

2024. 1. 17. 22:03
반응형

 

 

WebRTC(Web Real-Time Communication)는 웹 브라우저 간에 실시간으로 음성, 영상 및 데이터 통신을 가능하게 하는 오픈 소스이다. 

 

Google Meet, Zoom, Facebook Messenger 등 실시간 미디어 스트리밍이 사용되는 어플리케이션에서 많이 사용되고 있으며, 초기 연결에 성공한 이후에는 P2P Connection을 통해 데이터를 주고받는다는 특징이 있다.

 

 

 


Web RTC의 특징

 

P2P (Peer to Peer)

웹 애플리케이션과 사이트가 중앙 서버 없이, 오디오나 영상 미디어 스트림을 교환하고, 임의의 데이터를 교환할 수 있다.

데이터들은 중앙 서버를 거치지 않기 때문에 빠른 속도가 보장되며, 실시간으로 상호작용 할 수 있다는 특징이다.

 

브라우저 호환성

Chrome에서 특히 호환성이 높다는 특징을 가진다. 이는 Google 주도의 오픈소스 프로젝트이기 때문에 다른 브라우저에서는 호환성 지원 수준이 제각각이다. 즉, 아직까지는 표준화가 완전하지 않은 기술이며, 호환성 문제를 고려하여 사용해야 한다.

라이브러리 Adapter.js의 사용은 다양한 플랫폼에서 WebRTC 구현 호환성 문제를 완화시키는 데에 도움을 줄 수 있다.

 

실시간 스트리밍 특화

음성, 영상과 같은 스트리밍을 P2P 방식을 이용하여 주로 UDP 프로토콜을 이용하여 통신하게 된다. 따라서 일반적인 Centralized 서버 + TCP를 이용하는 경우보다 빠르고, 실시간의 통신을 구현할 수 있게 되는 것이다. 단, P2P와 UDP의 특징이 신뢰할 수 없다는 점이 있어 중간의 일부 파일 교환이 실패할 수는 있지만, 연속된 스트림 데이터의 경우 해당 방식이 이점이 많고, WebRTC 자체에서 손상된 스트림에 대한 복구 처리 기능을 포함하고 있다.

 

 

 


 

Web RTC 핵심 요소들

 

https://www.wowza.com/blog/webrtc-server-what-it-is-and-why-you-need-one

 

  • ICE(Interactive Connectivity Establishment)  : WebRTC는 해당 프레임워크를 사용하여 P2P 연결을 설정한다. 이를 통해 두 클라이언트 간에 최적의 통신 경로를 찾고 활용할 수 있다.

  • STUN 서버 : 클라이언트의 Public IP 주소 및 포트를 찾아내는 역할을 한다. 클라이언트는 먼저 STUN 서버에게 자신의 IP 주소와 포트를 요청하고, 이 정보를 상대 Peer와 공유한다. 이는 클라이언트들이 서로에게 직접 연결이 가능하도록 외부에서 접근 가능한 주소를 알려주는 역할로 사용된다. 이후 얻어낸 공인 IP 서버를 기반으로 Peer은 여러개의 연결 방식의 후보군(ICE Candidate)을 교환하고, 해당 Candidate 중 최적의 연결을 결정하게 된다. Google에서는 제공하는 Stun 서버가 많이 사용되고 있다.

    • TURN 서버  (차선책): Stun 서버를 이용했을 때, P2P 연결이 직접적으로 수립되지 않을 때가 있다. 라우터들의 방화벽 정책이나 보안 연결로 인한 제한이 있을 수도 있기 때문이다. 해당 경우에는 차선책으로 TURN 서버를 사용하여 중계된 데이터 전송이 가능하도록 한다. 특히 보안 정책이 엄격한 NAT(Network Address Translation)에서 사용을 고려할 수 있으나, STUN 서버와 달리 중간에 서버를 한번 거치는 방식으로 바뀌기 때문에 차선책으로 선택되어야 한다.

 

  • 시그널링 서버 : 클라이언트들이 서로의 주소를 알아내고 통신을 시작하기 위해서는 시그널링 서버가 필요하다. 통신 시작을 위한 연결 정보를 교환하는 중계 서버의 역할. 클라이언트들 간의 연결 메타데이터 및 통신을 위한 제어 메시지를 전달해주는 중계 서버 역할을 한다. 시그널링 서버를 통해 연결 설정이 완료되었고 P2P 통신이 시작되었다면 시그널링 서버를 경유하지 않고 통신하게 된다. 시그널링 서버를 직접 구축한다면 WebSocket 등을 이용한 서버 등을 사용하게 된다.
    과정으로는 SDP 교환을 하는 Offer/Answer, ICE Candidate 교환을 하는 Candidate 과정이 있다.


    • SDP : 피어 간에 미디어 연결 규약 정보 등을 교환하는 데 사용되는 프로토콜이다. 미디어의 유형, 포맷, 포트, 프로토콜 등의 미디어 및 연결 설정이 SDP에 포함된다.
    • ICE Candidate : Peer가 사용 가능한 네트워크 주소와 포트에 대한 정보를 포함한다. 이는 주로 STUN 서버를 통해 얻은 공인 IP 주소 및 포트 정보를 기반으로 상대가 나에게 연결할 수 있는 여러개의 후보군을 만들어낸다.
    • 쉽게 말해 SDP는 미디어 스트림 관련 연결 정보, ICE Candidate는 상대가 내 컴퓨터에 연결할 수 있는 주소(ICE)들의 후보!

  • Peer  : P2P 통신을 할 당사자들인 두 클라이언트를 말한다. 이들은 Stun 서버를 통해 자신들의 외부에서 연결 가능한 주소를 얻어내어, 이를 시그널링 서버를 통해 상대와 교환한다(Offer / Answer). 교환이 완료되었을 때, ICE Candidate를 통하여 최적의 연결 방식을 결정하여 P2P 통신을 시작하게 된다.

 

 

 


P2P 연결 과정

 

Offer / Answer

 

먼저 Peer 1이, 자신의 로컬 미디어 스트림(캠코더, 마이크) 등의 SDP를 생성하고 Offer를 Signaling 서버로 날린다. 이 때, Signaling Server의 Room에는 자신밖에 없으므로 아무에게도 전해지지 않는다.

 

 

 

Peer 1이 Signaling Server에 접속해 있는 상태에서, Peer 2가 이어서 SDP를 생성하고 Offer를 Signaling Server로 날린다.

이 때 Peer 1이 있으므로, Peer 2의 Offer가 Peer 1에게 전달된다.

Offer를 수신한 Peer 1은 Answer로 자신의 SDP를 마찬가지로 Signaling Server를 경유하여 전달한다.

 

해당 과정을 통해 서로의 미디어 유형, 정보, 프로토콜 등을 Local Connection의 상대 Peer의 SDP 정보를 설정할 수 있게 되었다.

 

 

 

 


 

Candidate

 

 

 

이어서 Peer들은 STUN 서버로부터 자신들의 외부에서 접근할 수 있는 주소와 포트를 받아온다.
이를 기반으로 자신을 연결할 수 있는 방법들의 후보인 ICE Candidate를 생성한다.

 

 

 

 

해당 ICE Candidate를 마찬가지로 Signaling Server를 통해서 교환하는 과정이 Candidate 과정이다.

 

 

 

 

이제 서로 상대의 미디어 정보와 ICE Candidate를 알고 있으니, 상대가 보내 준 Candidate 중 가장 연결하지 좋은 후보군을 결정하여 서로 연결하는 과정이 끝난다면, 연결이 완료된 것이고 이제부터는 P2P 연결이 시작되게 된다.

 

 


WebRTC 사용한 1:1 화상통화 만들기

 

 

 

Signaling Server (Node.js)

 

const express = require('express');
const SocketIO = require('socket.io');


const app = express();

var server_port = 5004;
const server = app.listen(server_port, () => {
    console.log("Started on : "+ server_port);
})

var io = SocketIO(server, {
    cors: {
      origin: "*"
    }
});


const maxClientsPerRoom = 2;
const roomCounts = {};

io.on('connection', (socket) => {



    socket.on("join", (roomId) => {
        // 클라이언트가 Room에 조인하려고 할 때, 클라이언트 수를 확인하고 제한.
        if (roomCounts[roomId] === undefined) {
            roomCounts[roomId] = 1;
        } 
        else if (roomCounts[roomId] < maxClientsPerRoom) {
            roomCounts[roomId]++;
        } 
        else {
            // 클라이언트 수가 제한을 초과하면 클라이언트를 Room에 입장시키지 않음.
            socket.emit('room-full', roomId);
            console.log("room full" + roomCounts[roomId]);
            return;
        }
        socket.join(roomId);
        console.log("User joined in a room : " + roomId + " count:" + roomCounts[roomId]);


        // 클라이언트가 Room을 떠날 때 클라이언트 수를 업데이트
        socket.on('disconnect', () => {
            roomCounts[roomId]--;
            console.log("disconnect, count:" + roomCounts[roomId]);
        });
    })



    socket.on("rtc-message", (data) => {
        var room = JSON.parse(data).roomId;
        socket.broadcast.to(room).emit('rtc-message', data);
    })


})

 

yarn add express
yarn add socket.io@3.0.4

 

시그널링 서버로 사용될 Node.js 서버를 간단하게 만들어주었다.

Socket.io를 활용하여 상호간의 연결 정보를 교환하는 용도의 중계 서버로 사용할 것이며, 1:1 스트리밍을 구현할 것이므로 (1:N은 매우 복잡해진다.) 하나의 Room에는 2명 이상이 들어오지 못하도록 가장 간단한 방식으로 제어해주었다.

 

서로 같은 Socket Room에 접속한 Peer들이 메시지를 교환할 수 있도록 해주자.

 

 

 

 

 

Peers (Vanila JS)

 

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    
    <title>Web RTC</title>
</head>
<body>
    <h1>실시간 P2P 통신을 해보자</h1>
    <button onclick="createOffer();">Connection</button><br>
    <div>나</div>
    <video id="myFace" playsinline autoplay width="300" height="300"></video>
    <br>
    <div>상대</div>
    <div id="peerZone" style="width:1280px; height:720px; margin:0px; padding:0px;">
        <video id="peerVideo" playsinline autoplay width="1280" height="720"></video>
    </div>


    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/3.0.4/socket.io.js" crossorigin="anonymous"></script>
    <script>

        var socket;
        var room;

        // 미디어와 관련된 변수를 선언
        var myFace = document.getElementById("myFace");
        var myStream;   //영상 스트림

        // ICE 정보를 가져올 STUN 서버 사용
        var iceServerConfig = {
            "iceServers" : [{
                "url" : "stun:stun.l.google.com:19302"
            }]
        }
        // RTCPeerConnection : P2P 연결을 설정하고 관리하는 객체
        var myPeerConnection = new RTCPeerConnection(iceServerConfig);
        
        window.onload = () => {
            room = prompt("Room ID를 입력하세요! (상대방과 연결할 때 같은 Room이어야 함!) : ");
            if(room.trim().length == 0) {
                location.reload(true);
            }

            // 시그널링 서버 -> 만든 Socket.io 서버 사용
            socket = io.connect('http://localhost:5004'); // Your Signaling Server.
            socket.emit("join", room);  // 시그널링 서버 접속

            // Room Join -> 이미 접속한 Master 존재
            socket.on('room-full', async (message) => {
                alert("입장 인원 초과");
                location.reload(true);
            })
            
            socket.on('rtc-message', async (message) => {

                var content = JSON.parse(message);
                // Offer 수신 : 누군가가 오퍼를 받음
                if (content.event == "offer") {
                    console.log("Receive Offer", content.data);
                    var offer = content.data;
                    myPeerConnection.setRemoteDescription(offer);   //받은 Offer SDP -> 상대 피어에 대한 원격 설정으로 저장


                    await getMedia();
                    // 상대 Peer와 공유할 미디어 트랙을 추가
                    myStream.getTracks().forEach((track) => myPeerConnection.addTrack(track, myStream));

                    var answer = await myPeerConnection.createAnswer();

                    //Answer로 자신의 SDP를 보냄
                    console.log("Send Answer");
                    send({
                        event: "answer",
                        data: answer
                    })

                    //자신의 Local ICE Candidate를 Stun Server로부터 얻어와 등록-> onicecandidate 트리거 -> Candidate를 Socket에 Answer로 보냄
                    myPeerConnection.setLocalDescription(answer);   
                } 

                // Answer 수신 : 오퍼를 보내고 나서 응답이 옴 
                else if (content.event == "answer") {
                    console.log("Receive Answer");
                    answer = content.data;
                    myPeerConnection.setRemoteDescription(answer); //받은 Offer SDP -> 상대 피어에 대한 원격 설정으로 저장
                } 
                
                // Candidate 수신 
                else if (content.event == "candidate") {
                    console.log("Receive Candidate");
                    myPeerConnection.addIceCandidate(content.data); //// Remote Description에 설정되어있는 Peer와의 연결방식을 결정
                }
            })
        }

        // Signaling Server에 메세지를 보내는 Function
        async function send(message) {
            const data = {
                roomId: room,
                ...message
            }
            socket.emit("rtc-message", JSON.stringify(data));

        }



        // 자신의 Local ICE Candidate를 Stun Server로부터 얻어와 등록되었을 때 자동으로 트리거된다. 상대에게 Candidate를 보낸다.
        // (Local Description 설정하면 작동)
        myPeerConnection.onicecandidate = function(event) {
            console.log("Send Candidate");
            send({
                event: "candidate",
                data: event.candidate
            })
        }


        // Connection이 이루어져 피어의 스트림이 내 RTC에 등록되면 시작되는 메서드
        myPeerConnection.addEventListener("addstream", handleAddStream);

        function handleAddStream(data) {
            console.log("Receive Streaming Data!");

            var peerVideo = document.getElementById("peerVideo");
            peerVideo.srcObject = data.stream;
        }


        // Offer를 먼저 전송하는 버튼을 클릭했을 때 실행
        async function createOffer() {


            await getMedia();
            // 상대 Peer와 공유할 미디어 트랙을 추가
            myStream.getTracks().forEach((track) => myPeerConnection.addTrack(track, myStream));
            
            // 상대 Peer에게 보낼 SDP Offer 생성
            var offer = await myPeerConnection.createOffer();

            // 시그널링 서버로 Offer 전송
            await send({
                event: "offer",
                data: offer
            })
            console.log("Send Offer");

            //자신의 Local ICE Candidate를 Stun Server로부터 얻어와 등록-> onicecandidate 트리거 -> Candidate를 Socket에 Answer로 보냄
            myPeerConnection.setLocalDescription(offer);

        }


        // Browser의 미디어 Stream을 얻어낸다.
        async function getMedia() {
            try {
                myStream = await navigator.mediaDevices.getUserMedia({
                    audio: true,
                    video: true,
                    
                });
               
                // 화면을 받고 싶은 경우
                // myStream = await navigator.mediaDevices.getDisplayMedia({
                //     video: { 
                //         displaySurface: 'window' 
                //     } 
                // })
                myFace.srcObject = myStream;

            } catch (e) {
                console.log("미디어 스트림 에러");
            }
        }        
    </script>
</body>
</html>

 

해당 코드는 클라이언트 피어에 해당한다.  동작 방식은 아래와 같다.

기본 설정

1. RTCPeerConnection : P2P 연결을 설정하고 관리하는 객체이다. 이 곳에 나와 상대의 연결 정보를 저장해둔다.

2. iceServerConfig : RTCPeerConnection에 사용될 ICE를 가져올 서버로 Google Stun Server를 지정하였다.

3. Signaling Server : Socket.io Client를 사용하여 프롬프트에 Room ID를 이용하여 시그널링 서버에 접속한다

4. HTML에 미디어 구성을 만들어두고, 이것의 스트림을 시작할 수 있는 getMedia() 함수 준비

 

Connection 과정

1. Connection 버튼을 누르게 되면 영상(Media Stream)이 시작되면서 해당 SDP가 같은 Room에 속한 Peer에게 Offer가 전송된다.

2. Offer를 받은 상대방 Peer 또한 Media Stream이 시작되면서 SDP를 Answer로 보내게 된다.

3. Offer와 Answer를 전송할 때, 자신의 Offer/Answer를 LocalDescription에 등록하게 되는데, 이는 Stun Server로부터 자신의 ICE Candidate를 얻어와 onicandidate 이벤트가 트리거되도록 한다.

4. onicandidate가 트리거되면 상대방에게 나의 ICE Candidate들을 Signaling Server를 통해 보내게 된다.(Candidate)

5. 상대방 Peer의 Candidate를 Signaling Server를 통해 수신받아 후보들 중 Peer와의 최적의 연결 방식을 설정한다.

6. Candidate의 교환까지 모두 이루어지면 상대방과 나의 1:1 실시간 스트리밍이 시작된다.

 

 

 

 

 


 

작동 결과

 

Signaling Server를 실행시키고, 두 개의 브라우저에서 Peer HTML 파일을 실행시켜 보자.

아래는 미디어 유형을 다르게 해서 얼굴과 컴퓨터 화면이 통신되게 한 결과이다.

 

 

 

 


References

https://okane-on-cliff.tistory.com/257
https://wormwlrm.github.io/2021/01/24/Introducing-WebRTC.html
https://webrtc.org/?hl=ko
반응형

BELATED ARTICLES

more