게시: 2021년 1월 15일
지난 시간에는 WebRTC를 이용한 1:N P2P 통신에 대해서 포스팅했다. SFU 방식에 대해 포스팅을 할까말까 고민을 했는 데 그래도 하는 게 낫지 않을까 싶어 이렇게 글을 남긴다. SFU는 Media Server의 한 종류로 그에 대한 설명은 여기를 눌러 지난 포스팅을 확인해보기 바란다. 미디어 서버는 Kurento와 mediasoup 등을 이용하여 상용화 단계에서 사용한다. 하지만 글쓴이는 이론을 바탕으로 미디어 서버, 그 중 SFU 서버를 구성해보고자 했다. 이론적인 설명은 기존의 포스팅에서 다뤘으니 위의 여기 링크를 눌러 확인해보기 바란다. 이론적인 바탕은 모두 안다고 가정하고 구현에 대한 포스팅을 작성해보겠다.
let receiverPCs = {};
let senderPCs = {};
let users = {};
let socketToRoom = {};
socket.on("joinRoom", data => {
try {
let allUsers = getOtherUsersInRoom(data.id, data.roomID);
io.to(data.id).emit("allUsers", { users: allUsers });
} catch (error) {
console.log(error);
}
});
socket.on("senderOffer", async data => {
try {
socketToRoom[data.senderSocketID] = data.roomID;
let pc = createReceiverPeerConnection(
data.senderSocketID,
socket,
data.roomID
);
await pc.setRemoteDescription(data.sdp);
let sdp = await pc.createAnswer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
await pc.setLocalDescription(sdp);
socket.join(data.roomID);
io.to(data.senderSocketID).emit("getSenderAnswer", { sdp });
} catch (error) {
console.log(error);
}
});
socket.on("senderCandidate", async data => {
try {
let pc = receiverPCs[data.senderSocketID];
await pc.addIceCandidate(new wrtc.RTCIceCandidate(data.candidate));
} catch (error) {
console.log(error);
}
});
socket.on("receiverOffer", async data => {
try {
let pc = createSenderPeerConnection(
data.receiverSocketID,
data.senderSocketID,
socket,
data.roomID
);
await pc.setRemoteDescription(data.sdp);
let sdp = await pc.createAnswer({
offerToReceiveAudio: false,
offerToReceiveVideo: false,
});
await pc.setLocalDescription(sdp);
io.to(data.receiverSocketID).emit("getReceiverAnswer", {
id: data.senderSocketID,
sdp,
});
} catch (error) {
console.log(error);
}
});
socket.on("receiverCandidate", async data => {
try {
const senderPC = senderPCs[data.senderSocketID].filter(
sPC => sPC.id === data.receiverSocketID
);
await senderPC[0].pc.addIceCandidate(
new wrtc.RTCIceCandidate(data.candidate)
);
} catch (error) {
console.log(error);
}
});
socket.on("disconnect", () => {
try {
let roomID = socketToRoom[socket.id];
deleteUser(socket.id, roomID);
closeRecevierPC(socket.id);
closeSenderPCs(socket.id);
socket.broadcast.to(roomID).emit("userExit", { id: socket.id });
} catch (error) {
console.log(error);
}
});
const isIncluded = (array, id) => {
let len = array.length;
for (let i = 0; i < len; i++) {
if (array[i].id === id) return true;
}
return false;
};
const createReceiverPeerConnection = (socketID, socket, roomID) => {
let pc = new wrtc.RTCPeerConnection(pc_config);
if (receiverPCs[socketID]) receiverPCs[socketID] = pc;
else receiverPCs = { ...receiverPCs, [socketID]: pc };
pc.onicecandidate = e => {
//console.log(`socketID: ${socketID}'s receiverPeerConnection icecandidate`);
socket.to(socketID).emit("getSenderCandidate", {
candidate: e.candidate,
});
};
pc.oniceconnectionstatechange = e => {
//console.log(e);
};
pc.ontrack = e => {
if (users[roomID]) {
if (!isIncluded(users[roomID], socketID)) {
users[roomID].push({
id: socketID,
stream: e.streams[0],
});
} else return;
} else {
users[roomID] = [
{
id: socketID,
stream: e.streams[0],
},
];
}
socket.broadcast.to(roomID).emit("userEnter", { id: socketID });
};
return pc;
};
const createSenderPeerConnection = (
receiverSocketID,
senderSocketID,
socket,
roomID
) => {
let pc = new wrtc.RTCPeerConnection(pc_config);
if (senderPCs[senderSocketID]) {
senderPCs[senderSocketID].filter(user => user.id !== receiverSocketID);
senderPCs[senderSocketID].push({ id: receiverSocketID, pc: pc });
} else
senderPCs = {
...senderPCs,
[senderSocketID]: [{ id: receiverSocketID, pc: pc }],
};
pc.onicecandidate = e => {
//console.log(`socketID: ${receiverSocketID}'s senderPeerConnection icecandidate`);
socket.to(receiverSocketID).emit("getReceiverCandidate", {
id: senderSocketID,
candidate: e.candidate,
});
};
pc.oniceconnectionstatechange = e => {
//console.log(e);
};
const sendUser = users[roomID].filter(user => user.id === senderSocketID);
sendUser[0].stream.getTracks().forEach(track => {
pc.addTrack(track, sendUser[0].stream);
});
return pc;
};
const getOtherUsersInRoom = (socketID, roomID) => {
let allUsers = [];
if (!users[roomID]) return allUsers;
let len = users[roomID].length;
for (let i = 0; i < len; i++) {
if (users[roomID][i].id === socketID) continue;
allUsers.push({ id: users[roomID][i].id });
}
return allUsers;
};
const deleteUser = (socketID, roomID) => {
let roomUsers = users[roomID];
if (!roomUsers) return;
roomUsers = roomUsers.filter(user => user.id !== socketID);
users[roomID] = roomUsers;
if (roomUsers.length === 0) {
delete users[roomID];
}
delete socketToRoom[socketID];
};
const closeRecevierPC = socketID => {
if (!receiverPCs[socketID]) return;
receiverPCs[socketID].close();
delete receiverPCs[socketID];
};
const closeSenderPCs = socketID => {
if (!senderPCs[socketID]) return;
let len = senderPCs[socketID].length;
for (let i = 0; i < len; i++) {
senderPCs[socketID][i].pc.close();
let _senderPCs = senderPCs[senderPCs[socketID][i].id];
let senderPC = _senderPCs.filter(sPC => sPC.id === socketID);
if (senderPC[0]) {
senderPC[0].pc.close();
senderPCs[senderPCs[socketID][i].id] = _senderPCs.filter(
sPC => sPC.id !== socketID
);
}
}
delete senderPCs[socketID];
};
const [socket, setSocket] = useState<SocketIOClient.Socket>();
const [users, setUsers] = useState<Array<IWebRTCUser>>([]);
let localVideoRef = useRef<HTMLVideoElement>(null);
let sendPC: RTCPeerConnection;
let receivePCs: any;
const pc_config = {
iceServers: [
// {
// urls: 'stun:[STUN_IP]:[PORT]',
// 'credentials': '[YOR CREDENTIALS]',
// 'username': '[USERNAME]'
// },
{
urls: "stun:stun.l.google.com:19302",
},
],
};
newSocket.on("userEnter", (data: { id: string }) => {
createReceivePC(data.id, newSocket);
});
newSocket.on("allUsers", (data: { users: Array<{ id: string }> }) => {
let len = data.users.length;
for (let i = 0; i < len; i++) {
createReceivePC(data.users[i].id, newSocket);
}
});
newSocket.on("userExit", (data: { id: string }) => {
receivePCs[data.id].close();
delete receivePCs[data.id];
setUsers(users => users.filter(user => user.id !== data.id));
});
newSocket.on(
"getSenderAnswer",
async (data: { sdp: RTCSessionDescription }) => {
try {
await sendPC.setRemoteDescription(
new RTCSessionDescription(data.sdp)
);
} catch (error) {
console.log(error);
}
}
);
newSocket.on(
"getSenderCandidate",
async (data: { candidate: RTCIceCandidateInit }) => {
try {
if (!data.candidate) return;
sendPC.addIceCandidate(new RTCIceCandidate(data.candidate));
} catch (error) {
console.log(error);
}
}
);
newSocket.on(
"getReceiverAnswer",
async (data: { id: string; sdp: RTCSessionDescription }) => {
try {
let pc: RTCPeerConnection = receivePCs[data.id];
await pc.setRemoteDescription(data.sdp);
} catch (error) {
console.log(error);
}
}
);
newSocket.on(
"getReceiverCandidate",
async (data: { id: string; candidate: RTCIceCandidateInit }) => {
try {
let pc: RTCPeerConnection = receivePCs[data.id];
if (!data.candidate) return;
pc.addIceCandidate(new RTCIceCandidate(data.candidate));
} catch (error) {
console.log(error);
}
}
);
navigator.mediaDevices
.getUserMedia({
audio: true,
video: {
width: 240,
height: 240,
},
})
.then(stream => {
if (localVideoRef.current) localVideoRef.current.srcObject = stream;
localStream = stream;
sendPC = createSenderPeerConnection(newSocket, localStream);
createSenderOffer(newSocket);
newSocket.emit("joinRoom", {
id: newSocket.id,
roomID: "1234",
});
})
.catch(error => {
console.log(`getUserMedia error: ${error}`);
});
const createReceivePC = (id: string, newSocket: SocketIOClient.Socket) => {
try {
let pc = createReceiverPeerConnection(id, newSocket);
createReceiverOffer(pc, newSocket, id);
} catch (error) {
console.log(error);
}
};
const createSenderOffer = async (newSocket: SocketIOClient.Socket) => {
try {
let sdp = await sendPC.createOffer({
offerToReceiveAudio: false,
offerToReceiveVideo: false,
});
await sendPC.setLocalDescription(new RTCSessionDescription(sdp));
newSocket.emit("senderOffer", {
sdp,
senderSocketID: newSocket.id,
roomID: "1234",
});
} catch (error) {
console.log(error);
}
};
const createReceiverOffer = async (
pc: RTCPeerConnection,
newSocket: SocketIOClient.Socket,
senderSocketID: string
) => {
try {
let sdp = await pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
await pc.setLocalDescription(new RTCSessionDescription(sdp));
newSocket.emit("receiverOffer", {
sdp,
receiverSocketID: newSocket.id,
senderSocketID,
roomID: "1234",
});
} catch (error) {
console.log(error);
}
};
const createSenderPeerConnection = (
newSocket: SocketIOClient.Socket,
localStream: MediaStream
): RTCPeerConnection => {
let pc = new RTCPeerConnection(pc_config);
pc.onicecandidate = e => {
if (e.candidate) {
newSocket.emit("senderCandidate", {
candidate: e.candidate,
senderSocketID: newSocket.id,
});
}
};
pc.oniceconnectionstatechange = e => {
console.log(e);
};
if (localStream) {
console.log("localstream add");
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
} else {
console.log("no local stream");
}
// return pc
return pc;
};
const createReceiverPeerConnection = (
socketID: string,
newSocket: SocketIOClient.Socket
): RTCPeerConnection => {
let pc = new RTCPeerConnection(pc_config);
// add pc to peerConnections object
receivePCs = { ...receivePCs, [socketID]: pc };
pc.onicecandidate = e => {
if (e.candidate) {
newSocket.emit("receiverCandidate", {
candidate: e.candidate,
receiverSocketID: newSocket.id,
senderSocketID: socketID,
});
}
};
pc.oniceconnectionstatechange = e => {
console.log(e);
};
pc.ontrack = e => {
setUsers(oldUsers => oldUsers.filter(user => user.id !== socketID));
setUsers(oldUsers => [
...oldUsers,
{
id: socketID,
stream: e.streams[0],
},
]);
};
// return pc
return pc;
};
interface IWebRTCUser {
id: string;
email: string;
stream: MediaStream;
}
interface Props {
email: string;
stream: MediaStream;
muted?: boolean;
}
const Video = ({ email, stream, muted }: Props) => {
const ref = useRef<HTMLVideoElement>(null);
const [isMuted, setIsMuted] = useState<boolean>(false);
useEffect(() => {
if (ref.current) ref.current.srcObject = stream;
if (muted) setIsMuted(muted);
});
return (
<Container>
<VideoContainer ref={ref} muted={isMuted} autoPlay></VideoContainer>
<UserLabel>{email}</UserLabel>
</Container>
);
};
return (
<div>
<video
style={{
width: 240,
height: 240,
margin: 5,
backgroundColor: "black",
}}
muted
ref={localVideoRef}
autoPlay
></video>
{users.map((user, index) => {
return (
<Video key={index} email={user.email} stream={user.stream} />
);
})}
</div>
);
미디어 서버를 처음으로 개발해보니 생각보다 부하가 엄청나다는 걸 처음 알았다. 물론 테스트 자체를 Client와 Server가 나의 한 PC에서 실행되다 보니 부하가 기하급수적으로 증가하는 걸 알고는 있지만, 미디어 서버를 구현하려면 돈이 많아야 되구나.. 라는 걸 다시금 느꼈다. 처음부터 SFU 서버까지 구현해보리라 생각하고 시작한 포스팅은 아니었지만 구현하다보니 이것저것 호기심이 생겨서 더더 해보자 하다보니 이렇게 구현하게 됐다. 구현 중 큰 어려움은 없었지만 createOffer, createAnswer 시에 offerToReceiveAudio, offerToReceiveVideo 속성에 대한 잘못된 이해로 고생을 좀 했다. 다른 사람들은 이런 고생을 안 했으면 좋겠단 마음으로 주의할 점에 꼼꼼히 기록해놨다. MCU 서버를 구현해볼지 안 할지는 사실 잘 모르겠다. 해보고 싶긴 한데 시간이 날지가 의문이기 때문에... 혹여나 누군가 요청한다면 도전해볼 의향은 있다. 다만, 미디어 서버 자체를 상용화해서 사용하는 경우에는 위의 서론에서 말한대로 Kurento나 mediasoup 등을 이용하는 것으로 알고 있으므로 직접 개발하는 것이 목적이 아닌 상용화가 목적이라면 저 두 사이트를 방문해서 공식문서를 기반으로 개발하기를 권장한다.