Simple video chat with WebRTC

Contents

  • Introduction to WebRTC
  • What I will learn from this post
  • How WebRTC works in the browser
  • Stream video from your webcam
  • Stream video with RTCPeerConnection
  • RTCDataChannel to exchange data
  • Create signaling server
  • Limitation
  • Reference

Introduction to WebRTC

WebRTC는 Web Real Time Communication의 약자입니다. WebRTC는 오픈 소스로 브라우저 간 실시간 커뮤니케이션이 이루어질 수 있도록 도와주는 API(Application Programing Interface)입니다. WebRTC는 Peer-to-Peer 통신이 가능하게 하며, 이를 통해 브라우저 간 비디오, 텍스트, 이미지, 음성 등 실시간 데이터를 주고 받을 수 있습니다. WebRTC에는 여러 종류의 Javascript API가 있습니다.

  • getUserMedia: 오디오, 비디오 캡쳐 기능
  • MediaRecorder: 오디오, 비디오 녹화 기능
  • RTCPeerConnection: 유저간 오디오, 비디오의 스트림 처리 기능
  • RTCDataChannel: User간 데이터의 스트림 처리 기능

Main reason for the popularity

  • 플러그인 없이 실시간으로 오디오, 비디오, 데이터를 교환할 수 있음.
  • 브라우저간 Peer-to-Peer로 연결할 수 있음.
  • 보안이 좋음.
  • 무료이며 전세계적으로 사용할 수 있음.

What is signaling?

WebRTC는 RTCPeerConnection을 이용해 브라우저간 데이터를 스트리밍합니다. 하지만 커뮤니케이션을 관장하고 통제 메세지를 전달하는 메카니즘이 필요한데, 이것을 signaling이라고 합니다. Sinaling method 및 protocol은 WebRTC에는 정의되어 있지 않습니다.

What are STUN and TURN?

WebRTC는 peer-to-peer 통신을 위해 설계되었고, user는 가장 직접적인 방법으로 접촉을 합니다. 하지만, WebRTC는 real-world networking에 기반합니다. 바꿔말하면, 실제 웹 애플리케이션이 동작하는 환경은 일반적으로 NAT, 일부 Port와 Protocol을 차단하는 보안 소프트웨어, 프록시 또는 방화벽 등 하나 이상의 레이어 뒤에서 동작합니다. Client application은 NAT gateways, 방화벽을 순회해야하며 direct connection이 실패할 경우를 대비한 fallback이 필요합니다. 이러한 과정의 일환으로 WebRTC API는 user 컴퓨터의 IP 주소를 얻기 위해 STUN 서버를 이용합니다.

Is WebRTC secure?

암호화는 모든 WebRTC components에 필수적이며, 관련한 Javascript API는 HTTPS 또는 localhost에서 사용할 수 있습니다. signaling server에 대해서는 WebRTC에 규정되어 있지 않기 때문에 안전화된 프로토콜을 사용하는 것은 그것을 사용하는 사람에 달려있습니다.

What I will learn from this post

  • Get video from your webcam
  • Stream video with RTCPeerConnection
  • Stream data with RTCDataChannel
  • Set up a signaling service to exchange messages
  • Combine peer connection and signaling
  • Taka a photo and share it via a data channel

How WebRTC workd in the browser

브라우저 간 WebRTC 커뮤니케이션에 대한 이해를 높이기 위해서는 크게 아래와 같은 세 가지 컴포넌트에 대해 알아야 합니다. WebRTC의 기능은 아래와 같은 세 가지 기능으로 나눌 수 있습니다.

MediaStream

첫 단계로 유저가 공유하기 원하는 데이터를 캡쳐하는 것입니다. Local media stream은 브라우저에 카메라나 마이크 등 로컬 디바이스에 접근을 허용합니다. 유저는 getUserMedia 함수로 브라우저로부터 접근을 할 수 있습니다.

RTCPeerConnection

유저가 stream을 정하고 나면 파트너 시스템과 연결을 합니다. 이로써 다른 브라우저(peer)와 음성, 비디오 등을 직접 교환할 수 있습니다. STUN & TURN 서버로 sender와 receiver의 관계를 형성합니다.

