본문 바로가기
JavaScript

[javascript] Webrtc 1:1 영상통화 구현하기

by teamnova 2023. 2. 23.
728x90

안녕하세요. 이번 시간에는 webrtc 1:1 영상통화를 구현해보겠습니다.

시그널링 서버 코드를 돌리기 위한 node.js 세팅이 완료되었다는 가정을 하고,

영상통화 구현을 위한 코드에만 초점을 맞춰서 설명하겠습니다.

 

+ 참고 socket.io 와 express 버전

        "express": "4.17.1",
        "socket.io": "2.3.0"

 

server.js

//express는 다음과 같은 것을 제공한다.
// 1. http  통신 요청 (Request; GET, POST, DELETE 등)에 대한 핸들러를 만든다.
// -> 핸들러는 특정 데이터에 특화되어 있고, 특정 작업에 중점을 둔 함수, 방식
// 2. 템플릿에 데이터를 넣어서 응답을 생성하기 위해 "view" 렌더링 엔진과 통합을 한다
// -> 템플릿 : 무언가의 기본 형식을 가진 컴퓨터 문서
// -> 렌더링 엔진 : 화면에 텍스트와 이미지를 그리는 스프트웨어
// 3. 접속을 위한 포트나 응답 렌더링을 위한 템플릿 위치같은 공통 웹 어플리케이션 세팅을 한다.
// 4. 핸들링 파이프 라인 중 필요한 곳에 추가적인 미들웨어 처리 요청을 추가한다.
// -> 미들웨어란? 클라이언트에게 요청이 오면 요청을 보내기 위해 중간에서 목적에 맞게 거쳐가는 함수들 미들웨어 패키지 : 쿠키, 세션,
// 사용자 로그인, url 매개변수 등의 라이브러리
const express = require('express');
const path = require('path');
// path 모듈은 파일 및 디렉터리 경로 작업을 위한 utility 를 제공한다.
const app = express();
// express 모듈과 , application을 생성한다. 여기서 app 객체는 일반적으로 express 응용 프로그램을 나타낸다. app
// 객체는
// 1. http 요청 라우팅, 2. 미들웨어 구성, 3. html 뷰 렌더링
// 4. 템플릿 엔진 등록 5. 애플리케이션 동작 방식-> 설정 수정을 위한 방법 설정
// 5->ex) 환경모드, 경로정의가 대소문자를 구분하는지의 여부 등 을 제어

const http = require('http').createServer(app);
// http 서버와 클라이언트를 사용하려면 require('http')를 사용함 http 모듈을 이용하여 express 서버를 생성한다. 위
// 방식을 통해 생성한 서버들을 활용할 수 있다는 점에서 더 유용하게 쓰인다고 한다 자세한 내용 ->
// https://stackoverflow.com/questions/17696801/express-js-app-listen-vs-server-listen
const io = require('socket.io')(http);
// 생성된 http 모듈로 socket.io 모듈을 생성할 수 있다. socket.io 는 node.js 기반 실시간 웹 애플리케이션 지원
// 라이브러리 io는 socket.io 패키지를 import 한 변수이다.

app.use(express.static(path.join(__dirname, '../public')));
// app.use 안에 있는 모든 함수들은 모두 미들웨어이며 요청이 올 때 마다 이 미들웨어를 거치며 클라이언트에게 응답하게 된다.
// __dirname은 바로 현재 위치를 가리키는 Node.js 의 전역 변수이다. express.static은 어떤 파일을 읽을 때
// 사용하는데, 원하는 값을 public이라는 폴더에서 불러온다는 뜻이다. 불러오는 모든 파일의 source(소스 코드) 는
// _dirname/public이 된다는 것이다. 예를 들어 index.css 파일을 요청하게 되면 자동으로 /public 폴더에 찾는다.
let connectedUsers = [];
//  let으로 선언하면 값을 재정의 할 수 있다. 범위 {}

