MediaStream API, WebRTC

merci·2023년 7월 13일
0

nodejs

목록 보기
3/3


자바스크립트 API중에서 MediaStream API를 이용해서 영상 회의룸을 만들어 봅시다.

MediaStream API

이 API는 스트리밍 스트림의 읽기, 쓰기, 변환을 위한 인터페이스를 제공하는 WebRTC 관련 API입니다 .

MediaStream은 오디오 트랙, 비디오 트랙같은 MediaStreamTrack 객채로 이루어져 있습니다.
getUserMedia()을 이용해서 사용자의 소스를 가져와 local객체를 만들고 MediaStream에 연결합니다.

이렇게 사용자의 미디어 소스를 가져와 스트림 기능을 수행합니다.

사용자 소스 가져오기

먼저 간단한 뷰를 만듭니다.

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")
        link(rel="stylesheet", href="https://unpkg.com/mvp.css")
        title Noom
    body 
        header
            h1 Noom
        main
            video#myFace(autoplay, playsinline, width='400', height='400')
            
        script(src="/socket.io/socket.io.js")
        script(src="/public/js/app_video.js")

video태그는 모바일에서 전체화면으로 재생하려고 하기 때문에 playsinline 옵션을 추가합니다.
어떤 영상을 재생하고 싶다면 <source src="video.mp4" type="video/mp4"> 소스 태그를 이용합니다.

getUserMedia()를 이용해서 app_video.js를 작성하겠습니다.

const socket = io();

const myFace = document.getElementById('myFace');

let myStream;

async function getMedia(constraints) {
    try {
        myStream = await navigator.mediaDevices.getUserMedia({
            audio: true,
            video: true,
        });
        console.log(myStream);
    } catch (err) {
        console.log(err);
    }
}

getMedia();

먼저 myStream를 전역변수로 선언하고 getUserMedia()함수를 이용해서 비동기로 초기화 합니다.

그리고 간단한 server.js를 작성해서 서버를 만듭니다.

import express from "express";
import http from "http";
import { Server } from "socket.io";

const app = express();

app.locals.title = 'My App';

app.set("view engine", "pug");
app.set("views", __dirname + "/views"); // __dirname 는 실행중인 스크립트의 경로
app.use("/public", express.static(__dirname + "/public")); // express.static 으로 정적파일 제공

app.get('/', function (req, res) {
    res.render('home_video');
});

app.get('/*', (_, res) => { res.redirect("/") });

const server = http.createServer(app);
const io = new Server(server);

const handleListen = () => console.log(app.locals.title + ' is listening on port 3000');
server.listen(3000, handleListen);

서버를 실행시키고 브라우저에서 접근하면 먼저 사용권한을 요청하게 됩니다

미디어 소스 접근 권한을 허용하게 되면 상단탭에 녹화아이콘이 생성됩니다.

브라우저 콘솔을 열어보면 getUserMedia()함수로 생성된 사용자의 미디어스트림객체의 정보를 확인할 수 있습니다.

생성된 미디어스트림객체를 이용해서 브라우저에 사용자의 비디오를 보여줄 수 있습니다.

        myStream = await navigator.mediaDevices.getUserMedia({
            audio: true,
            video: true,
        });
        myFace.srcObject = myStream; 

이제 사용자가 버튼으로 오디오와 비디오를 제어하도록 만들겠습니다.

뷰에 간단한 버튼을 추가합니다.

        button#mute 소리 켜짐
        button#camera 카메라 켜기

버튼에 클릭 이벤트 리스너를 붙여서 현재 상태를 보여주도록 만듭니다.

const muteBtn = document.getElementById('mute');
const cameraBtn = document.getElementById('camera');

let muted = false;
let cameraOff = false;

function handleMuteClick(){
    if(!muted){
        muteBtn.innerText="음소거  "
        muted = true;
    }else{
        muteBtn.innerText="소리 켜짐"
        muted = false;
    }
}

function handleCameraClick(){
    if(cameraOff){
        cameraBtn.innerText="카메라 켜기"
        cameraOff = false;
    }else{ 
        cameraBtn.innerText="카메라 끄기"
        cameraOff = true;
    }
}

