[개발] WebRTC & SpringBoot & Vue.js를 활용한 Group Video Call 2 : 구현

Byuk_mm·2022년 6월 1일
5

WebRTC Development

목록 보기
2/2
post-thumbnail

WebRTC 맛보기 구현 개요


이제서야 쓰는 WebRTC를 활용한 화상 비디오 회의 구현부 포스팅이다.
사실 1편 이론부를 작성하고 구현부도 후다닥 작성하고 싶었으나,, 미루고 미루다 한달이 넘어버렸다. 허허...

해당 포스팅에서 이론 포스팅에서 다뤄봤던 내용을 기반으로 실제로 화상 회의 기능 구현을 해보도록 하겠다. 주된 기술 스택을 정리해보자면 다음과 같다.

Front-End : Vue.js, WebRTC Simple-Peer Library, Stomp
Back-End : Spring boot, WebRTC, Stomp

WebRTC를 구현하는데 보통의 구글링 자료들에서 SocketIONode.js를 많이 사용하는 것도 따로 이유가 있었다. WebRTC는 각각의 클라이언트 사이의 소켓을 통해서 수많은 데이터 통신이 일어나는데, 이때 무거운 Spring boot 보다는 가벼운 Node.js를 쓰는게 낫다는 것이다.
하지만 나는 취업을 위해 K-BackendSpring boot를 공부하는 입장이니 아묻따 Spring boot로 개발을 시작했다.

구현함에 있어서는 나는 Mesh 방식을 선택하여 전형적인 P2P 통신을 구현하고자 했고(Mesh 방식에 대한 소개는 1편 이론 포스팅 참고), 그렇기 때문에 서버 사이드에서는 단순히 소켓 통신 서버를 열어두고 각각 클라이언트 Peer Data를 중개해주기만 하면 됐다(Sinaling Server). 때문에 실제로 서버 사이드의 코드 양은 매우 적다!
Spring boot를 사용했기 때문에 이러한 소켓 통신은 STOMP를 사용했다.

그리고 WebRTC는 그대로 사용하기에는 너무 어렵다ㅠㅡㅠ 물론 WebRTC의 Deep한 부분까지 개발을 할 때 조정해야 했다면, WebRTC를 raw하게 사용했겠지만,, 이번 프로젝트는 화상 회의 구현이 목적이었기 때문에 WebRTC사용을 도와주는 라이브러리를 사용하기로 했다. 이러한 라이브러리는 매우 많았는데 그 중 Simple-Peer 라이브러리를 사용했다. Simple-Peer 라이브러리 깃헙에 예시 코드도 많고 활용법이 가이드가 깔끔하게 작성돼 있어서 참고할만 하다!

또한 정말 많은 도움을 받은 Velog 글유튜브 영상이 있다. 해당 Velog 글에서 쏘스 참고를 정말 많이 했고, 유튜브는 WebRTC 관해서 여러편 강의 영상을 올라와 있는데 참고할 만한 부분이 정말 많다ㅠㅡㅠ. Simple-Peer 라이브러리를 채택한 이유도 해당 유튜브 영상에서 사용했기 때문이다. 나도 해당 유튜브 영상의 코드를 많이 공부해보고 적용시켜 보았다.

Mesh 방식을 활용한다면 1대 1 화상회의를 구현하는 것은 어렵지 않다. 하지만 다대 다 화상회의를 구현하는 것은 또 다른 문제이다. 오늘 포스팅에서 그 부분까지 내가 구현한 코드를 훑어보도록 하겠다.



Client와 Signaling Server 사이 동작 과정


다시금 WebRTC의 과정을 상기해보는 것이 중요하다. Mesh 방식으로 구현된 WebRTCSignaling Server를 통해 클라이언트 Peer 데이터를 전달하고 전달 받으면서 Peer간 연결된다. 아래 동작 과정을 한번더 살펴보면 이해하기 훨씬 쉬울 것이다.


① Client Side의 A Client(Peer)에서 Signaling Server로 연결에 필요한 A Clinet의 데이터를 보낸다.
=> Signaling Offer

② Server Side에서, Signaling Server에 연결된 모든 세션들에게 A Client의 데이터를 전달한다.

③ Client Side의 B Client(Peer)에서 A Client의 데이터를 활용해서 연결에 필요한 일련의 작업을 한 후, B Client의 데이터를 Signaling Server로 보낸다.
=> Signaling Answer

