개발일기

Javascript - WebRTC로 실시간 미디어 스트리밍 구축 본문

Javascript

Javascript - WebRTC로 실시간 미디어 스트리밍 구축

Flashback 2025. 6. 8. 14:13
728x90
반응형

1. WebRTC의 기본 구성 요소

WebRTC

웹 브라우저 간에 실시간 통신을 할 수 있게 해주는 오픈 소스 기술이다. 별도의 플러그인 없이 브라우저만으로 미디어 데이터 스트리밍을 주고받을 수 있다.

Stun Server

사용자가 자신의 공인 IP주소, 포트와 NAT종류를 파악할 수 있도록 해주는 서버로 주로 공유기나 NAT 환경에 이는 사용자가 외부에서 볼 수 있는 정보를 확인하게 해주는 서버이다. NAT 뒤에 있는 기기는 보통 사설 IP를 사용하여 외부 피어가 직접 연결을 하기 힘들다. STUN은 NAT으로 인해 발생하는 연결 문제를 우회하는데 중요한 역할을 한다. 즉 Stun 서버를 통해 자신의 공인 IP와 포트 번호를 파악한 후 그 정보를 다른 피어에게 전달해주는 역할을 하는게 Stun서버이다.

Turn Server

Stun서버로 P2P연결이 불가능한 경우가 있다. 네트워크 설정에서 특정 규칙이 적용되어 네트워크 통신이 제한된 NAT이나 방화벽 제한이 있을 경우가 이에 속한다. Stun서버의 단점을 보완하기 위해 사용하는게 바로 Turn서버이다. Turn서버는 직접 연결이 불가능한 장치들 사이에서 중계자 역할을 하며 양방향으로 전달되는 트래픽을 우회하여 전달한다. 즉, Stun서버는 직접 연결을 하게 해주지만 직접 연결이 어려울 경우 Turn을 통해 데이터를 중간에서 전달해준다.

ICE

ICE는 Stun과 Turn처럼 서버를 나타내는게 아니라 연결 방법을 의미한다. Stun과 Turn 등 다양한 연결 경로(candidate)중에서 가장 좋은 연결을 선택하는 방식으로 최적의 연결을 도와주는 도구이다. 최적의 연결이라는 것은 두 브라우저가 서로 통신하기 위해 사용할 수 있는 가장 빠르고 안정적인 네트워크 경로를 의미한다.

ICE Candidate는 브라우저가 연결을 시도할 수 있는 네트워크 주소들의 후보들을 의미한다. 이 후보들을 바탕으로 연결이 가능한지 확인한 후 가장 적합한 경로를 찾아 통신을 시작하게 한다. 후보 타입에는 Local Address, Server Reflexive Address, Relayed Address가 있다.

  • Local Address: 자신의 로컬 IP를 의미한다.
  • Server Reflexive Address: STUN서버를 통해 확인한 공인 IP를 의미한다.
  • Relayed Address: TURN서버를 경유한 중계용 IP를 의미한다.

NAT

공유기, 방화벽, 라우터 같은 네트워크 장비가 사용하는 기능으로 내부 사설 IP와 외부 공인 IP를 연결해주는 역할을 한다.

  • new RTCPeerConnection(): 피어 간의 연결을 설정하기 위한 객체를 생성하는데 사용하는 함수이다. 여기서 각 피어는 주로 로컬 피터와 원격 피어 간의 RTC연결을 의미한다. 또한 RTCPeerConnection()은 영상과 음성 미디어 데이터를 주고받을 수 있다.
const peerConnection = new RTCPeerConnection({
    {urls: [
        "stun:stun1.l.google.com:19302",
        "stun:stun2.l.google.com:19305"
    ]},
    {urls:["turn server url"], username: "fruit", credential: "mango"} 
})

 

SDP: 피어 간에 미디어 연결 설정에 필요한 정보인 코덱, IP, 포트, 암호화 등을 정의하는 문자열이다. WebRTC에서 서로 연결하려면 이 SDP 정보를 주고받아야 한다. offer, answer를 통해 각 peer가 sdp를 주고받는다.

 

