Getting Started With Webrtc1
WebRTC
WebRTC는 Web RealTime Communication의 약자로 웹 브라우저 간에 실시간, 플러그인이 필요 없이 영상 및 음성, 데이터 통신에 대한 공개 된 표준이다.
즉, 별도의 프로그램 설치 없이 웹 브라우저 사이에 화상통신, 음성, 채팅을 가능하게 한다.
구글이 처음으로 WebRTC를 제안한 이후 Google, Mozilla, Opera 및 MS까지 기술적 표준을 만들어 가고 있다.
위에서 웹브라우저라고 한정을 시켰지만, 사실 웹브라우저에 제한되지 않는다. 그 이유는 webrtc 자체가 c++ native 로 작성되어있어서 현존 하는 기기들로 포팅이 용이하며, 이를 직접 빌드하여 사용할 수 있기 때문이다.
이 문서에서는 검색만 하면 나오는 용어들의 설명은 최대한 하지 않을 것이며, 최대한 가장 간단한 예제를 보여주는 것이 목표다.
WebRTC 를 학습하면서 겪을 수 있는 어려운점을 최대한 풀어드리겠다.
Tutorial Installation
git clone git@github.com:hsnks100/webrtc-examples.git
https://chrome.google.com/webstore/detail/web-server-for-chrome/ofhbbkphhbklhfoeikjpcbhemlocgigb
chrome extension 을 통해 01-webrtc-web-loopback 를 열어서 http://localhost:8887 입력 후, call 을 눌러본다.
Loopback WebRTC
실행 시키면 위와 같은 화면이 뜨고 call 을 누르면 미리 준비되어있던 remote video 에 같은 영상이 뜰 것이다. 내가 원하는 것은 P2P 라고! 라고 외치기전에 loop-back 예제를 먼저 봐야하는 이유가 있다.
보통은 loop-back 예제를 한번 대충 실행시켜본 후, 그래 이제 실제 peer 간의 통신하는 예제를 찾으러 떠날 것이다. 이 때부터 복잡함이 시작된다. Signaling 이 포함된 예제를 보면 일단 등장하는 인물들이 많다. TURN, STUN, ICE, SDP, OFFER, ANSWER 생각나는대로 대충써도 이 정도이며 각 요소들이 독립적인 의미를 가지는 것도 아니며 network 상에서 어떻게 흐름이 흘러가는지 알기가 정말 어렵다. 이 쯤 됐으면 “에이 안해.”
차근차근 loop-back 예제부터 정복해보자. loop-back 예제만 잘 보고 Signaling server 를 어떻게 구축해야 하며 각 client 들은 어떤정보를 줘야 하는지 각을 잡을 수 있어야 한다.
기본적인 webrtc 구동에 관한 Sequence Diagram 이다. 위 다이어그램을 보면서 아래 코드를 읽어야 한다. 그래도 이해하는데 필요한 SDP, ICE Candidate 에 대해서 살짝만 써보려 한다.
ICE (Interactive Connectivity Establishment, RFC 5245)
peer-to-peer 간 다이렉트로 통신을 위한 기술 ICE 는 이미지를 통해 이해하는 것이 편하다. 이미지와 같이 peer-to-peer 간에는 여러 가지 경로를 통해 연결될 수 있다.
Candidate 는 각 peer 의 setLocalDescription 의 콜백을 통해 구해지며, 최종적으로 서로에게 전송한다 그러면 각 peer 는 서로에게 Connectivity Check 를 하게 되고 p2p 로
'use strict';
// Define initial start time of the call (defined as connection between peers).
let startTime = null;
// Define peer connections, streams and video elements.
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
let localStream;
let remoteStream;
let localPeerConnection;
let remotePeerConnection;
// Define action buttons.
const callButton = document.getElementById('callButton');
const hangupButton = document.getElementById('hangupButton');
// Add click event handlers for buttons.
callButton.addEventListener('click', callAction);
hangupButton.addEventListener('click', hangupAction);
// Set up initial action buttons status: disable call and hangup.
callButton.disabled = true;
hangupButton.disabled = true;
navigator.mediaDevices.getUserMedia({
video: true,
audio: true
}).then((mediaStream) => {
localVideo.srcObject = mediaStream;
localStream = mediaStream;
trace('Received local stream.');
callButton.disabled = false; // Enable call button.
}).catch(handleLocalMediaStreamError);
trace('Requesting local stream.');
// Handles error by logging a message to the console.
function handleLocalMediaStreamError(error) {
trace(`navigator.getUserMedia error: ${error.toString()}.`);
}
// Define RTC peer connection behavior.
// Handles call button action: creates peer connection.
function callAction() {
callButton.disabled = true;
hangupButton.disabled = false;
trace('Starting call.');
startTime = window.performance.now();
const servers = null; // Allows for RTC server configuration.
localPeerConnection = new RTCPeerConnection(servers);
trace('Created local peer connection object localPeerConnection.');
localPeerConnection.addEventListener('icecandidate', localIceCandidate);
localPeerConnection.addEventListener(
'iceconnectionstatechange', handleConnectionChange);
remotePeerConnection = new RTCPeerConnection(servers);
trace('Created remote peer connection object remotePeerConnection.');
remotePeerConnection.addEventListener('icecandidate', remoteIceCandidate);
remotePeerConnection.addEventListener(
'iceconnectionstatechange', handleConnectionChange);
remotePeerConnection.addEventListener('addstream', (event) => {
const mediaStream = event.stream;
remoteVideo.srcObject = mediaStream;
remoteStream = mediaStream;
trace('Remote peer connection received remote stream.');
});
// Add local stream to connection and create offer to connect.
localPeerConnection.addStream(localStream);
trace('Added local stream to localPeerConnection.');
trace('localPeerConnection createOffer start.');
localPeerConnection.createOffer()
.then((description) => {
trace(`Offer from localPeerConnection:\n${description.sdp}`);
trace('localPeerConnection setLocalDescription start.');
localPeerConnection.setLocalDescription(description);
trace('remotePeerConnection setRemoteDescription start.');
remotePeerConnection.setRemoteDescription(description);
trace('remotePeerConnection createAnswer start.');
remotePeerConnection.createAnswer()
.then((description) => {
trace(`Answer from remotePeerConnection:\n${description.sdp}.`);
trace('remotePeerConnection setLocalDescription start.');
remotePeerConnection.setLocalDescription(description);
trace('localPeerConnection setRemoteDescription start.');
localPeerConnection.setRemoteDescription(description);
}).catch(setSessionDescriptionError);
}).catch(setSessionDescriptionError);
}
// Handles hangup action: ends up call, closes connections and resets peers.
function hangupAction() {
localPeerConnection.close();
remotePeerConnection.close();
localPeerConnection = null;
remotePeerConnection = null;
hangupButton.disabled = true;
callButton.disabled = false;
trace('Ending call.');
}
// Define helper functions.
// Gets the "other" peer connection.
function getOtherPeer(peerConnection) {
return (peerConnection === localPeerConnection) ?
remotePeerConnection : localPeerConnection;
}
// Gets the name of a certain peer connection.
function getPeerName(peerConnection) {
return (peerConnection === localPeerConnection) ?
'localPeerConnection' : 'remotePeerConnection';
}
// Connects with new peer candidate.
function localIceCandidate(event) {
const peerConnection = event.target;
const iceCandidate = event.candidate;
if (iceCandidate) {
const newIceCandidate = new RTCIceCandidate(iceCandidate);
remotePeerConnection.addIceCandidate(newIceCandidate)
.then(() => {
}).catch((error) => {
});
}
}
function remoteIceCandidate(event) {
const peerConnection = event.target;
const iceCandidate = event.candidate;
if (iceCandidate) {
const newIceCandidate = new RTCIceCandidate(iceCandidate);
localPeerConnection.addIceCandidate(newIceCandidate)
.then(() => {
}).catch((error) => {
});
}
}
// Logs changes to the connection state.
function handleConnectionChange(event) {
const peerConnection = event.target;
console.log('ICE state change event: ', event);
trace(`${getPeerName(peerConnection)} ICE state: ` +
`${peerConnection.iceConnectionState}.`);
}
// Logs error when setting session description fails.
function setSessionDescriptionError(error) {
trace(`Failed to create session description: ${error.toString()}.`);
}
// Logs an action (text) and the time when it happened on the console.
function trace(text) {
text = text.trim();
const now = (window.performance.now() / 1000).toFixed(3);
console.log(now, text);
}
사용자는 맨 처음 call 버튼을 누르면서 loop-back 예제가 시작된다.
navigator.mediaDevices.getUserMedia({
video: true,
audio: true
}).then((mediaStream) => {
localVideo.srcObject = mediaStream;
localStream = mediaStream;
callButton.disabled = false; // Enable call button.
});
먼저 브라우저한테 현재 연결된 카메라와 오디오의 스트림을 가져와 달라고 한다. 그 정보는 localStream 에 저장된다. localStream 은 추후 peer connection 객체에 추가된다.
const servers = null; // Allows for RTC server configuration.
localPeerConnection = new RTCPeerConnection(servers);
trace('Created local peer connection object localPeerConnection.');
localPeerConnection.addEventListener('icecandidate', localIceCandidate);
localPeerConnection.addEventListener(
'iceconnectionstatechange', handleConnectionChange);
remotePeerConnection = new RTCPeerConnection(servers);
trace('Created remote peer connection object remotePeerConnection.');
remotePeerConnection.addEventListener('icecandidate', remoteIceCandidate);
remotePeerConnection.addEventListener(
'iceconnectionstatechange', handleConnectionChange);
remotePeerConnection.addEventListener('addstream', (event) => {
const mediaStream = event.stream;
remoteVideo.srcObject = mediaStream;
remoteStream = mediaStream;
trace('Remote peer connection received remote stream.');
});
// Add local stream to connection and create offer to connect.
localPeerConnection.addStream(localStream);
localPeerConnection, remotePeerConnection 두개가 초기화되는데, 이는 앞서 봤던 Sequence Diagram 의 A, B 와 매칭된다. 여기서 헷갈릴 수 있는점이, 실제 구현에서는 각 디바이스가 localPeerConnection 하나만을 가지고, remotePeerConnection 는 필요없다. icecandate 에 대한 event 는 들어온 sdp 의 정보를 파싱해서 들어온 candidate 가 있을 때 호출된다.
이 코드조각에서 가장 중요한 부분은 마지막 addEventListener 인 addstream 이다. remotePeerConnection 에 stream 이 들어올 때, 어디다가 화면을 뿌려줄 건지 정하는 것이다. 즉, local video stream 이 remote video stream 으로 전송이 되고 그것을 보여주는 코드다. 만약 구동되는 컴퓨터의 사양이 매우 안좋다면, remoteVideo 에 보여지는 화면이 localVideo 보다 살짝 느릴 것이다. 여기까지 기본코드가 끝이 난다.
localPeerConnection.createOffer()
.then((description) => {
localPeerConnection.setLocalDescription(description);
// description is needed to send via network in actual world.
remotePeerConnection.setRemoteDescription(description);
remotePeerConnection.createAnswer()
.then((description) => {
remotePeerConnection.setLocalDescription(description);
// description is needed to send via network in actual world.
localPeerConnection.setRemoteDescription(description);
}).catch(setSessionDescriptionError);
}).catch(setSessionDescriptionError);
이제 Sequence Diagram 에 있는 상황에 대한 코드인데, 최대한 짧게 한눈에 보기위해 lambda 를 이용하여 표현했다.
먼저 createOffer 가 수행이 된다. 생성된 offer 는 localPeerConnection.setLocalDescription 을 이용하여 저장한다. 그리고 바로 remotePeerConnection.setRemoteDescription 하게 된다. 여기에서 실제 signaling server 를 이용한 구현에서는 네트워크를 이용하여 이 description 을 전송하여 받는 쪽에서 setRemoteDescription 하게 된다.
offer 를 받은 쪽에서는 createAnswer 를 이용하여 답장을 한다. 마찬가지로 remotePeerConnection.setLocalDescription 로 바로 저장한다. 여기서도 localPeerConnection.setRemoteDescription 하는 부분이 있는데, 마찬가지로 네트워크를 이용하여 description 을 전송하여 받는 쪽에서 setRemoteDescription 한다.
// Connects with new peer candidate.
function localIceCandidate(event) {
const peerConnection = event.target;
const iceCandidate = event.candidate;
if (iceCandidate) {
const newIceCandidate = new RTCIceCandidate(iceCandidate);
remotePeerConnection.addIceCandidate(newIceCandidate)
.then(() => {
}).catch((error) => {
});
}
}
function remoteIceCandidate(event) {
const peerConnection = event.target;
const iceCandidate = event.candidate;
if (iceCandidate) {
const newIceCandidate = new RTCIceCandidate(iceCandidate);
localPeerConnection.addIceCandidate(newIceCandidate)
.then(() => {
}).catch((error) => {
});
}
}
다음으로 ICE candidate 를 받았을 때 하는 행동을 보자. localIceCandidate/remoteIceCandidate 에 들어온 candidate 는 상대방에게 전달되어야 하는 candidate 다. 이 예제에서 전달하는 부분은 loop-back 예제이므로 생략되었다.
대신 remoteIceCandidate 에서는 localPeerConnection.addIceCandidate
localIceCandidate 에서는 remotePeerConnection.addIceCandidate
이 과정이 끝나고 나면 영상이 뜰 것이다. 축하한다.