TL;DR
- WebRTC = P2P + 미디어 + 데이터 — 브라우저, 모바일, 서버 어디서나 통신
- 3가지 핵심 API:
getUserMedia(카메라/마이크),RTCPeerConnection(P2P 연결),RTCDataChannel(임의 데이터) - NAT 트래버설이 핵심: STUN(IP 발견) → TURN(중계, 비싼 마지막 수단) → ICE(최적 경로 선택)
- 3명 이상이면 SFU 필수: P2P는 N²; SFU는 중앙 서버가 미디어 라우팅
- 실전 SFU 라이브러리: mediasoup(Node), Janus(C), Pion(Go), LiveKit(Go)
1. WebRTC의 비밀
1.1 한 줄 정의
WebRTC = 브라우저에서 플러그인 없이 P2P 음성/비디오/데이터 통신을 가능케 하는 W3C 표준.
WebRTC가 등장하기 전:
- 비디오 채팅 = Flash, Java Applet, ActiveX (모두 사라짐)
- 음성 통화 = Skype 같은 별도 앱
- 화면 공유 = 전용 소프트웨어
WebRTC의 약속: 단 몇 줄의 JavaScript로 Skype 수준의 통신 구현.
1.2 WebRTC의 3가지 API
| API | 용도 |
|---|---|
getUserMedia | 카메라/마이크 접근 |
RTCPeerConnection | 피어 간 연결 + 미디어 송수신 |
RTCDataChannel | 임의 데이터 P2P 전송 (파일, 게임 상태) |
1.3 가장 단순한 예시
// 1. 카메라 접근
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
})
document.querySelector('video').srcObject = stream
// 2. P2P 연결
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
// 3. 로컬 스트림 추가
stream.getTracks().forEach(track => pc.addTrack(track, stream))
// 4. 원격 스트림 수신
pc.ontrack = (event) => {
document.querySelector('#remote-video').srcObject = event.streams[0]
}
이게 전부입니다. 단, 시그널링과 NAT 트래버설이 숨어있습니다.
2. 시그널링 (Signaling)
2.1 시그널링이란?
WebRTC는 P2P 연결을 만들지만, 연결을 만들기 전에 정보를 교환해야 합니다:
- 내 IP/포트는?
- 어떤 코덱을 지원해?
- DTLS 핑거프린트는?
이 정보 교환을 시그널링이라고 합니다. WebRTC 표준은 시그널링 방법을 정의하지 않습니다 — 개발자가 구현.
2.2 일반적 시그널링 = WebSocket
// 시그널링 서버 (Node.js)
const WebSocket = require('ws')
const wss = new WebSocket.Server({ port: 8080 })
wss.on('connection', (ws) => {
ws.on('message', (msg) => {
// 다른 클라이언트들에게 브로드캐스트
wss.clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(msg)
}
})
})
})
2.3 SDP — Session Description Protocol
피어들이 교환하는 정보는 SDP 형식:
v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 9 0 8
a=rtcp-mux
a=rtpmap:111 opus/48000/2
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99
a=rtcp-mux
a=rtpmap:96 VP8/90000
이는 "내가 지원하는 코덱, 내 RTP 설정, 보안 키 등"을 담고 있습니다.
2.4 Offer/Answer 모델
// Peer A (caller)
const offer = await pcA.createOffer()
await pcA.setLocalDescription(offer)
signalingChannel.send({ type: 'offer', sdp: offer.sdp })
// Peer B (callee)
signalingChannel.on('offer', async (offer) => {
await pcB.setRemoteDescription(offer)
const answer = await pcB.createAnswer()
await pcB.setLocalDescription(answer)
signalingChannel.send({ type: 'answer', sdp: answer.sdp })
})
// Peer A receives answer
signalingChannel.on('answer', async (answer) => {
await pcA.setRemoteDescription(answer)
})
// 양쪽: ICE 후보 교환
pcA.onicecandidate = (event) => {
if (event.candidate) {
signalingChannel.send({ type: 'ice', candidate: event.candidate })
}
}
3. NAT 트래버설 — WebRTC의 가장 어려운 부분
3.1 NAT 문제
대부분의 클라이언트는 NAT 뒤에 있습니다:
- 가정용 라우터
- 회사 방화벽
- 모바일 캐리어 네트워크
NAT 뒤의 클라이언트는 자신의 공인 IP를 모릅니다. 다른 클라이언트가 직접 연결할 수도 없습니다.
3.2 STUN — Simple Traversal of UDP through NAT
STUN 서버가 클라이언트의 공인 IP/포트를 알려줍니다.
[Client A] → STUN server → "당신의 공인 IP는 1.2.3.4:5678"
[Client B] → STUN server → "당신의 공인 IP는 5.6.7.8:9012"
이 정보를 시그널링으로 교환하면, 클라이언트끼리 직접 연결 시도 가능 (UDP hole punching).
무료 STUN 서버: stun:stun.l.google.com:19302
3.3 TURN — 중계 서버
STUN으로 안 되는 경우 (대칭 NAT, 엄격한 방화벽):
- 모든 트래픽을 TURN 서버가 중계
- 중계 비용 = 비싸다 (대역폭 + CPU)
- 마지막 수단
coturn 같은 오픈소스 TURN 서버 운영 가능.
3.4 ICE — 모든 것을 합치기
ICE (Interactive Connectivity Establishment):
- 모든 후보 IP/포트 수집 (로컬, STUN, TURN)
- 각 후보 쌍에 대해 연결 시도
- 가장 빠른 경로 선택
ICE 후보 우선순위:
- 호스트 후보 (로컬 IP) - 최고
- server reflexive (STUN으로 발견) - 좋음
- 릴레이 (TURN) - 최후
3.5 STUN/TURN 서버 설정
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:my-turn-server.com:3478',
username: 'user',
credential: 'password'
}
]
})
4. 미디어 서버 — P2P를 넘어서
4.1 P2P의 한계
3명 회의 = 각자 2개 연결 = 총 6개 스트림 = N(N-1) 연결.
| 참여자 | 연결 수 | 송신 스트림 |
|---|---|---|
| 2명 | 1 | 2 |
| 5명 | 10 | 20 |
| 10명 | 45 | 90 |
| 50명 | 1,225 | 2,450 |
10명만 되어도 각 클라이언트가 9개 스트림 송신 → 대역폭 폭증, 배터리 소모, 모바일 불가능.
4.2 SFU (Selective Forwarding Unit)
해결: 중앙 서버가 각 참여자의 스트림을 받고 다른 모두에게 전달.
[A] ─stream─→ [SFU] ─stream─→ [B]
├─stream─→ [C]
└─stream─→ [D]
각 클라이언트는 1개 송신, N-1개 수신. 송신 측 부하 절감.
SFU의 장점:
- 인코딩 1번만 (각 참여자가)
- 서버는 라우팅만 (디코딩 X) → 가벼움
- 셀렉티브 포워딩 (저해상도/고해상도 선택)
- Simulcast 지원
4.3 MCU (Multipoint Control Unit)
또 다른 옵션: 서버가 모든 스트림을 합성하여 단일 스트림 전송.
장점: 클라이언트가 1개만 받음 (가장 가벼움). 단점: 서버에서 디코딩+합성+인코딩 → 매우 비쌈, 지연시간 증가.
4.4 비교
| P2P | SFU | MCU | |
|---|---|---|---|
| 참여자 | 1:1 | 다수 | 다수 |
| 클라이언트 부하 | 낮음 | 보통 | 매우 낮음 |
| 서버 부하 | 없음 | 보통 | 매우 높음 |
| 비용 | 0 (TURN 제외) | 대역폭 | 대역폭+CPU+GPU |
| 지연시간 | 가장 낮음 | 낮음 | 보통 |
| 보안 | E2EE 가능 | 서버가 봄 | 서버가 봄 |
| 사용 사례 | 1:1 통화 | 화상회의 | 방송 |
4.5 SFU 라이브러리
| 라이브러리 | 언어 | 특징 |
|---|---|---|
| mediasoup | Node.js + C++ | 가장 인기, Discord 사용 |
| Janus | C | 모듈식, Pion보다 무거움 |
| Pion | Go | 순수 Go, 가벼움, 임베디드 가능 |
| LiveKit | Go | 매니지드 서비스 + 오픈소스 |
| Jitsi Videobridge | Java | Jitsi Meet의 핵심 |
| Galene | Go | 단순 |
| OpenVidu | Java | Kurento 기반 |
4.6 mediasoup 예시
const mediasoup = require('mediasoup')
// Worker 생성 (별도 프로세스)
const worker = await mediasoup.createWorker()
const router = await worker.createRouter({
mediaCodecs: [
{ kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2 },
{ kind: 'video', mimeType: 'video/VP8', clockRate: 90000 }
]
})
// Producer (스트림 송신자)
const transport = await router.createWebRtcTransport({...})
const producer = await transport.produce({ kind: 'video', rtpParameters })
// Consumer (스트림 수신자)
const consumerTransport = await router.createWebRtcTransport({...})
const consumer = await consumerTransport.consume({
producerId: producer.id,
rtpCapabilities
})
mediasoup의 핵심 개념:
- Router: 같은 미디어 라우팅 그룹 (방)
- Transport: 클라이언트와의 연결
- Producer: 미디어 송신자
- Consumer: 미디어 수신자
5. 데이터 채널 — RTCDataChannel
5.1 사용 사례
- 파일 전송 (P2P, 서버 거치지 않음)
- 게임 상태 (낮은 지연시간)
- 채팅 메시지 (시그널링과 별도)
- CRDT 동기화 (Yjs y-webrtc)
5.2 사용법
// 송신자
const dc = pc.createDataChannel('my-channel', {
ordered: true, // 순서 보장
maxRetransmits: 3 // 재전송 제한
})
dc.onopen = () => {
dc.send('Hello!')
dc.send(new Uint8Array([1, 2, 3])) // 바이너리도 가능
}
// 수신자
pc.ondatachannel = (event) => {
const channel = event.channel
channel.onmessage = (e) => console.log('Received:', e.data)
}
5.3 옵션
| 옵션 | 설명 |
|---|---|
ordered: true | 순서 보장 (TCP-like) |
ordered: false | 순서 무시 (UDP-like, 빠름) |
maxRetransmits: N | 재전송 N회 |
maxPacketLifeTime: ms | 시간 내 못 가면 포기 |
게임처럼 최신 정보가 중요하면 ordered: false, maxRetransmits: 0 (UDP-like).
6. 미디어 품질 최적화
6.1 Adaptive Bitrate
WebRTC는 자동으로 네트워크 상황에 맞춰 비트레이트를 조정합니다 (google-congestion-control).
수동 제어:
const sender = pc.getSenders().find(s => s.track.kind === 'video')
const params = sender.getParameters()
params.encodings[0].maxBitrate = 1000000 // 1 Mbps
await sender.setParameters(params)
6.2 Simulcast
같은 비디오를 여러 해상도/비트레이트로 동시 전송. SFU가 수신자에 따라 선택.
const transceiver = pc.addTransceiver(videoTrack, {
sendEncodings: [
{ rid: 'q', maxBitrate: 100000, scaleResolutionDownBy: 4 }, // 240p
{ rid: 'h', maxBitrate: 500000, scaleResolutionDownBy: 2 }, // 480p
{ rid: 'f', maxBitrate: 1500000 } // 720p
]
})
효과: 모바일 사용자는 240p, 데스크톱은 720p — 같은 회의에서 각자 최적.
6.3 SVC (Scalable Video Coding)
Simulcast의 진화. 단일 스트림에 여러 레이어를 인코딩.
- VP9, AV1이 SVC 지원
- 서버에서 레이어 선택 가능 (재인코딩 X)
- 대역폭 절감
6.4 코덱
| 코덱 | 라이선스 | 품질 | 호환성 |
|---|---|---|---|
| VP8 | 무료 | 보통 | 모든 브라우저 |
| VP9 | 무료 | 좋음 | 대부분 |
| AV1 | 무료 | 최고 | 점차 확산 |
| H.264 | 라이선스 (대부분 무료) | 좋음 | 모든 장치 (하드웨어 가속) |
| H.265 | 라이선스 | 매우 좋음 | 제한적 |
WebRTC 권장: VP8 (호환성), VP9 (품질), AV1 (미래).
7. 음성 처리
7.1 자동 처리
WebRTC가 자동 적용:
- 에코 캔슬링 (AEC)
- 노이즈 억제 (NS)
- 자동 게인 컨트롤 (AGC)
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
})
7.2 Opus 코덱
WebRTC의 표준 음성 코덱:
- 6 kbps ~ 510 kbps 가변
- 고품질 (음악도 가능)
- 저지연 (5ms ~ 60ms)
- 에러 복구 (FEC, PLC)
7.3 음성 처리 라이브러리
- Krisp — AI 노이즈 제거 (상용)
- RNNoise — 무료 RNN 기반 (Mozilla)
- WebRTC VAD — Voice Activity Detection
8. 보안
8.1 DTLS-SRTP
WebRTC는 모든 미디어가 암호화됩니다:
- DTLS — 키 교환 (TLS over UDP)
- SRTP — 미디어 암호화
선택권 없음 — 평문 미디어 불가능. 시큐어 by default.
8.2 End-to-End Encryption (E2EE)
기본: SFU는 미디어를 봅니다 (라우팅만 하지만 복호화 가능).
E2EE: 클라이언트끼리만 알 수 있는 키로 미디어를 한 번 더 암호화.
WebRTC Insertable Streams API:
const sender = pc.getSenders()[0]
const stream = sender.createEncodedStreams()
const transformStream = new TransformStream({
transform(encodedFrame, controller) {
// 추가 암호화
encryptFrame(encodedFrame)
controller.enqueue(encodedFrame)
}
})
stream.readable.pipeThrough(transformStream).pipeTo(stream.writable)
제약: SFU는 비트레이트 기반 라우팅만 가능 (내용 모름). Simulcast 등 일부 기능 제약.
사용: Whereby, Galaxy 회의 (Apple FaceTime은 자체 프로토콜로 E2EE).
8.3 인증과 권한
WebRTC 자체는 신원 검증 안 함. 시그널링 레벨에서 인증:
- JWT 토큰
- OAuth
- 짧은 룸 코드
9. 프로덕션 운영
9.1 모니터링
const stats = await pc.getStats()
stats.forEach(report => {
if (report.type === 'inbound-rtp') {
console.log({
packetsLost: report.packetsLost,
jitter: report.jitter,
bytesReceived: report.bytesReceived,
framesPerSecond: report.framesPerSecond
})
}
})
핵심 지표:
- 패킷 손실률 (>3%면 문제)
- Jitter (>30ms면 끊김)
- Round-trip time (RTT)
- 비트레이트 (실제 vs 목표)
- 프레임 레이트
9.2 디버깅 도구
- chrome://webrtc-internals — Chrome의 내장 도구
- about:webrtc — Firefox
- webrtc-stats.js — 통계 라이브러리
9.3 일반적 문제
| 증상 | 원인 | 해결 |
|---|---|---|
| 연결 실패 | NAT 통과 못 함 | TURN 서버 확인 |
| 끊김 | 네트워크 불안정 | 비트레이트 낮춤, FEC |
| 에코 | AEC 비활성 | echoCancellation: true |
| 검은 화면 | 카메라 권한 | 권한 다이얼로그 확인 |
| 음성만 안 나옴 | 자동 재생 차단 | 사용자 제스처 후 재생 |
9.4 확장성
수십~수백 명: SFU 1대로 충분 수천 명: SFU 클러스터 + 로드 밸런서 수만 명: 캐스케이딩 SFU + Edge 노드
10. 실전 — Discord, Google Meet은 어떻게 만들까?
10.1 Discord (mediasoup)
- mediasoup + Elixir 시그널링
- 글로벌 SFU 분산 (낮은 지연시간)
- Opus 음성 + 화면 공유
- ~1000명 음성 채널 지원
10.2 Google Meet (자체 구현)
- 자체 미디어 서버
- VP9 SVC 사용
- AI 기반 노이즈 제거
- 100명+ 참여자 지원
10.3 Zoom (자체 프로토콜)
- WebRTC가 아닌 자체 프로토콜
- 더 효율적 (커스텀)
- 호환성을 위해 WebRTC도 지원
10.4 LiveKit (오픈소스 + 매니지드)
import { Room } from 'livekit-client'
const room = new Room()
await room.connect('wss://my-livekit-server.com', token)
await room.localParticipant.enableCameraAndMicrophone()
room.on('participantConnected', (p) => {
console.log('Joined:', p.identity)
})
LiveKit는 SFU + 클라이언트 SDK + 매니지드 서비스를 모두 제공. WebRTC 복잡도를 추상화.
퀴즈
1. WebRTC에서 시그널링이 필요한 이유는?
답: WebRTC는 P2P 연결을 만들지만, 연결 전 정보 교환이 필요합니다: (1) 내 IP/포트는 무엇인가? (2) 어떤 코덱을 지원하는가? (3) DTLS 핑거프린트는? 이 정보를 SDP(Session Description Protocol) 형식으로 교환합니다. WebRTC 표준은 시그널링 방법을 정의하지 않아 개발자가 구현해야 합니다 (보통 WebSocket).
2. STUN과 TURN의 차이는?
답: STUN은 클라이언트가 자신의 공인 IP/포트를 발견하게 해줍니다 — 매우 가벼운 서버. TURN은 STUN으로 연결이 안 될 때 (대칭 NAT, 엄격한 방화벽) 모든 미디어 트래픽을 중계합니다 — 비싼 마지막 수단. 일반적으로 80% 사용자는 STUN으로 충분, 20%는 TURN 필요. coturn 오픈소스 TURN 서버를 직접 운영하면 비용 절감.
3. SFU와 MCU의 차이는?
답: **SFU (Selective Forwarding Unit)**는 미디어를 받아 다른 참여자들에게 라우팅만 합니다 (디코딩 X). 가볍고, simulcast 지원. **MCU (Multipoint Control Unit)**는 모든 스트림을 디코딩+합성+재인코딩하여 단일 스트림 전송. 클라이언트는 가벼우나 서버 부하가 매우 큼. 현대 화상회의는 거의 SFU를 사용 (Discord, Meet, Zoom 모두). MCU는 방송이나 매우 제한된 클라이언트에서.
4. P2P가 3명 이상에서 안 되는 이유는?
답: N(N-1) 연결이 필요합니다. 10명이면 90개 송신 스트림 = 각 클라이언트가 9개 스트림 송신. 대역폭 폭증, 배터리 소모, 모바일 불가능. SFU는 각 클라이언트가 1개 송신, N-1개 수신으로 줄여줍니다. SFU의 추가 이점: simulcast (수신자별 해상도 선택), 인코딩 1번만, 서버는 라우팅만.
5. WebRTC의 E2EE가 어려운 이유는?
답: 기본 WebRTC는 DTLS-SRTP로 클라이언트-서버 구간만 암호화합니다. SFU가 미디어를 라우팅하려면 복호화해야 합니다 (이론적으로 SFU 운영자가 미디어를 볼 수 있음). 진정한 E2EE는 Insertable Streams API로 클라이언트끼리만 아는 키로 추가 암호화합니다. 단, SFU는 콘텐츠를 모르므로 simulcast 같은 기능에 제약. Whereby, Apple FaceTime이 E2EE를 구현했습니다.
참고 자료
- WebRTC.org — 공식
- MDN WebRTC API
- WebRTC for the Curious — 무료 책
- High Performance Browser Networking — 챕터 18
- mediasoup — Node.js SFU
- Pion — Go WebRTC
- LiveKit — 오픈소스 + 매니지드
- Janus
- Jitsi — 오픈소스 화상회의
- coturn — 오픈소스 TURN 서버
- chrome://webrtc-internals — Chrome 디버깅
현재 단락 (1/352)
- **WebRTC = P2P + 미디어 + 데이터** — 브라우저, 모바일, 서버 어디서나 통신