WebRTC란? Socket.IO를 활용하여 Signaling Server를 구현하기

mrban 2022. 5. 24. 02:00

1. WebRTC란?

WebRTC(Web Real-Time Communication)는 웹 브라우저 간에 플러그인의 도움 없이 서로 통신할 수 있도록 설계된 API이다. 비디오, 음성 및 일반 데이터를 P2P 실시간으로 전송 가능하게 해주는 오픈 프레임워크이다. 여기서 중요한 점은 WebRTC는 P2P로 전송이 가능하다는 것이지, 서버의 도움 없이 상대방 Peer를 발견할 수는 없다. 또한 더 나아가 WebRTC를 통해 통신하고자 하는 유저의 숫자가 많아진다면 서버의 도움 영역은 더욱 커질 것이다. 하지만 그럼에도 최대한 서버를 거치지 않고 P2P연결로 바로 브라우저간에 데이터를 주고받는다면 속도가 굉장히 빠르다는 장점이 있다. 

 

2. 웹 실시간 통신 기술에는 여러가지가 있는데 그 중에서도 webRTC를 선택한 이유

이번 프로젝트에서 구현하고 싶었던 기능은 방탈출게임에서의 보이스 채팅 기능입니다. 방탈출 게임에서 시간은 굉장히 중요한 요소이므로 보이스 채팅 기능은 최대한 실시간으로 이뤄져야한다는 특징이 있습니다. 또한 보이스 채팅은 양방향 통신이 가능해야 합니다. 이 상황을 고려하고 한번 분석해봅시다.  

 

웹 실시간 통신 기술에는 여러가지가 있는데 대표적으로 Polling과 Long Polling, SSE, WebSocket, WebRTC 등이 존재한다. 

 

2-1. Polling

Polling은 클라이언트가 일정 시간주기로 계속해서 request를 보내는 방법을 의미한다. 이 방법은 기본적으로 HTTP 통신 특성상 양방향 통신이 아닌 단방향 통신(클라이언트가 request를 보낼때만 response가 가능)이기 때문에 보이스 채팅을 구현하기에 적합하지 않습니다.  또한 결정적으로 HTTP 통신은 기본적으로 비연결성 특성을 가집니다. 즉, request와 response가 이뤄지는 순간 연결은 끊어집니다. 이런 특성들로 인해 게임을 진행하는 동안 보이스 채팅을 실시간으로 구현하는 것은 불가능하다 판단했습니다. 성능면에서도 HTTP 오버헤드(올바른 대상에 도달하기 위해 전송중인 데이터에 추가로 보내지는 정보)가 상당히 증가한다는 단점이 있어서 선택에서 제외했습니다.

 

2-2. Long Polling

Long Polling은 Polling처럼 클라이언트가 무한히 request를 보내지만 일반 polling은 주기적으로 물어본다면, long polling은 일단 보내고 time out될 때까지 무한정 기다린다는 것이다. 만약 time out이 된다면 클라이언트는 바로 다시 request를 보내게 된다. 반대로 서버가 time out 전에 response를 보낸다면 바로 클라이언트에게 전달되고 클라이언트는 정보를 받고 바로 다시 서버에 request를 보낸다. 이를 통해 client는 마치 실시간으로 데이터를 받는 느낌을 받게 된다. 하지만 Polling에서 개선했다고 하더라도 HTTP의 단방향 통신, 비연결성이라는 한계상 여전히 보이스 채팅을 실시간으로 구현하는 것은 불가능하다 판단했습니다.

 

2-3. Streaming

Polling과 Long Polling 방식은 항상 커넥션을 걸었다 끊었다 하는 방식이었다면, 이 Streaming 방식은 이벤트가 발생했을 때 응답을 내려주는데, 응답을 완료 시키지 않고 계속 연결을 유지하는 방식이라고 말할 수 있다. 즉, 클라이언트는 서버에 request를 보낸 뒤에 계속 response를 받는다. 그리고 서버는 이벤트가 발생하면 응답을 보낸다. Streaming으로 비연결성은 해결이 가능할지라도 HTTP의 단방향 통신 문제(서버에서만 클라이언트로 전송 가능)는 여전하기 때문에 보이스 채팅을 실시간으로 구현하는 것은 불가능하다 판단했습니다.

 

 

