WebRTC 화상회의 서버 구축

  1. 소개하기 전…
  2. WebRTC 기본 정보
    1. WebRTC 란?
    2. WebRTC 종류
    3. WebRTC 서버 구축을 위해 필요한 4가지
  3. 테스트 프로젝트
    1. spring boot 시그널링 서버
    2. 프론트 코드 ( html + javascript )
    3. flutter 예제

소개하기 전..

이번에 프로젝트에서 webRTC 사용하게 되었습니다. 사용 이유는 CCTV 를 웹과 flutter 앱 화면에 보기 위해서 입니다.

저는 WebRTC를 한번도 사용한 적이 없기에, 이곳저곳 정보를 얻어 테스트용 프로젝트를 만들어서 테스트에 성공하였습니다.

이글은 WebRTC에 대해 조사했던 정보와 테스트 프로젝트 코드 및 사용방법을 적어놨습니다.



WebRTC 기본 정보

WebRTC 란?

WebRTC는 웹 기반 실시간 음성, 영상 통신 기술입니다. 이 기술은 Google에서 개발되었으며, 브라우저 상에서 플러그인 없이 음성이나 영상을 전송할 수 있게 해줍니다.

WebRTC는 Peer-to-Peer 기술을 사용하여, 서버를 거치지 않고 브라우저 간에 직접적인 연결을 가능하게 합니다. 이를 통해 더 빠르고 안정적인 통신이 가능해졌습니다.

WebRTC는 다양한 분야에서 사용됩니다. 예를 들어, 비디오 채팅, 화상 회의, 온라인 교육, 의료 분야에서의 원격 진료 등에 사용됩니다.

WebRTC의 구성 요소는 크게 세 가지로 나눌 수 있습니다. 미디어 스트림, 시그널링, NAT 트래버설입니다.

  • 미디어 스트림 : 오디오, 비디오 데이터를 전송하는 역할을 합니다.
  • 시그널링 : P2P 연결을 위한 정보 교환 역할을 합니다.
  • NAT 트래버설 : 브라우저가 서로 다른 네트워크에 있을 때, 네트워크 간의 연결을 가능하게 합니다.

WebRTC는 오픈소스 기술이며, Chrome, Firefox, Opera 등의 주요 브라우저에서 지원됩니다.



WebRTC 종류

Untitled

Mesh

  • 미디어 정보를 직접 peer끼리 connection을 맺어 주고 받는다.
  • peer끼리 connection을 맺기 위한 시그널 정보를 주고 받기 위한 시그널 서버 필요
  • 시그널링 서버 구축을 위해 websocket, socket.io와 같은 양방향 통신을 지원하는 기술 사용
  • peer에 직접 미디어 정보를 주고 받기 때문에 peer(클라이언트)에 부하가 생김
  • 1:1 구조에 적합

SFU

  • peer의 부하는 mesh에 비해서 줄어들지만 그만큼 서버의 부하가 있다.
  • 1개의 upstream과 n개의 downstream을 갖는 구조
  • 미디어 서버가 필요
  • 대규모 연결에 적합

MCU

  • peer의 부하가 가장 적다.
  • 서버의 부하는 가장 크다.
  • 1개의 upstream과 1개의 downstream을 갖는 구조
  • 서버에서 peer의 스트림을 모아 인코딩, 디코딩을 하기 때문에 서버에 큰 compute power가 필요
  • 미디어 서버가 필요

해당 테스트 프로젝트는 Mesh 형식의 WebRTC를 사용하였습니다.



WebRTC 서버 구축을 위해 필요한 4가지

1. 시그널링 서버 ( 만나서 무엇을 할지 ) 약속 잡기 서버

  • 시그널링 서버는 약속을 잡기 위해서 sdp 프로토콜과 ice 프로토콜을 사용
  • sdp와 ice를 서로 주고 받으며 양방향 통신을 해야하기 때문에 socket 기술을 사용

2. stun 서버 ( 어디서 만날지 ) 약속 자기 서버 2

  • ip를 알아봐주는 서버

Untitled

3. turn 서버 ( 우리만의 비밀 장소)

  • nat (public ip를 private ip와 매칭 시켜주는곳)
  • nat의 종류 또는 설정에 따라 접근을 허용하지 않을때도 존재 → 우회 필요
  • 우회를 해주는게 turn 서버
  • 구글의 coturn 사용

4. 미디어 서버

  • mesh를 개발할땐 필요 없음
  • media 정보를 주고 받아야 하기 때문에 많은 트래픽 비용 발생
  • 인코딩과 디코딩 필요


시그널링 서버