RTCDataChannel

브라우저간 양방향으로 데이터를 교환할 수 있도록 합니다. createDataChannel 함수가 처음 PeerConnection Object 인스턴스 생성 시 호출됩니다.

Stream video from your webcam

캠을 이용해 비디오 정보를 받아와, html의 video element에 stream을 출력하는 코드를 알아보겠습니다. 현재 origin으로부터 첫 카메라 접근을 하는 경우 브라우저는 user에게 카메라 접근 허용에 대한 수락을 요청합니다. 수락 하면 mediaStream을 반환하고 이 stream을 video tag의 srcObject에 할당해서 화면을 출력할 수 있습니다.

navigator.mediaDevices
  .getUserMedia(mediaStreamConstraints)
  .then(gotLocalMediaStream)
  .catch(handleLocalMediaStreamError);

function gotLocalMediaStream(stream) {
  document.querySelector('video').srcObject = stream;
}

"constraints"를 통해서 어떠한 midea를 취할지 정할 수 있습니다. default로 audio는 disabled이기 때문에 video stream만 가져오도록 하겠습니다.

const mediaStreamConstraints = {
  video: true
};

만약 contraints로 전달한 resolution이 선택된 카메라에서 지원하지 않는다면, getUserMedia 호출 시 OverconstrainedError가 발생하고 user는 카메라 접근에 대한 허용을 할 수 없습니다.

Note: stream이 계속 흘러나오게 하려면 video tag에 autoplay 속성을 추가해야 합니다. 그렇지 않으면 single frame만 확인할 수 있습니다.

Stream video with RTCPeerConnection

RTCPeerConnection은 WebRTC를 이용해 오디오, 비디오를 streaming하고 data를 교환할 수 있게 해주는 api입니다. 아래의 예를 통해서 두 RTCPeerConnection object를 연결하는 방법을 확인할 수 있습니다. 실무에서 활용하기보다는 RTCPeerConnection이 동작하는 방법에 대해 이해하기 좋습니다.

Add video elements and control buttons

<video id="localVideo" autoplay playsinline></video> <video id="remoteVideo" autoplay playsinline></video>

<div>
  <button id="startButton">Start</button> <button id="callButton">Call</button>
  <button id="hangupButton">Hang Up</button>
</div>

첫번째 비디오 요소는 getUserMedia 함수를 통해서 전달 받은 데이터를 보여주고, 다른 비디오 테스는 RTCPeerConnection을 통해 전달 받은 stream을 보여줄 것입니다. (실제 환경에서는, 하나는 local stream을 다른 하나는 remote stream을 보여줄 것입니다.)

HTML 파일은 아래와 같습니다.

<!DOCTYPE html>
<html>
  <head>
    <title>Realtime communication with WebRTC</title>
    <link rel="stylesheet" href="css/main.css" />
  </head>
  <body>
    <h1>Realtime communication with WebRTC</h1>
    <video id="localVideo" autoplay playsinline></video> <video id="remoteVideo" autoplay playsinline></video>
    <div>
      <button id="startButton">Start</button> <button id="callButton">Call</button>
      <button id="hangupButton">Hang Up</button>
    </div>
    <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
    <script src="js/main.js"></script>
  </body>
</html>

WebRTC peer 간 전화 연결을 하는것은 크게 아래와 같이 세 단계로 구성됩니다.

  • RTCPeerConnection을 생성한 후, getUserMedia 메소드를 통해 local Stream을 추가합니다.
  • Network information을 교환합니다. 커뮤니케이션 대상인 connection endpoints를 ICE candidates라고 합니다.
  • local, remote description을 교환합니다. (SDP format의 local media에 대한 metadata)

RTCPeerConnection을 이용해 두 Peer 간 화상 통화를 위해서 network 정보를 교류해야합니다. 통화를 위한 대상자를 찾는 작업은 ICE framework을 이용해 network interface 및 port를 찾는 작업을 의미합니다.

