[졸업프로젝트] webRTC의 기본적인 기능 구현

미루미·2022년 11월 23일
0

졸업프로젝트

목록 보기
1/2
post-thumbnail

프로젝트 주제

졸업 프로젝트로 스터디 플랫폼을 만들려고 한다.

우리 팀의 프로젝트는 화상회의를 포함하여 스터디 진행, 관리에 필요한 기능들을 제공하는 스터디 플랫폼을 서비스하는 것이다. 스터디뿐만 아니라 가벼운 취미 모임 등 여러 사람이 모여 공동의 목표를 가지고 학습 내용을 공유하는 형태의 모임에는 대학생이 된 이후 꾸준히 참여해오고 있다. 동아리 사람들과 프로그래밍 언어를 공부하기 위해 모인 적도, 친한 친구들과 각자 관심있는 외국어의 단어 암기를 위해 모인 적도 있다.

그러나 매번 카톡에서 날짜, 시간을 정하는 채팅을 주고 받고, 정리를 위해 노션에서 스터디 자료를 사진이나 문서 파일을 공유하고, 회의 자체는 줌이나 구글 미트를 이용했다. 이처럼 하나의 스터디 진행을 위해 분산된 플랫폼의 기능을 사용해야 하는 점, 또 그 기능들이 '스터디 관리'에 정확히 들어맞지 않는다는 점에서 불편을 느껴 이런 프로젝트를 기획하게 되었다.

그중에서도 이 포스팅은 화상회의의 아주 기본적인 구현과 관련된 내용이다. WebRtc 개념 자체가 낯설어서 포스팅의 초반부에 이해에 필요한 개념들을 요약하였고 후반부에 구현 과정을 코드와 함께 적어보았다. 가장 아래에는 채팅에 대한 것도 아주 조금 덧붙였다.

참고한 강의는 노마드코더의 줌 클론코딩 강의이다. 강의는 6시간 정도의 분량으로, JavaScript와 Express의 기초 지식이 있다면 강의를 이해할 수 있다.

자바스크립트의 경우는 노마드코더에 바닐라 JS로 크롬 앱 만들기 강의가 제공되고 있다. Node.js와 Express의 경우는 생활코딩의 강의를 들었다.

WebRTC API

일단은 카메라와 마이크로 비디오와 오디오 데이터를 받아오는 기능까지 구현해보았다. 이후 코드에서 navigator.mediaDevices.getUserMedia로 유저미디어를 가져올 것이다.

WebSocket vs Socket IO

socket은 양방향 소통을 가능하게 한다. 백엔드에서 프론트엔드로, 프론트엔드에서 백엔드로 메시지를 보낼 수 있다.

우선 WebSocket은 브라우저와 서버 사이의 양방향 소통을 위한 프로토콜이다. WebSocket은 표준 기술이다. 그러나 Socket IO는 표준 기술은 아니지만 많이 사용되고 있는 Node.js의 모듈이다. WebSocket을 기반으로 하고 있기에 기본적으로는 소켓을 이용해 브라우저와 서버 사이의 양방향 통신을 지원한다. 추가적으로 개발자에게 편리한 여러 기능을 제공하기도 하는 라이브러리인데, 속도는 WebSocket에 비해 조금 느리다.

그러나 WebSocket과는 달리 연결이 끊겼을 때 대안을 제시한다는 점을 들어 이번 토이 프로젝트에서 Socket IO를 사용해보았다. 연결에 실패한다고 해도 fallback을 통해 자동적으로 재연결을 시도한다.

화질이 떨어지지만 잘 보면 커맨드창에서 서버를 종료한 직후부터 콘솔에 에러가 뜬다. connection_refused라고 되어 있는데 계속해서 재연결을 시도하고 있는 것이다. 그리고 다시 서버에 연결하면 더 이상 에러가 뜨지 않는다.

아래의 명령어를 커맨드창에 입력하여 모듈을 다운로드할 수 있다.

npm install socket.io

Socket.io의 공식 문서가 도움이 되었다.

P2P

webRTC는 사용자 간의 peer-to-peer 연결을 전제로 한다. peer-to-peer란 사용자들이 데이터를 주고 받을 때 서버를 통하지 않는 것이다. 물론 서로의 주소를 알기 위해서는 서버가 필요하다. 이 서버를 Signaling Server시그널링 서버라고 한다. 시그널링 서버는 서로의 위치를 알아낸 다음 p2p connection을 할 수 있도록 한다. 그러니까 상대에게 보내기 위한 내 비디오를 업로드하고, 또 상대의 비디오를 보기 위해 다운로드할 때는 그 connection에 의하는 것이다.