WebRTC API에서 제공하는 createOffer()와 createAnswer()로 SDP를 생성하고 상대방에게 전달한다. 받는 상대방은 setRemoteDescription()으로 정보를 받는다. 피어간 정보를 교환할 때 브라우저 간 직접적으로 주고받을 수 없으며 WebSocket, socket.io와 같은 Signaling Server를 사용하여 별도의 경로를 통해 정보를 주고받아야 한다. 즉, WebSocket과 socket.io는 단순히 SDP 문자열을 전달하기 위한 용도로만 사용된다. 이 방법 외에 다른 방법을 사용하여 SDP 문자열만 전달하면 두 피어간의 RTC 연결이 가능하다.

 

2. WebRTC의 연결 과정

PeerA

  1. PeerA가 P2P연결을 실행하기 위해 new RTCPeerConnection()로 객체를 생성한다.
  2. PeerA가 createOffer()로 sdp를 생성한다.
  3. setLocalDescription()으로 생성된 sdp를 자신의 peerConnection에 추가한다.
  4. sdp데이터를 담아 시그널링 서버를 통해 PeerB에 데이터를 전달한다. 이때 전달되는 sdp type은 offer이다.
  5. 시그널링 서버에서 PeerA에서 온 데이터를 PeerB로 전달한다.

PeerB

  1. PeerB는 new RTCPeerConnection()으로 객체를 생성 한 후, 전달받은 sdp데이터를 setRemoteDescription()로 자신의 peerConnection에 추가한다.
  2. PeerB가 createAnswer()로 sdp를 생성한다.
  3. setLocalDescription()으로 생성된 sdp를 자신의 peerConnection에 추가한다.
  4. sdp데이터를 담아 시그널링 서버를 통해 PeerA에 데이터를 전달한다. 이때 전달되는 sdp type은 answer이다.
  5. 시그널링 서버에서 PeerB에서 온 데이터를 PeerA로 전달한다.

PeerA

  1. PeerB로부터 전달받은 sdp데이터를 setRemoteDescription()로 자신의 peerConnection에 추가한다.

WebRTC는 위의 과정을 거쳐 연결이 이뤄진다. 복잡하긴 하지만 각각의 peer가 데이터를 주고받아 연결이 이뤄지게 된다. 연결을 하면서 시그널링 서버라는 것이 등장한다. 시그널링 서버는 각 Peer가 연결되기 전에 필요한 정보를 주고 받을 수 있게 해야 하는데 이를 가능하게 해주는 중계 서버를 의미한다. 시그널링 서버를 통해 sdp, ICE Candidate와 상대 PeerId 등을 교환한다. 주로 시그널링 서버로 소켓 서버를 사용한다.

 

3. WebRTC Connection

peerA가 peerB와 통신하는 1:1 통신이라는 것을 가정하고 코드를 확인해보자. peerA는 먼저 peerB에 offer를 보내고 peerB는 peerA에 answer를 보내는 역할이다.

 

3-1. socket connection

// Client
import { io } from "socket.io-client"

const peerListRef = useRef({}) // socket connection list
const dataChannelRef = useRef({}) // data channel list
const mediaRef = useRef({}) // media stream
const [userSocket, setUserSocket] = useState(false)
const [peerList, setPeerList] = useState({})
const [messageText, setMessageText] = useState("")

useEffect(() => {
		const myPeerId = 'client-' + crypto.randomUUID()
		console.log('My PeerId: ', myPeerId)
		let socket = io('<http://localhost:3000>', {
			query: {
				peerId: myPeerId
			},
			transports: ['websocket']
		}) // Connect socket.io
		console.log('Connect socket')
		setUserSocket(socket)

		return () => {
			  console.log('Socket disconnected!!')
        socket.disconnect()
        socket.removeAllListeners()
    }
	}, [])

useEffect(() ⇒ {…}, [] ) 부분에 소켓 연결 부분을 넣어 페이지에 접속하는 클라이언트들은 소켓 서버에 연결되게 한다.

 

3-2. connection server code

