화면 공유 서비스로 WebRTC 공부하기 1장 - peer 구현
사전에 알아두면 좋을 주의사항
연결 방향성
webRTC 커넥션은 방향성이 존재한다,
만약 peerA
와 peerB
가 서로 화면을 공유하려면 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);
}
});