Study/WEBRTC

WebRTC 및 NodeJS로 화상회의를 구현하는 방법

AC 2022. 2. 10. 01:39

WebRTC 소개

WebRTC(Web Real Time Communication)는 브라우저 간 P2P 통신을 가능하게 하는 오픈 소스 프로젝트입니다. 즉, WebRTC를 사용하면 필요한 플러그인이나 프레임워크 없이 웹을 통해 모든 종류의 미디어(예: 비디오, 오디오 및 데이터)를 교환할 수 있습니다.

브라우저 간의 직접 통신은 클라이언트가 서버를 통해 메시지를 계속 보내고 받을 필요가 없기 때문에 성능을 향상시키고 대기 시간을 줄입니다. 예를 들어 WebSocket 을 사용하여 두 클라이언트를 연결할 수 있지만 서버는 다음 다이어그램과 같이 해당 메시지를 라우팅해야 합니다.

대조적으로 WebRTC는 클라이언트의 연결을 설정하고 제어하기 위해 서버만 있으면 됩니다. 이 프로세스를 시그널링 이라고 합니다. 브라우저가 피어의 필수 정보를 수집하면 서로 통신할 수 있습니다.

WebRTC는 신호 메시징 프로토콜을 지정하지 않으므로 선택한 구현은 애플리케이션 요구 사항을 기반으로 해야 하며 가장 일반적인 접근 방식은 WebSocket, SIP, XHR, XMPP 등입니다.

신호 프로세스는 다음과 같이 작동합니다.

  • 클라이언트가 통화를 시작합니다.
  • 호출자는 SDP( Session Description Protocol ) 를 사용하여 제안 을 생성 하고 다른 피어(피호출자)에게 보냅니다.
  • 수신자 도 SDP 설명이 포함된 응답 메시지 로 제안에 응답합니다.
  • 두 피어 모두 브라우저 코덱 및 메타데이터와 같은 정보를 포함하는 로컬 및 원격 세션 설명을 설정하고 나면 호출에 사용되는 미디어 기능을 알게 됩니다. 그러나 SDP는 외부 NAT (Network Address Translators), IP 주소 및 포트 제한 처리 방법을 인식하지 못하기 때문에 아직 미디어 데이터를 연결하고 교환할 수 없습니다 . 이것은 ICE( 대화형 연결 설정 ) 에 의해 달성됩니다 .

그렇다면 대화형 연결 설정은 어떻게 작동합니까? ICE는 인터넷을 통해 미디어 정보를 주고받는 P2P 네트워크 통신 방식입니다. 라우팅 방식(NAT, 방화벽 ...)은 이 기사의 범위를 벗어나지만 WebRTC가 처리해야 하는 것입니다. ICE는 ICE 후보 로 알려진 사용 가능한 네트워크 연결을 수집 하고 NAT 및 방화벽 통과를 위해 STUN (Session Traversal Utilities for NAT) 및 TURN (Traversal Using Relays around NAT) 프로토콜을 사용합니다 .

ICE가 연결을 처리하는 방법은 다음과 같습니다.

  • 먼저 UDP를 통해 직접 피어 연결을 시도합니다.
  • UDP가 실패하면 TCP를 시도합니다.
  • NAT와 방화벽으로 인해 실제 시나리오에서 자주 발생하는 UDP 및 TCP 직접 연결이 모두 실패하면 ICE는 먼저 UDP와 함께 STUN 서버를 사용하여 피어를 연결합니다. STUN 서버는 STUN 프로토콜을 구현하는 서버로 비대칭 NAT 뒤에 있는 피어의 공용 주소와 포트를 찾는 데 사용됩니다.
  • STUN 서버가 실패하면 ICE는 대칭 NAT를 통과할 수 있는 몇 가지 추가 중계 기능이 있는 STUN 서버인 TURN 서버를 사용합니다.

