티스토리 뷰
지금까지 WebRTC란 무엇이고 어떠한 사전지식이 필요한지 알아보았습니다. 그래서 오늘은 구현을 하면서 한번 더 정리를 해보는 시간을 가져보겠습니다. 먼저 Peer to Peer을 연결하면서 어떻게 클라이언트를 연결해야하지? 라는 생각과 이 개념이 제대로 잡히지 않아서 어려웠습니다. 그래서 제가 생각하는 중요한 개념을 먼저 짚고 넘어가겠습니다.
시그널링 (Signaling)이란?
- 서로 다른 네트워크에 있는 2개의 디바이스들을 통신하기 위해서는, 각 디바이스들의 위치(IP)를 발견 및 미디어 포맷 협의가 진행되어야 합니다. 이 프로세스를 시그널링이라 부르고 디바이스들을 상호간에 동의된 서버(중간 서버 ex: socket)에 연결시킵니다.
시그널링 서버란?
- 두 디바이스들 사이에 WebRTC 커넥션을 맺기 위해 인터넷 네트워크에서 두 디바이스들을 연결 시키는 작업을 해줄가 필요한데 이 서버가 바로 시그널링 서버입니다.
시그널링과 시그널링 과정
1. SDP 교환 과정
- 홍길동과 이순신은 화상통화를 위해 시그널링 과정을 진행할려고 시작합니다.
- 홍길동이 offer라는 세션 정보를 만들고 이 offer는 세션 정보를 SDP 포맷으로 가지고 있으며, 커넥션을 이어지기를 원하는 이순신에게 전달됩니다.
- 이순신은 전달 받은 offer에 SDP description을 포함하는 Answer SDP를 다시 홍길동에게 전달해줍니다.
- 이 과정이 끝나면 홍길동과 이순신은 서로 어떤 코덱들과 어떤 video parmeter들이 사용될지 알 수 있습니다. 하지만 이 둘은 미디어 데이터 전송 방법을 모르기 때문에 이대 ICE가 사용됩니다.
2. ICE 교환 과정
- 위에서 홍길동과 이순신은 서로의 SDP를 교환했습니다. 이제 ICE를 교환해야합니다. 각 ICE candidate는 발신 Peer 입장에서 통신을 할 수 있는 방법을 설명합니다.
- 각 Peer는 검색되는 순서대로 candidate를 보내고 미디어가 이미 스트리밍을 시작했더라도 모든 가능한 candidate가 전송 완료될 때까지 계속 보냅니다. 두 Peer가 서로 호환되는 candidate를 제안했다면 미디어는 통신을 시작하게 됩니다.
3. 시그널링 정보를 교환하는 기초 과정
- 지금까지 홍길동은 이순신에게 화상통화를 걸었습니다.
Step 01 - 홍길동의 커넥션
- RTCPeerConnection 객체를 생성합니다.
- getUserMedia() 함수를 호출하여 웹캠과 마이크의 권한을 얻습니다.
- RTCPeerConnection에 stream을 추가하기 위해 RTCPeerConnection.addTrack() 함수를 호출하여 자신의 stream을 넘깁니다.
Step 02 - 웹 브라우저의 확인
- 웹 브라우저는 홍길동에게 화상통화가 준비되었는지 물어봅니다.
Step 03 - 홍길동의 offer SDP 생성
- RTCPeerConnection.createOffer() 함수를 호출하여 홍길동의 SDP offer를 생성합니다.
- 생성된 SDP offer 정보를 전달하기 위해 RTCPeerConnection.setLocalDescription() 함수를 실행합니다.
- "video-offer" 타입의 메세지로 시그널링 서버(socket)의 이순신에게 제안을 보냅니다.
Step 04 - 웹 브라우저
- ICE layer는 candidates를 이순신에게 보냅니다.
Step 05 - 시그널링 서버
- "video-offer" 타입의 메세지를 받아 이순신에게 전송합니다.
Step 06 - 이순신의 offer 확인 및 answer 전달
- 이순신도 RTCPeerConnection 객체를 생성합니다.
- 전달받은 offer SDP 를 사용하여 RTCSessionDescription 객체를 생성합니다.
- 홍길동의 WebRTC 구성에 알려주기 위해 RTCPeerConnection.setRemoteDescription() 함수를 사용하여 위에서 생성한 RTCSessionDescription을 인자로 넣어줍니다.
- getUserMedia() 함수를 통해 웹캠과 마이크의 권한을 얻습니다.
- RTCPeerConnection에 stream을 추가하기 위해 RTCPeerConnection.addTrack() 함수를 호출하여 자신의 stream을 넘깁니다.
- 홍길동에게 보낼 Answer SDP를 생성하기 위해 RTCPeerConnection.createAnswer() 함수를 실행합니다.
- 홍길동과의 연결을 완료를 위해 RTCPeerConnection.setLocalDescription() 함수를 호출하여 연결합니다.
- 시그널링 서버를 통해 Answer SDP를 "video-answer" 타입으로 전송합니다.
Step 07 - 웹 브라우저
- ICE layer는 candidates를 홍길동에게 보냅니다.
Step 08 - 시그널링 서버
- "video-answer" 타입의 메세지를 받아 홍길동에게 전송합니다.
Step 09 - 이순신이 보내준 Answer 확인
- 이순신이 보내준 Answer SDP를 받고, 전달 받은 Answer SDP를 토대로 RTCSessionDescription 객체를 생성합니다.
- 세션 설명을 위해 RTCPeerConnection.setRemoteDescription()에 RTCSessionDescription을 전달하며 이순신과의 연결이 어떻게 구성되었는지 알 수 있도록 홍길동의 WebRTC 계층을 구성합니다.
4. ICE candidate 교환 과정
- ICE layer에서 candidate를 보내기 시작할 때 다음과 같은 교환 과정이 발생하게 됩니다.
Step 01 - 웹 브라우저
- SDP 문자열로 표현되는 ICE 후보 생성
Step 02 - 홍길동의 웹 브라우저
- candidate를 받고 시그널링 서버를 통해 이순신에게 "new-ice-candidate" 메세지를 전송합니다.
Step 03 - 시그널링 서버
- "new-ice-candidate" 메세지를 받아 이순신에게 전달합니다.
Step 04 - 이순신의 웹 브라우저
- candidate에 제공된 SDP를 사용하기 위해 RTCIceCandidate를 생성합니다.
- candidate를 RTCPeerConnection.addIceCandidate() 함수에 생성한 RTCIceCandidate 객체를 넣어줍니다.
Step 05 - 웹 브라우저
- SDP 문자열로 표현되는 ICE 후보 생성
Step 06 - 이순신의 웹 브라우저
- 홀길동이 보낸 candidate를 받고 시그널링 서버를 통해 홍길동에게 "new-ice-candidate" 메세지를 전송합니다.
Step 07 - 시그널링 서버
- "new-ice-candidate" 메세지를 받아 홍길동에게 전달합니다.
Step 08 - 홍길동의 웹 브라우저
- candidate에 제공된 SDP를 사용하기 위해 RCTIceCandidate를 생성합니다.
- candidate를 RTCPeerConnection.addIceCandidate() 함수에 생성한 RTCIceCandidate 객체를 넣어주고 홍길동의 ICE 레이어로 전달합니다.
구현 예제
- 지금까지 위에서는 서로의 클라이언트가 어떻게 PeerConnection이 이루어지는지 알아봤습니다. 어설픈 예제를 해보겠습니다!
클라이언트 js
💡 기본 변수의 역할
- localVideoRef : 본인의 video, audio를 재생할 video 태그의 ref
- remoteVideoRef : 상대방의 video, audio를 재생할 video 태그의 ref
- shareVideoRef : 화면 공유를 하기 위한 video, audio 재생할 video 태그의 ref
💡 Socket 수신 이벤트
- useConnection
- 자신을 제외한 같은 방의 모든 클라이언트의 수를 가져옵니다.
- 상대에게 offer signal을 보냅니다. (createOffer())
- getOffer
- 상대방으로부터 offer를 받으며 RTCSessionDescription을 받습니다.
- 다시 offer를 준 상대에게 answer를 전달합니다. (createAnswer())
- getAnswer
- 본인의 RTCPeerConnection의 setRemoteDescription 함수를 사용하여 상대방의 RTCSessionDescription 정보를 설정합니다.
- getCandidate
- 본인의 RTCPeerConnection에 addIceCandidate 함수를 사용하여 상대방의 candidate 정보를 설정합니다.
- fullRoom : 해당 Room이 만석인지 체크합니다.
- screenShare : 화면 공유 중 여부를 체크합니다.
- userDisconnect : 상대방이 해당 Room을 나갔는지 체크합니다.
💡 MediaStream 설정 및 RTCPeerConnection 이벤트
- onicecandidate
- offer 또는 answer SDP를 생성한 후부터 본인의 icecandidate 정보 이벤트가 발생합니다. offer 또는 answer를 보냈던 상대방에게 자신의 icecandidate 정보를 시그널링 서버를 통해 보냅니다.
- ontrack
- 상대방의 RTCPeerConnection을 본인의 RTCPeerConnection에서의 remoteSessionDescription으로 지정하면 상대방의 track 데이터에 대한 이벤트가 발생합니다.
import React, { useEffect, useState, useRef } from "react";
import "../styles/video.css";
import io from "socket.io-client";
import { ShareScreenIcon, MicOnIcon, MicOffIcon, CamOnIcon, CamOffIcon } from "./Icons";
const pc_config = {
iceServers: [
{
urls: "stun:stun.l.google.com:19302",
},
],
};
function video(props) {
const { roomId } = props.match.params;
const [micState, setMicState] = useState(true);
const [camState, setCamState] = useState(true);
const [isVideoShare, setIsVideoShare] = useState(false);
const socketRef = useRef();
const peerRef = useRef();
const localVideoRef = useRef(null);
const shareVideoRef = useRef(null);
const remoteVideoRef = useRef(null);
useEffect(() => {
socketRef.current = io.connect("https://5d75-223-38-45-189.ngrok.io");
peerRef.current = new RTCPeerConnection(pc_config);
socketRef.current.on("userConnection", (ConnectionCount) => {
if (ConnectionCount > 0) {
createOffer();
}
});
// 상대방에게 offer 전달
socketRef.current.on("getOffer", (sdp) => {
createAnswer(sdp);
});
// offer를 전달받은 상대방이 그에 대한 답으로 Answer 전달
socketRef.current.on("getAnswer", (sdp) => {
if (!peerRef.current) return;
peerRef.current.setRemoteDescription(new RTCSessionDescription(sdp));
});
socketRef.current.on("getCandidate", async (candidate) => {
if (!peerRef.current) return;
await peerRef.current.addIceCandidate(new RTCIceCandidate(candidate));
});
socketRef.current.on("fullRoom", (roomID) => {
if (!peerRef.current) return;
console.log(roomID + " 해당 Room은 만석입니다.");
});
socketRef.current.on("screenShare", (shareVideo) => {
if (!peerRef.current) return;
setIsVideoShare(shareVideo);
});
socketRef.current.on("userDisconnect", () => {
remoteVideoRef.current.srcObject = null;
console.log("user is disconnect");
});
setVideoTracks();
}, []);
const setVideoTracks = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
if (localVideoRef.current) {
localVideoRef.current.srcObject = stream;
}
if (!(peerRef.current && socketRef.current)) {
console.log("연결된 Peer 또는 Socket이 없습니다.");
}
stream.getTracks().forEach((track) => {
if (!peerRef.current) return;
peerRef.current.addTrack(track, stream);
});
peerRef.current.onicecandidate = (e) => {
if (e.candidate) {
if (!socketRef.current) return;
socketRef.current.emit("createCandidate", { candidate: e.candidate, roomId: roomId });
}
};
peerRef.current.ontrack = (ev) => {
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = ev.streams[0];
shareVideoRef.current = ev.streams[0];
}
};
socketRef.current.emit("joinRoom", {
roomId: roomId,
});
} catch (e) {
console.error(e);
}
};
const createOffer = async () => {
if (!(peerRef.current && socketRef.current)) {
console.log("연결된 Peer 또는 Socket이 없습니다.");
}
try {
const sdp = await peerRef.current.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
await peerRef.current.setLocalDescription(new RTCSessionDescription(sdp));
socketRef.current.emit("createOffer", { sdp: sdp, roomId: roomId });
} catch (e) {
console.error(e);
}
};
const createAnswer = async (sdp) => {
if (!(peerRef.current && socketRef.current)) {
console.log("연결된 Peer 또는 Socket이 없습니다.");
}
try {
await peerRef.current.setRemoteDescription(new RTCSessionDescription(sdp));
const mySdp = await peerRef.current.createAnswer({
offerToReceiveVideo: true,
offerToReceiveAudio: true,
});
await peerRef.current.setLocalDescription(new RTCSessionDescription(mySdp));
socketRef.current.emit("createAnswer", { sdp: mySdp, roomId: roomId });
} catch (e) {
console.error(e);
}
};
/**
* 화면 공유 이벤트
*/
const displayShareHandler = async () => {
const stream = await navigator.mediaDevices.getDisplayMedia();
const shareScreenTrack = stream.getVideoTracks()[0];
const sender = peerRef.current.getSenders().find((sender) => {
return sender.track.kind == shareScreenTrack.kind;
});
sender.replaceTrack(shareScreenTrack);
setIsVideoShare(true);
shareVideoRef.current.srcObject = stream;
shareScreenTrack.addEventListener("ended", () => {
setIsVideoShare(false);
shareVideoRef.current = null;
sender.replaceTrack(localVideoRef.current.srcObject.getVideoTracks()[0]);
socketRef.current.emit("screenSharing", { isVideoShare: false, roomId: roomId });
});
if (shareVideoRef.current) {
socketRef.current.emit("screenSharing", { isVideoShare: true, roomId: roomId });
}
};
/**
* 화면 비디오 이벤트
*/
const webCamHandler = () => {
if (localVideoRef.current.srcObject.getVideoTracks().length > 0) {
localVideoRef.current.srcObject.getVideoTracks().forEach((track) => {
track.enabled = !track.enabled;
});
}
setCamState(!camState);
};
/**
* 화면 오디오 이벤트
*/
const audioHandler = () => {
if (localVideoRef.current.srcObject.getAudioTracks().length > 0) {
localVideoRef.current.srcObject.getAudioTracks().forEach((track) => {
track.enabled = !track.enabled;
});
}
setMicState(!micState);
};
return (
<div className="video-wrapper">
<div className="local-video-wrapper">
<video
style={{
width: 240,
height: 240,
margin: 5,
backgroundColor: "black",
}}
muted
ref={localVideoRef}
autoPlay
/>
</div>
<div className="local-video-wrapper">
<video
style={{
width: 240,
height: 240,
margin: 5,
backgroundColor: "black",
}}
id="remotevideo"
ref={remoteVideoRef}
autoPlay
/>
</div>
{isVideoShare && (
<div className="local-video-wrapper">
<video
style={{
width: 240,
height: 240,
margin: 5,
backgroundColor: "black",
}}
id="shareVideo"
ref={shareVideoRef}
autoPlay
/>
</div>
)}
<div className="controls">
<button
className="control-btn"
onClick={() => {
displayShareHandler();
}}
>
<ShareScreenIcon />
</button>
<button
className="control-btn"
onClick={() => {
audioHandler();
}}
>
{micState ? <MicOnIcon /> : <MicOffIcon />}
</button>
<button
className="control-btn"
onClick={() => {
webCamHandler();
}}
>
{camState ? <CamOnIcon /> : <CamOffIcon />}
</button>
</div>
</div>
);
}
export default video;
서버 js
💡 Socket 이벤트
- 기본적으로 클라이언트로부터 받은 roomId로 socket.join을 사용하여 room을 만들고 있습니다.
- createOffer : 상대방에게 자신의 RTCSessionDescription 정보(offer)를 보냅니다.
- createAnswer : offer를 보낸 상대방에게 자신의 RTCSessionDescription 정보(answer)를 보냅니다.
- createCandidate : 자신의 ICECandidate 정보를 offer 또는 answer에게 보냅니다.
- screenSharing : 화면 공유 상태를 상대방에게 전달합니다.
- disconnect : 상대방이 Room을 나갔는지 여부를 체크하여 클라이언트에게 알려줍니다.
const express = require("express");
const http = require("http");
const app = express();
const cors = require("cors");
const server = http.createServer(app);
const socketio = require("socket.io");
const io = socketio.listen(server);
app.use(cors());
const PORT = 3001;
io.on("connection", (socket) => {
socket.on("joinRoom", (data) => {
socket.join(data.roomId);
socket.room = data.roomId;
const sockets = io.of("/").in().adapter.rooms[data.roomId];
console.log(sockets);
if (sockets.length === 1) {
console.log("1명이 입장하였습니다.");
} else if (sockets.length === 2) {
console.log("2명이 입장하였습니다.");
io.sockets.in(socket.id).emit("userConnection", sockets.length);
} else {
console.log("해당 Room은 만석입니다.");
io.sockets.in(socket.id).emit("fullRoom", data.roomId);
}
});
socket.on("createOffer", ({ sdp, roomId }) => {
socket.in(roomId).emit("getOffer", sdp);
});
socket.on("createAnswer", ({ sdp, roomId }) => {
socket.in(roomId).emit("getAnswer", sdp);
});
socket.on("createCandidate", ({ candidate, roomId }) => {
socket.in(roomId).emit("getCandidate", candidate);
});
socket.on("screenSharing", ({ isVideoShare, roomId }) => {
socket.in(roomId).emit("screenShare", isVideoShare);
});
socket.on("disconnect", () => {
const roomId = Object.keys(socket.adapter.rooms)[1];
socket.in(roomId).emit("userDisconnect");
console.log("유저가 나갔습니다.");
});
});
server.listen(PORT, () => {
console.log(`server running on ${PORT}`);
});
실행 결과
방 입장 시
화면 공유 시
느낀점
처음에는 WebRTC에 대한 개념이 부족한 상태로 일단 먼저 구글링하면서 만들어보자라는 생각을 가지게 되었고, 만들면서 WebRTC에 대한 개념이 많이 부족했던 터라 다시 개념 공부로 돌아갔습니다.(개념의 중요성..!) 지금 예제 코드에서는 수정할 부분이 많이 있지만 추후에 개념 공부를 조금씩 더 하면서 보충을 해야겠습니다! 그리고 구글링을 하면서 WebRTC에 대한 정보가 많이 없어 힘들었지만 나름 재미가 있었습니다. 그리고 구글 Meet, zoom, 게더 타운 등 요즘 WebRTC를 많이 사용하는데 대체 어떻게 수준 높은 WebRTC 기능을 많들 수 있을까 궁금해졌고 기회가 된다면 1:1이 아닌 1:N도 만들어 보고 싶다는 생각을 가지게 되었습니다.
화면 공유 시 대체 방안
화면 공유시 내가 공유하고 있는 화면을 상대방에게 전달해주어야 한다. 여기서 전달이란 실시간으로 전달을 해주어야 한다는 것입니다. 어떻게 실시간으로 전달을 해줄 수 있을까? 라는 고민을 많이 했는데 socket으로 공유하고 있는 화면의 stream을 전달해주어야하나 라는 생각을 가지게 되었는데 steam은 너무 큰 용량이고 넘길 시 오류가 발생하였습니다. 그리고 socket을 사용하여 넘긴다 하더라도 실시간으로 계속 넘겨야할텐데 어떻게 실시간으로 넘길 수 있을까? 이게 옳은 방법일까? 라는 생각을 가지게 되었고 결국 제 작고 귀여운 뇌에서는 이러면 안되겠다라는 결론이 나와 해당 방법을 사용하지 않았습니다. 그렇다면 화면 공유를 하고 있는 stream을 하나의 video 태그를 더 만들어서 (예제에서는 shareVideo 태그입니다.) 상대방의 shareVideoRef로 넘겨주면 되지 않을까? 라는 생각을 하였지만 넘겨줄 수 있는 방법이 없습니다,,,ㅠㅠ
그래서 사용한 방법이 RTCPeerConnection의 replaceTrack을 사용하여 협상없이 대체를 해야겠다라는 생각으로 구글링을하여 진행을 하게되었습니다. 다른 분들은 WebRTC에서 화면 공유를 할 때 어떻게 효과적으로 할 수 있는지 지나가시는 길에 답변 부탁드립니다!!
깃 링크
https://github.com/kdg0209/WebRTC-P2P
참고
WebRTC는 HTTPS 환경에서만 동작을 합니다. 그렇기 때문에 ngrok를 사용하여 임시의 https를 발급 받고 진행하였습니다.
언제나 피드백은 환영입니다!
'FrontEnd' 카테고리의 다른 글
WebRTC를 이해하기 위해 필요한 지식들 (0) | 2022.04.06 |
---|---|
WebRTC란? (0) | 2022.04.05 |
- Total
- Today
- Yesterday
- java ThreadLocal
- 람다 표현식
- redis sorted set으로 대기열 구현
- java userThread와 DaemonThread
- spring boot redisson destributed lock
- spring boot redisson sorted set
- 자바 백엔드 개발자 추천 도서
- pipe and filter architecture
- space based architecture
- 공간 기반 아키텍처
- spring boot 엑셀 다운로드
- transactional outbox pattern
- @ControllerAdvice
- spring boot poi excel download
- spring boot redis 대기열 구현
- spring boot excel download oom
- pipeline architecture
- microkernel architecture
- service based architecture
- 트랜잭셔널 아웃박스 패턴 스프링부트
- 레이어드 아키텍처란
- transactional outbox pattern spring boot
- spring boot redisson 분산락 구현
- spring boot excel download paging
- 트랜잭셔널 아웃박스 패턴 스프링 부트 예제
- JDK Dynamic Proxy와 CGLIB의 차이
- polling publisher spring boot
- redis 대기열 구현
- redis sorted set
- 서비스 기반 아키텍처
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |