Angular와 WebRTC를 이용한 화상 채팅

Angular와 WebRTC를 이용한 화상 채팅 updated_at: 2024-01-26 16:50

WebRTC를 이용한 화상 채팅

webRTC 및 angular를 이용한 화상채팅 구현하기

개발환경

  • angular 16.1.0 (frontEnd)
  • nodeJs v20.10.0 (backend)

용어정리

참조

  • ICE (Interactive Connectivity Establishment)

ICE는 두 단말이 서로 통신할 수 있는 최적의 경로를 찾을 수 있도록 도와주는 프레임워크이다.

  • STUN(Session Traversal Utilities for NAT)

공개 주소를 발견하거나 peer간의 직접 연결을 맏는 등 라우터의 제한을 결정하며 ICE를 보완하는 프로토콜이다.
간단하게 말하면 STUN 서버는 해당 Peer의 Public IP 주소를 보내는 역할을 한다.

  • candidate

STUN, TURN등으로 찾아낸 연결 가능한 네트워크 주소들을 Candidate(후보) 라고 할 수 있다.

  • SDP (Session Description Protocol)

해상도나 형식, 코덱, 암호화등의 멀티미이더 컨텐츠의 연결을 설명하기 위한 표준입니다. 이러한 것이 두개의 peer가 다른 한쪽이 데이터가 전송되고 있다는 것을 알게 해 줍니다.

  • ICE Candidate Gathering

Local Address, Server Reflexive Address, Relayed Address 등 통신 가능한 주소들을 모두 가져와 가장 최적의 경로를 찾아서 연결시켜준다.

개념도

caller와 callee가 가진 sdp를 서로 교환함으로서 실제 RTC가 이루어진다.

  1. caller 는 sdp를 만들어 callee에게 전달한다(peerConnection.createOffer)
  • peerConnection.createOffer()
  • peerConnection.setLocalDescription(new RTCSessionDescription(sdp))
  1. callee 는 caller의 sdp를 받아서 응답을 한다.(peerConnection.createAnswer)
  • peerConnection.createAnswer()
  • peerConnection.setRemoteDescription()
  1. caller 는 callee의 sdp를 받아서 Icd에 추가한다.
  • peerConnection.setRemoteDescription()
  • peerConnection.addIceCandidate()

step 1 : (front-end: caller) 미디어 환경 체크 및 socket의 chatting room에 접근

navigator.mediaDevices?.getUserMedia({
  audio: true,
  video: {
    width: 240,
    height: 240,
  },
})
.then(stream => {
  if (this.localVideoRef) this.localVideoRef.srcObject = stream; // 본인의 video를 play한다.
  this.localStream = stream;
  this.socket.emit("joinRTCRoom", roomName, {User Info});
})
.catch(error => {
  console.log(`getUserMedia error: ${error}`);
});

step 2 : (back-end) room join 및 기존 회원들 및 caller에게 room에 있는 사용자들을 리스트업 한다.

socket.on("joinRTCRoom", (room, user) => {
  if (typeof users[room] === 'undefined') {
    users[room] = [];
  }

  user.id = socket.id; // 사용자들의 구분을 위해 고유값이 socket id를 user.id에 맵핑한다. (uuid는 통신에서 어떤 사용자인지 확인하는데 매우 중요한 부분이다.) 
  users[room].push(user); 

  socket.join(room); // room에 조인한다.

  // 본인은 제외한 룸에 있는 사용자 출력
  const members = users[room].filter(
    user => user.id !== socket.id
  );

  io.to(room).emit('usersInRoom', members); // 현재 방에 있는 사용자에게 정보를 전달한다.
});

step 3 : (front-end: caller) node로 부터 받은 기존 회원정보를 이용하여 RTCPeerConnection을 만든다.

this.createPeerConnection(uuid); // uuid는 기존 사용자의 고유아이디로 여기서는 node로 전달받은 기존 사용자의 socket.id 가 입력된다. 

private createPeerConnection(uuid: string): RTCPeerConnection {
  
  const peerConnectionConfig = { // 무료로 이용가능한 iceSevers (https://gist.github.com/sagivo/3a4b2f2c7ac6e1b5267c2f1f59ac6c6b)
    'iceServers': [
      {'urls': 'stun:stun.l.google.com:19302'},
      {'urls': 'stun:stun.services.mozilla.com'},
    ]
  };

  const peerConnection = new RTCPeerConnection(peerConnectionConfig); // RTC PeerConnction을 만든다.

  this.peerConnections = { ...this.peerConnections, [uuid]: peerConnection };

  // onicecandidate 이벤트 수신
  // RTCPeerConnection 을 맺으면 연결가능한 네트워크 주소등이 있는데 이를 candidate라고 말하며 이러한 것을 caller와 callee는 서로 정보를 공유한다.
  peerConnection.onicecandidate = e => {
    if (e.candidate) {
      this.socket.emit("candidate", {
        candidate: e.candidate,
        candidateTo: uuid,
      });
    }
  };

  // oniceconnectionstatechange 이벤트 수신
  peerConnection.oniceconnectionstatechange = e => {
    console.log(new Date().getTime(), 'oniceconnectionstatechange', e);
  };

  // ontrack 이벤트 수신
  peerConnection.ontrack = e => {
    this.remoteVideoRef.srcObject = e.streams[0]; // 상대의 video 를 play 한다.
  };

  // navigator.mediaDevices.getUserMedia 로 부터 처리되는 본인의 video를 peerConnection의 track에 포함시킨다.
  // 상대와 peerConnection 이 될때 이 부분은 상대의 peerConnection.ontrack 으로 전달된다.
  this.localStream.getTracks().forEach((track: any) => {
    peerConnection.addTrack(track, this.localStream);
  });


  return peerConnection;
};