// Server
const peerList = {}
io.on('connection', (socket) => {
    console.log('Socket Connected: ', socket.id)

    const peerId = socket.handshake.query.peerId // connected client id
    console.log('My peerId: ', peerId)
    
    const socketKeyList = Array.from(io.sockets.sockets.keys())
    socketKeyList.map((key) => {
        const connectedSocket = io.sockets.sockets.get(key)
        if(connectedSocket) {
            const queryId = connectedSocket.handshake.query.peerId
            if(typeof peerList[queryId] === 'undefined') {
                peerList[queryId] = connectedSocket
            }
        }
    })
    console.log('socket list: ', socketKeyList)

    socket.emit('peer-connected', {
        peerList: Object.keys(peerList),
        peerId: peerId
    })
})
  • peerId: 클라이언트의 쿼리에 담겨 넘어온 peerId는 서버에 연결된 소켓을 구분하는 용도로 사용한다.
  • socketKeyList: 현재 소켓에 연결된 소켓들을 배열 형식으로 저장한다. socketKeyList 변수에 자신을 제외한 연결된 peer들을 저장하여 관리하기 위해 사용한다.
  • peer-connected: 연결이 되면 클라이언트에 연결된 peer목록과 자신의 peerId를 전달한다. 이 이벤트를 기점으로 RTC연결이 시작된다.

3-3. new RTCPeerConnection

// Client - new RTCPeerConnection
useEffect(() => {
	if(userSocket !== false) {
		const peerConnectionConfig = { 
			'iceServers': [
				{ urls: [ "stun:stun1.l.google.com:19302", "stun:stun2.l.google.com:19305" ] },
			]
		}; // RTC peer conneciton info

		userSocket.on('peer-connected', (data) => { // other peer connected
			const peerId = data.peerId // connected peer id
			const connectedPeerList = data.peerList // connected peer list

			console.log('Connected peerId: ', peerId)
			console.log('peerList: ', connectedPeerList)

			connectedPeerList.forEach((peer) => {
				if(typeof peerListRef.current[peer] === 'undefined') {
					if(peer !== peerId) { // Except my peer
						console.log('Start peer connection!')
						const peerConnection = new RTCPeerConnection(peerConnectionConfig)
						peerListRef.current[peer] = peerConnection
						setPeerList((prev) => ({
							...prev,
							[peer]: peerConnection
						}))
					} else {	
						console.log('current peer connection except')
					}
				}
			})

			console.log('peerListRef:', peerListRef.current)
		})
	}
}, [userSocket])

클라이언트가 소켓에 접속하면 서버에서 emit을 통해 peer-connected 이벤트가 클라이언트에 전달된다. 클라이언트는 연결된 peer목록과 자신의 peerId를 받아 RTC연결을 진행한다.

  • peer !== peerId: 자신의 peerId는 RTC연결을 진행하지 않기 위해 추가한다. 다른 peer와 연결을 진행해야 하기 때문에 예외처리를 추가한다.
  • new RTCPeerConnection(): 선언한 peerConnectionConfig데이터를 바탕으로 객체를 생성한다. peerConnectionConfig에 기술된 내용을 stun, turn중에서 연결을 시도한다.

3-4. createDataChannel

// Client - createDataChannel
const dataChannel = peerConnection.createDataChannel('dataChannel') // Create data channel
dataChannelRef.current[peer] = dataChannel
console.log('Create DataChannel: ', dataChannel)

dataChannel.onopen = () => { // Data channel open success
	console.log('dataChannel open!')
	dataChannel.send('hello')
}
dataChannel.onmessage =(event) => { // Received data channel message
	console.log('dataChannel onmessage: ', event)
}
dataChannel.onerror = (error) => {
	console.error('dataChannel error: ', error)
}
dataChannel.onclose = () => {
	console.log('dataChannel close')
}

WebRTC는 기본적으로 영상, 음성을 스트림을 전송하는데 사용하지만 문자열이나 일반 데이터도 P2P로 주고받을 수 있다. 이때 사용하는 기능이 바로 createDataChannel이다.

  • createDataChannel(): 데이터 채널을 생성하며 지정한 채널 명에 따라 데이터가 전달되는 채널이 달라진다. 데이터 채널이 A와 B가 있다면 A채널에 전달되는 데이터는 B채널에서 확인할 수 없다. 데이터 채널을 통해 전달되는 데이터를 확인하려면 서로 같은 데이터 채널에 연결되어야 하낟.
  • onopen(): 데이터 채널이 연결되어 메시지를 전달이 가능할 때 실행된다.
  • onmessage: 데이터 채널에 메시지가 전달되면 실행된다.
  • onerror: 채널에서 오류가 발생하면 실행된다.
  • onclose: 채널이 닫히면 실행된다.

3-5. onicecandidate

