본문 바로가기
HTML/CSS

[HTML/CSS] Webrtc N:M 실시간으로 영상 전송하기

by teamnova 2023. 3. 21.
728x90

안녕하세요 이번 시간에는 Webrtc N:M 실시간으로 영상 전송을 구현해보겠습니다.

이 글을 보기 전에 해당 링크를 참고해주세요. ( https://stickode.tistory.com/715)

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

 

server.js

const express = require('express');
const path = require('path');
const app = express();
const http = require('http').createServer(app);
const io = require('socket.io')(http);
app.use(express.static(path.join(__dirname, '../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 메시지 웹브라우저로 보냄 '
    );
    // console.log(socket.id + '  mediaOffer  data.to : ' + data.to + '  data.from :
    // ' + data.from + '  data.offer : ' + data.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 메시지 웹브라우저로 ' +
      '보냄 '
    );

    // console.log(socket.id + '  mediaAnswer   data to :' + data.to + '  data.from
    // : ' + data.from + '  data.offer :' + data.offer);

  });

  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'); //콘솔에 로그 주석을 인쇄한다.
});

 

 

index.js

// Creating the peer 로컬 컴퓨터와 원격 피어 간의 웹 RTC 연결하기 위해 필요한 객체 연결을 유지 및 모니터링함 또한 더이상
// 필요하지않으면 연결을 닫을 수 있는 방법 제공 이 객체는 어떻게 peer 연결 설정할지, 사용할 ice 서버의정보 Connecting to
// socket
// "use strict";

const socket = io('http://서버주소:포트번호');
let stream;
// let pcs = {};
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, 마이크 에코 등을 설정가능
    video: true
  };
  console.log(' / ' + msToTime() +
    ' constraints 변수 생성 및 초기화 : 제약 조건(해상도, 마이크 에코 등을 설정)');

  stream = await navigator.mediaDevices.getUserMedia(constraints);
  console.log(' / ' + msToTime() + ` stream 변수 생성 및 초기화  : 연결된 장치들 중 
  constraints 제약 조건에 만족하는 장치 불러오고 성공시 MediaStream 반환됨 `);
  document.querySelector('#localVideo').srcObject = stream;
  console.log(' / ' + msToTime() + ' stream 변수를 html 비디오 부분에 넣어줌. ');
  console.log(' / ' + msToTime() + ` MediaStreamTrack : 스트림 종류,  peerConnection 등 여러가지 설정이 담겨 있다.`);
  socket.emit('requestUserList');

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

}