2-4. WebSocket

WebSocket은 HTTP 통신이 아니다. 마찬가지로 TCP위에서 동작하지만, WS 혹은 WSS라는 통신 프로토콜로 양방향 통신과 영구성을 지닌다. 따라서 HTTP와 달리 STATEFUL하며 Polling처럼 주기적으로 요청을 받을 필요없다. WebSocket만으로도 충분히 보이스 채팅을 구현할 수 있음에도 선택하지 않았던 이유는 최대한 실시간으로 구현하기 위해서 입니다. 실시간으로 구현하는데 있어서 서버를 거쳐 유저간에 데이터를 주고 받는 WebSocket보다 직접적으로 P2P로 데이터를 주고 받을 수 있는 WebRTC가 훨씬 전송속도가 빨라 이번 프로젝트 성격에 잘 맞는다고 생각했습니다. 

 

3. WebRTC구현을 위한 서버 역할

3-1. Signaling Server

기본적으로 WebRTC를 통해서 P2P로 데이터를 주고 받고 싶더라도 서로 상대방에 대한 정보가 없다면 데이터를 주고 받는 것이 불가능하다. 그런 부분을 극복하기 위해서 존재하는 것이 Signaling Server이다. Signaling Server는 사용자 간의 WebRTC를 위한 P2P 통신을 할 때 상대방의 정보를 알려준다. 여기서 말하는 정보란 SDP, ICE Candidate를 의미한다.

 

SDP는 WebRTC에서 스트리밍 미디어의 해상도나 형식, 코덱(인코딩, 디코딩 프로그램) 등의 멀티미디어 컨텐츠의 초기 인수를 설명하기 위한 프로토콜이다. 비디오의 해상도, 오디오 전송 또는 수신 여부 등을 송수신 할 수 있다.

 

구체적으로 아래와 같은 방식으로 SDP 정보를 주고 받는다.

 

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에 등록한다.

 

 

ICE Candidate이란 연결 가능한 네트워크 주소들을 의미한다. ICE는 (2개의) peer끼리 P2P 연결을 가능하게 하도록 최적 경로를 찾아주는 프레임워크다.

 

  • 장점
    • 서버의 부하가 적기 때문에 서버 자원이 적게 든다.
    • peer간의 직접 연결로 데이터를 송수신하기 때문에 실시간 성이 보장된다(서버를 거치지 않는다).
  • 단점
    • N:N 혹은 N:M 연결에서 클라이언트(브라우저)의 과부하가 급격하게 증가한다. 왜냐하면 기본적으로 P2P 연결이기 때문에 연결하는 유저수만큼 클라이언트는 연결을 계속 유지하고 있어야 하기 때문에 기하급수적으로 증가한다고 볼 수 있다.

 

3-2. SFU(Selective Forwarding Unit)서버

종단 간 미디어 트래픽을 중계하는 중앙 서버 방식이다. 클라이언트 peer간 연결이 아닌, 서버와 클라이언트 간의 peer를 연결한다. 클라이언트는 연결된 모든 사용자에게 데이터를 보낼 필요없이 서버에게만 자신의 영상 데이터를 보내면 된다.(즉, Uplink가 1개다). 하지만 상대방의 수만큼 데이터를 받는 peer를 유지해야한다.(Downlink는 P2P(Signaling서버)일 때와 동일하다.)

 

장점

  • 데이터가 서버를 거치고 Signaling 서버(P2P 방식)를 사용할 때 보다 느리긴하지만 비슷한 수준의 실시간성을 유지할 수 있다.
  • Signaling 서버를 사용하는 것보다 클라이언트가 받는 부하가 줄어든다.

 단점

  • Signaling 서버보다 서버 비용이 증가한다.
  • 대규모 N:M 구조에서는 여전히 클라이언트가 많은 부하를 감당한다. 데이터를 보낼때는 한번만 보내면 되지만 데이터를 받기 위해서는 연결되는 Peer들과의 연결을 계속 유지하고 있어야하기 때문이다.