// Client - opicecandidate
peerConnection.onicecandidate = event => { // Get IceCandidate
	console.log('Start onicecandidate')
	if(event.candidate) {
		console.log('Candidate: ', event.candidate)
		userSocket.emit('ice-candidate', {
			toId: peer,
			fromId: peerId,
			candidate: event.candidate
		})
	}
}

onicecandidate는 WebRTC연결에 필수적인 단계이며 이를 통해 상대방에게 내 ICE Candidate를 전달한다. setLocalDescription()이 호출되면 ICE Candidate를 자동으로 수집을 시작한다. ICE Candidate가 발견되면 ICE Candidate를 시그널링 서버를 통해 상대방에게 전달한다. 상대방은 addIceCandidate()로 추가하여 연결 테스트를 진행한다. 전달된 ICE Candidate중에서 하나로 ICE 연결이 성공하면 P2P연결이 최종 성공하게 된다. 이 코드가 없으면 ip와 port를 교환하지 못해 P2P연결이 성립되지 않는다.

 

3-6. signaling offer

// Client - signaling offer
const startSiganlingOffer = async () => {
	try {
		const offer = await peerConnection.createOffer()
		await peerConnection.setLocalDescription(offer)
		console.log('Signal offer')
		userSocket.emit('signal', {
			toId: peer, // Receiver peer id
			fromId: peerId, // Sender peer id (me)
			sdp: offer // SDP offer data
		})
	} catch (error) {
		console.error('startSignalingOffer error: ', error)
	} finally {}
}
startSiganlingOffer()

sdp데이터를 주고받는 부분의 시작점이다.

  • createOffer(): WebRTC 연결을 시작하기 위해 필요한 sdp를 생성하는 함수
  • setLocalDescription(): 생성된 내 sdp데이터를 peerConnection에 등록하는 함수

이후 시그널링 서버를 통해 상대방에게 sdp데이터를 전달한다.

 

3-7. signaling server code

// Server - signaling
socket.on('signal', (data) => {
    const toId = data.toId // Receiver
    const fromId = data.fromId // Sender
    const sdp = data.sdp // SDP data: offer, answer

    console.log('signal type: ', sdp.type)

    peerList[toId].emit('signal', data)
})

socket.on('ice-candidate', (data) => {
    const toId = data.toId
    const fromId = data.fromId
    const candidate = data.candidate

    peerList[toId].emit('ice-candidate', data)
    console.log('ice candidate on!')
})
  • signal: signal이벤트는 클라이언트에서 넘어온 sdp 데이터를 상대방에게 전달한다.
  • ice-candidate: ice-candidate이벤트는 클라이언트에서 넘어온 ICE Candidate를 상대방에게 전달한다.

3-8. client ice-candidate event

// Client
userSocket.on('ice-candidate', (data) => {
	const fromId = data.fromId
	const candidateData = data.candidate
	console.log('candidate data: ', data)

	if(typeof peerListRef.current[fromId] != 'undefined') {
		peerListRef.current[fromId].addIceCandidate(candidateData)
		console.log('success icenCandidate')
	}
})

socket.emit을 통해 peerA에서 peerB에게 전달된 ice-candidate이벤트다. 전달된 ICE Candidate를 바탕으로 연결을 시도한다. 연결이 성공하면 WebRTC 연결을 할 port와 ip를 찾은 단계가 되고 sdp 시그널링도 마무리해야 WebRTC 연결이 마무리된다.

 

3-9. offer and answer

