최종 프로젝트 때 화상회의를 구현하기 위해서 mediasoup, nextjs, socketIO를 이용하여 화상회의를 구현을 완료하였다. 다른 팀원들에게 내 생각도 좀 정제해서 남길 겸 글로 세세하게 작성을 바로 시작해보겠다...
mediasoup을 이용해서 n:m 화상회의를 구현하기 위해서는 mediasoup에 대한 이해가 우선순위라고 생각을 한다.
여러 문서나 동영상에서 굉장히 어렵게 설명을 하고 있어서 애로사항이 많았지만 내가 이해한 mediasoup을 구성하는 요소는 클라이언트에서는 4가지, 서버에서는 클라이언트 + 2가지가 될 것 같다.
구성 요소들에 대해서 간략하게 소개하자면
device, router, worker는 mediasoup을 이용하기 위한, 세팅을 위한 도구들이다.
한번 세팅을 하고나면 만질일이 거의 없다.
개인적으로 더 중요한 개념은 transport, consumer, producer라는 개념이다.
transport는 전송 통로를 의미한다. 이 통로를 통해서 media stream을 주고 받아서 상대방에게 내 화면이 공유되거나 웹 캠을 공유할 수 있다. 이러한 것을 produce라고 한다. 반대로 받는 것을 consume이라고 한다.
하나의 media stream은 하나의 producer 혹은 consumer가 돼야 한다.
여러 개의 media stream은 여러 개의 producer와 여러 개의 consumer가 돼야 한다.
한 클라이언트와 서버 사이의 transport는 2개만 있으면 된다. 이때, sendTransport와 recvTransport가 있으며 sendTransport는 produce를 위한 transport라고 생각하면 되고 recvTransport는 consumer을 위한 transport라고 생각하면 된다.
서버 측에서도 클라이언트마다 2개의 transport만 존재하면 된다.
간략한 시나리오를 통해서 어떻게 동작하는지에 대해서 먼저 설명을 하고 코드와 함께 덧붙여서 설명을 하겠다.
서버와 클라이언트1만 존재하고 다른 클라이언트는 존재하지 않는다. 이때 클라이언트 1이 media stream을 공유하는 상황을 가정한다면 아래의 절차대로 진행된다.
순서대로 이렇게 흘러가게 된다. 서버와 주고 받는게 굉장히 많아서 헷갈리는데 나중에 그림을 통해서 좀 더 이해하기 쉽게 내용을 추가하도록 하겠다. 코드를 제공하는 방식은 필요한 내용들만 쏙쏙 뽑아와서 보여주는 식으로 작성을 하겠다.
const mediasoup = require("mediasoup");
let worker;
let router;
async function createWorker() {
worker = await mediasoup.createWorker({
rtcMinPort: 2000,
rtcMaxPort: 2100,
});
router = await worker.createRouter({
mediaCodecs: [
{
kind: "audio",
mimeType: "audio/opus",
clockRate: 48000,
channels: 2,
},
{
kind: "video",
mimeType: "video/VP8",
clockRate: 90000,
parameters: {
"x-google-start-bitrate": 1000,
},
},
],
});
return worker;
}
(async() {createWorker()})()
// client
useEffect(() => {
socket.emit("join-room", spaceId, currentPlayerId);
},[]);
// server
const SEND_TRANSPORT_KEY = 'sendTransport';
const RECV_TRANSPORT_KEY = 'recvTransport';
let clients = {};
socket.on("join-room", (spaceId, playerId) => {
clients[playerId] = {
spaceId: spaceId,
[SEND_TRANSPORT_KEY]: null,
[RECV_TRANSPORT_KEY]: null,
producers: [],
consumers: [],
};
clients[playerId].spaceId = spaceId;
socket.join(spaceId);
});
// client
const {
loadDevice,
getRtpCapabilitiesFromDevice,
createSendTransportWithDevice,
createRecvTransportWithDevice,
} = useDevice();
const { sendTransport, createSendTransport } = useSendTransport({
socket,
createSendTransportWithDevice,
playerId: currentPlayerId,
});
const { recvTransport, createRecvTransport } = useRecvTransport({
socket,
createRecvTransportWithDevice,
playerId: currentPlayerId,
});
useEffect(() => {
socket.emit("create-transport", currentPlayerId, handleCreatedTransport);
},[]);
async function handleCreatedTransport(
rtpCapabilities: RtpCapabilities,
sendTransportParams: TransPortParams,
recvTransportParams: TransPortParams
) {
await loadDevice(rtpCapabilities);
createSendTransport(sendTransportParams);
createRecvTransport(recvTransportParams);
}
// server
socket.on("create-transport", async (playerId, onTransportCreated) => {
const client = clients[playerId];
try {
const rtpCapabilities = getRtcCapabilities();
const sendTransport = await createWebRtcTransport();
const recvTransport = await createWebRtcTransport();
setTransport(client, sendTransport, SEND_TRANSPORT_KEY);
setTransport(client, recvTransport, RECV_TRANSPORT_KEY);
onTransportCreated(
rtpCapabilities,
getTransportParams(sendTransport),
getTransportParams(recvTransport)
);
} catch (error) {
console.error("while creating transport error:", error);
}
});
코드 내용이 많으므로 하나하나 씩 뜯어보자면...
device를 사용하기 위해서는 load가 돼야 한다. load를 위해서는 서버에서 rtpCapabilities라는 내용을 받아와서 세팅을 해줘야한다.
이러한 device가 하는 역할은 transport를 위한 rtpCapabilities를 제공하거나, recvTransport, sendTransport를 제공하는 역할을 한다.
import { Device } from "mediasoup-client";
import { useCallback, useEffect, useRef } from "react";
export default function useDevice() {
const deviceRef = useRef<Device>(); // device 인스턴스를 위한 ref 객체
useEffect(() => {
if (deviceRef.current) return;
deviceRef.current = new Device();
}, []);
async function loadDevice(rtpCapabilities: RtpCapabilities) {
const device = deviceRef.current;
if (device && device.loaded) return;
try {
await device?.load({ routerRtpCapabilities: rtpCapabilities });
} catch (error) {
console.error("load device error", error);
}
}
const createSendTransportWithDevice = useCallback(
(params: TransPortParams) => {
return deviceRef.current!.createSendTransport(params);
},
[deviceRef.current]
);
const createRecvTransportWithDevice = useCallback(
(params: TransPortParams) => {
return deviceRef.current!.createRecvTransport(params);
},
[deviceRef.current]
);
function getRtpCapabilitiesFromDevice() {
return deviceRef.current!.rtpCapabilities;
}
return {
device: deviceRef.current!,
loadDevice,
createSendTransportWithDevice,
createRecvTransportWithDevice,
getRtpCapabilitiesFromDevice,
};
}
send transport는 서버 측에 media stream을 제공하기 위한 transport이다.
send transport도 세팅을 위해서는 paramameter들이 필요로 한데 먼저 서버 측에다가 send transport를 만들어 달라고 요청을 하면 이후에 만들어진 trnasport의 정보를 기반으로 클라이언트에서도 동일하게 세팅하여 서로 매칭을 시켜준다.
이때, client 쪽에서 send Transport가 만들어진 이후에는 connect 이벤트가 발생하고, 이 다음에 trnasport를 이용하여 produce 메서드를 실행하여 producer를 만드는데 이때 produce 이벤트가 발생한다.
connect 이벤트는 보일러 플레이트 느낌으로 callback과 errorback은 이벤트에 해당 콜백함수들이 있고 저것을 통해서 mediasoup 내부적으로 transport의 parameters가 전송되었다고 알리는 역할을 한다고 한다.
produce 이벤트는 produce 메서드를 통해서 새로운 producer가 생성이 될 때 발생하는 이벤트로 producer를 매칭시키기 위한 parameter들에 대한 정보를 제공하고 이를 통해서 서버 측에서도 동일하게 producer를 만들어서 서로 매칭을 시켜주는 역할을 한다.
import {
DtlsParameters,
ProduceParameter,
SendTransportType,
TransPortParams,
} from "@/components/video-conference/types/ScreenShare.types";
import { useRef } from "react";
import { Socket } from "socket.io-client";
interface Props {
socket: Socket;
createSendTransportWithDevice: (params: TransPortParams) => SendTransportType;
playerId: string;
}
export default function useSendTransport({
socket,
createSendTransportWithDevice,
playerId,
}: Props) {
const sendTransportRef = useRef<SendTransportType | null>(null);
function createSendTransport(sendTransportParams: TransPortParams) {
if (sendTransportRef.current) return sendTransportRef.current;
try {
const sendTransport = createSendTransportWithDevice(sendTransportParams);
sendTransport.on("connect", handleSendTransportConnect);
sendTransport.on("produce", handleSendTransportProduce);
sendTransportRef.current = sendTransport;
return sendTransport;
} catch (error) {
console.error("create send transport error: ", error);
}
}
async function handleSendTransportConnect(
{ dtlsParameters }: DtlsParameters,
callback: Function,
errorBack: Function
) {
try {
// 미디어 데이터를 안전하게 전송하기 위하여 dtlsParameters를 서버측으로 전송하여 핸드쉐이크를 한다.
socket.emit("transport-send-connect", { dtlsParameters, playerId });
// transport에 parameters들이 전송되었다는 것을 알려주는 역할
callback();
} catch (error) {
errorBack(error);
}
}
async function handleSendTransportProduce(
parameter: ProduceParameter,
callback: Function,
errorBack: Function
) {
try {
socket.emit(
"transport-send-produce",
{ parameter, playerId },
(data: { id: string }) => {
const { id } = data;
callback({ id });
}
);
} catch (error) {
errorBack(error);
console.error("handle local producer Transport Produce error:", error);
}
}
return { sendTransport: sendTransportRef, createSendTransport };
}
recv transport는 서버 측에 있는 media stream을 수신받기 위한 transport이다.
recv transport도 세팅을 위해서도 paramameter들이 필요로 한데 먼저 서버 측에다가 recv transport를 만들어 달라고 요청을 하면 이후에 서버에서 만들어진 trnasport의 정보를 기반으로 클라이언트에서도 동일하게 세팅하여 서로 매칭을 시켜준다.
마찬가지로 client 쪽에서 recv Transport가 만들어진 이후에 connect 이벤트가 발생한다.
import {
DtlsParameters,
RecvTransportType,
TransPortParams,
} from "@/components/video-conference/types/ScreenShare.types";
import { useRef } from "react";
import { Socket } from "socket.io-client";
interface Props {
socket: Socket;
createRecvTransportWithDevice: (params: TransPortParams) => RecvTransportType;
playerId: string;
}
export default function useRecvTransport({
socket,
createRecvTransportWithDevice,
playerId,
}: Props) {
const recvTransportRef = useRef<RecvTransportType | null>(null);
function createRecvTransport(params: TransPortParams) {
if (recvTransportRef.current) return recvTransportRef.current;
try {
const recvTransport = createRecvTransportWithDevice(params);
recvTransport.on("connect", handleRecvConsumerTransportConnect);
recvTransportRef.current = recvTransport;
return recvTransport;
} catch (error) {
console.error("create recv transport error: ", error);
}
}
async function handleRecvConsumerTransportConnect(
{ dtlsParameters }: DtlsParameters,
callback: Function,
errorBack: Function
) {
try {
socket.emit("transport-recv-connect", {
dtlsParameters,
playerId,
});
callback();
} catch (error) {
errorBack(error);
}
}
return { recvTransport: recvTransportRef, createRecvTransport };
}
내용이 많아서 여러 시리즈로 나눠서 필요한 부분 부분들을 나름대로 나눠서 작성을 이어나가겠다...