function msToTime() { // 타임스탬프 찍기 위한 함수
  let date = new Date(
    new Date().getTime() - new Date().getTimezoneOffset() * 60000
  ).toISOString();
  let splits = date.split(':', 4);
  // 분:초:msc
  return splits[1] + ':' + splits[2];
}

// on 메소드 : 현재 접속 되어 있는 클라이언트로부터 메시지를 받을기 위해 사용
io.on('connection', socket => {

  console.log('---------------------start-------------------------- ');
  console.log(
    socket.id + ' / ' + msToTime() + ': 사용자 웹사이트에 들어오면 소켓에 연결됨 '
  );
  // 'connection' : soket.io의 기본 이벤트, 사용자가 웹사이트에 들어왔을 때 연결된다. 두 번째 변수는 socket은 연결이
  // 성공 했을 때 connection 에 대한 정보를 담고 있다.
  connectedUsers.push(socket.id);
  console.log(
    socket.id + ' / ' + msToTime() + ' : 유저배열에 소켓 식별자를 값으로 넣어준다.'
  );
  // push 함수는 배열 끝에 새 요소를 추가하고 배열의 새 길이를 반환한다. 인자 값 1 : socket.id는 배열에 새로 추가할 값이다.
  // 새 연결에는 20자로 구성된 임의의 식별자(socket.id)가 할당된다.
  socket.on('disconnect', () => { //클라이언트와 연결이 끊어졌을 때 발생한다.
    console.log(
      socket.id + ' / ' + msToTime() + '클라이언트와 연결 끊어짐 -> 소켓 끊어짐'
    );
    connectedUsers = connectedUsers.filter(user => user !== socket.id)
    console.log(
      socket.id + ' / ' + msToTime() + ' : 유저 배열에서 끊어진 소켓 아이디를 제거'
    );
    // filter 함수는 : 콜백 함수에 지정된 조건을 충족하는 배열 요소를 반환한다. connectedUsers 배열에서 인자값이
    // socket.id와 타입&값이 다른 배열 요소만 반환 socket.broadcast.emit('update-user-list', {
    // userIds: connectedUsers }) console.log(socket.id +' / ' + msToTime() + ' :
    // <--- update-user-list  : \n' + connectedUsers);
    console.log('f--------------finish----------------f \n\n');
    // sender인 socket의 클라이언트는 제외하여 메시지가 전송된다.
  })

  socket.on('mediaOffer', data => {
    console.log(
      socket.id + ' / ' + msToTime() +
      ' --> mediaOffer : 웹브라우저에서 내가 만든 offer 메시지 받음'
    );

    // socket.on(namespace) 특정 노드끼리 연결해주는 것이 namespace 이다 즉 지정한 Namespace에 있는 소켓끼리만
    // 통신 한다는 것이다. 소켓을 묶어주는 단위 io.on("new_namespace", (namespace) => {   ...}); 의
    // 형태는 새 namespace가 생셩될 때 실행됨.

    socket
      .to(data.to)
      .emit('mediaOffer', {
        // socket.to(room) : 이벤트가 지정된 룸(인자)에 가입한 클라이언트에게만 방송되는 이벤트 emit에 대한 한정자(다른 코드의
        // 의미를 규정하는 키워드)를 설정한다
        from: data.from,
        offer: data.offer
      });
    console.log(
      socket.id + ' / ' + msToTime() +
      ' : <-- mediaOffer 다른 유저한테 offer 메시지 웹브라우저로 보냄 '
    );

  });

  socket.on('mediaAnswer', data => {
    console.log(
      socket.id + ' / ' + msToTime() +
      ' --> mediaAnswer : 웹브라우저에서 내가 만든 answer 메시지 받음'
    );

    socket
      .to(data.to)
      .emit('mediaAnswer', {
        from: data.from,
        answer: data.answer
      });
    console.log(
      socket.id + ' / ' + msToTime() + ' : <-- mediaAnswer 다른 유저한테 answer 메시지 웹브라우저로 ' +
      '보냄 '
    );


  });

  socket.on('iceCandidate', data => {
    console.log(
      socket.id + ' / ' + msToTime() + ' --> iceCandidate : 웹 브라우저에서 내가 만든 SDP string' +
      '으로 나타내지는  ice candidate(후보자) 받음'
    );
    socket
      .to(data.to)
      .emit('remotePeerIceCandidate', { from: data.from, candidate: data.candidate });
    console.log(
      socket.id + ' / ' + msToTime() + ' <-- remotePeerIceCandidate : 다른 유저한테 candida' +
      'te후보자 보냄'
    );

  })

  socket.on('requestUserList', () => {
    console.log(
      socket.id + ' / ' + msToTime() + ' --> requestUserList : 웹브라우저에서 보낸 메시지는 없음'
    );
    socket.emit('update-user-list', { userIds: connectedUsers });
    console.log(
      socket.id + ' / ' + msToTime() + ' : <-- update-user-list 나에게 업데이트된 유저 리스트 담아서 ' +
      '보냄 '
    );
    socket.broadcast.emit('update-user-list', { userIds: connectedUsers });
    // userIds가 키값 value 로는 유저 소켓 식별자가 들어간 배열값이다.
    console.log(socket.id + ' / ' + msToTime() +
      ' : <-- update-user-list 나를 제외한 유저들에게 업데이트된 유저 리스트 담아서 보냄');
    console.log(socket.id + ' // update-user-list : ' + connectedUsers);
    // broadcast는 sender 을 제외한 모든 client에게 보낸다.
  });

});