muteBtn.addEventListener('click', handleMuteClick);
cameraBtn.addEventListener('click', handleCameraClick);


트랙 제어

버튼이 만들어 졌으니 버튼이 미디어스트림 객체의 오디오 트랙과 비디오 트랙을 제어하도록 만듭니다.
아래의 함수들을 이용하면 트랙 객체를 가져올 수 있습니다.

function handleMuteClick(){
    console.log(myStream.getAudioTracks());
  // 생략
  
function handleCameraClick(){
    console.log(myStream.getVideoTracks());
  // 생략

버튼을 눌러서 콘솔에 나온 정보를 확인하면 id, kind, label 등의 프로퍼티가 있습니다.


버튼이 토글기능을 하도록 수정합니다.

function handleMuteClick(){
    myStream.getAudioTracks().forEach(track => {
        track.enabled = !track.enabled
    });
 // 생략
  
function handleCameraClick(){
    myStream.getVideoTracks().forEach(track => {
        track.enabled = !track.enabled
    });
  // 생략

카메라를 끄게 되면 사용자의 비디오가 off 되면서 검은 화면이 송출됩니다.

트랙 선택

사용자가 여러개의 캠이나 마이크를 pc에 연결했을 경우 선택할 수 있도록 만들겠습니다.

enumerateDevices() 함수는 현재 연결되어 있는 미디어 장비들의 배열 목록을 보여줍니다.

async function getCameras() {
    try {
        const devices = await navigator.mediaDevices.enumerateDevices();
        console.log(devices);
    } catch (err) {
        console.log(err);
    }
}

getMedia() 함수에서 await getCameras(); 실행하도록하고 콘솔을 확인하면 모든 장비 목록이 나옵니다.

사용자가 선택할수 있도록 뷰에 select-option을 생성합니다.

          select#audios
              option(value="device") Choose Audio input


일단 제가 캠이 없어서 오디오를 선택하는 함수를 만들겠습니다.

async function getAudios() {
    try {
        const devices = await navigator.mediaDevices.enumerateDevices();
        const audios = devices.filter(device => device.kind === 'audioinput');
        console.log(audios);
    } catch (err) {
        console.log(err);
    }
}

장비 종류가 audioinput 인 것만 콘솔에 출력합니다.

이 목록에서 간단하게 구별할수 있는 프로퍼티는 label입니다.

label 정보를 가져와서 select-option에 추가합니다.
변경할 디바이스 정보는 deviceId를 이용합니다.

async function getAudios() {
    try {
        const devices = await navigator.mediaDevices.enumerateDevices();
        const audios = devices.filter(device => device.kind === 'audioinput');
        audios.forEach(audio => {
            const option = document.createElement('option')
            option.value = audio.deviceId;
            option.innerText = audio.label;
            audioSelect.appendChild(option);
        })
    } catch (err) {
        console.log(err);
    }
}

옵션을 제거하고 함수가 옵션 태그를 추가하도록 합니다.

            button#mute 소리 켜짐
            button#camera(style="margin-left: 100px;") 카메라 켜기
            select#audios


생성된 옵션 태그들입니다.

기본 미디어 설정

사용자가 브라우저에 들어왔을때 카메라와 오디오의 기본값을 설정합니다.

getUserMedia() 함수에 제약 설정을 추가해서 오디오와 비디오를 가져올지, 어떤 설정으로 가져올지 설정할 수 있습니다.

제약 조건

getUserMedia({
  audio: true,
  video: { width: 1280, height: 720 },
});
getUserMedia({
  audio: true,
  video: {
    width: { min: 1024, ideal: 1280, max: 1920 },
    height: { min: 576, ideal: 720, max: 1080 },
  },
});

폰에서 셀카 캠을 가져오도록 할 수도 있습니다.

getUserMedia({
  audio: true,
  video: { facingMode: "user" },
});

후면 카메라를 가져오고 싶다면

getUserMedia({
  audio: true,
  video: {
    facingMode: { exact: "environment" },
  },
});

특정 카메라 지정하거나 없으면 다른 디바이스를 지정하는 경우입니다.

getUserMedia({
  video: {
    deviceId: myPreferredCameraDeviceId,
  },
});
getUserMedia({
  video: {
    deviceId: {
      exact: myExactCameraOrBustDeviceId,
    },
  },
});

기존의 app_video.js를 수정합니다.

const audioSelect = document.getElementById('audios');

async function getAudios() {
    try {
        const devices = await navigator.mediaDevices.enumerateDevices();
        const audios = devices.filter(device => device.kind === 'audioinput');
        const currentAudio = myStream.getAudioTracks()[0];
        audios.forEach(audio => {
            const option = document.createElement('option')
            option.value = audio.deviceId;
            option.innerText = audio.label;
            if(currentAudio.label == audio.label){
                option.selected = true;
            }
            audioSelect.appendChild(option);
        })
    } catch (err) {
        console.log(err);
    }
}

async function getMedia(deviceId) {
    const initialConstrains = {
        audio: true,
        video: {
            facingMode: "user",
        },
    }
    const audioContrains = {
        audio: {
            deviceId: {
                exact: deviceId,
            },
        },
        video: true
    }
    try {
        myStream = await navigator.mediaDevices.getUserMedia(deviceId ? audioContrains : initialConstrains);
        myFace.srcObject = myStream;
        if(!deviceId){
            await getAudios();
        }
        myStream.getVideoTracks().forEach(track => { // 처음 비활성화
            track.enabled = !track.enabled
        });
    } catch (err) {
        console.log(err);
    }
}

getMedia();

async function handleAudioChange() {
    await getMedia(audioSelect.value);
}

audioSelect.addEventListener('input', handleAudioChange);

deviceId 유무에 따라서 getUserMedia() 에 제약 설정을 다르게 줍니다.

처음 getMedia() 함수가 실행될때는 deviceId가 없으므로 기본 설정으로 미디오 소스를 불러오고
버튼으로 handleAudioChange() 함수를 호출하면 선택된 오디오 소스가 미디어 스트림 객체에 설정됩니다.



이번에는 두명의 사용자가 영상회의할 수 있도록 WebRTC를 이용해보겠습니다.

WebRTC

(Web Real-Time Communication)
웹 브라우저 간에 플러그인의 도움 없이 실시간 음성, 비디오, 데이터 공유를 가능하게 하는 오픈 소스 프로젝트입니다.

WebRTC는 P2P(Peer-to-Peer) 연결을 지원하므로, 사용자 간에 직접적인 데이터 스트리밍이 가능해져서, 실시간 통신을 보다 효율적이고 빠르게 수행할 수 있습니다.

WebRTC의 특징

  • 리얼타임 커뮤니케이션
  • P2P 연결 - 브라우저들끼리 서버를 거치치 않고 데이터를 직접 교환
  • 플러그인 불필요 - WebRTC를 지원하는 브라우저
  • 보안 - 강제로 채널 암호화

가장 큰 특징은 p2p를 지원하는것입니다.
이 점이 일반적인 웹소켓과 다른 WebRTC만의 특이점으로 서버의 리소스를 사용하지 않아 부하가 적습니다.
또한 WebRTC를 지원하는 웹 브라우저만 있으면 영상 통화, 채팅, 파일 공유, 게임 등의 서비스를 만들 수 있습니다.

하지만 p2p연결을 하기 위해서 상대방의 public address(ip, port..)가 무엇인지 알아야 합니다.
그래서 WebRTC는 초기 연결에 서버를 잠깐 사용해서 Peer들의 public address를 알아냅니다.

서버에서 room을 만든다면 Peer가 room에 접속했을때 room안의 Peer들의 public address를 전달해주면 서버가 할 일은 더 이상 없습니다.

WebRTC를 이용해서 영상회의룸을 만들어 봅시다.

          div#welcome
              form
                  input(placeholder="room name", required, type="text")
                  button Enter room
          div#call
              div#myStream
              		// 생략

const welcome = document.getElementById('welcome');
const call = document.getElementById('call');

call.hidden = true;

welcomeForm = welcome.querySelector('form');

function handleWelcomeSubmit(event){
    event.preventDefault();
    const input = welcomeForm.querySelector('input');
    console.log(input.value);
}

welcomeForm.addEventListener('submit', handleWelcomeSubmit);

콘솔에 나오는것을 확인한 후에 서버에 기본 커넥션을 만듭니다.

io.on('connection', (socket) => {
    socket.on('join_room', (roomName, done) => {
        socket.join(roomName);
        done();
        socket.to(roomName).emit('welcome');
    })
});

그다음 app_video.js에서 서버가 실행시킬 함수를 만들고 welcome이벤트 리스너의 마지막 인수로 실행합니다.

function startMedia(){
    welcome.hidden = true;
    call.hidden = false;
    getMedia(); // 미디어 스트림
}

WebRTC - offer

아래의 이미지가 WebRTC의 작동 과정입니다.
이 과정을 참고해서 WebRTC을 이용한 데이터 스트림을 만들어 보겠습니다.

첫번째로 사용자의 미디어를 가져오는 함수가 필요합니다. -> getUserMedia()

두번째로 Peer연결을 만듭니다. -> new RTCPeerConnection()

let myPeerConnection;

async function startMedia(){
    welcome.hidden = true;
    call.hidden = false;
    await getMedia();
    makeConnection();
}

function makeConnection(){ // 연결을 만든다.
    mypeerConnection = new RTCPeerConnection();
}

만들어진 RTCPeerConnection 객체에 사용자의 미디어 스트림을 추가합니다. -> addStream 과정

일단 사용자의 미디어 스트림 객체에서 트랙정보를 확인합니다.

function makeConnection(){ // 연결을 만든다.
    myPeerConnection = new RTCPeerConnection();
    console.log(myStream.getTracks()); 
}

오디오와 비디오 트랙 확인

두 개의 트랙을 RTCPeerConnection에 추가합니다. -> addTrack()

function makeConnection() {
    myPeerConnection = new RTCPeerConnection();
    myStream.getTracks().forEach(track => {
        myPeerConnection.addTrack(track, myStream)
    })
}

RTCPeerConnection 객체의 createOffer() 함수를 이용해서 offer를 생성하고 확인합니다.

socket.on('welcome', async () => {
    const offer = await myPeerConnection.createOffer();
    console.log(offer);
});


WebRTC에서 offerSession Description Protocol(SDP)를 이용한 커뮤니케이션의 시작 부분입니다.
두 Peer 간에 실시간 통신을 시작하기 위해서는 먼저 양쪽 Peer의 미디어 형식과 네트워크 정보를 공유해야 합니다.
이를 위해 offeranswer라는 두 가지 SDP 메시지를 교환합니다.

offer에는 수신 피어가 어떤 미디어 유형을 받을 수 있는지 여부와 호출자의 IP 주소와 포트 번호가 포함됩니다.

Peer는 offer를 분석하고, 자신의 미디어와 네트워크 정보를 포함한 answer를 생성하여 다시 호출자에게 보냅니다.
이렇게 offer와 answer를 교환함으로써 양쪽 Peer는 각자의 미디어와 네트워크 정보를 알게 되며,
실제로 미디어 데이터를 주고받을 준비를 마치게 됩니다.

호출자는 이렇게 생성된 offer 객체를 RTCPeerConnection에 설정합니다. -> setLocalDescription()

socket.on('welcome', async () => { // room에 있는 Peer들은 각자의 offer를 생성 및 제안
    const offer = await myPeerConnection.createOffer();
  	// 각자의 offer로 SDP(Session Description Protocol) 설정
    myPeerConnection.setLocalDescription(offer); 
    console.log('send offer');
    socket.emit('offer', offer, roomName); // 만들어진 offer를 전송
});

이제 서버를 이용해서 offer를 보내 Signaling을 합니다.

    socket.on('offer', (offer, roomNmae) => {
        socket.to(roomName).emit('offer', offer);
    });

서버가 보낸 offer를 받는 함수를 만들어서 offer 정보를 확인합니다.

socket.on('offer', (offer) => {
    console.log(offer);
});

먼저 room에 참가해 있던 Peer는 offer를 보내게 되고

room에 뒤늦게 들어간 Peer는 offer 정보를 받습니다.

이렇게 메세지를 전달하는 과정을 Signaling process 라고 합니다.

WebRTC - answer

각 Peer는 미디어 트랙을 주고 받을때 서버가 필요하지 않지만 offer/answer를 주고 받을때는 서버를 이용하게 됩니다.

Peer들이 메세지를 전달하는 과정은 다음과 같습니다.

  1. 피어 A가 "offer" SDP를 생성하고 setLocalDescription()을 호출하여 자신의 SDP를 설정합니다.
  2. 피어 A가 "offer" SDP를 피어 B에게 전송합니다 (일반적으로 웹 서버를 통해).
  3. 피어 B가 setRemoteDescription()을 호출하여 피어 A의 "offer" SDP를 설정합니다.
  4. 피어 B가 "answer" SDP를 생성하고 setLocalDescription()을 호출하여 자신의 SDP를 설정합니다.
  5. 피어 B가 "answer" SDP를 피어 A에게 전송합니다.
  6. 피어 A가 setRemoteDescription()을 호출하여 피어 B의 "answer" SDP를 설정합니다.

이렇게 하면 두 피어 간에 통신할 수 있는 연결이 설정됩니다. 이 과정을 "SDP Offer/Answer Exchange"라고도 합니다.
( 'offer-answer' handshake )

이 과정대로 offer와 answer 메세지를 전달해봅시다.
먼저 받은 offer로 setRemoteDescription() 함수를 호출합니다.

socket.on('offer', (offer) => {
    myPeerConnection.setRemoteDescription(offer);
});

하지만 연결을 시도한 Peer에서 아래 에러가 발생합니다

RTCPeerConnection을 초기화하는 makeConnection() 함수를 호출하기 전에
서버가 전송한 offer를 수신해서 존재하지 않는 myPeerConnection에 접근했기 때문입니다.

async function startMedia() {
    welcome.hidden = true;
    call.hidden = false;
    await getMedia(); // 여기서 대기중.. offer이벤트를 socketIO가 먼저 듣는다.
    makeConnection();
}

따라서 room에 참가한 후 startMedia()를 호출하지 않고 참가하기 전에 RTCPeerConnection 객체를 만들도록 합니다.

서버에서도 done()을 제거합니다.

async function handleWelcomeSubmit(event) {
    event.preventDefault();
    const input = welcomeForm.querySelector('input');
    await startMedia(); // 여기로 이동
    socket.emit('join_room', input.value);
    roomName = input.value; // 전역변수에 저장
    input.value = "";
}

이제 Peer B가 보낼 answer 메세지를 만듭니다.

socket.on('offer', async (offer) => {
    myPeerConnection.setRemoteDescription(offer);
    const answer = await myPeerConnection.createAnswer(); // offer를 받고 answer를 생성해 SDP 설정
    myPeerConnection.setLocalDescription(answer); // 각자의 peer는 local, remote를 설정
    socket.emit('answer', answer, roomName);
});

서버에서도 answer를 전송합니다.

    socket.on('answer', (answer, roomName) => {
        socket.to(roomName).emit('answer', answer);
    });

각 peer는 answer정보를 받아 remote설정을 합니다.

socket.on('answer', (answer) => {
    myPeerConnection.setRemoteDescription(answer);
});

여기까지의 과정이 아래 이미지입니다.

WebRTC - ICE Candidate

아 과정에서는 addICECandidate()함수가 이용됩니다.

ICE(Interactive Connectivity Establishment)는 NAT(네트워크 주소 변환)나 방화벽과 같은 네트워크 제약을 피해 P2P 네트워크 연결을 설정할 수 있도록 하는 프레임워크입니다.
이 과정에서 "ICE Candidate"는 웹RTC 연결을 설정하는데 필요한 가능한 연결 경로를 의미합니다. ( IP, port.. )

피어는 가능한 모든 ICE Candidate를 수집하고 이들을 SDP(Session Description Protocol) 설명에 포함하여 다른 피어에게 전송합니다.

피어들은 서로의 ICE Candidate를 교환하고, 이들 중 가장 효율적인 경로를 선택하여 연결을 설정합니다. -> ICE candidate pairing' 'ICE candidate selection'

먼저 icecandidate 이벤트를 리스닝하는 함수를 만들고 서버에 icecandidate를 보냅니다.

function handleIce(data){
    socket.emai('ice', data.candidate, roomName);
}

function makeConnection() {
    myPeerConnection = new RTCPeerConnection();
    myPeerConnection.addEventListener('icecandidate', handleIce);
    myStream.getTracks().forEach(track => {
        myPeerConnection.addTrack(track, myStream)
    })
};
    socket.on('ice', (ice, roomName) => {
        socket.to(roomName).emit('ice', ice);
    });

이벤트를 다시 듣고 Peer들은 icecandidate 정보를 커넥션 SDP에 추가합니다.

socket.on('ice', (ice) => {
    myPeerConnection.addICECandidate(ice); // ICE
});

이러한 과정을 추가해서 각 Peer들은 offer/answer 핸드셰이크와 동시에 icecandidate 정보를 교환하고 최적의 경로를 선택합니다.

WebRTC - addStream

addstream의 기능을 addTrack으로 이미 RTCPeerConnection에 추가했기 때문에 특별히 할건 없습니다.

function handleAddStream(data){
    console.log(data);
}

function makeConnection() { // 연결을 만든다.
    myPeerConnection = new RTCPeerConnection();
    myPeerConnection.addEventListener('icecandidate', handleIce);
    myPeerConnection.addEventListener('addstream', handleAddStream);
    myStream.getTracks().forEach(track => {
        myPeerConnection.addTrack(track, myStream)
    })
};

room에 들어간 Peer가 받은 stream

room에 있었던 Peer가 받은 stream

각 Peer는 offer/answer 메세지 교환과 IceCandidate 정보를 알고 있으므로 서로 연결되어 있습니다.
그러므로 Peer들은 서로의 stream정보를 수신하고 있습니다.

수신한 스트림을 화면에 그려봅시다.

		video#peerFace(autoplay, playsinline, width='400', height='400')
function handleAddStream(data){
    const peerFace = document.getElementById('peerFace');
    peerFace.srcObject = data.stream;
}

브라우저에 2개의 캠화면이 생기고 Peer A의 캠을 끄면 Peer B의 수신 화면이 꺼지게 됩니다.
( 노트북 웹캠으로는 세팅할 수 없음, 웹캠은 하나의 브라우저만 접근 가능 )

트랙 변경 - RTCRtpSender

여기까지 했을때 서로의 stream이 전달되어 상대방의 브라우저에서 확인할 수 있지만
트랙을 변경했을때 변경된 트랙이 상대방의 브라우저에서 적용되지는 않습니다.

RTCPeerConnection 객체에 addTrack함수로 트랙을 추가하면 RTCRtpSender가 반환됩니다.

그러므로 아래 코드에서 myPeerConnection은 이미 RTCRtpSender 타입입니다.

const audioSelect = document.getElementById('audios');

async function handleAudioChange() {
    await getMedia(audioSelect.value);
    if( myPeerConnection ) {
        console.log(myPeerConnection.getSenders());
    }
}

audioSelect.addEventListener('input', handleAudioChange);

getSenders()함수로 RTCRtpSender 객체를 가져옵니다.

sender를 이용하면 원격 peer로 보내진 media stream track을 컨트롤 할 수 있습니다.
원격 Peer의 트랙을 제어하므로 기존의 WebRTC 연결을 유지합니다.
즉, 끊김없이 연결된 Peer에게 나의 바뀐 트랙정보를 송신할 수 있습니다.

sender 객체에서 선택된 sender를 출력해봅니다.

async function handleAudioChange() {
    await getMedia(audioSelect.value);
    if( myPeerConnection ) {
        const audioSender = myPeerConnection.getSenders()
        	.find((sender) => sender.track.kind === "audio");
        console.log(audioSender);
    }
}	

선택한 트랙이 맞으면 replaceTrack() 함수를 이용해서 원격 Peer의 트랙을 변경합니다.

async function handleAudioChange() {
    await getMedia(audioSelect.value);
    if( myPeerConnection ) {
        const videoTrack = myStream.getVideoTracks()[0]; // 변경된 myStream
        const audioSender = myPeerConnection.getSenders()
        	.find((sender) => sender.track.kind === "audio");
        audioSender.replaceTrack(videoTrack);
    }  
}


localtunnel

localtunnel은 개발자가 개발 중인 로컬 서버를 인터넷에 노출시키는 도구입니다. ( + ngrok, serveo )
배포하지 않고 간단하게 URL을 만들어서 테스트 할 수 있습니다.

먼저 설치부터 한 뒤에

npm i -g localtunnel

다음과 같은 명령어로 localtunnel을 사용할 수 있습니다:

npx localtunnel --port 8000

또는

lt --port 3000

실행중인 local 서버에 public url을 임시적으로 제공해주므로
위 명령어를 실행하기 전에 local에서 node서버가 실행중이어야 합니다.

$ lt --port 3000
your url is: https://crazy-kids-repair.loca.lt

알려주는 url에 들어가면 다음과 같은 화면이 나옵니다.
여기서 링크에 들어가면 ip주소가 나옵니다.


ip주소를 입력창에 넣어 제출하면 브라우저에서 내 local 서버에 접근할 수 있게 됩니다.

localtunnel을 종료하려면 실행하는 콘솔에서 Ctrl + C를 입력해 세션을 종료합니다.

같은 wifi환경에서 모바일로 url에 접근하면 노트북의 웹캠과 영상회의가 가능해집니다.
하지만 다른 wifi에서는 stream이 연결되지가 않습니다.

그 이유는 같은 wifi환경에서는 디바이스들이 서로의 public address를 알 수 있지만 다른 환경에서는 알 수 없기 때문입니다.
WebRTC 에서는 Peer들의 public address를 알아 내기 위해서 STUN 서버가 필요합니다.


STUN server

STUN 서버의 주요 역할은 NAT (Network Address Translation) 트래버설을 지원하는 것입니다.
NAT는 사설 IP 주소와 공개 IP 주소 간의 주소 변환을 수행하는 데 사용되는 기술입니다. ( 공유기 )
NAT 트래버설은 NAT를 우회해서 Peer 간의 직접적인 네트워크 연결을 설정하는 과정을 의미합니다.

STUN 서버는 클라이언트가 자신의 공용 IP 주소와 포트를 알아내는 데 도움을 주고
이 정보를 ICE 프레임워크가 사용해서 네트워크 연결이 이루어집니다.

WebRTC를 이용한 서비스를 만드려면 자신만의 STUN 서버를 만들어야 하지만 이번에는 구글이 만든 STUN 서버를 이용해보겠습니다.

RTCPeerConnection 객체를 생성할때 아래의 STUN 서버 url을 추가합니다. ( 5개 미만 )

function makeConnection() {
    myPeerConnection = new RTCPeerConnection({
        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 서버를 이용해서 각 Peer는 public IP주소를 찾아 연결하게 되므로 다른 wifi에서도 모바일과 브라우저는 영상회의가 가능해집니다.

Data channel

WebRTC에서 Data channel은 텍스트, 바이너리 데이터를 보낼 수 있습니다.
이를 통해 채팅, 파일 전송, 게임 상태 데이터 등을 보낼 수 있습니다.

기존 코드 위에 추가합니다.

socket.on('welcome', async () => { 
    myDataChannel = myPeerConnection.createDataChannel('chat');
    myDataChannel.addEventListener('message', console.log);
    console.log('dataChannel 생성됨');
    const offer = await myPeerConnection.createOffer();
    myPeerConnection.setLocalDescription(offer); 
    socket.emit('offer', offer, roomName); 
});

이벤트를 받는쪽도 수정합니다.

socket.on('offer', async (offer) => {
    myPeerConnection.addEventListener('datachannel', event => { // datachannel 감지
        myDataChannel = event.channel;
        myDataChannel.addEventListener('message', console.log);
    });

myDataChannel.send() 함수를 이용해서 데이터를 전달할 수 있습니다.

webRTC 단점

webRTC는 p2p 연결이므로 연결이 많아질수록 직점 업로드와 다운로드를 하므로 데이터 대역폭 사용량이 폭발적으로 늘어나게 됩니다.
따라서 1:1 영상통화나 1:1 대전 같이 소규모 연결에서 사용하는게 좋습니다.
실제로 여려명이 접속하는 연결을 구현할 경우 SFU, MCU등을 고려 해볼 수 있습니다.

profile
작은것부터

0개의 댓글