실시간 통신 API, WebRTC 알아보기

태인
Written by 태인 on

WebRTC란?

WebRTC(Web Real-Time Communications)란, 웹 어플리케이션(최근에는 android 및 ios도 지원) 및 사이트들이 별도의 소프트웨어 없이 음성, 영상 미디어 혹은 텍스트, 파일 같은 데이터를 브라우져끼리 주고 받을 수 있게 만든 기술이다. WebRTC로 구성된 프로그램들은 별도의 플러그인이나 소프트웨어 없이 p2p 화상회의 및 데이터 공유를 한다. 문서

즉, 웹 브라우저를 이용해 채팅, 음성/화상 채팅 등 데이터 교환을 가능하게 하는 기술이다.


WebRTC는 크게 3가지의 클래스에 의해서 동작한다.

  • MediaStream - 카메라/마이크 등 데이터 스트림 접근
  • RTCPeerConnection - 암호화 및 대역폭 관리 및 오디오 또는 비디오 연결
  • RTCDataChannel - 일반적인 데이터 P2P통신

이 RTCPeerConnection들이 데이터를 교환할 수 있게 처리하는 과정을 시그널링(Signaling)이라고 한다.

이미지

PeerConnection 연결을 요청한 콜러(caller)와 연결을 받는 콜리(callee)로 나눌 수 있다.

콜러와 콜리가 통신을 할 때는 중간의 서버를 통해서 SessionDescription을 서로 주고 받는다.


WebRTC에 대해서 알아보다 보면 Stun Server , Turn Server라는 녀석을 만나게 된다. WebRTC는 Peer들의 네트워크(ip) 주소를 알아내야 하는데, 보안상의 문제로 쉽지 않다. 이 때 Stun/Turn Server가 P2P통신을 가능하게 해준다.

Ice (Interactive Connectivity Establishment)는 자신의 Public IP를 파악한 후 상대에게 데이터를 전송하기 위한 응답 프로토콜이다. 한 쪽이 요청을 보내면 다른 한쪽이 응답하여 연결이 이루어진다.

SDP (Session Description Protocol)는 스트리밍 미디어의 포맷이다. WebRTC는 SDP format에 맞추어 영상, 음성 데이터를 교환한다.


용어는 이쯤 알아보고, 바로 화상 채팅 서비스를 만들어보도록 하자. 여기서는 매우 간단한 1:1 화상 채팅 기능을 구현해보겠다.

먼저 html 파일을 만들어주자.

<html>
<head>
    <meta charset="utf-8" />
    <title>WebRTC</title>
</head>

<body>
    <div>
        <video id="localVideo" autoplay width="480px"></video>
        <video id="remoteVideo" width="480px" autoplay></video>
    </div>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.4.0/socket.io.min.js"></script>
    <script src="rtc.js"></script>
</body>

</html>

아래서 4번째 줄에 socket.io cdn이 필요한데, https://socket.io/docs/v4/client-installation/에서 CDN을 이용해도 되고, 직접 다운로드를 해도 된다. 여기서는 cdn을 통해 socket.io를 이용하겠다.

video 오브젝트 2개를 만들어줬는데, 하나는 자신의 모습, 다른 하나는 상대방의 화면을 나타내준다.

이제 rtc.js 파일을 만들어 다음과 같이 작성한다.

let localVideo = document.getElementById("localVideo");
let remoteVideo = document.getElementById("remoteVideo");
let localStream;

navigator.mediaDevices
  .getUserMedia({
    video: true,
    audio: false,
  })
  .then(gotStream)
  .catch((error) => console.error(error));

function gotStream(stream) {
  console.log("Adding local stream");
  localStream = stream;
  localVideo.srcObject = stream;
  sendMessage("got user media");
  if (isInitiator) {
    maybeStart();
  }
}

mediaDevice 객체의 getUserMedia 메소드를 통해서 사용자의 미디어 데이터(음성, 카메라)를 스트림으로 받을 수 있다.

function sendMessage(message){
  console.log('Client sending message: ',message);
  socket.emit('message',message);
}

서버로 소켓정보를 전송하는 메소드를 만들어준다.

function createPeerConnection() {
  try {
    pc = new RTCPeerConnection(null);
    pc.onicecandidate = handleIceCandidate;
    pc.onaddstream = handleRemoteStreamAdded;
    console.log("Created RTCPeerConnection");
  } catch (e) {
    alert("connot create RTCPeerConnection object");
    return;
  }
}

function handleIceCandidate(event) {
  console.log("iceCandidateEvent", event);
  if (event.candidate) {
    sendMessage({
      type: "candidate",
      label: event.candidate.sdpMLineIndex,
      id: event.candidate.sdpMid,
      candidate: event.candidate.candidate,
    });
  } else {
    console.log("end of candidates");
  }
}