// Create media offer
socket.on('mediaOffer', async (data) => {
  console.log(
    ' / ' + msToTime() + socket.id + ' --> mediaOffer : 웹브라우저에서 다른 유저의 offer 메시지 받고' +
    ' peerConnection 생성');
  let peerConnection = new RTCPeerConnection({
    iceServers: [{ urls: "stun:stun.stunprotocol.org" }]
  });

  stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
  console.log(' / ' + msToTime() + ` stream.getTrack() : MediaStreamTrack 반환함`);

  let pc = data.from;
  selectedUser_id.push(pc);
  pcs.push(peerConnection);
  // console.log(' / ' + msToTime() + '추가전의 pcs 배열 값 : ' + pcs);
  // console.log(' / ' + msToTime() + ` 변수 pc에 offer를 보낸 caller의 소켓 아이디 넣어줌 / pc : ` + pc);
  // pcs.push({ pc: peerConnection });
  // console.log(' / ' + msToTime() + '추가후의 pcs 배열 값 : ' + pcs);




  peerConnection.addEventListener("icegatheringstatechange", (ev) => {
    switch (peerConnection.iceGatheringState) {
      case "new":
        console.log(' / ' + msToTime() + 'new');
        break;
      case "gathering":
        console.log(' / ' + msToTime() + 'gathering');
        break;
      case "complete":
        console.log(' / ' + msToTime() + 'complete');
        break;
    }
  });

  // 인자 1 : track : addTrack은 다른 피어로 전송될 트랙 집합에 새로운 미디어 트랙을 추가한다. 새로운 미디어 트랙을
  // 추가한다는 것은 무슨 의미지? 상태에 대한 걱정 없이 미디어를 추가하거나 제거 할 수 있는 방식이라는데 -> 검증안됨 연결에 트랙을
  // 추가하면 협상에 필요한 이벤트가 발생하여 재협상이 트리거 된다. ICE layer
  peerConnection.onicecandidate = (event) => {

    console.log(' / ' + msToTime() + ' --- onicecandidate 이벤트 핸들러 icecandidate에 관한 이벤트핸들러 대부분 이벤트가 후보자가 더해졌을 때임');
    console.log(' / ' + msToTime() +
      ' setLocalDescrition()을 통해 후보자가 구분되고 로컬피어에 추가되면 이벤트 핸들러 실행됨');
    console.log(' / ' + msToTime() +
      ' 이벤트 핸들러은 콜백 루틴이기 때문에 이 코드 상에서 원격 피어에 candidate들을 추가할 수 있도록 해야함');

    // 이벤트 핸들러란? 어떠한 이벤트가 발생되었을 때 비동기적(기다리지 않음)으로 작동하는 콜백 루틴
    // 이벤트에 뒤따르는 행동을 지시함
    console.log(' / ' + msToTime() + 'event : ');
    console.log(event);

    if (event.candidate) {
      console.log(' / ' + msToTime() + '새로 더해진 후보자가 있을 때만 if 문 실행 후 sendIceCandidate 호출 ');
      console.log(' / ' + msToTime() + 'sendIceCandidate 함수 호출 / 인자 1 : event , 인자 2 :callee 소켓아이디: ' + pc);
      sendIceCandidate(event, pc);
    }
    else {
      console.log(' / ' + msToTime() + '추가된 후보자가 없을 떄 else 문 ');
      /* there are no more candidates coming during this negotiation */
    }
  }

  peerConnection.addEventListener('track', (event) => {
    console.log(
      ' / ' + msToTime() + '-------------------peer.addEventListener'
    );

    // let [remoteStream] = event.streams;
    // document.querySelector('#remoteVideo').srcObject = remoteStream;

    const [stream] = event.streams;
    const tagArea = document.getElementById("video-grid");
    //집어넣을 영역
    const newVideo = document.createElement('video');

    newVideo.autoplay = true;
    newVideo.srcObject = stream;
    // 새로운 비디오 태그 만들기
    tagArea?.appendChild(newVideo);



  })

  // remotePeerIceCandiate ->  addEventListener 후에 다음 로그 실행
  await peerConnection.setRemoteDescription(new RTCSessionDescription(data.offer));
  console.log(' / ' + msToTime() + 'setRemoteDescription 원격 피어(상대방)의 offer 인자로 넣어줘서 환경설정');

  const peerAnswer = await peerConnection.createAnswer();
  console.log(' / ' + msToTime() + 'createAnswer : 원격 피어로부터 받은 제안에 대한 sdp 응답 생성');
  // 답변에는 세션에 이미 연결된 모든 미디어, 브라우저에서 지원하는 코덱 및 옵션, 이미 수집된 모든 ICE 후보에 대한 정보가 포함됨
  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);
  const pc = data.from;
  console.log(' / ' + msToTime() + 'answer pcs 배열 값 : ' + pcs);

  for (let i = 0; i < selectedUser.length; i++) {
    console.log(` / ${msToTime()}answer 보낸 사람 : ${pc}  for 문 ${i}번째`);
    console.log(' / ' + msToTime() + 'pcs[i] 값 확인 해볼까 ' + selectedUser_id[i]);

    if (selectedUser_id[i] == pc) {

      console.log(' / ' + msToTime()
        + 'mediaAnswer를 보낸 유저 소켓 아이디와 pcs 배열 중에서 키값이 일치할 때 : ' + pcs[i]);
      let peerConnection = pcs[i];
      await peerConnection.setRemoteDescription(new RTCSessionDescription(data.answer));
      console.log(' / ' + msToTime()
        + 'setRemoteDescription 원격 피어(상대방)의 answer 인자로 넣어줘서 환경설정  ');
      break;
    }
  }


});

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);
    for (let i = 0; i < selectedUser.length; i++) {
      console.log(' / ' + msToTime() + " -------remotePeerIcecandidate for문 " + i + '번째');
      let pc = data.from;
      console.log(' / ' + msToTime() + " 변수 pc 에 candidate 후보자를 보내준 유저의 socket id 값 넣어줌");
      console.log(' / ' + msToTime() + " 후보자 보낸 사람 : " + pc);
      console.log(' / ' + msToTime() + 'pcs[i] 값 확인 해볼까 ' + selectedUser_id[i]);
      if (selectedUser_id[i] == pc) {
        console.log(' / ' + msToTime() + " pcs[{socket.id : peerConnection}] 에서 보내준 유저의 socket id와 일치하는 키 값이 있다면 if 문 실행됨");
        console.log(' / ' + msToTime() + ' candidate 를 보낸 유저 소켓 아이디와 pcs 배열 중에서 키값이 일치할 때' + pcs[i]);
        const peerConnection = pcs[i];
        await peerConnection.addIceCandidate(candidate);
        console.log(' / ' + msToTime() + "addIceCandidate 는 새로 받은 후보자를 브라우저 ice 에이전트에 전달함 *-ice agent 는 연결관리를 한다고 한다.");
      }
    }
  } catch (error) {
    console.log(
      ' / ' + msToTime() + ' % % % % % ERROR_remotePeerIceCandidate % % % % % ');
    console.log(error);

  }
})



