본문 바로가기

개발/WebRTC

5. WebRTC 구현하기(1:N SFU)

WebRTC 이론부터 구현까지
1. WebRTC 정리하기

2. WebRTC 구현 방식
3. WebRTC 구현하기(1:1 P2P)
4. WebRTC 구현하기(1:N P2P)
5. WebRTC 구현하기(1:N SFU)
6. WebRTC 성능 비교(P2P vs SFU)

1. 서론

지난 시간에는 WebRTC를 이용한 1:N P2P 통신에 대해서 포스팅했다. SFU 방식에 대해 포스팅을 할까 말까 고민을 했는 데 그래도 하는 게 낫지 않을까 싶어 이렇게 글을 남긴다. SFU는 Media Server의 한 종류로 그에 대한 설명은 여기를 눌러 지난 포스팅을 확인해보기 바란다. 미디어 서버는 Kurento와 mediasoup 등을 이용하여 상용화 단계에서 사용한다. 하지만 글쓴이는 이론을 바탕으로 미디어 서버, 그중 SFU 서버를 구성해보고자 했다. 이론적인 설명은 기존의 포스팅에서 다뤘으니 위의 여기 링크를 눌러 확인해보기 바란다. 이론적인 바탕은 모두 안다고 가정하고 구현에 대한 포스팅을 작성해보겠다.

2. 실제 코드

2-1. SFU Server(Node.js)

주의할 점: socket.io version=2.3.0을 사용하셔야 합니다.

1. 변수

let receiverPCs = {};
let senderPCs = {};
let users = {};
let socketToRoom = {};

2. socket 이벤트

joinRoom

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);
    }
});

senderOffer

주의할 점: createAnswer에서 offerToReceiveAudio, offerToReceiveVideo를 모두 true로 두는 것은 user로부터 audio와 video stream을 모두 받아와야 하기 때문이다.

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);
    }
});

senderCandidate

socket.on('senderCandidate', async(data) => {
    try {
        let pc = receiverPCs[data.senderSocketID];
        await pc.addIceCandidate(new wrtc.RTCIceCandidate(data.candidate));
    } catch (error) {
        console.log(error);
    }
});

receiverOffer

주의할 점: createAnswer에서 offerToReceiveAudio, offerToReceiveVideo를 모두 false로 두는 것은 user로부터 audio와 video stream을 받지 않기 때문이다. (지금 생성한 RTCPeerConnection은 기존에 있던 user의 stream을 보내기 위한 연결이다.)

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);
    }
});

receiverCandidate

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);
    }
});

disconnect

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);
    }
});

3. 함수 설명

isIncluded

const isIncluded = (array, id) => {
    let len = array.length;
    for (let i = 0; i < len; i++) {
        if (array[i].id === id) return true;
    }
    return false;
}

createReceiverPeerConnection

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;
}

createSenderPeerConnection

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;
}

getOtherUsersInRoom

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;
}

deleteUser

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];
}

closeReceiverPC

const closeRecevierPC = (socketID) => {
    if (!receiverPCs[socketID]) return;

    receiverPCs[socketID].close();
    delete receiverPCs[socketID];
}

closeSenderPCs

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];
}

2-2. Client(ReactJS, Typescript)

주의할 점: socket.io-client version=2.3.0, @types/socket.io-client version=1.4.34을 사용하셔야 합니다.

1. 변수 설명

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'
        }
    ]
}

 

userEnter

newSocket.on('userEnter', (data: {id: string}) => {
    createReceivePC(data.id, newSocket);
});

2. Socket 수신 이벤트

allUsers

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);
    }
});

userExit

newSocket.on('userExit', (data: {id: string}) => {
    receivePCs[data.id].close();
    delete receivePCs[data.id];
    setUsers(users => users.filter(user => user.id !== data.id));
});

getSenderAnswer

newSocket.on('getSenderAnswer', async (data: {sdp: RTCSessionDescription}) => {
    try {
        await sendPC.setRemoteDescription(new RTCSessionDescription(data.sdp));
    } catch (error) {
        console.log(error);
    }
});

getSenderCandidate

newSocket.on('getSenderCandidate', async(data: {candidate: RTCIceCandidateInit}) => {
    try {
        if (!data.candidate) return;
        sendPC.addIceCandidate(new RTCIceCandidate(data.candidate));
    } catch (error) {
        console.log(error);
    }
});

getReceiverAnswer

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);
    }
});

getReceiverCandidate

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);
    }
});

3. MediaStream 설정

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}`);
});

4. 함수 설명

createReceivePC

const createReceivePC = (id: string, newSocket: SocketIOClient.Socket) => {
    try {
        let pc = createReceiverPeerConnection(id, newSocket);
        createReceiverOffer(pc, newSocket, id);
    } catch (error) {
        console.log(error);
    }
}

 

createSenderOffer

주의할 점: 자신의 MediaStream을 보내기 위한 RTCPeerConnection이므로 offerToReceiveAudio, offerToReceiveVideo는 모두 false로 둔다.

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);
    }
}

 

createReceiverOffer

주의할 점: 다른 user의 MediaStream을 받기 위한 RTCPeerConnection이므로 offerToReceiveAudio, offerToReceiveVideo는 모두 true로 둬야 한다.

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);
    }
}

 

createSenderPeerConnection

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;
}

 

createReceiverPeerConnection

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;
}

5. 본인과 상대방의 video 렌더링

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>
);

3. 느낀 점

미디어 서버를 처음으로 개발해보니 생각보다 부하가 엄청나다는 걸 처음 알았다. 물론 테스트 자체를 Client와 Server가 나의 한 PC에서 실행되다 보니 부하가 기하급수적으로 증가하는 걸 알고는 있지만, 미디어 서버를 구현하려면 돈이 많아야 되는구나..라는 걸 다시금 느꼈다. 처음부터 SFU 서버까지 구현해보리라 생각하고 시작한 포스팅은 아니었지만 구현하다 보니 이것저것 호기심이 생겨서 더더 해보자 하다 보니 이렇게 구현하게 됐다. 구현 중 큰 어려움은 없었지만 createOffer, createAnswer 시에 offerToReceiveAudio, offerToReceiveVideo 속성에 대한 잘못된 이해로 고생을 좀 했다. 다른 사람들은 이런 고생을 안 했으면 좋겠단 마음으로 주의할 점에 꼼꼼히 기록해놨다. MCU 서버를 구현해볼지 안 할지는 사실 잘 모르겠다. 해보고 싶긴 한데 시간이 날지가 의문이기 때문에... 혹여나 누군가 요청한다면 도전해볼 의향은 있다. 다만, 미디어 서버 자체를 상용화해서 사용하는 경우에는 위의 서론에서 말한 대로 Kurento나 mediasoup 등을 이용하는 것으로 알고 있으므로 직접 개발하는 것이 목적이 아닌 상용화가 목적이라면 저 두 사이트를 방문해서 공식문서를 기반으로 개발하기를 권장한다.

[GitHub]

https://github.com/Seung3837/Typescript-ReactJS-WebRTC-1-N-SFU

[참고]

surprisecomputer.tistory.com/8

surprisecomputer.tistory.com/12

 

'개발 > WebRTC' 카테고리의 다른 글

6. WebRTC 성능 비교(P2P vs SFU)  (4) 2021.01.20
4. WebRTC 구현하기(1:N P2P)  (0) 2021.01.19
3. WebRTC 구현하기(1:1 P2P)  (9) 2021.01.18
2. WebRTC 구현 방식  (3) 2021.01.17
1. WebRTC 정리하기  (0) 2021.01.16