본문 바로가기

Nest js/WebRTC

WebRTC의 이해

728x90

WebRTC란? 

Web Real Time Communication의 약자이고 오픈 소스 프로젝트이며 

WebRTC를 사용하면 PlugIn 또는 Framework 없이 웹을 통해 모든 종류의 비디오, 오디오, 데이터를 교환할 수 있다. 

 

WebRTC API

WebRTC는 3가지 Javascript API 를 사용

  • MediaStream: getUserMedia라고 불린다. 이 interface는 오디오 및 비디오 트랙을 포함할 수 있는 장치의 media stream을 나타낸다.  MediaDevices.getUserMedia() 메서드 MediaStream을 검색한다.
  • RTCPeerConnection: 피어 간의 통신을 허용한다. MediaDevices.getUserMedia()에 의해 접근되는 stream이 이 구성 요소에 추가되며 peer와 ICE후보 간에 교환되는 SDP제안 및 응답 메세지도 처리
  • RTCDataChannel: 데이터를 직접 교환하기 위해 browser를 연결하지만 WebSocket과 비교대상임

 

Peer To Peer 연결은? 

한 쪽에서 상대방에게 바로 무언가 넘어간다. 이런 의미이다. 즉 서버를 거치지 않고 원하는 상대방에게 바로 영상 서비스가 된다고 이해하자 (signaling 하기 위해 서버가 필요  ) 나의 브라우저가 상대방의 브라우저에 연결된다는 건가? 맞다! 므찌다..

웹소켓과 WebRTC의 차이

WEBSOCKET의 경우  client1 ----- WebSocket server ------ client2   

"웹소켓은 클라이언트 간에 연결이 가능하지만 서버는 메시지를 라우팅해야한다. "

 

WebRTC의 경우    client1 ------ (Signalling Server) -- client2 

"server를 통해 메세지를 계속 보내고 받을 필요가 없다. 연결 설정 및 제어만 담당! "

구글 크롬 브라우저가 시그널링 서버에게 " 여기가 내가 있는 곳이고 port야 유남셍!? "

즉 브라우저는 서버한테 configuration만 전달하고 서버는 peer to peer 에게 서로의 위치를 알려준다.

WebRTC는 클라이언트의 연결을 설정하고 제어하기 위해 server만 있으면 된다. (라우팅이 필요가 없음)

이 프로세스를 시그널링(Signalling )이라고 하고 그 서버를 Signalling Server라고 부른다. 

 

 

그렇다면 본격적으로 연결을 생성해보자

1.서버 연결 설정: 각 브라우저(구글, 파이어폭스) 연결 설정, 서버(소켓)을 통해서 이어줄거다. 

let myPeerConnection: RTCPeerConnection;;
function makeConnection() {
//누구나 myStream에 접촉 할 수 있도록, 크롬 브라우저와 FireFox에 만드는거다. 
//✅ 크롬 브라우저(Peer B)와 FireFox 브라우저(Peer A) peert-to-peer connection 연결을 만든다.
myPeerConnection= new RTCPeerConnection(); 

}

 

2. addStream: 현재 연결되어있는 캠 또는 카메라 stream의 데이터를 가져다가 연결을 만든다. 이유는 영상과 오디오를 연결을 통해 전달하려고하는 목적 때문이다. peer-to-peer 연결 안에다가 영상과 오디오를 집어 넣어야 한다. 

현재 연결되어 있는 track들을 확인해보니 2개의 트랙을 확인

console.log(myStream.getTracks())

myStream

 

영상과 오디오 데이터를 주고 받고 할 때 그 데이터들을 peer connection에 넣어야한다. 코딩으로 구현 해보자. 

  let myPeerConnection: RTCPeerConnection;;
  
  function makeConnection() {
    //🌟누구나 myStream에 접촉 할 수 있도록, 크롬 브라우저와 FireFox에 만드는거다. 
    myPeerConnection= new RTCPeerConnection();   //iceServers
    // ✅양쪽 브라우저에서 카메라와 마이크의 데이터 stream을 받아서 그것들을 연결 안에 집어 넣는다.
    myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream))

  }

