본문으로 건너뛰기 WebRTC Complete Guide | Peer-to-Peer Video· Audio

WebRTC Complete Guide | Peer-to-Peer Video· Audio

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:

  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

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.


Other articles related to this topic.


Keywords Covered in This Article (Related Search Terms)

This article covers WebRTC, Real-Time, Video, Peer-to-Peer, JavaScript, Frontend.