WebRTC 시그널링 서버 구현하기

Hello from signaling server

Featured image

들어가며

WebRTC는 실시간 음성, 영상, 데이터를 교환할 수 있는 P2P 기술이다. 서로 다른 네트워크에 있는 2개의 클라이언트 간 미디어 포맷등을 상호 연동하기 위한 협의과정(Negotiation)이 필요하다.

이 프로세스를 시그널링 Signalling 이라고 부른다.

쉽게 말하면, 클라이언트 A와 B가 서로 실시간 미디어를 주고 받기 위해 서로 사전 작업을 하는 것으로 이해해도 된다.

본 글에서는 이 시그널링 과정을 간략하게 구현하는 내용을 담고자 한다.


기본 흐름

1. SDP 교환 - Offer, Answer

WebRTC의 기본 흐름은 mozilla.org에서 친절하게 설명해두었다.

미디어 시스템을 잘 다루는 업체중 WOWZA에서도 잘 설명을 해두었다.

우선 미안하지만 mozilla에서 설명하는 내용은 구구절절 상세히 설명해줘서 좋긴하지만, 너무 길다.

(그래도 감사하다.) 우리는 Wowza에서 설명한 내용에 조금더 덧붙혀 설계를 이어나가자

webrtc-signaling-servers

The exchange illustrated in lines 1-4 is the offer-answer mechanism that is part of WebRTC. These messages aren’t WebRTC messages, but rather proprietary ones that contain SDP. What happens here is that WebRTC creates SDP blobs. - https://www.wowza.com/blog/webrtc-signaling-servers

해석하자면, 1~4는 Offer-Answer에 관한 내용이고 WebRTC 가 아닌 SDP 를 생성하여 서로 주고받으며 시그널링을 수행한다고 한다.

SDP는 Session Description Protocol로 RFC 4566에 규정된 스트리밍 미디어의 초기화 인수를 기술하고 협상하기 위한 것이다.

생긴건 이렇게 생겼다.

아직 해당 프로토콜의 세부사항까지는 알지 않아도 된다. 추후 세부 최적화 작업을 진행 한다면 코덱의 순서나, rtcp의 포트 정도의 조정이 가능 할 수 있지만, 현재는 시그널링 서버 설계와 구현에 집중하자.

조금 더 단계별로 예쁘게 표현하면 다음과 같은 모양이다.

webRTC CodeLab Alice와 Bob 두명의 Peer가 있다. Alice와 Bob은 서로 SDP 기반의 Offer와 Answer 메시지를 주고 받는다.

각 단계는 아래와 같다.

  1. Alice 가 SDP 형태의 Offer 메시지를 생성한다.
  2. Alice가 생성된 Offer 메시지를 본인의 LocalDescription으로 등록한다.
  3. Alice가 Offer메시지를 시그널링 서버에게 전달한다.
  4. 시그널링서버는 상대방 Bob을 찾아서 SDP 정보를 전달한다.
  5. Bob은 전달받은 Offer메시지를 본인의 RemoteDecsription에 등록한다.
  6. Bob은 Answer 메시지를 생성한다.
  7. 생성된 Answer 메시지를 본인의 LocalDescription으로 등록한다.
  8. Bob은 Answer 메시지를 시그널링서버에게 전달한다.
  9. 시그널링서버는 상대방 Alice를 찾아서 Answer 메시지를 전달한다.
  10. Alice는 전달받은 Anser 메시지를 본인의 RemoteDescription에 등록한다.

2. ICE 협상 (ICE Negotiation)

SDP를 서로 교환한 후, 각 Peer Alice와 Bob 은 서로의 주소 값을 알기 위해 ICE Candidate(ICE 후보)를 교환한다. 이때 사용되는 기술이 NAT Traversal 이다.

ICE(Interactive Connectivity Establishment)는 P2P 네트워킹에서 두 컴퓨터가 가능한 한 직접 서로 통신하는 방법을 찾기 위해 컴퓨터 네트워킹에 사용되는 기술 https://en.wikipedia.org/wiki/Interactive_Connectivity_Establishment

각 ICE 메세지들은 두 개의 컴퓨터를 서로 연결하기 위한 정보들에 덧붙여 프로토콜(TCP or UDP), IP 주소, 포트 넘버, 커넥션 타입 등을 제안한다. - https://developer.mozilla.org/ko/docs/Web/API/WebRTC_API/Signaling_and_video_calling

Image Source: https://hikingartist.com/2012/01/03/cat-and-dog-online-2-0/