3-3. MCU(Multi-point Control Unit)서버

다수의 송출 미디어를 중앙 서버에서 혼합(muxing) 또는 가공(transcoding)하여 수신측으로 전달하는 중앙 서버 방식이다.

  • 클라이언트 peer간 연결이 아닌, 서버와 클라이언트 간의 peer를 연결한다.
  • 모든 연결 형식에서 클라이언트는 연결된 모든 사용자에게 데이터를 보낼 필요없이 서버에게만 자신의 영상 데이터를 보내면 된다.(즉, Uplink가 1개다.)
  • 모든 연결 형식에서 클라이언트는 연결된 사용자의 수와 상관없이 서버에게서 하나의 peer로 데이터를 받으면 된다.(즉, Downlink가 1개다.)
  • 중앙 서버의 높은 컴퓨팅 파워가 요구된다.

 

  • 장점
    • 클라이언트의 부하가 현저히 줄어든다.(항상 Uplink 1개, Downlink 1개로 총 2개)
  • 단점
    • WebRTC의 최대 장점인 실시간성이 저해된다.
    • video, audio를 결합하는 과정에서 비용이 많이 든다.

 

4. 왜 이번 프로젝트에서 WebRTC의 Signaling Server(P2P 방식)를 선택하였는가?

 

이번 프로젝트에서 WebRTC를 통해 구현하고 싶었던 기능은 방탈출게임에서의 보이스 채팅 기능이었습니다. 방탈출 게임 특성상 빠른 시간내에 소통하는 것은 중요하였고 이를 위해서는 Signaling Server를 활용한 P2P방식이 좋다고 생각했습니다. 물론 다대다 연결에서는 클라이언트 부담이 급격하게 증가할 수 있으나 프로젝트에서 연결되는 Peer 숫자는 3명(총 4명이 한방에 들어가는 방식)이었기 때문에 클라이언트가 어느정도 감당할 수 있는 범위라고 판단했습니다. 실제로 P2P방식으로 4명이서 실시간 보이스 채팅을 실시했을 때 문제없이 잘 돌아갔습니다.

 

5. Socket.IO란? Sock.JS란? Socket.IO를 사용한 이유

위에서 언급한 WebRTC의 Signaling Server기능을 구현하기 위해서는 서버와 클라이언트간의 양방향 통신을 구현할 필요가 있다. 이 과정에서 Socket.IO와 Sock.JS를 고려하게 되었다.

 

직접 웹소켓을 통해 양방향 통신을 시도할 수 있지만 Socket.IO나 Sock.JS를 사용한다면 간단하게 검증된 양방향 통신을 쉽게 구현할 수 있다. 또한 결정적으로 웹 서비스를 제공하는 입장에서 webSocket을 지원하지 않는 클라이언트가 존재할 수도 있기 때문에 Socket.IO나 Sock.JS를 사용하는 것이 안정적인 서비스를 제공할 수 있다는 강점이 존재한다. WebSocket방식이 사용 불가할 경우 Socket.IO나 Sock.JS는 Long Polling 방식이나 Streaming방식들을 사용하여 양방향 통신을 시도하기 때문이다.

 

Socket.IO는 실시간, 양방향, 이벤트 기반 통신을 제공하는 프레임워크이다. Socket.IO는 실시간 통신을 위한 여러 선택지들 중에서 기본적으로 WebSocket을 이용하여 통신을 한다. 기본적으로 그렇다는 것이기 때문에 만일 WebSocket이 사용 불가능한 상황이라면 Http Long Polling방식을 사용한다. 

 

Sock.JS도 Socket.IO처럼 WebSocket을 이용하여 실시간 통신을 가능하게 해주는 자바 스크립트 라이브러리이다. WebSocket 사용이 불가능할 경우 http long Polling 및 http Streaming사용한다.

 

그렇다면 Socket.IO와 Sock.JS는 어떤 차이점을 가지고 있을까?

 