userSocket.on('signal', (data) => {
	console.log('Signal Event On', data)

	let toId = data.toId // Receiver (me)
	let fromId = data.fromId // Sender
	let sdp = data.sdp // SDP offer data

	if(sdp.type === 'offer') {
		console.log('SDP type: offer')

		const peerConnection = new RTCPeerConnection(peerConnectionConfig)
		peerListRef.current[fromId] = peerConnection
		setPeerList((prev) => ({
			...prev,
			[fromId]: peerConnection
		}))

		peerConnection.ondatachannel = (event) => { // Get data channel
			const channel = event.channel
			dataChannelRef.current[fromId] = channel
			console.log('ondatachannel', fromId)
			console.log('channel: ', channel)

			channel.onopen = () => { // Data channel open success
				console.log('data channel open!')
			}
			channel.onmessage = (event) => { // Receive message data
				console.log('Receive data channel onmessage: ', event)
			}
		}
		
		navigator.mediaDevices.getUserMedia({
			video: true,
			audio: true
		})
		.then((stream) => {
			console.log('answer getUserMedia: ', stream)
			stream.getTracks().forEach((track) => {
				peerConnection.addTrack(track, stream)
			})
		})
		.catch((error) => {
			console.error('getUserMedia error: ', error)
		})
		.finally(() => {})

		peerConnection.setRemoteDescription(new RTCSessionDescription(sdp))
		.then(() => {
			peerConnection.createAnswer()
			.then((answer) => {
				peerConnection.setLocalDescription(answer)
				.then(() => {
					console.log('Signal answer')
					
					userSocket.emit('signal', {
						toId: data.fromId, // Sender peer id (me)
						fromId: data.toId, // Receiver peer id
						sdp: answer
					}) // Reverse toId, fromId
				})
				.catch((error) => {
					console.error('setLocaleDescription error: ', error)
				})
				.finally(() => {}) // End setLocaleDescription
			})
			.catch((error) => {
				console.error('createAnswer error: ', error)
			})
			.finally(() => {}) // End createAnswer
		})
		.catch((error) => {
			console.error('setRemoteDescription error: ', error)
		})
		.finally(() => {}) // End setRemoteDescription

		peerConnection.oniceconnectionstatechange = () => {
			console.log(peerConnection.iceConnectionState, 'connected state')
		}

		peerConnection.ontrack = (event) => {
			console.log('Offer ontrack: ', event)
			const [remoteStream] = event.streams
			const videoElement = mediaRef.current[fromId]

			if(videoElement) {
				videoElement.srcObject = remoteStream
			} else {
				console.warn(`video element not found for peer ${fromId}`)
			}
		}

	} else if(sdp.type === 'answer') {
		console.log('SDP type: answer')

		peerListRef.current[fromId].setRemoteDescription(new RTCSessionDescription(sdp))
		.then(() => {
			console.log('RTC Connection Success')

			peerListRef.current[fromId].ontrack = (event) => {
				console.log('Answer ontrack: ', event)
				const [remoteStream] = event.streams
				const videoElement = mediaRef.current[fromId]

				if(videoElement) {
					videoElement.srcObject = remoteStream
				} else {
					console.warn(`video element not found for peer ${fromId}`)
				}
			}
		})
		.catch((error) => {
			console.error('setRemoteDescription error: ', error)
		})
		.finally(() => {})
		
	}
})

offer는 peerB에서 실행되고 answer는 peerA에서 실행된다.

  • setRemoteDescription: peerA가 보낸 sdp offer는 peerB에 등록한다.
  • createAnswer: 등록된 offer에 대한 answer를 생성한다. 이 answer는 시그널링 서버를 통해 peerA에 전달된다.
  • setLocalDescription: peerB가 생성한 answer를 peerB의 peerConnection에 등록한다. 이를 통해 ICE Candidate를 수집하여 네트워크 연결을 진행할 수 있다.

또한 peerA가 데이터 채널을 생성했기 대문에 해당 채널에 접근하려면 ondatachannel이 필요하다.

  • ondatachannel: 상대방이 만든 데이터 채널을 수신할 때 호출되는 이벤트 핸들러다. 이를 통해 상대가 생성한 데이터 채널을 받을 수 있으며 이를 통해 메시지를 수신하거나 전달할 수 있다.

offer를 받은 후 signal이벤트를 호출하여 시그널링 서버를 거쳐 다시 peerA에게 sdp answer를 보내게 된다. answer부분을 실행하는 peerA가 setRemoteDescription로 peerB가 보낸 sdp answer를 peerA에 정상적으로 등록하게 되면 두 peer는 WebRTC연결에 성공하게 된다.

미디어 스트림도 주고받으려면 추가적으로 navigator.mediaDevices.getUserMedia, addtrack과 ontrack을 통해 미디어 데이터도 주고받아야 한다.

 

3-10. media stream

// Client - addTrack offer
navigator.mediaDevices.getUserMedia({
	video: true,
	audio: true
})
.then((stream) => {
	console.log('answer getUserMedia: ', stream)
	stream.getTracks().forEach((track) => {
		peerConnection.addTrack(track, stream)
	})
})
.catch((error) => {
	console.error('getUserMedia error: ', error)
})
.finally(() => {})
  • addTrack: navigator.mediaDevices.getUserMedia를 통해 얻은 오디오, 비디오 트랙을 peerConnection에 추가하여 상대방에게 전송할 수 있게 하는 함수다.
