현재 WebRTC를 이용한 화상회의 프로젝트를 개발중입니다.
해당 프로젝트에서 사용한 WebRTC의 개념과 1:1 통신을 위한 기본적인 연결 과정에 대해 포스팅하고자 합니다.
Web-RealTime-Communication 의 약자로 서버의 개입이나 플러그인 없이 브라우저간 통신을 할 수 있게 하는 기술입니다.
W3C에서 제시된 초안이며, 음성 통화, 영상 통화, P2P 파일 공유 등으로 활용될 수 있습니다.
현재 유일한 P2P 표준이기도 합니다.
브라우저간 통신을 한다는 독특한 기술적 특징 때문에 다른 스트림 서비스와 달리 3가지의 서버가 필요합니다.
이 서버들은 데이터 전송에는 직접적인 역할을 하지 않으며 브라우저간 통신을 위해 필요한 사전 데이터를 주고받는 용도로 사용됩니다.
이번 포스팅에선 그 중 가장 기본적인 시그널링 서버에 대해 설명한 후 연결 과정에 대해 설명하겠습니다.
브라우저간 통신을 위해선 우선 서로의 정보를 알아야 합니다. 이를 중계해주는 서버를 signaling server 라고 합니다.
각 브라우저는 signaling server를 통해 자신의 정보를 전달하고 상대방의 정보를 받아 P2P 연결을 하게 됩니다.
서로의 정보를 알기 위해 필요한 데이터 송수신 과정은 다음 그림과 같습니다.
💡 1 : 1 통신을 예제로 설명합니다.
방장이 화상회의에 필요한 방을 만든 후 참여자가 입장하는 상황을 설정해보겠습니다.
중간에 ICECandidate 이벤트 리스너 설정 등의 추가 작업이 있지만 해당 내용은 아래 내용으로 이어서 진행하겠습니다.
서버에게 클라이언트의 정보를 실시간으로 전달하기 위해 websocket을 활용해야 했고 본 예제에서는 프로젝트에서 사용한 socket.io
라이브러리를 활용하여 작성했습니다.
본 프로젝트에선 webRTC 관련 함수들을 클래스로 구현하여 사용했습니다.
import { Socket } from "socket.io-client";
import { events } from "../constants/events";
export interface offerType {
sdp: string;
roomCode: number;
sid: string;
}
export class WebRTC {
peerConnection: RTCPeerConnection;
socket: Socket;
roomCode: number;
constructor(socket: Socket, roomCode: number) {
this.peerConnection = new RTCPeerConnection();
this.socket = socket;
this.roomCode = roomCode;
}
addTracks(streams: MediaStream[]) {
streams.forEach((stream) => {
stream
.getTracks()
.forEach((track) => this.peerConnection.addTrack(track, stream));
});
}
async setLocalOffer(sid: string) {
const offer = await this.peerConnection.createOffer();
this.peerConnection.setLocalDescription(offer);
this.socket.emit(events.OFFER, {
sdp: offer.sdp,
roomCode: this.roomCode,
sid,
});
}
async setRemoteOffer(offer: RTCSessionDescriptionInit, sid: string) {
this.peerConnection.setRemoteDescription(offer);
const answer = await this.peerConnection.createAnswer();
this.peerConnection.setLocalDescription(answer);
this.socket.emit(events.ANSWER, {
sdp: answer.sdp,
roomCode: this.roomCode,
sid,
});
}
async setAnswer(answer: RTCSessionDescriptionInit) {
this.peerConnection.setRemoteDescription(answer);
}
setIceCandidate(sid: string) {
this.peerConnection.addEventListener("icecandidate", (data) => {
this.socket.emit("icecandidate", {
candidate: data.candidate,
sid: sid,
roomCode: this.roomCode,
});
});
this.socket.on(events.ICE_CANDIDATE, (response) => {
if (response.candidate) {
this.peerConnection.addIceCandidate(response.candidate);
}
});
}
setRemoteStream(remoteVideo: HTMLVideoElement | null) {
this.peerConnection.addEventListener("track", async (data) => {
if (data.track.kind === "video") {
if (!remoteVideo) return;
remoteVideo.srcObject = data.streams[0];
}
});
}
}
방장과 참여자 모두 방에 처음 입장하면 자신의 미디어 정보들을 MediaTrack에 넣어줘야 합니다. 그래야 해당 트랙 정보들을 바탕응로 미디어 스트림 송수신을 진행하기 때문입니다.
그 후 상대방의 MediaTrack이 추가 되었을 경우의 이벤트 리스너를 달아 줍니다.
webRTC.addTrack(myTracks);
webRTC.setRemoteStream(video)
방을 생성한 방장은 참여자의 입장 여부를 듣는 이벤트 리스너를 생성합니다.
해당 이벤트 리스너는 참여자가 방에 입장 시 방 참여를 확인했다는 이벤트를 전송한 후 ICECandidate 설정을 진행하며 위의 2, 3, 4을 실행합니다.
socket.on(events.JOIN, async (response) => {
socket.emit(events.HAND_SHAKE, {
sid: response.sid,
roomCode: props.roomCode,
});
webRTC.current.setIceCandidate(response.sid);
webRTC.current.setLocalOffer(response.sid);
});
참여자는 방 입장 시 입장 관련 이벤트를 서버에 전송 후, 방장으로 부터 받은 참여 확인 이벤트를 통해 참여자의 ICECandidate를 설정합니다.
socket.emit(events.JOIN, {
nickName: props.nickName,
roomCode: String(props.roomCode),
});
socket.on(events.HAND_SHAKE, (response) => {
webRTC.setIceCandidate(response.sid);
});
방 참여 후 방장으로부터 offer 이벤트 수신 시 해당 offer를 RemoteDescription으로 설정한 후 answer를 생성하여 전송하는 5,7,8,9 과정을 진행합니다.
socket.on(events.OFFER, (response: offerType) => {
webRTC.setRemoteOffer(
{
type: events.OFFER as RTCSdpType,
sdp: response.sdp,
},
response.sid
);
});
방장은 참여자로부터 받은 answer 정보를 자신의 remoteDescription으로 설정하는 10번 과정을 진행합니다.
socket.on(events.ANSWER, (response : answerType) => {
webRTC.setAnswer({
type: events.ANSWER as RTCSdpType,
sdp: response.sdp,
});
});
};