[project] WebRTC signaling [ 1 ]

😎·2023년 1월 2일
1

PROJECT

목록 보기
10/26
post-thumbnail

읽기전에!...

해당 글은 제가 공부하면서 이해한 WebRTC 시그널링 파트를
제가 이해한대로 주관적으로 서술한 글입니다!
잘못된 부분이 있을 수 있으며, 아직 테스트를 마치지 못하였습니다.

저희조 프론트, 백엔드 분들의 WebRTC 통신의 이해를 돕기 위한 글이며, 테스트 후 수정 될 수 있습니다.

시그널링을 스텝으로 구분하여 기재 하였으며 읽기전 아래의 코드파일과 첨부한 이미지를 먼저 간단하게 먼저 흝어 봐 주세요 :)

// 외부모듈
import styled from 'styled-components';
import React, { useRef, useEffect } from 'react';
import SockJS from 'sockjs-client';
import { Stomp } from '@stomp/stompjs';

function Card() {
  const SockJs = new SockJS('http://sangt.shop/ws/chat');
  const ws = Stomp.over(SockJs);
  const reconnect = 0;
  const videoRef = useRef(null);
  const muteBtn = useRef(null);
  const cameraBtn = useRef(null);
  const camerasSelect = useRef(null);
  const cameraOption = useRef(null);
  let muted = false;
  let cameraOff = false;
  let stream;

  function onClickCameraOffHandler() {
    stream.getVideoTracks().forEach((track) => {
      track.enabled = !track.enabled;
    });
    if (!cameraOff) {
      cameraBtn.current.innerText = 'OFF';
      cameraOff = !cameraOff;
    } else {
      cameraBtn.current.innerText = 'ON';
      cameraOff = !cameraOff;
    }
  }
  function onClickMuteHandler() {
    stream.getAudioTracks().forEach((track) => {
      track.enabled = !track.enabled;
    });
    if (!muted) {
      muteBtn.current.innerText = 'Unmute';
      muted = !muted;
    } else {
      muteBtn.current.innerText = 'Mute';
      muted = !muted;
    }
  }

  async function getCameras() {
    try {
      // 유저의 장치를 얻어옵니다
      const devices = await navigator.mediaDevices.enumerateDevices();
      // 얻어온 유저의 장치들에서 카메라장치만 필터링 합니다
      const cameras = devices.filter((device) => device.kind === 'videoinput');
      // 현재내가 사용중인 카메라의 label명을 셀렉트란에 보여주기위한 과정입니다.
      //  아래의 if문과 이어서 확인 해주세요
      const currentCamera = stream.getVideoTracks()[0];
      cameras.forEach((camera) => {
        cameraOption.current.value = camera.deviceId;
        cameraOption.current.innerText = camera.label;
        if (currentCamera.label === camera.label) {
          cameraOption.current.selected = true;
        }
        camerasSelect.current.appendChild(cameraOption.current);
      });
    } catch (error) {
      console.log(error);
    }
  }

  async function getUserMedia(deviceId) {
    const initialConstrains = {
      video: { facingMode: 'user' },
      audio: true,
    };
    const cameraConstrains = {
      audio: true,
      video: { deviceId: { exact: deviceId } },
    };
    try {
      stream = await navigator.mediaDevices.getUserMedia(
        deviceId ? cameraConstrains : initialConstrains,
      );
      videoRef.current.srcObject = stream;
      if (!deviceId) {
        await getCameras();
      }
    } catch (err) {
      console.log(err);
    }
  }

  async function onInputCameraChange() {
    await getUserMedia(camerasSelect.current.value);
  }

  useEffect(() => {
    getUserMedia();
    // makeConnection line
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <StCard>
      Card
      <h4>키워드</h4>
      <span>OOO님</span>
      <div>
        {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
        <video
          ref={videoRef}
          id="myFace"
          autoPlay
          playsInline
          width={200}
          height={200}

          비디오
        </video>
        <button ref={muteBtn} onClick={onClickMuteHandler}>
          mute
        </button>
        <button ref={cameraBtn} onClick={onClickCameraOffHandler}>
          camera OFF
        </button>
        <select ref={camerasSelect} onInput={onInputCameraChange}>
          <option>기본</option>
          {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
          <option ref={cameraOption} value="device" />
        </select>
      </div>
      <button>방장일 경우 시작버튼?</button>
    </StCard>
);
}
export default Card;
const StCard = styled.div`
  margin: 20px;
  border: 1px solid green;
`;

위 코드는 아래의 스텝을 적용시키기 전의 코드 입니다.

step 1

make RTCPeerConnection

getMedia 를 호출하여 미디어를 얻어온 후 (async await 해주어야함) makeConnection() 호출하여
RTC 커넥션을 생성하여 준다 myPeerConnection은 상위 스코프에 선언하여 다른 곳에서 사용할 수 있도록 해준다

useEffect(() => {
socket.emit("join_room", useParam으로 얻어올 roomName)
// 아래의 설명부터는 roomName으로 작성 했습니다
getUserMedia();
makeConnection()
}, []);

function makeConnection() {
myPeerConnection = new RTCPeerConnection();
}

(서버코드)
socket.on("join_room",(roomName) => {
socket.to(roomName).emit("welcome");
})
** 백에서는 "join_room"타입으로 들어온 룸네임을 이용하여 socket.to(roomName).emit("welcome");
해당 룸네임을 구독?연결? 하고있는 클라이언트에게 welcome 타입의 데이터(위코드에선 전달하는데이터없음)
를 전송한다

step 2

addStream

우리는 영상과 오디오를 연결을 통해 전달하고자 한다 그러므로
우리는 peer to peer 연결 안에
영상과 오디오를 집어넣어야함
얻어온 유저의 영상과 오디오 데이터들을 stream 에 할당해 주었는데
stream.getTracks() 함수를 사용하여 저장한 오디오,비디오 트랙을 가져올 수 있다.
가져온 각각의 트랙을 forEach 함수를 이용하여 myPeerConnection에 addTrack() 함수를 이용하여
넣어준다 이때 매개변수로 stream의 각각의 track과 stream을 매개변수로 전달해 줍니다(stream 은 왜 넣는지 아직 이해못함)

makeConnection 함수를 아래와 같이 수정 합니다.
function makeConnection() {
myPeerConnection = new RTCPeerConnection();
stream.getTracks().forEach(track => myPeerConnection.addTrack(track,stream);
}

step 3

create offer

다른 클라이언트가 해당 방 입장시? 기존에 방에있던 클라이언트에서수행되는 코드
여기서 offer 를 만들어 줍니다
socket.on("welcome", asynk ()=>{
const offer = await myPeerConnection.createOffer();
}
콘솔로그로 offer 를 확인해보면 알수없는 텍스트가 보여지는데 다른 클라이언트가 참가할수 있도록
초대장을 만들어 주는정도로 이해 해주세요~!

step 4

setLocalDescription

우리는 만들어진 offer로 연결을 구성해야 한다
위 스텝 3의 코드에 (추가)라인을 추가한다
socket.on("welcome", asynk ()=>{
const offer = await myPeerConnection.createOffer();
(추가) myPeerConnection.setLocalDescription(offer)
}
여기서 위의 코드는 기존에 먼저 방에 입장해 있던 클아이언트에서만 실행 되고
새로 방에 입장한 클라이언트에게 실행되는 코드가 아니다 !주의

step 5

send offer

여기서 우리는 socket.io에게 어떤방에 이 offer데이터를 전달 할건지 알려주어야 하기 때문에 roomName을 같이전달 해줘야 합니다
(누구한테로 이 offer를 전달할지 알려줘야 한다는 의미이며 서버에선
해당 roomName의 방에 입장한 클라이언트들에게 전달하는 용도로 사용)

위 스텝 4의 코드에 (추가)라인을 추가한다
socket.on("welcome", asynk ()=>{
const offer = await myPeerConnection.createOffer();
myPeerConnection.setLocalDescription(offer)
(추가) socket.emit("offer",offer,roomName)
}

이렇게 서버로 offer란 타입으로 offer와 roomName을 전달한다
이때 서버는 offer란 타입으로 들어온 데이터를 받아서
전달받은 roomName을 구독(입장해있는)하고 있는 클라이언트 들에게 "offer"란 타입으로 전달 받은 offer를 전달해준다

(서버코드)
socket.on("offer",(offer,roomName)=>{
socket.to(roomName).emit("offer",offer);
}

step 6

setRemoteDescription

다시 브라우저로 돌아와서 서버에서 offer란 타입으로 들어올 offer 데이터를 받을 코드를 작성해준다
해당 코드는 기존에 방에있던 클라이언트가아닌 새로 입장한 클라이언트에서 실행될 코드이다
서버로부터 전달 받은 offer를 이용하여 setRemoteDescription을 설정한다
socket.on("offer", offer => {
myPeerConnection.setRemoteDescription(offer);
}
여기서 우리는 에러를 마주치게 될 것이다 !. 왜냐하면 새로입장하는 클라이언트에게는 myPeerConnection이
아직 존재하지 않기 때눈이다 위의 스텝들을 살펴보면 새로운 클라이언트가 방에 입장 하였을때
기존에 있던 클라이언트에게서만 myPeerConnection이 생성된다
이를 해결해주어야 한다

useEffect( async() => {
await getUserMedia();
await makeConnection()
socket.emit("join_room", roomName)
}, []);

useEffect를 위의 코드로 수정해준다
의미는 스텝 1에서 만든 makeConnection 함수를 호출하여 PeerConnection을 먼저 성생하기 때문에 해당 오류를 막는다

step 7

createAnswer

스텝6의 코드를
socket.on("offer", async(offer) => {
myPeerConnection.setRemoteDescription(offer);
const answer = await myPeerConnection.createAnswer();
myPeerConnection.setLocalDescription(answer);
socket.emit("answer", answer, roomName)
}
위와같이 수정하여 Answer를 생성후 setLocalDescription을 answer를 이용하여 설정후 서버로
answer 타입으로 answer를 전달하고 룸네임을 함께 전달해야 한다 이유는 서버에서 어떤 방으로
받은 answer 데이터를 전달할지 알아야 하기때문에

이후서버에서
socket.on("answer",(answer,roomName)=>{
socket.to(roomName).emit("answer",answer)
})
위의 코드를 이용하여 기존의 방에 연결 되어있는 클라이언트에게 answer 타입으로 answer데이터를 전달한다

다시 클라이언트로 돌아와서 아래의 코드를 추가해주자!
아래의 코드는 방에 입장해 있던 클라이언트들에게서 실행 될 코드이다!
socket.on("answer", answer =>{
myPeerConnection.setRemoteDescription(answer);
})
전달받은 answer데이터로 setRemoteDescription 설정을 잡아준다

여기까지 answer 끝!

ice Candidate 란 ? 인터넷 연결 생성
webRTC에 필요한 프로토콜이며, 브라우저가 서로 소통할수 있게 해주는 방법 입니다.

step 8

sned ICECandidate and recieve ICECandidate

makeConnection 함수를 아래와 같이 수정합니다.
function makeConnection() {
myPeerConnection = new RTCPeerConnection();
myPeerConnection.addEventListener("icecandidate", handleIce)
stream.getTracks().forEach(track => myPeerConnection.addTrack(track,stream)
}

handleIce 함수를 추가해 줍니다.
function handleIce(data){
socket.emit("ice",data.candidate,roomName)
console.log(got ice candidate);
console.log(data);
}
위의 코드는 기존에 입장해 있던 클라이언트와 새로 입장한 클라이언트가 candidate를 주고받는 뜻이며,
icecandidate 이벤트가 언제 일어나는지 어떤 data를 생성하는지 알수 있습니다.

아래의 코드를 클라이언트에서 추가해줍니다
socket.on("ice", ice => {
myPeerConnection.addICECandidae(ice);
})

아래의 코드를 서버에서 추가해줍니다
(서버코드)
socket.on("ice", (ice, roomName) =>{
socket.to(roomName).emit("ice",ice);
})

step 9

addstream event

makeConnection 함수를 아래와 같이 수정합니다.
function makeConnection() {
myPeerConnection = new RTCPeerConnection();
myPeerConnection.addEventListener("icecandidate", handleIce)
myPeerConnection.addEventListener("addstream",handleAddStream)
stream.getTracks().forEach(track => myPeerConnection.addTrack(track,stream)
}

handleAddStream 함수를 추가합니다

function handleAddStream(data) {
console.log("got an stream from my peer")
console.log("Peer's Stream",data.stream);
console.log("My stream", stream);
}

위의 함수를 통해 상호 클라이언트간 상대 클라이언트의 stream을 얻어온 것을 확인 콘솔로 확인 할 수 있습니다.

마치며

위의 스텝의 코드들은 socket.io 를 이용한 코드입니다
우리는 프로젝트에서 sockjs와 stomp를 사용하기로 했기때문에
sockjs와 stomp 를 사용한 코드는 아래의 코드를 확인해 주세요:)

/* eslint-disable no-plusplus */
// 외부모듈
import styled from 'styled-components';
import React, { useRef, useEffect, useState } from 'react';
import SockJS from 'sockjs-client';
import { Stomp } from '@stomp/stompjs';
import { useParams } from 'react-router-dom';

function Card() {
  let SockJs = new SockJS('http://sangt.shop/ws/chat');
  let ws = Stomp.over(SockJs);
  let reconnect = 0;
  const videoRef = useRef(null);
  const muteBtn = useRef(null);
  const cameraBtn = useRef(null);
  const camerasSelect = useRef(null);
  const cameraOption = useRef(null);
  const param = useParams();
  const [messages, setMessages] = useState([]);
  const messageArray = [];
  let muted = false;
  let cameraOff = false;
  let stream;
  let myPeerConnection;

  function onClickCameraOffHandler() {
    stream.getVideoTracks().forEach((track) => {
      track.enabled = !track.enabled;
    });
    if (!cameraOff) {
      cameraBtn.current.innerText = 'OFF';
      cameraOff = !cameraOff;
    } else {
      cameraBtn.current.innerText = 'ON';
      cameraOff = !cameraOff;
    }
  }
  function onClickMuteHandler() {
    stream.getAudioTracks().forEach((track) => {
      track.enabled = !track.enabled;
    });
    if (!muted) {
      muteBtn.current.innerText = 'Unmute';
      muted = !muted;
    } else {
      muteBtn.current.innerText = 'Mute';
      muted = !muted;
    }
  }

  async function getCameras() {
    try {
      // 유저의 장치를 얻어옵니다
      const devices = await navigator.mediaDevices.enumerateDevices();
      // 얻어온 유저의 장치들에서 카메라장치만 필터링 합니다
      const cameras = devices.filter((device) => device.kind === 'videoinput');
      // 현재내가 사용중인 카메라의 label명을 셀렉트란에 보여주기위한 과정입니다.
      //  아래의 if문과 이어서 확인 해주세요
      const currentCamera = stream.getVideoTracks()[0];
      cameras.forEach((camera) => {
        cameraOption.current.value = camera.deviceId;
        cameraOption.current.innerText = camera.label;
        if (currentCamera.label === camera.label) {
          cameraOption.current.selected = true;
        }
        camerasSelect.current.appendChild(cameraOption.current);
      });
    } catch (error) {
      console.log(error);
    }
  }

  async function getUserMedia(deviceId) {
    const initialConstrains = {
      video: { facingMode: 'user' },
      audio: true,
    };
    const cameraConstrains = {
      audio: true,
      video: { deviceId: { exact: deviceId } },
    };
    try {
      stream = await navigator.mediaDevices.getUserMedia(
        deviceId ? cameraConstrains : initialConstrains,
      );
      videoRef.current.srcObject = stream;
      if (!deviceId) {
        await getCameras();
      }
    } catch (err) {
      console.log(err);
    }
  }

  async function onMessageReceived(payload) {
    const message = JSON.parse(payload.body);
    if (message.type === 'welcome') {
      const offer = await myPeerConnection.createOffer();
      myPeerConnection.setLocalDescription(offer);
      ws.send(
        '/app/chat/message',
        {},
        JSON.stringify({
          type: 'offer',
          offer,
          roomName: param.roomName,
        }),
      );
    } else if (message.type === 'offer') {
      myPeerConnection.setRmoteDescription(message.offer);
      const answer = await myPeerConnection.createAnswer();
      myPeerConnection.setLocalDescription(answer);
      ws.send(
        '/app/chat/message',
        {},
        JSON.stringify({
          type: 'answer',
          answer,
          roomName: param.roomName,
        }),
      );
    } else if (message.type === 'answer') {
      myPeerConnection.setRemoteDescription(message.answer);
    } else if (message.type === 'ice') {
      myPeerConnection.addICECandidae(message.ice);
    }
    // messageArray.push(message);
    // setMessages([...messageArray]);
  }

  function onConnected(frame) {
    ws.subscribe(`/topic/chat/room/${param.roomName}`, onMessageReceived);
    ws.send(
      '/app/chat/message',
      {},
      JSON.stringify({
        type: 'JOIN',
        roomName: param.roomName,
      }),
    );
  }

  function onError(error) {
    if (reconnect <= 5) {
      // eslint-disable-next-line func-names
      setTimeout(function () {
        console.log('connection reconnect');
        SockJs = new SockJS('/ws/chat');
        ws = Stomp.over(SockJs);
        reconnect++;
        // eslint-disable-next-line no-use-before-define
        roomSubscribe();
      }, 10 * 1000);
    }
  }

  function roomSubscribe(event) {
    ws.connect({}, onConnected(), onError());
    event.preventDefault();
  }

  async function onInputCameraChange() {
    await getUserMedia(camerasSelect.current.value);
  }

  function handleIce(data) {
    ws.send(
      '/app/chat/message',
      {},
      JSON.stringify({
        type: 'ice',
        candidate: data.candidate,
        roomName: param.roomName,
      }),
    );
    console.log('got ice candidate');
    console.log(data);
  }

  function handleAddStream(data) {
    console.log('got an stream from my peer');
    console.log("Peer's Stream", data.stream);
    console.log('My stream', stream);
  }
  function makeConnection() {
    myPeerConnection = new RTCPeerConnection();
    myPeerConnection.addEventListener('icecandidate', handleIce);
    myPeerConnection.addEventListener('addstream', handleAddStream);
    stream.getTracks().forEach((track) => {
      myPeerConnection.addTrack(track, stream);
    });
  }

  useEffect(() => {
    async function fetchData() {
      await getUserMedia();
      await makeConnection();
      await roomSubscribe();
    }
    fetchData();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <StCard>
      Card
      <h4>키워드</h4>
      <span>OOO님</span>
      <div>
        {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
        <video
          ref={videoRef}
          id="myFace"
          autoPlay
          playsInline
          width={200}
          height={200}
        >
          비디오
        </video>
        <button ref={muteBtn} onClick={onClickMuteHandler}>
          mute
        </button>
        <button ref={cameraBtn} onClick={onClickCameraOffHandler}>
          camera OFF
        </button>
        <select ref={camerasSelect} onInput={onInputCameraChange}>
          <option>기본</option>
          {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
          <option ref={cameraOption} value="device" />
        </select>
      </div>
      <button>방장일 경우 시작버튼?</button>
    </StCard>
  );
}

export default Card;

const StCard = styled.div`
  margin: 20px;
  border: 1px solid green;
`;
profile
개발 블로그

0개의 댓글