유튜브 플랫폼에서 구독자와 소통하기 위해 라이브 방송을 킨다고 가정해보자.
스트리밍을 하기 위해 가장 먼저 해야하는 것은 웹/앱에 연결된 카메라, 마이크 등에 연결을 해야한다.
(카메라, 마이크가 연결되지 않으면 스트리밍을 하는 의미가 없어진다.)
그렇다면, 어떻게 연결할 수 있을까?
연결과정을 알아보자.
1. WebRTC와 미디어 장치
1-1. 미디어 장치와 연결하기
[1] 제약조건에 맞는 카메라, 마이크를 선택해준다.
const openMediaDevices = async (constraints) => {
return await navigator.mediaDevices.getUserMedia(constraints);
}
try {
const stream = openMediaDevices({'video':true,'audio':true});
console.log('Got MediaStream:', stream);
} catch(error) {
console.error('Error accessing media devices.', error);
}
-
navigator.mediaDevices 객체를 통해서 해당 기기에 연결되어 있는 장치들을 불러온다.
-
getUserMedia(constraints) : 연결된 장치들 중 constraints에 만족하는 장치들을 불러온다.
-
정상적으로 호출한다면 권한 요청
-
권한 수락 : MediaStream이 연결됨
-
권한 거부 : PermissionDeniedError 발생
-
-
연결된 장치가 없다면, NotFoundError 발생
-
-
MediaDevices의 인터페이스(또는 예외상황-중간에 장치들의 연결이 끊김 등)
[2] 사용자가 직접 고르도록 하기
async function getConnectedDevices(type) {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter(device => device.kind === type)
}
const videoCameras = getConnectedDevices('videoinput');
console.log('Cameras found:', videoCameras);
[3] 장치가 변경되었을 경우
USB로 연결된 웹캠을 사용하고 있다가, 연결이 끊겨서 노트북에 내장된 웹캠을 사용할 때, 미디어 장치의 변경 사항 수신한다.
// Updates the select element with the provided set of cameras
function updateCameraList(cameras) {
const listElement = document.querySelector('select#availableCameras');
listElement.innerHTML = '';
cameras.map(camera => {
const cameraOption = document.createElement('option');
cameraOption.label = camera.label;
cameraOption.value = camera.deviceId;
}).forEach(cameraOption => listElement.add(cameraOption));
}
// Fetch an array of devices of a certain type
async function getConnectedDevices(type) {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter(device => device.kind === type)
}
// Get the initial set of cameras connected
const videoCameras = getConnectedDevices('videoinput');
updateCameraList(videoCameras);
// Listen for changes to media devices and update the list accordingly
navigator.mediaDevices.addEventListener('devicechange', event => {
const newCameraList = getConnectedDevices('video');
updateCameraList(newCameraList);
});
1-2. 미디어 제약사항
[1] constraints 객체를 통해 요구사항 설정 가능
-
구체적 또는 느슨하게 정의할 수 있다.
-
ex ) 해상도, 장치ID, 기존 장치인지 확인하는 deviceId, 마이크 에코, 최소 너비 및 높이 설정 등
[2] constraints 객체는 MediaStreamConstraints 인터페이스를 상속한다.
developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints
1-3. 로컬 재생
카메라, 마이크 등에 연결을 하고 제약사항 등을 설정한 후 실제 방송을 키게 되면, 내 방송이 어떻게 스트리밍 되고 있는지 로컬에서 재생하고 있는 화면이 보인다.
async function playVideoFromCamera() {
try {
const constraints = {'video': true, 'audio': true};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const videoElement = document.querySelector('video#localVideo');
videoElement.srcObject = stream;
} catch(error) {
console.error('Error opening video camera.', error);
}
}
2. 미디어 캡처 및 제약
2-1. 미디어 제약 조건
[1] 미디어 장치에 액세스 할 때 가능한 자세한 제약 조건을 제공하는 것이 좋다.
애플리케이션 용도에 가장 적합한 미디어 스트림(마이크, 카메라)을 찾기 위해서
[2] 특정 제약 조건은 MediaTrackConstraint 개체에 정의되어 있다.
정확한 값을 요구하는 경우
// Camera with the exact resolution of 1024x768
{
"video": {
"width": {
"exact": 1024
},
"height": {
"exact": 768
}
}
}
2-2. 디스플레이 미디어
-
화면을 캡처하고 녹화를 하려면 Display Media API를 사용해야한다.
-
navigator.mediaDevices 의 getDisplayMedia()를 통해 디스플레이의 내용을 여는 데 사용된다.
{
video: {
cursor: 'always' | 'motion' | 'never',
displaySurface: 'application' | 'browser' | 'monitor' | 'window'
}
}
2-3. 스트림 및 트랙
MediaStream은 오디오, 비디오의 트랙(MediaStreamTrack)으로 구성된 미디어 콘텐츠 스트림을 말한다.MediaStream.getTracks()은 MediaStreamTrack 객체의 배열을 반환한다.
2-3-1. MediaStreamTrack
-
MediaStreamTrack은 kind(종류) 속성이 있고, audio인지 video인지를 구별한다.
-
enabled : 트랙을 음소거 할 수 있다.
-
remote : RTCPeerConnection을 하고 remote peer로 부터 왔는지를 판단하는 Boolean type
이제 영상을 송출할 준비는 끝났다!
내가 지금 찍고 있는 이 영상을 다른 곳에 보내기 위해서 대상이 되는 PEER(클라이언트)와 연결을 시켜주어야 한다.
3. Peer Connection
두 피어가 연결을 할 수 있으려면 두 클라이언트 모두 ICE 서버 구성을 제공해야한다.
이를 STUN, TURN 서버가 remote peer와 연결할 수 있는 ICE 후보들을 제공한다.
ICE 후보들을 전송하는 것을 "Signaling(시그널링)"이라고 부른다.
3-1. Signaling
-
WebRTC 사양에 ICE서버와 통신하기 위한 API가 포함되어 있지만, Signaling의 일부는 아니다.
-
Signalling은 어떻게 서로 연결될 수 있는지 공유하기위해 필요하다.
-
예를 들어 A는 어떤 대상에게 편지를 보낼 때 박스로 동봉해서 주는 방식, 편지봉투에 담아서 보내는 방식 등등 어떤 방식으로 보낼지 연결할지 공유한다.
-
-
Signalling은 피어 연결이 이루어지기 전 웹 애플리케이션이 필요한 정보를 전달할 수 있는 HTTP 기반의 웹 API로 이루어진다.
// 비동기적으로 메세지를 주고 받는 방식
// used during the peer connection setup
const signalingChannel = new SignalingChannel(remoteClientId);
signalingChannel.addEventListener('message', message => {
// New message from remote client received
});
// Send an asynchronous message to the remote client
signalingChannel.send('Hello!');
cf) ICE는 두 단말이 서로 통신할 수 있는 최적의 경로를 찾을 수 있도록 도와주는 프레임워크이다. STUN과 TURN을 활용하며 SDP 제안 및 수락 모델을 적용할 수 있다.
3-2. Peer Connection 시작
각 피어간의 연결은 RTCPeerConnection 객체에 의해 다뤄진다.
-
RTCPeerConnection의 생성자는 RTCConfiguration객체 하나를 매개변수로 가진다.
-
RTCConfiguration 객체는 어떻게 peer연결을 설정할지, 사용할 ICE 서버의 정보를 포함해야한다.
-
-
RTCPeerConnection은 송신/수신을 할지에 따라 SDP offer/answer을 만들어야한다.
-
SDP offer/answer이 생성되면 다른 채널을 통해 remote peer에 보내야한다.
-
SDP object를 remote peer에 보내주는 것을 Signalling이라고 한다.
-
WebRTC 사양에는 포함되지 않음
-
3-2-1. 송신자( 스트리머 )
-
송신자(스트리머)가 연결을 시작하기 위해, RTCPeerConnection 객체를 만들고 createOffer()을 호출하면 RTCSessionDescription 객체가 생성된다.
-
RTCSessionDescription 은 setLocalDescription()함수를 사용해서 로컬을 설정하고 수신측의 signaling채널로 보낸다.
-
-
또한, Signaling채널에 listener를 만들고 우리가 보냈던 세션 정보를 수신측에서 수신을 잘 받았다는 답변을 받는다.
async function makeCall() {
const configuration = {'iceServers': [{'urls': 'stun:stun.l.google.com:19302'}]}
const peerConnection = new RTCPeerConnection(configuration);
signalingChannel.addEventListener('message', async message => {
if (message.answer) {
const remoteDesc = new RTCSessionDescription(message.answer);
await peerConnection.setRemoteDescription(remoteDesc);
}
});
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
signalingChannel.send({'offer': offer});
}
3-2-2. 수신자( Viewer )
-
수신 측에서는 offer가 오기 전까지 RTCPeerConnection 인스턴스를 만들지 않는다.
-
offer가 오면 setRemoteDescription()함수로 연결을 성립한다.
-
그 다음으로 createAnswer()을 만들어서 송신 측에 잘 받았다는 응답을 보낸다.
-
만들어진 answer로 local description을 셋팅하고(setLocalDescription() 함수 사용) 시그널링 서버를 통해 송신 측에 answer을 보낸다.
-
const peerConnection = new RTCPeerConnection(configuration);
signalingChannel.addEventListener('message', async message => {
if (message.offer) {
peerConnection.setRemoteDescription(new RTCSessionDescription(message.offer));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
signalingChannel.send({'answer': answer});
}
});
-
양쪽의 피어가 로컬과 원격 세션 정보를 세팅하면 원격 피어의 기능을 알 수 있다.
-
아직 두 피어간의 연결이 된 것은 아니다.
-
피어 간의 연결이 완료되기 위해서는 각 피어에서 ICE 후보들을 수집해 서로에게 전송해야 한다.
3-3. ICE candidates
-
두 피어들이 WebRTC를 사용해서 데이터 전송, 수신 등을 하기 전에, 연결정보를 교환해야한다.
-
네트워크 상태가 여러 요인에 의해 달라지기 때문에 연결할 수 있는 후보자들을 찾아주는 역할을 할 외부 서비스가 필요하다.
-
그것이 바로 ICE!
-
ICE는 STUN, TURN 서버를 사용한다.
-
STUN(Session Traversal Utilities for NAT)서버는 대부분 WebRTC 애플리케이션에서 종종 사용한다.
-
TRUN(Traversal Using Relay NAT)는 STUN 프로토콜을 통합한 발전된 솔루션으로 대부분의 상용 WebRTC 서비스들이 사용한다.
-
-
-
WebRTC API는 STUN과 TURN을 지원해준다.
-
WebRTC로 연결을 할 때, 보통 한 개 이상의 ICE 서버들을 RTCPeerConnection객체에 담아서 제공한다.
3-4. Trickle ICE
-
RTCPeerConnection객체가 만들어지면 제공된 ICE서버들을 사용해서 candidates들을 모은다.
-
RTCPeerConnection의 icegatheringstatechange이벤트는 ICE gathering이 (new, gathering, complete) 인지 상태를 나타낸다.
-
피어에 ICE 수집이 완료되기 전까지 대기하라고 하는 것이 가능하지만, 보통 "trickle ice" 테크닉을 사용해 각 ICE 후보가 발견이 될 때마다 remote 피어에 전달하는 것이 효율적이다.
-
Trickle ICE 테크닉은 피어 간의 연결 설정 시간을 줄여 비디오가 적은 딜레이로 시작할 수 있게 해준다.
-
ICE 후보를 수집하려면, icecandidate 이벤트를 listener에 추가하면 된다. 해당 리스너에서 방출된 RTCPeerConnectionIceEvent는 remote 피어에게 전송될 새로운 후보를 포함한 candidate 속성을 갖는다.
// Listen for local ICE candidates on the local RTCPeerConnection
peerConnection.addEventListener('icecandidate', event => {
if (event.candidate) {
signalingChannel.send({'new-ice-candidate': event.candidate});
}
});
// Listen for remote ICE candidates and add them to the local RTCPeerConnection
signalingChannel.addEventListener('message', async message => {
if (message.iceCandidate) {
try {
await peerConnection.addIceCandidate(message.iceCandidate);
} catch (e) {
console.error('Error adding received ice candidate', e);
}
}
});
3-5. 연결설정
ICE 후보들이 수신되면, peer의 연결상태가 연결된 상태로 변경된다.
연결된 상태로 변한 것을 알기 위해 connectionstatechange 를 수신하는 RTCPeerConnction에 리스너를 추가하면 된다.
// Listen for connectionstatechange on the local RTCPeerConnection
peerConnection.addEventListener('connectionstatechange', event => {
if (peerConnection.connectionState === 'connected') {
// Peers connected!
}
});
4. 원격 스트림 시작하기
RTCPeerConnection이 remote peer에 연결이 되면, 비디오 또는 오디오를 스트리밍 할 수 있다.
미디어 스트림은 적어도 하나의 미디어 track이 있어야하고 각 트랙들은 원격 피어에게 미디어를 전송하고 싶을 때 RTCPeerConnection에 개별적으로 더해진다.
const localStream = await getUserMedia({vide: true, audio: true});
const peerConnection = new RTCPeerConnection(iceConfig);
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
트랙은 remote peer에 연결되기 전에 RTCPeerConnection에 추가할 수 있다. 이렇기 때문에 연결될 때까지 기다리지 않고 먼저 설정해주는 편이 낫다.
4-1. 원격 트랙 추가
다른 피어가 추가한 원격 트랙을 받기 위해서, track 이벤트를 받을 리스터를 추가해줘야 한다.
MediaStream객체에서 재생이 되기 때문에, 먼저 빈 인스턴스를 만든 다음 트랙을 수신할 때 원격 피어의 트랙으로 채운다.
참고 자료
webrtc.org/getting-started/overview?hl=en
webrtc.org/getting-started/media-capture-and-constraints?hl=ko
RTCPeerConnection 관련 MDN 문서 : developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection
'WebRTC' 카테고리의 다른 글
[WebRTC] Kurento를 사용하여 실시간 스트리밍하기 (3) | 2021.01.25 |
---|---|
[WebRTC] NAT, ICE, STUN, TURN 이란? ( WebRTC를 이해하기 위해 필요한 지식들) (2) | 2021.01.11 |
[Kurento] 쿠렌토 서버 Docker로 실행시켜보기 (feat. 윈도우) (1) | 2021.01.08 |
[WebRTC] 쿠렌토(Kurento)는 무엇인가? (0) | 2021.01.07 |
[WebRTC] WebRTC란 무엇일까? (0) | 2020.12.22 |