이전 포스팅에서 Zoom의 핵심 기능인 영상 및 음성 스트림 기능을 구현해보았습니다.
그렇다면 이제 서로간의 통신을 해봐야겠죠?
※ 본 내용은 노마드코더 줌 클론코딩의 3.3~3.5 까지의 수업 내용을 담고 있습니다!
목차
0. WebRTC
먼저 좋은 레퍼런스가 존재하기 때문에, 이 글을 읽기 전에 참조하시면 좋습니다. (반드시 읽어야 하는 것은 아닙니다 ㅋㅋㅋㅋ)
WebRTC API - Web API | MDN
WebRTC(Web Real-Time Communication)은 웹 애플리케이션과 사이트가 중간자 없이 브라우저 간에 오디오나 영상 미디어를 포착하고 마음대로 스트림할 뿐 아니라, 임의의 데이터도 교환할 수 있도록 하는
developer.mozilla.org
WebRTC
An open framework for the web that enables Real-Time Communications (RTC) capabilities in the browser.
webrtc.org
WebRTC를 사용하는 가장 큰 이유는, webSocket이나 Socket.io에서 실현하지 못한 Peer-to-Peer 통신을 실현할 수 있기 때문입니다!!
이전에 채팅을 구현할 때 내 채팅을 서버에 보내고 서버에서 브로드캐스팅하는 형태였지만! WebRTC는 서버를 거치지 않고 바로 데이터를 전송할 수 있는 것이죠.
그렇다고 서버가 아예 필요하지 않은 것은 아닙니다.
위 사진을 보면 Signaling이라는게 존재하죠?
Peer-to-Peer로 연결하기 전에 사용자가 어디서 접속하는지 확인해야 할 필요가 있습니다.
서로의 IP주소가 public인지, 방화벽은 열려있는지, 어떤 포트를 사용할 수 있을지 확인하기 위해 서버가 중개자로 동작하게 됩니다.
이렇게 한번 Signaling이 끝나고 나면 서로의 위치를 파악하고 Peer-to-Peer로 직접 연결되게 되는 것이죠.
서버는 이 때만 동작하게 됩니다.
실제로 구현해보면서 더 자세하게 알아보도록 하죠!
1. 방 만들기
채팅에서 만들었던 방처럼, Video call을 처리하기 위해서 room이 다시 필요합니다.
사람들은 방에 들어와서 서로 통신할 거니까요!!
home.pug에 방에 참가할 수 있는 부분을 추가합니다.
이제 app.js를 고쳐야하는데, 우리가 원하는 것은 방에 접속하기 전에는 div#call 아래 부분이 보여지면 안되겠죠?
그렇기 때문에 getMedia를 통해 모든 내용이 시작되는 것을 고쳐야 합니다.
getMedia();를 지워줍니다!
그리고 welcome, call을 가져온 후에 call부분은 hidden처리해줍니다.
그 다음에는 지금까지 했던 내용의 반복입니다.
app.js에서 room name에 적었던 자료들을 서버로 보내는 작업을 해줍니다.
server.js에서는 받아온 input_value를 이용해서 소켓에 접속합니다.
반복이기 떄문에 자세한 설명은 생략합니다!!
방에 접속했을 때 welcome을 안보이게 하고 call을 보이게 하는 부분이 추가되어야겠죠?
이전에 사용했던 것처럼 emit할 때 함수를 보내도록 app.js와 server.js에 내용을 추가해봅시다.
이제 방에 접속하면 화면이 잘 뜨게 됩니다!!
추가로 가독성을 위해 welcome 부분을 따로 정리해주었습니다.
접속한 방 이름을 사용할 일이 있을 수도 있겠죠?
그런 상황을 대비해서 roomName이라는 변수를 만들어준 후에 여기에다가 접속한 방 이름을 저장해주도록 합시다.
2. offer 전송하기
먼저 누군가가 접속했을 때 알림이 가도록 만들어보겠습니다.
이 작업은 WebRTC의 연결 과정에서 반드시 필요한 부분입니다.
두 대상의 연결은 offer라는 시그널을 대화 상대에게 날리는 것에서 시작하는데, 이 시그널을 직접 만들어보는 것이죠! 재미있겠죠?
누군가 접속했을 때 someone joined를 콘솔에 찍도록 app.js를 바꿔줍니다.
그리고 서버에서 누군가가 접속했을 때 welcome 이벤트를 방 접속자에게 전달하도록 만들어줍니다.
구글 크롬과 파이어폭스 브라우저에서 동시에 hi라는 방에 접속을 하게 되면 먼저 접속한 브라우저의 콘솔창에 다른 브라우저가 접속할 때 someone joined라고 뜨게 됩니다! 신기하죠!!
연결의 시작을 잘 만들었으니, 이제 RTCPeerConnection이라는 함수를 이용해서 브라우저를 연결해봅시다.
myPeerConnection이라는 변수를 만들고, 연결이 되었을 때 작동하도록 해봅시다.
app.js에서 순서대로 변수를 만들고, makeConnection 함수를 만들고, 미디어가 시작될 때 makeConnection 함수가 작동하도록 만들어줍니다. (자세한 설명은 주석과 영상으로 갈음합니다!)
makeConnection 함수 내부에서 myPeerConenction에 RTCPeerConnection 인스턴스를 생성해서 넣어주고, getTracks를 통해 영상과 음성을 받아온 후에 각각을 myPeerConnection에 추가해줍니다.
그리고 나서 접속했을 때 offer를 만들어주는 부분을 추가해줍니다.
위에 올린 다이어그램에서 3번째 createOffer 부분이라고 보시면 됩니다!
offer는 초대장과 같은 기능이라고 보시면 됩니다. 현재 내 브라우저에 관한 내용들이 들어가있죠.
자세한 내용은 주석에 적어두었고, offer를 서버에 보내고 또 offer를 받을 수 있도록 구성하였습니다.
추가로 welcome 이벤트를 받았을 때 setLocalDescription(offer)를 통해서 내 브라우저의 myPeerConnection 변수에 offer를 등록해줍니다.
내 위치를 브라우저에 등록해주는 것이라고 보면 됩니다.
마지막으로 서버에서 offer를 처리할 수 있는 부분을 추가해줍니다.
offer이벤트가 들어오면 방에 있는 모든 사용자에게 offer를 전달할 수 있게 해줍니다.
여기까지 만들고 서버를 작동한 후에 두 브라우저에서 접속하게 되면, 다른 브라우저에서 접속하게 되면 그 브라우저에게 기존 접속자의 offer가 전송되는 것을 확인할 수 있습니다!
우리는 Signaling의 첫 단추를 꿴 것이죠!!
3. 소스 코드
// app.js
const socket = io(); // io function은 알아서 socket.io를 실행하고 있는 서버를 찾을 것이다!
const myFace = document.getElementById("myFace");
const muteBtn = document.getElementById("mute");
const cameraBtn = document.getElementById("camera");
const camerasSelect = document.getElementById("cameras");
const call = document.getElementById("call");
call.hidden = true;
// stream받기 : stream은 비디오와 오디오가 결합된 것
let myStream;
let muted = false; // 처음에는 음성을 받음
let cameraOff = false; // 처음에는 영상을 받음
let roomName;
let myPeerConnection; // 누군가 getMedia함수를 불렀을 때와 똑같이 stream을 공유하기 위한 변수
async function getCameras(){
try {
const devices = await navigator.mediaDevices.enumerateDevices(); // 장치 리스트 가져오기
const cameras = devices.filter(device => device.kind === "videoinput"); // 비디오인풋만 가져오기
const currentCamera = myStream.getVideoTracks()[0]; // 비디오 트랙의 첫 번째 track 가져오기 : 이게 cameras에 있는 label과 같다면 그 label은 선택된 것이다!
cameras.forEach(camera => {
const option = document.createElement("option"); // 새로운 옵션 생성
option.value = camera.deviceId; // 카메라의 고유 값을 value에 넣기
option.innerText = camera.label; // 사용자가 선택할 때는 label을 보고 선택할 수 있게 만들기
if(currentCamera.label === camera.label) { // 현재 선택된 카메라 체크하기
option.selected = true;
}
camerasSelect.appendChild(option); // 카메라의 정보들을 option항목에 넣어주기
})
} catch (e) {
console.log(e);
}
}
// https://developer.mozilla.org/ko/docs/Web/API/MediaDevices/getUserMedia 사용 : 유저의 유저미디어 string을 받기위함
async function getMedia(deviceId){
const initialConstraints = { // initialConstraints는 deviceId가 없을 때 실행
audio: true,
video: {facingMode: "user"}, // 카메라가 전후면에 달려있을 경우 전면 카메라의 정보를 받음 (후면의 경우 "environment")
};
const cameraConstraints = { // CameraConstraints는 deviceId가 있을 때 실행
audio: true,
video: {deviceId: {exact: deviceId}}, // exact를 쓰면 받아온 deviceId가 아니면 출력하지 않는다
};
try {
myStream = await navigator.mediaDevices.getUserMedia(
deviceId ? cameraConstraints : initialConstraints
)
myFace.srcObject = myStream;
if (!deviceId) { // 처음 딱 1번만 실행! 우리가 맨 처음 getMedia를 할 때만 실행됨!!
await getCameras();
}
} catch (e) {
console.log(e);
}
}
function handleMuteClick() {
myStream.getAudioTracks().forEach(track => track.enabled = !track.enabled);
if(!muted) {
muteBtn.innerText = "Unmute";
muted = true;
}
else {
muteBtn.innerText = "Mute";
muted = false;
}
}
function handleCameraClick() {
myStream.getVideoTracks().forEach(track => track.enabled = !track.enabled);
if(cameraOff) {
cameraBtn.innerText = "Turn Camera Off";
cameraOff = false;
}
else {
cameraBtn.innerText = "Turn Camera On";
cameraOff = true;
}
}
async function handleCameraChange() {
await getMedia(camerasSelect.value);
}
muteBtn.addEventListener("click", handleMuteClick);
cameraBtn.addEventListener("click", handleCameraClick);
// 카메라 변경 확인
camerasSelect.addEventListener("input", handleCameraChange);
// Welcome Form (join a room)
const welcome = document.getElementById("welcome");
const welcomeForm = welcome.querySelector("form");
async function startMedia(){
welcome.hidden = true;
call.hidden = false;
await getMedia();
makeConnection();
}
function handleWelcomeSubmit(event){
event.preventDefault();
const input = welcomeForm.querySelector("input");
socket.emit("join_room", input.value, startMedia); // 서버로 input value를 보내는 과정!! startMedia함수도 같이 보내준다!
roomName = input.value; // 방에 참가했을 때 나중에 쓸 수 있도록 방 이름을 변수에 저장
input.value = "";
}
welcomeForm.addEventListener("submit", handleWelcomeSubmit);
// Socket Code
socket.on("welcome", async () => {
const offer = await myPeerConnection.createOffer(); // 다른 사용자를 초대하기 위한 초대장!! (내가 누구인지를 알려주는 내용이 들어있음!)
myPeerConnection.setLocalDescription(offer); // myPeerConnection에 내 초대장의 위치 정보를 연결해 주는 과정 https://developer.mozilla.org/ko/docs/Web/API/RTCPeerConnection/setLocalDescription
console.log("sent the offer");
socket.emit("offer", offer, roomName);
})
socket.on("offer", offer => {
console.log(offer);
})
// RTC code
function makeConnection() {
myPeerConnection = new RTCPeerConnection(); // peerConnection을 각각의 브라우저에 생성 https://developer.mozilla.org/ko/docs/Web/API/RTCPeerConnection 참조
myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream)); // 영상과 음성 트랙을 myPeerConnection에 추가해줌 -> Peer-to-Peer 연결!!
}
// server.js
import http from "http"; // 이미 기본 설치되어있음
import WebSocket from "ws"; // 기본설치!
import express from "express"; // npm i express 설치
import { instrument } from "@socket.io/admin-ui";
import { Server } from "socket.io";
const app = express(); // app이라는 변수에 가져와서 사용
app.set("view engine", "pug"); // 뷰 엔진을 pug로 하겠다
app.set("views", __dirname + "/views"); // 디렉토리 설정
app.use("/public", express.static(__dirname + "/public")); // public 폴더를 유저에게 공개 (유저가 볼 수 있는 폴더 지정)
app.get("/", (req, res) => res.render("home")); // 홈페이지로 이동할 때 사용될 템플릿을 렌더
app.get("/*", (req, res) => res.redirect("/")) // 홈페이지 내 어느 페이지에 접근해도 홈으로 연결되도록 리다이렉트 (다른 url 사용 안할거라)
const handleListen = () => console.log(`Listening on http://localhost:3000`)
// app.listen(3000, handleListen); // 3000번 포트와 연결
const httpServer = http.createServer(app);
const wsServer = new Server(httpServer);
httpServer.listen(3000, handleListen); // 서버는 ws, http 프로토콜 모두 이해할 수 있게 된다!
wsServer.on("connection", socket => {
socket.on("join_room", (roomName, done) => {
socket.join(roomName);
done();
socket.to(roomName).emit("welcome"); // 특정 룸에 이벤트 보내기
});
socket.on("offer", (offer, roomName) => { // offer이벤트가 들어오면, roomName에 있는 사람들에게 offer 이벤트를 전송하면서 offer를 전송한다.
socket.to(roomName).emit("offer", offer);
});
});
//- home.pug
doctype html
html(lang="en")
head
meta(charset="UTF-8")
meta(http-equiv="X-UA-Compatible", content="IE=edge")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
title Noom
//- 덜 못생기게 만들어주는 요소
link(rel="stylesheet", href="https://unpkg.com/mvp.css")
body
header
h1 Noom
main
div#welcome
form
input(placeholder="room name", required, type="text")
button Enter room
//- video call에서 오는 모든 것들이 이 div 안으로 들어오게 된다!
div#call
//- playsinline은 모바일 브라우저가 필요로 하는 property
//- 모바일 기기로 비디오를 재생하면 비디오가 전체화면이 되버림 -> playsinline을 하면 전체화면이 되지 않는다!
div#myStream
video#myFace(autoplay, playsinline, width="400", height="400")
button#mute Mute
button#camera Turn Camera Off
select#cameras
option(value="device") Face Camera
//- 프론트엔드에 웹소켓 설치
script(src="/socket.io/socket.io.js")
script(src="/public/js/app.js")
4. 참고 자료
WebRTC API - Web API | MDN
WebRTC(Web Real-Time Communication)은 웹 애플리케이션과 사이트가 중간자 없이 브라우저 간에 오디오나 영상 미디어를 포착하고 마음대로 스트림할 뿐 아니라, 임의의 데이터도 교환할 수 있도록 하는
developer.mozilla.org
RTCPeerConnection - Web API | MDN
RTCPeerConnection 인터페이스는 로컬 컴퓨터와 원격 피어 간의 WebRTC 연결을 담당하며 원격 피어에 연결하기 위한 메서드들을 제공하고, 연결을 유지하고 연결 상태를 모니터링하며 더 이상 연결
developer.mozilla.org
RTCPeerConnection.setLocalDescription() - Web API | MDN
RTCPeerConnection.setLocalDescription() 메소드는 연결 인터페이스와 관련이 있는 로컬 설명 (local description)을 변경합니다. 로컬 설명은 미디어 형식을 포함하는 연결의 로컬 엔드에 대한 속성을 명시합
developer.mozilla.org
최근댓글