http.listen(3000, () => { // 지정된 포트 3000에서 서버를 시작하고
  console.log(msToTime() + ': listening on *:3000'); //콘솔에 로그 주석을 인쇄한다.
});

 

 

그다음은 peer 의 js 코드 입니다.

index.js

// Creating the peer
// 로컬 컴퓨터와 원격 피어 간의 웹 RTC 연결하기 위해 필요한 객체
// 연결을 유지 및 모니터링함 또한 더이상 필요하지않으면 연결을 닫을 수 있는 방법 제공
// 이 객체는 어떻게 peer 연결 설정할지, 사용할 ice 서버의정보
const peerConnection = new RTCPeerConnection({
  iceServers: [
    {
      urls: "stun:stun.stunprotocol.org"
    }
  ]
});

// Connecting to socket
const socket = io('http://자신의 서버주소:(자신의 포트번호)');
const videoGrid = document.getElementById('video-grid')
function msToTime() { // 분:초:msc
  let date =
    new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString();
  let splits = date.split(':', 4);
  return splits[1] + ':' + splits[2];
}


const onSocketConnected = async () => {
  console.log(' / ' + msToTime() + ' -- onSocketConnected 나의 웹켐과 마이크에 접근하기 위한 과정---');
  const constraints = { //제약 조건
    // 해상도, 장치 id, 마이크 에코 등을 설정가능
    audio: true,
    video: true
  };
  const stream = await navigator.mediaDevices.getUserMedia(constraints);
  console.log(' / ' + msToTime() + ` 연결된 장치들 중 constraints 제약 조건에 만족하는 장치 불러오고 성공시 MediaStream 반환됨`);
  // 연결된 장치들 중 constraints 제약 조건에 만족하는 장치 불러오고 
  // 성공 시 MediaStream 반환됨
  document.querySelector('#localVideo').srcObject = stream;
  console.log(' / ' + msToTime() + ` 제약 조건에 만족하는 장치의 Mediastream : ` + stream);
  stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
  console.log(' / ' + msToTime() + `Mediastream 에서 트랙 목록을 얻고..?`);

  //  getTrack은 MediaStream 인터페이스(규격)의 함수
  // 배열을 반환한다. 이 배열은 모든 mediaStreamtrack객체를 나타낸다.
  // 트랙은 미디어를 기록하는 길이다.
}