function handleCreateOfferError(event) {
  console.log("createOffer() error: ", event);
}

function handleRemoteStreamAdded(event) {
  console.log("remote stream added");
  remoteStream = event.stream;
  remoteVideo.srcObject = remoteStream;
}

createPeerConnections는 RTCPeerConnection에 대한 객체를 생성해준다. iceCandidate는 데이터 교환을 할 대상의 End point*의 정보이다. 이 iceCandidate할 대상이 있으면 handleIceCandidate 메소드를 실행한다. 시그널링 서버로 정보를 넘겨줘 상대방이 내 스트림에 연결할 수 있도록 하는 것이다. 연결된 peer는 handleRemoteStreamAdded 메소드를 통해서 remoteVideo에 띄운다.

}

function maybeStart() {
  console.log(">>MaybeStart() : ", isStarted, localStream, isChannelReady);
  if (!isStarted && typeof localStream !== "undefined" && isChannelReady) {
    console.log(">>>>> creating peer connection");
    createPeerConnection();
    pc.addStream(localStream);
    isStarted = true;
    console.log("isInitiator : ", isInitiator);
    if (isInitiator) {
      doCall();
    }
  }else{
    console.error('maybeStart not Started!');
  }
}
}

maybeStart는 자신의 RTCPeerConnection을 초기화하고 상대방의 RTCPeerConnection과 연결하는 함수이다.

function doCall() {
  console.log("Sending offer to peer");
  pc.createOffer(setLocalAndSendMessage, handleCreateOfferError);
}

function doAnswer() {
  console.log("Sending answer to peer");
  pc.createAnswer().then(
    setLocalAndSendMessage,
    onCreateSessionDescriptionError
  );
}

function setLocalAndSendMessage(sessionDescription) {
  pc.setLocalDescription(sessionDescription);
  sendMessage(sessionDescription);
}

연결이 되면 doCall과 doAnswer를 통해서 Description을 교환하고 이 과정을 통해서 내 화상 정보가 상대방에게, 상대방의 화상정보가 내 화면에 보이게 된다.

let pcConfig = { ‘iceServers’: [{ ‘urls’: ‘stun:stun.l.google.com:19302’ }] }

socket.on('message', (message)=>{
  console.log('Client received message :',message);
  if(message === 'got user media'){
    maybeStart();
  }else if(message.type === 'offer'){
    if(!isInitiator && !isStarted){
      maybeStart();
    }
    pc.setRemoteDescription(new RTCSessionDescription(message));
    doAnswer();
  }else if(message.type ==='answer' && isStarted){
    pc.setRemoteDescription(new RTCSessionDescription(message));
  }else if(message.type ==='candidate' &&isStarted){
    const candidate = new RTCIceCandidate({
      sdpMLineIndex : message.label,
      candidate:message.candidate
    });

    pc.addIceCandidate(candidate);
  }
})

이제 node.js를 이용해 signaling 서버를 구현해야 한다.

const http = require('http');
const os = require('os');
const socketIO = require('socket.io');
const nodeStatic = require('node-static');

let fileServer = new(nodeStatic.Server)();
let app = http.createServer((req,res)=>{
    fileServer.serve(req,res);
}).listen(8080);

let io = socketIO.listen(app);
io.sockets.on('connection',socket=>{
    function log() {
        let array = ['Message from server:'];
        array.push.apply(array,arguments);
        socket.emit('log',array);
    }

    socket.on('message',message=>{
        log('Client said : ' ,message);
        socket.broadcast.emit('message',message);
    });

    socket.on('create or join',room=>{
        let clientsInRoom = io.sockets.adapter.rooms[room];
        let numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;
        log('Room ' + room + ' now has ' + numClients + ' client(s)');
        
        if(numClients === 0){
            console.log('create room!');
            socket.join(room);
            log('Client ID ' + socket.id + ' created room ' + room);
            socket.emit('created',room,socket.id);
        }
        else if(numClients===1){
            console.log('join room!');
            log('Client Id' + socket.id + 'joined room' + room);
            io.sockets.in(room).emit('join',room);
            socket.join(room);
            socket.emit('joined',room,socket.id);
            io.sockets.in(room).emit('ready');
        }else{
            socket.emit('full',room);
        }
    });


});

서버를 실행시켜 두 개의 창을 띄우면 두 웹캠 화면이 서로에게 전송되는 것을 볼 수 있다.

원래 제 레포지토리를 공개하려 했는데, 이것저것 기능을 넣다보니 딱 필요한 코드만 있는 게 나을 것 같아서 다른 분의 코드를 공유한다. https://github.com/ehdrms2034/WebRtcTutorial

참고-600g (Kim Dong Geun)

태인

태인

댓글

comments powered by Disqus