Angular와 WebRTC를 이용한 화상 채팅
WebRTC를 이용한 화상 채팅
webRTC 및 angular를 이용한 화상채팅 구현하기
개발환경
- angular 16.1.0 (frontEnd)
- nodeJs v20.10.0 (backend)
용어정리
- ICE (Interactive Connectivity Establishment)
ICE는 두 단말이 서로 통신할 수 있는 최적의 경로를 찾을 수 있도록 도와주는 프레임워크이다.
- STUN(Session Traversal Utilities for NAT)
공개 주소를 발견하거나 peer간의 직접 연결을 맏는 등 라우터의 제한을 결정하며 ICE를 보완하는 프로토콜이다.
간단하게 말하면 STUN 서버는 해당 Peer의 Public IP 주소를 보내는 역할을 한다.
- candidate
STUN, TURN등으로 찾아낸 연결 가능한 네트워크 주소들을 Candidate(후보) 라고 할 수 있다.
- SDP (Session Description Protocol)
해상도나 형식, 코덱, 암호화등의 멀티미이더 컨텐츠의 연결을 설명하기 위한 표준입니다. 이러한 것이 두개의 peer가 다른 한쪽이 데이터가 전송되고 있다는 것을 알게 해 줍니다.
- ICE Candidate Gathering
Local Address, Server Reflexive Address, Relayed Address 등 통신 가능한 주소들을 모두 가져와 가장 최적의 경로를 찾아서 연결시켜준다.
개념도
caller와 callee가 가진 sdp를 서로 교환함으로서 실제 RTC가 이루어진다.
- caller 는 sdp를 만들어 callee에게 전달한다(peerConnection.createOffer)
- peerConnection.createOffer()
- peerConnection.setLocalDescription(new RTCSessionDescription(sdp))
- callee 는 caller의 sdp를 받아서 응답을 한다.(peerConnection.createAnswer)
- peerConnection.createAnswer()
- peerConnection.setRemoteDescription()
- caller 는 callee의 sdp를 받아서 Icd에 추가한다.
- peerConnection.setRemoteDescription()
- peerConnection.addIceCandidate()
step 1 : (front-end: caller) 미디어 환경 체크 및 socket의 chatting room에 접근
navigator.mediaDevices?.getUserMedia({
audio: true,
video: {
width: 240,
height: 240,
},
})
.then(stream => {
if (this.localVideoRef) this.localVideoRef.srcObject = stream; // 본인의 video를 play한다.
this.localStream = stream;
this.socket.emit("joinRTCRoom", roomName, {User Info});
})
.catch(error => {
console.log(`getUserMedia error: ${error}`);
});
step 2 : (back-end) room join 및 기존 회원들 및 caller에게 room에 있는 사용자들을 리스트업 한다.
socket.on("joinRTCRoom", (room, user) => {
if (typeof users[room] === 'undefined') {
users[room] = [];
}
user.id = socket.id; // 사용자들의 구분을 위해 고유값이 socket id를 user.id에 맵핑한다. (uuid는 통신에서 어떤 사용자인지 확인하는데 매우 중요한 부분이다.)
users[room].push(user);
socket.join(room); // room에 조인한다.
// 본인은 제외한 룸에 있는 사용자 출력
const members = users[room].filter(
user => user.id !== socket.id
);
io.to(room).emit('usersInRoom', members); // 현재 방에 있는 사용자에게 정보를 전달한다.
});
step 3 : (front-end: caller) node로 부터 받은 기존 회원정보를 이용하여 RTCPeerConnection을 만든다.
this.createPeerConnection(uuid); // uuid는 기존 사용자의 고유아이디로 여기서는 node로 전달받은 기존 사용자의 socket.id 가 입력된다.
private createPeerConnection(uuid: string): RTCPeerConnection {
const peerConnectionConfig = { // 무료로 이용가능한 iceSevers (https://gist.github.com/sagivo/3a4b2f2c7ac6e1b5267c2f1f59ac6c6b)
'iceServers': [
{'urls': 'stun:stun.l.google.com:19302'},
{'urls': 'stun:stun.services.mozilla.com'},
]
};
const peerConnection = new RTCPeerConnection(peerConnectionConfig); // RTC PeerConnction을 만든다.
this.peerConnections = { ...this.peerConnections, [uuid]: peerConnection };
// onicecandidate 이벤트 수신
// RTCPeerConnection 을 맺으면 연결가능한 네트워크 주소등이 있는데 이를 candidate라고 말하며 이러한 것을 caller와 callee는 서로 정보를 공유한다.
peerConnection.onicecandidate = e => {
if (e.candidate) {
this.socket.emit("candidate", {
candidate: e.candidate,
candidateTo: uuid,
});
}
};
// oniceconnectionstatechange 이벤트 수신
peerConnection.oniceconnectionstatechange = e => {
console.log(new Date().getTime(), 'oniceconnectionstatechange', e);
};
// ontrack 이벤트 수신
peerConnection.ontrack = e => {
this.remoteVideoRef.srcObject = e.streams[0]; // 상대의 video 를 play 한다.
};
// navigator.mediaDevices.getUserMedia 로 부터 처리되는 본인의 video를 peerConnection의 track에 포함시킨다.
// 상대와 peerConnection 이 될때 이 부분은 상대의 peerConnection.ontrack 으로 전달된다.
this.localStream.getTracks().forEach((track: any) => {
peerConnection.addTrack(track, this.localStream);
});
return peerConnection;
};
step 4 : (front-end: caller) createOffer
step 3의 RTCPeerConnection 정상적으로 처리되면 createOffer 를 이용하여 sdp 생성합니다.
caller는 전화를 걸기전 PeerConnection 을 만든다.
const peerConnection = this.createPeerConnection(callee.socketId)
peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
})
.then(sdp => { // createOffer를 실행하면 sdp를 받는다.
peerConnection.setLocalDescription(new RTCSessionDescription(sdp)); //
this.socket.emit("offer", {
sdp: sdp,
callTo: callee.uuid // sdp를 받을 사람(여기서는 내가 통화를 하고 싶은 사람의 socket.id가 들어 간다)
});
})
step 5 : (back-end) caller의 sdp를 callee 에게 전달한다.
socket.on("offer", data => {
socket.to(data.callTo).emit("getOffer", {
sdp: data.sdp,
callFrom: socket.id //caller 의 소켓 아이디
});
});
step 6 : (front-end: callee) createAnswer
caller의 sdp를 받아서 setRemoteDescription 을 처리하고 createAnswer 을 실행하여 callee의 sdp를 caller에게 전달한다.
this.socket.on("getOffer", (data: {sdp: RTCSessionDescription; callFrom: string;}) => {
// step 3 과 마찬가지로 이번에는 caller의 id를 이용하여 peerConnection을 만든다. (step 3을 참조)
const peerConnection = this.createPeerConnection(callFrom)
peerConnection.setRemoteDescription(new RTCSessionDescription(data.sdp)).then(
() => {
peerConnection.createAnswer({
offerToReceiveVideo: true,
offerToReceiveAudio: true
})
.then(sdp => {
peerConnection.setLocalDescription(
new RTCSessionDescription(sdp)
);
this.socket.emit("answer", {
sdp: sdp,
answerTo: callFrom
});
})
.catch(error => {
console.log(error);
});
}
);
);
step 7 : (back-end) callee의 createAnswer 결과물인 sdp를 caller 에게 전달한다.
socket.on("answer", data => {
socket.to(data.answerTo).emit("getAnswer", {
sdp: data.sdp,
answerFrom: socket.id,
});
});
step 7 : (front-end: caller) setRemoteDescription
callee의 sdp를 받아서 setRemoteDescription 을 처리한다.
caller는 전화를 걸기전 PeerConnection 을 만든다.
this.socket.on(
"getAnswer",
(data: { sdp: RTCSessionDescription; answerFrom: string }) => {
console.log(new Date().getTime(), this.socket.id, "get answer");
const peerConnection: RTCPeerConnection = this.peerConnections[data.answerFrom];
if (peerConnection) {
peerConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
}
}
);
이벤트 수신
onicecandidate
icecandidate가 발견될때 마다 실행된다.
peerConnection.onicecandidate = e => {
// 이벤트 수신시 caller 혹은 callee에게 candidate 전달하여 서로 공유한다.
};
this.socket.on("getCandidate", (data: { candidate: RTCIceCandidateInit; candidateFrom: string }) => {
// 기존 peerConnection 불러와 addIceCandidate 를 처리한다.
const peerConnection: RTCPeerConnection = this.peerConnections[data.candidateFrom];
if (peerConnection) {
peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate)).then(() => {
});
}
});
ontrack
ontrack 은 setRemoteDescription 이 정상적으로 실행되면 이벤트가 활성화되고 상대의 스트림을 받는다.
현재 소스에도 두군데가 있는데 offer 를 받았을때 (caller -> callee) 및 answer를 받았을때 (callee -> caller) 일때 두 부분이다.
peerConnection.ontrack = e => {
this.remoteVideoRef.srcObject = e.streams[0]; // 상대의 video 를 play 한다.
};
Table of contents 목차
- WebRTC를 이용한 화상 채팅
- 개발환경
- 용어정리
- 개념도
- step 1 : (front-end: caller) 미디어 환경 체크 및 socket의 chatting room에 접근
- step 2 : (back-end) room join 및 기존 회원들 및 caller에게 room에 있는 사용자들을 리스트업 한다.
- step 3 : (front-end: caller) node로 부터 받은 기존 회원정보를 이용하여 RTCPeerConnection을 만든다.
- step 4 : (front-end: caller) createOffer
- step 5 : (back-end) caller의 sdp를 callee 에게 전달한다.
- step 6 : (front-end: callee) createAnswer
- step 7 : (back-end) callee의 createAnswer 결과물인 sdp를 caller 에게 전달한다.
- step 7 : (front-end: caller) setRemoteDescription
- 이벤트 수신