배경
서비스 요구사항에 영상 통화 기능이 추가되어 개발해야 했습니다. 별도의 라이브러리 없이 직접 구현할 수 있는 방법을 찾던 중 WebRTC라는 API를 알게 되었고, 추후 요구사항 변경이나 확장성을 고려한다면 WebRTC가 무엇인지, 그리고 관련 구성요소들이 어떤 게 있는지 정확히 알 필요가 있다고 생각해 공부한 내용을 기록하게 되었습니다.
목차
- WebRTC란
- WebRTC로 결정한 이유
- WebRTC의 장단점
- WebRTC는 어떻게 동작할까
- Peer 연결
- Signaling Server
- SDP
- ICE
- STUN 서버
- TURN 서버
- 무료 STUN / TURN 서버 URL 목록
- 구현
WebRTC란
WebRTC(Web Real-Time Commnication)는 Apple, Google, Microsoft, Mozila, Opera에서 지원하는 오픈소스 프로젝트입니다.
WebRTC를 사용하면, 브라우저 또는 모바일 앱이 P2P(Peer to Peer)로 연결되어 실시간으로 영상, 음성, 일반 데이터를 전송할 수 있습니다.
WebRTC로 결정한 이유
영상 통화는 웹 소켓이나 ffmpeg 등을 사용해 구현하는 방법도 있지만, WebRTC는 P2P 연결, 오디오, 비디오 스트림을 처리하는 목적으로 개발됐기 때문에 WebRTC를 사용하는 것이 더 빠르고 효율적이라고 판단했습니다. 특히 WebRTC는 브라우저에 내장된 API이므로 별도의 라이브러리를 설치하지 않아도 영상 통화를 구현할 수 있고 무료라는 점이 좋았습니다.
WebRTC의 장단점
장점
- 네트워크 상태가 바뀔 때마다 통신 품질, 대역폭 및 트래픽 흐름을 조정할 수 있습니다.
- 데스크톱 및 Android용 Google Chrome, 데스크톱 및 Android용 Mozilla Firefox, Safari를 포함한 대부분의 주요 웹 브라우저에서 지원됩니다.
- 별도의 라이브러리가 필요하지 않습니다.
- 오픈 소스로 무료입니다.
단점
- 각 사용자는 P2P 브라우저 연결을 설정해야 하므로 대역폭이 문제가 될 수 있습니다.
- 보안 및 개인 정보 보호 표준이 아직 명확하지 않으며, 개인 정보 보호 표준을 충족할 수 있는지 확인하는 것이 어렵습니다.
- 명확한 서비스 표준이 없습니다. 이는 인터넷을 통한 비디오 또는 오디오 품질이 일관되지 않을 수 있음을 의미합니다.
WebRTC는 어떻게 동작할까
Peer 연결
WebRTC는 P2P 기반으로 동작하며, 통화 참가자는 중개자에 의존하지 않고 한 참가자가 다른 참가자에게 데이터를 전송합니다. WebRTC의 특이한 점은 기존 스트리밍 통신과는 달리, 한 참가자의 연결이 끊어져도 계속해서 데이터를 브로드캐스트 한다는 점입니다.
아래는 4명의 참가자가 있을 때의 그림입니다.
Signaling Server
실시간으로 통화가 진행되는 동안 Peer들이 참여하거나 나가는 Peer를 추적하고 각각 연결을 설정하려면 중앙에 서버가 하나 필요한데, 그것이 시그널링 서버입니다. 시그널링 서버는 직접 구현해야 하며, 웹 소켓 등을 사용하여 구현할 수 있습니다.
SDP
한 Peer가 새로 참가하거나 나가게 되면 각 Peer들의 연결 설정을 위해 한 Peer에 대한 세부 정보(agent, hardware, media type 등)를 서로 교환할 수 있어야 합니다. 이 세부 정보를 교환하는 데 사용되는 프로토콜이 SDP(Session Description Protocol)입니다. SDP를 통해 각 Peer들은 SDP config를 통해 양방향으로 서로 offer(제안)하고 answer(응답)하여 연결을 설정하게 됩니다.
ICE
SDP로 각 Peer들이 서로 제안하고 응답하려면 각 Peer들은 자신의 네트워크에 대한 정보도 교환해야 합니다. 여기에서 사용되는 프레임워크가 ICE(Interactive Connectivity Establishment)입니다.
각 Peer들은 다양한 네트워크로 구성되어 있을 수 있기 때문에, IP, 프로토콜, 포트로 구성된 네트워크 주소들을 ICE Candidate(ICE 후보)로 만듭니다. SDP 통신을 할 때 이 후보들 중에서 가장 좋은 후보를 먼저 제안하고, 더 나쁜 후보들을 차례로 제안하게 됩니다.
ICE가 보는 이상적인 후보는 UDP이지만 ICE 표준에서는 TCP 후보도 허용하고 있습니다. TCP는 이전 패킷이 100% 전송되지 않으면 패킷이 스트리밍되지 않지만 UDP는 패킷 상태와는 관계없이 스트리밍 패킷을 유지하여 훨씬 빠르기 때문입니다.
STUN
ICE 후보를 만들려면 공인 IP가 필요한데, Peer의 기기가 NAT(예를 들어 공유기)로 구성되어 있는 경우, STUN(Session Traversal Utils for NAT) 서버에서 Peer의 공인 IP 정보를 가져와야 하며, WebRTC가 STUN 서버 URL을 지정하는 기능을 제공하는 이유가 이 때문입니다. STUN 서버가 반환하는 것은 공인 IP와 포트 뿐이며, 구글이나 오픈소스 등 무료로 제공하는 STUN 서버도 있기 때문에 이를 사용하기로 했습니다.
TURN
여기까지는 Peer들의 연결이 순조로워 보입니다. 하지만 몇몇 Peer의 NAT가 강력한 보안 정책(예를 들어 방화벽 등)을 사용하고 있다면 연결에 실패할 수 있는데, 이는 NAT 테이블에 없는 IP를 차단하기 때문입니다.
연결하려는 Peer가 cone NAT 또는 full cone NAT로 구성된 경우 Hole punching이라는 네트워크 기술을 사용하여 우회할 수 있습니다.
- Peer A는 cone NAT를 사용하고, Peer B는 full cone NAT를 사용하고 있습니다.
- Peer A는 STUN 서버를 사용하여 공용 IP/포트에 대한 정보를 얻습니다.
- Peer A는 신호 서버를 사용하여 해당 정보를 Peer B로 보냅니다.
- Peer B는 해당 정보를 얻고 Peer A와 연결을 시도합니다.
- Peer B는 연결 설정에 실패하지만 NAT 테이블에 Peer A 공개 정보를 저장합니다.
- Peer A는 Peer B와의 연결 설정을 시도합니다. Peer A가 이미 Peer B NAT 테이블에 존재하므로 연결이 허용됩니다.
- Peer A는 Peer B에 대한 공개 정보를 저장합니다.
- 이제 Peer B는 Peer A와 연결을 설정할 수 있습니다.
하지만 연결하려는 Peer가 symmetric NAT로 구성된 경우엔 트래픽이 외부로 나갈 때 랜덤한 포트 매핑을 사용하기 때문에 이야기가 다릅니다.
- Peer A는 symmetric NAT를 사용하고, Peer B는 full cone NAT를 사용하고 있습니다.
- Peer A는 STUN 서버를 사용하여 공용 IP/포트에 대한 정보를 얻습니다.
- Peer A는 신호 서버를 사용하여 해당 정보를 Peer B로 보냅니다.
- Peer B는 해당 정보를 얻고 Peer A와 연결을 시도합니다.
- Peer B는 연결 설정에 실패하지만 NAT 테이블에 Peer A 공개 정보를 저장합니다.
- Peer A는 Peer B와의 연결 설정을 시도합니다. 하지만 Peer B는 NAT 테이블에 저장된 공개 정보가 실제로 수신한 정보와 다르기 때문에 Peer A를 거부합니다.
Peear A의 공용IP와 포트가 고정이 아닌 경우 Peer B와 연결할 수 있는 방법은 없습니다. 이 때 사용되는 것이 NAT의 방화벽 통과를 지원하는 TURN(Traversal Using Relays around NAT) 서버이며, WebRTC가 TURN 서버 URL 지정 기능을 제공하는 이유입니다.
하지만 TURN 서버를 사용하게 되면 역방향 프록시를 사용하게 되므로 연결 속도가 느려지고, 효율성이 떨어지게 되며, ICE 후보에서 항상 가장 낮은 우선순위를 갖게 됩니다. 또한 WebRTC의 특징인 직접적인 P2P 연결 방식에서 벗어나게 됩니다.
이제 WebRTC 연결의 전체적인 흐름을 다이어그램으로 확인하면 아래와 같습니다.
무료 STUN / TURN 서버 URL 목록과 정보
https://gist.github.com/sagivo/3a4b2f2c7ac6e1b5267c2f1f59ac6c6b
구현
프로세스
- 프런트(피어A): 화면 접근 시 소켓 서버에 참여 요청(join-room)
- 프런트(피어A): 다른 피어와의 접속을 위해 offer 생성하여 local description을 설정하고, offer를 시그널링 서버로 전송
- 백엔드: 요청받은 offer를 다른 피어에게 브로드캐스팅
- 프런트(피어B): 전달받은 피어A의 offer를 remote description에 설정하고, answer를 생성하여 local description을 설정하고 시그널링 서버로 전송
- 백엔드: 요청받은 answer를 다른 피어에게 브로드캐스팅
- 프런트(피어A): 전달받은 answer를 remote description에 설정
- 각자 ice-candidate가 생성되면 다른 피어에게 시그널링 서버를 통해 전송
- 각자 전달받은 ice-candidate를 peerConnection 객체에 추가
- 연결 완료
백엔드(Nest.js) 시그널링 서버 예시
import { Logger } from '@nestjs/common';
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Namespace, Socket } from 'socket.io';
interface IIceCandidateMessage {
roomName: string;
sdpMLineIndex: number;
sdpMid: string;
candidate: RTCIceCandidate;
}
interface IOfferMessage {
roomName: string;
sdp: RTCSessionDescription;
type: string;
}
interface IAnswerMessage {
roomName: string;
sdp: RTCSessionDescription;
type: string;
}
// WebRTC 시그널링 서버
@WebSocketGateway({
namespace: 'vchat',
cors: {
origin: [
'http://YOUR_DOMAIN'
],
},
})
export class VChatGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
private readonly logger = new Logger(VChatGateway.name);
@WebSocketServer() nsp: Namespace;
// 웹소켓 서버 초기화 직후 실행
afterInit() {
this.logger.debug('VChat Server Initialized');
}
// 소켓이 연결되면 실행
handleConnection(@ConnectedSocket() socket: Socket) {
return;
}
// 소켓 연결이 끊기면 실행
handleDisconnect(@ConnectedSocket() socket: Socket) {
return;
}
// 채팅방 생성 및 참여
@SubscribeMessage('join-room')
handleJoinRoom(
@ConnectedSocket() socket: Socket,
@MessageBody() roomName: string,
) {
socket.join(roomName); // 기존에 없던 room으로 join하면 room이 생성됨
return { success: true, payload: roomName };
}
// offer 브로드캐스트
@SubscribeMessage('sdp-offer')
async handleOfferMessage(
@ConnectedSocket() socket: Socket,
@MessageBody() message: IOfferMessage,
) {
socket.broadcast.to(message.roomName).emit('sdp-offer', message);
}
// answer 브로드캐스트
@SubscribeMessage('sdp-answer')
async handleAnswerMessage(
@ConnectedSocket() socket: Socket,
@MessageBody() message: IAnswerMessage,
) {
socket.broadcast.to(message.roomName).emit('sdp-answer', message);
}
// ice-candidate 브로드캐스트
@SubscribeMessage('ice-candidate')
async handleIceCandidateMessage(
@ConnectedSocket() socket: Socket,
@MessageBody() message: IIceCandidateMessage,
) {
// 같은 방의 소켓들에게 전달
socket.broadcast.to(message.roomName).emit('ice-candidate', message);
}
// 채팅방 나감 처리
@SubscribeMessage('leave-room')
handleLeaveRoom(
@ConnectedSocket() socket: Socket,
@MessageBody() roomName: string,
) {
console.log(`leaving ${roomName}...`);
socket.leave(roomName);
}
}
프런트엔드(Next.js)
import { Socket, io } from 'socket.io-client';
import styles from './LessonVChat.module.scss';
import React, { useEffect, useRef, useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useAuth } from '@/hooks/useAuth';
import { curLssnDataAtom, isVChatOpenAtom } from '@/store/lesson';
import Alert from '@/components/popup/common/Alert';
import { rtcConfig } from './vchat-config';
interface IIceCandidateMessage {
roomName: string;
sdpMLineIndex: number;
sdpMid: string;
candidate: RTCIceCandidate;
}
interface IOfferMessage {
roomName: string;
sdp: RTCSessionDescription;
type: string;
}
interface IAnswerMessage {
roomName: string;
sdp: RTCSessionDescription;
type: string;
}
interface IProps {
className?: string;
socketRoomName: string;
}
const LessonVChat = ({ className }: IProps) => {
// 전역 상태
const { data: user } = useAuth();
const curLssnData = useRecoilValue(curLssnDataAtom);
const roomName = curLssnData?.lssnData.data_id;
const [isVChatOpen, setIsVChatOpen] = useRecoilState<boolean>(isVChatOpenAtom);
const studentData = curLssnData?.lssnData.lssn_students[0].student_data;
const teacherData = curLssnData?.lssnData.teacher_data;
// 컴포넌트 상태
const remoteVideoRef = useRef<HTMLVideoElement>(null); // <video> ref
// pc객체와 소켓 객체는 useState로 할 수도 있으나, 리렌더링 이슈를 방지하기 위해 ref를 활용함
const pcRef = useRef<RTCPeerConnection>();
const socketRef = useRef<Socket>();
useEffect(() => {
// 로그인 되어 있고, 화상 채팅이 활성화인 경우
// roomName은 전역 상태라서 다른 컴포넌트에 의해 세팅되는데, null일 수 있으므로,
// roomName값이 다른 컴포넌트에 의해 생성되었을 때만 실행
if (user && isVChatOpen && roomName) {
// peerConnection 생성
pcRef.current = new RTCPeerConnection(rtcConfig);
// 소켓 생성
socketRef.current = io(`${process.env.NEXT_PUBLIC_CHAT_URL}/vchat`); // 화상 채팅 참가
socketRef.current.on('sdp-offer', handleOfferMeesage); // offer 수신
socketRef.current.on('sdp-answer', handleAnswerMessage); // answer 수신
socketRef.current.on('ice-candidate', handleIceCandidateMessage); // ice 후보 수신
socketRef.current.emit('join-room', roomName);
// offer 생성
const createOffer = async () => {
if (pcRef.current && socketRef.current) {
try {
const sdp = await pcRef.current.createOffer({
iceRestart: true,
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
pcRef.current.setLocalDescription(sdp);
socketRef.current.emit('sdp-offer', {
roomName,
sdp,
});
} catch (error) {
console.error(error);
}
}
};
// 기기 카메라 설정
const getLocalMedia = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
if (!(pcRef.current && socketRef.current)) {
return;
}
// 스트림을 peerConnection에 등록
stream.getTracks().forEach((track) => {
if (pcRef.current) {
pcRef.current.addTrack(track, stream);
}
});
// offer/answer 후에 브라우저에 의해 ice candidate가 만들어지면 콜백
pcRef.current.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
if (event.candidate) {
if (!socketRef.current) return;
socketRef.current.emit('ice-candidate', {
roomName,
sdpMLineIndex: event.candidate.sdpMLineIndex,
sdpMid: event.candidate.sdpMid,
candidate: event.candidate,
});
}
};
// 연결 후에 상대방으로부터 스트림 수신 시
pcRef.current.ontrack = (event: RTCTrackEvent) => {
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = event.streams[0];
}
};
createOffer();
} catch (error) {
console.error(error);
}
};
getLocalMedia();
} else {
// 권한이 없거나 화상 채팅를 OFF하면 방 나감 처리 및 이벤트 제거
if (socketRef.current) {
socketRef.current.emit('leave-room', roomName);
socketRef.current.off('message');
}
}
// unmount 시 소켓, pc 초기화
return () => {
if (socketRef.current) {
socketRef.current.disconnect();
}
if (pcRef.current) {
pcRef.current.close();
}
};
}, [isVChatOpen, roomName]);
return (
<div className={`${styles.root} ${className || ''} ${isVChatOpen ? styles.show : ''}`}>
<div className={styles.remote}>
<video ref={remoteVideoRef} autoPlay playsInline controls></video>
</div>
</div>
);
// SDP 제안 요청 수신 시 answer 전송
async function handleOfferMeesage(message: IOfferMessage) {
if (pcRef.current && socketRef.current) {
try {
pcRef.current.setRemoteDescription(message.sdp);
const answerSdp = await pcRef.current.createAnswer();
pcRef.current.setLocalDescription(answerSdp);
socketRef.current.emit('sdp-answer', {
roomName,
sdp: answerSdp,
});
} catch (error) {
console.error(error);
}
}
}
// SDP offer에 대한 answer 수신 시
function handleAnswerMessage(message: IAnswerMessage) {
if (pcRef.current) {
pcRef.current.setRemoteDescription(message.sdp);
}
}
// ICE 후보 메시지 수신 시
async function handleIceCandidateMessage(message: IIceCandidateMessage) {
if (pcRef.current) {
await pcRef.current.addIceCandidate(message.candidate);
}
}
// 참가자가 나갔을 시
function handleLeaveRoomMessage() {
//...
}
};
export default React.memo(LessonVChat);
rtcConfig 예시
// vchat-config.js
export const rtcConfig = {
iceServers: [
{
urls: ['stun:bn-turn1.xirsys.com'],
// urls: ['stun:stun.l.google.com:19302'],
},
{
username: '0kYXFmQL9xojOrUy4VFemlTnNPVFZpp7jfPjpB3AjxahuRe4QWrCs6Ll1vDc7TTjAAAAAGAG2whXZWJUdXRzUGx1cw==',
credential: '285ff060-5a58-11eb-b269-0242ac140004',
urls: [
'turn:bn-turn1.xirsys.com:80?transport=udp',
'turn:bn-turn1.xirsys.com:3478?transport=udp',
'turn:bn-turn1.xirsys.com:80?transport=tcp',
'turn:bn-turn1.xirsys.com:3478?transport=tcp',
'turns:bn-turn1.xirsys.com:443?transport=tcp',
'turns:bn-turn1.xirsys.com:5349?transport=tcp',
],
},
],
};
참고 링크
https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API
https://webrtc.org/getting-started/overview?hl=ko
https://webrtc.github.io/samples/
https://brunch.co.kr/@linecard/141
'JavaScript' 카테고리의 다른 글
모바일에서 input.focus()로 키보드 띄우기 (0) | 2023.02.12 |
---|