그러나 시그널링 서버는 mesh 형태이다.

그림을 보아도 충분히 알 수 있을 것이다. mesh에서는 모두가 모두와 연결되어야 하기 때문에 사용자가 조금만 늘어도 속도가 크게 느려진다. 나는 나와 연결된 모든 상대들에게 데이터를 송신해야 하고, 모두로부터 수신해야 한다.

팀에서 필요한 화상회의는 일대일이 아닌 다대다를 대상으로 하기 때문에 고려 중인 서버는 SFU이다. 비록 중앙에 서버를 두고 있고 송수신 데이터가 이 서버를 거쳐야 하므로 서버가 하는 일은 늘어나지만, 클라이언트의 부하가 mesh에 비해 줄어든다. 또 connection이 N:N이긴 해도 일반적인 스터디 규모가 아주 크지는 않기 때문에 적합한 형태라고 생각한다.

STUN

화상회의는 컴퓨터와 모바일 모두로 접속할 수 있다. 그러나 만약 컴퓨터와 모바일이 같은 Wifi를 사용하지 않는다면, 상대방의 주소를 찾지 못해서 연결되지 않는다. 그래서 STUN 서버가 필요하다.

STUN 서버는 서로 다른 네트워크에 있는 장치들의 주소를 찾아줄 수 있다.

구현

첫 화면이다. 여기서 방 번호를 입력한다. (이후 이 부분에는 공유 가능한 형태의 초대 링크를 생성해주는 것이 추가되어야 한다)

대부분의 브라우저에서 socket.io를 지원하고 있다. 나는 크롬과 사파리, 이렇게 두 창을 띄우고 같은 방에 들어갔다.

그럼 내 얼굴이 각각의 브라우저에 모두 뜨는 걸 볼 수 있다. 오른쪽 사진의 사파리에서도 스크롤을 내리면 비디오를 하나 더 볼 수 있다. 게다가 사파리 사용자의 카메라를 끄면 크롬 사용자에서도 상대의 비디오가 꺼지고, 그 반대도 마찬가지이다.

프론트엔드는 app.js에 백엔드는 server.js로 분리했다.

Socket code - server.js

모듈을 import하고 웹소켓 서버라는 의미에서 wsServer를 정의한 부분이다. 여기에 본인이 사용하는 뷰와 관련된 코드나 리다이렉션 시켜주는 코드를 추가할 수 있다.

import http from "http";
import SocketIO from "socket.io";
import express from "express";

const app = express();
const httpServer = http.createServer(app);
const wsServer = SocketIO(httpServer);

서버가 브라우저와의 connection이 생겼을 때 소켓에서 이벤트를 listen하는 부분이다. 'join_room'이라는 이벤트를 listen하면 welcome 이벤트를 실행한다. 'offer'이라는 이벤트를 listen하면 offer 이벤트를 실행한다. 그 아래의 'answer'과 'ice'도 마찬가지이다.

나중에 브라우저에서는 'welcome', 'offer'과 같은 각각의 이벤트들을 정의하고 그 이벤트를 addEventListener()로 소켓에 등록해줄 것이다.

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);
  });
});

여기서 서버와 브라우저의 소통이 양방향임을 알 수 있다. 내가 커맨드창에서 shift+c를 눌러서 서버를 종료시키면 브라우저에서는 연결이 끊겼을 때의 이벤트가 발생한다.(콘솔창에 에러가 뜬다든가) 반대로 브라우저 창을 끄면 서버에서도 연결이 끊겼을 때의 이벤트가 발생한다.(커맨드창에 메시지가 뜬다든가)

Socket code - app.js

소켓을 주고 받는 과정이 조금 복잡해서 이해하기 어려울 수 있다. 일단은 단순하게 주는 역할, 받는 역할로 나누면 쉽다.

먼저 연결한 쪽을 peer A, 나중에 연결된 쪽을 peer B라고 하자. 내가 크롬, 사파리 순서로 room에 접속한다면 크롬이 peer A, 사파리가 peer B이다.

