WebRTC Complete Guide | Peer-to-Peer Video, Audio, and Data Channels

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:

  1. Signaling — exchange SDP descriptions via your server
  2. ICE — find a network path (direct or via TURN relay)
  3. 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

WebRTCWebSocketSSE
MediaPeer-to-peerServer relayServer push only
LatencyLowest (direct)LowLow
Server loadNone (media)HighMedium
NAT traversalRequiredNot neededNot needed
Best forVideo/audio calls, P2P dataChat, gaming, collaborationNotifications, 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