시그널링 서버는 WebRTC 기반 애플리케이션에서 클라이언트 간의 연결 설정 및 메타데이터 교환을 담당하는 중개서버 입니다. WebRTC 자체는 미디어를 전송하기 위한 기술이며, 시그널링 서버는 그러한 미디어 전송을 위한 초기 연결을 설정하고 유지하기 위해 사용됩니다.

SDP ( Session Description Protocol )

예시 시나리오 ( 1:1 )

Untitled

  1. Alice는 offer를 생성
  2. Alice는 생성한 offer를 LocalDescription에 등록
  3. Alice는 생성한 offer를 Bob에게 보낸다.
  4. Bob은 받은 offer를 RemoteDescription에 등록
  5. Bob은 answer를 생성
  6. Bob은 생성된 answer를 LocalDescription에 등록
  7. Bob은 생성된 answer를 Alice에게 보낸다.
  8. Alice는 Bob에게서 받은 answer를 RemoteDescription에 등록
  9. ice candidate를 교환
    • 이때 offer/answer를 주고 받기위한 서버가 시그널링 서버

    Untitled

예시 시나리오 (N:M) ( room 개념 도입 )

Untitled

  • Alice, Bob, Aiden이 서로 통화하는데 그 장소는 roomA
    1. Alice는 시그널링 서버에게 roomA에 들어가고 싶다고 request
    2. 시그널링 서버는 room 정보(room에 누가 있는지, 몇명이 있는지 등)와 성공 여부를 Alice에게 response
    1. 성공했다면 roomA 접속, 현재는 roomA에 아무도 없기에 혼자있는방
      1. Bob이 roomA에 들어가기 위해 시그널링 서버에 request
      2. Bob도 roomA의 정보와 성공여부 response
      3. Bob은 roomA에 있는 모든이들과 offer/answer 교환을 통한 negociation
      4. Aiden도 Bob과 동일

출처 : https://kid-dev.tistory.com/5




테스트 프로젝트


spring boot 시그널링 서버

설정 정보

spring boot : 3.1.2

자바 버전 : openjdk 17 ( Corretto )

웹소켓 : Stomp

gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-websocket' // 시그널링 서버 구축을 위한 websocket
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

1. spring boot webSocket 설정

우선 spring boot 프로젝트를 생성 한뒤

아래와 같이 webSocket config 파일을 설정해준다.

package com.webrtc.signaling.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.AbstractWebSocketMessage;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic"); // broker url 설정
        config.setApplicationDestinationPrefixes("/app"); // send url 설정
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/signaling")// webSokcet 접속시 endpoint 설정
                .setAllowedOriginPatterns("*") // cors 에 따른 설정 ( * 는 모두 허용 )
                .withSockJS(); // 브라우저에서 WebSocket 을 지원하지 않는 경우에 대안으로 어플리케이션의 코드를 변경할 필요 없이 런타임에 필요할 때 대체하기 위해 설정
    }
}

2. webSocket Controller 설정

웹소켓을 위한 controller를 설정해준다.

package com.webrtc.signaling.controller;

import com.webrtc.signaling.dto.SignalingMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class SignalingController {
		//offer 정보를 주고 받기 위한 websocket
		//camKey : 각 요청하는 캠의 key , roomId : 룸 아이디
    @MessageMapping("/peer/offer/{camKey}/{roomId}")
    @SendTo("/topic/peer/offer/{camKey}/{roomId}")
    public String PeerHandleOffer(@Payload String offer, @DestinationVariable(value = "roomId") String roomId,
                                  @DestinationVariable(value = "camKey") String camKey) {
        log.info("[OFFER] {} : {}", camKey, offer);
        return offer;
    }
		
		//iceCandidate 정보를 주고 받기 위한 webSocket
		//camKey : 각 요청하는 캠의 key , roomId : 룸 아이디
    @MessageMapping("/peer/iceCandidate/{camKey}/{roomId}")
    @SendTo("/topic/peer/iceCandidate/{camKey}/{roomId}")
    public String PeerHandleIceCandidate(@Payload String candidate, @DestinationVariable(value = "roomId") String roomId,
                                         @DestinationVariable(value = "camKey") String camKey) {
        log.info("[ICECANDIDATE] {} : {}", camKey, candidate);
        return candidate;
    }

		//

    @MessageMapping("/peer/answer/{camKey}/{roomId}")
    @SendTo("/topic/peer/answer/{camKey}/{roomId}")
    public String PeerHandleAnswer(@Payload String answer, @DestinationVariable(value = "roomId") String roomId,
                                   @DestinationVariable(value = "camKey") String camKey) {
        log.info("[ANSWER] {} : {}", camKey, answer);
        return answer;
    }
		
		//camKey 를 받기위해 신호를 보내는 webSocket
    @MessageMapping("/call/key")
    @SendTo("/topic/call/key")
    public String callKey(@Payload String message) {
        log.info("[Key] : {}", message);
        return message;
    }
		
		//자신의 camKey 를 모든 연결된 세션에 보내는 webSocket
    @MessageMapping("/send/key")
    @SendTo("/topic/send/key")
    public String sendKey(@Payload String message) {
        return message;
    }

}

