Signaling - 2편 Broswer with RTCPeerConnection (5)

박근수·2024년 2월 20일
0

저의 블로그를 보기 전에 꼭 읽으셔야 하는 것들이 있습니다. 저는 제가 경험한걸 적을 뿐이고 이해도가 탁월하다고 할 수도 없습니다.

WebRTC connectivity
Lifetime of WebRTC Sessioin
Signaling and Video Call
Signaling
Connecting

위의 3가지는 MDN의 설명이고 아래 2개는 각 과정을 자세하게 설명한 영어 문서입니다. 저는 JS의 구현을 MDN의 설명을 주로 참고하였습니다.

RTCPeerConnection

RTCPeerConnectioin

브라우저로 WebRTC를 사용하기 위해서 사용하는 API입니다.
Peer를 연결하고 유지하고 관리할 수 있습니다. EventTarget 인터페이스를 상속받고 있어 Event 위주로 구현합니다. 지속적으로 Peer 통신을 하고 있는 상태에서 네트워크 상태에 따라 연결 상태가 변할 수 있습니다. 이때 Event가 발생하도록 API가 설계되어 있어서 EventListner 중심으로 코딩하는게 좋은 것 같습니다.

연결 주의 사항

  • KMS를 사용하는 Spring Signaling Server와 Broswer가 PeerConnection을 맺는 순서입니다. 모든 경우에 통용되지 않습니다.
  • MDN의 WebRTC Connectivity를 읽으시면 어떻게 연결되어야 하는지 알 수 있습니다.
  • STOMP 연결된 상황에서 PeerConnection을 진행합니다.

1. local peer는 RTCPeerConnection 객체를 생성합니다.

