WebRTC가 해결하는 문제
영상 통화를 만들겠다고 서버에 비디오를 다 올렸다가 다시 다른 사용자에게 보내는 구조를 짠다면, 두 사용자만 있어도 서버 대역폭이 양쪽 트래픽의 합이 됩니다. 100명이 동시 통화하면 서버는 200개 스트림을 처리해야 합니다. 비용·지연 모두 끔찍합니다.
WebRTC는 처음부터 브라우저끼리 직접(peer-to-peer) 미디어를 주고받는 표준입니다. 서버는 시그널링(누가 누구와 연결할지 협상)만 처리하고, 실제 비디오·오디오·데이터는 브라우저끼리 직거래합니다. 코덱 협상·NAT 우회·암호화·혼잡 제어를 모두 표준이 책임집니다. 2011년 W3C에 들어온 이래 Zoom·Google Meet·Discord·Stadia·구글 클래스룸 모두 WebRTC의 일부 또는 전부를 사용합니다.
시그널링 흐름
WebRTC가 처음 어렵게 느껴지는 이유는 시그널링이 표준이 아니기 때문입니다. WebSocket이든 Firebase든 SSE든 서버를 통해 메시지만 잘 주고받으면 됩니다. 흐름 자체는 정형화되어 있습니다.
순서를 외워두면 디버깅이 훨씬 쉬워집니다.
- Peer A가
createOffer()→ SDP를 만들고 시그널링 서버로 전송 - 서버가 Peer B에게 전달
- Peer B가
setRemoteDescription(offer)→createAnswer()→ SDP 회신 - Peer A가
setRemoteDescription(answer) - 양쪽이 ICE 후보(IP·포트 조합)를 모아 서로 보냄(STUN/TURN으로 NAT 뒤 주소 발견)
- 가장 좋은 경로로 P2P 연결 수립 → 시그널링 서버 우회
최소 구현
const pc = new RTCPeerConnection({
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "turn:turn.example.com:3478", username: "u", credential: "p" },
],
});
// 1. 로컬 미디어 추가
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach((t) => pc.addTrack(t, stream));
// 2. 원격 트랙 수신
pc.ontrack = (e) => { remoteVideoEl.srcObject = e.streams[0]; };
// 3. ICE 후보 수신/송신
pc.onicecandidate = (e) => { if (e.candidate) signaling.send({ ice: e.candidate }); };
signaling.on("ice", (cand) => pc.addIceCandidate(cand));
// 4. Offer/Answer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send({ sdp: offer });
signaling.on("sdp", async (sdp) => {
if (sdp.type === "offer") {
await pc.setRemoteDescription(sdp);
const ans = await pc.createAnswer();
await pc.setLocalDescription(ans);
signaling.send({ sdp: ans });
} else {
await pc.setRemoteDescription(sdp);
}
});
STUN과 TURN — NAT 우회의 두 단계
대부분의 사용자는 NAT 뒤에 있습니다. 자기 IP가 192.168.x.x인데 상대가 그 주소로 패킷을 보내면 도달할 수 없습니다.
- STUN — 공용 STUN 서버에 물어 "내 외부 IP·포트가 뭐냐" 알아냄. 비용 거의 0
- TURN — STUN으로 안 풀리는 NAT 환경(대칭 NAT, 강한 방화벽)에서 서버가 중계. 트래픽이 서버를 한 번 거치므로 비용 발생
운영 통계로는 전체 사용자의 80~90%는 STUN만으로 충분, 10~20%가 TURN 폴백을 씁니다. coturn 서버를 직접 운영하거나 Twilio·Xirsys 같은 매니지드를 쓰는 게 일반적입니다.
데이터 채널 — 비디오 외 P2P
WebRTC는 미디어만이 아니라 임의 데이터를 P2P로 보내는 RTCDataChannel도 제공합니다. 게임의 입력 동기화, 협업 편집의 변경 이벤트, 파일 전송에 쓰입니다. WebSocket과 달리 서버를 거치지 않으므로 지연이 매우 낮습니다.
const dc = pc.createDataChannel("game", { ordered: false, maxRetransmits: 0 });
dc.onmessage = (e) => applyOpponentMove(JSON.parse(e.data));
dc.send(JSON.stringify({ x: 100, y: 50 }));
ordered: false로 두면 도착 순서를 무시하고 가장 빠른 경로로 전달돼 게임에 적합합니다.
IoT 카메라와 연결하기
ESP32-CAM·라즈베리파이로 만든 IoT 카메라를 WebRTC로 웹앱과 직접 연결하면 클라우드 비디오 비용 없이 라이브 뷰가 가능합니다. 두 가지 접근이 있습니다.
- 카메라가 WebRTC를 직접 구현 —
libdatachannel(C++) 또는aiortc(Python)로 카메라 측이 Peer가 됨 - 카메라는 RTSP만 송출, 게이트웨이가 WebRTC 변환 — go2rtc·MediaMTX 같은 게이트웨이가 RTSP↔WebRTC 변환
후자가 훨씬 단순합니다. ESP32-CAM의 RTSP 출력을 라즈베리파이의 MediaMTX가 WebRTC로 변환해 웹브라우저에 직접 P2P로 전달하는 구조가 일반적입니다.
운영의 함정
- 네트워크 의존성 — STUN·TURN·시그널링 셋 다 살아있어야 함. 헬스체크와 재시도 로직 필수
- 비디오 코덱 협상 — H.264·VP8·VP9·AV1. 모바일 사파리 호환성을 보려면 H.264 우선
- 에코·노이즈 —
audioContext.createMediaStreamSource+ 에코 캔슬링 옵션 활성화 - 방화벽/엔터프라이즈 환경 — UDP 차단된 곳에선 TURN over TCP·TLS 필요
다음 단계
WebRTC는 선택이 아니라 표준입니다. 1:1 통화면 직접 구현, 그룹 통화·녹화·라이브 방송이 필요하면 SFU(Selective Forwarding Unit, 예: mediasoup·LiveKit·Janus)를 도입하는 게 자연스러운 다음 단계입니다. SFU는 P2P를 포기하는 대신 N명 통화를 효율적으로 처리합니다. 그 위로는 라이브 스트리밍의 LL-HLS·DASH로 시야가 넓어지는데, 거기까지 가면 비디오 인프라 전반을 다시 설계할 시점입니다.