안녕하세요 :)
오늘은 WebRTC를 통해, '실시간 미디어 송출하기'를 구현했던 기억을 살려서 비슷하게 시나리오를 하나 만든 다음, 어떻게 구현하는 가에 대해 작성해보려고 합니다. 먼저, WebRTC란 무엇인가에 대해 간단하게 알고 넘어가보도록 하겠습니다.
WebRCT란?
Web Real-Time Coummunication의 약자로, 브라우저 간 실시간 음성, 영상 및 데이터 통신을 가능하게 하는 오픈소스입니다. 별도의 플러그인 설치 없이도 웹 애플리케이션에 실시간 미디어 기능을 통합할 수 있어 개발자들에게 매력적인 선택지가 됩니다.
특히, 다양한 인터넷 환경에서도 괜찮은 성능을 발휘하도록 설계되어 있는 라이브러리이기 때문에, 높은 품질의 미디어 스트리밍과 네트워크 적응성을 제공해줍니다.
자, 그럼 WebRTC를 사용해서 '실시간 미디어 송출 및 통신'을 구현해볼까요?
시나리오
먼저 시나리오가 있어야겠죠?
"에이(A)"라는 이름을 가진 어린이와 "비(B)"라는 이름을 가진 아이의 부모가 있습니다.
에이는 온라인 학습지를 풀면서 공부를 하는데, 온라인 학습을 하면서 게임으로 자주 빠져버리기 때문에 부모님께서 직접 아이의 학습 환경을 모니터링 하고 싶어합니다. 그래서 비는 에이와 실시간 통신 시스템을 구현해서 에이의 온라인 학습 환경을 관찰하고자 합니다. 비는 시스템을 WebRTC로 구현해서 에이가 학습을 어떻게 하는지, 중간에 게임을 하지는 않는지 모니터링 하려고 합니다.
뭐, 대충 이런 시나리오를 가지고 구현해보도록 하겠습니다. B는 A의 미디어를 모니터링 해야 하고, A는 B에게 미디어를 보내면서 실시간으로 공부를 해야겠네요?
시그널링 채널 준비
먼저, WebRTC에서 실시간 미디어 통신을 구현하려면, 먼저 이 개념에 대해 알아두셔야 합니다.
시그널링 채널이란 무엇일까요?
시그널링 채널은 WebRTC 연결을 설정하기 위해 필요한 초기 정보를 교환하는 통신 경로입니다. 여기에는 SDP와 ICE 후보 등이 포함되며, 브라우저 간의 연결 설정을 돕습니다.
(SDP가 무엇인지, ICE 후보가 무엇인지는 뒤에서 다루겠습니다.)
시그널링 채널이 왜 필요할까요?
WebRTC는 브라우저 간 오디오, 비디오 데이터를 실시간으로 전송하도록 표준화가 되어 있긴 하지만, "서로 어떤 코덱/IP로 연결할지" 등의 시그널링(일종의 협상) 과정을 직접 제공하지는 않습니다. 그러므로, 우리가 따로 시그널링 채널을 별도로 만들어야 합니다.
일반적으로 Socket.io를 이용해 실시간 통신을 구성하고, 그 채널 위에 "Offer/Answer/ICE Candidate"와 같은 WebRTC 정보를 교환하게 됩니다.
// simpleSignalService.ts
import { io, Socket } from 'socket.io-client';
class SimpleSignalService {
private static socket: Socket | null = null; // 소켓 인스턴스 저장
private constructor() {} // 외부에서 객체 생성 금지
// 소켓 연결 생성 및 반환
public static connect(): Socket {
if (!this.socket) { // 소켓이 없는 경우 새로 생성
this.socket = io('https://example-signaling-server.com'); // 서버 URL 연결
console.log('소켓 연결 시도 중...');
// 연결 성공 이벤트
this.socket.on('connect', () => {
console.log('소켓 연결 성공:', this.socket?.id);
});
// 연결 해제 이벤트
this.socket.on('disconnect', () => {
console.log('소켓 연결이 해제되었습니다.');
});
}
return this.socket; // 소켓 반환
}
// 소켓 연결 해제
public static disconnect(): void {
if (this.socket) { // 소켓이 있는 경우만 실행
this.socket.disconnect(); // 연결 종료
this.socket = null; // 인스턴스 초기화
console.log('소켓 연결 종료');
}
}
}
export default SimpleSignalService;
이렇게 Socket.io를 통한 시그널링 채널을 생성할 수 있습니다. SimpleSignalService라는 이름으로 export 시켰으니, WebRTC 연결할 때, import 하는 방식으로 구현할 수 있습니다.
Stun/Turn 서버 설정
A와 B는 인터넷이 연결된 상태에서, NAT/방화벽 뒤에 있을 가능성이 매우 높습니다. WebRTC는 STUN서버로 "공인 IP"를 알아내서 직접 연결을 우선 시도하고, 안 될 경우에 TURN 서버(중계 서버)를 통해 우회하게 됩니다.
const servers = {
iceServers: [
{ urls: 'stun:stun1.l.google.com:00000' },
{
urls: 'turn:turn.myserver.com:0000',
username: 'turnUser',
credential: 'turnPass'
}
]
};
const peerConnection = new RTCPeerConnection(servers);
이렇게 해두면 브라우저 내부에서 STUN -> TURN 순으로 NAT 우회 시도가 자동으로 이루어지게 됩니다. TURN서버는 비용이 들어가는 서버이기 때문에, 먼저 STUN 서버로 연결이 가능한지 체크하고, 안 된다면 TURN서버로 변환하는 식입니다.
로컬 미디어 트랙 준비하기
시나리오대로 라면, A가 자신의 화면을 브라우저를 통해 실시간으로 B에게 보내야 합니다.
A의 입장에서 생각해보죠.
자신의 화면을 보내고, 음성을 보내려면 브라우저에서 사용자 허가를 받아야 합니다.
다들 이런 권한 요청을 한 번씩은 보셨을 겁니다. 이 권한을 코드 내에서 얻어오려면 getUserMedia()라는 함수를 사용해야 합니다.
// A 측에서 로컬 카메라나 데스크톱 화면을 가져옴
// (학습 화면 모니터링 시엔 getDisplayMedia()를 사용할 수도 있음)
const localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
// WebRTC PeerConnection에 트랙 추가
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
A가 B에게 자신의 화면/음성을 보내야 한다면, getUserMedia()를 통해 트랙을 준비해 addTrack()을 해야 합니다.
만약, 반대로 B에서 A로도 화면 및 음성을 보내야 한다? 그렇다면 B측에서도 getUserMedia를 사용하면 됩니다. 하지만 시나리오상 "B가 A를 모니터링"하기 때문에, B는 굳이 getUserMedia()를 호출할 필요가 없고 수신 전용(onTrack)으로 동작하게 됩니다.
자, 그럼 여기서 "트랙"이 무엇이냐?
트랙이란, WebRTC에서 오디오와 비디오 데이터를 전송하기 위해 사용되는 미디어 데이터의 개별적인 스트림입니다.
조금 더 쉽게 보자면, "트랙"이란 건, 미디어 데이터의 가장 작은 단위입니다. WebRTC에서 오디오 트랙과 비디오 트랙을 각각 분리하여 다룰 수 있도록 설계된 구조를 말합니다.
시그널링 (Offer/Answer) 시작
이제 본격적으로 A와 B가 서로 코덱/포트 등의 정보를 교환해야 합니다.
1. A가 "내가 음성/영상을 줄게"라며 브라우저 내부에서 createOffer()를 호출하고,
2. setLocalDescription(offer)로 브라우저 A에 설정하면, A의 PeerConnection 내부에서 "Offer"라는 SDP가 만들어집니다.
3. A는 이 SDP를 Socket.io로 서버로 보내고, 서버가 B에게 중계하게 됩니다.
// A 측
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
// 이제 offer를 소켓으로 전송
socket.emit('offerInfo', {
offer: peerConnection.localDescription,
targetId: bSocketId // B의 소켓 ID
});
setRemoteDescription() + ICE 시도
이제 앞에서 말한 A에서 보낸 Offer를 B에서 수신해야겠죠?
1. B 측에서 socket.on('offerInfo', (data) => {...}) 로 받는데, data에는 A가 만든 SDP 정보가 들어있습니다.
2. B는 PeerConnection에 "A가 이런 코덱/설정으로 미디어를 줄 예정이구나~"라고 등록하고,
3. 동시에 브라우저는 "상대 Offer에 맞춰서 ICE 절차(네트워크 경로 탐색)를 본격적으로 시작"합니다.
// B 측
socket.on('offerInfo', async ({ offer, from }) => {
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
console.log('Offer를 성공적으로 RemoteDescription에 등록했습니다.');
// 이 시점부터 ICE가 활발히 동작해 “어떤 IP/포트로 연결 가능할지” 탐색한다.
// 이후 createAnswer() 단계가 진행됨
});
여기서 ICE란 무엇일까요?
WebRTC의 ICE는 Interactive Connectivity Establishment의 약자로, P2P 연결을 설정하는 데 사용되는 프레임워크입니다. ICE는 네트워크 환경에서 서로 다른 클라이언트가 가장 적합한 경로를 찾아 데이터를 직접 교환할 수 있도록 지원하는 역할을 합니다. 위에서 지정했던 STUN서버와 TURN서버와 협력하여 ICE Candidate(연결 후보)를 생성하고 연결 가능성을 테스트해줍니다.
그럼 왜 ICE가 시도될까요?
브라우저는 상대방의 SDP(Offer)까지 알아야 "내가 어떤 ICE Candidate를 사용해 연결 테스트를 해야 하는지"를 확실히 알 수 있기 때문입니다. 이후 B가 Answer를 만들기 전에도 브라우저 내부에서는 STUN/TURN서버를 통해 사용 가능한 네트워크 경로(로컬 IP, 공인 IP, TURN IP 등) 연결 후보를 수집해 나갑니다.
createAnswer() -> "Answer" 전송
B가 Offer를 받은 뒤, "나도 이 코덱/포트를 지원해"라는 식으로 Answer를 만들어야 최종 협상이 완료됩니다.
1. B가 peerConnection.createAnswer()를 호출하면, "내가 지원 가능한 코덱, 해상도, RTP포트" 등이 담긴 Answer SDP를 만듭니다.
2. B가 "이제 나 이 설정으로 연결할게?"라고 로컬에 확정하면,
3. socket.emit('answerInfo', { answer, targetId: socektId }) 등으로 B가 A에게 전송하게 됩니다.
// B 측 (Offer 받은 뒤 실행)
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
socket.emit('answerInfo', {
answer: peerConnection.localDescription,
targetId: aSocketId
});
ICE Candidate 교환
양쪽 클라이언트 A, B는 이제 서로 Candidate를 찾아내야 합니다. Candidate란 가능한 네트워크 경로(IP/Port) 정보로, STUN, TURN 서버가 이 정보를 도출해냅니다.
1. 클라이언트는 하나씩 Candidate를 발견할 때마다 아래 콜백을 호출하는데요,
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('candidateInfo', { candidate: event.candidate, targetId: otherSocketId });
}
};
2. 이후 상대도 candidateInfo를 받아 addIceCandidate()를 통해 ICE Candidate를 추가하게 됩니다.
3. 이렇게 서로가 찾아낸 Candidate를 상대에게 전달합니다. 여기서 전달되는 정보는 'host candidate, srflx candidate(공인 IP), relay candidate(TURN)' 등이 있습니다.
// 공통 (A, B 둘 다)
socket.on('candidateInfo', async ({ candidate, from }) => {
if (!candidate) return;
try {
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
console.log('Candidate 등록 성공');
} catch (err) {
console.error('Candidate 등록 실패:', err);
}
});
Candidate는 여러 개가 오갈 수 있습니다. 왜 그럴까요?
이유는 NAT/방화벽 상태에 따라 "직접 연결" 가능 여부가 달라지기 때문입니다. 브라우저가 지속적으로 Candidate를 찾아보고, 하나라도 성공하면 실제 미디어 연결이 성립되는 것입니다.
연결 완성( connectionState가 connected)
ICE Candidate 교환이 충분히 이루어지고, Offer/Answer가 안정적으로 적용되면, 브라우저는 P2P(또는 TURN) 연결을 성립합니다.
이때는 peerConnection.onconnectionstatechange 콜백에서 "connected"상태를 확인할 수 있습니다. 이 시점부터 오디오/비디오 RTP 패킷이 실제 양쪽 클라이언트 사이를 흐르게 되는 것입니다.
peerConnection.onconnectionstatechange = () => {
console.log('Connection state:', peerConnection.connectionState);
if (peerConnection.connectionState === 'connected') {
console.log('WebRTC 연결이 성공적으로 맺어졌습니다!');
} else if (peerConnection.connectionState === 'disconnected' || peerConnection.connectionState === 'failed') {
console.warn('연결이 끊겼거나 실패했습니다!');
}
};
UI에 연결된 상대 등록 & onTrack 처리
ICE Candidate 교환을 통해 연결이 성립되면, 브라우저가 'ontrack'이벤트로 상대방이 보내는 오디오/비디오 트랙을 수신하게 됩니다.
peerConnection.ontrack = (event) => {
// 전달된 event.track을 우리가 관리할 MediaStream에 추가해줄 수 있음
};
그런데 이때 "어떤 MediaStream"에 event.track를 추가할까요? 직접 MediaStream 객체를 만들어서 관리하는 것이 일반적입니다.
// 수신 전용 미디어 스트림
const remoteStream = new MediaStream();
// PeerConnection에서 새로운 트랙이 수신될 때마다 remoteStream에 추가
peerConnection.ontrack = (event) => {
remoteStream.addTrack(event.track);
console.log('새 트랙이 추가되었습니다', event.track);
}
이렇게 remoteStream을 만들어두면, 나중에 <video> 태그의 srcObject에 넣기만 하면 실시간 영상을 재생할 수 있습니다.
<video id="remoteVideo" autoplay playsinline></video>
---
// 연결 성공 후나 ontrack 시점에
const videoEl = document.getElementById('remoteVideo') as HTMLVideoElement;
if (videoEl) {
videoEl.srcObject = remoteStream; // 이제 실시간 비디오/오디오가 여기로 재생
}
정리하면,
- remoteStream = new MediaStream()
- ontrack에서 remoteStream.addTrack(event.track)
- <video>에 remoteStram 할당해서 화면에 표시
이렇게 하면 "B가 A의 미디어를 실시간으로 화면에 띄우는 UI 구현"이 가능해집니다.
끝내는 말
오늘은 WebRTC를 활용한 실시간 미디어 송출 시스템을 구현하는 과정에 대해 알아보았습니다. 다른 프로젝트에서 구현해보면서 공부했더 내용을 적어보았는데요. 코드를 완전히 똑같이 가져올 수는 없어서 시나리오를 하나 간략히 만들고 작성해보았습니다 ㅎ.ㅎ
WebRTC는 말 그대로 '실시간 통신'이기 때문에, Zoom, Google Meet, MS Teams 와 같은 화상 회의 플랫폼에서도 사용되는 라이브러리입니다. 요약하면, '음성 통화 및 비디오 통화'가 가능한 웹 애플리케이션과 모바일 앱에 주로 사용되는 라이브러리라는 소리입니다.
다음에 시간이 나면 WebRTC로 다른 시스템을 한 번 더 구현해봐야겠습니당
읽어주셔서 감사합니다 :)
'JavaScript' 카테고리의 다른 글
제네릭 타입은 무엇이며 왜 쓰는 걸까? (2) | 2024.12.18 |
---|---|
알아두면 좋은 TypeScript와 JavaScript의 차이점 (3) | 2024.12.15 |
WebPack 설치 및 설정하기 (4) | 2024.12.13 |
WebPack이란 무엇인가? 기본 파헤치기 (6) | 2024.12.11 |
브라우저 간 API 차이, Shim으로 통합하기 (0) | 2024.11.19 |