ICE(Interactive Connectivity Establishment)는 WebRTC에서 NAT를 우회하는 방법중 하나입니다. ICE는 local IP address, STUN, TURN으로부터 모든 가능한 candidate를 취합하고, 이 취합된 address를 SDP를 통해 remote peer에게 전달합니다. WebRTC client가 모든 ice address를 받으면, 연결 가능한 address를 찾게됩니다.

  1. Peer-A는 아래와 같이 RTCPeerConnection instance를 생성하고, onicecandidate 이벤트 핸들러를 추가합니다.
let localPeerConnection;
const servers = null;

localPeerConnection = new RTCPeerConnection(servers);
localPeerConnection.addEventListener('icecandidate', handleConnection);
localPeerConnection.addEventListener('iceconnectionstatechange', handleConnectionChange);

function handleConnection(event) {
  const peerConnection = event.target;
  const iceCandidate = event.candidate;

  if (iceCandidate) {
    const newIceCandidate = new RTCIceCandidate(iceCandidate);
    const otherPeer = getOtherPeer(peerConnection);

    otherPeer.addIceCandidate(newIceCandidate);
  }
}

function getOtherPeer(peerConnection) {
  return peerConnection === localPeerConnnection ? remotePeerConnection : localPeerConnection;
}
  1. 다음으로 Peer-A는 getUserMedia 메소드를 호출해 stream을 추가합니다.
navigator.mediaDevices
  .getUserMedia(mediaStreamConstraints)
  .then(gotLocalMediaStream)
  .catch(handleLocalMediaStreamError);

function gotLocalMediaStream(mediaStream) {
  localVideo.srcObject = mediaStream;
  callButton.disabled = false;
}

localPeerConnection.addStream(localStream);
  1. 앞서 작성한 onicecandidate handler는 유효한 network candidate가 있을 때 호출됩니다.
  2. Peer-A는 일련의 candidate data를 Peer-B에게 전달합니다. 실제 application에서는, 이러한 signaling 작업은 messaging service를 통해 이루어집니다. messaging service에 대해서는 뒤쪽에서 다루도록 하겠습니다.
  3. Peer-B가 Peer-A로부터 candidate message를 받으면, Peer-B는 candidate를 remote peer description에 추가하기 위해서 addIceCandidate를 호출합니다. 우리의 애플리케이션이 Signaling channel을 통해서 remote peer의 새로운 ICE candidate를 전달 받으면, addIceCandidate 호출을 통해 브라우저의 ICE agent에 새로운 ICE candidate를 전달합니다.

WebRTC peer는 local, remote 오디오/비디오 미디어 정보를 교환해야 합니다. 미디어에 대한 설정 정보를 signaling 서버에 전달하는 작업이 metadata를 교환하기 전 진행됩니다. Session Description Protocol(SDP)을 이용한 이 절차를 "offer", "answer"라고 합니다.

  1. Peer-A는 RTCPeerConnection의 createOffer 메소드를 호출합니다. createOffer 메소드는 remote peer와 WebRTC 연결을 시작하기 위해 SDP offer를 초기화합니다. SDP offer는 WebRTC session에 첨부된 MediaStreamTrack, codec, 브라우저 옵션, ICE agent에 의해 모인 candidates 등의 정보를 담고있습니다. 이러한 정보는 signaling channel을 통해 다른 peer에 전달이 되고, 새로운 연결 또는 설정 정보의 업데이트 요청을 위해 사용됩니다. 반환값은 Promise입니다. offer가 생성되면 RTCSessionDescription 객체를 제공하며 새로 생성된 offer에 대한 정보를 담고 있습니다.
localPeerConnection
  .createOffer(offerOptions)
  .then(createdOffer)
  .catch(setSessionDescriptionError);
  1. 위 작업이 성공적으로 진행되면, Peer-A는 setLocalDescription 메소드를 이용해 local description을 설정하고 signaling server를 통해 이 session description을 Peer-B에게 보냅니다. setLocalDescription은 local description을 연결된 상태로 수정합니다. 메소드는 session description을 인자로 받으며, promise를 반환합니다. 반환된 promise는 description이 비동기로 수정된 후 fulfilled 됩니다.
