해당 글은 제가 공부하면서 이해한 WebRTC 시그널링 파트를
제가 이해한대로 주관적으로 서술한 글입니다!
잘못된 부분이 있을 수 있으며, 아직 테스트를 마치지 못하였습니다.
저희조 프론트, 백엔드 분들의 WebRTC 통신의 이해를 돕기 위한 글이며, 테스트 후 수정 될 수 있습니다.
시그널링을 스텝으로 구분하여 기재 하였으며 읽기전 아래의 코드파일과 첨부한 이미지를 먼저 간단하게 먼저 흝어 봐 주세요 :)
// 외부모듈 import styled from 'styled-components'; import React, { useRef, useEffect } from 'react'; import SockJS from 'sockjs-client'; import { Stomp } from '@stomp/stompjs'; function Card() { const SockJs = new SockJS('http://sangt.shop/ws/chat'); const ws = Stomp.over(SockJs); const reconnect = 0; const videoRef = useRef(null); const muteBtn = useRef(null); const cameraBtn = useRef(null); const camerasSelect = useRef(null); const cameraOption = useRef(null); let muted = false; let cameraOff = false; let stream; function onClickCameraOffHandler() { stream.getVideoTracks().forEach((track) => { track.enabled = !track.enabled; }); if (!cameraOff) { cameraBtn.current.innerText = 'OFF'; cameraOff = !cameraOff; } else { cameraBtn.current.innerText = 'ON'; cameraOff = !cameraOff; } } function onClickMuteHandler() { stream.getAudioTracks().forEach((track) => { track.enabled = !track.enabled; }); if (!muted) { muteBtn.current.innerText = 'Unmute'; muted = !muted; } else { muteBtn.current.innerText = 'Mute'; muted = !muted; } } async function getCameras() { try { // 유저의 장치를 얻어옵니다 const devices = await navigator.mediaDevices.enumerateDevices(); // 얻어온 유저의 장치들에서 카메라장치만 필터링 합니다 const cameras = devices.filter((device) => device.kind === 'videoinput'); // 현재내가 사용중인 카메라의 label명을 셀렉트란에 보여주기위한 과정입니다. // 아래의 if문과 이어서 확인 해주세요 const currentCamera = stream.getVideoTracks()[0]; cameras.forEach((camera) => { cameraOption.current.value = camera.deviceId; cameraOption.current.innerText = camera.label; if (currentCamera.label === camera.label) { cameraOption.current.selected = true; } camerasSelect.current.appendChild(cameraOption.current); }); } catch (error) { console.log(error); } } async function getUserMedia(deviceId) { const initialConstrains = { video: { facingMode: 'user' }, audio: true, }; const cameraConstrains = { audio: true, video: { deviceId: { exact: deviceId } }, }; try { stream = await navigator.mediaDevices.getUserMedia( deviceId ? cameraConstrains : initialConstrains, ); videoRef.current.srcObject = stream; if (!deviceId) { await getCameras(); } } catch (err) { console.log(err); } } async function onInputCameraChange() { await getUserMedia(camerasSelect.current.value); } useEffect(() => { getUserMedia(); // makeConnection line // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <StCard> Card <h4>키워드</h4> <span>OOO님</span> <div> {/* eslint-disable-next-line jsx-a11y/media-has-caption */} <video ref={videoRef} id="myFace" autoPlay playsInline width={200} height={200} 비디오 </video> <button ref={muteBtn} onClick={onClickMuteHandler}> mute </button> <button ref={cameraBtn} onClick={onClickCameraOffHandler}> camera OFF </button> <select ref={camerasSelect} onInput={onInputCameraChange}> <option>기본</option> {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} <option ref={cameraOption} value="device" /> </select> </div> <button>방장일 경우 시작버튼?</button> </StCard> ); } export default Card; const StCard = styled.div` margin: 20px; border: 1px solid green; `;
위 코드는 아래의 스텝을 적용시키기 전의 코드 입니다.
getMedia 를 호출하여 미디어를 얻어온 후 (async await 해주어야함) makeConnection() 호출하여
RTC 커넥션을 생성하여 준다 myPeerConnection은 상위 스코프에 선언하여 다른 곳에서 사용할 수 있도록 해준다
useEffect(() => {
socket.emit("join_room", useParam으로 얻어올 roomName)
// 아래의 설명부터는 roomName으로 작성 했습니다
getUserMedia();
makeConnection()
}, []);
function makeConnection() {
myPeerConnection = new RTCPeerConnection();
}
(서버코드)
socket.on("join_room",(roomName) => {
socket.to(roomName).emit("welcome");
})
** 백에서는 "join_room"타입으로 들어온 룸네임을 이용하여 socket.to(roomName).emit("welcome");
해당 룸네임을 구독?연결? 하고있는 클라이언트에게 welcome 타입의 데이터(위코드에선 전달하는데이터없음)
를 전송한다
우리는 영상과 오디오를 연결을 통해 전달하고자 한다 그러므로
우리는 peer to peer 연결 안에
영상과 오디오를 집어넣어야함
얻어온 유저의 영상과 오디오 데이터들을 stream 에 할당해 주었는데
stream.getTracks() 함수를 사용하여 저장한 오디오,비디오 트랙을 가져올 수 있다.
가져온 각각의 트랙을 forEach 함수를 이용하여 myPeerConnection에 addTrack() 함수를 이용하여
넣어준다 이때 매개변수로 stream의 각각의 track과 stream을 매개변수로 전달해 줍니다(stream 은 왜 넣는지 아직 이해못함)
makeConnection 함수를 아래와 같이 수정 합니다.
function makeConnection() {
myPeerConnection = new RTCPeerConnection();
stream.getTracks().forEach(track => myPeerConnection.addTrack(track,stream);
}
다른 클라이언트가 해당 방 입장시? 기존에 방에있던 클라이언트에서수행되는 코드
여기서 offer 를 만들어 줍니다
socket.on("welcome", asynk ()=>{
const offer = await myPeerConnection.createOffer();
}
콘솔로그로 offer 를 확인해보면 알수없는 텍스트가 보여지는데 다른 클라이언트가 참가할수 있도록
초대장을 만들어 주는정도로 이해 해주세요~!
우리는 만들어진 offer로 연결을 구성해야 한다
위 스텝 3의 코드에 (추가)라인을 추가한다
socket.on("welcome", asynk ()=>{
const offer = await myPeerConnection.createOffer();
(추가) myPeerConnection.setLocalDescription(offer)
}
여기서 위의 코드는 기존에 먼저 방에 입장해 있던 클아이언트에서만 실행 되고
새로 방에 입장한 클라이언트에게 실행되는 코드가 아니다 !주의
여기서 우리는 socket.io에게 어떤방에 이 offer데이터를 전달 할건지 알려주어야 하기 때문에 roomName을 같이전달 해줘야 합니다
(누구한테로 이 offer를 전달할지 알려줘야 한다는 의미이며 서버에선
해당 roomName의 방에 입장한 클라이언트들에게 전달하는 용도로 사용)
위 스텝 4의 코드에 (추가)라인을 추가한다
socket.on("welcome", asynk ()=>{
const offer = await myPeerConnection.createOffer();
myPeerConnection.setLocalDescription(offer)
(추가) socket.emit("offer",offer,roomName)
}
이렇게 서버로 offer란 타입으로 offer와 roomName을 전달한다
이때 서버는 offer란 타입으로 들어온 데이터를 받아서
전달받은 roomName을 구독(입장해있는)하고 있는 클라이언트 들에게 "offer"란 타입으로 전달 받은 offer를 전달해준다
(서버코드)
socket.on("offer",(offer,roomName)=>{
socket.to(roomName).emit("offer",offer);
}
다시 브라우저로 돌아와서 서버에서 offer란 타입으로 들어올 offer 데이터를 받을 코드를 작성해준다
해당 코드는 기존에 방에있던 클라이언트가아닌 새로 입장한 클라이언트에서 실행될 코드이다
서버로부터 전달 받은 offer를 이용하여 setRemoteDescription을 설정한다
socket.on("offer", offer => {
myPeerConnection.setRemoteDescription(offer);
}
여기서 우리는 에러를 마주치게 될 것이다 !. 왜냐하면 새로입장하는 클라이언트에게는 myPeerConnection이
아직 존재하지 않기 때눈이다 위의 스텝들을 살펴보면 새로운 클라이언트가 방에 입장 하였을때
기존에 있던 클라이언트에게서만 myPeerConnection이 생성된다
이를 해결해주어야 한다
useEffect( async() => {
await getUserMedia();
await makeConnection()
socket.emit("join_room", roomName)
}, []);
useEffect를 위의 코드로 수정해준다
의미는 스텝 1에서 만든 makeConnection 함수를 호출하여 PeerConnection을 먼저 성생하기 때문에 해당 오류를 막는다
스텝6의 코드를
socket.on("offer", async(offer) => {
myPeerConnection.setRemoteDescription(offer);
const answer = await myPeerConnection.createAnswer();
myPeerConnection.setLocalDescription(answer);
socket.emit("answer", answer, roomName)
}
위와같이 수정하여 Answer를 생성후 setLocalDescription을 answer를 이용하여 설정후 서버로
answer 타입으로 answer를 전달하고 룸네임을 함께 전달해야 한다 이유는 서버에서 어떤 방으로
받은 answer 데이터를 전달할지 알아야 하기때문에
이후서버에서
socket.on("answer",(answer,roomName)=>{
socket.to(roomName).emit("answer",answer)
})
위의 코드를 이용하여 기존의 방에 연결 되어있는 클라이언트에게 answer 타입으로 answer데이터를 전달한다
다시 클라이언트로 돌아와서 아래의 코드를 추가해주자!
아래의 코드는 방에 입장해 있던 클라이언트들에게서 실행 될 코드이다!
socket.on("answer", answer =>{
myPeerConnection.setRemoteDescription(answer);
})
전달받은 answer데이터로 setRemoteDescription 설정을 잡아준다
ice Candidate 란 ? 인터넷 연결 생성
webRTC에 필요한 프로토콜이며, 브라우저가 서로 소통할수 있게 해주는 방법 입니다.
makeConnection 함수를 아래와 같이 수정합니다.
function makeConnection() {
myPeerConnection = new RTCPeerConnection();
myPeerConnection.addEventListener("icecandidate", handleIce)
stream.getTracks().forEach(track => myPeerConnection.addTrack(track,stream)
}
handleIce 함수를 추가해 줍니다.
function handleIce(data){
socket.emit("ice",data.candidate,roomName)
console.log(got ice candidate);
console.log(data);
}
위의 코드는 기존에 입장해 있던 클라이언트와 새로 입장한 클라이언트가 candidate를 주고받는 뜻이며,
icecandidate 이벤트가 언제 일어나는지 어떤 data를 생성하는지 알수 있습니다.
아래의 코드를 클라이언트에서 추가해줍니다
socket.on("ice", ice => {
myPeerConnection.addICECandidae(ice);
})
아래의 코드를 서버에서 추가해줍니다
(서버코드)
socket.on("ice", (ice, roomName) =>{
socket.to(roomName).emit("ice",ice);
})
makeConnection 함수를 아래와 같이 수정합니다.
function makeConnection() {
myPeerConnection = new RTCPeerConnection();
myPeerConnection.addEventListener("icecandidate", handleIce)
myPeerConnection.addEventListener("addstream",handleAddStream)
stream.getTracks().forEach(track => myPeerConnection.addTrack(track,stream)
}
handleAddStream 함수를 추가합니다
function handleAddStream(data) {
console.log("got an stream from my peer")
console.log("Peer's Stream",data.stream);
console.log("My stream", stream);
}
위의 함수를 통해 상호 클라이언트간 상대 클라이언트의 stream을 얻어온 것을 확인 콘솔로 확인 할 수 있습니다.
위의 스텝의 코드들은 socket.io 를 이용한 코드입니다
우리는 프로젝트에서 sockjs와 stomp를 사용하기로 했기때문에
sockjs와 stomp 를 사용한 코드는 아래의 코드를 확인해 주세요:)
/* eslint-disable no-plusplus */
// 외부모듈
import styled from 'styled-components';
import React, { useRef, useEffect, useState } from 'react';
import SockJS from 'sockjs-client';
import { Stomp } from '@stomp/stompjs';
import { useParams } from 'react-router-dom';
function Card() {
let SockJs = new SockJS('http://sangt.shop/ws/chat');
let ws = Stomp.over(SockJs);
let reconnect = 0;
const videoRef = useRef(null);
const muteBtn = useRef(null);
const cameraBtn = useRef(null);
const camerasSelect = useRef(null);
const cameraOption = useRef(null);
const param = useParams();
const [messages, setMessages] = useState([]);
const messageArray = [];
let muted = false;
let cameraOff = false;
let stream;
let myPeerConnection;
function onClickCameraOffHandler() {
stream.getVideoTracks().forEach((track) => {
track.enabled = !track.enabled;
});
if (!cameraOff) {
cameraBtn.current.innerText = 'OFF';
cameraOff = !cameraOff;
} else {
cameraBtn.current.innerText = 'ON';
cameraOff = !cameraOff;
}
}
function onClickMuteHandler() {
stream.getAudioTracks().forEach((track) => {
track.enabled = !track.enabled;
});
if (!muted) {
muteBtn.current.innerText = 'Unmute';
muted = !muted;
} else {
muteBtn.current.innerText = 'Mute';
muted = !muted;
}
}
async function getCameras() {
try {
// 유저의 장치를 얻어옵니다
const devices = await navigator.mediaDevices.enumerateDevices();
// 얻어온 유저의 장치들에서 카메라장치만 필터링 합니다
const cameras = devices.filter((device) => device.kind === 'videoinput');
// 현재내가 사용중인 카메라의 label명을 셀렉트란에 보여주기위한 과정입니다.
// 아래의 if문과 이어서 확인 해주세요
const currentCamera = stream.getVideoTracks()[0];
cameras.forEach((camera) => {
cameraOption.current.value = camera.deviceId;
cameraOption.current.innerText = camera.label;
if (currentCamera.label === camera.label) {
cameraOption.current.selected = true;
}
camerasSelect.current.appendChild(cameraOption.current);
});
} catch (error) {
console.log(error);
}
}
async function getUserMedia(deviceId) {
const initialConstrains = {
video: { facingMode: 'user' },
audio: true,
};
const cameraConstrains = {
audio: true,
video: { deviceId: { exact: deviceId } },
};
try {
stream = await navigator.mediaDevices.getUserMedia(
deviceId ? cameraConstrains : initialConstrains,
);
videoRef.current.srcObject = stream;
if (!deviceId) {
await getCameras();
}
} catch (err) {
console.log(err);
}
}
async function onMessageReceived(payload) {
const message = JSON.parse(payload.body);
if (message.type === 'welcome') {
const offer = await myPeerConnection.createOffer();
myPeerConnection.setLocalDescription(offer);
ws.send(
'/app/chat/message',
{},
JSON.stringify({
type: 'offer',
offer,
roomName: param.roomName,
}),
);
} else if (message.type === 'offer') {
myPeerConnection.setRmoteDescription(message.offer);
const answer = await myPeerConnection.createAnswer();
myPeerConnection.setLocalDescription(answer);
ws.send(
'/app/chat/message',
{},
JSON.stringify({
type: 'answer',
answer,
roomName: param.roomName,
}),
);
} else if (message.type === 'answer') {
myPeerConnection.setRemoteDescription(message.answer);
} else if (message.type === 'ice') {
myPeerConnection.addICECandidae(message.ice);
}
// messageArray.push(message);
// setMessages([...messageArray]);
}
function onConnected(frame) {
ws.subscribe(`/topic/chat/room/${param.roomName}`, onMessageReceived);
ws.send(
'/app/chat/message',
{},
JSON.stringify({
type: 'JOIN',
roomName: param.roomName,
}),
);
}
function onError(error) {
if (reconnect <= 5) {
// eslint-disable-next-line func-names
setTimeout(function () {
console.log('connection reconnect');
SockJs = new SockJS('/ws/chat');
ws = Stomp.over(SockJs);
reconnect++;
// eslint-disable-next-line no-use-before-define
roomSubscribe();
}, 10 * 1000);
}
}
function roomSubscribe(event) {
ws.connect({}, onConnected(), onError());
event.preventDefault();
}
async function onInputCameraChange() {
await getUserMedia(camerasSelect.current.value);
}
function handleIce(data) {
ws.send(
'/app/chat/message',
{},
JSON.stringify({
type: 'ice',
candidate: data.candidate,
roomName: param.roomName,
}),
);
console.log('got ice candidate');
console.log(data);
}
function handleAddStream(data) {
console.log('got an stream from my peer');
console.log("Peer's Stream", data.stream);
console.log('My stream', stream);
}
function makeConnection() {
myPeerConnection = new RTCPeerConnection();
myPeerConnection.addEventListener('icecandidate', handleIce);
myPeerConnection.addEventListener('addstream', handleAddStream);
stream.getTracks().forEach((track) => {
myPeerConnection.addTrack(track, stream);
});
}
useEffect(() => {
async function fetchData() {
await getUserMedia();
await makeConnection();
await roomSubscribe();
}
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<StCard>
Card
<h4>키워드</h4>
<span>OOO님</span>
<div>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoRef}
id="myFace"
autoPlay
playsInline
width={200}
height={200}
>
비디오
</video>
<button ref={muteBtn} onClick={onClickMuteHandler}>
mute
</button>
<button ref={cameraBtn} onClick={onClickCameraOffHandler}>
camera OFF
</button>
<select ref={camerasSelect} onInput={onInputCameraChange}>
<option>기본</option>
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
<option ref={cameraOption} value="device" />
</select>
</div>
<button>방장일 경우 시작버튼?</button>
</StCard>
);
}
export default Card;
const StCard = styled.div`
margin: 20px;
border: 1px solid green;
`;