그런데! 아직까지 연결은 하지 않았다. 지금까지는 각 브라우저들을 따로 구성했을 뿐이다. 

 

3. 연결 설정

위 그림을 참조하여 createOffer를 만들어야 하는데 Peer A(FireFox 브라우저)가 offer를 만드는 행위의 주체이다.

useEffect(() => {
    let socket = io(`${WS_BASE_PATH}`, {
      transports:['websocket'], 
    },
    ) 
    setSocket(socket)
    
    socket.on("welcom", async () => { 
	//✅FireFox 브라우저가 offer를 생성 및 행위 주체 (FireFox 브라라우저에서 방에 참가하는 행위)
    // chrome브라우저에서 실행되는 코드이다. (크롬 브라우저는 FireFox에서 참가하는 offer행위를 리스닝)
    const offer = await myPeerConnection.createOffer();
    console.log(offer);
    
    })
    
    return () => {
      socket.disconnect();
    };
  }, [])

console.log(offer)의 결과

  • 참고로 OS가 window의 경우 다른 브라우저 간에 한개의 트랙(카메라)을 불러오지 못 한다. 크롬 부라우저 창을 2개 띄워주고 다른 브라우저라고 생각하고 실행하면된다! 
  • 리눅스 또는 Mac OS에서 실험 테스트 필요 

SDP

prototype을 보면 RTCSessionDescription을 보니 실시간 세션에서 일어날 일에 대한 설명으로 추측할 수 있다. 

SDP가 뭐지? 또 열심히 찾아보았다..  "나는 누구이고 어디있고 " 이게 바로 우리가 찾던 signalling 서버에게 전달해야 될 Configuration으로 보인다. 

 

Peer B (크롬), setLocalDescription: 크롬에서만 실행하는 것이고   웹RTC 연결을 초기화하고 설정

  useEffect(() => {
    let socket = io(`${WS_BASE_PATH}`, {
      transports:['websocket'], 
    },
    ) 
    setSocket(socket)
    
    socket.on("welcom", async () => { 
      const offer = await myPeerConnection.createOffer();
       //✅ 리스닝하는 크롬 브라우저에서만 실행 
      myPeerConnection.setLocalDescription(offer); 

    })
    
    return () => {
      socket.disconnect();
    };
  }, [])

 

이제는 Peer B가 FireFox(offer를 보낸 상대방)에게  offer를 잘 받았다고 답장해줘야한다. 

useEffect(() => {
    let socket = io(`${WS_BASE_PATH}`, {
      transports:['websocket'], 
    },
    ) 
    setSocket(socket)
    
    socket.on("welcom", async () => { 
      const offer = await myPeerConnection.createOffer();
      myPeerConnection.setLocalDescription(offer); 
      socket.emit("offer", offer, roomName)
    })
    
    return () => {
      socket.disconnect();
    };
  }, [])

서버

@SubscribeMessage('offer')
  rtc_receiveOffer(@MessageBody() {offer, roomName}) {
    this.conferenceRoomToSockets[roomName].forEach((s) => {
      s.emit("offer", offer)
    })
  }

Peer B(크롬)의 답장

useEffect(() => {
    let socket = io(`${WS_BASE_PATH}`, {
      transports:['websocket'], 
    },
    ) 
    setSocket(socket)
    
    socket.on("welcom", async () => { 
      console.log("someone Join")
      const offer = await myPeerConnection.createOffer();
      myPeerConnection.setLocalDescription(offer);  // 리스닝하는 크롬 브라우저에서만 실행 
      console.log(offer);
      socket.emit("offer", offer, roomName)
    })
    socket.on("offer", (offer) => {
      ✅console.log("Peer B(크롬 브라우저)의 답장:")
      console.log(offer);
    } )
    return () => {
      socket.disconnect();
    };
  }, [])

Peer B의 답장, 이것이 Signalling process이고  직접 대화를 할 수 있다. 

setRemoteDescription