끝이다.

시그널링 서버를 구축하면서 생각보다 많은 코드를 작성하지 않고 만들수 있어서 약간 신기했습니다. 그리고 이제 시그널링 서버는 그 미디어 전송을 위한 초기 설정과 메타데이터 중계 역활을 하는 서버라고 이해할수 있었습니다.



프론트 코드 ( html + javascript )

설정 정보

html + javascript

자바스크립트 사용 라이브러리

sockjs : 1.5.1 깃허브 링크

stompjs : 2.3.3 깃허브 링크

1. index.html 작성

간단하게 화면에 확인할수 있는 html를 작성한다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebRTC working example</title>

    <!-- 웹소캣 연결에 필요한 라이브러리 선언 -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.1/sockjs.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>

</head>
<body>
<div>
		<!-- 룸 아이디 번호를 입력하는 input -->
    <input type="number" id="roomIdInput" />
		<!-- 룸 아이디를 입력후 클릭하는 button -->
    <button type="button" id="enterRoomBtn">enter Room</button>
		<!-- enterRoomBtn 클릭시 나타남, Streams 정보를 담은 Peer 를 웹소켓 ( 시그널링 )   -->
    <button type="button" id="startSteamBtn" style="display: none;">start Streams</button>
</div>
		<!-- 내 웹캠 화면을 보여주는 video html -->
    <video id="localStream" autoplay playsinline controls style="display: none;"></video>
		
		<!-- WebRTC에 연결된 웹캠들이 추가되는 Div  -->
    <div id="remoteStreamDiv">
    </div>
		<!-- webRTC 연결을 위한 js  -->
    <script src="peerConfig.js"></script>
</body>
</html>

