WebRTC는 브라우저 간 P2P 미디어 전송을 표준으로 제공합니다. React에서 WebRTC를 사용해 실시간 영상 통화를 구현하려면 크게 세 가지가 필요합니다. 사용자 미디어 접근, RTCPeerConnection 설정, 그리고 시그널링 서버입니다. 시그널링은 SDP/ICE 후보를 교환하는 과정으로, 실제 미디어는 P2P로 흐릅니다.
1. 전체 흐름과 준비물
구성은 다음과 같습니다. 1) 사용자 권한으로 카메라/마이크 스트림 획득, 2) RTCPeerConnection 생성 및 트랙 추가, 3) 시그널링 서버(예: Socket.IO)를 통해 오퍼/응답/ICE 후보 교환, 4) 연결 완료 후 양쪽 비디오에 스트림 표시, 5) 종료와 정리입니다. 로컬에서 개발 시 localhost는 http로 동작해도 getUserMedia가 허용되지만, 배포 시 https가 필수입니다.
2. 시그널링 서버 (Socket.IO) 예제
두 명만 참여 가능한 간단한 방 구조입니다. 첫 번째 참가자는 created, 두 번째는 joined 이벤트를 받고 두 명이 모이면 ready를 브로드캐스트합니다.
import express from 'express'
import http from 'http'
import { Server } from 'socket.io'
const app = express()
const httpServer = http.createServer(app)
const io = new Server(httpServer, { cors: { origin: '*' } })
io.on('connection', (socket) => {
socket.on('join', (room) => {
const roomSet = io.sockets.adapter.rooms.get(room)
const num = roomSet ? roomSet.size : 0
if (num === 0) {
socket.join(room)
socket.emit('created')
} else if (num === 1) {
socket.join(room)
socket.emit('joined')
socket.to(room).emit('ready')
socket.emit('ready')
} else {
socket.emit('full')
}
})
socket.on('signal', ({ room, data }) => {
socket.to(room).emit('signal', data)
})
})
httpServer.listen(3001, () => console.log('signaling server on 3001'))실전에서는 인증, 방 상태 관리, 로깅 등이 필요합니다. 또한 NAT 환경을 고려해 STUN/TURN 서버 구성이 필수입니다.
3. React 컴포넌트: 기본 통화 구현
아래 예시는 방 참가, 자동 Offer/Answer, ICE 후보 교환, 종료와 정리를 포함한 최소 MVP입니다. STUN 서버는 구글 공개 STUN을 사용합니다. 상용 환경에서는 반드시 TURN을 추가하세요.
import React, { useEffect, useRef, useState } from 'react'
import io from 'socket.io-client'
const pcConfig = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }
export default function WebRTCPage() {
const [roomId, setRoomId] = useState('demo')
const [joined, setJoined] = useState(false)
const [isInitiator, setIsInitiator] = useState(false)
const socketRef = useRef(null)
const pcRef = useRef(null)
const localStreamRef = useRef(null)
const localVideoRef = useRef(null)
const remoteVideoRef = useRef(null)
useEffect(() => {
return () => {
cleanup()
}
}, [])
const setup = async () => {
socketRef.current = io('http://localhost:3001', { transports: ['websocket'] })
socketRef.current.on('created', async () => {
setIsInitiator(true)
await ensureMedia()
createPeerConnection()
})
socketRef.current.on('joined', async () => {
setIsInitiator(false)
await ensureMedia()
createPeerConnection()
})
socketRef.current.on('ready', async () => {
if (isInitiator && pcRef.current) {
const offer = await pcRef.current.createOffer()
await pcRef.current.setLocalDescription(offer)
socketRef.current.emit('signal', { room: roomId, data: offer })
}
})
socketRef.current.on('signal', async (data) => {
if (!pcRef.current) return
if (data.type === 'offer') {
await pcRef.current.setRemoteDescription(new RTCSessionDescription(data))
const answer = await pcRef.current.createAnswer()
await pcRef.current.setLocalDescription(answer)
socketRef.current.emit('signal', { room: roomId, data: answer })
} else if (data.type === 'answer') {
await pcRef.current.setRemoteDescription(new RTCSessionDescription(data))
} else if (data.candidate) {
try {
await pcRef.current.addIceCandidate(new RTCIceCandidate(data))
} catch (e) {
console.error('Failed to add ICE', e)
}
}
})
socketRef.current.on('full', () => {
alert('방이 가득 찼습니다.')
})
socketRef.current.emit('join', roomId)
setJoined(true)
}
const ensureMedia = async () => {
if (localStreamRef.current) return
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' },
audio: true
})
localStreamRef.current = stream
if (localVideoRef.current) {
localVideoRef.current.srcObject = stream
}
}
const createPeerConnection = () => {
pcRef.current = new RTCPeerConnection(pcConfig)
pcRef.current.onicecandidate = (e) => {
if (e.candidate) {
socketRef.current.emit('signal', { room: roomId, data: e.candidate })
}
}
pcRef.current.ontrack = (e) => {
if (remoteVideoRef.current && e.streams && e.streams[0]) {
remoteVideoRef.current.srcObject = e.streams[0]
}
}
localStreamRef.current.getTracks().forEach((t) => {
pcRef.current.addTrack(t, localStreamRef.current)
})
}
const leave = () => {
socketRef.current && socketRef.current.disconnect()
cleanup()
setJoined(false)
setIsInitiator(false)
}
const cleanup = () => {
if (pcRef.current) {
pcRef.current.getSenders().forEach((s) => s.track && s.track.stop())
pcRef.current.close()
pcRef.current = null
}
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach((t) => t.stop())
localStreamRef.current = null
}
}
const toggleMic = () => {
if (!localStreamRef.current) return
localStreamRef.current.getAudioTracks().forEach((t) => (t.enabled = !t.enabled))
}
const toggleCam = () => {
if (!localStreamRef.current) return
localStreamRef.current.getVideoTracks().forEach((t) => (t.enabled = !t.enabled))
}
const shareScreen = async () => {
if (!pcRef.current) return
const display = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
const screenTrack = display.getVideoTracks()[0]
const sender = pcRef.current.getSenders().find((s) => s.track && s.track.kind === 'video')
if (sender) {
await sender.replaceTrack(screenTrack)
}
screenTrack.onended = async () => {
const camTrack = localStreamRef.current.getVideoTracks()[0]
if (sender && camTrack) {
await sender.replaceTrack(camTrack)
}
}
}
return (
<div style={{ display: 'flex', gap: 12, flexDirection: 'column' }}>
<div>
<input value={roomId} onChange={(e) => setRoomId(e.target.value)} disabled={joined} />
{!joined ? (
<button onClick={setup}>방 참가</button>
) : (
<button onClick={leave}>나가기</button>
)}
<button onClick={toggleMic} disabled={!joined}>마이크 토글</button>
<button onClick={toggleCam} disabled={!joined}>카메라 토글</button>
<button onClick={shareScreen} disabled={!joined}>화면 공유</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<video ref={localVideoRef} autoPlay playsInline muted style={{ width: 320, background: '#000' }} />
<video ref={remoteVideoRef} autoPlay playsInline style={{ width: 320, background: '#000' }} />
</div>
</div>
)
}
테스트 방법은 간단합니다. 시그널링 서버를 띄우고 앱을 로컬에서 실행한 뒤, 서로 다른 브라우저/탭 두 개에서 같은 방 ID로 참가하세요. 두 사용자가 모이면 Initiator가 자동으로 Offer를 만들어 연결을 시도합니다.
4. 마이크/카메라 토글, 화면 공유
위 코드에 포함된 토글과 화면 공유는 실무에서 자주 쓰는 컨트롤입니다. 마이크/카메라는 트랙의 enabled 플래그만 토글하면 됩니다. 화면 공유는 getDisplayMedia로 가져온 비디오 트랙을 기존 비디오 RTCRtpSender.replaceTrack으로 교체하고, 공유 중단 이벤트에서 카메라 트랙으로 복귀합니다.
5. 실전 팁과 배포 체크리스트
1) HTTPS: 배포 시 반드시 TLS를 적용해야 getUserMedia 및 일부 자동 재생 정책이 정상 동작합니다. 2) 오토플레이: 사용자 제스처 후 재생하거나 로컬 비디오는 muted 속성을 사용합니다. 3) STUN/TURN: STUN만으로는 사내망/대기업망에서 실패할 수 있어 TURN 구성이 필수입니다(coturn 권장). 4) 대역폭: 720p 기준 업/다운 대역폭을 고려하고, RTCRtpSender.setParameters로 비트레이트 상한을 제한할 수 있습니다. 5) 정리: 컴포넌트 언마운트 시 트랙 stop과 RTCPeerConnection.close, 소켓 disconnect를 반드시 수행합니다. 6) 모바일: facingMode와 해상도 제약을 단말에 맞게 조정하고, iOS는 화면공유 제약이 있으니 정책을 분기하세요.
6. 자주 막히는 포인트와 디버깅
SDP/ICE가 한쪽만 오가면 시그널링 이벤트 이름/방 ID를 점검하세요. 두 브라우저에서 카메라 권한이 허용되었는지, 콘솔 오류에 NotAllowedError나 OverconstrainedError가 없는지 확인합니다. 연결은 iceConnectionStatechange 로그로 상태를 확인하고 fail/disconnected 반복 시 TURN 설정과 방화벽을 의심합니다.
7. 다음 단계
1:N 또는 N:N 통화는 SFU(예: mediasoup, Janus, LiveKit) 도입을 고려하세요. 녹화는 MediaRecorder를 사용하거나 서버 측 녹화를 지원하는 SFU에 위임합니다. 통계 수집은 getStats로 패킷 손실, RTT, 비트레이트를 모니터링해 자동 품질 조정을 구현합니다.
'React' 카테고리의 다른 글
| React에서 서버 사이드 렌더링(SSR)과 클라이언트 사이드 렌더링(CSR) 차이 분석 (0) | 2026.05.07 |
|---|---|
| React 앱에 OAuth 2.0 로그인 기능 추가하기 (Authorization Code + PKCE) (0) | 2026.05.06 |
| React에서 Intersection Observer API로 요소 감시하기 (0) | 2026.05.04 |
| React로 디자인 시스템 구축하기 (0) | 2026.05.04 |
| React 앱에서 사용자 행동 분석 추적하기 (2) | 2026.04.30 |