멀리 떨어진 peer의 description을 세팅한다는 것의 의미는 PeerB(크롬)의  상대방의 peer description 나의 description에서 설정한다 !

이 에러는 아직까지 myPeerConnection이 존재하지 않는다. 

useEffect(() => {
    let socket = io(`${WS_BASE_PATH}`, {
      transports:['websocket'], 
    },
    ) 
    setSocket(socket)
    
    socket.on("welcom", async () => { 
      // Peer A(파이어 폭스)가 offer 생성 
      const offer = await myPeerConnection.createOffer();
      // PeerA, FireFox 브라우저에서만 실행 
      myPeerConnection.setLocalDescription(offer); 
      // Peer A가 Peer B에 보낸다. 
      socket.emit("offer", offer, roomName)
    })
    socket.on("offer", (offer) => {
    //Peer B(크롬)에서만 실행하며(내peer의 description에서 설정)'offer'를 받아서 '상대방의 peer의 description'을 세팅한다. 
      myPeerConnection.setRemoteDescription(offer); //🚨 아직까지 myPeerConnection 존재x

    } )
    return () => {
      socket.disconnect();
    };
  }, [])

이유는! Peer B에서 아직까지 발현되지 않았다.  

async function makeConnection() {
    //누구나 myStream에 접촉 할 수 있도록, 크롬 브라우저와 FireFox에 만드는거다. 
    myPeerConnection= new RTCPeerConnection();   //2번, iceServers
    myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream));
  }
  
async function initialCall(eventValue:any) {
    await getUserMedia(eventValue);
    makeConnection(); //✅ myPeerConnection 
}
  const handleWelcomeSubmit = (event:any) => {
    event.preventDefault();
    const {roomId} = getValues();
    if(roomId === "") return;
    initialCall(cameraId); //✅순서를 join_room에 보내기 전으로 변경!
    socket!.emit("join_room", roomId)  //✅핑퐁이 되기 전 까지 PeerB에 myPeerConnection이 연결x 
    roomName = roomId //방에 참가 했을 때 나중에 쓸 수 있도록 방 이름을 변수에 저장
    setRoomId("")
    setCamON((prev) => !prev)

  }

answer는 offer과 비슷해 핑퐁 코드를 통한 이해

useEffect(() => {  
    let socket = io(`${WS_BASE_PATH}`, {
      transports:['websocket'], 
    },
    ) 
    setSocket(socket)
    
    socket.on("welcom", async () => { 
      // Peer A(파이어 폭스)가 offer 생성 
      const offer = await myPeerConnection.createOffer();
      // PeerA, FireFox 브라우저에서만 실행 
      await myPeerConnection.setLocalDescription(offer); 
      console.log("PeerA just Join!")
      console.log(offer);
      // Peer A가 Peer B에 보낸다. 
      socket.emit("offer", offer, roomName)
    })
    socket.on("offer", async (offer) => {
    //Peer B(크롬)에서만 실행하며(내peer의 description에서 설정)'offer'를 받아서 '상대방의 peer의 description'을 세팅한다. 
      myPeerConnection.setRemoteDescription(offer); 
      const answer = await myPeerConnection.createAnswer();✅
      myPeerConnection.setLocalDescription(answer); ✅
      socket.emit("answer", answer, roomName) ✅
      
    } )
    
    socket.on("answer", (answer) => {
      console.log("answer:")
      console.log(answer)
      myPeerConnection.setRemoteDescription(answer) ✅
      console.log("a:")
      console.log(a)
    })

    return () => {
      socket.disconnect();
    };
  }, [])

Nestjs 의 서버 answer 코드

@SubscribeMessage('answer')
  rtc_receiveAnswer(@MessageBody() info) {
    this.logger.log(info);
    this.conferenceRoomToSockets[info[1]].forEach((s) => {
      s.emit("answer", info[0])
    })
  }

ICE candidate (아래 요약된 도식화 그림에서 상세 설명)

  • 호스트와 피어 간에 최적의 연결(경로)을 설정하기 위한 기술
  • SDP가 외부 NAT(Network Address Translators), IP 주소와 port 제한 처리 방법을 인식하기 위한 기술
    "ICE candidate 가 없으면 peer와 정상적으로 연결이 안되겠다" 라는 생각이 든다. 이 정도로 이해하고 일단 킵고잉 