첫째로 Sock.JS가 Socket.IO보다 더 많은 브라우저에서 지원이 가능하다. 사실상 거의 모든 브라우저에서 지원이 가능하다고 한다. 그렇다고 Sock.JS가 너무 적은 브라우저에 대해서 지원한다는 것은 아니다. 정확히 직접 사이트에서 비교해본 결과 Opera와 Konqueror 이 두 브라우저에 대해서 Sock.JS는 지원해주고 있지만 Socket.IO는 지원해주지 않고 있다

 

둘째로 Socket.IO에서 지원하지 않는 http Streaming을 지원해준다

 

셋째로 Sock.JS가 Socket.IO에 비해 테스트가 많이 이뤄져 안정성 측면에서 더 좋다고 한다. 그렇다고 Socket.IO가 안정성과 신뢰성이 떨어진다는 얘기가 아니다. 실제로 안정성과 신뢰성이 굉장히 중요한 카지노 사이트들에서도 Socket.IO를 사용하고 있다. 모든것은 상대적으로 비교했을 때의 얘기이다.

 

넷째로 Socket.IO에서는 지원하는 이벤트들을 Sock.JS에서는 지원하지 않기 때문에 Sock.JS를 사용시에 직접 구현해야한다.

 

이번 프로젝트에서는 Sock.JS가 아닌 Socket.IO를 사용하였는데 그 이유는 다음과 같다.

 

첫째로 프로젝트 당시 이미 처음 다루는 WebRTC 관련해서 많은 시간을 소비했다는 점이다. 프로젝트를 기한내에 완성하는 것이 가장 중요하기 때문에 WebRTC에 더이상 시간을 쏟아서는 안된다고 판단했다. 따라서 기존에 다양한 이벤트를 자체적으로 지원하는 Socket.IO를 사용하는 것이 이벤트를 직접 구현해야하는 Sock.JS보다 상황에 적합하다고 판단했다.

둘째로 우리 프로젝트의 서비스 규모가 작다는 점이다. 규모가 작기 때문에 안정성보다는 빠르게 기능을 구현하는 것이 더 중요하다고 판단했다. 상대적으로 안정성이 떨어질 수는 있지만 Socket.IO도 충분히 많은 카지노 사이트들에서 사용될 만큼 안정성은 검증되었다고 생각했다.

셋째로 Socket.IO보다 Sock.JS가 S더 많은 브라우저에서 지원이 가능하다. 근데 우리나라에서 Opera와 Konqueror를 사용하는 사람이 얼마나 될지 의문이였고 사실상 그 소수의 사람들이 우리 웹 서비스를 사용할 가능성은 극히 낮다고 봤기 때문에 상대적으로 Sock.JS를 사용해야할 이점이 줄어들었다.

 

 

6. Socket.IO를 Spring 서버가 아닌 별도의 Node.JS 서버에서 구현한 이유

Socket.IO는 JavaScript 클라이언트가 기본이기 때문에 자바와 같은 다른 언어에 대한 지원이 상대적으로 미약하다. 실제로 자료를 찾아보면 압도적으로 Node.JS에서 Socket.IO를 구현하는 방법이 많이 나타난다. 또한 WebRTC는 기본적으로 프론트와 백이 함께 호흡을 맞춰야 하는 부분이다. 특히 우리 프로젝트에서는 P2P 방식을 선택했기 때문에 서버가 해줄 역할은 Signaling Server 정도이고 프론트에서 해야하는 부분이 많다. 우리 프로젝트에서는 프론트가 React기반으로 작성되기 때문에 클라이언트와 서버가 호흡을 맞추는데 있어서도 Node.JS를 기반으로 Socket.IO를 구현하는 것이 유리할 것으로 판단했다.

 

 

7. 코드리뷰

const app = require("./app");
const https = require("https")
const fs = require("fs");

// https 적용을 위해 ec2의 인증서 경로 입력
var privateKey = fs.readFileSync("/etc/letsencrypt/live/roomescape57.shop/privkey.pem")
var certificate = fs.readFileSync("/etc/letsencrypt/live/roomescape57.shop/cert.pem")
var ca = fs.readFileSync("/etc/letsencrypt/live/roomescape57.shop/chain.pem")
const credentials = { key: privateKey, cert: certificate, ca: ca }