보시다시피 ICE는 먼저 STUN 서버를 사용하려고 시도하지만 매우 제한적인 기업 네트워크에는 TURN 릴레이 서버가 필요합니다. TURN 릴레이 서버는 비싸고 자체 비용을 지불하거나 서비스 제공자를 사용해야 하지만 대부분의 경우 ICE는 피어를 STUN과 연결할 수 있습니다. 다음 스키마는 STUN/TURN 통신을 보여줍니다.

요약하자면 시그널링 프로세스는 미디어 정보를 SDP 파일과 교환하는 데 사용되며 ICE는 피어의 네트워크 연결을 교환하는 데 사용됩니다. 통신이 설정된 후 피어는 마침내 브라우저를 통해 직접 데이터를 교환할 수 있습니다.

보안과 관련하여 WebRTC에는 다양한 위험에 대처하기 위한 몇 가지 필수 기능이 포함되어 있습니다.

  • 미디어 스트림은 SRTP (Secure Real-time Transport Protocol )를 사용하여 암호화되고 데이터 스트림은 DTLS (Datagram Transport Layer Security )를 사용하여 암호화됩니다.
  • 카메라 및 마이크에 대한 액세스 권한은 클라이언트가 부여해야 합니다. 클라이언트가 계속 인식할 수 있도록 브라우저는 장치의 카메라 또는 마이크가 활성화된 경우 아이콘을 표시합니다.
  • 모든 WebRTC 구성 요소는 브라우저 샌드박스 에서 실행되고 암호화를 사용합니다. 어떤 종류의 설치도 필요하지 않으며 브라우저가 지원하는 한 작동합니다.

WebRTC API

WebRTC는 세 가지 주요 JavaScript API에 의존합니다.

  • MediaStream ( getUserMedia라고도 함 ): 이 인터페이스는 오디오 및 비디오 트랙을 포함할 수 있는 장치의 미디어 스트림을 나타냅니다. MediaDevies.getUserMedia () 메서드는 MediaStream을 검색합니다(예: 휴대폰 카메라에 액세스하는 데 사용할 수 있음).
  • RTCPeerConnection : 피어 간의 통신을 허용합니다. MediaDevices.getUserMedia() 에 의해 액세스되는 스트림이 이 구성 요소에 추가되며 피어와 ICE 후보 간에 교환되는 SDP 제안 및 응답 메시지도 처리합니다.
  • RTCDataChannel : 임의의 데이터를 실시간으로 통신할 수 있습니다. 데이터를 직접 교환하기 위해 브라우저를 연결하지만 종종 WebSocket과 비교됩니다. 이전에 설명했듯이 브라우저 간의 직접 통신은 성능을 향상시켜 이 API를 게임이나 암호화된 파일 공유와 같은 흥미로운 응용 프로그램에 사용할 수 있습니다.

다음 WebRTC 구현 예에서는 getUserMedia  RTCPeerConnection 만 필요합니다.

Node로 화상회의 구현

이 섹션에서는 사용자가 선택할 수 있는 여러 방이 있는 화상 채팅 애플리케이션을 구현합니다. 두 명의 클라이언트가 같은 방에 연결되면 화상 회의를 시작합니다. 이것은 단순한 목적이 WebRTC의 작동 방식을 보여주는 것이므로 많은 개선 및 기능의 여지가 있다는 점에 유의하십시오.

1. 애플리케이션 구조 생성

먼저 프로젝트의 폴더를 만들고 시작합니다.

mkdir webrtc-node-app && cd webrtc-node-app
npm 초기화

우리 응용 프로그램의 구조는 다음과 같습니다.

server.js
public/
|_index.html
|_client.js

2. 서버 구현

클라이언트와 서버 간의 실시간 통신을 위해 Express 를 Node 프레임워크로 사용하고 SocketIO 를 JavaScript 라이브러리로 사용합니다. SocketIO는 WebSocket과 함께 작동하는 라이브러리이지만 브로드캐스팅과 같은 몇 가지 추가 기능을 지원합니다.

다음을 사용하여 이러한 종속성을 설치해 보겠습니다.

npm은 익스프레스 socket.io를 설치합니다.

