LabHub

블로그

WebRTC 완전 가이드 2025: 실시간 비디오/오디오 통신, P2P, STUN/TURN, 시그널링, SFU/MCU

TL;DR


1. WebRTC의 비밀

1.1 한 줄 정의

WebRTC = 브라우저에서 플러그인 없이 P2P 음성/비디오/데이터 통신을 가능케 하는 W3C 표준.

WebRTC가 등장하기 전:

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 연결을 만들지만, 연결을 만들기 전에 정보를 교환해야 합니다:

이 정보 교환을 시그널링이라고 합니다. 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, 엄격한 방화벽):

coturn 같은 오픈소스 TURN 서버 운영 가능.

3.4 ICE — 모든 것을 합치기

ICE (Interactive Connectivity Establishment):

  1. 모든 후보 IP/포트 수집 (로컬, STUN, TURN)
  2. 각 후보 쌍에 대해 연결 시도
  3. 가장 빠른 경로 선택

ICE 후보 우선순위:

  1. 호스트 후보 (로컬 IP) - 최고
  2. server reflexive (STUN으로 발견) - 좋음
  3. 릴레이 (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명12
5명1020
10명4590
50명1,2252,450

10명만 되어도 각 클라이언트가 9개 스트림 송신 → 대역폭 폭증, 배터리 소모, 모바일 불가능.

4.2 SFU (Selective Forwarding Unit)

해결: 중앙 서버가 각 참여자의 스트림을 받고 다른 모두에게 전달.

[A] ─stream─→ [SFU] ─stream─→ [B]
                  ├─stream─→ [C]
                  └─stream─→ [D]

각 클라이언트는 1개 송신, N-1개 수신. 송신 측 부하 절감.

SFU의 장점:

4.3 MCU (Multipoint Control Unit)

또 다른 옵션: 서버가 모든 스트림을 합성하여 단일 스트림 전송.

장점: 클라이언트가 1개만 받음 (가장 가벼움). 단점: 서버에서 디코딩+합성+인코딩 → 매우 비쌈, 지연시간 증가.

4.4 비교

P2PSFUMCU
참여자1:1다수다수
클라이언트 부하낮음보통매우 낮음
서버 부하없음보통매우 높음
비용0 (TURN 제외)대역폭대역폭+CPU+GPU
지연시간가장 낮음낮음보통
보안E2EE 가능서버가 봄서버가 봄
사용 사례1:1 통화화상회의방송

4.5 SFU 라이브러리

라이브러리언어특징
mediasoupNode.js + C++가장 인기, Discord 사용
JanusC모듈식, Pion보다 무거움
PionGo순수 Go, 가벼움, 임베디드 가능
LiveKitGo매니지드 서비스 + 오픈소스
Jitsi VideobridgeJavaJitsi Meet의 핵심
GaleneGo단순
OpenViduJavaKurento 기반

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의 핵심 개념:


5. 데이터 채널 — RTCDataChannel

5.1 사용 사례

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의 진화. 단일 스트림에 여러 레이어를 인코딩.

6.4 코덱

코덱라이선스품질호환성
VP8무료보통모든 브라우저
VP9무료좋음대부분
AV1무료최고점차 확산
H.264라이선스 (대부분 무료)좋음모든 장치 (하드웨어 가속)
H.265라이선스매우 좋음제한적

WebRTC 권장: VP8 (호환성), VP9 (품질), AV1 (미래).


7. 음성 처리

7.1 자동 처리

WebRTC가 자동 적용:

const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true
  }
})

7.2 Opus 코덱

WebRTC의 표준 음성 코덱:

7.3 음성 처리 라이브러리


8. 보안

8.1 DTLS-SRTP

WebRTC는 모든 미디어가 암호화됩니다:

선택권 없음 — 평문 미디어 불가능. 시큐어 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 자체는 신원 검증 안 함. 시그널링 레벨에서 인증:


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
    })
  }
})

핵심 지표:

9.2 디버깅 도구

9.3 일반적 문제

증상원인해결
연결 실패NAT 통과 못 함TURN 서버 확인
끊김네트워크 불안정비트레이트 낮춤, FEC
에코AEC 비활성echoCancellation: true
검은 화면카메라 권한권한 다이얼로그 확인
음성만 안 나옴자동 재생 차단사용자 제스처 후 재생

9.4 확장성

수십~수백 명: SFU 1대로 충분 수천 명: SFU 클러스터 + 로드 밸런서 수만 명: 캐스케이딩 SFU + Edge 노드


10. 실전 — Discord, Google Meet은 어떻게 만들까?

10.1 Discord (mediasoup)

10.2 Google Meet (자체 구현)

10.3 Zoom (자체 프로토콜)

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를 구현했습니다.


참고 자료

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다