WebRTC란?
Web Real Time Communication의 약자이고 오픈 소스 프로젝트이며
WebRTC를 사용하면 PlugIn 또는 Framework 없이 웹을 통해 모든 종류의 비디오, 오디오, 데이터를 교환할 수 있다.
WebRTC API
WebRTC는 3가지 Javascript API 를 사용
- MediaStream: getUserMedia라고 불린다. 이 interface는 오디오 및 비디오 트랙을 포함할 수 있는 장치의 media stream을 나타낸다. MediaDevices.getUserMedia() 메서드는 MediaStream을 검색한다.
- RTCPeerConnection: 피어 간의 통신을 허용한다. MediaDevices.getUserMedia()에 의해 접근되는 stream이 이 구성 요소에 추가되며 peer와 ICE후보 간에 교환되는 SDP제안 및 응답 메세지도 처리
- RTCDataChannel: 데이터를 직접 교환하기 위해 browser를 연결하지만 WebSocket과 비교대상임
Peer To Peer 연결은?
한 쪽에서 상대방에게 바로 무언가 넘어간다. 이런 의미이다. 즉 서버를 거치지 않고 원하는 상대방에게 바로 영상 서비스가 된다고 이해하자 (signaling 하기 위해 서버가 필요 ) 나의 브라우저가 상대방의 브라우저에 연결된다는 건가? 맞다! 므찌다..
웹소켓과 WebRTC의 차이
WEBSOCKET의 경우 client1 ----- WebSocket server ------ client2
"웹소켓은 클라이언트 간에 연결이 가능하지만 서버는 메시지를 라우팅해야한다. "
WebRTC의 경우 client1 ------ (Signalling Server) -- client2
"server를 통해 메세지를 계속 보내고 받을 필요가 없다. 연결 설정 및 제어만 담당! "
구글 크롬 브라우저가 시그널링 서버에게 " 여기가 내가 있는 곳이고 port야 유남셍!? "
즉 브라우저는 서버한테 configuration만 전달하고 서버는 peer to peer 에게 서로의 위치를 알려준다.
WebRTC는 클라이언트의 연결을 설정하고 제어하기 위해 server만 있으면 된다. (라우팅이 필요가 없음)
이 프로세스를 시그널링(Signalling )이라고 하고 그 서버를 Signalling Server라고 부른다.
그렇다면 본격적으로 연결을 생성해보자
1.서버 연결 설정: 각 브라우저(구글, 파이어폭스) 연결 설정, 서버(소켓)을 통해서 이어줄거다.
let myPeerConnection: RTCPeerConnection;;
function makeConnection() {
//누구나 myStream에 접촉 할 수 있도록, 크롬 브라우저와 FireFox에 만드는거다.
//✅ 크롬 브라우저(Peer B)와 FireFox 브라우저(Peer A) peert-to-peer connection 연결을 만든다.
myPeerConnection= new RTCPeerConnection();
}
2. addStream: 현재 연결되어있는 캠 또는 카메라 stream의 데이터를 가져다가 연결을 만든다. 이유는 영상과 오디오를 연결을 통해 전달하려고하는 목적 때문이다. peer-to-peer 연결 안에다가 영상과 오디오를 집어 넣어야 한다.
현재 연결되어 있는 track들을 확인해보니 2개의 트랙을 확인
console.log(myStream.getTracks())
영상과 오디오 데이터를 주고 받고 할 때 그 데이터들을 peer connection에 넣어야한다. 코딩으로 구현 해보자.
let myPeerConnection: RTCPeerConnection;;
function makeConnection() {
//🌟누구나 myStream에 접촉 할 수 있도록, 크롬 브라우저와 FireFox에 만드는거다.
myPeerConnection= new RTCPeerConnection(); //iceServers
// ✅양쪽 브라우저에서 카메라와 마이크의 데이터 stream을 받아서 그것들을 연결 안에 집어 넣는다.
myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream))
}
그런데! 아직까지 연결은 하지 않았다. 지금까지는 각 브라우저들을 따로 구성했을 뿐이다.
3. 연결 설정
위 그림을 참조하여 createOffer를 만들어야 하는데 Peer A(FireFox 브라우저)가 offer를 만드는 행위의 주체이다.
useEffect(() => {
let socket = io(`${WS_BASE_PATH}`, {
transports:['websocket'],
},
)
setSocket(socket)
socket.on("welcom", async () => {
//✅FireFox 브라우저가 offer를 생성 및 행위 주체 (FireFox 브라라우저에서 방에 참가하는 행위)
// chrome브라우저에서 실행되는 코드이다. (크롬 브라우저는 FireFox에서 참가하는 offer행위를 리스닝)
const offer = await myPeerConnection.createOffer();
console.log(offer);
})
return () => {
socket.disconnect();
};
}, [])
console.log(offer)의 결과
- 참고로 OS가 window의 경우 다른 브라우저 간에 한개의 트랙(카메라)을 불러오지 못 한다. 크롬 부라우저 창을 2개 띄워주고 다른 브라우저라고 생각하고 실행하면된다!
- 리눅스 또는 Mac OS에서 실험 테스트 필요
prototype을 보면 RTCSessionDescription을 보니 실시간 세션에서 일어날 일에 대한 설명으로 추측할 수 있다.
SDP가 뭐지? 또 열심히 찾아보았다.. "나는 누구이고 어디있고 " 이게 바로 우리가 찾던 signalling 서버에게 전달해야 될 Configuration으로 보인다.
Peer B (크롬), setLocalDescription: 크롬에서만 실행하는 것이고 웹RTC 연결을 초기화하고 설정
useEffect(() => {
let socket = io(`${WS_BASE_PATH}`, {
transports:['websocket'],
},
)
setSocket(socket)
socket.on("welcom", async () => {
const offer = await myPeerConnection.createOffer();
//✅ 리스닝하는 크롬 브라우저에서만 실행
myPeerConnection.setLocalDescription(offer);
})
return () => {
socket.disconnect();
};
}, [])
이제는 Peer B가 FireFox(offer를 보낸 상대방)에게 offer를 잘 받았다고 답장해줘야한다.
useEffect(() => {
let socket = io(`${WS_BASE_PATH}`, {
transports:['websocket'],
},
)
setSocket(socket)
socket.on("welcom", async () => {
const offer = await myPeerConnection.createOffer();
myPeerConnection.setLocalDescription(offer);
socket.emit("offer", offer, roomName)
})
return () => {
socket.disconnect();
};
}, [])
서버
@SubscribeMessage('offer')
rtc_receiveOffer(@MessageBody() {offer, roomName}) {
this.conferenceRoomToSockets[roomName].forEach((s) => {
s.emit("offer", offer)
})
}
Peer B(크롬)의 답장
useEffect(() => {
let socket = io(`${WS_BASE_PATH}`, {
transports:['websocket'],
},
)
setSocket(socket)
socket.on("welcom", async () => {
console.log("someone Join")
const offer = await myPeerConnection.createOffer();
myPeerConnection.setLocalDescription(offer); // 리스닝하는 크롬 브라우저에서만 실행
console.log(offer);
socket.emit("offer", offer, roomName)
})
socket.on("offer", (offer) => {
✅console.log("Peer B(크롬 브라우저)의 답장:")
console.log(offer);
} )
return () => {
socket.disconnect();
};
}, [])
Peer B의 답장, 이것이 Signalling process이고 직접 대화를 할 수 있다.
setRemoteDescription
멀리 떨어진 peer의 description을 세팅한다는 것의 의미는 PeerB(크롬)의 상대방의 peer description을 나의 description에서 설정한다 !
이 에러는 아직까지 myPeerConnection이 존재하지 않는다.
useEffect(() => {
let socket = io(`${WS_BASE_PATH}`, {
transports:['websocket'],
},
)
setSocket(socket)
socket.on("welcom", async () => {
// Peer A(파이어 폭스)가 offer 생성
const offer = await myPeerConnection.createOffer();
// PeerA, FireFox 브라우저에서만 실행
myPeerConnection.setLocalDescription(offer);
// Peer A가 Peer B에 보낸다.
socket.emit("offer", offer, roomName)
})
socket.on("offer", (offer) => {
//Peer B(크롬)에서만 실행하며(내peer의 description에서 설정)'offer'를 받아서 '상대방의 peer의 description'을 세팅한다.
myPeerConnection.setRemoteDescription(offer); //🚨 아직까지 myPeerConnection 존재x
} )
return () => {
socket.disconnect();
};
}, [])
이유는! Peer B에서 아직까지 발현되지 않았다.
async function makeConnection() {
//누구나 myStream에 접촉 할 수 있도록, 크롬 브라우저와 FireFox에 만드는거다.
myPeerConnection= new RTCPeerConnection(); //2번, iceServers
myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream));
}
async function initialCall(eventValue:any) {
await getUserMedia(eventValue);
makeConnection(); //✅ myPeerConnection
}
const handleWelcomeSubmit = (event:any) => {
event.preventDefault();
const {roomId} = getValues();
if(roomId === "") return;
initialCall(cameraId); //✅순서를 join_room에 보내기 전으로 변경!
socket!.emit("join_room", roomId) //✅핑퐁이 되기 전 까지 PeerB에 myPeerConnection이 연결x
roomName = roomId //방에 참가 했을 때 나중에 쓸 수 있도록 방 이름을 변수에 저장
setRoomId("")
setCamON((prev) => !prev)
}
answer는 offer과 비슷해 핑퐁 코드를 통한 이해
useEffect(() => {
let socket = io(`${WS_BASE_PATH}`, {
transports:['websocket'],
},
)
setSocket(socket)
socket.on("welcom", async () => {
// Peer A(파이어 폭스)가 offer 생성
const offer = await myPeerConnection.createOffer();
// PeerA, FireFox 브라우저에서만 실행
await myPeerConnection.setLocalDescription(offer);
console.log("PeerA just Join!")
console.log(offer);
// Peer A가 Peer B에 보낸다.
socket.emit("offer", offer, roomName)
})
socket.on("offer", async (offer) => {
//Peer B(크롬)에서만 실행하며(내peer의 description에서 설정)'offer'를 받아서 '상대방의 peer의 description'을 세팅한다.
myPeerConnection.setRemoteDescription(offer);
const answer = await myPeerConnection.createAnswer();✅
myPeerConnection.setLocalDescription(answer); ✅
socket.emit("answer", answer, roomName) ✅
} )
socket.on("answer", (answer) => {
console.log("answer:")
console.log(answer)
myPeerConnection.setRemoteDescription(answer) ✅
console.log("a:")
console.log(a)
})
return () => {
socket.disconnect();
};
}, [])
Nestjs 의 서버 answer 코드
@SubscribeMessage('answer')
rtc_receiveAnswer(@MessageBody() info) {
this.logger.log(info);
this.conferenceRoomToSockets[info[1]].forEach((s) => {
s.emit("answer", info[0])
})
}
ICE candidate (아래 요약된 도식화 그림에서 상세 설명)
- 호스트와 피어 간에 최적의 연결(경로)을 설정하기 위한 기술
- SDP가 외부 NAT(Network Address Translators), IP 주소와 port 제한 처리 방법을 인식하기 위한 기술
"ICE candidate 가 없으면 peer와 정상적으로 연결이 안되겠다" 라는 생각이 든다. 이 정도로 이해하고 일단 킵고잉
function makeConnection() {
myPeerConnection= new RTCPeerConnection();
myPeerConnection.addEventListener("icecandidate", handleIce); //✅icecandidate 확인
myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream));
}
function handleIce(data: any) {
console.log("got ice candidate ");
console.log(data);
}
어느 시점에 ICE candidate를 받을까? 정답은 Peer A가 answer를 받을 때
(그런데! setRemoteDescription 에러가 보인다)
이제 해야 될 것은 바로 ! candidate들을 다시 다른 브라우저로 보내는거다. 왜냐하면 candidate들은 브라우저들에 의해 만들어지기 때문이다. (어떻게 보내야 되지.. 엄청 고민함.. ) 쉽게 생각하면 candidate는 브라우저가 "우리가 이러한 방법으로 소통을 해!" 이런 느낌이다.
send candidate & addstream 코드 구현
function makeConnection() {
myPeerConnection= new RTCPeerConnection();
myPeerConnection.addEventListener("icecandidate", handleIce);
myPeerConnection.addEventListener("addstream", handleAddStream);
myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream));
}
// ✅ ice 이벤트 언제? answer를 받을 때
function handleIce(data: any) {
socket?.emit("ice", data.candidate, roomName)
}
// ✅ addstream 이벤트 언제? 상대 peer가 참가할 때 (직접 console을 찍어서 확인하는 것을 추천)
function handleAddStream(data:any) {
peerVideoRef!.current!.srcObject = data.stream; //상대 peer의 stream을 video 속성 scrObject에 동적으로 할당
}
add candidate 구현
socket.on("ice", (ice) => {
myPeerConnection.addIceCandidate(ice);
})
그런데 상대방의 카메라를 변경해도 나의 화면에서 변경 감지가 되지 않는 문제가 발생!
문제 해결방법 : "보내는 stream, track을 변경해주면 되겠다!" 라는 생각이 들었다.
- RTCPeerConnection.getSenders() 메서드 확인
const handleCameraChange = async (event:any) => {
setCameraId(event.target.value);
await getUserMedia(event.target.value);
if(myPeerConnection){
// ✅카메라 변경시, myStream = await navigator.mediaDevices.getUserMedia(deviceId)가 재실행 전제
const videoTrack = myStream.getVideoTracks()[0]; //✅새로운 stream의 track을 받는다.
const videoSender = myPeerConnection
.getSenders()//✅ WebRTC 세션 중에 전송되는 미디어를 동적으로 수정해야하는 경우
console.log("videoSender:");
console.log(videoSender);
}
}
- 정리: 보낼 track의 kind가 "video"를 찾아서 새로운 stream의 track(선택한 카메라) 로 변경 한다.
코드 구현
const handleCameraChange = async (event:any) => {
setCameraId(event.target.value);
await getUserMedia(event.target.value);
if(myPeerConnection){
//✅1.(카메라를 선택하면)새로운 stream의 track을 받는다.
const videoTrack = myStream.getVideoTracks()[0];
//✅2. 보낼 track의 kind를 video를 찾아서 새로운 videoTrack으로 변경해줘라
const videoSender = myPeerConnection
.getSenders()
.find((sender) => sender.track?.kind === "video");
videoSender?.replaceTrack(videoTrack); //새로운 videoTrack
}
}
ICE candidate & STUN 서버
- 상대의 IP, PORT을 어떻게 알까? 방화벽은 어떻게 뚫어? 바로 STUN 서버가 필요하다.
- ICE를 보완하는 프로토콜 stun서버는 구글에서 제공하는 public stun 서버를 통한 구현
- ( 실제 본인 app을 만들 때 stun 서버가 필요하지 않을까 ? 보안 문제가 없을까.. )
const iceServers = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302'},
{ urls: 'stun:stun1.l.google.com:19302'},
{ urls: 'stun:stun2.l.google.com:19302'},
{ urls: 'stun:stun3.l.google.com:19302'},
{ urls: 'stun:stun4.l.google.com:19302'},
]
}
function makeConnection() {
//누구나 myStream에 접촉 할 수 있도록, 크롬 브라우저와 FireFox에 만드는거다.
myPeerConnection = new RTCPeerConnection(iceServers);
myPeerConnection.addEventListener("icecandidate", handleIce);
myPeerConnection.addEventListener("addstream", handleAddStream);
myStream.getTracks().forEach(track => myPeerConnection.addTrack(track, myStream));
}
WebRTC 도식화를 통한 정리 및 요약
1. 발신자 클라이언트가 - call -> 수신자 클라이언트
2. 호출자는 SDP(Session Description Protocol)를 사용하여 제안을 생성하고 다른 피어(수신자)에게 보낸다.
* 피어란? 호출자 또는 피호출자
* sdp란? sdp는 세션의 이름, 연결 정보(네트워크 주소 및 포트), 미디어 유형(예: 오디오, 비디오), 미디어 포맷(예: PCM, H.264), 미디어 속성(예: 대역폭, 코덱 옵션) 서로 주고 받았다.
3. 수신자는 SDP 설명이 포함된 응답 메시지로 제안에 응답
4. 호출자 및 수신자 피어는 브라우저 코덱(coder-decode) 또는 메타데이터의 정보를 포함하는 로컬 및 원격 세션 설명을 설정하고 그 후 호출에 사용되는 media 기능을 인식
*코덱이란? data stream을 인코딩하거나 디코딩하는 프로그램, 알고리즘 또는 장치
5. 그러나 SDP는 외부 NAT(Network Address Translators), IP 주소와 port 제한 처리 방법을 인식하지 못해서 아직 media 데이터를 연결하고 교환할 수 없다 .
6. 5번의 문제의 해결은 ICE(대화형 연결 설정)에 의해 목표 달성!
대화형 연결 설정은 어떻게 작동 하는가?
7. ICE candidate 는 사용 가능한 네트워크 연결을 수집하고 NAT 및 방화벽을 통과하기 위해 STUN(Session Traversal Utilities for NAT) 및 TURN (Traversal Using Relays around NAT)프로토콜을 사용
8. ICE가 연결을 처리하는 방법의 기본지식
- 우선 UDP를 통해 직접 피어 연결을 시도
- UDP가 실패하면 TCP를 시도
- UDP, TCP 모두 실패시 먼저 UDP와 함께 STUN 서버를 사용하여 피어를 연결
ICE candidate 상세 이해
- STUN Client는 자신이 사용할 공인 IP주소를 알 수 없으므로 STUN서버에게 자신의 공인 IP주소를 요청
- STUN메시지가 방화벽을 지날 때 네트워크 계층(3계층)의 IP와 전송 계층(4계층)의 port가 바뀐다.
- STUN서버는 패킷의 IP헤더와 UDP(전송계층)헤더의 값(클라이언트의 공인주소)과 STUN메시지 안에 있는 STUN클라이언트의 IP주소와 UDP포트넘버(클라이언트의 사설 주소)를 비교
- STUN서버는 두 개의 서로 다른 주소에 대한 바인딩 테이블을 생성하고 요청에 대한 응답 메시지에 공인 IP주소를 보낸다.
- STUN클라이언트는 VoIP시그널링을 생성할 때 사설IP가 아닌 공인 IP주소를 사용
- NAT보안 정책이 강하면 STUN의 확장 TURN 서버를 사용 (그러나 리소스의 낭비가 심하다고 한다. )
시그널링 서버와 STUN 서버의 차이
- 시그널링 서버는 연결 설정, 메타데이터 교환 및 네트워크 정보 교환과 같은 통신의 제어를 담당하는 반면, STUN 서버는 클라이언트의 네트워크 환경을 파악하고 통신을 설정하고 관리
Data Channels