// sets peer connection session description
function createdOffer(description) {
  localPeerConnection.setLocalDescription(description);
  remotePeerConnection.setRemoteDescription(description);

  remotePeerConnection
    .createAnswer()
    .then(createdAnswer)
    .catch(setSessionDescriptionError);
}
  1. Peer-B는 Peer-A가 보낸 remote description을 setRemoteDescription을 이용해 설정합니다. setRemoteDescription은 일반적으로 signaling server를 통해 다른 peer의 offer 또는 answer를 받았을 때 호출됩니다. 한가지 인지해야 할 것은 setRemoteDescription은 연결이 된 상태에서 호출이 된다는 것입니다. 즉, 아직 peer간 network condition 관련 교섭이 진행중에 있고, description은 양쪽에서 새로운 구성에 승락했을때 적용이 됩니다.
  2. Peer-B는 RTCPeerConnection의 createAnswer 메소드를 실행합니다. 이 때 Peer-A로부터 전달받은 remote description을 전달해, Peer-A의 session과 호환이 되는 local session이 생성될 수 있도록 합니다. Peer-B는 local description을 설정 후 Peer-A에게 전달합니다.
// sets peer connection session descriptions.
function createdAnswer(description) {
  remotePeerConnection.setLocalDescription(description);
  localPeerConnection.setRemoteDescription(description);
}
  1. Peer-A가 Peer-B의 session description을 전달 받으면, Peer-A는 이것을 remote description으로 설정합니다. Click for sample code

RTCDataChannel to exchange data

RTCDataChannel을 이용하면 실시간으로 데이터 교환을 빠른 반응 속도로 진행할 수 있습니다. 통신은 브라우저간 직접 연결됩니다. RTCDataChannel은 WebSocket보다 매우 빠릅니다. 심지어 방화벽과 NAT의 방해로 '구멍내기'가 실패하여 중계(TURN) 서버와 연결이 되더라도 빠릅니다.

버튼을 클릭 시 HTML textarea 태그의 텍스트를 전송하는 기능을 구현하며 RTCDataChannel을 살펴보도록 하겠습니다. HTML에 두 개의 textarea와 세 개의 버튼이 있습니다.

let localConnection;
let remoteConnection;
let sendChannel;
let receiveChannel;
const dataChannelSend = document.querySelector('textarea#dataChannelSend');
const dataChannelReceive = document.querySelector('textarea#dataChannelReceive');
const startButton = document.querySelector('button#startButton');
const sendButton = document.querySelector('button#sendButton');
const closeButton = document.querySelector('button#closeButton');

그리고 각 버튼에 이벤트 리스너를 추가합니다.

startButton.onclick = createConnection;
sendButton.onclick = sendData;
closeButton.onclick = closeDataChannels;

RTCPeerConnection interface의 createDataChannel 메소드는 remote peer와 연결된 새로운 channel을 생성합니다. 어떠한 종류의 데이터도 전송이 가능한데 이미지나, 파일, 채팅 텍스트, 게임 업데이트 패킷 등의 데이터 전송을 할 수 있습니다. 위의 버튼 중 start 버튼을 클릭하면 createConnection 함수가 호출됩니다. 이 함수는 PeerConnection을 생성한 후 데이터 채널을 추가로 생성합니다.

function createConnection() {
  const servers = null;
  localConnection = new RTCPeerConnection(servers);
  sendChannel = localConnection.createDataChannel('sendDataChannel', dataConstraint);

  localConnection.onicecandidate = iceCallback1;
  sendChannel.onopen = onSendChannelStateChange;
  sendChannel.onclose = onSendChannelStateChange;

  remoteConnection = new RTCPeerConnection(servers, pcConstraint);
  remoteConnection.onicecandidate = iceCallback2;
  remoteConnection.ondatachannel = receiveChannelCallback;

  localConnection.createOffer().then(gotDescription1, onCreateSessionDescriptionError);
}

function iceCallback1(event) {
  if (event.candidate) {
    remoteConnection.addIceCandidate(event.candidate);
  }
}

function iceCallback2(event) {
  if (event.candidate) {
    localConnection.addIceCandidate(event.candidate);
  }
}