④ Server Side에서, A Client의 세션에게 B Client의 데이터를 전달한다.

각각의 데이터를 활용하여 WebRTC가 A Client와 B Client가 연결한다.



구현


아래로는 Simple-Peer 라이브러리를 통해서 다대다 연결을 구현한 코드이다.
Simple-Peer 라이브러리 깃헙의 2명 이상의 P2P 연결 부분을 참고하면 좋다.

또한 아래 코드들은 이해를 돕기 위한 예시 코드이다!! 전체 코드는 깃헙(FE), 깃헙(BE)에서 확인 가능하나 다른 기능과 처리 코드도 섞여 있기 때문에 참고하기 어려울 수 있다.

해당 코드에서는 예시로 3명의 Client가 연결한다고 가정해보겠다.
아래 코드 플로우 시나리오대로 코드를 구현한다고 보면 된다!!

코드 플로우 시나리오
1. A Client 세션 접속.
2. B Client 세션 접속.
3. B Client 세션이 새로 접속했다는 사실을 A Client에게 소켓을 통해 전달.
4. A ClientB Client에게 Signaling Offer.
5. B ClientA Client에게 Signaling Answer.
6. A ClientB Client 사이에 P2P 연결 성공.

  1. C Client 새로 세션 접속.
  2. C Client가 새로 접속했다는 사실을 A, B Client에게 전달.
  3. A, B ClientC Client에게 Signaling Offer.
  4. C ClientA,B Clinet에게 Signaling Answer.
  5. A, B, C Client와 P2P 연결 성공.

1. Video 태그와 Stream 연결, Socket 설정

  1. 자신의 video 태그와 video stream을 연결시키고 저장해놓는다.
  2. 새로운 세션에 접속했을때를 listen하는 소켓 연결.
  3. 자신이 접속할 때 소켓 send.
<!--Vue.js Html-->


<!--예시로 3개의 video 태그-->

<video ref="aaa_video" autoplay/>

<video ref="bbb_video" autoplay/>

<video ref="ccc_video3" autoplay/>

// Vue.js Js

import Peer from "simple-peer";
import SockJS from "sockjs-client";
import Stomp from "webstomp-client";

let socket;
let stomp;

data() {
  
  	// 접속한 id
    myId: "",
      
    // caller의 stream 저장
    callerStream: "",
      
    // 자신과 연결된 세션 peer를 저장
    peers: []
  }
},

//... 중략

methods: {

  // 자신의 video 태그 연결 & stream 저장하는 메소드
  // mounted() 에서 사용
  async userSet() {

    await navigator.mediaDevices
      .getUserMedia({
      video: true,
      audio: true,
    })
      .then((stream) => {

      // stream 추출
      let videoStream = new MediaStream(stream.getVideoTracks());
      this.callerStream = stream;

      // video 태그와 연결
      this.$refs[this.myId + "_video"].srcObject = videoStream;

      // 소켓 통신 연결 메소드
      // 아래 기술.
      this.connect();
    });
  },
    
  // ... 중략
    
  connect() {

    // Stomp 소켓 통신 선언부
    socket = new SockJS(Constants.API_URL + "/socket");
    stomp = Stomp.over(socket);

    // subscribe&pub 정의
    stomp.connect(
      {},
      
      // connectCallback
      () => {

        // 누군가 join 했을때 listen, 접속해 있는 전체 세션 리스트를 받는다.
        stomp.subscribe("/sub/video/joined-room-info", (data) => {

          // 접속해 있는 전체 세션 리스트
          let users = JSON.parse(data.body);

          // 마지막으로 접속한 user
          let topIdx = users.length - 1;
          let joinedID = users[topIdx].id;

          // 인원이 한명 이하거나, 자신이 join 일경우는 return
          if (topIdx <= 0 || users[topIdx].id === this.myId) return;

          // 아래 기술
          // 자신이 접속해 있는 상태에서, 새로운 클라이언트가 접속한 경우,
          // 해당 클라이언트와 연결하기 위한 메소드
          this.initCall(joinedID);
        });

        // 자신이 접속했다는 socket send
        stomp.send(
          "/pub/video/joined-room-info",
          JSON.stringify({from: this.myId})
        );

        // ... 중략

      },

      // onErrorCallback
      () => {
        console.log("ws error");
      });
	},

  	// ... 중략
},

// Spring boot

public class VideoRoomController {

    // 테스트용 세션 리스트.
    private final ArrayList<TestSession> sessionIdList;
    private final SimpMessagingTemplate template;