2. peerConfig.js 작성

  1. 우선 웹에서 웹캠을 여는 함수를 추가 해준다.

     // let remoteStreamElement = document.querySelector('#remoteStream');
     let localStreamElement = document.querySelector('#localStream');
     //자신을 식별하기위한 랜덤한 key 
     const myKey = Math.random().toString(36).substring(2, 11);
     let pcListMap = new Map();
     let roomId;
     let otherKeyList = [];
     let localStream = undefined;
        
     const startCam = async () =>{
         if(navigator.mediaDevices !== undefined){
             await navigator.mediaDevices.getUserMedia({ audio: true, video : true })
                 .then(async (stream) => {
                     console.log('Stream found');
     								//웹캠, 마이크의 스트림 정보를 글로벌 변수로 저장한다.
                     localStream = stream;
                     // Disable the microphone by default
                     stream.getAudioTracks()[0].enabled = true;
                     localStreamElement.srcObject = localStream;
                     // Connect after making sure that local stream is availble
        
                 }).catch(error => {
                     console.error("Error accessing media devices:", error);
                 });
         }
        
     }
    
  2. 웹소켓을 연결하기 위한 함수를 추가 해준다.

        
     const connectSocket = async () =>{
         const socket = new SockJS('/signaling');
         stompClient = Stomp.over(socket);
         stompClient.debug = null;
        
         stompClient.connect({}, function () {
             console.log('Connected to WebRTC server');
                
     				//iceCandidate peer 교환을 위한 subscribe
             stompClient.subscribe(`/topic/peer/iceCandidate/${myKey}/${roomId}`, candidate => {
                 const key = JSON.parse(candidate.body).key
                 const message = JSON.parse(candidate.body).body;
        
     						// 해당 key에 해당되는 peer 에 받은 정보를 addIceCandidate 해준다.
                 pcListMap.get(key).addIceCandidate(new RTCIceCandidate({candidate:message.candidate,sdpMLineIndex:message.sdpMLineIndex,sdpMid:message.sdpMid}));
        
             });
        				
     				//offer peer 교환을 위한 subscribe
             stompClient.subscribe(`/topic/peer/offer/${myKey}/${roomId}`, offer => {
                 const key = JSON.parse(offer.body).key;
                 const message = JSON.parse(offer.body).body;
        						
     						// 해당 key에 새로운 peerConnection 를 생성해준후 pcListMap 에 저장해준다.
                 pcListMap.set(key,createPeerConnection(key));
     						// 생성한 peer 에 offer정보를 setRemoteDescription 해준다.
                 pcListMap.get(key).setRemoteDescription(new RTCSessionDescription({type:message.type,sdp:message.sdp}));
                 //sendAnswer 함수를 호출해준다.
     						sendAnswer(pcListMap.get(key), key);
        
             });
        				
     				//answer peer 교환을 위한 subscribe
             stompClient.subscribe(`/topic/peer/answer/${myKey}/${roomId}`, answer =>{
                 const key = JSON.parse(answer.body).key;
                 const message = JSON.parse(answer.body).body;
        						
     						// 해당 key에 해당되는 Peer 에 받은 정보를 setRemoteDescription 해준다.
                 pcListMap.get(key).setRemoteDescription(new RTCSessionDescription(message));
        
             });
        				
     			  //key를 보내라는 신호를 받은 subscribe
             stompClient.subscribe(`/topic/call/key`, message =>{
     						//자신의 key를 보내는 send
                 stompClient.send(`/app/send/key`, {}, JSON.stringify(myKey));
        
             });
        				
     				//상대방의 key를 받는 subscribe
             stompClient.subscribe(`/topic/send/key`, message => {
                 const key = JSON.parse(message.body);
        						
     						//만약 중복되는 키가 ohterKeyList에 있는지 확인하고 없다면 추가해준다.
                 if(myKey !== key && otherKeyList.find((mapKey) => mapKey === myKey) === undefined){
                     otherKeyList.push(key);
                 }
             });
        
         });
     }
    
  3. peerConnection 를 생성해주는 함수를 추가한다.

     const createPeerConnection = (otherKey) =>{
         const pc = new RTCPeerConnection();
         try {
     				// peerConnection 에서 icecandidate 이벤트가 발생시 onIceCandidate 함수 실행
             pc.addEventListener('icecandidate', (event) =>{
                 onIceCandidate(event, otherKey);
             });
     				// peerConnection 에서 track 이벤트가 발생시 onTrack 함수를 실행
             pc.addEventListener('track', (event) =>{
                 onTrack(event, otherKey);
             });
        
     				// 만약 localStream 이 존재하면 peerConnection에 addTrack 으로 추가함
             if(localStream !== undefined){
                 localStream.getTracks().forEach(track => {
                     pc.addTrack(track, localStream);
                 });
             }
             console.log('PeerConnection created');
         } catch (error) {
             console.error('PeerConnection failed: ', error);
         }
         return pc;
     }
    
  4. 위의 onIceCandidate , onTrack 함수를 추가해준다.

     //onIceCandidate
     let onIceCandidate = (event, otherKey) => {
         if (event.candidate) {
             console.log('ICE candidate');
             stompClient.send(`/app/peer/iceCandidate/${otherKey}/${roomId}`,{}, JSON.stringify({
                 key : myKey,
                 body : event.candidate
             }));
         }
     };
        
     //onTrack
     let onTrack = (event, otherKey) => {
         if(document.getElementById(`${otherKey}`) === null){
             const video =  document.createElement('video');
        
             video.autoplay = true;
             video.controls = true;
             video.id = otherKey;
             video.srcObject = event.streams[0];
        
             document.getElementById('remoteStreamDiv').appendChild(video);
         }
     };
    
  5. 이제 위의 offer, answer subscribe 에 있는 onOffer, onAnswer 함수를 만들어 준다.

     let sendOffer = (pc ,otherKey) => {
         pc.createOffer().then(offer =>{
             setLocalAndSendMessage(pc, offer);
             stompClient.send(`/app/peer/offer/${otherKey}/${roomId}`, {}, JSON.stringify({
                 key : myKey,
                 body : offer
             }));
             console.log('Send offer');
         });
     };
        
     let sendAnswer = (pc,otherKey) => {
         pc.createAnswer().then( answer => {
             setLocalAndSendMessage(pc ,answer);
             stompClient.send(`/app/peer/answer/${otherKey}/${roomId}`, {}, JSON.stringify({
                 key : myKey,
                 body : answer
             }));
             console.log('Send answer');
         });
     };
        
     const setLocalAndSendMessage = (pc ,sessionDescription) =>{
         pc.setLocalDescription(sessionDescription);
     }
    
  6. 이제 html 버튼 이벤트들을 추가해준다.

     //룸 번호 입력 후 캠 + 웹소켓 실행
     document.querySelector('#enterRoomBtn').addEventListener('click', async () =>{
         await startCam();
        
         if(localStream !== undefined){
             document.querySelector('#localStream').style.display = 'block';
             document.querySelector('#startSteamBtn').style.display = '';
         }
         roomId = document.querySelector('#roomIdInput').value;
         document.querySelector('#roomIdInput').disabled = true;
         document.querySelector('#enterRoomBtn').disabled = true;
        
         await connectSocket();
     });
        
     // 스트림 버튼 클릭시 , 다른 웹 key들 웹소켓을 가져 온뒤에 offer -> answer -> iceCandidate 통신
     // peer 커넥션은 pcListMap 으로 저장
     document.querySelector('#startSteamBtn').addEventListener('click', async () =>{
         await stompClient.send(`/app/call/key`, {}, {});
        
         setTimeout(() =>{
        
             otherKeyList.map((key) =>{
                 if(!pcListMap.has(key)){
                     pcListMap.set(key, createPeerConnection(key));
                     sendOffer(pcListMap.get(key),key);
                 }
        
             });
        
         },1000);
     });
    
  7. 위의 코드의 전체버전이다.

     // let remoteStreamElement = document.querySelector('#remoteStream');
     let localStreamElement = document.querySelector('#localStream');
     const myKey = Math.random().toString(36).substring(2, 11);
     let pcListMap = new Map();
     let roomId;
     let otherKeyList = [];
     let localStream = undefined;
        
     const startCam = async () =>{
         if(navigator.mediaDevices !== undefined){
             await navigator.mediaDevices.getUserMedia({ audio: true, video : true })
                 .then(async (stream) => {
                     console.log('Stream found');
     								//웹캠, 마이크의 스트림 정보를 글로벌 변수로 저장한다.
                     localStream = stream;
                     // Disable the microphone by default
                     stream.getAudioTracks()[0].enabled = true;
                     localStreamElement.srcObject = localStream;
                     // Connect after making sure that local stream is availble
        
                 }).catch(error => {
                     console.error("Error accessing media devices:", error);
                 });
         }
        
     }
        
     // 소켓 연결
     const connectSocket = async () =>{
         const socket = new SockJS('/signaling');
         stompClient = Stomp.over(socket);
         stompClient.debug = null;
        
         stompClient.connect({}, function () {
             console.log('Connected to WebRTC server');
                
     				//iceCandidate peer 교환을 위한 subscribe
             stompClient.subscribe(`/topic/peer/iceCandidate/${myKey}/${roomId}`, candidate => {
                 const key = JSON.parse(candidate.body).key
                 const message = JSON.parse(candidate.body).body;
        
     						// 해당 key에 해당되는 peer 에 받은 정보를 addIceCandidate 해준다.
                 pcListMap.get(key).addIceCandidate(new RTCIceCandidate({candidate:message.candidate,sdpMLineIndex:message.sdpMLineIndex,sdpMid:message.sdpMid}));
        
             });
        				
     				//offer peer 교환을 위한 subscribe
             stompClient.subscribe(`/topic/peer/offer/${myKey}/${roomId}`, offer => {
                 const key = JSON.parse(offer.body).key;
                 const message = JSON.parse(offer.body).body;
        						
     						// 해당 key에 새로운 peerConnection 를 생성해준후 pcListMap 에 저장해준다.
                 pcListMap.set(key,createPeerConnection(key));
     						// 생성한 peer 에 offer정보를 setRemoteDescription 해준다.
                 pcListMap.get(key).setRemoteDescription(new RTCSessionDescription({type:message.type,sdp:message.sdp}));
                 //sendAnswer 함수를 호출해준다.
     						sendAnswer(pcListMap.get(key), key);
        
             });
        				
     				//answer peer 교환을 위한 subscribe
             stompClient.subscribe(`/topic/peer/answer/${myKey}/${roomId}`, answer =>{
                 const key = JSON.parse(answer.body).key;
                 const message = JSON.parse(answer.body).body;
        						
     						// 해당 key에 해당되는 Peer 에 받은 정보를 setRemoteDescription 해준다.
                 pcListMap.get(key).setRemoteDescription(new RTCSessionDescription(message));
        
             });
        				
     			  //key를 보내라는 신호를 받은 subscribe
             stompClient.subscribe(`/topic/call/key`, message =>{
     						//자신의 key를 보내는 send
                 stompClient.send(`/app/send/key`, {}, JSON.stringify(myKey));
        
             });
        				
     				//상대방의 key를 받는 subscribe
             stompClient.subscribe(`/topic/send/key`, message => {
                 const key = JSON.parse(message.body);
        						
     						//만약 중복되는 키가 ohterKeyList에 있는지 확인하고 없다면 추가해준다.
                 if(myKey !== key && otherKeyList.find((mapKey) => mapKey === myKey) === undefined){
                     otherKeyList.push(key);
                 }
             });
        
         });
     }
        
     let onTrack = (event, otherKey) => {
        
         if(document.getElementById(`${otherKey}`) === null){
             const video =  document.createElement('video');
        
             video.autoplay = true;
             video.controls = true;
             video.id = otherKey;
             video.srcObject = event.streams[0];
        
             document.getElementById('remoteStreamDiv').appendChild(video);
         }
        
         //
         // remoteStreamElement.srcObject = event.streams[0];
         // remoteStreamElement.play();
     };
        
     const createPeerConnection = (otherKey) =>{
         const pc = new RTCPeerConnection();
         try {
             pc.addEventListener('icecandidate', (event) =>{
                 onIceCandidate(event, otherKey);
             });
             pc.addEventListener('track', (event) =>{
                 onTrack(event, otherKey);
             });
             if(localStream !== undefined){
                 localStream.getTracks().forEach(track => {
                     pc.addTrack(track, localStream);
                 });
             }
        
             console.log('PeerConnection created');
         } catch (error) {
             console.error('PeerConnection failed: ', error);
         }
         return pc;
     }
        
     let onIceCandidate = (event, otherKey) => {
         if (event.candidate) {
             console.log('ICE candidate');
             stompClient.send(`/app/peer/iceCandidate/${otherKey}/${roomId}`,{}, JSON.stringify({
                 key : myKey,
                 body : event.candidate
             }));
         }
     };
        
     let sendOffer = (pc ,otherKey) => {
         pc.createOffer().then(offer =>{
             setLocalAndSendMessage(pc, offer);
             stompClient.send(`/app/peer/offer/${otherKey}/${roomId}`, {}, JSON.stringify({
                 key : myKey,
                 body : offer
             }));
             console.log('Send offer');
         });
     };
        
     let sendAnswer = (pc,otherKey) => {
         pc.createAnswer().then( answer => {
             setLocalAndSendMessage(pc ,answer);
             stompClient.send(`/app/peer/answer/${otherKey}/${roomId}`, {}, JSON.stringify({
                 key : myKey,
                 body : answer
             }));
             console.log('Send answer');
         });
     };
        
     const setLocalAndSendMessage = (pc ,sessionDescription) =>{
         pc.setLocalDescription(sessionDescription);
     }
        
     //룸 번호 입력 후 캠 + 웹소켓 실행
     document.querySelector('#enterRoomBtn').addEventListener('click', async () =>{
         await startCam();
        
         if(localStream !== undefined){
             document.querySelector('#localStream').style.display = 'block';
             document.querySelector('#startSteamBtn').style.display = '';
         }
         roomId = document.querySelector('#roomIdInput').value;
         document.querySelector('#roomIdInput').disabled = true;
         document.querySelector('#enterRoomBtn').disabled = true;
        
         await connectSocket();
     });
        
     // 스트림 버튼 클릭시 , 다른 웹 key들 웹소켓을 가져 온뒤에 offer -> answer -> iceCandidate 통신
     // peer 커넥션은 pcListMap 으로 저장
     document.querySelector('#startSteamBtn').addEventListener('click', async () =>{
         await stompClient.send(`/app/call/key`, {}, {});
        
         setTimeout(() =>{
        
             otherKeyList.map((key) =>{
                 if(!pcListMap.has(key)){
                     pcListMap.set(key, createPeerConnection(key));
                     sendOffer(pcListMap.get(key),key);
                 }
        
             });
        
         },1000);
     });
    

