June 18th 2019
Contents
WebRTC는 Web Real Time Communication의 약자입니다. WebRTC는 오픈 소스로 브라우저 간 실시간 커뮤니케이션이 이루어질 수 있도록 도와주는 API(Application Programing Interface)입니다. WebRTC는 Peer-to-Peer 통신이 가능하게 하며, 이를 통해 브라우저 간 비디오, 텍스트, 이미지, 음성 등 실시간 데이터를 주고 받을 수 있습니다. WebRTC에는 여러 종류의 Javascript API가 있습니다.
WebRTC는 RTCPeerConnection을 이용해 브라우저간 데이터를 스트리밍합니다. 하지만 커뮤니케이션을 관장하고 통제 메세지를 전달하는 메카니즘이 필요한데, 이것을 signaling이라고 합니다. Sinaling method 및 protocol은 WebRTC에는 정의되어 있지 않습니다.
WebRTC는 peer-to-peer 통신을 위해 설계되었고, user는 가장 직접적인 방법으로 접촉을 합니다. 하지만, WebRTC는 real-world networking에 기반합니다. 바꿔말하면, 실제 웹 애플리케이션이 동작하는 환경은 일반적으로 NAT, 일부 Port와 Protocol을 차단하는 보안 소프트웨어, 프록시 또는 방화벽 등 하나 이상의 레이어 뒤에서 동작합니다. Client application은 NAT gateways, 방화벽을 순회해야하며 direct connection이 실패할 경우를 대비한 fallback이 필요합니다. 이러한 과정의 일환으로 WebRTC API는 user 컴퓨터의 IP 주소를 얻기 위해 STUN 서버를 이용합니다.
암호화는 모든 WebRTC components에 필수적이며, 관련한 Javascript API는 HTTPS 또는 localhost에서 사용할 수 있습니다. signaling server에 대해서는 WebRTC에 규정되어 있지 않기 때문에 안전화된 프로토콜을 사용하는 것은 그것을 사용하는 사람에 달려있습니다.
브라우저 간 WebRTC 커뮤니케이션에 대한 이해를 높이기 위해서는 크게 아래와 같은 세 가지 컴포넌트에 대해 알아야 합니다. WebRTC의 기능은 아래와 같은 세 가지 기능으로 나눌 수 있습니다.
첫 단계로 유저가 공유하기 원하는 데이터를 캡쳐하는 것입니다. Local media stream은 브라우저에 카메라나 마이크 등 로컬 디바이스에 접근을 허용합니다. 유저는 getUserMedia 함수로 브라우저로부터 접근을 할 수 있습니다.
유저가 stream을 정하고 나면 파트너 시스템과 연결을 합니다. 이로써 다른 브라우저(peer)와 음성, 비디오 등을 직접 교환할 수 있습니다. STUN & TURN 서버로 sender와 receiver의 관계를 형성합니다.
브라우저간 양방향으로 데이터를 교환할 수 있도록 합니다. createDataChannel 함수가 처음 PeerConnection Object 인스턴스 생성 시 호출됩니다.
캠을 이용해 비디오 정보를 받아와, 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만 확인할 수 있습니다.
RTCPeerConnection은 WebRTC를 이용해 오디오, 비디오를 streaming하고 data를 교환할 수 있게 해주는 api입니다. 아래의 예를 통해서 두 RTCPeerConnection object를 연결하는 방법을 확인할 수 있습니다. 실무에서 활용하기보다는 RTCPeerConnection이 동작하는 방법에 대해 이해하기 좋습니다.
<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을 이용해 두 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를 찾게됩니다.
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;
}
navigator.mediaDevices
.getUserMedia(mediaStreamConstraints)
.then(gotLocalMediaStream)
.catch(handleLocalMediaStreamError);
function gotLocalMediaStream(mediaStream) {
localVideo.srcObject = mediaStream;
callButton.disabled = false;
}
localPeerConnection.addStream(localStream);
localPeerConnection
.createOffer(offerOptions)
.then(createdOffer)
.catch(setSessionDescriptionError);
// sets peer connection session description
function createdOffer(description) {
localPeerConnection.setLocalDescription(description);
remotePeerConnection.setRemoteDescription(description);
remotePeerConnection
.createAnswer()
.then(createdAnswer)
.catch(setSessionDescriptionError);
}
// sets peer connection session descriptions.
function createdAnswer(description) {
remotePeerConnection.setLocalDescription(description);
localPeerConnection.setRemoteDescription(description);
}
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);
}
WebRTC를 이용해 인터넷을 통해 브라우저 간 통신을 하기 하려면 signaling server가 필요합니다. Signaling information을 교환하기 위해 WebSocket 또는 XMLHttpRequest를 이용할 수 있습니다. Signaling server는 Peer간 교류하는 데이터 내용에 대해서는 중요시하지 않습니다. 중요한 것은 Signaling data만을 상대편 peer에게 흘려보내는 것입니다.
WebRTC call을 설정하고 유지하기 위해서, clients는 아래와 같은 metadata를 교환하는 signaling 작업을 거쳐야 합니다.
다른 기기의 client가 metadata에 대한 정보를 주고 받기 위해서, signaling server가 필요합니다.
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();
}
}