알면 좋지만, 우선은 이미 구현되어 있는 서버 및 오픈소스가 많다. 정말 ICE 후보들을 정밀하게 튜닝해야 할 일이 있다면 더 파보는것이 좋을듯 하나, 이번 글에서는 넘어가도록 한다. ICE에 대해서 조금더 자세히 알고 싶다면 아래의 글이 도움이 될 수도 있다.

WOWZA에서 좋은 그림을 보여준다. 대략 이런 느낌으로 동작하는 프레임워크가 ICE라고 생각하면 된다.

https://www.wowza.com/blog/webrtc-signaling-servers

정리하면,

WebRTC에서 시그널링서버는 이런일을 한다고 생각하면 쉽다.


시그널링 서버 구현 설계

시그널링 서버 구현에 관해서는 별도의 기술제약사항은 없다. 본 글에서는 Node.js express [socket.io](http://socket.io) 를 이용하여 시그널링서버를 구현하고자 한다.

Javascript를 가지고 구현하는 이유는 예시를 위해 WebRTC는 FrontEnd 쪽에서 사용되는 js 파일작업이 필요했고, 한 프로젝트에서 같은 언어를 사용하여 표현하고, 문법적 차이로 인한 불필요한 학습비용을 낮추고 싶었기에 선택했다. (조만간 JAVA, Spring도 공개예정)

Node.js는 확장성 있는 네트워크 애플리케이션(특히 서버 사이드) 개발에 사용되는 소프트웨어 플랫폼이다. 작성 언어로 자바스크립트를 활용하며 Non-blocking I/O와 단일 스레드 이벤트 루프를 통한 높은 처리 성능을 가지고 있다. 내장 HTTP 서버 라이브러리를 포함하고 있어 웹 서버에서 아파치 등의 별도의 소프트웨어 없이 동작하는 것이 가능하며 이를 통해 웹 서버의 동작에 있어 더 많은 통제를 가능케 한다. - https://ko.wikipedia.org/wiki/Node.js

Express 는 Node에서 사용되는 웹 프레임 워크로 아래와 같은 일을 지원한다.

https://developer.mozilla.org/ko/docs/Learn/Server-side/Express_Nodejs/Introduction

Socket.io는 웹소켓 구현체로 Node.js의 라이브러리이다.

웹소켓은 L7에서 동작하는 실시간 웹브라우저 통신 프로토콜이다. 사전적 정의는 아래와 같다.

웹소켓 프로토콜은 HTTP 풀링과 같은 반이중방식에 비해 더 낮은 부하를 사용하여 웹 브라우저(또는 다른 클라이언트 애플리케이션)과 웹 서버 간의 통신을 가능케 하며, 서버와의 실시간 데이터 전송을 용이케 한다. 이는 먼저 클라이언트에 의해 요청을 받는 방식이 아닌, 서버가 내용을 클라이언트에 보내는 표준화된 방식을 제공함으로써, 또 연결이 유지된 상태에서 메시지들을 오갈 수 있게 허용함으로써 가능하게 되었다. 이러한 방식으로 양방향 대화 방식은 클라이언트와 서버 간에 발생할 수 있다. - https://ko.wikipedia.org/wiki/웹소켓

동작 방식에 대해 추가적으로 궁금하면 다음 사이트를 방문하자. - https://ko.javascript.info/websocket

위에서 말했듯이 WebRTC에서 시그널링서버는 이런일을 한다고 생각하면 쉽다.

이 기능을 Socket.io의 Rooms 를 활용하여 구현을 해보자.

룸은 소켓이 참여하고 나갈 수 있는 임의의 채널입니다. 클라이언트의 하위 집합에 이벤트를 브로드캐스트하는 데 사용할 수 있습니다.


시그널링 서버 구현 예시

작업의 순서는 위에서 말한 과정과 유사하다. 4, 9 작업은 시그널링 서버에서 수행하며, 이외에는 클라이언트에서 수행토록 구현된다. 여기서 다시한번 기억할 내용은 본 글에서는 Peer to Peer를 구현하고 있다는 점이다. 클라이언트의 역할은 SDP를 중계하는 책임이 전부이다.

  1. Alice 가 SDP 형태의 Offer 메시지를 생성한다.
  2. Alice가 생성된 Offer 메시지를 본인의 LocalDescription으로 등록한다.
  3. Alice가 Offer메시지를 시그널링 서버에게 전달한다.
  4. 시그널링서버는 상대방 Bob을 찾아서 SDP 정보를 전달한다. //server 구현
  5. Bob은 전달받은 Offer메시지를 본인의 RemoteDecsription에 등록한다.
  6. Bob은 Answer 메시지를 생성한다.
  7. 생성된 Answer 메시지를 본인의 LocalDescription으로 등록한다.
  8. Bob은 Answer 메시지를 시그널링서버에게 전달한다.
  9. 시그널링서버는 상대방 Alice를 찾아서 Answer 메시지를 전달한다. //server 구현
  10. Alice는 전달받은 Anser 메시지를 본인의 RemoteDescription에 등록한다.

Client

우선 각 Peer에서 동작 할 Client를 구성한다.

1. View Page 작업

webrtc-actio.js 와 함께 아래 HTML을 활용하여 View Page를 구현한다. webrtc-action.js 는 클라이언트 측에서 시그널링 서버를 구현하는 방법을 담고 있다. (우리가 만들 소스)

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Web RTC Sample</title>
    </head>
    <body>

        <div id="start-page">
            <h2 class="text">Web RTC Sample</h2>
            <input id="roomName" type="text" placeholder="Room Name" />
            <button id="join">Join</button>
        </div>
        <div id="video-page">
            <video id="user-video"></video>
            <video id="peer-video"></video>
        </div>

    </body>
    <script src="https://cdn.socket.io/3.1.3/socket.io.min.js" integrity="sha384-cPwlPLvBTa3sKAgddT6krw0cJat7egBga3DJepJyrLl4Q9/5WLra3rrnMcyTyOnh" crossorigin="anonymous"></script>
    <script src="/webrtc-action.js"></script>
</html>

2.Created, Joined

최초에 방을 생성(Created) 했을때와 참가(Joined) 했을 때 미디어를 표기해야 한다. 우선 웹브라우저에 미디어를 표현 할 수 있는 객체를 추가 해야 한다. 여기서 사전에 알아야 할 Web API는 아래와 같다.

Navigator.mediaDevices

Navigator.mediaDevices 읽기 전용 속성은 카메라, 마이크, 화면 공유와 같이 현재 연결된 미디어 입력 장치에 접근할 수 있는 MediaDevices 객체를 반환합니다. - https://developer.mozilla.org/ko/docs/Web/API/Navigator/mediaDevices

MediaDevices

MediaDevices 인터페이스는 카메라, 마이크, 공유 화면 등 현재 연결된 미디어 입력 장치로의 접근 방법을 제공하는 인터페이스입니다. 다르게 말하자면, 미디어 데이터를 제공하는 모든 하드웨어로 접근할 수 있는 방법입니다. - https://developer.mozilla.org/ko/docs/Web/API/MediaDevices

Created 의 경우에는 아래와 같이 작업이 필요하다.


let userVideo = document.getElementById("user-video");

socket.on("created", function () {
  creator = true;

  navigator.mediaDevices
    .getUserMedia({
      audio: true,
      video: { width: 1280, height: 720 },
    })
    .then(function (stream) {
      userStream = stream;
      startPageDiv.style = "display:none";
      userVideo.srcObject = stream;
      userVideo.onloadedmetadata = function (e) {
        userVideo.play();
      };
    })
    .catch(function (err) {
      /* handle the error */
      alert("Couldn't Access User Media");
    });
});

Joined의 경우에는 Create와 거의 동일 하지만, 로직의 마지막 부분에 server에게 ready 이벤트를 전달하여 후행작업을 진행토록 한다.

.then(function (stream) {
      userStream = stream;
      startPageDiv.style = "display:none";
      userVideo.srcObject = stream;
      userVideo.onloadedmetadata = function (e) {
        userVideo.play();
      };
      socket.emit("ready", roomName);
    })

3.offer / answer

RTCPeerConnection(iceServers) 를 이용하여 offer 메시지를 생성한다. 생성된 RTCPeerConnection 인스턴스는 iceServer의 정보를 가지고 있고, Peer간 SDP 정보를 수신 후에 ICE 과정에서 활용된다.

Offer를 생성후 클라이언트에게 제공하는 단계는 ready 일 때이며, 그 구현은 아래와 같다.

let iceServers = {
  iceServers: [
    { urls: "stun:stun.services.mozilla.com" },
    { urls: "stun:stun.l.google.com:19302" },
  ],
};

socket.on("ready", function () {
  if (creator) {
    rtcPeerConnection = new RTCPeerConnection(iceServers);
    rtcPeerConnection.onicecandidate = OnIceCandidateFunction;
    rtcPeerConnection.ontrack = OnTrackFunction;
    rtcPeerConnection.addTrack(userStream.getTracks()[0], userStream);
    rtcPeerConnection.addTrack(userStream.getTracks()[1], userStream);
    rtcPeerConnection
      .createOffer()
      .then((offer) => {
        rtcPeerConnection.setLocalDescription(offer);
        socket.emit("offer", offer, roomName);
      })

      .catch((error) => {
        console.log(error);
      });
  }
});

이 과정이 다음 과정의 구현이다.

  1. Alice 가 SDP 형태의 Offer 메시지를 생성한다.
  2. Alice가 생성된 Offer 메시지를 본인의 LocalDescription으로 등록한다.
  3. Alice가 Offer메시지를 시그널링 서버에게 전달한다.

Offer메시지를 수신받은 Peer는 Answer 이벤트를 처리해야 한다. 그 때 Peer Client(Joined) 가 처리하는 Answer SDP 구현 로직은 아래와 같다.

socket.on("offer",function(offer) {
if(!creator) {
rtcPeerConnection=newRTCPeerConnection(iceServers);
rtcPeerConnection.onicecandidate = OnIceCandidateFunction;
rtcPeerConnection.ontrack = OnTrackFunction;
rtcPeerConnection.addTrack(userStream.getTracks()[0],userStream);
rtcPeerConnection.addTrack(userStream.getTracks()[1],userStream);
rtcPeerConnection.setRemoteDescription(offer);
rtcPeerConnection
.createAnswer()
      .then((answer) => {
rtcPeerConnection.setLocalDescription(answer);
socket.emit("answer", answer,roomName);
      })
      .catch((error) => {
console.log(error);
      });
  }
});

이 과정이 다음 과정의 구현이다.

  1. Bob은 전달받은 Offer메시지를 본인의 RemoteDecsription에 등록한다.
  2. Bob은 Answer 메시지를 생성한다.
  3. 생성된 Answer 메시지를 본인의 LocalDescription으로 등록한다.
  4. Bob은 Answer 메시지를 시그널링서버에게 전달한다.

그리고 마지막으로 answer 메시지를 이벤트로 전달받은 Created Client가 해당 SDP를 등록한다

socket.on("answer",function(answer) {
rtcPeerConnection.setRemoteDescription(answer);
});

이 과정이 다음의 구현이다.

  1. Alice는 전달받은 Anser 메시지를 본인의 RemoteDescription에 등록한다.

Server

1.Room 관리

요청한 룸 이름이 없으면, Client의 create 를 동작 시키며, 1명인 경우 joined 처리한다. 그외에는 full 로 더 이상 방에 접근하지 않게 처리한다.

socket.on("join", function (roomName) {
    let rooms = io.sockets.adapter.rooms;
    let room = rooms.get(roomName);

    if (room == undefined) {
      socket.join(roomName);
      socket.emit("created");
    } else if (room.size == 1) {
      socket.join(roomName);
      socket.emit("joined");
    } else {
      socket.emit("full");
    }
    console.log(rooms);
  });

2.이벤트 관리

각 단계별 클라이언트로 부터 요청받은 이벤트를 처리한다. 아래와 같이 Proxy Foward 형태로 구현이 가능하다.

socket.on("ready", function (roomName) {
    socket.broadcast.to(roomName).emit("ready");
  });

  socket.on("candidate", function (candidate, roomName) {
    console.log(candidate);
    socket.broadcast.to(roomName).emit("candidate", candidate);
  });

  socket.on("offer", function (offer, roomName) {
    socket.broadcast.to(roomName).emit("offer", offer);
  });

  socket.on("answer", function (answer, roomName) {
    socket.broadcast.to(roomName).emit("answer", answer);
  });

이 과정이 다음의 구현이다.

  1. 시그널링서버는 상대방 Bob을 찾아서 SDP 정보를 전달한다.
  2. Bob은 Answer 메시지를 시그널링서버에게 전달한다.

정리

복잡해 보이지만, 정리하면 아래와 비슷한 방식으로 로직이 흘러간다.

자세한 코드는 아래 주소에서 확인이 가능하다.

또한 각 단계별 구현방안이 궁금하면 아래 인강(유료) 에서 확인이 가능하다.

https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Connectivity

SDP 교환 이후 ICE Candidate하는 과정은 크롬의 내장기능으로 확인이 가능하다.

Caller

Callee

시그널링 서버를 통한 P2P WebRTC 구현을 간략하게 해보았다. 이어지는 글로는 N:M Peer, SFU 와 같은 다중통화 방식을 구현하고, 미디어 데이터의 코덱설정, ICE 설정 등에 대한 이야기들을 이어서 해보고자 한다.


참고