let callButton = document.querySelector('#call');

// Handle call button
callButton.addEventListener('click', async () => {
  console.log(' / ' + msToTime() + '----- call 버튼 클릭 리스너 함수 실행 ------');
  const localPeerOffer = await peerConnection.createOffer();
  console.log(' / ' + msToTime() + 'createoffer : SDP Offer 생성 -> 원격피어에 대한 새로운 webrtc 연결 시작 위해서');
  await peerConnection.setLocalDescription(new RTCSessionDescription(localPeerOffer));
  // 두 피어가 구성에 동의할 때까지 설명이 교환되기 때문에 setRemotDescription은 즉시 적용되지 않는다.
  console.log(' / ' + msToTime() + 'setLocalDescription에 SDP Offer 인자로 넣어줌 :  연결과 관련된 로컬 설명을 변경');
  console.log(' / ' + msToTime() + 'sendMediaOffer 함수 호출 : 시그널링 서버에 offer 보내주기 위해서');
  // 미디어 형식과 연결의 로컬 끝 속성 지정
  // 인자1: RTCSessionDescription:연결의 한쪽 끝(또는 잠재적 연결)과 구성 방법
  sendMediaOffer(localPeerOffer);
});

// Create media offer
socket.on('mediaOffer', async (data) => {
  console.log(' / ' + msToTime() + socket.id + ' --> mediaOffer : 웹브라우저에서 다른 유저의 offer 메시지 받음');
  // remotePeerIceCandiate ->  addEventListener 후에 다음 로그 실행
  await peerConnection.setRemoteDescription(new RTCSessionDescription(data.offer));
  console.log(' / ' + msToTime() + 'setRemoteDescription 원격 피어(상대방)의 offer 인자로 넣어줘서 환경설정  ');
  //remotePeerIceCandiate 코드 실행
  const peerAnswer = await peerConnection.createAnswer();
  console.log(' / ' + msToTime() + 'createAnswer : 원격 피어로부터 받은 제안에 대한 sdp 응답 생성');
  // 답변에는 세션에 이미 연결된 모든 미디어, 브라우저에서 지원하는 코덱 및 옵션, 
  // 이미 수집된 모든 ICE 후보에 대한 정보가 포함됨
  await peerConnection.setLocalDescription(new RTCSessionDescription(peerAnswer));
  console.log(' / ' + msToTime() + 'setLocalDescription에 위에서 만든 Answer을 사용해 연결과 관련된 로컬 설명을 변경');
  console.log(' / ' + msToTime() + 'sendMediaAnswer 함수 호출');
  sendMediaAnswer(peerAnswer, data); //sendMediaAnswer 함수 실행
});

// Create media answer
socket.on('mediaAnswer', async (data) => {
  console.log(' / ' + msToTime() + '--> mediaAnswer / answer 메시지 받음');
  console.log(' / ' + msToTime() + 'answer : ' + data);
  await peerConnection.setRemoteDescription(new RTCSessionDescription(data.answer));
  console.log(' / ' + msToTime() + 'setRemoteDescription 원격 피어(상대방)의 answer 인자로 넣어줘서 환경설정  ');

});

// ICE layer
peerConnection.onicecandidate = (event) => {
  console.log(' / ' + msToTime() + '-------------> peer.onicecandidate event : ');
  console.log(' / ' + msToTime() + ' event : ');
  console.log(event);
  console.log(' / ' + msToTime() + 'sendIceCandidate 함수 호출 / 인자로 event 넘겨줌');
  sendIceCandidate(event);
}

