WebRTC Complete Guide | Peer-to-Peer Video, Audio, and Data Channels
이 글의 핵심
WebRTC enables direct browser-to-browser video, audio, and data transfer without a media server. This guide covers the complete connection lifecycle — signaling, ICE negotiation, media streams, and data channels — with production patterns.
How WebRTC Works
Peer A Signaling Server Peer B
│ │ │
│──── SDP Offer ────────────→│──── SDP Offer ───────────→│
│ │ │
│←─── SDP Answer ───────────│←─── SDP Answer ───────────│
│ │ │
│──── ICE candidates ───────→│──── ICE candidates ──────→│
│←─── ICE candidates ───────│←─── ICE candidates ───────│
│ │ │
│◄══════════ Direct P2P media/data connection ═══════════│
WebRTC has three phases:
- Signaling — exchange SDP descriptions via your server
- ICE — find a network path (direct or via TURN relay)
- Connection — media/data flows peer-to-peer
1. Signaling Server
The signaling server exchanges connection metadata. Here’s a minimal Socket.IO implementation:
// server.ts
import { Server } from 'socket.io'
import { createServer } from 'http'
const httpServer = createServer()
const io = new Server(httpServer, {
cors: { origin: '*' }
})
io.on('connection', (socket) => {
console.log('Client connected:', socket.id)
// Join a room
socket.on('join-room', (roomId: string) => {
socket.join(roomId)
socket.to(roomId).emit('user-joined', socket.id)
console.log(`${socket.id} joined room ${roomId}`)
})
// Forward SDP offer to the target peer
socket.on('offer', ({ targetId, offer }: { targetId: string; offer: RTCSessionDescriptionInit }) => {
socket.to(targetId).emit('offer', { fromId: socket.id, offer })
})
// Forward SDP answer
socket.on('answer', ({ targetId, answer }: { targetId: string; answer: RTCSessionDescriptionInit }) => {
socket.to(targetId).emit('answer', { fromId: socket.id, answer })
})
// Forward ICE candidates
socket.on('ice-candidate', ({ targetId, candidate }: { targetId: string; candidate: RTCIceCandidateInit }) => {
socket.to(targetId).emit('ice-candidate', { fromId: socket.id, candidate })
})
socket.on('disconnect', () => {
socket.broadcast.emit('user-left', socket.id)
})
})
httpServer.listen(3001, () => console.log('Signaling server on port 3001'))
2. RTCPeerConnection — Core API
// webrtc.ts — WebRTC peer connection manager
const ICE_SERVERS = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }, // free Google STUN
{ urls: 'stun:stun1.l.google.com:19302' },
// TURN server (required for symmetric NAT)
// {
// urls: 'turn:your-turn-server.com:3478',
// username: 'username',
// credential: 'password',
// },
],
}
async function createPeerConnection(
onIceCandidate: (candidate: RTCIceCandidate) => void,
onTrack: (streams: readonly MediaStream[]) => void
): Promise<RTCPeerConnection> {
const pc = new RTCPeerConnection(ICE_SERVERS)
// Send ICE candidates to the remote peer via signaling
pc.onicecandidate = (event) => {
if (event.candidate) {
onIceCandidate(event.candidate)
}
}
pc.oniceconnectionstatechange = () => {
console.log('ICE state:', pc.iceConnectionState)
// 'checking' → 'connected' → 'completed'
// 'failed' → try restart ICE or reconnect
}
pc.onconnectionstatechange = () => {
console.log('Connection state:', pc.connectionState)
}
// Remote media tracks received
pc.ontrack = (event) => {
onTrack(event.streams)
}
return pc
}
3. Video Call — Complete Example
// client.ts
import { io } from 'socket.io-client'
const socket = io('http://localhost:3001')
let localStream: MediaStream
let peers: Map<string, RTCPeerConnection> = new Map()
// Step 1: Get local media
async function startLocalStream(): Promise<MediaStream> {
localStream = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720, facingMode: 'user' },
audio: { echoCancellation: true, noiseSuppression: true },
})
const localVideo = document.getElementById('local-video') as HTMLVideoElement
localVideo.srcObject = localStream
localVideo.muted = true // prevent echo
return localStream
}
// Step 2: Join room and create connections
async function joinRoom(roomId: string) {
await startLocalStream()
socket.emit('join-room', roomId)
}
// Step 3: When a new user joins — create offer
socket.on('user-joined', async (remoteId: string) => {
const pc = await createPeerConnection(remoteId)
// Add local tracks to connection
localStream.getTracks().forEach(track => pc.addTrack(track, localStream))
// Create and send offer
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
socket.emit('offer', { targetId: remoteId, offer })
})
// Step 4: Receive offer — create answer
socket.on('offer', async ({ fromId, offer }: { fromId: string; offer: RTCSessionDescriptionInit }) => {
const pc = await createPeerConnection(fromId)
localStream.getTracks().forEach(track => pc.addTrack(track, localStream))
await pc.setRemoteDescription(offer)
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
socket.emit('answer', { targetId: fromId, answer })
})
// Step 5: Receive answer
socket.on('answer', async ({ fromId, answer }: { fromId: string; answer: RTCSessionDescriptionInit }) => {
const pc = peers.get(fromId)
await pc?.setRemoteDescription(answer)
})
// Step 6: Exchange ICE candidates
socket.on('ice-candidate', async ({ fromId, candidate }: { fromId: string; candidate: RTCIceCandidateInit }) => {
const pc = peers.get(fromId)
await pc?.addIceCandidate(candidate)
})
async function createPeerConnection(remoteId: string): Promise<RTCPeerConnection> {
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
peers.set(remoteId, pc)
pc.onicecandidate = ({ candidate }) => {
if (candidate) socket.emit('ice-candidate', { targetId: remoteId, candidate })
}
pc.ontrack = ({ streams }) => {
// Add remote video
let remoteVideo = document.getElementById(`video-${remoteId}`) as HTMLVideoElement
if (!remoteVideo) {
remoteVideo = document.createElement('video')
remoteVideo.id = `video-${remoteId}`
remoteVideo.autoplay = true
remoteVideo.playsInline = true
document.getElementById('remote-videos')!.appendChild(remoteVideo)
}
remoteVideo.srcObject = streams[0]
}
return pc
}
// Cleanup on user leave
socket.on('user-left', (remoteId: string) => {
peers.get(remoteId)?.close()
peers.delete(remoteId)
document.getElementById(`video-${remoteId}`)?.remove()
})
4. Data Channels
Send arbitrary data peer-to-peer (no server involved):
// Create data channel on the offering side
const pc = new RTCPeerConnection(config)
const dataChannel = pc.createDataChannel('chat', {
ordered: true, // guaranteed order (like TCP)
// ordered: false, // unordered (like UDP, lower latency)
})
dataChannel.onopen = () => {
console.log('Data channel open')
dataChannel.send(JSON.stringify({ type: 'hello', text: 'Hi!' }))
}
dataChannel.onmessage = (event) => {
const msg = JSON.parse(event.data)
console.log('Received:', msg)
}
// Receive data channel on the answering side
pc.ondatachannel = (event) => {
const channel = event.channel
channel.onopen = () => console.log('Channel ready')
channel.onmessage = (e) => {
const msg = JSON.parse(e.data)
displayMessage(msg)
}
}
// Send different data types
dataChannel.send('plain text')
dataChannel.send(JSON.stringify({ type: 'message', text: 'Hello' }))
// Send binary (files, images)
const file = new File(['content'], 'test.txt')
const buffer = await file.arrayBuffer()
dataChannel.send(buffer)
5. Screen Sharing
async function startScreenShare(pc: RTCPeerConnection, localStream: MediaStream) {
// Get screen stream
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
displaySurface: 'monitor', // 'window', 'browser', 'monitor'
frameRate: 30,
},
audio: true, // capture system audio (if supported)
})
const screenTrack = screenStream.getVideoTracks()[0]
// Replace video track in connection
const sender = pc.getSenders().find(s => s.track?.kind === 'video')
if (sender) await sender.replaceTrack(screenTrack)
// Restore camera when screen share stops
screenTrack.onended = async () => {
const cameraTrack = localStream.getVideoTracks()[0]
if (sender) await sender.replaceTrack(cameraTrack)
console.log('Screen share stopped, back to camera')
}
return screenStream
}
6. Media Controls
// Mute/unmute audio
function toggleAudio(stream: MediaStream) {
stream.getAudioTracks().forEach(track => {
track.enabled = !track.enabled
console.log('Audio:', track.enabled ? 'on' : 'off')
})
}
// Enable/disable video
function toggleVideo(stream: MediaStream) {
stream.getVideoTracks().forEach(track => {
track.enabled = !track.enabled
console.log('Video:', track.enabled ? 'on' : 'off')
})
}
// Change video quality
async function changeResolution(pc: RTCPeerConnection, width: number, height: number) {
const sender = pc.getSenders().find(s => s.track?.kind === 'video')
if (!sender) return
const params = sender.getParameters()
params.encodings = [{ maxBitrate: 500000, scaleResolutionDownBy: 1 }]
await sender.setParameters(params)
}
// Get connection statistics
async function getStats(pc: RTCPeerConnection) {
const stats = await pc.getStats()
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
console.log('Video received:', {
bitrate: report.bytesReceived,
packets: report.packetsReceived,
lost: report.packetsLost,
fps: report.framesPerSecond,
})
}
})
}
7. Production Considerations
TURN Server (Coturn)
# Install on Ubuntu
sudo apt install coturn
# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
listening-ip=0.0.0.0
external-ip=YOUR_PUBLIC_IP
realm=your-domain.com
user=username:password
lt-cred-mech
fingerprint
no-loopback-peers
no-multicast-peers
Time-limited TURN credentials (security)
// Never expose static TURN credentials in frontend code
// Generate short-lived credentials server-side
import crypto from 'crypto'
function generateTurnCredentials(username: string, ttlSeconds = 3600) {
const expiry = Math.floor(Date.now() / 1000) + ttlSeconds
const temporaryUser = `${expiry}:${username}`
const credential = crypto
.createHmac('sha1', process.env.TURN_SECRET!)
.update(temporaryUser)
.digest('base64')
return {
urls: ['turn:your-turn.example.com:3478'],
username: temporaryUser,
credential,
}
}
// API endpoint to get TURN credentials
app.get('/api/turn-credentials', requireAuth, (req, res) => {
const creds = generateTurnCredentials(req.user.id)
res.json({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }, creds] })
})
WebRTC vs WebSocket vs SSE
| WebRTC | WebSocket | SSE | |
|---|---|---|---|
| Media | Peer-to-peer | Server relay | Server push only |
| Latency | Lowest (direct) | Low | Low |
| Server load | None (media) | High | Medium |
| NAT traversal | Required | Not needed | Not needed |
| Best for | Video/audio calls, P2P data | Chat, gaming, collaboration | Notifications, feeds |
Key Takeaways
- Signaling server (WebSocket/Socket.IO) only exchanges SDP + ICE — no media
- STUN discovers public IP; works for 80% of connections
- TURN relays media when direct connection fails — required for production
- Data channels send P2P text/binary without server involvement
- Screen sharing:
getDisplayMedia()+replaceTrack()on existing connection - SFU (mediasoup, Janus) required for calls with 5+ participants
- Credentials: generate time-limited TURN credentials server-side — never hardcode