WebRTC 화상회의 서버 구축
정창현 주임
WebRTC 화상회의 서버 구축
소개하기 전..
이번에 프로젝트에서 webRTC 사용하게 되었습니다. 사용 이유는 CCTV 를 웹과 flutter 앱 화면에 보기 위해서 입니다.
저는 WebRTC를 한번도 사용한 적이 없기에, 이곳저곳 정보를 얻어 테스트용 프로젝트를 만들어서 테스트에 성공하였습니다.
이글은 WebRTC에 대해 조사했던 정보와 테스트 프로젝트 코드 및 사용방법을 적어놨습니다.
WebRTC 기본 정보
WebRTC 란?
WebRTC는 웹 기반 실시간 음성, 영상 통신 기술입니다. 이 기술은 Google에서 개발되었으며, 브라우저 상에서 플러그인 없이 음성이나 영상을 전송할 수 있게 해줍니다.
WebRTC는 Peer-to-Peer 기술을 사용하여, 서버를 거치지 않고 브라우저 간에 직접적인 연결을 가능하게 합니다. 이를 통해 더 빠르고 안정적인 통신이 가능해졌습니다.
WebRTC는 다양한 분야에서 사용됩니다. 예를 들어, 비디오 채팅, 화상 회의, 온라인 교육, 의료 분야에서의 원격 진료 등에 사용됩니다.
WebRTC의 구성 요소는 크게 세 가지로 나눌 수 있습니다. 미디어 스트림, 시그널링, NAT 트래버설입니다.
- 미디어 스트림 : 오디오, 비디오 데이터를 전송하는 역할을 합니다.
- 시그널링 : P2P 연결을 위한 정보 교환 역할을 합니다.
- NAT 트래버설 : 브라우저가 서로 다른 네트워크에 있을 때, 네트워크 간의 연결을 가능하게 합니다.
WebRTC는 오픈소스 기술이며, Chrome, Firefox, Opera 등의 주요 브라우저에서 지원됩니다.
WebRTC 종류
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를 알아봐주는 서버
3. turn 서버 ( 우리만의 비밀 장소)
- nat (public ip를 private ip와 매칭 시켜주는곳)
- nat의 종류 또는 설정에 따라 접근을 허용하지 않을때도 존재 → 우회 필요
- 우회를 해주는게 turn 서버
- 구글의 coturn 사용
4. 미디어 서버
- mesh를 개발할땐 필요 없음
- media 정보를 주고 받아야 하기 때문에 많은 트래픽 비용 발생
- 인코딩과 디코딩 필요
시그널링 서버
시그널링 서버는 WebRTC 기반 애플리케이션에서 클라이언트 간의 연결 설정 및 메타데이터 교환을 담당하는 중개서버 입니다. WebRTC 자체는 미디어를 전송하기 위한 기술이며, 시그널링 서버는 그러한 미디어 전송을 위한 초기 연결을 설정하고 유지하기 위해 사용됩니다.
SDP ( Session Description Protocol )
- 참고 문헌
- 스트리밍 미디어의 초기화 인수를 기술하기 위한 포맷
- 규격 - IETF의 RFC 4566
- 기본적으로 제안과 수락 모델(offer/answer)로 정의
예시 시나리오 ( 1:1 )
- Alice는 offer를 생성
- Alice는 생성한 offer를 LocalDescription에 등록
- Alice는 생성한 offer를 Bob에게 보낸다.
- Bob은 받은 offer를 RemoteDescription에 등록
- Bob은 answer를 생성
- Bob은 생성된 answer를 LocalDescription에 등록
- Bob은 생성된 answer를 Alice에게 보낸다.
- Alice는 Bob에게서 받은 answer를 RemoteDescription에 등록
- ice candidate를 교환
- 이때 offer/answer를 주고 받기위한 서버가 시그널링 서버
예시 시나리오 (N:M) ( room 개념 도입 )
- Alice, Bob, Aiden이 서로 통화하는데 그 장소는 roomA
- Alice는 시그널링 서버에게 roomA에 들어가고 싶다고 request
- 시그널링 서버는 room 정보(room에 누가 있는지, 몇명이 있는지 등)와 성공 여부를 Alice에게 response
- 성공했다면 roomA 접속, 현재는 roomA에 아무도 없기에 혼자있는방
- Bob이 roomA에 들어가기 위해 시그널링 서버에 request
- Bob도 roomA의 정보와 성공여부 response
- Bob은 roomA에 있는 모든이들과 offer/answer 교환을 통한 negociation
- 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 작성
-
우선 웹에서 웹캠을 여는 함수를 추가 해준다.
// 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); }); } }
-
웹소켓을 연결하기 위한 함수를 추가 해준다.
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); } }); }); }
-
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; }
-
위의 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); } };
-
이제 위의 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); }
-
이제 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); });
-
위의 코드의 전체버전이다.
// 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 예제
- 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 를 이용하여 테스트 해봤습니다.