// mysql 연결
const config = require('./config/config.json');
const mysql = require('mysql2');

const connection = mysql.createConnection({
  host : config.development.host,
  user : config.development.username,
  password : config.development.password,
  database : config.development.database
});

const server = https.createServer(credentials, app).listen(3000)

const io = require("socket.io")(server, {
  cors: {
    origin: ["http://localhost:3000", "https://zzz-escape.netlify.app"],
    credentials: true,
  },
});

let users = {};
let socketToRoom = {};
const maximum = process.env.MAXIMUM || 4;

// socket 연결
io.on('connection', socket => {

  // 방 참여하기  
  socket.on('join_room', data => {
      // 방이 있다면
      if (users[data.room]) {
          const length = users[data.room].length;
          // 방에 인원이 다 차면 room_full 이벤트를 emit
          if (length === maximum) {
              socket.to(socket.id).emit('room_full');
              return;
          }
          // 방에 자리가 있으면 그 방 users에 해당 socket.id를 넣어줌
          users[data.room].push({id: socket.id});
      // 방이 없으면 새로 만들어서 그 방 users 해당 socket.id를 넣어줌    
      } else {
          users[data.room] = [{id: socket.id}];
      }
      // 해당 socket.id의 socketToRoom은 client에서 전달받은 roomId로 저장
      socketToRoom[socket.id] = data.room;

      // 해당 socket은 roomId에 참여시킴
      socket.join(data.room);
      console.log(`[${socketToRoom[socket.id]}]: ${socket.id} enter`);

      // 해당 방에 있는 users를 연결된 브라우저를 제외하고 usersInThisRoom에 저장
      const usersInThisRoom = users[data.room].filter(user => user.id !== socket.id);
      console.log(usersInThisRoom);

      io.sockets.to(socket.id).emit('all_users', usersInThisRoom);
  });

    // WebRTC를 위한 signalling server 역할
    socket.on('offer', data => {
        socket.to(data.offerReceiveID).emit('getOffer', {sdp: data.sdp, offerSendID: data.offerSendID});
    });
    // WebRTC를 위한 signalling server 역할
    socket.on('answer', data => {
        socket.to(data.answerReceiveID).emit('getAnswer', {sdp: data.sdp, answerSendID: data.answerSendID});
    });
    // WebRTC를 위한 signalling server 역할
    socket.on('candidate', data => {
        socket.to(data.candidateReceiveID).emit('getCandidate', {candidate: data.candidate, candidateSendID: data.candidateSendID});
    })

    // 게임 시작하기
    socket.on('loading', () => {
        const roomID = socketToRoom[socket.id];
        console.log(roomID, '에서 game loading을 시작했습니다!!');
        // 해당 방에 알려줌
        io.to(roomID).emit('loadingComplete', 'loading complete');
    })

    // 맞춘 문제 수 올리기
    socket.on('count', data => {
        const roomID = socketToRoom[socket.id];
        console.log(roomID, '에서 문제를 맞췄습니다!!')
        // 해당 방에 알려줌
        io.to(roomID).emit('countPlus', data);
    })

    // 찬스 사용하기
    socket.on('chance', () => {
        const roomID = socketToRoom[socket.id];
        console.log(roomID, '에서 chance를 사용했습니다!!')
        // 해당 방에 알려줌
        io.to(roomID).emit('chanceMinus', 'countMinus!!!!');
    })

    // 유저가 브라우저를 종료했을 때
    socket.on('disconnect', () => {
        console.log(`[${socketToRoom[socket.id]}]: ${socket.id} exit`);
        const roomID = socketToRoom[socket.id];
        let room = users[roomID];
        if (room) {
            room = room.filter(user => user.id !== socket.id);
            users[roomID] = room;
            // 방에 혼자 있을 때
            if (room.length === 0) {
                console.log('마지막 유저가 나갑니다.')
                delete users[roomID];
                connection.connect(function(err) {
                    if (err) {
                        throw err;
                    } else {
                        // 해당 방 user 삭제
                        connection.query(`DELETE FROM user WHERE room_id = ${roomID}`, function(err, rows, fields) {
                            console.log(`delete user in ${roomID} success`);
                        })
                        // 해당 방 clue 삭제
                        connection.query(`DELETE FROM clue WHERE room_id = ${roomID}`, function(err, rows, fields) {
                            console.log(`delete clue in ${roomID} success`);
                        })
                        // 해당 방 state를 CLOSE(1)로 변경
                        connection.query(`UPDATE room SET state = 1 WHERE room_id = ${roomID}`, function(err, rows, fields) {
                            console.log(`update ${roomID} success`);
                        })
                    }
                });
            // 방에 여러명 있을 때
            } else {
                console.log('유저 중 한명이 나갑니다.')
                connection.connect(function(err) {
                    if (err) {
                        throw err;
                    } else {
                        connection.query(`SELECT created_user FROM room WHERE room_id = ${roomID}`,
                        function(err, rows, fields) {
                            connection.query(`DELETE FROM user WHERE user_id = '${socket.id}'`, function(err, rows, fields) {
                                console.log(`delete ${socket.id} success`);
                            });
                            const createdUser = rows
                            console.log(createdUser)
                            // undefined면 return
                            if (!createdUser[0]?.created_user) return;
                            // 방장이 나갔을 때
                            if (socket.id === createdUser[0].created_user) {
                                console.log('방장 나갔을 때')
                                // 해당 방의 user들을 다 찾아옴
                                connection.query(`Select user_id From user WHERE room_id = ${roomID}`,
                                function(err, rows, fields) {
                                    console.log('userList: ', rows)
                                    // undefined면 return
                                    if (!rows[0]?.user_id) return;
                                    // 해당 방 userList의 0번째를 새로운 방장으로 지정
                                    let newCreatedUser = rows[0].user_id;
                                    console.log('새로운 방장 : ', newCreatedUser);
                                    // 해당 방에 변경된 방장을 알려줌
                                    io.to(roomID).emit('changedUser', {createdUser: newCreatedUser});
                                    // 해당 방 created_user에 새로운 방장 정보 업데이트
                                    connection.query(`UPDATE room SET created_user = '${newCreatedUser}' WHERE room_id = ${roomID}`,
                                    function(err, rows, fields) {
                                    console.log(`createdUser in ${roomID} change success`);
                                    });
                                });
                                
                            }
                        })
                    }
                })
            }
        }
        // 해당 방에 나간 유저 정보를 알려줌
        socket.to(roomID).emit('user_exit', {id: socket.id});
        console.log('현재 연결된 모든 user: ', users);    
    });
  
});

