오늘은 실시간 통신의 마지막! websocket과 socket.io에 이어 webRTC에 대해 공부해보려고 한다.
✅
navigator.mediaDevices.getUserMedia()
MediaDevices 인터페이스의 getUserMedia() 메서드는 사용자에게 미디어 입력 장치 사용 권한을 요청한다. 요청이 수락되면 미디어 종류의 트랙을 포함한 MediaStream을 반환한다.
MDN 공식문서: getUserMedia 중 발췌...
사용자의 마이크나 카메라를 활성화하고자 할 때 자바스크립트에서는 getUserMedia()
메소드를 사용하면 된다!
메소드를 사용하고 사용자가 요청을 수락했을 때 MediaStream 값을 반환한다고 했는데, 이 미디어스트림은 비디오 트랙, 오디오 트랙 등등의 다른 스트림 정보들을 포함한다.
아래 함수에서 매개변수인 constrains는 요청할 미디어를 가지는 객체라고 이해하면 된다. 그러니까 이 매개변수에 이 미디어 정보들이 들어가는 것이다.
async function getMedia(constrains) {
const initialContrains = {
audio: true;
video: true;
} // 특별한 요구사항 없이 오디오와 비디오 요청
try {
stream = await navigator.mediaDevices.getUserMedia(initialContrains);
const video = document.createElement('video');
video.srcObject = stream;
} catch(error) {
console.log(error)
}
}
constrains 매개변수에 담을 객체로 세부적인 요청을 할 수도 있다. 예를 들어 비디오의 해상도를 특정해서 요청할수도 있고, 모바일 장치 전면, 후면 카메라를 특정해 요청할수도 있다.
그런다음 <video>
태그를 생성하고 srcObject 속성에 getUserMedia메소드를 호출한 값을 넣어준다.
✔️ getAudioTracks()
오디오 정보 가져오기
✔️ getVideoTracks()
비디오 정보 가져오기
function handleMuteClick() {
myStream
.getAudioTracks() // 스트림에서 getAudioTrack() 가져오기
.forEach((track) => (track.enabled = !track.enabled));
if (!muted) {
muteBtn.innerText = "Unmute";
muted = true;
}
function handleCameraClick() {
myStream
.getVideoTracks() // 스트림에서 getVideoTracks() 가져오기
.forEach((track) => (track.enabled = !track.enabled));
if (cameraOff) {
cameraBtn.innerText = "Turn Camera Off";
cameraOff = false;
} else {
cameraBtn.innerText = "Turn Camera On";
cameraOff = true;
}
}
muteBtn.addEventListener("click", handleMuteClick);
cameraBtn.addEventListener("click", handleCameraClick);
✔️ enumerateDevices()
모든 미디어 장치 보여주기
컴퓨터에 연결되거나 모바일이 가지고 있는 모든 미디어 장치를 알려준다.
async function getCameras() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter((device) => device.kind === "videoinput");
const currentCamera = myStream.getVideoTracks()[0];
cameras.forEach((camera) => {
const option = document.createElement("option");
option.value = camera.deviceId;
option.innerText = camera.label;
if (currentCamera.label === camera.label) {
option.selected = true;
}
camerasSelect.appendChild(option);
});
} catch (e) {
console.log(e);
}
}
WebRTC (Web Real-Time Communication)
웹 애플리케이션과 사이트가 중간자 없이 브라우저 간에 오디오나 영상 미디어를 포착하고 마음대로 스트림할 뿐 아니라, 임의의 데이터도 교환할 수 있도록 하는 기술입니다. WebRTC를 구성하는 일련의 표준들은 플러그인이나 제 3자 소프트웨어 설치 없이 종단 간 데이터 공유와 화상 회의를 가능하게 합니다. (중략)
MDN: WebRTC_API
MDN에서도 브라우저의 지원 상태가 다 다르기 때문에 Adapter.js 라이브러리를 사용하는 것을 권장하고 있다.
WebRTC는 Peer to Peer 형식으로 실시간 통신이 이루어진다. 여기서 Peer to Peer은 서버에서 모두에게 통신을 전달하는 방식이라면, Peer to Peer는 서버를 통하지 않고, 나의 브라우저와 너의 브라우저가 바로 직통으로 연결되어 커뮤니케이션이 가능하다.
그러니까 "서버"가 중간에 끼지 않는 것이 바로 WebRTC!. 바로 직통으로 전송되기 때문에 실시간으로 속도가 엄청나게 빠른 것이 특징이다.
그렇다면 WebRTC는 서버가 전혀 필요없는 것일까? 그렇지는 않다. 일단 브라우저끼리 연결을 하려면 어떤 브라우저인지는 알아야 할 것 아닌가? 그 정보를 알아내기 위해 서버에서 signaling을 이용한다.
브라우저 간 직통으로 연결되어 실시간 소통을 하기 위해서는 통신을 원하는 곳의 ip 주소가 필요한데, 이 때 브라우저는 서버한테 configuration(방화벽, 라우터 설정같은 것)을 전달한다. 그럼 서버가 연결하고자 하는 다른 브라우저에게 나의 위치를 알려준다. 브라우저는 서로의 위치를 알고 나서야 연결이 가능하다.
앞서 WebRTC를 사용하기 위해서는 먼저 signaling을 통해 서로 연결을 해주어야 한다고 했다. 그리고 signaling은 서버를 통해 이루어지며, 이 부분은 websocket으로 해결할 수 있다.
예시로 두명의 유저가 같은 방의 이름을 적고 입장하면 채팅을 시작하는 코드를 짠다고 생각해보자. 그럼 방의 이름을 적고 submit하는 것까지는 websocket으로 이뤄지며, 입장한 이후 두사람은 WebRTC로 소통한다.
이 연결들은 일단 따로 설정이 이루어지며, 그것들을 서버로 연결해준다!
// client
// RTC Code
let myPeerConnection
let myStream
function makeConnection() {
myPeerConnection = new RTCPeerConnection(); // 양 브라우저 간 peer to peer 연결을 만듦.
myStream
.getTrack()
.forEach(track => myPeerConnection.addTrack(track, myStream) // myStream.getTrack()으로 얻은 데이터를 myPeerConnection안에 집어넣음
}
만약 비디오나 오디오 데이터를 연결하고자 한다면 이 데이터들을 peer connection안에 넣어야 한다.
이렇게 peer to peer connection을 만들었다면 Peer A 브라우저, Peer B 브라우저가 생긴 것이다. 그럼 Peer A 브라우저에서 createOffer
메소드를 사용한다.이 메소드는 소켓 코드에서 사용한다.
// client
// for Peer A
socket.on("welcome", async () => {
const offer = await myPeerConnection.createOffer();
myPeerConnection.setLocalDescription(offer);
socket.emit("offer", offer, roomName) // 어떤 룸에 오퍼를 줄건지 알려주기 위해
})
이렇게 Peer A 브라우저에서 offer
를 만들었다면 setLocalDescription
로 연결해야 한다. 기억해야할 점은 이 코드는 Peer A 브라우저에만 돌아가는 코드란 것이다.
// server
wsServer.on("connection", (socket) => {
socket.on("join_room", (roomName, done) => {
socket.join(roomName);
done();
socket.to(roomName).emit("welcome");
});
socket.on("offer", (offer, roomName) => {
socket.to(roomName).emit("offer", offer);
});
socket.on("answer", (answer, roomName) => {
socket.to(roomName).emit("answer", answer);
});
});
클라이언트에서 데이터를 받았으면 서버에서 받아서 roomName방에 연결된 모든 브라우저에 offer 데이터를 보내준다.
// client
// for Peer A
socket.on("welcome", async () => {
const offer = await myPeerConnection.createOffer();
myPeerConnection.setLocalDescription(offer);
socket.emit("offer", offer, roomName) // 어떤 룸에 오퍼를 줄건지 알려주기 위해
})
// for Peer B
socket.on("offer", offer => {
myPeerConnection.setRemoteDescription(offer);
const answer = await myPeerConnection.createAnswer();
myPeerConnection.setLocalDescription(answer);
socket.emit("answer", answer, roomName); // 서버로 answer 보내기
})
socket.on("answer", (answer) => {
myPeerConnection.setRemoteDescription(answer);
})
Peer A 브라우저(이하 A브라우저)에서 setLocalDescription을 하며 offer를 만들었고 이 offer를 Peer B 브라우저(이하 B브라우저)로 보낸다. B브라우저는 description을 받고, setRemoteDescription을 하며 answer을 만들어 A브라우저에 보낸다. 그럼 A브라우저는 answer을 통해 setRemoteDescription을 가질 수 있게 되었다.
MDN: RTCPeerConnection.setLocalDescription()
MDN: RTCPeerConnection.setRemoteDescription()
MDN: RTCPeerConnection.createOffer()
MDN: RTCPeerConnection.createAnswer()
우와 진짜 서버랑 클라이언트 왔다갔다하니까 헷갈린다!!!!근데 반밖에 안왔다니;;;;;이렇게 webRTC에서 peer to peer 연결을 위해 offer와 answer의 과정을 끝마치게 되면 양 브라우저에서 icecandidate라는 이벤트를 실행하기 시작한다.
하나의 ICE candidate는 WebRTC가 원격 장치와 통신을 하기 위해 요구되는 프로토콜과 라우팅에 대해 알려줍니다.
MDN: RTCIceCandidate
ICE(Internet Connectivity Establishment, 인터넷 연결 생성)는 webRTC의 프로토콜이며, 원격으로 다른 장치와 소통할 수 있게 해준다.
아직 100% 이해는 못했지만... 일단 이해한대로 이야기해보면, WebRTC에서 peer to peer 연결이 시작되면 여러가지 candidate들이 나타나게 된다. 여기서 candidate는 브라우저가 통신하는 방식에 대한 정보를 나타낸다고 생각했다. 통신방식이 여러가지 있다고 생각했고 이 정보들을 원격 브라우저에 보내서 로컬과 원격 유저의 연결이 어떤 것이 가장 최적화된 것이라고 동의하기전까지 계속해서 연결을 시도한다고 한다.
그러니까 ICE는 브라우저가 어떤 소통 방법이 제일 좋은지 제안하며 연결해주는 프로세스이다. 다수의 후보(candidates)들을 제외하고 서로의 동의하에 하나를 선택한다!!!
예를 들어 크롬 브라우저와 파이어폭스가 webRTC로 소통하려고 한다고 가정해보자.
크롬 브라우저가 ICE Candidate 이벤트를 발생시킨다. 그럼 크롬 브라우저는 파이어폭스에게 candidate를 보내게 된다.
그럼 파이어폭스는 브라우저에 icecandidate를 추가한다. 그리고 나서 파이어폭스 브라우저는 똑같이 ICE Candidate 이벤트를 발생시키고 크롬 브라우저에 candidate를 보낸다. 쌍방 소통이니까!
// client
// candidate를 받고 추가하기
socket.on("ice", (ice) => {
// receive the candidate
myPeerConnection.addIceCandidate(ice);
});
// 위에서 webRTC연결을 위해 만들었던 함수
function makeConnection () {
myPeerConnection = new RTCPeerConnection(); // 양 브라우저 간 peer to peer 연결을 만듦.
myPeerConnection.addEventListener("icecandidate", handleIce); // candidate를 다른 브라우저에 전달하기 위함
myPeerConnection.addEventListener("addstream", handleAddStream); // peer브라우저로부터 데이터를 전달받음
myStream
.getTrack()
.forEach(track => myPeerConnection.addTrack(track, myStream) // myStream.getTrack()으로 얻은 데이터를 myPeerConnection안에 집어넣음
}
function handleIce(data) { // 아이스를 서버로 전달
socket.emit("ice", data.candidate, roomName);
}
function handleAddStream() { // 상대방의 비디오 스트림 데이터를 전달받음
const peerFace = document.getElementById("peerFace");
peerFace.srcObject = data.stream;
}
// server
socket.on("ice", (ice, roomName) => {
socket.to(roomName).emit("ice", ice);
});
우리는 카메라 종류를 바꾸거나 음소거하거나 등의 미디어를 바꿀 때가 있다. Sender는 다른 브라우저로 이미 보낸 media stream track을 컨트롤할 수 있게 만들어준다. 그럼 로컬 브라우저에서 원격으로 비디오나 오디오에 대한 변경을 실시간으로 반영할 수 있다.
RTCRtpSender
✅ RTCRtpSender.replaceTrack()
webRTC로 소통할 때 비디오나 오디오 트랙을 변경할 때 실시간으로 데이터를 전달해주기 위해서는 RTCRtpSender.replaceTrack() 메소드를 사용하면 된다!
async function handleCameraChange() {
await getMedia(camerasSelect.value);
if (myPeerConnection) { // webRTC 발생 후 선택한 새 장치로 새로 업데이트 된 video track을 받음
const videoTrack = stream.getVideoTracks()[0];
const videoSender = myPeerConnection
.getSenders()
.find((sender) => sender.track.kind === "video");
videoSender.replaceTrack(videoTrack);
}
}
STUN 서버란?
STUN 서버는 컴퓨터가 공용 IP주소를 찾게 해준다.
우리가 네트워크에서 데이터를 주고받기 위해서는 공용 퍼블릭 IP가 필요하다. 그렇기 때문에 peer to peer 방식으로 작동하는 webRTC는 모든 기기의 환경이 다르기 때문에 단순하게 연결되지 않는다. 그렇기 때문에 STUN서버로 좀 더 완전하게 동작할 수 있도록 만들어줘야 한다.
노마드코더 수업에서는 구글에서 제공하는 STUN서버를 사용했다. 물론 이것을 실제 서비스에 적용하는 것은 무리가 있다고 한다. 그때는 직접 STUN 서버를 만들어야 한다고 한다.
아니면 찾아보니까 peer이라는 라이브러리를 설치하면 라이브러리 자체 안에 STUN자체를 제공해주고도 있나보다.
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:stun4.l.google.com:19302",
],
},
],
});
myPeerConnection.addEventListener("icecandidate", handleIce);
myPeerConnection.addEventListener("addstream", handleAddStream);
stream
.getTracks()
.forEach((track) => myPeerConnection.addTrack(track, stream)); // 각 브라우저들을 구성했음
}
너무 많은 Peer을 가지고 있을 때 굉장히 느려진다.
Peer이 많아지기 시작하면 실시간 소통이 굉장히 느려지기 시작할 것이다. 만약 5개의 peer 브라우저가 있다고 했을 때, A브라우저가 비디오 정보를 보낸다면, B, C, D, E 브라우저에 각각 보내야 하기 때문이다. 같은 비디오 스트림을 4번 실행해야 하는 것이다. 모든 사람들이 직접적으로 연결되어 있기 때문이다.
그렇기 때문에 최대 3개까지하는 것이 좋을 것이다.
우아 여기까지 webRTC에 대해 굉장히 많은 것을 배웠다. 노마드코더 줌 클론코딩 강의와 공식문서, 그리고 여러 블로그를 통해 채팅 기능을 구현하고 싶었는데 앞으로 스스로 프로젝트도 더 만들어 볼 수 있을 것 같다.
사실 이 포스트에 미디어 트랙들을 담는 것부터 시작해서 webRTC의 offer, answer, ice candidate등의 실행 순서, 그리고 실시간 소통에서 미디어 트랙 변경 정보를 다른 브라우저에 원격으로 컨트롤 하는 방법, STUN서버 등등에 대해 배웠다. 그냥 나눠서적는게 더 나았나?😅 뭔가 흐름이 끊기는게 싫었다...ㅎㅎㅎ 앞으로 더 알게되는 사항들은 틈틈히 추가해야징!