React와 WebRTC를 활용하여 실시간 화상 채팅 구현하기
FE/React2024. 4. 28. 11:00시그널링 서버(signaling server)
실시간 화상채팅을 구현하기 전에 Signaling 서버에 대해 알아야 합니다.
WebRTC에서 시그널링 서버(signaling server
)는 매우 중요한 역할을 합니다. 이 서버는 WebRTC 연결을 구성하기 위해 필요한 메타데이터와 control 메시지를 주고받는 데 사용됩니다.
시그널링 서버는 peer-to-peer 간의 초기 설정, 네트워크 정보 교환, 세션 관리 등을 합니다. WebRTC 프로토콜 자체에서 제공해주는 기능은 아니기 때문에 시스템 구조 및 상황에 맞게 개발자가 시그널링 프로세스를 구현해야 합니다. (예를들어, 화상 채팅 앱을 만든다면, 인증 및 채팅 방을 관리하기 위한 프로세스가 필요합니다. 이런 프로세스를 직접 시그널링 서버에 구현해햐 함)
시그널링 서버 구현
본 글에서는 typescript와 express
, socket.io
를 활용하여 시그널링 서버를 구현하는 내용을 설명합니다.
우선 필요한 의존성을 설치합니다.
yarn add express socket.io
그리고, 시그널링 서버의 기본 틀을 작성합니다.
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
cors: {
origin: true, // 보안을 위해 실제로는 cors를 구체적으로 정의하는게 좋음
},
});
io.on('connection', (socket: any) => {
console.log(`Client connected. socket: ${socket.id}`);
socket.on('disconnect', () => {
console.log('Client disconnected');
});
});
server.listen(5000, () => { // 원하는 포트 번호로 변경 가능
console.log('Listening on port 5000');
});
화상 채팅 프로세스
세부적인 기능을 구현하기 전에 화상채팅 프로세스를 알아야 합니다. 프로세스는 6단계로 나눌 수 있습니다.
- connect: peer와 server가 소켓 연결을 하는 단계
- join: 화상채팅을 위한 채팅 방에 접속하는 단계
- offer: 화상채팅을 하기 위해 peer가 메시지를 보내는 단계
- answer: 다른 peer에게 offer에 대한 응답 메시지를 보내는 단계
- candidate: 각 peer는 자신이 데이터를 보낼 수 있는 네트워크 경로(후보, candidate)를 찾기 위해 ICE 프로세스를 수행
- disconnect: 화상채팅을 종료하고 connection을 종료하는 단계
offer와 answer 단계에서는 peer의 네트워크 및 미디어 설정을 SDP(Session Description Protocol) 포맷으로 주고 받으며, 이를 통해 연결 설정을 위한 상세한 값들이 정의됩니다.
그럼, 위에서 설명한 프로세스를 코드로 작성하면 아래와 같습니다.
// ...
io.on('connection', (socket: any) => {
console.log(`Client connected. socket: ${socket.id}`);
socket.on('join', (data: { room: string }) => {
console.log(`Join room ${data.room}. Socket ${socket.id}`);
});
socket.on('offer', (data: { sdp: string; room: string }) => {
socket.to(data.room).emit('offer', { sdp: data.sdp, sender: socket.id });
});
socket.on('answer', (data: { sdp: string; room: string }) => {
socket.to(data.room).emit('answer', { sdp: data.sdp, sender: socket.id });
});
socket.on('candidate', (data: { candidate: string; room: string }) => {
socket.to(data.room).emit('candidate', { candidate: data.candidate, sender: socket.id });
});
socket.on('disconnect', () => {
console.log('Client disconnected');
});
});
// ...
이제 채팅 방을 관리하기 위한 코드를 추가하면 아래와 같습니다.
// ...
const totalRooms = {} as { [key: string]: { users: string[] } };
io.on('connection', (socket: any) => {
console.log(`Client connected. socket: ${socket.id}`);
socket.on('join', (data: { room: string }) => {
if (!data?.room) return;
socket.join(data.room);
// 방이 없으면 새로운 방을 만듦
if (!totalRooms[data.room]) {
totalRooms[data.room] = { users: [] };
}
// 방에 사용자를 추가
totalRooms[data.room].users.push(socket.id);
socket.room = data.room;
console.log(`Join room ${data.room}. Socket ${socket.id}`);
});
// ...
socket.on('disconnect', () => {
// 연결이 끊어지면 방에서 사용자를 제거
if (socket.room && totalRooms[socket.room]) {
totalRooms[socket.room].users = totalRooms[socket.room].users.filter(
(id) => id !== socket.id,
);
// 사용자가 한명도 없으면 방을 없앰
if (totalRooms[socket.room].users.length === 0) {
delete totalRooms[socket.room];
}
}
console.log('Client disconnected');
});
});
// ...
이렇게 기본적인 시그널링 서버는 구현이 완료되었습니다.
클라이언트 구현
본 글에서는 클라이언트 영역을 React로 구현합니다. 우선 필요한 의존성을 추가합니다.
yarn add socket.io socket.io-client
그리고, VideoChat이라는 컴포넌트를 생성합니다. (스타일은 tailwindcss를 사용합니다)
import React, { useEffect, useRef, useState } from 'react';
import io, { Socket } from 'socket.io-client';
const VideoChat = () => {
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const [isStartedVideo, setIsStartedVideo] = useState<boolean>(false);
const [room, setRoom] = useState<string>('test_room');
const [socket, setSocket] = useState<Socket | null>(null);
const [peerConnection, setPeerConnection] = useState<RTCPeerConnection | null>(null);
useEffect(() => {
const nextSocket = io('http://123.123.123.123:5000'); // 자신의 시그널링 서버 IP 주소
setSocket(nextSocket);
// 구글에서 제공해주는 coturn 서버 활용
const pc = new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.l.google.com:19302',
},
{
urls: 'stun:stun1.l.google.com:19302',
},
{
urls: 'stun:stun2.l.google.com:19302',
},
{
urls: 'stun:stun3.l.google.com:19302',
},
],
});
pc.onicecandidate = (event) => {
if (!event.candidate) return;
nextSocket.emit('candidate', { candidate: event.candidate, room });
};
pc.ontrack = (event) => {
if (!remoteVideoRef.current || !event.streams[0]) return;
remoteVideoRef.current.srcObject = event.streams[0];
};
nextSocket.on('offer', async (msg) => {
// 내가 보낸 offer인 경우, skip
if (msg.sender === socket?.id) return;
// connection에 상대 peer의 SDP 정보를 설정
await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
// 설정 이후 상대 peer에게 나의 SDP 응답
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
nextSocket.emit('answer', { sdp: pc.localDescription, room });
});
nextSocket.on('answer', (msg) => {
if (msg.sender === socket?.id) return;
// connection에 상대 peer에게 받은 SDP 정보를 설정
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
});
nextSocket.on('candidate', (msg) => {
if (msg.sender === socket?.id) return;
// 데이터를 보낼 수 있는 네트워크 경로를 찾기 위해 ICE 프로세스를 수행하는 단계
pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
});
setPeerConnection(pc);
}, []);
const startVideo = async () => {
if (!localVideoRef.current) return;
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localVideoRef.current.srcObject = stream;
stream.getTracks().forEach((track) => peerConnection?.addTrack(track, stream));
setIsStartedVideo(true);
};
const joinRoom = () => {
if (!socket || !room) return;
socket.emit('join', { room });
};
const call = async () => {
const offer = await peerConnection?.createOffer();
await peerConnection?.setLocalDescription(offer);
socket?.emit('offer', { sdp: offer, room });
};
return (
<div className="flex flex-col gap-6">
<div className="flex justify-center gap-2">
<div className="flex flex-col items-center">
<div className="font-semibold">내 화면</div>
<video ref={localVideoRef} autoPlay playsInline muted></video>
</div>
<div className="flex flex-col items-center">
<div className="font-semibold">상대 화면</div>
<video ref={remoteVideoRef} autoPlay playsInline></video>
</div>
</div>
<div className="text-center font-semibold">Room Name: {room}</div>
<div className="justify-center flex items-center gap-6">
{!isStartedVideo && (
<button
className="shadow-md px-3 py-2 rounded hover:bg-slate-50 active:shadow-none"
onClick={() => {
startVideo();
joinRoom();
}}
>
비디오 연결
</button>
)}
<button
className="shadow-md px-3 py-2 rounded hover:bg-slate-50 active:shadow-none"
onClick={call}
>
통화 시작
</button>
</div>
</div>
);
};
export default VideoChat;
관련 글
'FE > React' 카테고리의 다른 글
Vite 기반 React 프로젝트에 Tailwind CSS 적용하기 (0) | 2024.08.25 |
---|---|
Bun 설치 및 React 프로젝트 생성하기 (0) | 2024.08.24 |
React와 Intersection Observer로 목차(TOC) 만들기 (0) | 2023.09.30 |
react와 tailwind를 사용하여 다크 테마 구현하기 (0) | 2023.09.25 |
react를 사용해서 크롬(chrome) extension 만들기 (1) | 2023.09.24 |
IT 기술에 대한 글을 주로 작성하고, 일상 내용, 맛집/숙박/제품 리뷰 등 여러가지 주제를작성하는 블로그입니다. 티스토리 커스텀 스킨도 개발하고 있으니 관심있으신분은 Berry Skin을 검색바랍니다.
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!