구체적으로 참고하고 싶으면 https://github.com/HangHae99Zzz/RoomEscape_BE-nodeJS 참고하세요.

 

8. 참고

https://socket.io/docs/v4/client-installation/

https://www.quora.com/Sock-js-What-are-the-pros-and-cons-of-socket-io-vs-sockjs

https://github.com/sockjs/sockjs-client

http://kwseo.github.io/2017/03/25/sse/

https://ws-pace.tistory.com/104

https://velog.io/@jennyfromdeblock/%E3%85%87

https://fgh0296.tistory.com/24#:~:text=%EC%A7%81%EC%A0%91%20sockJS%EC%9D%98%20%EA%B9%83%ED%97%99%20%EC%A3%BC%EC%86%8C,%EC%A7%80%EC%9B%90%ED%95%98%EB%8A%94%20API%EB%9D%BC%EB%8A%94%20%EA%B2%83%EC%9D%B4%EB%8B%A4.

 

'' 카테고리의 다른 글

웹이란? 웹 작동과정? WS vs WAS  (0) 2022.06.15
무중단 배포란?  (0) 2022.06.04
CORS란 무엇인가? SOP란 무엇인가?  (0) 2022.02.13
http와 https란?  (0) 2022.01.20
API 이해하기  (0) 2022.01.16