WebRTC Complete Guide | Peer-to-Peer Video· Audio
이 글의 핵심
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
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. Build real-time peer-to-peer applications with WebRTC. Covers RTCPeerConnection, signaling, ICE/STUN/TURN, media streams…
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
- WebSocket vs SSE vs Long Polling
- Socket.IO Complete Guide | Real-Time Apps· Rooms
- Node.js + Nginx Reverse Proxy Setup
Keywords Covered in This Article (Related Search Terms)
This article covers WebRTC, Real-Time, Video, Peer-to-Peer, JavaScript, Frontend.