시그널링 서버는 많은 코드가 필요없었다면, 프론트는 peer 생성부터 그 정보를 교환하고 offer, answer 등을 관리해야되어 코드가 백엔드 소스보다 길어졌습니다. WebRTC는 어찌보면 백엔드에서는 별 다른 것이 없고 프론트에서 전부다 처리하는거 같습니다.



flutter 예제

  1. main.dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:stomp_dart_client/stomp.dart';
import 'package:stomp_dart_client/stomp_config.dart';
import 'package:stomp_dart_client/stomp_frame.dart';
import 'package:uuid/uuid.dart';

void main() {
  runApp(const MaterialApp(
    home: HomePage(),
  ));
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  late StompClient stompClient;

  final config = {
    'iceServers': [
      {
        "url": "stun:stun.l.google.com:19302",
      },
    ],
    'sdpSemantics': 'unified-plan'
  };

  final sdpConstraints = {
    'mandatory': {
      'OfferToReceiveAudio': true,
      'OfferToReceiveVideo': true,
    },
    'optional': []
  };

  String socketUrlSockJS = "http://localhost:8080/signaling";

  final String _myKey = const Uuid().v4();
  String message = '';
  String _roomId = '';
  List<String> otherKeyList = [];
  Map<String, RTCPeerConnection> pcListMap = {};
  final _localRenderer = RTCVideoRenderer();
  final _remoteRenderer = RTCVideoRenderer();
  late MediaStream localStream;

  void onConnect(StompClient stompClient, StompFrame stompFrame) {
    stompClient.subscribe(
      destination: '/topic/peer/iceCandidate/$_myKey/$_roomId',
      callback: (frame) {
        if (frame.body != null) {
          print("Received iceCandidate");

          try {
            Map<String, dynamic> result = jsonDecode(frame.body!);
            String key = result['key'];
            Map<String, dynamic> body = result['body'];
            String candidate = body['candidate'];
            String sdpMid = body['sdpMid'];
            int sdpMLineIndex = body['sdpMLineIndex'];
            if (key == _myKey) return;
            // 해당 키로부터 받은 ice 등록
            RTCPeerConnection? pc = pcListMap[key];
            if (pc != null) {
              print("onIceCandidate: $key");
              print("onIceCandidate: $candidate , $sdpMid , $sdpMLineIndex");

              RTCIceCandidate candidates = RTCIceCandidate(
                candidate,
                sdpMid,          
                sdpMLineIndex,  
              );

              pc.addCandidate(candidates);
            }
          } catch (e) {
            print('/topic/peer/iceCandidate : $e');
          }
        }
      },
    );

    stompClient.subscribe(
        destination: '/topic/peer/offer/$_myKey/$_roomId',
        callback: (frame) async {
          if (frame.body != null) {
            try {
              Map<String, dynamic> result = jsonDecode(frame.body!);
              String key = result['key'];
              print("Received offer from: $key");
              Map<String, dynamic> body = result['body'];
              String sdp =
                  body['sdp'].replaceAll('setup:actpass', 'setup:active');
              String type = body['type'];
              print("Set offer: $key");
              // 해당 키와 나와 peerConnection 등록
              // 오퍼를 받는 경우는 상대방이 peerConnection을 등록해서 보냈기 때문
              pcListMap[key] = await createPeer(key);
              // 해당 키와 나와 연결된 peerConnection에 전달 받은 오퍼 등록
              await pcListMap[key]!
                  .setRemoteDescription(RTCSessionDescription(sdp, type));
              pcListMap[key]!.onSignalingState = (state) {
                if (state ==
                    RTCSignalingState.RTCSignalingStateHaveRemoteOffer) {
                  // 해당 키로부터 받은 오퍼를 등록한 후 answer 생성,등록 후 전송
                  sendAnswer(pcListMap[key]!, key);
                }
              };
            } catch (e) {
              print('/topic/peer/offer : $e');
            }
          }
        });

    stompClient.subscribe(
        destination: '/topic/peer/answer/$_myKey/$_roomId',
        callback: (frame) {
          if (frame.body != null) {
            try {
              Map<String, dynamic> result = jsonDecode(frame.body!);
              String key = result['key'];
              Map<String, dynamic> body = result['body'];
              String sdp = body['sdp'];
              String type = body['type'];
              print("Received answer: $result");
              // 해당 키와 peerConnection을 만든 후 보낸 오퍼에 대한 answer 등록
              RTCPeerConnection? pc = pcListMap[key];
              if (pc != null) {

                pc.setRemoteDescription(RTCSessionDescription(sdp, type));
              }
            } catch (e) {
              print('/topic/peer/answer : $e');
            }
          }
        });

    stompClient.subscribe(
        destination: '/topic/call/key',
        callback: (frame) {
          stompClient.send(
              destination: '/app/send/key', headers: {}, body: '"$_myKey"');
        });

    stompClient.subscribe(
        destination: '/topic/send/key',
        callback: (frame) async {
          if (frame.body != null) {
            String key = frame.body!.replaceAll('"', '');
            if (key == _myKey) return;
            print("GET Other key: ${frame.body}");
            // 키를 받았을때 나와 연결된 peerConnection이 없으면 생성
            if (!pcListMap.containsKey(key)) {
              print("Create PC for otherKey: $key");
              pcListMap[key] = await createPeer(key);
              // 나와 연결된 키로 offer 전송
              sendOffer(pcListMap[key]!, key);
            }
          }
        });
  }

  @override
  void initState() {
    super.initState();
    initRenderers();
    permission();
  }

  void initRenderers() async {
    await _localRenderer.initialize();
    await _remoteRenderer.initialize();
  }

  void permission() async {

// You can request multiple permissions at once.
    Map<Permission, PermissionStatus> statuses = await [
      Permission.location,
      Permission.storage,
      Permission.camera,
      Permission.microphone
    ].request();
  }

  Future<void> _handleButtonPress() async {
    print("Send My Key $_myKey");
    stompClient.send(
        destination: '/app/call/key', headers: {}, body: '"$_myKey"');
  }

  void sendOffer(RTCPeerConnection pc, String otherKey) {
    pc.createOffer().then((offer) {
      print("Sending offer At: $otherKey");
      String jsonOffer = jsonEncode(offer.toMap());
      String body = '{"key":"$_myKey","body":$jsonOffer}';
      print("Register Offer At $_myKey");
      setLocalAndSendMessage(pc, offer);
      stompClient.send(
          destination: '/app/peer/offer/$otherKey/$_roomId',
          headers: {},
          body: body);
    });
  }

  void sendAnswer(RTCPeerConnection pc, String otherKey) {
    // 오퍼가 등록되어야 answer 생성
    // 상대방에게 생성된 answer 전송
    pc.createAnswer().then((answer) {
      print("Sending answer At: $otherKey");
      print("Register answer At $_myKey");
      setLocalAndSendMessage(pc, answer);
      String destination = '/app/peer/answer/$otherKey/$_roomId';
      String jsonAnswer = jsonEncode(answer.toMap());
      String body = '{"key":"$_myKey","body":$jsonAnswer}';
      stompClient.send(destination: destination, headers: {}, body: body);
    });
  }

  void setLocalAndSendMessage(
      RTCPeerConnection pc, RTCSessionDescription sessionDescription) {
    pc.setLocalDescription(sessionDescription);
  }

  Future<RTCPeerConnection> createPeer(String otherKey) async {
    print("Create Peer: $otherKey");
    RTCPeerConnection pc = await createPeerConnection(config, sdpConstraints);

    pc.onIceCandidate = (ice) {
      if (ice.candidate != null) {
        String jsonIce = jsonEncode(ice.toMap());
        String body = '{"key":"$_myKey","body":$jsonIce}';

        stompClient.send(
            destination: '/app/peer/iceCandidate/$otherKey/$_roomId',
            headers: {},
            body: body);
      }
    };

    pc.onTrack = (event) {
      print("Get Remote Stream : $event");
      var stream = event.streams;
      _remoteRenderer.srcObject = stream[0];
    };

    pc.onAddStream = (stream) {
      print(stream);
    };

    localStream.getTracks().forEach((track) {
      pc.addTrack(track, localStream);
    });

    return pc;
  }

  void _handleInputChangeRoomId(String newText) {
    if (newText.isNotEmpty) {
      setState(() {
        _roomId = newText;
      });

      startCam();

      // Configuration client with SockJS.
      stompClient = StompClient(
        config: StompConfig.SockJS(
          url: socketUrlSockJS,
          onConnect: (stompFrame) => onConnect(stompClient, stompFrame),
        ),
      );

      stompClient.activate();
      print("Stomp Activate");
    }
  }

  void _handleGetPCList() {
    print("PC List: $pcListMap");
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Stomp Client Demo"),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              "Your message from server:",
              style: Theme.of(context).textTheme.bodyLarge,
            ),
            Text(message),
            SizedBox(height: 20),
            TextField(
              onChanged: _handleInputChangeRoomId,
              decoration: const InputDecoration(
                labelText: 'Enter Room ID',
              ),
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: _handleButtonPress,
              child: Text('Stream'),
            ),
            Text(
              "My Video",
              style: Theme.of(context).textTheme.bodyLarge,
            ),
            SizedBox(
              width: 100,
              height: 100,
              child: RTCVideoView(_localRenderer, mirror: true),
            ),
            Text(
              "Other Video",
              style: Theme.of(context).textTheme.bodyLarge,
            ),
            SizedBox(
              width: 100,
              height: 100,
              child: RTCVideoView(_remoteRenderer, mirror: true),
            ),
            ElevatedButton(
              onPressed: _handleGetPCList,
              child: Text('GET PC List'),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    stompClient.deactivate();
    super.dispose();
  }

  startCam() async {
    await navigator.mediaDevices
        .getUserMedia({'audio': true, 'video': true}).then((stream) {
      localStream = stream;
      stream.getAudioTracks()[0].enabled = true;
      _localRenderer.srcObject = localStream;
    }).catchError((onError) => print(onError));
  }
}

모바일 앱으로도 WebRTC가 제대로 작동하는지 확인하기 위해 flutter 를 이용하여 테스트 해봤습니다.