socket.on('remotePeerIceCandidate', async (data) => {
  console.log(' / ' + msToTime() + '--> remotePeerIceCandidate / candidate(후보자) 메시지 받음');
  console.log(' / ' + msToTime() + 'remotePeerIceCandidate data : ' + data);
  try {
    console.log(' / ' + msToTime() + "try 다음 RTCICeCandidate 후보자 메시지를 인자를 넣어줌");
    const candidate = new RTCIceCandidate(data.candidate);
    console.log(' / ' + msToTime() + "candiate 후보를 자세히 설명하는 문자열 반환 ");
    console.log(candidate);
    await peerConnection.addIceCandidate(candidate);
    console.log(' / ' + msToTime() + "peer.addIceCandidate 에 후보자를 인자로 넘겨줘서  새로운 원격 후보가 추가 됨");
  } catch (error) {
    console.log(' / ' + msToTime() + ' % % % % % ERROR_remotePeerIceCandidate % % % % % ');
    console.log(error);

  }
})

peerConnection.addEventListener('track', (event) => {
  console.log(' / ' + msToTime() + '-------------------peer.addEventListener');
  const [stream] = event.streams;
  document.querySelector('#remoteVideo').srcObject = stream;
  
})

let selectedUser;

const sendMediaAnswer = (peerAnswer, data) => {
  console.log(' / ' + msToTime() + '<-- sendMediaAnswer 함수 시작');
  console.log(' / ' + msToTime() + '<-- mediaAnswer');
  // console.log('<-- sendMediaAnswer / answer : ' + peerAnswer + '/' + '보내는 사람 : ' + socket.id + '/' + '받는 사람 ' + data.from);
  socket.emit('mediaAnswer', {
    answer: peerAnswer,
    from: socket.id,
    to: data.from


  })
}

const sendMediaOffer = (localPeerOffer) => {
  console.log(' / ' + msToTime() + 'sendMediaOffer 함수 시작');
  console.log(' / ' + msToTime() + '<-- mediaOffer /msg : localPeerOffer');
  socket.emit('mediaOffer', {
    offer: localPeerOffer,
    from: socket.id,
    to: selectedUser
  });
};

const sendIceCandidate = (event) => {
  console.log(' / ' + msToTime() + 'sendIceCandidate 함수 실행');
  console.log(' / ' + msToTime() + '<-- iceCandiate');
  socket.emit('iceCandidate', {
    to: selectedUser,
    candidate: event.candidate
  });
}

const onUpdateUserList = ({ userIds }) => {


  console.log(' / ' + msToTime() + 'onUpdateUserList 함수 실행 : 매개변수 : 유저 리스트');

  console.log(' / ' + msToTime() +
    ' : html 유저리스트 동적 추가 & selectUser 변수에 나를 제외한 참여한 유저 리스트를 넣어준다. ');
  const usersList = document.querySelector('#usersList');

  const usersToDisplay = userIds.filter(id => id !== socket.id);


  usersList.innerHTML = '';

  usersToDisplay.forEach(user => {
    const userItem = document.createElement('div');

    userItem.innerHTML = user;
    userItem.className = 'user-item';
    userItem.addEventListener('click', () => {

      const userElements = document.querySelectorAll('.user-item');
      userElements.forEach((element) => {
        element.classList.remove('user-item--touched');
    

      })
      userItem.classList.add('user-item--touched');
      selectedUser = user;
 

    });
    usersList.appendChild(userItem);
  });
};
socket.on('update-user-list', onUpdateUserList);

const handleSocketConnected = async () => {
  console.log(' / ' + msToTime() + '소켓 연결이 된 후에 handleSocketConnected 함수 실행 ');
  console.log(' / ' + msToTime() + 'onSocketConnected 함수 호출함 : : 나의 mediaStream 얻기 위해서 ');
  onSocketConnected();
  socket.emit('requestUserList');
  //나에게만 보냄
  console.log(' / ' + msToTime() + '<-- requestUserList : 시그널링 서버에서 유저 리스트 업데이트 하는 과정');
};

socket.on('connect', handleSocketConnected);
// 소켓이 연결되면 handleSocketConneted 함수 실행됨

Webrtc 1:1 영상통화를 위한 화면 세팅하기  부분은 다음 글을 참고해주세요

https://stickode.tistory.com/716

 

1:1 영상통화 영상