function makeConnection() {
    myPeerConnection= new RTCPeerConnection();   
    myPeerConnection.addEventListener("icecandidate", handleIce); //✅icecandidate 확인 
    myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream));
  }
  function handleIce(data: any) {
    console.log("got ice candidate ");
    console.log(data);
  }

어느 시점에 ICE candidate를 받을까? 정답은 Peer A가 answer를 받을 때

(그런데! setRemoteDescription 에러가 보인다)

콘솔창

이제 해야 될 것은 바로 ! candidate들을 다시 다른 브라우저로 보내는거다. 왜냐하면 candidate들은 브라우저들에 의해 만들어지기 때문이다. (어떻게 보내야 되지.. 엄청 고민함.. ) 쉽게 생각하면 candidate는 브라우저가 "우리가 이러한 방법으로 소통을 해!" 이런 느낌이다. 

보낼 candidate

send candidate & addstream 코드 구현

function makeConnection() {
    myPeerConnection= new RTCPeerConnection();   
    myPeerConnection.addEventListener("icecandidate", handleIce);  
    myPeerConnection.addEventListener("addstream", handleAddStream); 
    myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream));
  }
 // ✅ ice 이벤트 언제? answer를 받을 때 
function handleIce(data: any) {
    socket?.emit("ice", data.candidate, roomName)
  }
  // ✅ addstream 이벤트 언제? 상대 peer가 참가할 때 (직접 console을 찍어서 확인하는 것을 추천) 
function handleAddStream(data:any) {
    peerVideoRef!.current!.srcObject = data.stream; //상대 peer의 stream을 video 속성 scrObject에 동적으로 할당
}

 

add candidate 구현

socket.on("ice", (ice) => {
      myPeerConnection.addIceCandidate(ice);
    })

그런데 상대방의 카메라를 변경해도 나의 화면에서 변경 감지가 되지 않는 문제가 발생!

문제 해결방법 :  "보내는 stream, track을 변경해주면 되겠다!" 라는 생각이 들었다.

  • RTCPeerConnection.getSenders() 메서드 확인
const handleCameraChange = async (event:any) => {
    setCameraId(event.target.value);
    await getUserMedia(event.target.value); 
    if(myPeerConnection){
    
    // ✅카메라 변경시, myStream = await navigator.mediaDevices.getUserMedia(deviceId)가 재실행 전제 
      const videoTrack = myStream.getVideoTracks()[0]; //✅새로운 stream의 track을 받는다.  
      const videoSender = myPeerConnection
        .getSenders()//✅ WebRTC 세션 중에 전송되는 미디어를 동적으로 수정해야하는 경우
      console.log("videoSender:"); 
      console.log(videoSender);
    }
  }

 

두 개의 Sender를 배열로 반환

 

  • 정리: 보낼 track의 kind가 "video"를 찾아서  새로운 stream의 track(선택한 카메라) 로 변경 한다. 

코드 구현

const handleCameraChange = async (event:any) => {
    setCameraId(event.target.value);
    await getUserMedia(event.target.value); 
    if(myPeerConnection){
    //✅1.(카메라를 선택하면)새로운 stream의 track을 받는다.
      const videoTrack = myStream.getVideoTracks()[0];  
	 //✅2. 보낼 track의 kind를 video를 찾아서 새로운 videoTrack으로 변경해줘라  
      const videoSender = myPeerConnection
        .getSenders()
        .find((sender) => sender.track?.kind === "video");
      videoSender?.replaceTrack(videoTrack); //새로운 videoTrack

    }
  }

ICE candidate & STUN 서버

  • 상대의 IP, PORT을 어떻게 알까? 방화벽은 어떻게 뚫어? 바로 STUN 서버가 필요하다.
  • ICE를 보완하는 프로토콜 stun서버는 구글에서 제공하는  public stun 서버를 통한 구현
  • ( 실제 본인 app을 만들 때  stun 서버가 필요하지 않을까 ? 보안 문제가 없을까.. )
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'},
  ]
}