    // 실시간으로 들어온 세션 감지하여 전체 세션 리스트 반환
    @MessageMapping("/video/joined-room-info")
    @SendTo("/sub/video/joined-room-info")
    private ArrayList<TestSession> joinRoom(@Header("simpSessionId") String sessionId, JSONObject ob) {

        // 현재 들어온 세션 저장.
        sessionIdList.add(new TestSession((String) ob.get("from"), sessionId));

        return sessionIdList;
    }
    
    // ... 중략
}

2. 이미 접속해 있던 클라이언트가 새로 들어온 클라이언트에게 Signaling Offer

  1. 자신이 첫 접속일 경우 위 코드에 의해 return 됨.
  2. 자신이 접속해 있는 상황에서 새로운 클라이언트가 접속할 경우 initCall 메소드 실행. => Signaling Offer
  3. 이미 접속해 있던 클라이언트의 세션이 Caller가 되고,
    새로 들어온 클라이언트의 세션Callee가 된다.
// Vue.js Js

// ... 중략(in method)

// 새로운 client가 접속했을 때, 해당 클라이언트와 연결할 Peer을 생성
initCall(joinedID) {
  
  // peer 생성
  // sinmple peer 라이브러리
  const peer = new Peer({
    initiator: true,
    trickle: false,
    stream: this.callerStream,
  });
  
  // caller의 signaling data를 새로 들어온 클라이언트에 send
  peer.on("signal", (data) => {
    
    // calling을 시작한 클라이언트(caller)의 singal 데이터 socket send
    stomp.send(
      "/pub/video/caller-info",
      JSON.stringify({
        toCall: joinedID,
        from: this.myId,
        signal: data,
      })
    );
  });

  // 새로 들어온 클라이언트 video 연결
  peer.on("stream", (stream) => {
    this.$refs[joinedID + "_video"].srcObject = stream;
  });

  peer.on("error", (stream) => {
    console.log("error");
  });

  // peer 저장.
  this.peers.push([peer, this.myId, joinedID]);
},
  
// ... 중략

// Spring boot

public class VideoRoomController {

	// ... 중략
	
    // caller의 데이터를 그대로 전달.
    @MessageMapping("/video/caller-info")
    @SendTo("/sub/video/caller-info")
    private Map<String, Object> caller(JSONObject ob) {
        return ob;
    }
}

3. Caller에게 온 Signaling Data를 확인한 후 Caller에게 다시 Signaling Answer

  1. calleecaller에게 온 Signaling data를 이용하여 촤종 Signaling => Peer to Peer 연결

// Vue.js Js

// ... 중략(in method)

// connect 메소드 추가 구현
connect() {
	// .. 중략
  stomp.connect(
    {},
    () => {
      
      //.. 중략
      
      // caller의 info를 담은 socket lieten,
      stomp.subscribe("/sub/video/caller-info", (data) => {
        data = JSON.parse(data.body);

        // 나에게서 오거나(from me) 혹은 나에게 온(to me)이 아니면 return
        if (data.from === this.myId || data.toCall !== this.myId) return;
		
        // 아래 구현
        // callig을 받은 시점에, return call을 보내 signaling한다.
        // caller의 데이터를 받고, 내(callee)의 데이터를 보내는 과정
        this.returnCall(data.signal, data.from);
      });
    });
},
  
// caller에게 요청을 받은 상태에서 callee의 signal data return
returnCall(callerSignal, callerId) {
   
  // callee의 peer 생성
  const peer = new Peer({
    initiator: false,
    trickle: false,
    stream: this.callerStream,
  });

  // callee의 정보를 caller에게 보냄.
  peer.on("signal", (data) => {
    stomp.send(
      "/pub/video/callee-info",
      JSON.stringify({
        from: this.myId,
        to: callerId,
        signal: data,
      })
    );
  });

  // caller 의 비디오 연결
  peer.on("stream", (stream) => {
    this.$refs[callerId + "_video"].srcObject = stream;
  });

  peer.on("error", (stream) => {
    console.log("error");
  });

  // callee와 caller의 연결. => Signaling
  // 이 시점에서 연결된다고 볼 수 있다.
  peer.signal(callerSignal);
	
  // 연결된 peer 리스트에 push
  this.peers.push([peer, this.myId, callerId]);
},
    
      

// Spring boot

public class VideoRoomController {

	// ... 중략
	