server.js 파일 은 포트 3000에서 애플리케이션을 실행하고 신호에 사용될 WebSockets 메시지를 처리합니다(앞서 논의한 바와 같이 피어가 미디어 정보를 교환하는 방법).

server.js 파일 에서 클래식 Express 서버를 만들고 미들웨어를 추가하여 공용 폴더에 액세스합니다.

const express = require('express')
const app = express()
const server = require('http').createServer(app)

app.use('/', express.static('public'))

// START THE SERVER ==========================================================
const port = process.env.PORT || 3000
server.listen(port, () => {
  console.log(`Express server listening on port ${port}`)
})
복사

그런 다음 SocketIO 라이브러리를 가져오고 클라이언트에서 내보내는 메시지를 처리합니다.

const express = require('express')
const app = express()
const server = require('http').createServer(app)
const io = require('socket.io')(server)

app.use('/', express.static('public'))

io.on('connection', (socket) => {
  socket.on('join', (roomId) => {
    const roomClients = io.sockets.adapter.rooms[roomId] || { length: 0 }
    const numberOfClients = roomClients.length

    // These events are emitted only to the sender socket.
    if (numberOfClients == 0) {
      console.log(`Creating room ${roomId} and emitting room_created socket event`)
      socket.join(roomId)
      socket.emit('room_created', roomId)
    } else if (numberOfClients == 1) {
      console.log(`Joining room ${roomId} and emitting room_joined socket event`)
      socket.join(roomId)
      socket.emit('room_joined', roomId)
    } else {
      console.log(`Can't join room ${roomId}, emitting full_room socket event`)
      socket.emit('full_room', roomId)
    }
  })

  // These events are emitted to all the sockets connected to the same room except the sender.
  socket.on('start_call', (roomId) => {
    console.log(`Broadcasting start_call event to peers in room ${roomId}`)
    socket.broadcast.to(roomId).emit('start_call')
  })
  socket.on('webrtc_offer', (event) => {
    console.log(`Broadcasting webrtc_offer event to peers in room ${event.roomId}`)
    socket.broadcast.to(event.roomId).emit('webrtc_offer', event.sdp)
  })
  socket.on('webrtc_answer', (event) => {
    console.log(`Broadcasting webrtc_answer event to peers in room ${event.roomId}`)
    socket.broadcast.to(event.roomId).emit('webrtc_answer', event.sdp)
  })
  socket.on('webrtc_ice_candidate', (event) => {
    console.log(`Broadcasting webrtc_ice_candidate event to peers in room ${event.roomId}`)
    socket.broadcast.to(event.roomId).emit('webrtc_ice_candidate', event)
  })
})

// START THE SERVER =================================================================
const port = process.env.PORT || 3000
server.listen(port, () => {
  console.log(`Express server listening on port ${port}`)
})
복사

3. 클라이언트 보기 생성

public/index.html 파일 내에서 앱 보기를 만들 것 입니다. 예를 들어, 간단한 것이 작동할 것입니다. 두 개의 섹션 컨테이너를 사용할 것입니다. 하나는 회의실 선택용이고 다른 하나는 화상 회의용입니다. CSS를 사용하여 이러한 보기에 스타일을 추가하고 여기에서도 SocketIO 라이브러리를 가져오고 있습니다.

<!DOCTYPE html>
<html lang=”en”>
  <head>
    <meta charset=”UTF-8” />
    <meta name=”viewport” content=”width=device-width, initial-scale=1.0” />
    <title>WebRTC</title>

    <style type=”text/css”>
      body {
        margin: 0;
        font-size: 20px;
      }

      .centered {
        position: absolute;
        top: 40%;
        left: 50%;
        transform: translate(-50%, -50%);
      }

      .video-position {
        position: absolute;
        top: 35%;
        left: 50%;
        transform: translate(-50%, -50%);
      }

      #video-chat-container {
        width: 100%;
        background-color: black;
      }

      #local-video {
        position: absolute;
        height: 30%;
        width: 30%;
        bottom: 0px;
        left: 0px;
      }

      #remote-video {
        height: 100%;
        width: 100%;
      }
    </style>
  </head>

  <body>
    <div id=”room-selection-container” class=”centered”>
      <h1>WebRTC video conference</h1>
      <label>Enter the number of the room you want to connect</label>
      <input id=”room-input” type=”text” />
      <button id=”connect-button”>CONNECT</button>
    </div>

    <div id=”video-chat-container” class=”video-position” style=”display: none”>
      <video id=”local-video” autoplay=”autoplay”></video>
      <video id=”remote-video” autoplay=”autoplay”></video>
    </div>

    <script src=”/socket.io/socket.io.js”></script>
    <script type=”text/javascript” src=”client.js”></script>
  </body>