이 둘 사이에 해야할 일을 순서대로 적어보았다.

  1. getUserMedia(): 컴퓨터에 등록된 장치인 카메라, 마이크 등을 가져와서 영상을 띄우는 것이다.
  2. addStream(): 크롬과 사파리 각각에서 따로! 통로를 만든 다음 그 통로를 연결시켜준다. 이때 Socket IO가 사용된다. 그리고 이 통로 안에 오디오와 비디오 데이터를 넣어서 주고 받는다.
  3. createOffer(): (먼저 연결된) peer A에서 Offer을 만든다.
  4. setLocalDescription(): 만든 Offer를 담아서 연결을 구성한다.
  1. peer A가 Offer을 보낸다.
  1. setRemoteDescription(): peer B가 Offer를 받는다. (멀리 떨어진 peer A가 보낸 offer이니까 remote)
  2. createAnswer(): (나중에 연결된) peer B에서 Answer을 만든다.
  3. setLocalDescription(): 만든 Answer을 담아서 연결을 구성한다.
  1. peer B가 Answer을 보낸다.
  1. setRemoteDescription(): peer A가 Answer을 받는다. (멀리 떨어진 peer B가 보낸 Answer니까 remote)

peer A, B가 서로 주고 받는 것을 끝냈다면 이제 둘 다 IceCandidate라는 이벤트를 실행한다. 두 브라우저의 소통을 가능하게 하기 위해서 다수의 후보를 제안하고 하나를 선택해서 그걸 소통 방식으로 사용하는 이벤트이다.

ICE란 Interactive Connectivity Establishment의 약자로 브라우저가 peer를 통한 연결이 가능하도록 하게 하는 프레임워크이다.

위의 내용을 바탕으로 아래와 같이 코드를 짜면 된다.

  • the peer connected first
    먼저 연결된 브라우저에서 실행되는 코드이다.
socket.on("welcome", async () => { 
  myDataChannel = myPeerConnection.createDataChannel("chat");
  myDataChannel.addEventListener("message", (event) => console.log(event.data));
  console.log("made data channel");
  const offer = await myPeerConnection.createOffer();
  myPeerConnection.setLocalDescription(offer);
  console.log("sent the offer");
  socket.emit("offer", offer, roomName);
});
  • the peer connected after
    나중에 연결된 브라우저에서 실행되는 코드이다.
socket.on("offer", async (offer) => { 
    myPeerConnection.addEventListener("datachannel", (event) => {
    myDataChannel = event.channel;
    myDataChannel.addEventListener("message", (event) =>
      console.log(event.data)
      );
    });
    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");
});

이벤트 정의 - app.js

우선 소켓을 가져온다.

const socket = io();
  • getCameras
    비디오를 보여야 하니까 사용자의 컴퓨터에 등록된 카메라를 모두 가져온다.
    그리고 카메라를 변경할 수 있도록 deviceId를 사용하여 option을 만든다.
    만약 카메라 옵션이 현재 선택된 카메라와 같은 label이라면, 현재 사용 중인 카메라라는 것을 알 수 있다는 의미의 조건문도 넣어준다.
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);
  }
}
  • getMedia

getUserMedia로 사용자의 userMedia를 가져와서 스트림을 만든다.

initialContraints는 카메라를 만들기 전, deviceId가 없을 때 실행된다.
cameraConstraints는 deviceId가 있을 때 실행된다.
이때 후면 카메라부터 먼저 사용하려면 { facingMode: "environment" }로 바꿔준다.

async function getMedia(deviceId) {
  const initialConstraints = {
    audio: true,
    video: { facingMode: "user" },
  };
  const cameraConstraints = {
    audio: true,
    video: { deviceId: { exact: deviceId } },
  };
  try {
    myStream = await navigator.mediaDevices.getUserMedia(
      deviceId ? cameraConstraints : initialConstraints
    );
    myFace.srcObject = myStream;
    if (!deviceId) {
      await getCameras();
    }
  } catch (e) {
    console.log(e);
  }
}

버튼 클릭에 대한 이벤트들을 함수로 정의한 부분이다.

  • 음소거 버튼을 눌렀을 때 음소거가 되어야 하는데, 이를 위해서는 내 컴퓨터에 연결된 디바이스들의 상태를 트랙, 추적하고 있어야 한다. 그 흐름을 보고 있다가, 만약 언뮤트 상태에서 뮤트 버튼을 누르면 스피커 상태를 뮤트로 바꾼 다음 버튼 글자도 뮤트로 바꿔준다.
  • 카메라 버튼도 마찬가지이다. 만약 카메라가 켜진 상태에서 카메라 끄기 버튼을 누르면 카메라 상태를 off로 바꾼 다음 버튼 글자도 off로 바꿔준다.
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;
  }
}
  • handleCameraChange
    여기까지의 코드만 실행해도 음소거나 카메라 끄기는 문제 없이 작동한다. 그러나 문제는 디바이스를 다른 것으로 바꾸는 경우이다. 위에서 myStream으로 카메라의 stream을 추적해주던 게, 카메라를 바꾸면 디바이스에 따른 stream도 바뀌기 때문에 추적을 할 수 없게 된다.
    그래서 카메라를 바꿀 때마다 새로운 stream을 만들어주어 이 문제를 해결하는 부분이다. 먼저 비디오를 보내는 Sender를 찾는다. 그리고 Sender를 새롭게 생성한 Track으로 바꿔준다.