RTCDataChannel의 onmessage 속성은 message 이벤트가 발생했을 때 실행하는 Event Handler를 담는 역할을 합니다. 아래의 경우 이벤트 핸들러는 전달받은 텍스트를 textarea tag에 추가하는 기능을 합니다.

function receiveChannelCallback(event) {
  receiveChannel = event.channel;
  receiveChannel.onmessage = onReceiveMessageCallback;
  receiveChannel.onopen = onReceiveChannelStateChange;
  receiveChannel.onclose = onReceiveChannelStateChange;
}

function onReceiveMessageCallback(event) {
  dataChannelReceive.value = event.data;
}

send 버튼을 클리하면 sendData 함수가 호출됩니다. sendData함수는 textarea tag의 value를 가져와 dataChannel을 통해 데이터를 전송합니다.

function sendData() {
  const data = dataChannelSend.value;
  sendChannel.send(data);
}

Create signaling server

WebRTC를 이용해 인터넷을 통해 브라우저 간 통신을 하기 하려면 signaling server가 필요합니다. Signaling information을 교환하기 위해 WebSocket 또는 XMLHttpRequest를 이용할 수 있습니다. Signaling server는 Peer간 교류하는 데이터 내용에 대해서는 중요시하지 않습니다. 중요한 것은 Signaling data만을 상대편 peer에게 흘려보내는 것입니다.

WebRTC call을 설정하고 유지하기 위해서, clients는 아래와 같은 metadata를 교환하는 signaling 작업을 거쳐야 합니다.

  • Candidate(network) information
  • 미디어 관련 정보를 제공하는 offer/answer messages

다른 기기의 client가 metadata에 대한 정보를 주고 받기 위해서, signaling server가 필요합니다.

Socket server file structure

routes
|  |__index.js
socket
|  |__indes.js
|__index.js
|__package.json

node js로 signaling server를 만들었으며, Root folder의 index.js는 아래와 같습니다. route 폴더를 생성하기는 했지만, 별도의 end point는 생성하지 않았습니다. 추가로 route를 생성하고 싶으면 이 폴더에서 작업을 할 수 있습니다.

// index.tsx
const express = require('express');
const logger = require('morgan');
const index = require('./routes');

const app = express();
const server = require('http').Server(app);
const io = require('socket.io')(server);

require('./socket')(io);

app.use(logger('dev'));

app.use('/', index);

app.use((err, req, res, next) => {
  res.status(err.state || 500);

  res.render('error', { message: err.message });
});

server.listen(process.env.PORT || 5000, () => console.log(`Hello!! Listening on port ${process.env.PORT || 5000}!`));

socket을 이용해 구축한 signaling server는 아래와 같습니다. peer 간 handshake를 하고 message 전달 기능만 하기 때문에 코드 자체가 길지 않습니다.

const uniqid = require('uniqid');

const userIds = {};
const noop = () => {};

module.exports = (io) => {
  io.on('connection', (socket) => {
    let id;

    socket.on('init', () => {
      id = uniqid();
      userIds[id] = socket;
      socket.emit('init', { id });
    });

    socket.on('request', (data) => {
      sendTo(
        data.to,
        to => to.emit('request', { from: id })
      );
    });

    socket.on('call', (data) => {
      sendTo(
        data.to,
        to => to.emit('call', { ...data, from: id })
        () => socket.emit('failed'),
      );
    });

    socket.on('end', (data) => {
      sendTo(
        data.to,
        to => to.emit('end'),
      );
    });

    socket.on('disconnect', () => {
      delete userIds[id];
      console.log(id, 'disconnected');
    });
  });
}
function sendTo(to, done, fail) {
  const receiver = userIds[to];

  if (receiver) {
    const next = typeof done === 'function' ? done : noop;
    next(receiver);
  } else {
    const next = typeof fail === 'function' ? fail : noop;
    next();
  }
}

Socket-server-repository-link

Limitation

  • 최소 한 쪽은 고속 인터넷 망을 사용해야 합니다.
  • 오프라인에서는 사용할 수 없습니다.
  • 모든 브라우저가 지원하지 않습니다.

Reference