webRTC
화면 공유 서비스로 WebRTC 공부하기 1장 - peer 구현

화면 공유 서비스로 WebRTC 공부하기 1장 - peer 구현

사전에 알아두면 좋을 주의사항

연결 방향성

webRTC 커넥션은 방향성이 존재한다,

만약 peerApeerB 가 서로 화면을 공유하려면 a(offer) -> b(answer) 연결 하나 b(offer) -> c(answer) 연결 하나 총 두개의 webRTC 연결이 필요하다

새로운 전송 데이터가 생길 경우 재협상 필요

연결 이후에 동영상, 오디오와 같이 새로운 전송 데이터가 생길 경우 재협상 이 이뤄져야한다,

여기서 재협상이란 Offer SDP 를 다시 보내야 함을 말하며, 그 이유는 SDP 에 주고 받은 데이터 항목들이 명시되어있기 때문,

그렇기에 새로운 전송 데이터가 추가되면 재협상(Offer SDP, Answer SDP 교환)을 해야함

Offer SDP, Answer SDP 교환이 끝나도 ICECandiate 교환까지 이뤄지지 않음

RTCPeerConnection 을 이용할 경우, Offer SDP, Answer SDP 교환이 끝나도 ICECandiate 교환까지 이뤄지지 않음,

데이터를 보내는 행위 (data channel 생성 또는 addTrack으로 미디어/오디오 추가 등) 가 발생해야 그 다음 ICECandiate 를 진행함


코드 예시

peer 구현에 대한 내용 포스팅으므로 Signal Server 구현은 포함되지 않음 별도의 Signal Server 구현 포스팅이 추가될 예정이니 그때 참고 필요

Offer

// 0. signalingChannel 시그널링 서버와의 통신을 위한 객체 생성
// 내부적으로 webSocket을 이용하며 상세 구현은 스킵
const signalingChannel = new SignalingChannel();
 
// 1. config 설정
const config: RTCConfiguration = {
    iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
};
 
// 2. connection 객체 생성
const peerConnection = new RTCPeerConnection(config)
 
 
// 3. signalingChannel 에서 받은 message 가 ANSWER 면 peerConnection에 setRemoteDescription() 을 이용하여 응답받은 Answer SDP를 등록하는 이벤트 핸들러 등록
signalingChannel?.addEventListener("message", async (signalMessage) => {
    if (signalMessage.type === WebRTCSignalMessageType.ANSWER) {
    const remoteDesc = new RTCSessionDescription({
        sdp: signalMessage.sdp,
        type: "answer",
    });
 
    await peerConnection.setRemoteDescription(remoteDesc);
 
    console.log("received answer : " + signalMessage.type);
    }
});
 
// 4. 화면을 공유하기 위한 Track 추가
// 꼭 Offer 생성 전에 하는게 좋음, Offer 생성 이후에 하면, 다시 SDP 생성해야함
// 왜? SDP에는 webRTC로 어떤 데이터들을 주고받을지가 정의되어있는데
// 거기에 Track 정보도 들어있음
// 그래서 Offer 생성 후 Track 을 추가하면 미리 생성했던 Offer 에는 추가된 Track 정보가 없음
// 만약 Offer 생성 후에 Track을 추가해야하는 경우가 발생하면 peerConnection에서 negotiationneeded 이벤트를 감지해서 새로운 Offer를 생성후 Send 해야함
navigator.mediaDevices
    .getDisplayMedia({
        video: true,
        audio: true,
    })
    .then((stream) => {
        stream.getTracks().forEach((track) => {
            console.log("stream : " + stream);
            peerConnection.addTrack(track, stream);
        });
    })
    .catch((error) => {
        console.error("stream error" + error);
    });
 
// 5. 채팅 메세지 전송을 위한 DataChannel 을 생성한 후 가상의 함수 setGlobalDataChannel() 를 이용하여  전역으로 등록한다
setGlobalDataChannel(peerConnection.createDataChannel("chatting"));
 
// 6. 데이터 채널 연결되었는지 확인
dataChannel.onopen = () => {
    console.log("데이터 채널이 열렸습니다!");
};
 