// Client - ontrack answer
peerListRef.current[fromId].ontrack = (event) => {
	console.log('Answer ontrack: ', event)
	const [remoteStream] = event.streams
	const videoElement = mediaRef.current[fromId]

	if(videoElement) {
		videoElement.srcObject = remoteStream
	} else {
		console.warn(`video element not found for peer ${fromId}`)
	}
}
  • ontrack: 상대방이 addTrack()으로 보낸 트랙을 수신하면 ontrack 이벤트가 발생한다. useRef로 선언한 mediaRef에 스트림 데이터를 담아 video태그에서 상대방의 실시간 영상을 확인할 수 있다.

 

4. 전체 코드

// Server
const http = require('http');
const { Server } = require('socket.io');
const port = 3000
const server = http.createServer();
const io = new Server(server, {
    cors: {
        origin: ['<http://localhost:4000>', '<http://localhost:3001>'],
        methods: ['GET', 'POST']
    }
});
const peerList = {}

io.on('connection', (socket) => {
    console.log('Socket Connected: ', socket.id)

    const peerId = socket.handshake.query.peerId // connected client id
    console.log('My peerId: ', peerId)
    
    const socketKeyList = Array.from(io.sockets.sockets.keys())
    socketKeyList.map((key) => {
        const connectedSocket = io.sockets.sockets.get(key)
        if(connectedSocket) {
            const queryId = connectedSocket.handshake.query.peerId
            if(typeof peerList[queryId] === 'undefined') {
                peerList[queryId] = connectedSocket
            }
        }
    })
    console.log('socket list: ', socketKeyList)

    socket.emit('peer-connected', {
        peerList: Object.keys(peerList),
        peerId: peerId
    })
    
    socket.on('signal', (data) => {
        const toId = data.toId // Receiver
        const fromId = data.fromId // Sender
        const sdp = data.sdp // SDP data: offer, answer

        console.log('signal type: ', sdp.type)

        peerList[toId].emit('signal', data)
    })

    socket.on('ice-candidate', (data) => {
        const toId = data.toId
        const fromId = data.fromId
        const candidate = data.candidate

        peerList[toId].emit('ice-candidate', data)
        console.log('ice candidate on!')
    })

    socket.on('disconnect', (reason) => {
        console.log('disconnect', reason)
        delete peerList[peerId]
        socket.broadcast.emit('peer-disconnected', peerId)
    })
})

server.listen(port, () => {
    console.log('Server listening on port ' + port);
});
// Client
import { useEffect, useRef, useState } from 'react';
import { io } from "socket.io-client"