    // callee의 데이터를 그대로 전달.
    @MessageMapping("/video/callee-info")
    @SendTo("/sub/video/callee-info")
    private Map<String, Object> answerCall(JSONObject ob) {

        return ob;
    }
}


결론


처음 구현할 때, 많이 돌아서 돌아서 코드를 치게 됐는데,, 막상 다 치고 나니까 정말 간단한 구조이다. Singnaling Server는 단순히 클라이언트끼리의 Signal Data를 중개해주는 역할만을 하며 직접적인 연결은 클라이언트 사이드에서 이루어진다.

그렇기 때문에 해당 방식(Mesh 구조의 방식)은 1대 1 연결에 적합한 구조이다. 실제로도 AWS 프리티어에 올려놓고 테스팅 해보니까 4명만 접속해도 매우 버벅이는 것을 확인할 수 있었다.. 해당 문제는 프리티어 클라우드 서버의 낮은 퍼포먼스 때문일수도 있고 내 프론트 단의 코드 구조가 문제일 수도 있다.

그럼에도 불구하고 Simple-Peer라는 라이브러리를 활용해서 비교적 간단하게 구현할 수 있었던 것 같았고 서칭하다가 매우 많은 WebRTC 사용에 도움을 주는 라이브러리와 오픈 소스들을 발견할 수 있었다.

AWS Kinesis, Open Vidu, Jitsi 정도를 발견했는데, WebRTC를 실제 프로덕트 수준으로 개발하려면 해당 기술을 찾아보는 것을 추천한다.

또한 위의 코드에서 STUN ServerTURN Server에 대한 내용은 빠졌는데, Simle-Peer 라이브러리에서는 해당 서버들이 구현만 돼있다면(구글 스턴 서버처럼 오픈된 서버를 사용한다면) 코드 한 줄로 적용할 수 있다. 해당 내용은 라리브러리 깃헙 레포를 확인해보면 예제가 있다.

아무쪼록 위의 코드는 전문은 깃헙(FE), 깃헙(BE) 에서 확인할 수 있긴한데,, 위의 코드에 비해 다른 기능들을 넣어놓은 것들이 많아서 참고하기 힘들 수도 있다.. 허허..

관련 프로젝트에 관심이 생겨서 WebRTC에 대해 여러가지로 알아봤는데,, 상용화할 정도로 개발하는 것은 실제로 정말 어렵다고 한다.
나도 어느정도 예외 대충 잡고 "이정도면 대충 돌아가겠지~"하고 서버 올려서 돌려보니까 어마어마하게 예민한(?) 화상 회의 기능이 만들어졌다.. 예민한다는 것은 뭐만하면 팅기고 갑자기 안되고 난리가 나는..

아무쪼록 나는 처음 시작할 때 방향 잡기가 너무 힘들어서 해당 포스팅을 작성해야겠다고 맘 먹었는데,,, 나와 똑같은 상황에 놓여있는 분들에게 조금이라도 참고가 되면 좋겠다!



참고

https://velog.io/@thms200/WebRTC-%ED%99%94%EC%83%81-%EC%97%B0%EA%B2%B0-%ED%95%98%EA%B8%B0

https://www.youtube.com/watch?v=R1sfHPwEH7A&list=PLK0STOMCFms4nXm1bRUdjhPg0coxI2U6h&index=

profile
어디야 벽벽 / 블로그 이전 -> byuk.dev

5개의 댓글

comment-user-thumbnail
2022년 6월 27일

좋은 글 감사합니다. :)

답글 달기
comment-user-thumbnail
2023년 1월 18일

잘읽고갑니다:)

답글 달기
comment-user-thumbnail
2023년 2월 13일

덕분에 좋은 내용 잘 보고 갑니다.
정말 감사합니다.

답글 달기
comment-user-thumbnail
2024년 4월 7일

안녕하세요
좋은 글 감사합니다 :)
몇 가지 궁금한 점이 있어 댓글 남기게 되었습니다.

  • '/video/audio-sentiment' 해당 API가 어떤 역할을 하며 Constants.ML_API_URL 가 어떤 API인지 알 수 있을까요?
  • 해당 글에서 프론트 깃허브 링크를 누르면 존재하지 않는 링크라고 뜨는데 혹시 프론트 코드를 얻을 수 있을까요?

댓글 읽어주셔서 감사하며 다시 한 번 좋은 글 작성해주셔서 감사합니다!

1개의 답글