// 7. ICE 후보 교환을 위한 이벤트 핸들러 등록
// Offer/Answer 교환 후 데이터 통신이 발생할 때 (Data Channel 또는 Track 추가로 인한 Media Stream 통신) 호출 됨
// (필자는 처음에 peerConnection에 local/remote set이 끝나면 자동 호출인줄 알았으나 그게 아니다, 실제로 데이터를 주고받는 행위가 발생하기 전에 호출된다)
peerConnection.addEventListener("icecandidate", (event) => {
    console.log("on icecandidate");
 
    if (event.candidate) {
    signalingChannel?.send({
        type: WebRTCSignalMessageType.ICECANDIDATE,
        roomId: roomId,
        sdp: JSON.stringify(event.candidate.toJSON()),
    });
    }
});
 
// 8. Offer 생성
const offer = await peerConnection.createOffer();
 
// 9. peerConnection에 setLocalDescription() 를 이용하여 자신이 생성한 Offer SDP 등록
await peerConnection.setLocalDescription(offer);
 
// 10. signalingChannel 로 Offer SDP 를 전송
signalingChannel?.send({
    type: WebRTCSignalMessageType.OFFER,
    roomId: roomId,
    sdp: offer.sdp,
});
 

Answer

 
const signalingChannel = // 0. Offer 설명쪽에서 생성한 signalingChannel 을 그대로 사용
 
 
signalingChannel?.addEventListener("message", async (signalMessage) => {
    
    // 1. 누군가가 나에게 연결 요청 (Offer SDP)를 보냈음을 감지
    if (signalMessage.type === WebRTCSignalMessageType.OFFER) {
 
        // 2. 응답용 peerConnection 을 생성
        // Offer에서 만든 peerConnection 을 재활용하지 않는 이유는
        // peerConnection 에는 방향성이 존재함
        // Offer에서 만든 peerConnection 은 이미 LocalDescription(offer) 가 셋팅되어있음
        // 그러니 재활용 불가
        const peerConnection = new RTCPeerConnection(config);
 
        // 3. peerConnection setRemoteDescription() 를 이용하여 상대방이 보낸 Offer SDP를 저장
        peerConnection.setRemoteDescription(
            new RTCSessionDescription({
            sdp: signalMessage.sdp,
            type: "offer",
            })
        );
 
        // 4. 상대방의 화면을 띄운 Video 엘리먼트 돔 조회
        const remoteVideo = document.querySelector(
            "#remoteVideo"
        ) as HTMLVideoElement;
 
        // 5. 상대방이 Track 을 (예 : 화면 공유) 추가하여 협상할 경우 발생할 이벤트 핸들러 등록
        peerConnection.addEventListener("track", async (event) => {
            const [remoteStream] = event.streams;
            if (remoteVideo) {
                // 6. 위에서 조회한 돔객체의 video Stream을 상대방이 보낸 Stream으로 갈아끼기
                remoteVideo.srcObject = remoteStream;
            } else {
                console.error("Video element #remoteVideo not found");
            }
        });
 
 
        // 6. ICE 후보 교환을 위한 이벤트 핸들러 등록
        // Offer/Answer 교환 후 데이터 통신이 발생할 때 (Data Channel 또는 Track 추가로 인한 Media Stream 통신) 호출 됨
        // (필자는 처음에 peerConnection에 local/remote set이 끝나면 자동 호출인줄 알았으나 그게 아니다, 실제로 데이터를 주고받는 행위가 발생하기 전에 호출된다)
        peerConnection.addEventListener("icecandidate", (event) => {
            console.log("on icecandidate");
 
            if (event.candidate) {
            signalingChannel?.send({
                type: WebRTCSignalMessageType.ICECANDIDATE,
                roomId: roomId,
                sdp: JSON.stringify(event.candidate.toJSON()),
            });
            }
        });
 
 
        // 7. offer와의 dataChannel이 연결되면 이벤트가 호출되는데
        // 이때 onmessage / onopen 등에 대한 이벤트 핸들러를 등록한다 
        peerConnection.addEventListener("datachannel", (event) => {
            const receivedChannel = event.channel;
            receivedChannel.onopen = () => console.log("데이터 채널 연결됨!");
            receivedChannel.onmessage = (e) => {
                console.log("메시지 수신:", e.data);
                setReceivedChatMessages((prevs) => [...prevs, "수신 : " + e.data]);
            };
        });
 
        // 8. Answer 생성
        const answer = await peerConnection.createAnswer();
 
        // 9. Answer 를 LocalDescription 로 저장
        await peerConnection.setLocalDescription(answer);
 
        // 10. Answer Send
        signalingChannel.send({
            type: WebRTCSignalMessageType.ANSWER,
            roomId: roomId,
            sdp: answer.sdp,
            to: signalMessage.from,
        });
        console.log("sent answer to userId : " + signalMessage.from);
    }
});