WebRTC 레퍼런스 — PeerConnection·ICE·STUN·TURN·시그널링·SFU
이 글의 핵심
WebRTC는 표준 브라우저 API와 ICE·DTLS-SRTP·SCTP 같은 IETF 프로토콜을 묶어 브라우저와 네이티브 클라이언트 사이에 저지연 미디어·데이터 경로를 만듭니다. 이 글은 W3C WebRTC 스펙, SDP/ICE 협상, STUN·TURN, 시그널링과 Trickle ICE, Perfect Negotiation, 1:1 구현과 SFU 기반 멀티파티, 화면 공유, Simulcast·SVC, 대역 추정과 혼잡 제어, 운영 이슈를 한곳에 정리한 참고 문서입니다.
WebRTC(Web Real-Time Communication)는 두 층으로 나눠 보면 이해하기 쉽습니다. 위층은 W3C가 정의한 브라우저 API로, WebRTC: Real-Time Communication in Browsers(흔히 webrtc-pc), Media Capture and Streams(getUserMedia), WebRTC Statistics가 여기에 속합니다. 아래층은 IETF가 표준화한 전송 프로토콜로, ICE·STUN·TURN으로 경로를 찾고, DTLS로 키를 교환하고, SRTP로 미디어를, SCTP로 데이터 채널을 실어 나릅니다. HLS·DASH가 HTTP 위의 단방향 배포에 맞춰져 있다면, WebRTC는 UDP 위의 양방향 저지연 통신에 맞춰져 있습니다.
이 문서는 스펙, 시그널링, 1:1 구현, 인프라, 코덱·전송 고급 주제, 운영 순으로 정리합니다. 코덱 지원(H.264 프로파일, AV1, VP9 SVC 등)은 브라우저와 버전마다 다르므로, 실제 서비스에서는 RTCRtpSender.getCapabilities("video")로 대상 브라우저의 지원 목록을 확인해야 합니다.
flowchart LR
subgraph A[Client A]
PC1[RTCPeerConnection]
M1[MediaStream]
end
subgraph S["Signaling (out-of-band)"]
WSS[WebSocket / App Server]
end
subgraph B[Client B]
PC2[RTCPeerConnection]
M2[MediaStream]
end
STUN[STUN]
TURN[TURN]
PC1 <--> WSS
WSS <--> PC2
PC1 -.- STUN
PC2 -.- STUN
PC1 -.- TURN
PC2 -.- TURN
M1 -.-|SRTP/ICE| M2
프로토콜 스펙
W3C WebRTC API의 구성
MediaStream과 MediaStreamTrack은 mediacapture-streams에 정의된 오디오·비디오 트랙 컨테이너입니다. 카메라·마이크는 getUserMedia로, 화면은 getDisplayMedia로 얻고, 각 트랙은 RTCRtpSender(송신)와 RTCRtpReceiver(수신)에 연결됩니다.
RTCPeerConnection은 ICE 후보 수집과 연결 검사, DTLS 핸드셰이크, SRTP 키 설정, RTP/RTCP 송수신을 모두 관리하는 중심 객체입니다. getStats()로 RTT·손실·지터·비트레이트 등을 webrtc-stats 형식으로 조회할 수 있습니다.
RTCDataChannel은 DTLS 위의 SCTP(RFC 8261, RFC 8831)로 임의의 텍스트·바이너리 데이터를 주고받습니다. 채널마다 순서 보장 여부(ordered)와 재전송 정책(maxRetransmits 또는 maxPacketLifeTime)을 고를 수 있어, 게임 상태처럼 늦게 도착하면 쓸모없는 데이터는 부분 신뢰성으로 보낼 수 있습니다.
// 최대 3회까지만 재전송하는 순서 보장 채널
const dc = pc.createDataChannel("app", { ordered: true, maxRetransmits: 3 });
dc.onopen = () => dc.send("hello");
dc.onmessage = (e) => console.log(e.data);
// 수신 측
pc.ondatachannel = (ev) => {
const ch = ev.channel;
ch.onmessage = (e) => { /* ... */ };
};
데이터 채널과 시그널링 채널은 다릅니다. 첫 데이터 채널을 만들면 SDP에 m=application 섹션이 추가되므로 offer/answer 협상이 한 번 필요하고, 그 뒤로 추가되는 채널은 이미 열린 SCTP 연결 안에서 DCEP(RFC 8832)로 열리기 때문에 재협상이 필요하지 않습니다. 반대로 미디어 트랙을 추가하거나 방향을 바꾸는 일은 매번 재협상을 일으킵니다.
SDP Offer/Answer (JSEP)
브라우저는 세션을 SDP 문자열로 서술하고, 이를 주고받는 절차는 JSEP(RFC 9429, RFC 8829를 대체)로 정의되어 있습니다. 기본 흐름은 다음과 같습니다.
- 제안 측이
createOffer()로 offer를 만들고setLocalDescription(offer)로 적용한 뒤 상대에게 보냅니다. 인자 없이setLocalDescription()만 호출하면 상태에 맞는 offer나 answer를 자동으로 만들어 적용합니다. - 응답 측이
setRemoteDescription(offer)후createAnswer()와setLocalDescription(answer)를 호출하고 answer를 돌려보냅니다. - 양쪽은
icecandidate이벤트로 나오는 후보를 상대에게 보내고, 받은 후보는addIceCandidate()로 추가합니다.event.candidate가null이면 후보 수집이 끝났다는 뜻입니다.
BUNDLE(RFC 9143)로 오디오·비디오·데이터를 하나의 전송(같은 5-튜플)에 묶는 것이 기본이며, bundlePolicy: "max-bundle"이면 ICE 후보 수집도 한 번만 합니다. 과거 Chrome의 Plan B SDP 형식은 제거되었고, 현재 모든 주요 브라우저는 트랙마다 m= 섹션과 RTCRtpTransceiver를 두는 Unified Plan만 지원합니다.
ICE, STUN, TURN
ICE(RFC 8445)는 후보를 수집하고, 우선순위를 매기고, 후보 쌍마다 STUN 연결 검사를 해서 실제로 통하는 경로를 찾습니다. 후보는 세 종류입니다. 로컬 인터페이스 주소인 host 후보, STUN 서버가 알려 준 NAT 바깥 주소인 server-reflexive(srflx) 후보, TURN 서버가 할당한 릴레이 주소인 relay 후보입니다.
STUN(RFC 8489, RFC 5389를 대체)은 “밖에서 보면 내 주소와 포트가 무엇인가”를 알려 주는 가벼운 프로토콜입니다. 연결 검사 메시지 형식으로도 쓰입니다. TURN(RFC 8656)은 직접 연결이 안 될 때 미디어를 서버가 대신 중계하게 합니다. 모든 트래픽이 서버를 거치므로 대역 비용과 지연이 늘어나지만, 이것 없이는 연결 자체가 안 되는 사용자가 반드시 생깁니다. Trickle ICE(RFC 8838)는 후보가 모두 모일 때까지 기다리지 않고 생길 때마다 보내서 연결 시간을 줄입니다.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: "stun:stun.example.com:3478" },
{ urls: ["turn:turn.example.com:3478?transport=udp",
"turns:turn.example.com:5349?transport=tcp"],
username, credential },
],
});
turns:(TLS over TCP, 보통 5349 또는 443 포트)는 UDP와 일반 포트가 막힌 기업 네트워크에서 마지막 수단이 됩니다. 이 경우 미디어가 TCP 위로 실리므로 손실 구간에서 헤드 오브 라인 블로킹 때문에 지연이 커질 수 있습니다.
DTLS-SRTP 암호화
ICE가 통하는 후보 쌍을 찾으면 그 경로 위에서 DTLS(1.2는 RFC 6347, 1.3은 RFC 9147) 핸드셰이크가 진행됩니다. 어느 쪽이 DTLS 클라이언트가 될지는 SDP의 a=setup 속성으로 정하고, 상대 인증서는 CA가 아니라 SDP에 담긴 a=fingerprint 해시와 비교해 검증합니다. 따라서 시그널링 채널이 변조되면 중간자 공격이 가능하므로, 시그널링은 반드시 TLS로 보호하고 인증해야 합니다.
DTLS-SRTP 프로파일(RFC 5764)은 핸드셰이크 결과에서 SRTP 마스터 키와 솔트를 추출하고, 이후 미디어는 SRTP/SRTCP(RFC 3711)로 암호화·인증됩니다. SRTP는 페이로드만 암호화하고 RTP 헤더는 인증만 하므로, 헤더 확장에 담긴 정보(예: 오디오 레벨)는 경로상에서 보일 수 있습니다.
이 암호화는 홉 단위입니다. SFU를 쓰면 SFU가 각 참가자와 따로 DTLS-SRTP 세션을 맺고 미디어를 복호화한 뒤 다시 암호화하므로, 서버는 원본 미디어에 접근할 수 있습니다. 서버도 보지 못하는 종단 간 암호화(E2EE)가 필요하면 Encoded Transform(RTCRtpScriptTransform, 이전 이름 Insertable Streams)으로 인코딩된 프레임을 한 번 더 암호화하고(SFrame, RFC 9605), 키 배포는 애플리케이션이 따로 설계해야 합니다.
시그널링 (WebSocket)
WebRTC 스펙은 시그널링 프로토콜을 정의하지 않습니다. 애플리케이션이 신뢰할 수 있는 채널로 SDP와 ICE 후보를 전달하기만 하면 됩니다.
메시지 모델
가장 단순한 형태는 { type, payload }이며 type은 join, offer, answer, ice-candidate, bye 정도면 충분합니다. 참가자가 여럿이면 roomId, from, to를 메시지 스키마에 고정해 두어야 합니다. 보안 측면에서는 WSS, 사용자 인증(쿠키나 토큰), 룸 입장 권한 검사가 기본입니다. 앞에서 설명했듯 SDP의 fingerprint를 바꿔치기할 수 있으면 DTLS 암호화를 우회할 수 있기 때문에, 시그널링 서버의 보안이 곧 미디어의 보안입니다.
SDP와 ICE 후보 교환 시 주의점
offer/answer는 연결할 때 한 번만 일어나는 것이 아닙니다. 화면 공유 트랙을 추가하거나, 카메라를 끄면서 방향을 바꾸거나, ICE 재시작을 하면 재협상이 일어납니다. 시그널링 코드는 처음부터 여러 번의 offer/answer를 처리할 수 있게 짜야 합니다.
Trickle ICE에서 흔히 놓치는 부분은 순서입니다. 후보끼리의 순서는 상관없지만, 원격 description이 설정되기 전에 addIceCandidate()를 호출하면 실패합니다. 네트워크 사정으로 offer보다 후보가 먼저 도착할 수 있으므로, 수신 측은 remoteDescription이 설정될 때까지 후보를 큐에 쌓아 두었다가 나중에 추가해야 합니다. 이 처리를 빼고 예외를 무시하면, 대부분은 연결되다가 특정 네트워크에서만 가끔 연결이 안 되는 재현하기 어려운 버그가 됩니다.
Perfect Negotiation
양쪽이 동시에 offer를 만들면 서로 have-local-offer 상태에서 상대 offer를 받게 되는 충돌(glare)이 생깁니다. W3C 스펙 예제와 MDN이 소개하는 Perfect Negotiation 패턴은 두 피어에 역할을 하나씩 줍니다. 충돌이 나면 impolite 피어는 들어온 offer를 무시하고 자기 offer를 유지하며, polite 피어는 자기 offer를 롤백하고 상대 offer를 받아들입니다. 이렇게 하면 양쪽이 같은 코드 경로를 쓰면서도 협상이 교착되지 않습니다.
// Perfect Negotiation (W3C 스펙 예제 기반). polite는 두 피어 중 한쪽만 true
function perfectNegotiation(pc, send, { polite }) {
let makingOffer = false;
let ignoreOffer = false;
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription(); // 상태에 맞게 offer 생성
send({ description: pc.localDescription });
} catch (err) {
console.error(err);
} finally {
makingOffer = false;
}
};
pc.onicecandidate = ({ candidate }) => send({ candidate });
return async function onMessage({ description, candidate }) {
try {
if (description) {
const offerCollision = description.type === "offer" &&
(makingOffer || pc.signalingState !== "stable");
ignoreOffer = !polite && offerCollision;
if (ignoreOffer) return; // impolite: 상대 offer 무시
// polite: 충돌 시 setRemoteDescription이 암묵적으로 롤백 후 적용
await pc.setRemoteDescription(description);
if (description.type === "offer") {
await pc.setLocalDescription(); // answer 생성
send({ description: pc.localDescription });
}
} else if (candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (err) {
if (!ignoreOffer) throw err; // 무시한 offer의 후보면 버림
}
}
} catch (err) {
console.error(err);
}
};
}
setRemoteDescription()의 암묵적 롤백과 인자 없는 setLocalDescription()은 비교적 최근에 모든 주요 브라우저에 들어간 기능이므로, 오래된 브라우저를 지원해야 한다면 명시적으로 { type: "rollback" }을 적용하고 createOffer()/createAnswer()를 직접 호출하는 형태로 바꿔야 합니다. Mozilla 블로그의 Perfect negotiation in WebRTC에 이 패턴의 설계 배경과 순서도가 잘 정리되어 있습니다.
실전 구현
브라우저 1:1 통화 — getUserMedia와 WebSocket
아래 예제는 서버가 룸의 첫 입장자를 caller, 두 번째를 callee로 정해 주고, callee가 들어왔을 때 caller에게 알려서 그때 offer를 만들게 하는 구조입니다. 역할을 고정하면 양쪽이 동시에 offer를 만드는 충돌이 생기지 않습니다. 원격 description 이전에 도착한 후보는 큐에 모아 두었다가 적용합니다.
<!-- index.html: <video id="local" autoplay muted playsinline>, <video id="remote" autoplay playsinline> -->
<script type="module">
const room = new URLSearchParams(location.search).get("room") || "demo";
const scheme = location.protocol === "https:" ? "wss://" : "ws://";
const ws = new WebSocket(scheme + location.hostname + ":8080");
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }], // 운영에서는 TURN 필수
});
const send = (m) => ws.send(JSON.stringify({ ...m, room }));
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
document.getElementById("local").srcObject = stream;
stream.getTracks().forEach((t) => pc.addTrack(t, stream));
pc.ontrack = ({ streams }) => {
document.getElementById("remote").srcObject = streams[0];
};
pc.onicecandidate = ({ candidate }) => {
if (candidate) send({ t: "ice", c: candidate.toJSON() });
};
let role = null;
const pending = []; // remoteDescription 이전 후보
async function flushCandidates() {
while (pending.length) await pc.addIceCandidate(pending.shift());
}
ws.onopen = () => send({ t: "join" });
ws.onmessage = async (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "join-ok") role = m.role; // "caller" | "callee"
if (m.t === "room-full") alert("이미 두 명이 들어와 있습니다");
if (m.t === "peer-joined" && role === "caller") {
await pc.setLocalDescription(await pc.createOffer());
send({ t: "offer", sdp: pc.localDescription });
}
if (m.t === "offer" && role === "callee") {
await pc.setRemoteDescription(m.sdp);
await flushCandidates();
await pc.setLocalDescription(await pc.createAnswer());
send({ t: "answer", sdp: pc.localDescription });
}
if (m.t === "answer" && role === "caller") {
await pc.setRemoteDescription(m.sdp);
await flushCandidates();
}
if (m.t === "ice" && m.c) {
if (pc.remoteDescription) await pc.addIceCandidate(m.c);
else pending.push(m.c);
}
};
</script>
Unified Plan에서 addTrack은 트랙마다 RTCRtpTransceiver를 만들거나 재사용합니다. 이 예제는 연결 뒤에 트랙을 추가하지 않는다고 가정했기 때문에 negotiationneeded를 처리하지 않습니다. 화면 공유처럼 연결 후 트랙을 추가하는 기능을 넣으려면 앞의 Perfect Negotiation 패턴으로 바꾸는 편이 낫습니다. 또 이 예제에는 TURN이 없어서, 한쪽이라도 제한적인 NAT 뒤에 있으면 iceConnectionState가 "failed"로 끝납니다.
Node.js 시그널링 서버 (ws)
// server.mjs — npm i ws
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map(); // roomId -> Set<ws>
wss.on("connection", (ws) => {
let room = null;
ws.on("message", (raw) => {
let m;
try { m = JSON.parse(raw.toString()); } catch { return; }
if (m.t === "join" && typeof m.room === "string") {
const set = rooms.get(m.room) ?? new Set();
if (set.size >= 2) { ws.send(JSON.stringify({ t: "room-full" })); return; }
room = m.room;
set.add(ws);
rooms.set(room, set);
ws.send(JSON.stringify({ t: "join-ok", role: set.size === 1 ? "caller" : "callee" }));
if (set.size === 2) {
for (const p of set) if (p !== ws) p.send(JSON.stringify({ t: "peer-joined" }));
}
return;
}
if (!room) return;
for (const p of rooms.get(room)) if (p !== ws) p.send(raw.toString());
});
ws.on("close", () => {
const set = room && rooms.get(room);
if (!set) return;
set.delete(ws);
if (set.size === 0) rooms.delete(room);
});
});
console.log("WS signaling on 8080");
이 서버는 인증, 메시지 크기 제한, 재접속 처리가 없는 학습용입니다. caller가 나간 뒤 새 사람이 들어오면 남아 있던 callee와 새 입장자 모두 callee가 되는 문제도 있으므로, 실제 서비스에서는 역할을 입장 순서가 아니라 협상 시점에 정하거나 Perfect Negotiation을 써서 역할 의존성을 없애는 편이 좋습니다.
STUN/TURN 서버 — Coturn
Coturn의 turnserver.conf 예시입니다. 도메인, 시크릿, 인증서 경로, IP는 환경에 맞게 바꿉니다.
# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
realm=turn.example.com
fingerprint
# 시간 제한 임시 자격 증명(TURN REST API 방식)
use-auth-secret
static-auth-secret=REPLACE_WITH_LONG_RANDOM
# NAT 뒤라면 공인IP/사설IP 형태로 지정
external-ip=203.0.113.5
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
total-quota=100
log-file=/var/log/turn.log
no-multicast-peers
# 릴레이를 통해 내부망에 접근하지 못하게 사설 대역 차단
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip는 빠뜨리기 쉽지만 중요합니다. TURN 서버는 인증된 사용자가 지정한 임의의 주소로 트래픽을 중계하므로, 사설 대역을 막지 않으면 클라우드 메타데이터 엔드포인트나 내부 서비스에 접근하는 SSRF 통로가 됩니다.
use-auth-secret 방식에서는 앱 서버가 공유 시크릿으로 짧은 수명의 자격 증명을 발급합니다. 사용자 이름은 만료 시각(Unix 타임스탬프)과 사용자 ID를 콜론으로 이은 문자열이고, 비밀번호는 그 문자열에 대한 HMAC-SHA1을 Base64로 인코딩한 값입니다.
import crypto from "node:crypto";
function turnCredentials(userId, secret, ttlSeconds = 3600) {
const username = `${Math.floor(Date.now() / 1000) + ttlSeconds}:${userId}`;
const credential = crypto.createHmac("sha1", secret).update(username).digest("base64");
return { username, credential };
}
고정된 사용자 이름과 비밀번호를 클라이언트 코드에 넣으면 누구나 추출해서 운영 중인 TURN 서버를 무료 릴레이로 쓸 수 있습니다. Twilio나 Xirsys 같은 관리형 TURN 서비스도 같은 이유로 API를 통해 임시 자격 증명을 발급합니다.
멀티파티 토폴로지: Mesh, SFU, MCU
| 토폴로지 | 참가자당 송신 | 참가자당 수신 | 서버 부하 | 주로 쓰는 경우 |
|---|---|---|---|---|
| Mesh(풀 P2P) | N-1개 스트림 | N-1개 스트림 | 시그널링만 | 2~3명 소규모 통화 |
| SFU | 1개(Simulcast면 레이어 수만큼) | 보고 싶은 만큼 선택 | 패킷 포워딩, 암복호화 | 대부분의 화상 회의, 라이브 |
| MCU | 1개 | 합성된 1개 | 디코딩·합성·재인코딩으로 높음 | 레거시 SIP/H.323 연동, 방송 합성 |
SFU는 트랜스코딩 없이 RTP 패킷을 다른 참가자에게 전달하지만, 패킷을 그대로 복사하는 것은 아닙니다. 참가자마다 별도 DTLS-SRTP 세션이 있으므로 복호화와 재암호화를 하고, Simulcast 레이어를 바꿀 때는 수신자가 끊김 없는 스트림을 보도록 SSRC·시퀀스 번호·타임스탬프를 다시 씁니다. RTCP도 종단해서 처리합니다. 수신자의 NACK에는 SFU가 캐시한 패킷으로 직접 재전송하고, 키프레임 요청(PLI/FIR)은 필요할 때만 송신자에게 올려 보내며, 혼잡 제어 피드백은 각 구간별로 따로 계산합니다.
화면 공유: getDisplayMedia
Screen Capture 스펙의 getDisplayMedia()는 반드시 사용자 클릭 같은 사용자 활성화(user activation) 안에서 호출해야 하며, 공유 대상 선택은 브라우저 UI가 담당합니다. 공유 트랙을 보내는 방법은 두 가지입니다. 카메라 트랙을 보내던 sender에 replaceTrack()으로 갈아 끼우면 재협상 없이 전환되고, 카메라와 화면을 동시에 보내려면 addTrack()으로 새 트랙을 추가하고 재협상합니다.
async function shareScreen(pc, cameraSender) {
const s = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
const v = s.getVideoTracks()[0];
v.contentHint = "detail"; // 프레임레이트보다 선명도 우선
if (cameraSender) {
const cameraTrack = cameraSender.track;
await cameraSender.replaceTrack(v); // 재협상 없음
v.onended = () => cameraSender.replaceTrack(cameraTrack); // 사용자가 공유 중지
} else {
s.getTracks().forEach((t) => pc.addTrack(t, s)); // negotiationneeded 발생
}
}
replaceTrack()은 새 트랙이 기존 협상 범위 안에 있어야 합니다. 예를 들어 오디오 sender에 비디오 트랙을 넣을 수는 없습니다. 탭·시스템 오디오 캡처는 브라우저와 OS에 따라 지원 여부가 다르므로 audio: true를 요청해도 오디오 트랙이 없을 수 있습니다.
고급 주제: 코덱과 전송
Simulcast
Simulcast는 하나의 MediaStreamTrack을 해상도·비트레이트가 다른 여러 RTP 스트림으로 동시에 인코딩해 보내는 방식입니다. 인코딩 목록은 addTransceiver()의 sendEncodings로 처음에 정해야 하며, 나중에 setParameters()로는 기존 인코딩의 값만 바꿀 수 있고 개수를 늘릴 수는 없습니다. SFU는 각 수신자의 대역과 화면 크기에 맞춰 레이어 하나를 골라 전달합니다. 레이어는 rid로 구분되며, 재전송 스트림에는 rrid(repaired RID)가 붙습니다.
const [track] = stream.getVideoTracks();
const tx = pc.addTransceiver(track, {
direction: "sendonly",
streams: [stream],
sendEncodings: [
{ rid: "q", maxBitrate: 150_000, scaleResolutionDownBy: 4 },
{ rid: "h", maxBitrate: 500_000, scaleResolutionDownBy: 2 },
{ rid: "f", maxBitrate: 1_200_000 },
],
});
트랙을 addTransceiver()의 첫 인자로 바로 넘기면 됩니다. 종류 문자열("video")로 transceiver를 만든 뒤 트랙을 붙이려면 tx.sender.replaceTrack(track)을 써야 하며, 이때 addTrack()을 호출하면 조건에 따라 simulcast 설정이 없는 새 transceiver가 생길 수 있습니다. 브라우저는 입력 해상도가 낮으면 상위 레이어를 보내지 않을 수 있고, Simulcast에 대한 코덱 지원도 브라우저마다 차이가 있습니다.
SVC (Scalable Video Coding)
SVC는 하나의 비트스트림 안에 시간 계층(프레임레이트)과 공간 계층(해상도)을 쌓아 인코딩합니다. 브라우저에서는 VP8(시간 계층만), VP9, AV1에서 쓸 수 있으며, W3C Scalable Video Coding (SVC) Extension for WebRTC의 scalabilityMode("L1T3", "L3T3_KEY" 등)로 계층 구조를 지정합니다. SFU는 AV1의 Dependency Descriptor 헤더 확장이나 VP9 페이로드 디스크립터를 읽어 어떤 패킷을 버려도 되는지 판단합니다.
pc.addTransceiver(track, {
direction: "sendonly",
sendEncodings: [{ scalabilityMode: "L3T3_KEY" }], // 공간 3계층, 시간 3계층
});
Simulcast와 비교하면 송신자는 인코딩을 하나만 하므로 업링크와 인코딩 CPU를 아낄 수 있습니다. 대신 SFU가 해당 코덱의 SVC 라우팅을 지원해야 하고, 하드웨어 인코더가 SVC를 지원하지 않는 기기에서는 소프트웨어 인코딩으로 떨어져 오히려 CPU를 더 쓸 수 있습니다.
대역 추정과 혼잡 제어
브라우저의 WebRTC 구현(대부분 libwebrtc 기반)은 GCC(Google Congestion Control)를 씁니다. 패킷 도착 간격의 변화로 큐잉 지연 증가를 감지하는 지연 기반 추정과, 손실률을 보는 손실 기반 추정을 결합해 송신 비트레이트를 정합니다. 이 알고리즘은 애플리케이션이 설정으로 바꿀 수 없습니다.
피드백 형식은 몇 세대가 있습니다. REMB(draft-alvestrand-rmcat-remb)는 수신자가 추정한 대역을 송신자에게 알려 주는 예전 방식입니다. 현재 기본은 transport-wide congestion control(transport-cc, draft-holmer-rmcat-transport-wide-cc-extensions)로, 수신자가 패킷별 도착 시각을 RTCP로 돌려주고 송신자가 직접 추정합니다. RFC 8888은 비슷한 목적의 IETF 표준 피드백 형식(CCFB)이며 transport-cc와는 다른 형식입니다. TMMBR/TMMBN(RFC 5104)은 주로 SIP 단말 쪽에서 쓰는 비트레이트 제한 메시지입니다.
애플리케이션이 할 수 있는 일은 관측과 상한 조정입니다. getStats()의 candidate-pair(현재 RTT, availableOutgoingBitrate), outbound-rtp(송신 비트레이트, qualityLimitationReason), remote-inbound-rtp(상대가 보고한 손실·지터)를 주기적으로 모아서, 필요하면 maxBitrate나 해상도를 낮춥니다. qualityLimitationReason이 "cpu"인지 "bandwidth"인지를 보면 화질 저하 원인이 네트워크인지 단말 성능인지 구분할 수 있어서 장애 분석에 특히 유용합니다.
손실 복구: NACK/RTX와 FEC
NACK/RTX는 수신자가 빠진 패킷을 요청하면 송신자가 별도 RTX 스트림으로 재전송하는 방식입니다. 최소 한 번의 RTT가 추가되므로, RTT가 짧을 때는 효과적이지만 RTT가 길면 지터 버퍼가 그만큼 기다려야 합니다.
FEC는 복구용 중복 데이터를 미리 함께 보내서 재전송 없이 복원합니다. 비디오에는 ULPFEC와 FlexFEC가 있고, 오디오에는 Opus 자체의 인밴드 FEC와 이전 프레임을 겹쳐 보내는 RED(RFC 2198)가 있습니다. 대역을 추가로 쓰는 대신 지연이 늘지 않는다는 것이 장점입니다. 어떤 방식이 활성화되는지는 SDP의 payload type과 a=fmtp 설정, 브라우저 구현에 따라 달라지며, RTCRtpTransceiver.setCodecPreferences()로 코덱 목록 순서를 바꿔 협상에 영향을 줄 수 있습니다.
품질과 성능 조정
비트레이트 적응
RTCRtpSender.getParameters()로 현재 파라미터를 가져와 encodings[i].maxBitrate, maxFramerate, scaleResolutionDownBy를 바꾼 뒤 setParameters()로 적용하면 재협상 없이 즉시 반영됩니다. getParameters()가 돌려준 객체를 수정해서 그대로 넘겨야 하며, 새 객체를 만들어 넘기면 트랜잭션 ID가 맞지 않아 실패합니다.
MediaStreamTrack.contentHint는 인코더에 콘텐츠 성격을 알려 줍니다. 비디오는 "motion", "detail", "text", 오디오는 "speech", "music"을 지정할 수 있습니다. degradationPreference("maintain-framerate", "maintain-resolution", "balanced")로 대역이 부족할 때 무엇을 먼저 희생할지 정할 수도 있지만, 브라우저마다 지원 범위가 다릅니다.
네트워크 우선순위 (DSCP)
WebRTC Priority Control API의 priority와 networkPriority 인코딩 파라미터로 패킷에 DSCP 마킹을 요청할 수 있습니다. 다만 브라우저 지원이 제한적이고, 운영체제가 권한 문제로 마킹을 무시하기도 하며, 가정용 공유기나 모바일망, 인터넷 구간에서 DSCP가 지워지거나 다시 매핑되는 경우가 많아서 종단 간 효과는 기대하기 어렵습니다. 체감 품질에는 TURN 서버의 위치, SFU 리전 배치, 대역 추정이 훨씬 크게 작용합니다.
CPU 사용량
인코딩 CPU는 해상도·프레임레이트·Simulcast 레이어 수에 비례해 늘어납니다. 참가자가 많은 회의에서는 송신 측 레이어 수와 해상도 상한을 정하고, 수신 측은 화면에 보이는 타일만 높은 레이어로 받도록 SFU에 요청하는 것이 일반적입니다. 화면 공유는 해상도가 높고 글자가 많아 인코딩 비용이 큽니다. contentHint = "text"나 "detail"을 주면 인코더가 프레임레이트를 낮추고 선명도를 우선하므로, 슬라이드나 문서 공유에서는 오히려 CPU와 대역을 아낄 수 있습니다. 배경 흐림 같은 영상 처리는 Insertable Streams for MediaStreamTrack이나 WebGL/WebGPU, WASM으로 구현하며, 이 부분이 인코딩보다 CPU를 더 쓰는 경우도 흔합니다.
모바일 배터리
카메라가 필요 없을 때는 sender.replaceTrack(null)로 송신을 멈추거나 트랙을 stop()해서 카메라 하드웨어를 끄는 것이 효과가 큽니다. 트랙의 enabled = false는 검은 프레임을 보낼 뿐 카메라 자체는 켜 둘 수 있습니다. 앱이 백그라운드로 가면 OS가 카메라 접근을 막거나 네트워크·타이머를 제한할 수 있으므로, iOS와 Android 각각의 백그라운드 오디오·VoIP 정책을 확인해야 합니다.
대규모 운영: mediasoup, Janus, Jitsi, LiveKit
| 구성요소 | 설명 | 적합한 경우 |
|---|---|---|
| mediasoup | C++ 미디어 워커를 Node.js 또는 Rust API로 제어하는 SFU 라이브러리. Router·Transport·Producer·Consumer 단위로 저수준 제어 | 자체 제품에 SFU를 깊게 통합할 때 |
| Janus | C로 작성된 범용 WebRTC 서버. 플러그인으로 SFU(VideoRoom), SIP 게이트웨이, 녹화, 스트리밍 등 제공 | 텔레콤 연동, 녹화, 게이트웨이 |
| Jitsi | Jitsi Videobridge(Java SFU)와 웹·모바일 클라이언트를 포함한 완성형 회의 스택 | 회의 서비스를 빠르게 자체 호스팅할 때 |
| LiveKit | Go로 작성된 SFU(Pion WebRTC 라이브러리 기반)와 SDK, 관리형 클라우드 제공 | SDK와 운영 도구까지 포함한 스택이 필요할 때 |
운영 구성은 대체로 비슷합니다. SFU 노드를 수평으로 늘리고, 룸이 어느 노드에 있는지와 참가자 상태를 Redis 같은 공유 저장소에 두며, TURN 서버를 사용자와 가까운 리전에 배치합니다. 관측은 서버 쪽 지표(Prometheus 등)와 클라이언트 getStats()를 주기적으로 수집한 데이터를 함께 봐야 합니다. 서버 지표만 보면 모든 것이 정상인데 특정 ISP 사용자만 품질이 나쁜 경우를 놓치기 쉽습니다. 로그에 SDP 전문을 남기면 IP 주소와 fingerprint 같은 민감 정보가 함께 저장되므로 수집 범위를 제한하는 것이 좋습니다.
참고한 표준 문서
W3C WebRTC · W3C Media Capture and Streams · W3C Identifiers for WebRTC’s Statistics API · W3C WebRTC SVC · IETF RFC 8445 (ICE) · RFC 8489 (STUN) · RFC 8656 (TURN) · RFC 8838 (Trickle ICE) · RFC 9429 (JSEP) · RFC 9143 (BUNDLE) · RFC 5764 (DTLS-SRTP) · RFC 8831 (Data Channels).