function makeConnection() {
    //누구나 myStream에 접촉 할 수 있도록, 크롬 브라우저와 FireFox에 만드는거다. 
    myPeerConnection = new RTCPeerConnection(iceServers);
    myPeerConnection.addEventListener("icecandidate", handleIce);
    myPeerConnection.addEventListener("addstream", handleAddStream);
    myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream));
    
  }

 

WebRTC 도식화를 통한 정리 및 요약 

1. 발신자 클라이언트가 - call -> 수신자 클라이언트 

2. 호출자는 SDP(Session Description Protocol)를 사용하여 제안을 생성하고 다른 피어(수신자)에게 보낸다. 

  * 피어란? 호출자 또는 피호출자 

  * sdp란? sdp는 세션의 이름, 연결 정보(네트워크 주소 및 포트), 미디어 유형(예: 오디오, 비디오), 미디어 포맷(예: PCM, H.264), 미디어 속성(예: 대역폭, 코덱 옵션) 서로 주고 받았다. 

3. 수신자는 SDP 설명이 포함된 응답 메시지로 제안에 응답

4. 호출자 및 수신자 피어는 브라우저 코덱(coder-decode) 또는 메타데이터의 정보를 포함하는 로컬 및 원격 세션 설명을 설정하고 그 후 호출에 사용되는 media 기능을 인식

    *코덱이란? data stream을 인코딩하거나 디코딩하는 프로그램, 알고리즘 또는 장치

 

 

5. 그러나 SDP는 외부 NAT(Network Address Translators), IP 주소와 port 제한 처리 방법을 인식하지 못해서 아직 media 데이터를 연결하고 교환할 수 없다 .

 

6. 5번의 문제의 해결은 ICE(대화형 연결 설정)에 의해 목표 달성!

대화형 연결 설정은 어떻게 작동 하는가?

7. ICE candidate 는 사용 가능한 네트워크 연결을 수집하고 NAT 및 방화벽을 통과하기 위해 STUN(Session Traversal Utilities for NAT) 및 TURN (Traversal Using Relays around NAT)프로토콜을 사용

 

8. ICE가 연결을 처리하는 방법의 기본지식 

  • 우선 UDP를 통해 직접 피어 연결을 시도
  • UDP가 실패하면 TCP를 시도 
  • UDP, TCP 모두 실패시 먼저 UDP와 함께 STUN 서버를 사용하여 피어를 연결

 

ICE candidate 상세 이해

  • STUN Client는 자신이 사용할 공인 IP주소를 알 수 없으므로 STUN서버에게 자신의 공인 IP주소를 요청
  •  STUN메시지가 방화벽을 지날 때 네트워크 계층(3계층)의 IP와 전송 계층(4계층)의 port가 바뀐다. 
  • STUN서버는 패킷의 IP헤더와 UDP(전송계층)헤더의 값(클라이언트의 공인주소)과 STUN메시지 안에 있는 STUN클라이언트의 IP주소와 UDP포트넘버(클라이언트의 사설 주소)를 비교
  • STUN서버는 두 개의 서로 다른 주소에 대한 바인딩 테이블을 생성하고 요청에 대한 응답 메시지에 공인 IP주소를 보낸다. 
  • STUN클라이언트는 VoIP시그널링을 생성할 때 사설IP가 아닌 공인 IP주소를 사용
  • NAT보안 정책이 강하면 STUN의 확장 TURN 서버를 사용 (그러나 리소스의 낭비가 심하다고 한다. )

Turn 서버를 통한 이해

시그널링 서버와 STUN 서버의 차이

  • 시그널링 서버는 연결 설정, 메타데이터 교환 및 네트워크 정보 교환과 같은 통신의 제어를 담당하는 반면, STUN 서버는 클라이언트의 네트워크 환경을 파악하고  통신을 설정하고 관리

 

Data Channels

 

 

 

 

 

 

 

 

 

 

 

 

728x90