Sender는 Media Stream Track을 컨트롤할 수 있게 한다. 카메라 끄거나 켜거나 하는 것이다.

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);
    }
}

Welcome Form - app.js

Socket IO를 사용하기 때문에 emit으로 자료형에 구애받지 않고 무엇이든 쉽게 전송할 수 있다. 이때 emit하려는 이벤트 이름은 socket.on에서 쓴 것과 똑같아야 한다.

또한 Socket IO에서는, 어떤 작업을 마친 다음 실행되길 원하는 함수가 있다면 그 작업의 마지막 argument에 함수를 넣어주면 된다. 마지막 argument의 함수는 그 작업을 마친 다음 실행된다.

여기 welcome form은 구현한 사진에서 '첫 화면'에 해당한다. 사용자가 방 번호를 입력할 수 있어야 하고 버튼을 눌러 방에 들어갈 수 있어야 한다. 이때 enter room 버튼에 따른 이벤트를 클라이언트에서 emit해주고 있다. 방 이름은 사용자가 입력한 방 번호가 된다.

  • initCall
    처음에는 방 번호 입력창만 보여야 하니까 welcome은 보이도록, call은 보이지 않도록 한다. 그리고 getMedia도 실행시켜서 각종 장치들을 불러오게 한다.
const welcome = document.getElementById("welcome");
const welcomeForm = welcome.querySelector("form");

async function initCall() {
  welcome.hidden = true;
  call.hidden = false;
  await getMedia();
  makeConnection();
}
  • handleWelcomeSubmit
    그리고 서버에 사용자가 입력한 방 번호를 보내주면서 'join_room' 이벤트를 요청한다. 그러면 아까의 server.js, 즉 서버에서 'join_room' 이벤트를 listen할 때의 코드, 그러니까 소켓이 그 room에 참여한다는 코드가 실행될 것이다!
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);

RTC code - app.js

앞서 설명한 순서 중 두 브라우저를 연결해주는 부분이다. 각각의 브라우저에 myPeerConnection이라는 통로를 만들고, 오디오와 비디오 데이터(Media Stream Track)를 이 myPeerConnection 안에 넣어서 보내준다.

function makeConnection() {
  myPeerConnection = new RTCPeerConnection();
  myPeerConnection.addEventListener("icecandidate", handleIce);
  myPeerConnection.addEventListener("addstream", handleAddStream);
  myStream
    .getTracks()
    .forEach((track) => myPeerConnection.addTrack(track, myStream));
}

Ice candidate 이벤트로 candidate을 주고 받는 부분이다.
candidate을 받으면 candidate과 roomName을 보낸다.

function handleIce(data){
    console.log("sent candidate");
    socket.emit("ice", data.candidate, roomName);
}

peer connection의 마지막 단계로, peer의 stream으로부터 peer의 데이터를 받는 부분이다.
Peer 간의 데이터 교환인 것이다. data.stream을 콘솔에 출력해보면 서로의 MediaStream의 id를 확인할 수 있다. peer A의 상대방이 peer B라는 것과 peer B의 상대방이 peer A임을 볼 수 있다. 이걸 사용해서 상대방의 비디오까지도 추가해줄 수 있다.

function handleAddStream(data){
    const peersFace = document.getElementById("peersFace");
    peersFace.srcObject = data.stream;
}

Chat

이러한 내용을 바탕으로 비디오 이전에 텍스트를 주고 받는 채팅을 구현해 본 것이다.

data channel

data channel은 RTCPeerConnection을 통해 임의의 형태의 데이터를 주고 받는 것을 지원한다. 텍스트, 비디오뿐만 아니라 다양한 형태의 데이터를 전송할 수 있다.

플랫폼에서 기본적으로는 클라우드에 업로드를 하는 방식으로 스터디에 필요한 자료를 공유하겠지만, 경우에 따라서는 화상회의 도중 채팅을 통해 여러 형태의 파일을 전송해야 할 수도 있다. 채팅으로 텍스트가 아닌 형태의 데이터도 전송하기 위해서 data channel을 사용할 수 있다.

data channel의 사용을 위해 이 페이지를 참조할 수 있다.

profile
미루미루지마

1개의 댓글

comment-user-thumbnail
2022년 12월 22일

잘 읽었어요~

답글 달기