const Streaming = ({ isStreaming }) => {
    const pcRef = useRef(new RTCPeerConnection(PCConfig));
    const pc = pcRef.current;

이 때 파라미터를 줘야하는데 이는 Stun/Turn 서버 객체를 줍니다.

export const PCConfig = {
    iceServers:[
        {
            urls:"stun:stun.l.google.com:19302"
        },
        {
            urls:"turn:URL:PORT",
            username:"name",
            credential:"password"
        },
    ]
}

이 정보를 추후 IceCandidate에서 사용합니다.
미리 만들어 놓은 Coturn의 정보를 입력하시면 됩니다.

2. 브라우저 Camera,Audio 등록하기

navigator.mediaDevices.getUserMedia({video:true,audio:false})
  .then((stream) => {
  for (const track of stream.getTracks()){
    pc.addTrack(track,stream)
  }
  console.log("buskerId : "+ userId)
  videoElement.srcObject = stream
}).catch(error => {
  if (error.name === "OverconstrainedError") {
    console.error(
      `The resolution ${video.width.exact}x${video.height.exact} px is not supported by your device.`,
    );
  } else if (error.name === "NotAllowedError") {
    console.error("You need to grant this page permission to access your camera and microphone.",);
  } else {
    console.error(`getUserMedia error: ${error.name}`, error);
  }
})

3. SDP 교환

  1. 처음 Connection을 맺을 때 SDP를 교환해야합니다. local은 createOffer()를 통해 자신의 세션 정보를 생성합니다. 이 때 addTrack이나 DataChannel을 미리 생성해두고 createOffer()를 하는게 정확한 세션 정보를 포함하는 SDP를 만들 수 있습니다.

  2. 생성된 SDP를 setLocalDescription을 통해 등록합니다.

  3. STOMP를 통해서 Signaling Server에게 자신의 sdpOffer를 전달합니다.

  4. Server는 Offer를 받고 SDPAnswer를 생성 후 Peer에게 전송합니다.

  5. Broswer는 Answer를 받고 자신의 RemoteDescription에 등록합니다.

    pc.addTrack을 통해서 우리가 어떤 정보를 보낼 것인지 세션 정보를 등록할 수 있습니다. 연결이 맺어지면 PC는 등록된 track을 stream으로 상대 peer에게 전달합니다.

pc.onnegotiationneeded = (event) => {
  console.log(koreaTime+ " Negotiation을 진행합니다.")
  pc.createOffer({ // 1
  })
    .then((offer) => {
    console.log("sdp offer created") // sdp status
    pc.setLocalDescription(offer) // 2
      .then((r) => {
      client.publish({ // 3 
        destination: `/app/api/busker/${userId}/offer`,
        body: JSON.stringify({
          userId,
          offer,
        })
      })
    })
  })
    .catch((error) => {
    console.log(error)
  })
}
  • onnegotiationneeded는 PeerConnection의 연결 협상이 필요하게 되면 호출되는 EventHandler입니다. Track에 미디어가 추가되거나 통신환경이 변경되어서 재협상을 해야할 때도 사용됩니다.
client.onConnect = (frame) => {
  console.log("streaming stomp : " + frame);
  // sdpOffer를 보내고 Answer를 받음
  client.subscribe(`/busker/${userId}/sdpAnswer`, (res) => {
    const offerResponse = JSON.parse(res.body);
    const answerId = offerResponse.id;
    const response = offerResponse.response;
    const sdpAnswer = offerResponse.sdpAnswer;

    // console.log("Received SDP Answer \n");
    console.log("Received SDP Answer \n"+offerResponse)
    pc.setRemoteDescription({
      type: "answer",
      sdp: sdpAnswer
    }).then(() => {
      console.log("Remote description set successfully");
    }).catch((error) => {
      console.error("Error setting remote description:", error);
    });
  });

STOMP에서 subcribe는 되도록 onConnect 내에서 등록하는게 안정적인 것 같습니다. 메세지를 보낼 때는 EventHandler로 필요한 상황에 보내도록 하고 메세지는 늘 받을 수 있는 상태로 관리하는 것입니다.

4. Ice Candidate

pc.onicecandidate = (event) => { //setLocalDescription이 불러옴.
            if (event.candidate) {
                // console.log("Client Send Ice Candidate : [ " + event.candidate.candidate + " ] ")
                // candidateList.push({iceCandidate: event.candidate})
                client.publish({
                    destination: `/app/api/busker/${userId}/iceCandidate`,
                    body: JSON.stringify({iceCandidate: event.candidate})
                });
            }
            if ( event.target.iceGatheringState === 'complete') {
                console.log('done gathering candidates - got iceGatheringState complete');
            }
        }

SDP교환과 ICE Candidate는 동기적이지 않습니다. SDP교환이 다 이루어지고 나서 ICE Candidate를 교환하지 않습니다. setLocalDescription()이 끝나면 Local측에서 ICE Candidate를 수집하고 ICE 과정을 진행합니다.
onicecandidate는 setLocalDescription가 실행시키는 Event입니다. Broswer가 ice Candidate를 탐색하고 나면 서버에 전송합니다. 서버도 그것을 받으면 자신의 Ice Candidate를 탐색하고 Broswer에 전송합니다.

client.subscribe(`/busker/${userId}/iceCandidate`, (res) => {
  const iceResponse = JSON.parse(res.body);
  if (iceResponse.id === "iceCandidate") {
    console.log(koreaTime + " server send ice \n" + iceResponse.candidate.candidate)
    const icecandidate = new RTCIceCandidate(iceResponse.candidate)
    pc.addIceCandidate(icecandidate)
      .then()
  }
})

Broswer가 IceCandidate를 받고 나면 addIceCandidate에 등록합니다.

pc.oniceconnectionstatechange = (event) => {
    if (pc.iceConnectionState === 'new'){
      console.log(koreaTime +' 피어 연결을 시작 합니다. ')
    }
    console.log(koreaTime +' ICE 연결 상태:', pc.iceConnectionState);
    if (pc.iceConnectionState === 'connected') {
      console.log(pc.getStats().then(r=> console.log(koreaTime+" "+r)))
      console.log(koreaTime +' 피어 간 연결이 성공적으로 수립되었습니다.');
    } else if (pc.iceConnectionState === 'disconnected'){

      console.log(koreaTime +' 피어 간 연결이  끊어졌습니다.')
    } else if(pc.iceConnectionState === 'failed') {
      pc.restartIce()
      console.log(koreaTime +' 피어 간 연결이  실패.');
    }
};
pc.onconnectionstatechange = (event) => { // 데이터 연결 상태 확인
    console.log('데이터 연결 상태:', pc.connectionState);
    if (pc.connectionState === 'connected') {
      console.log(koreaTime +' 데이터 연결이 확립되었습니다.');
    } else if (pc.connectionState === 'disconnected') {
      console.log(koreaTime +' 데이터 연결이 끊어졌습니다.');
    }
};

이것으로 RTCPeerConnection의 중요한 연결이 끝났습니다.
연결 상태를 Event로 모니터링하면서 필요한 경우 대처하면 됩니다.

STOMP로 RTCPeerConnection을 구현하는 코드를 다 쓰고 나니 200줄이 조금 안되는군요. 물론 서버측 코드도 있습니다만...
이 적은 코드를 작성하기 위해서 공식문서와 갖은 블로그와 영어 문서를 2주 동안 매일 읽고 공부한 것 같습니다. 포스팅에 제가 했던 고민과 나름의 답을 나누고 싶었는데 너무 난잡해지는 것 같아서 다 쳐냈습니다. 제가 몇 줄 코드를 적고 뭐가 뭐라고 말씀 드리는 것보다 위의 문서들을 읽는게 WebRTC를 이해하시는데 훨씬 나은 것 같다고 생각했기 때문입니다.

그래도 참고해보시라고 RTCPeerConnection을 관리하는 컴포넌트의 전체 코드를 공개합니다.

import React, {useEffect, useRef, useState} from "react";
import CustomText from "../components/CustomText";
import {PCConfig} from "../WebRTC/RTCConfig";
import * as StompJS from "@stomp/stompjs";
import * as SockJS from "sockjs-client";
import {koreaTime} from "../WebRTC/PCEvent";
import {useRecoilState} from "recoil";
import {userInfoState} from "../RecoilState/userRecoilState";
import {useNavigate} from "react-router-dom";

// const userId = "buskerID"
let makingOffer = false


const Streaming = ({ isStreaming }) => {
    const [userInfo, setUserInfo] = useRecoilState(userInfoState);
    const pcRef = useRef(new RTCPeerConnection(PCConfig));
    const clientRef = useRef(
        new StompJS.Client({
            brokerURL: `${process.env.REACT_APP_API_WEBSOCKET_BASE_URL}`,
        })
    );
    const pc = pcRef.current;
    const client = clientRef.current;
    const userId = userInfo.userId
    const navigate = useNavigate();
    // Set Peer Connection
    useEffect(() => {
        if (isStreaming === false){
            pc.getSenders().forEach(sender => pc.removeTrack(sender))
            client.deactivate()
            pc.close()

            client.publish({
                destination: `app/api/busker/${userId}/stopBusking`
            })
            navigate("/")
        }
        const videoElement = document.getElementById("streamingVideo")
        pc.onicecandidate = (event) => { //setLocalDescription이 불러옴.
            if (event.candidate) {
                // console.log("Client Send Ice Candidate : [ " + event.candidate.candidate + " ] ")
                // candidateList.push({iceCandidate: event.candidate})
                client.publish({
                    destination: `/app/api/busker/${userId}/iceCandidate`,
                    body: JSON.stringify({iceCandidate: event.candidate})
                });
            }
            if ( event.target.iceGatheringState === 'complete') {
                console.log('done gathering candidates - got iceGatheringState complete');
            }
        }
        pc.oniceconnectionstatechange = (event) => {
            if (pc.iceConnectionState === 'new'){
                console.log(koreaTime +' 피어 연결을 시작 합니다. ')
            }
            console.log(koreaTime +' ICE 연결 상태:', pc.iceConnectionState);
            if (pc.iceConnectionState === 'connected') {
                console.log(pc.getStats().then(r=> console.log(koreaTime+" "+r)))
                console.log(koreaTime +' 피어 간 연결이 성공적으로 수립되었습니다.');
            } else if (pc.iceConnectionState === 'disconnected'){

                console.log(koreaTime +' 피어 간 연결이  끊어졌습니다.')
            } else if(pc.iceConnectionState === 'failed') {
                pc.restartIce()
                console.log(koreaTime +' 피어 간 연결이  실패.');
            }
        };
        pc.onconnectionstatechange = (event) => { // 데이터 연결 상태 확인
            console.log('데이터 연결 상태:', pc.connectionState);
            if (pc.connectionState === 'connected') {
                console.log(koreaTime +' 데이터 연결이 확립되었습니다.');
            } else if (pc.connectionState === 'disconnected') {
                console.log(koreaTime +' 데이터 연결이 끊어졌습니다.');
            }
        };
        pc.onnegotiationneeded = (event) => {
            console.log(koreaTime+ " Negotiation을 진행합니다.")
            pc.createOffer({
            })
                .then((offer) => {
                    console.log("sdp offer created") // sdp status
                    pc.setLocalDescription(offer)
                        .then((r) => {
                            client.publish({
                                destination: `/app/api/busker/${userId}/offer`,
                                body: JSON.stringify({
                                    userId,
                                    offer,
                                })
                            })
                        })
                })
                .catch((error) => {
                    console.log(error)
                })
        }

        const constraints = {video: false, audio: true}

         navigator.mediaDevices.getUserMedia(constraints)
            .then((stream) => {
                for (const track of stream.getTracks()){
                    pc.addTrack(track,stream)
                }
                console.log("buskerId : "+ userId)
                videoElement.srcObject = stream
            }).catch(error => {
                if (error.name === "OverconstrainedError") {
                    console.error(
                        `The resolution ${constraints.video.width.exact}x${constraints.video.height.exact} px is not supported by your device.`,
                    );
                } else if (error.name === "NotAllowedError") {
                    console.error("You need to grant this page permission to access your camera and microphone.",);
                } else {
                    console.error(`getUserMedia error: ${error.name}`, error);
                }
        })

        if (typeof WebSocket !== 'function') {
            client.webSocketFactory = function () {
                console.log("Stomp error sockjs is running");
                return new SockJS(`${process.env.REACT_APP_API_BASE_URL}/api`);
            };
        }

        client.onConnect = (frame) => {
            console.log("streaming stomp : " + frame);
            // sdpOffer를 보내고 Answer를 받음
            client.subscribe(`/busker/${userId}/sdpAnswer`, (res) => {
                const offerResponse = JSON.parse(res.body);
                const answerId = offerResponse.id;
                const response = offerResponse.response;
                const sdpAnswer = offerResponse.sdpAnswer;

                // console.log("Received SDP Answer \n");
                console.log("Received SDP Answer \n"+offerResponse)
                pc.setRemoteDescription({
                    type: "answer",
                    sdp: sdpAnswer
                }).then(() => {
                    console.log("Remote description set successfully");
                }).catch((error) => {
                    console.error("Error setting remote description:", error);
                });
            });
            client.subscribe(`/busker/${userId}/iceCandidate`, (res) => {
                const iceResponse = JSON.parse(res.body);
                if (iceResponse.id === "iceCandidate") {
                    console.log(koreaTime + " server send ice \n" + iceResponse.candidate.candidate)
                    const icecandidate = new RTCIceCandidate(iceResponse.candidate)
                    pc.addIceCandidate(icecandidate)
                        .then()
                }
            })
        }
        client.onStompError = (frame) => {
            console.log('Broker reported error: ' + frame.headers['message']);
            console.log('Additional details: ' + frame.body);
        };

        client.activate();

        return () => {
            // Cleanup function to be executed on component unmount
            console.log("Closing WebSocket connection and Peer Connection");
            client.deactivate(); // Close the WebSocket connection
            pc.close(); // Close the Peer Connection
        };

    }, [isStreaming]);
    return (
        <>
            <video id="streamingVideo" style={{width: '100%'}} autoPlay controls></video>
        </>
    )
}

export default Streaming;
profile
개성이 확실한편

0개의 댓글