</html>
복사

이제 콘솔에서 서버를 실행하고 브라우저에서 올바르게 실행되고 있고 클라이언트 보기가 표시되는지 확인할 시간입니다.

노드 서버.js

로컬 호스트:3000

4. 클라이언트 통신 구현

애플리케이션이 public/client.js 파일에 작동하는 데 필요한 기능을 추가할 것 입니다.

첫째, 이것은 클라이언트가 방에 참여하는 방법입니다(아무도 방에 참여하지 않은 경우 생성):

// DOM elements.
const roomSelectionContainer = document.getElementById('room-selection-container')
const roomInput = document.getElementById('room-input')
const connectButton = document.getElementById('connect-button')

const videoChatContainer = document.getElementById('video-chat-container')
const localVideoComponent = document.getElementById('local-video')
const remoteVideoComponent = document.getElementById('remote-video')

// Variables.
const socket = io()
const mediaConstraints = {
  audio: true,
  video: { width: 1280, height: 720 },
}
let localStream
let remoteStream
let isRoomCreator
let rtcPeerConnection // Connection between the local device and the remote peer.
let roomId

// Free public STUN servers provided by Google.
const iceServers = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    { urls: 'stun:stun1.l.google.com:19302' },
    { urls: 'stun:stun2.l.google.com:19302' },
    { urls: 'stun:stun3.l.google.com:19302' },
    { urls: 'stun:stun4.l.google.com:19302' },
  ],
}

// BUTTON LISTENER ============================================================
connectButton.addEventListener('click', () => {
  joinRoom(roomInput.value)
})

// SOCKET EVENT CALLBACKS =====================================================
socket.on('room_created', async () => {
  console.log('Socket event callback: room_created')

  await setLocalStream(mediaConstraints)
  isRoomCreator = true
})

socket.on('room_joined', async () => {
  console.log('Socket event callback: room_joined')

  await setLocalStream(mediaConstraints)
  socket.emit('start_call', roomId)
})

socket.on('full_room', () => {
  console.log('Socket event callback: full_room')

  alert('The room is full, please try another one')
})

// FUNCTIONS ==================================================================
function joinRoom(room) {
  if (room === '') {
    alert('Please type a room ID')
  } else {
    roomId = room
    socket.emit('join', room)
    showVideoConference()
  }
}

function showVideoConference() {
  roomSelectionContainer.style = 'display: none'
  videoChatContainer.style = 'display: block'
}

async function setLocalStream(mediaConstraints) {
  let stream
  try {
    stream = await navigator.mediaDevices.getUserMedia(mediaConstraints)
  } catch (error) {
    console.error('Could not get user media', error)
  }

  localStream = stream
  localVideoComponent.srcObject = stream
}
복사

보시다시피 클라이언트의 미디어 데이터를 가져오기 위해 navigator.mediaDevices.getUserMedia 메서드를 호출합니다. 클라이언트가 다른 클라이언트가 이미 만든 방에 참여하면 피어 간의 미디어 교환이 시작되고 다음 소켓 이벤트 콜백 및 함수에 의해 관리됩니다.

