오늘은 노마드 코더 강의를 보며 Web RTC 구현을 했습니다
강의 링크
import http from "http";
import SocketIO from "socket.io";
import express from "express";
const app = express();
app.set("view engine", "pug");
app.set("views", __dirname + "/views");
app.use("/public", express.static(__dirname + "/public"));
app.get("/", (_, res) => res.render("home"));
app.get("/*", (_, res) => res.redirect("/"));
const httpServer = http.createServer(app);
const wsServer = SocketIO(httpServer);
wsServer.on("connection", (socket) => {
socket.on("join_room", (roomName) => {
socket.join(roomName);
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);
});
socket.on("ice", (ice, roomName) => {
socket.to(roomName).emit("ice", ice);
});
});
const handleListen = () => console.log(`Listening on http://localhost:6000`);
httpServer.listen(6000, handleListen);
Socket.IO와 Express를 사용하여 웹 소켓 기능을 구현하는 서버 코드입니다. 이 서버는 사용자들이 영상 통화를 할 수 있는 간단한 채팅 애플리케이션을 만들기 위해 설계되었습니다. 각 부분별로 기능을 설명하겠습니다.
Express 앱을 생성하고, 뷰 엔진으로 Pug를 사용하도록 설정합니다.
정적 파일들을 제공하기 위해 /public 경로를 정적 파일 디렉토리로 설정합니다.
루트 경로("/")로 접속 시, "home" 템플릿을 렌더링하여 클라이언트에게 보여줍니다.
그 외의 모든 경로에 접속 시, 루트 경로("/")로 리다이렉트 합니다.
HTTP 서버를 생성하고, Express 앱을 HTTP 서버와 연결합니다.
Socket.IO 라이브러리를 사용하여 웹 소켓 서버를 생성합니다.
클라이언트가 소켓에 접속(connect)하면, "connection" 이벤트가 발생하고 해당 소켓 객체를 인자로 받아 실행됩니다.
클라이언트가 특정 방(room)에 입장(join_room) 요청을 보내면, 해당 방에 입장(join)하도록 합니다. 그리고 해당 방에 있는 다른 클라이언트들에게 "welcome" 이벤트를 보내 환영 메시지를 전달합니다.
"offer" 이벤트는 클라이언트가 원격 피어에게 offer 메시지를 보낼 때 발생하며, 해당 방에 있는 다른 클라이언트들에게 offer 메시지를 전달합니다.
"answer" 이벤트는 클라이언트가 원격 피어에게 answer 메시지를 보낼 때 발생하며, 해당 방에 있는 다른 클라이언트들에게 answer 메시지를 전달합니다.
"ice" 이벤트는 클라이언트가 원격 피어에게 ICE (Interactive Connectivity Establishment) 메시지를 보낼 때 발생하며, 해당 방에 있는 다른 클라이언트들에게 ICE 메시지를 전달합니다.
const 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;
let myStream;
let muted = false;
let cameraOff = false;
let roomName;
let myPeerConnection;
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);
}
}
async function getMedia(deviceId) {
const initialConstrains = {
audio: true,
video: { facingMode: "user" },
};
const cameraConstraints = {
audio: true,
video: { deviceId: { exact: deviceId } },
};
try {
myStream = await navigator.mediaDevices.getUserMedia(
deviceId ? cameraConstraints : initialConstrains
);
myFace.srcObject = myStream;
if (!deviceId) {
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);
if (myPeerConnection) {
const videoTrack = myStream.getVideoTracks()[0];
const videoSender = myPeerConnection
.getSenders()
.find((sender) => sender.track.kind === "video");
videoSender.replaceTrack(videoTrack);
}
}
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 initCall() {
welcome.hidden = true;
call.hidden = false;
await getMedia();
makeConnection();
}
async function handleWelcomeSubmit(event) {
event.preventDefault();
const input = welcomeForm.querySelector("input");
await initCall();
socket.emit("join_room", input.value);
roomName = input.value;
input.value = "";
}
welcomeForm.addEventListener("submit", handleWelcomeSubmit);
// Socket Code
socket.on("welcome", async () => {
const offer = await myPeerConnection.createOffer();
myPeerConnection.setLocalDescription(offer);
console.log("sent the offer");
socket.emit("offer", offer, roomName);
});
socket.on("offer", async (offer) => {
console.log("received the offer");
myPeerConnection.setRemoteDescription(offer);
const answer = await myPeerConnection.createAnswer();
myPeerConnection.setLocalDescription(answer);
socket.emit("answer", answer, roomName);
console.log("sent the answer");
});
socket.on("answer", (answer) => {
console.log("received the answer");
myPeerConnection.setRemoteDescription(answer);
});
socket.on("ice", (ice) => {
console.log("received candidate");
myPeerConnection.addIceCandidate(ice);
});
// RTC Code
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);
myStream
.getTracks()
.forEach((track) => myPeerConnection.addTrack(track, myStream));
}
function handleIce(data) {
console.log("sent candidate");
socket.emit("ice", data.candidate, roomName);
}
function handleAddStream(data) {
const peerFace = document.getElementById("peerFace");
peerFace.srcObject = data.stream;
}
이 코드는 앞서 설명한 서버와 클라이언트 사이에서 실시간 영상 통화를 수행하는 클라이언트 측의 JavaScript 코드입니다. 코드의 기능을 다음과 같이 설명하겠습니다.
getCameras(): 미디어 장치(카메라) 목록을 가져와서 옵션으로 추가하고, 현재 선택된 카메라를 표시합니다.
getMedia(deviceId): 사용자의 미디어(오디오 및 비디오)를 가져옵니다. deviceId가 주어지면 해당 카메라를 사용하고, 없으면 기본 카메라를 사용합니다.
handleMuteClick(): 오디오 뮤트 버튼을 클릭하면 오디오 트랙의 활성화 여부를 변경하고, 상태에 따라 버튼의 텍스트를 변경합니다.
handleCameraClick(): 카메라 On/Off 버튼을 클릭하면 비디오 트랙의 활성화 여부를 변경하고, 상태에 따라 버튼의 텍스트를 변경합니다.
handleCameraChange(): 사용자가 다른 카메라를 선택할 때 호출되며, 선택한 카메라로 미디어를 갱신합니다.
socket.on("welcome", ...): 서버로부터 "welcome" 이벤트를 수신하면 myPeerConnection.createOffer()를 호출하여 오퍼(offer)를 생성하고, 생성한 오퍼를 로컬 디스크립션으로 설정한 후 소켓을 통해 서버에 전송합니다.
socket.on("offer", ...): 서버로부터 "offer" 이벤트를 수신하면 해당 오퍼를 받아와서 원격 디스크립션으로 설정한 후, myPeerConnection.createAnswer()를 호출하여 앤서(answer)를 생성하고, 앤서를 로컬 디스크립션으로 설정한 후 소켓을 통해 서버에 전송합니다.
socket.on("answer", ...): 서버로부터 "answer" 이벤트를 수신하면 해당 앤서를 받아와서 원격 디스크립션으로 설정합니다.
socket.on("ice", ...): 서버로부터 "ice" 이벤트를 수신하면 해당 ICE(Interactive Connectivity Establishment) 정보를 받아서 피어 연결에 추가합니다.
makeConnection(): RTCPeerConnection을 생성하고, ICE 서버 정보를 설정하며, icecandidate 이벤트와 addstream 이벤트를 등록합니다. 또한 사용자의 미디어 트랙을 피어 연결에 추가합니다.
handleIce(data): ICE 후보(candidate) 정보를 소켓을 통해 서버에 전송합니다.
handleAddStream(data): 피어로부터 스트림이 추가되었을 때 실행되며, 피어의 비디오를 video 요소에 표시합니다.