function App() {

	const peerListRef = useRef({}) // socket connection list
	const dataChannelRef = useRef({}) // data channel list
	const mediaRef = useRef({}) // media stream
	const [userSocket, setUserSocket] = useState(false)
	const [peerList, setPeerList] = useState({})
	const [messageText, setMessageText] = useState("")

	useEffect(() => {
		const myPeerId = 'peer-' + crypto.randomUUID()
		console.log('My PeerId: ', myPeerId)
		let socket = io('<http://localhost:3000>', {
			query: {
				peerId: myPeerId
			},
			transports: ['websocket']
		}) // Connect socket.io
		console.log('Connect socket')
		setUserSocket(socket)

		return () => {
			console.log('Socket disconnected!!')
			peerListRef.current = {}
			dataChannelRef.current = {}
			mediaRef.current = {}
			setPeerList({})
            socket.disconnect()
            socket.removeAllListeners()
        }
	}, [])

	useEffect(() => {
		if(userSocket !== false) {
			const peerConnectionConfig = { 
				'iceServers': [
					{ urls: [ "stun:stun1.l.google.com:19302", "stun:stun2.l.google.com:19305" ] },
				]
			}; // RTC peer conneciton info

			userSocket.on('peer-connected', (data) => { // other peer connected
				const peerId = data.peerId // connected peer id
				const connectedPeerList = data.peerList // connected peer list

				console.log('Connected peerId: ', peerId)
				console.log('peerList: ', connectedPeerList)

				connectedPeerList.forEach((peer) => {
					if(typeof peerListRef.current[peer] === 'undefined') {
						if(peer !== peerId) { // Except my peer
							console.log('Start peer connection!')
							const peerConnection = new RTCPeerConnection(peerConnectionConfig)
							peerListRef.current[peer] = peerConnection
							setPeerList((prev) => ({
								...prev,
								[peer]: peerConnection
							}))

							const dataChannel = peerConnection.createDataChannel('dataChannel') // Create data channel
							dataChannelRef.current[peer] = dataChannel
							console.log('Create DataChannel: ', dataChannel)

							dataChannel.onopen = () => { // Data channel open success
								console.log('dataChannel open!')
								dataChannel.send('hello')
							}
							dataChannel.onmessage =(event) => { // Received data channel message
								console.log('dataChannel onmessage: ', event)
							}
							dataChannel.onerror = (error) => {
								console.error('dataChannel error: ', error)
							}
							dataChannel.onclose = () => {
								console.log('dataChannel close')
							}

							peerConnection.onicecandidate = event => { // Get IceCandidate
								console.log('Start onicecandidate')
								if(event.candidate) {
									console.log('Candidate: ', event.candidate)
									userSocket.emit('ice-candidate', {
										toId: peer,
										fromId: peerId,
										candidate: event.candidate
									})
								}
							}

							const startSiganlingOffer = async () => {
								try {
									const offer = await peerConnection.createOffer()
									await peerConnection.setLocalDescription(offer)
									console.log('Signal offer')
									userSocket.emit('signal', {
										toId: peer, // Receiver peer id
										fromId: peerId, // Sender peer id (me)
										sdp: offer // SDP offer data
									})
								} catch (error) {
									console.error('startSignalingOffer error: ', error)
								} finally {}
							}

							navigator.mediaDevices.getUserMedia({
								video: true,
								audio: true
							})
							.then((stream) => {
								console.log('offer getUserMedia: ', stream)
								// mediaRef.current.srcObject = stream
								stream.getTracks().forEach((track) => {
									peerConnection.addTrack(track, stream)
								})

								startSiganlingOffer()
							})
							.catch((error) => {
								console.error('error: ', error)
								startSiganlingOffer()
							})
							.finally(() => {})

							peerConnection.oniceconnectionstatechange = () => {
								console.log(peerConnection.iceConnectionState, 'connected state')
							}

							console.log('user media device: ', mediaRef)

						} else {	
							console.log('current peer connection except')
						}
					}
				})

				console.log('peerListRef:', peerListRef.current)
			})

			/* Get signal data */
			userSocket.on('signal', (data) => {
				console.log('Signal Event On', data)

				let toId = data.toId // Receiver (me)
				let fromId = data.fromId // Sender
				let sdp = data.sdp // SDP offer data

				if(sdp.type === 'offer') {
					console.log('SDP type: offer')

					const peerConnection = new RTCPeerConnection(peerConnectionConfig)
					peerListRef.current[fromId] = peerConnection
					setPeerList((prev) => ({
						...prev,
						[fromId]: peerConnection
					}))

					peerConnection.ondatachannel = (event) => { // Get data channel
						const channel = event.channel
						dataChannelRef.current[fromId] = channel
						console.log('ondatachannel', fromId)
						console.log('channel: ', channel)

						channel.onopen = () => { // Data channel open success
							console.log('data channel open!')
						}
						channel.onmessage = (event) => { // Receive message data
							console.log('Receive data channel onmessage: ', event)
						}
					}
					
					navigator.mediaDevices.getUserMedia({
						video: true,
						audio: true
					})
					.then((stream) => {
						console.log('answer getUserMedia: ', stream)
						stream.getTracks().forEach((track) => {
							peerConnection.addTrack(track, stream)
						})
					})
					.catch((error) => {
						console.error('getUserMedia error: ', error)
					})
					.finally(() => {})

					peerConnection.setRemoteDescription(new RTCSessionDescription(sdp))
					.then(() => {
						peerConnection.createAnswer()
						.then((answer) => {
							peerConnection.setLocalDescription(answer)
							.then(() => {
								console.log('Signal answer')
								
								userSocket.emit('signal', {
									toId: data.fromId, // Sender peer id (me)
									fromId: data.toId, // Receiver peer id
									sdp: answer
								}) // Reverse toId, fromId
							})
							.catch((error) => {
								console.error('setLocaleDescription error: ', error)
							})
							.finally(() => {}) // End setLocaleDescription
						})
						.catch((error) => {
							console.error('createAnswer error: ', error)
						})
						.finally(() => {}) // End createAnswer
					})
					.catch((error) => {
						console.error('setRemoteDescription error: ', error)
					})
					.finally(() => {}) // End setRemoteDescription

					peerConnection.oniceconnectionstatechange = () => {
						console.log(peerConnection.iceConnectionState, 'connected state')
					}

					peerConnection.ontrack = (event) => {
						console.log('Offer ontrack: ', event)
						const [remoteStream] = event.streams
						const videoElement = mediaRef.current[fromId]

						if(videoElement) {
							videoElement.srcObject = remoteStream
						} else {
							console.warn(`video element not found for peer ${fromId}`)
						}
					}

				} else if(sdp.type === 'answer') {
					console.log('SDP type: answer')

					peerListRef.current[fromId].setRemoteDescription(new RTCSessionDescription(sdp))
					.then(() => {
						console.log('RTC Connection Success')

						peerListRef.current[fromId].ontrack = (event) => {
							console.log('Answer ontrack: ', event)
							const [remoteStream] = event.streams
							const videoElement = mediaRef.current[fromId]

							if(videoElement) {
								videoElement.srcObject = remoteStream
							} else {
								console.warn(`video element not found for peer ${fromId}`)
							}
						}
					})
					.catch((error) => {
						console.error('setRemoteDescription error: ', error)
					})
					.finally(() => {})
					
				}
			})

			userSocket.on('ice-candidate', (data) => {
				const fromId = data.fromId
				const candidateData = data.candidate
				console.log('candidate data: ', data)

				if(typeof peerListRef.current[fromId] != 'undefined') {
					peerListRef.current[fromId].addIceCandidate(candidateData)
					console.log('success icenCandidate')
				}
			})

			userSocket.on('peer-disconnected', (disconnectedClientId) => { // CleanUp disconnected client info
				console.log('peer-disconnected', disconnectedClientId)
				if(typeof peerListRef.current[disconnectedClientId] != 'undefined') {
					peerListRef.current[disconnectedClientId].close()
				}
				delete peerListRef.current[disconnectedClientId]
				delete dataChannelRef.current[disconnectedClientId]
				setPeerList((prev) => {
					const updated = { ...prev }
					delete updated[disconnectedClientId]
					return updated
				})
			})

		}
	}, [userSocket])

	const changeMessageText = (e) => {
		setMessageText(e.target.value)
	}

	const sendMessage = (peer) => {
		console.log('send success')
		console.log('dataChannelRef: ', dataChannelRef)
		if(typeof dataChannelRef.current[peer] != 'undefined') {
			dataChannelRef.current[peer].send(messageText)
		}
	}

	return (
		<>
			{ Object.keys(peerList).map((peer) => {
				return (
					<div key={ peer }>
						<video
							ref={el => {
								if (el) mediaRef.current[peer] = el
							}}
							autoPlay
							playsInline
							style={{ width: '300px', border: '1px solid black' }}
						/>
						<input type='text' onChange={(e) => changeMessageText(e)} value={ messageText } />
						<button onClick={() => sendMessage(peer)}>send to { peer }</button>
					</div>
				)
				
			}) }
		</>
	);
}

export default App;

Client의 렌더링 부분을 보면 video, input태그가 있다. 상대방이 연결되면 해당 태그를 통해 상대방의 실시간 영상을 확인할 수 있고 상대방에게 메시지를 보낼 수 있다. 상대방이 비디오나 오디오에 연결되지 않아도 WebRTC연결을 성공하게 구조를 만들었고 데이터 채널을 통해 메시지 수신, 전달은 가능하다.


참고 사이트:

https://getstream.io/resources/projects/webrtc/basics/rtcpeerconnection/

 

RTCPeerConnection WebRTC Tutorial

Did you know? All Video & Audio API plans include a $100 free usage credit each month so you can build and test risk-free. View Plans ->

getstream.io

 

https://getstream.io/resources/projects/webrtc/advanced/stun-turn/

 

WebRTC Stun vs Turn Servers

Did you know? All Video & Audio API plans include a $100 free usage credit each month so you can build and test risk-free. View Plans ->

getstream.io

 

https://github.com/FlashBack102/webrtc-boilerplate

 

GitHub - FlashBack102/webrtc-boilerplate

Contribute to FlashBack102/webrtc-boilerplate development by creating an account on GitHub.

github.com

 

728x90
반응형
Comments