// SOCKET EVENT CALLBACKS =====================================================
socket.on('start_call', async () => {
  console.log('Socket event callback: start_call')

  if (isRoomCreator) {
    rtcPeerConnection = new RTCPeerConnection(iceServers)
    addLocalTracks(rtcPeerConnection)
    rtcPeerConnection.ontrack = setRemoteStream
    rtcPeerConnection.onicecandidate = sendIceCandidate
    await createOffer(rtcPeerConnection)
  }
})

socket.on('webrtc_offer', async (event) => {
  console.log('Socket event callback: webrtc_offer')

  if (!isRoomCreator) {
    rtcPeerConnection = new RTCPeerConnection(iceServers)
    addLocalTracks(rtcPeerConnection)
    rtcPeerConnection.ontrack = setRemoteStream
    rtcPeerConnection.onicecandidate = sendIceCandidate
    rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(event))
    await createAnswer(rtcPeerConnection)
  }
})

socket.on('webrtc_answer', (event) => {
  console.log('Socket event callback: webrtc_answer')

  rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(event))
})

socket.on('webrtc_ice_candidate', (event) => {
  console.log('Socket event callback: webrtc_ice_candidate')

  // ICE candidate configuration.
  var candidate = new RTCIceCandidate({
    sdpMLineIndex: event.label,
    candidate: event.candidate,
  })
  rtcPeerConnection.addIceCandidate(candidate)
})

// FUNCTIONS ==================================================================
function addLocalTracks(rtcPeerConnection) {
  localStream.getTracks().forEach((track) => {
    rtcPeerConnection.addTrack(track, localStream)
  })
}

async function createOffer(rtcPeerConnection) {
  let sessionDescription
  try {
    sessionDescription = await rtcPeerConnection.createOffer()
    rtcPeerConnection.setLocalDescription(sessionDescription)
  } catch (error) {
    console.error(error)
  }

  socket.emit('webrtc_offer', {
    type: 'webrtc_offer',
    sdp: sessionDescription,
    roomId,
  })
}

async function createAnswer(rtcPeerConnection) {
  let sessionDescription
  try {
    sessionDescription = await rtcPeerConnection.createAnswer()
    rtcPeerConnection.setLocalDescription(sessionDescription)
  } catch (error) {
    console.error(error)
  }

  socket.emit('webrtc_answer', {
    type: 'webrtc_answer',
    sdp: sessionDescription,
    roomId,
  })
}

function setRemoteStream(event) {
  remoteVideoComponent.srcObject = event.streams[0]
  remoteStream = event.stream
}

function sendIceCandidate(event) {
  if (event.candidate) {
    socket.emit('webrtc_ice_candidate', {
      roomId,
      label: event.candidate.sdpMLineIndex,
      candidate: event.candidate.candidate,
    })
  }
}
복사

이제 두 클라이언트가 모두 연결되어야 합니다. WebRTC로 서로를 듣고 볼 수 있으며 서버는 데이터를 처리하지 않습니다. 소켓을 사용하여 호출을 끝내고 연결을 제어할 수 있지만 신호용으로만 사용되었습니다. 또는 기타 목적.

다음은 애플리케이션을 실행하기 전에 몇 가지 조언입니다.

  • ngrok (또는 기타 터널링 서비스)를 사용 하여 로컬 서버를 공개 URL에 노출하고 두 개의 다른 장치로 애플리케이션을 테스트하십시오. 그러나 약간의 통신 지연이 발생할 수 있음을 고려하십시오.
  • 헤드폰을 사용하십시오. 오디오 피드백은 청력과 장치를 손상시킬 수 있습니다.
  • WebRTC가 브라우저에서 지원되는지 확인하고 HTTPS를 사용하는지 확인하십시오. 그렇지 않으면 getUserMedia 가 작동하지 않습니다.
LIST

'Study > WEBRTC' 카테고리의 다른 글

Public Stun Server List  (0) 2022.06.24
Stun Servers and Friends  (0) 2022.06.24
Public Stun Turn Server  (0) 2022.06.24
WebRTC 배우기 리소스  (0) 2022.02.10
coturn - WebRTC 외부에서 사용 해보기  (0) 2022.02.02