step 4 : (front-end: caller) createOffer

step 3의 RTCPeerConnection 정상적으로 처리되면 createOffer 를 이용하여 sdp 생성합니다.

caller는 전화를 걸기전 PeerConnection 을 만든다.

const peerConnection = this.createPeerConnection(callee.socketId)
peerConnection.createOffer({
  offerToReceiveAudio: true,
  offerToReceiveVideo: true,
})
.then(sdp => { // createOffer를 실행하면 sdp를 받는다.
  peerConnection.setLocalDescription(new RTCSessionDescription(sdp)); // 
  this.socket.emit("offer", {
    sdp: sdp,
    callTo: callee.uuid // sdp를 받을 사람(여기서는 내가 통화를 하고 싶은 사람의 socket.id가 들어 간다)
  });
})

step 5 : (back-end) caller의 sdp를 callee 에게 전달한다.

socket.on("offer", data => {
  socket.to(data.callTo).emit("getOffer", {
    sdp: data.sdp,
    callFrom: socket.id //caller 의 소켓 아이디
  });
});

step 6 : (front-end: callee) createAnswer

caller의 sdp를 받아서 setRemoteDescription 을 처리하고 createAnswer 을 실행하여 callee의 sdp를 caller에게 전달한다.

this.socket.on("getOffer", (data: {sdp: RTCSessionDescription; callFrom: string;}) => {
  // step 3 과 마찬가지로 이번에는 caller의 id를 이용하여 peerConnection을 만든다. (step 3을 참조)
  const peerConnection = this.createPeerConnection(callFrom)
  peerConnection.setRemoteDescription(new RTCSessionDescription(data.sdp)).then(
    () => {
      peerConnection.createAnswer({
        offerToReceiveVideo: true,
        offerToReceiveAudio: true
      })
      .then(sdp => {
        peerConnection.setLocalDescription(
          new RTCSessionDescription(sdp)
        );
        this.socket.emit("answer", {
          sdp: sdp,
          answerTo: callFrom
        });
      })
      .catch(error => {
          console.log(error);
      });
    }
  );
);

step 7 : (back-end) callee의 createAnswer 결과물인 sdp를 caller 에게 전달한다.

socket.on("answer", data => {
  socket.to(data.answerTo).emit("getAnswer", {
    sdp: data.sdp,
    answerFrom: socket.id,
  });
});

step 7 : (front-end: caller) setRemoteDescription

callee의 sdp를 받아서 setRemoteDescription 을 처리한다.

caller는 전화를 걸기전 PeerConnection 을 만든다.

this.socket.on(
  "getAnswer",
  (data: { sdp: RTCSessionDescription; answerFrom: string }) => {
    console.log(new Date().getTime(), this.socket.id, "get answer");
    const peerConnection: RTCPeerConnection = this.peerConnections[data.answerFrom];
    if (peerConnection) {
      peerConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
    }
  }
);

이벤트 수신

onicecandidate

icecandidate가 발견될때 마다 실행된다.

peerConnection.onicecandidate = e => {
  // 이벤트 수신시 caller 혹은 callee에게 candidate 전달하여 서로 공유한다.
};
this.socket.on("getCandidate", (data: { candidate: RTCIceCandidateInit; candidateFrom: string }) => {
  // 기존 peerConnection 불러와 addIceCandidate 를 처리한다.
  const peerConnection: RTCPeerConnection = this.peerConnections[data.candidateFrom];
  if (peerConnection) {
    peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate)).then(() => {
    });
  }
});

ontrack

ontrack 은 setRemoteDescription 이 정상적으로 실행되면 이벤트가 활성화되고 상대의 스트림을 받는다.
현재 소스에도 두군데가 있는데 offer 를 받았을때 (caller -> callee) 및 answer를 받았을때 (callee -> caller) 일때 두 부분이다.

peerConnection.ontrack = e => {
  this.remoteVideoRef.srcObject = e.streams[0]; // 상대의 video 를 play 한다.
};
평점을 남겨주세요
평점 : 5.0
총 투표수 : 2

질문 및 답글