const sendMediaAnswer = (peerAnswer, data) => {
  console.log(' / ' + msToTime() + '<-- sendMediaAnswer 함수 시작/ 서버에 answer 보냄');
  console.log(' / ' + msToTime() + '<-- mediaAnswer');
  socket.emit('mediaAnswer', {
    answer: peerAnswer,
    from: socket.id,
    to: data.from
  })
}

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

const sendIceCandidate = (event, toUser) => {
  console.log(' / ' + msToTime() + 'sendIceCandidate 함수 실행 / 서버에 후보자 보냄');
  console.log(' / ' + msToTime() + '<-- iceCandiate');
  socket.emit('iceCandidate', {
    from: socket.id,
    to: toUser,
    candidate: event.candidate
  });
}

let allUserList; //서버로부터 받는 현재 참가한 유저 리스트
let selectedUser = []; // 나를 제외한 유저 리스트
let selectedUser_id = [];
let pcs = []; //[{socket.id : peerConnection}]
let localPeerOffer; // offer 생성후 담는 변수

const onUpdateUserList = async ({ userIds }) => { // 업데이트 된 유저 리스트 받는다.
  console.log(' / ' + msToTime() + 'onUpdateUserList 함수 실행 : 매개변수 : 유저 리스트');
  allUserList = userIds; // 현재 참여한 유저리스트 allUserList 에 넣어줌

  selectedUser = userIds.filter(id => id !== socket.id);
  console.log(' / ' + msToTime() + ' selectUser 초기화 : 나를 제외한 참여한 유저 리스트를 넣어준다. ');
  console.log(' / ' + msToTime() + ' selectUser 값 : ' + selectedUser);
  let userIdsCount = userIds.length; // 현재 유저 수

  if (userIdsCount > 1 && socket.id == userIds[userIdsCount - 1]) { //Caller 만 if 문 통과 
    console.log(' / ' + msToTime() +
      '나를 제외한 유저 수가 1이상 그리고 가장 마지막으로 들어온 유저 socket.id 가 내 socket.id 와 같을 때 if 문실행');
    for (let i = 0; i < userIdsCount - 1; i++) {
      console.log(' / ' + msToTime() + '----- updateUserList 의 for문 나를 제외한 유저 수 만큼 반복 ------- ');
      const peerConnection = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.stunprotocol.org" }] });
      console.log(' / ' + msToTime() + 'peerConnection 생성');
      selectedUser_id.push(selectedUser[i]);
      pcs.push(peerConnection);

      // console.log(' / ' + msToTime() + 'pc 변수에 유저 소켓 아이디 값을 초기화 해줌 / pc 값 : ' + pc);
      // console.log(' / ' + msToTime() + '추가전의 pcs 배열 값 : ' + pcs);
      // pcs.push({ pc: peerConnection });
      // console.log(' / ' + msToTime() + '배열에 {socket.id : peerConnection}을 값으로 넣어줌 / pcs 배열 값 : ' + pcs);


      stream
        .getTracks()
        .forEach(track => peerConnection.addTrack(track, stream));
      console.log(' / ' + msToTime() + ` stream.getTrack() : MediaStreamTrack 반환된 것을 addTrack 해줌`);

      peerConnection.addEventListener("icegatheringstatechange", (ev) => {
        switch (peerConnection.iceGatheringState) {
          case "new":
            console.log(' / ' + msToTime() + 'new');
            break;
          case "gathering":
            console.log(' / ' + msToTime() + 'gathering');
            break;
          case "complete":
            console.log(' / ' + msToTime() + 'complete');
            break;
        }
      });
      // ICE layer
      peerConnection.onicecandidate = (event) => {
        console.log(' / ' + msToTime() + ' --- onicecandidate 이벤트 핸들러 icecandidate에 관한 이벤트핸들러 대부분 이벤트가 후보자가 더해졌을 때임');
        console.log(' / ' + msToTime() +
          ' setLocalDescrition()을 통해 후보자가 구분되고 로컬피어에 추가되면 이벤트 핸들러 실행됨');
        console.log(' / ' + msToTime() +
          ' 이벤트 핸들러은 콜백 루틴이기 때문에 이 코드 상에서 원격 피어에 candidate들을 추가할 수 있도록 해야함');

        // 이벤트 핸들러란? 어떠한 이벤트가 발생되었을 때 비동기적(기다리지 않음)으로 작동하는 콜백 루틴
        // 이벤트에 뒤따르는 행동을 지시함
        console.log(' / ' + msToTime() + 'event : ');
        console.log(event);

        if (event.candidate) {
          console.log(' / ' + msToTime() + '새로 더해진 후보자가 있을 때만 if 문 실행 후 sendIceCandidate 호출 ');
          console.log(' / ' + msToTime() + 'sendIceCandidate 함수 호출 / 인자 1 : event , 인자 2 :' + i + '번째 callee 소켓아이디');
          sendIceCandidate(event, selectedUser[i]);
        }
        else {
          console.log(' / ' + msToTime() + '추가된 후보자가 없을 떄 else 문 ');
          /* there are no more candidates coming during this negotiation */
        }
      }

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

        const [stream] = event.streams;
        const tagArea = document.getElementById("video-grid");
        //집어넣을 영역
        const newVideo = document.createElement('video');
        newVideo.autoplay = true;
        newVideo.srcObject = stream;
        // 새로운 비디오 태그 만들기
        tagArea.appendChild(newVideo);

        console.log(' / ' + msToTime() + 'remotePeer stream 세팅 ');
      });

      console.log(
        ' / ' + msToTime() + '----- Offer 생성 시작 ------');
      localPeerOffer = await peerConnection.createOffer();
      console.log(' / ' + msToTime() + ' createoffer 변수 : SDP Offer 생성 -> 원격피어에 대한 새로운 webrtc 연결 시작 위해서');
      peerConnection.setLocalDescription(new RTCSessionDescription(localPeerOffer));
      // 두 피어가 구성에 동의할 때까지 설명이 교환되기 때문에 setRemotDescription은 즉시 적용되지 않는다.
      console.log(
        ' / ' + msToTime() + ' setLocalDescription에 SDP Offer 인자로 넣어줌 :  연결과 관련된 로컬 설명을' +
        ' 변경'
      );
      console.log(
        ' / ' + msToTime() + ' sendMediaOffer 함수 호출 : 시그널링 서버에 offer 보내주기 위해서'
      );
      sendMediaOffer(localPeerOffer, selectedUser[i]);
      // 미디어 형식과 연결의 로컬 끝 속성 지정 인자1: RTCSessionDescription:연결의 한쪽 끝(또는 잠재적 연결)과 구성 방법
    }
    console.log(' / ' + msToTime() + '----- updateUserList 의 if문 끝 ------- ');
  }
};
socket.on('update-user-list', onUpdateUserList);

const handleSocketConnected = async () => {
  console.log(' / ' + msToTime() + 'socket : ' + socket.id);
  console.log(
    ' / ' + msToTime() + '소켓이 연결이 되면 handleSocketConnected 함수 호출됨'
  );
  console.log(
    ' / ' + msToTime() + '---handleSocketConnected(카메라 접근 및 서버에 유저 리스트 요청)--'
  );
  console.log(
    ' / ' + msToTime() + 'onSocketConnected 함수 호출할 예정 : 내 카메라와 마이크에 접근하기 위해서 '
  );
  onSocketConnected();

  //나에게만 보냄
};

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