-
Notifications
You must be signed in to change notification settings - Fork 7
๐ชต 2. WebRTC ์ค์ต (1) : 1:1 Mesh ๋ฐฉ์
ssum1ra edited this page Dec 5, 2024
·
1 revision
- client : vite + react + ts
- server : nestjs
// server/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
await app.listen(3000);
}
bootstrap();
// server/src/app.module.ts
import { Module } from '@nestjs/common';
import { WebRTCGateway } from './webrtc.gateway';
@Module({
providers: [WebRTCGateway],
})
export class AppModule {}
- ์ ์ฒด ์ฝ๋
// server/src/webrtc.gateway.ts
import {
WebSocketGateway,
SubscribeMessage,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({
cors: {
origin: '*',
},
})
export class WebRTCGateway {
@WebSocketServer() server: Server;
@SubscribeMessage('offer')
handleOffer(client: Socket, offer: RTCSessionDescriptionInit): void {
client.broadcast.emit('offer', offer);
}
@SubscribeMessage('answer')
handleAnswer(client: Socket, answer: RTCSessionDescriptionInit): void {
client.broadcast.emit('answer', answer);
}
@SubscribeMessage('ice-candidate')
handleIceCandidate(client: Socket, candidate: RTCIceCandidateInit): void {
client.broadcast.emit('ice-candidate', candidate);
}
}
- ์ฝ๋ ์ค๋ช
@WebSocketGateway({
cors: {
origin: '*',
},
})
-
@WebSocketGateway()
๋ฐ์ฝ๋ ์ดํฐ๋ WebSocket ๊ฒ์ดํธ์จ์ด๋ฅผ ์ ์ํ๋ ๋ฐ ์ฌ์ฉ๋๋ฉฐ, ์ฌ๊ธฐ์ WebSocket ์๋ฒ์ ์ค์ ์ ์ถ๊ฐํ ์ ์๋ค. -
cors: { origin: '*' }
: CORS ์ค์ ์ผ๋ก, ๋ชจ๋ ๋๋ฉ์ธ์์ WebSocket ์๋ฒ์ ์ ๊ทผํ ์ ์๋๋ก ํ์ฉํ๋ค. ์ค์ ์๋น์ค์์๋ ๋ณด์์ฑ์ ์ํด ํน์ ๋๋ฉ์ธ๋ง ํ์ฉํ๋ ๊ฒ์ด ์ข๋ค.
export class WebRTCGateway {
@WebSocketServer() server: Server;
-
server: Server
: WebSocket ์๋ฒ ์ธ์คํด์ค๋ก,@WebSocketServer()
๋ฐ์ฝ๋ ์ดํฐ๋ฅผ ์ฌ์ฉํ์ฌServer
ํ์ ์ WebSocket ์๋ฒ๋ฅผ ์์ฑํ๋ค. ์ด๋ฅผ ํตํด ์๋ฒ์ ๋ชจ๋ ํด๋ผ์ด์ธํธ์ ์ ๊ทผํ ์ ์์ผ๋ฉฐ, ํน์ ํด๋ผ์ด์ธํธ์๊ฒ ๋ฉ์์ง๋ฅผ ์ ์กํ ์ ์๋ค.
@SubscribeMessage('offer')
handleOffer(client: Socket, offer: RTCSessionDescriptionInit): void {
client.broadcast.emit('offer', offer);
}
-
@SubscribeMessage('offer')
:offer
๋ผ๋ ์ด๋ฒคํธ๋ฅผ ์์ ํ์ ๋ ์ด ๋ฉ์๋๋ฅผ ์คํํ๋ค. -
client.broadcast.emit('offer', offer)
: ์ด ํด๋ผ์ด์ธํธ๋ฅผ ์ ์ธํ ๋ชจ๋ ํด๋ผ์ด์ธํธ์๊ฒoffer
๋ฉ์์ง๋ฅผ ๋ธ๋ก๋์บ์คํธํ๋ค. - ์ฆ, WebRTC ์ฐ๊ฒฐ์ ์์ํ ๋ ์ฌ์ฉ๋๋
offer
(์ ์) ๋ฉ์์ง๋ฅผ ๋ค๋ฅธ ํด๋ผ์ด์ธํธ์๊ฒ ์ ๋ฌํ์ฌ ์ฐ๊ฒฐ์ ์์ํ๋๋ก ํ๋ค.
@SubscribeMessage('answer')
handleAnswer(client: Socket, answer: RTCSessionDescriptionInit): void {
client.broadcast.emit('answer', answer);
}
-
@SubscribeMessage('answer')
:answer
๋ผ๋ ์ด๋ฒคํธ๋ฅผ ์์ ํ์ ๋ ์ด ๋ฉ์๋๋ฅผ ์คํํ๋ค. -
client.broadcast.emit('answer', answer)
: ์ด ํด๋ผ์ด์ธํธ๋ฅผ ์ ์ธํ ๋ชจ๋ ํด๋ผ์ด์ธํธ์๊ฒanswer
๋ฉ์์ง๋ฅผ ๋ธ๋ก๋์บ์คํธํ๋ค. - ์ฆ,
offer
์ ๋ํ ์๋ต์ผ๋ก ์ ๊ณต๋๋answer
๋ฉ์์ง๋ฅผ ๋ธ๋ก๋์บ์คํธํ์ฌ WebRTC ์ฐ๊ฒฐ์ ์๋ฃํ๋๋ก ํ๋ค.
@SubscribeMessage('ice-candidate')
handleIceCandidate(client: Socket, candidate: RTCIceCandidateInit): void {
client.broadcast.emit('ice-candidate', candidate);
}
-
@SubscribeMessage('ice-candidate')
:ice-candidate
์ด๋ฒคํธ๋ฅผ ์์ ํ์ ๋ ์ด ๋ฉ์๋๋ฅผ ์คํํ๋ค. -
client.broadcast.emit('ice-candidate', candidate)
: ์ด ํด๋ผ์ด์ธํธ๋ฅผ ์ ์ธํ ๋ชจ๋ ํด๋ผ์ด์ธํธ์๊ฒ ICE ํ๋ณด ์ ๋ณด๋ฅผ ๋ธ๋ก๋์บ์คํธํ๋ค. - ์ฆ, WebRTC ํผ์ด ๊ฐ ์ฐ๊ฒฐ์ ์ต์ ํํ๊ธฐ ์ํด ์ฌ์ฉํ๋ ICE ํ๋ณด ์ ๋ณด๋ฅผ ๋ค๋ฅธ ํด๋ผ์ด์ธํธ๋ค์๊ฒ ์ ๋ฌํ์ฌ ๋คํธ์ํฌ ์ฐ๊ฒฐ์ ๊ฐํํ๋ค.
- ์ ์ฒด ์ฝ๋
import { useEffect, useRef, useState } from 'react'
import io from 'socket.io-client'
const SOCKET_SERVER = 'http://localhost:3000'
type SocketType = ReturnType<typeof io>;
function App() {
const localVideoRef = useRef<HTMLVideoElement>(null)
const remoteVideoRef = useRef<HTMLVideoElement>(null)
const peerConnectionRef = useRef<RTCPeerConnection | null>(null)
const socketRef = useRef<SocketType>()
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
// ์์ผ ์ฐ๊ฒฐ
socketRef.current = io(SOCKET_SERVER)
// ์์ผ ์ด๋ฒคํธ ๋ฆฌ์ค๋
socketRef.current.on('offer', handleOffer)
socketRef.current.on('answer', handleAnswer)
socketRef.current.on('ice-candidate', handleIceCandidate)
return () => {
socketRef.current?.disconnect()
}
}, [])
const startVideo = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
})
if (localVideoRef.current) {
localVideoRef.current.srcObject = stream
}
// WebRTC ์ฐ๊ฒฐ ์ด๊ธฐํ
peerConnectionRef.current = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
// ๋ก์ปฌ ์คํธ๋ฆผ ์ถ๊ฐ
stream.getTracks().forEach(track => {
if (peerConnectionRef.current) {
peerConnectionRef.current.addTrack(track, stream)
}
})
// ์๊ฒฉ ์คํธ๋ฆผ ์ฒ๋ฆฌ
peerConnectionRef.current.ontrack = (event) => {
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = event.streams[0]
}
}
// ICE candidate ์ด๋ฒคํธ
peerConnectionRef.current.onicecandidate = (event) => {
if (event.candidate) {
socketRef.current?.emit('ice-candidate', event.candidate)
}
}
setIsConnected(true)
} catch (error) {
console.error('Error accessing media devices:', error)
}
}
const makeCall = async () => {
if (!peerConnectionRef.current) return
try {
const offer = await peerConnectionRef.current.createOffer()
await peerConnectionRef.current.setLocalDescription(offer)
socketRef.current?.emit('offer', offer)
} catch (error) {
console.error('Error creating offer:', error)
}
}
const handleOffer = async (offer: RTCSessionDescriptionInit) => {
if (!peerConnectionRef.current) return
try {
await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(offer))
const answer = await peerConnectionRef.current.createAnswer()
await peerConnectionRef.current.setLocalDescription(answer)
socketRef.current?.emit('answer', answer)
} catch (error) {
console.error('Error handling offer:', error)
}
}
const handleAnswer = async (answer: RTCSessionDescriptionInit) => {
if (!peerConnectionRef.current) return
try {
await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(answer))
} catch (error) {
console.error('Error handling answer:', error)
}
}
const handleIceCandidate = async (candidate: RTCIceCandidateInit) => {
if (!peerConnectionRef.current) return
try {
await peerConnectionRef.current.addIceCandidate(new RTCIceCandidate(candidate))
} catch (error) {
console.error('Error handling ICE candidate:', error)
}
}
const hangUp = () => {
if (peerConnectionRef.current) {
peerConnectionRef.current.close()
peerConnectionRef.current = null
}
if (localVideoRef.current) {
const stream = localVideoRef.current.srcObject as MediaStream
stream?.getTracks().forEach(track => track.stop())
localVideoRef.current.srcObject = null
}
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = null
}
setIsConnected(false)
}
return (
<div className="p-4">
<div className="flex gap-4 mb-4">
<div className="w-[400px] h-[300px] bg-gray-200">
<video
ref={localVideoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover"
/>
</div>
<div className="w-[400px] h-[300px] bg-gray-200">
<video
ref={remoteVideoRef}
autoPlay
playsInline
className="w-full h-full object-cover"
/>
</div>
</div>
<div className="flex gap-4">
<button
onClick={startVideo}
disabled={isConnected}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:bg-gray-400"
>
Start Video
</button>
<button
onClick={makeCall}
disabled={!isConnected}
className="px-4 py-2 bg-green-500 text-white rounded disabled:bg-gray-400"
>
Call
</button>
<button
onClick={hangUp}
disabled={!isConnected}
className="px-4 py-2 bg-red-500 text-white rounded disabled:bg-gray-400"
>
Hang Up
</button>
</div>
</div>
)
}
export default App;
- ์ ์ฒด ํ๋ฆ
-
Start Video ๋ฒํผ ํด๋ฆญ
- ๋ก์ปฌ ๋น๋์ค ๋ฐ ์ค๋์ค ์คํธ๋ฆผ์ ๊ฐ์ ธ์ ํ๋ฉด์ ํ์ํ๊ณ , WebRTC ์ฐ๊ฒฐ์ ์ด๊ธฐํํ๋ค.
-
Call ๋ฒํผ ํด๋ฆญ
- ์ฐ๊ฒฐ ์ ์(
offer
)์ ์์ฑํ์ฌ ์๋๋ฐฉ์๊ฒ ์ ์กํ๋ค.
- ์ฐ๊ฒฐ ์ ์(
-
์๋๋ฐฉ์ด
offer
๋ฅผ ์์ ํ๊ณanswer
๋ก ์๋ต- ์ฐ๊ฒฐ์ด ์๋ฃ๋๊ณ ๋ ํด๋ผ์ด์ธํธ ๊ฐ์ P2P ๋น๋์ค ํตํ๊ฐ ์์๋๋ค.
-
ํตํ ์ค ICE Candidate ๊ตํ
- ๋คํธ์ํฌ ์ต์ ํ ์ ๋ณด๊ฐ ๊ตํ๋์ด ์์ ์ ์ธ ์ฐ๊ฒฐ์ ์ ์งํ๋ค.
-
Hang Up ๋ฒํผ ํด๋ฆญ
- WebRTC ์ฐ๊ฒฐ ๋ฐ ๋น๋์ค ์คํธ๋ฆผ์ ์ ๋ฆฌํ์ฌ ํตํ๋ฅผ ์ข ๋ฃํ๋ค.
- ์ฝ๋ ์ค๋ช
์ฃผ์ ๋ณ์
-
localVideoRef
: ๋ก์ปฌ(์์ )์ ๋น๋์ค ์คํธ๋ฆผ์ ํ์ํ<video>
์์์ ๋ํ ์ฐธ์กฐ์ด๋ค. -
remoteVideoRef
: ์๋๋ฐฉ์ ๋น๋์ค ์คํธ๋ฆผ์ ํ์ํ<video>
์์์ ๋ํ ์ฐธ์กฐ์ด๋ค. -
peerConnectionRef
: WebRTCRTCPeerConnection
์ธ์คํด์ค๋ฅผ ์ฐธ์กฐํ๋ฉฐ, ํผ์ด ๊ฐ P2P ์ฐ๊ฒฐ์ ๊ด๋ฆฌํ๋ค. -
socketRef
: WebSocket ์ฐ๊ฒฐ์ ์ํSocket.IO
์์ผ์ ์ฐธ์กฐํ๋ค. -
isConnected
: ๋ก์ปฌ ์คํธ๋ฆผ ์ฐ๊ฒฐ ์ํ๋ฅผ ๋ํ๋ด๋ฉฐ, ์ฐ๊ฒฐ๋ ๊ฒฝ์ฐ ๋ฒํผ ์ํ๋ฅผ ๋ณ๊ฒฝํ๋ค.
์ฃผ์ ํจ์
useEffect(() => {
// ์์ผ ์ฐ๊ฒฐ
socketRef.current = io(SOCKET_SERVER)
// ์์ผ ์ด๋ฒคํธ ๋ฆฌ์ค๋
socketRef.current.on('offer', handleOffer)
socketRef.current.on('answer', handleAnswer)
socketRef.current.on('ice-candidate', handleIceCandidate)
return () => {
socketRef.current?.disconnect()
}
}, [])
-
socketRef
๋ฅผ ์ฌ์ฉํ์ฌ ์๋ฒ์ ์์ผ ์ฐ๊ฒฐ์ ์ค์ ํ๊ณ ์ด๋ฒคํธ ๋ฆฌ์ค๋๋ฅผ ๋ฑ๋กํ๋ค. -
offer
,answer
,ice-candidate
์ด๋ฒคํธ๋ฅผ ์์ ํ์ฌ WebRTC ์ฐ๊ฒฐ์ ์ํ ์ ํธ๋ฅผ ์ฒ๋ฆฌํ๋ค. - ์ปดํฌ๋ํธ๊ฐ ์ธ๋ง์ดํธ๋ ๋
disconnect
๋ก ์์ผ ์ฐ๊ฒฐ์ ํด์ ํ๋ค.
const startVideo = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
})
if (localVideoRef.current) {
localVideoRef.current.srcObject = stream
}
// WebRTC ์ฐ๊ฒฐ ์ด๊ธฐํ
peerConnectionRef.current = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
// ๋ก์ปฌ ์คํธ๋ฆผ ์ถ๊ฐ
stream.getTracks().forEach(track => {
if (peerConnectionRef.current) {
peerConnectionRef.current.addTrack(track, stream)
}
})
// ์๊ฒฉ ์คํธ๋ฆผ ์ฒ๋ฆฌ
peerConnectionRef.current.ontrack = (event) => {
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = event.streams[0]
}
}
// ICE candidate ์ด๋ฒคํธ
peerConnectionRef.current.onicecandidate = (event) => {
if (event.candidate) {
socketRef.current?.emit('ice-candidate', event.candidate)
}
}
setIsConnected(true)
} catch (error) {
console.error('Error accessing media devices:', error)
}
}
-
๋ก์ปฌ ๋ฏธ๋์ด ์ค์ :
getUserMedia
๋ฅผ ์ฌ์ฉํ์ฌ ๋น๋์ค ๋ฐ ์ค๋์ค ์คํธ๋ฆผ์ ๊ฐ์ ธ์ ๋ก์ปฌ ๋น๋์ค์ ํ์ํ๋ค. -
WebRTC ์ฐ๊ฒฐ ์ด๊ธฐํ:
RTCPeerConnection
์ ์์ฑํ๊ณ ,STUN ์๋ฒ
๋ฅผ ์ฌ์ฉํ์ฌ NAT ๋ฐฉํ๋ฒฝ์ ํตํด ํผ์ด ์ฐ๊ฒฐ์ ์ค์ ํ๋ค. - ์คํธ๋ฆผ ์ถ๊ฐ: ๋ก์ปฌ ๋น๋์ค ์คํธ๋ฆผ์ ๊ฐ ํธ๋์ P2P ์ฐ๊ฒฐ์ ์ถ๊ฐํ๋ค.
-
์๊ฒฉ ์คํธ๋ฆผ ์์ : ์๋๋ฐฉ์ ๋น๋์ค ์คํธ๋ฆผ์ ๋ฐ์
remoteVideoRef
์ ํ์ํ๋ค. -
ICE Candidate ์ด๋ฒคํธ: ICE candidate๊ฐ ๋ฐ๊ฒฌ๋๋ฉด ์์ผ์ ํตํด ์๋๋ฐฉ์๊ฒ ์ ๋ฌํ๋ค.
-
RTCPeerConnection
๊ฐ์ฒด๊ฐ ์์ฑ๋๊ณcreateOffer
๋๋createAnswer
๋ฉ์๋๊ฐ ํธ์ถ๋๋ฉด, WebRTC๋ ์๋์ผ๋ก ๋คํธ์ํฌ ํ์์ ์์ํ๋ค. - ์ฆ, ice ํ๋ณด ๊ตํ์ ์ํด์๋ ์ด๋ฒคํธ ํธ๋ค๋ฌ๋ง ๋ฑ๋กํด์ฃผ๋ฉด ๋๋ค.
-
const makeCall = async () => {
if (!peerConnectionRef.current) return;
try {
const offer = await peerConnectionRef.current.createOffer();
await peerConnectionRef.current.setLocalDescription(offer);
socketRef.current?.emit('offer', offer);
} catch (error) {
console.error('Error creating offer:', error);
}
};
-
ํธ์ถ ์์:
createOffer
๋ฅผ ํธ์ถํ์ฌ ์ฐ๊ฒฐ ์์ฒญ(offer)์ ์์ฑํ๊ณ , ๋ก์ปฌ ์ค๋ช ์ผ๋ก ์ค์ ํ ๋ค, ์์ผ์ ํตํด ์๋๋ฐฉ์๊ฒoffer
๋ฅผ ๋ณด๋ธ๋ค.
const handleOffer = async (offer: RTCSessionDescriptionInit) => {
if (!peerConnectionRef.current) return;
try {
await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await peerConnectionRef.current.createAnswer();
await peerConnectionRef.current.setLocalDescription(answer);
socketRef.current?.emit('answer', answer);
} catch (error) {
console.error('Error handling offer:', error);
}
};
-
Offer ์์ ๋ฐ ์๋ต ์์ฑ: ์๋๋ฐฉ์
offer
๋ฅผ ๋ฐ์ remote description์ผ๋ก ์ค์ ํ๊ณ ,createAnswer
๋ก ์๋ต(answer)์ ์์ฑํ์ฌ ์๋๋ฐฉ์๊ฒ ์ ๋ฌํ๋ค.
const handleAnswer = async (answer: RTCSessionDescriptionInit) => {
if (!peerConnectionRef.current) return;
try {
await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(answer));
} catch (error) {
console.error('Error handling answer:', error);
}
};
-
Answer ์์ : ์๋๋ฐฉ์
answer
๋ฅผ ๋ฐ์ ์๊ฒฉ ์ค๋ช ์ผ๋ก ์ค์ ํ์ฌ ์ฐ๊ฒฐ์ ์๋ฃํ๋ค.
const handleIceCandidate = async (candidate: RTCIceCandidateInit) => {
if (!peerConnectionRef.current) return;
try {
await peerConnectionRef.current.addIceCandidate(new RTCIceCandidate(candidate));
} catch (error) {
console.error('Error handling ICE candidate:', error);
}
};
- ICE Candidate ์์ : ICE ํ๋ณด๋ฅผ ์์ ํ์ฌ P2P ์ฐ๊ฒฐ์ ์ถ๊ฐํ๋ค. ICE ํ๋ณด๋ ๋คํธ์ํฌ ์ฐ๊ฒฐ์ ์ต์ ํํ์ฌ ๋ ๋์ ์ฐ๊ฒฐ ๊ฒฝ๋ก๋ฅผ ์ ๊ณตํ๋ค.
const hangUp = () => {
if (peerConnectionRef.current) {
peerConnectionRef.current.close();
peerConnectionRef.current = null;
}
if (localVideoRef.current) {
const stream = localVideoRef.current.srcObject as MediaStream;
stream?.getTracks().forEach(track => track.stop());
localVideoRef.current.srcObject = null;
}
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = null;
}
setIsConnected(false);
};
-
์ข
๋ฃ ์ฒ๋ฆฌ: ์ฐ๊ฒฐ์ ์ข
๋ฃํ๊ณ , ๋ก์ปฌ ๋ฐ ์๊ฒฉ ์คํธ๋ฆผ์ ํด์ ํ์ฌ ํตํ๋ฅผ ์ข
๋ฃํ๋ค.
setIsConnected(false)
๋ก ์ฐ๊ฒฐ ์ํ๋ฅผ ์ด๊ธฐํํ๋ค.
const startConnection = () => {
peerConnectionRef.current = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
peerConnectionRef.current.onicecandidate = (event) => {
if (event.candidate) {
socketRef.current?.emit('ice-candidate', event.candidate)
}
}
dataChannelRef.current = peerConnectionRef.current.createDataChannel("canvas")
setupDataChannelListeners()
makeCall() // ์๋๋ฐฉ์๊ฒ offer๋ฅผ ๋ณด๋ธ๋ค.
}
- STUN ์๋ฒ ์ค์ : WebRTC ์ฐ๊ฒฐ ์ค์ ์ ํ์ํ ๊ณต์ฉ STUN ์๋ฒ๋ฅผ ์ค์ ํ๋ค. STUN ์๋ฒ๋ ๊ฐ ํด๋ผ์ด์ธํธ์ ๊ณต์ฉ IP ์ฃผ์์ ๋คํธ์ํฌ ํฌํธ๋ฅผ ๊ฒฐ์ ํด NAT ๋ฐฉํ๋ฒฝ์ ํต๊ณผํ ์ ์๋๋ก ๋์์ค๋ค.
-
ICE ํ๋ณด ์ ์ก:
onicecandidate
์ด๋ฒคํธ๊ฐ ๋ฐ์ํ ๋๋ง๋ค ICE ํ๋ณด๋ฅผ ์์ผ์ ํตํด ์ ์กํ์ฌ WebRTC ์ฐ๊ฒฐ์ด ์ํํ๊ฒ ์ด๋ฃจ์ด์ง ์ ์๋๋ก ํ๋ค. -
๋ฐ์ดํฐ ์ฑ๋ ์์ฑ:
peerConnectionRef
์์createDataChannel
์ ์ฌ์ฉํด"canvas"
๋ผ๋ ์ด๋ฆ์ ๋ฐ์ดํฐ ์ฑ๋์ ์์ฑํ๋ค. ์ด ๋ฐ์ดํฐ ์ฑ๋์ ํตํด ์ค์๊ฐ ๊ทธ๋ฆฌ๊ธฐ ์ ๋ณด๋ฅผ ์ฃผ๊ณ ๋ฐ์ ์ ์๋ค. - makeCall ํธ์ถ: WebRTC ์ฐ๊ฒฐ์ ์ด๊ธฐํํ๊ณ ์๋๋ฐฉ์๊ฒ ์ฐ๊ฒฐ ์์ฒญ์ ๋ณด๋ธ๋ค.
const setupDataChannelListeners = () => {
dataChannelRef.current!.onopen = () => console.log("Data channel opened")
dataChannelRef.current!.onmessage = (event) => {
const { type, x, y } = JSON.parse(event.data)
handleRemoteDrawing(type, x, y)
}
}
-
๋ฐ์ดํฐ ์ฑ๋ ์ด๋ฆผ ๊ฐ์ง:
onopen
์ด๋ฒคํธ๋ฅผ ํตํด ๋ฐ์ดํฐ ์ฑ๋์ด ์ฑ๊ณต์ ์ผ๋ก ์ด๋ ธ์์ ํ์ธํ ์ ์๋ค. -
๋ฉ์์ง ์์ :
onmessage
์ด๋ฒคํธ์์ ์๋๋ฐฉ์ผ๋ก๋ถํฐ ๊ทธ๋ฆฌ๊ธฐ ๋ฐ์ดํฐ(type
,x
,y
)๋ฅผ JSON ํ์์ผ๋ก ์์ ํ๋ค. ๋ฐ์ดํฐ๋ฅผ ํ์ฑํ ํhandleRemoteDrawing
ํจ์๋ก ์ ๋ฌํ์ฌ ์๊ฒฉ ๊ทธ๋ฆฌ๊ธฐ ๋์์ ์ํํ๋ค.
const handleRemoteDrawing = (type: string, x: number, y: number) => {
if (!canvasRef.current) return
const context = canvasRef.current.getContext('2d')
if (!context) return
if (type === 'start') {
context.beginPath()
context.moveTo(x, y)
} else if (type === 'draw') {
context.lineTo(x, y)
context.stroke()
}
}
-
type์ ๋ฐ๋ฅธ ์์
๋ถ๊ธฐ
-
start
: ์๊ฒฉ ์ฌ์ฉ์๊ฐ ์๋ก์ด ์ ์ ์์ํ ๋ ํธ์ถ๋๋ค.beginPath
์moveTo
๋ก ์์์ ์ ์ค์ ํ๋ค. -
draw
:lineTo
์stroke
๋ฅผ ์ฌ์ฉํ์ฌ ์์์ ์์ ์ง์ ๋ ์ขํ๊น์ง ์ ์ ๊ทธ๋ฆฌ๊ณ , ํ๋ฉด์ ๋ฐ์ํ๋ค.
-
- ์ด ๋ก์ง์ ํตํด ์๊ฒฉ ์ฌ์ฉ์์ ์บ๋ฒ์ค ๊ทธ๋ฆฌ๊ธฐ ๋์์ด ๋ก์ปฌ ์บ๋ฒ์ค์๋ ์ค์๊ฐ์ผ๋ก ๋๊ธฐํ๋๋ค.
const startDrawing = ({ nativeEvent }: React.MouseEvent) => {
const { offsetX, offsetY } = nativeEvent
const context = canvasRef.current!.getContext('2d')
context?.beginPath()
context?.moveTo(offsetX, offsetY)
setIsDrawing(true)
sendDrawingData('start', offsetX, offsetY)
}
const draw = ({ nativeEvent }: React.MouseEvent) => {
if (!isDrawing) return
const { offsetX, offsetY } = nativeEvent
const context = canvasRef.current!.getContext('2d')
context?.lineTo(offsetX, offsetY)
context?.stroke()
sendDrawingData('draw', offsetX, offsetY)
}
const stopDrawing = () => {
setIsDrawing(false)
}
-
startDrawing
: ๋ง์ฐ์ค๋ฅผ ๋๋ฅด๋ฉดbeginPath
๋ก ์ ๊ฒฝ๋ก๋ฅผ ์์ํ๊ณ , ์์ ์ง์ ์ ์ค์ ํ๋ค.sendDrawingData
๋กstart
ํ์ ์ ๊ทธ๋ฆฌ๊ธฐ ๋ฐ์ดํฐ๋ฅผ ์ ์กํ๋ค. -
draw
: ๋ง์ฐ์ค๋ฅผ ๋๋๊ทธํ ๋๋ง๋คlineTo
๋ก ์ ์ ๊ทธ๋ฆฌ๋ฉฐ,draw
ํ์ ์ ๊ทธ๋ฆฌ๊ธฐ ๋ฐ์ดํฐ๋ฅผ ์ ์กํ๋ค. -
stopDrawing
: ๋ง์ฐ์ค ๋ฒํผ์ ๋ผ๋ฉด ๊ทธ๋ฆฌ๊ธฐ ๋์์ ์ค๋จํ๋ค.
const sendDrawingData = (type: string, x: number, y: number) => {
const message = JSON.stringify({ type, x, y })
dataChannelRef.current?.send(message)
}
- ๊ฐ ๊ทธ๋ฆฌ๊ธฐ ๋์์์ ์ขํ์ ํ์
(
start
๋๋draw
)์ JSON์ผ๋ก ์ธ์ฝ๋ฉํ ํdataChannelRef
๋ฅผ ํตํด ๋ค๋ฅธ ํผ์ด์๊ฒ ์ ์กํ๋ค.
const handleOffer = async (offer: RTCSessionDescriptionInit) => {
peerConnectionRef.current = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
peerConnectionRef.current.ondatachannel = (event) => {
dataChannelRef.current = event.channel
setupDataChannelListeners()
}
peerConnectionRef.current.onicecandidate = (event) => {
if (event.candidate) {
socketRef.current?.emit('ice-candidate', event.candidate)
}
}
// ๊ธฐ์กด๊ณผ ๋์ผํ ๋ก์ง
}
์ ์ฒด ์ฝ๋
import { useEffect, useRef, useState } from 'react'
import io from 'socket.io-client'
const SOCKET_SERVER = 'http://localhost:3000'
type SocketType = ReturnType<typeof io>;
function App() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const peerConnectionRef = useRef<RTCPeerConnection | null>(null)
const dataChannelRef = useRef<RTCDataChannel | null>(null)
const socketRef = useRef<SocketType>()
const [isDrawing, setIsDrawing] = useState(false)
useEffect(() => {
// Initialize socket connection
socketRef.current = io(SOCKET_SERVER)
socketRef.current.on('offer', handleOffer)
socketRef.current.on('answer', handleAnswer)
socketRef.current.on('ice-candidate', handleIceCandidate)
return () => {
socketRef.current?.disconnect()
}
}, [])
const startConnection = () => {
peerConnectionRef.current = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
// Setup ICE candidate handling
peerConnectionRef.current.onicecandidate = (event) => {
if (event.candidate) {
socketRef.current?.emit('ice-candidate', event.candidate)
}
}
// Setup Data Channel
dataChannelRef.current = peerConnectionRef.current.createDataChannel("canvas")
setupDataChannelListeners()
makeCall()
}
const setupDataChannelListeners = () => {
dataChannelRef.current!.onopen = () => console.log("Data channel opened")
dataChannelRef.current!.onmessage = (event) => {
const { type, x, y } = JSON.parse(event.data)
handleRemoteDrawing(type, x, y)
}
}
const handleRemoteDrawing = (type: string, x: number, y: number) => {
if (!canvasRef.current) return
const context = canvasRef.current.getContext('2d')
if (!context) return
if (type === 'start') {
context.beginPath()
context.moveTo(x, y)
} else if (type === 'draw') {
context.lineTo(x, y)
context.stroke()
}
}
const startDrawing = ({ nativeEvent }: React.MouseEvent) => {
const { offsetX, offsetY } = nativeEvent
const context = canvasRef.current!.getContext('2d')
context?.beginPath()
context?.moveTo(offsetX, offsetY)
setIsDrawing(true)
sendDrawingData('start', offsetX, offsetY)
}
const draw = ({ nativeEvent }: React.MouseEvent) => {
if (!isDrawing) return
const { offsetX, offsetY } = nativeEvent
const context = canvasRef.current!.getContext('2d')
context?.lineTo(offsetX, offsetY)
context?.stroke()
sendDrawingData('draw', offsetX, offsetY)
}
const stopDrawing = () => {
setIsDrawing(false)
}
const sendDrawingData = (type: string, x: number, y: number) => {
const message = JSON.stringify({ type, x, y })
dataChannelRef.current?.send(message)
}
const makeCall = async () => {
try {
const offer = await peerConnectionRef.current!.createOffer()
await peerConnectionRef.current!.setLocalDescription(offer)
socketRef.current?.emit('offer', offer)
} catch (error) {
console.error('Error creating offer:', error)
}
}
const handleOffer = async (offer: RTCSessionDescriptionInit) => {
peerConnectionRef.current = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
peerConnectionRef.current.ondatachannel = (event) => {
dataChannelRef.current = event.channel
setupDataChannelListeners()
}
peerConnectionRef.current.onicecandidate = (event) => {
if (event.candidate) {
socketRef.current?.emit('ice-candidate', event.candidate)
}
}
try {
await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(offer))
const answer = await peerConnectionRef.current.createAnswer()
await peerConnectionRef.current.setLocalDescription(answer)
socketRef.current?.emit('answer', answer)
} catch (error) {
console.error('Error handling offer:', error)
}
}
const handleAnswer = async (answer: RTCSessionDescriptionInit) => {
try {
await peerConnectionRef.current?.setRemoteDescription(new RTCSessionDescription(answer))
} catch (error) {
console.error('Error handling answer:', error)
}
}
const handleIceCandidate = async (candidate: RTCIceCandidateInit) => {
try {
await peerConnectionRef.current?.addIceCandidate(new RTCIceCandidate(candidate))
} catch (error) {
console.error('Error handling ICE candidate:', error)
}
}
return (
<div className="p-4">
<canvas
ref={canvasRef}
width={800}
height={600}
className="border bg-gray-100"
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
/>
<button onClick={startConnection} className="mt-4 px-4 py-2 bg-blue-500 text-white rounded">
Start Collaboration
</button>
</div>
)
}
export default App;
์ ์ฒด ํ๋ฆ
ํด๋ผ์ด์ธํธ A (์ฐ๊ฒฐ ์์)
-
์ฐ๊ฒฐ ์ด๊ธฐํ
- ํด๋ผ์ด์ธํธ A๋ WebRTC ์ฐ๊ฒฐ์ ์ค์ ํ๊ณ , ๋ฐ์ดํฐ ์ฑ๋์ ์์ฑํ๋ค.
-
Offer ์์ฑ ๋ฐ ์ ์ก
- WebRTC ์ฐ๊ฒฐ ์์ฒญ์ธ Offer๋ฅผ ์์ฑํ๊ณ , ์ด๋ฅผ ์๋ฒ๋ฅผ ํตํด ํด๋ผ์ด์ธํธ B์๊ฒ ์ ์กํ๋ค.
-
ICE ํ๋ณด ์ ์ก
- ๋คํธ์ํฌ ๊ฒฝ๋ก ์ค์ ์ ๋๊ธฐ ์ํด ICE ํ๋ณด๋ฅผ ์๋ฒ๋ฅผ ํตํด ํด๋ผ์ด์ธํธ B์๊ฒ ์ ์กํ๋ค.
-
๋ฐ์ดํฐ ์ ์ก ์ค๋น
- ๋ฐ์ดํฐ ์ฑ๋์ด ์ด๋ฆฌ๋ฉด, ํด๋ผ์ด์ธํธ A๋ ์บ๋ฒ์ค์์ ๋ฐ์ํ๋ ๊ทธ๋ฆฌ๊ธฐ ๋์์ ์ค์๊ฐ์ผ๋ก ๋ฐ์ดํฐ ์ฑ๋์ ํตํด ํด๋ผ์ด์ธํธ B๋ก ์ ์กํ๋ค.
ํด๋ผ์ด์ธํธ B (Offer ์์ ๋ฐ ์๋ต)
-
Offer ์์ ๋ฐ ์ฒ๋ฆฌ
- ํด๋ผ์ด์ธํธ B๋ ์๋ฒ๋ฅผ ํตํด ๋ฐ์ Offer๋ฅผ WebRTC ์ฐ๊ฒฐ ์ค์ ์ ์ฌ์ฉํ๋ค.
- ํด๋ผ์ด์ธํธ B๋ ์๋๋ฐฉ์ ๋ฐ์ดํฐ ์ฑ๋์ ๊ฐ์งํ๊ณ , ์ด๋ฒคํธ ๋ฆฌ์ค๋๋ฅผ ์ค์ ํ๋ค.
-
Answer ์์ฑ ๋ฐ ์ ์ก
- Offer์ ๋ํ ์๋ต์ผ๋ก Answer๋ฅผ ์์ฑํ๊ณ ์๋ฒ๋ฅผ ํตํด ํด๋ผ์ด์ธํธ A์๊ฒ ์ ์กํ๋ค.
-
ICE ํ๋ณด ์์ ๋ฐ ์ฒ๋ฆฌ
- ์๋ฒ๋ฅผ ํตํด ์์ ํ ํด๋ผ์ด์ธํธ A์ ICE ํ๋ณด๋ฅผ ์ฌ์ฉํด ์ต์ ํ๋ ๋คํธ์ํฌ ๊ฒฝ๋ก๋ฅผ ์ค์ ํ๋ค.
์ฐ๊ฒฐ ์๋ฃ ํ (A โ B ์ํธ์์ฉ)
-
์ค์๊ฐ ์บ๋ฒ์ค ๋๊ธฐํ
- A์ B๋ ์๋ก ๋ฐ์ดํฐ ์ฑ๋์ ํตํด ๊ทธ๋ฆฌ๊ธฐ ๋ฐ์ดํฐ๋ฅผ ์ฃผ๊ณ ๋ฐ๋๋ค.
- ๊ฐ ํด๋ผ์ด์ธํธ๋ ์๋๋ฐฉ์ ๊ทธ๋ฆฌ๊ธฐ ๋์์ ์ค์๊ฐ์ผ๋ก ์์ ํ์ฌ ์์ ์ ์บ๋ฒ์ค์ ๋ฐ์ํ๋ค.
- 1. ๊ฐ๋ฐ ํ๊ฒฝ ์ธํ ๋ฐ ํ๋ก์ ํธ ๋ฌธ์ํ
- 2. ์ค์๊ฐ ํต์
- 3. ์ธํ๋ผ ๋ฐ CI/CD
- 4. ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์์ด Canvas ๊ตฌํํ๊ธฐ
- 5. ์บ๋ฒ์ค ๋๊ธฐํ๋ฅผ ์ํ ์์ CRDT ๊ตฌํ๊ธฐ
-
6. ์ปดํฌ๋ํธ ํจํด๋ถํฐ ์น์์ผ๊น์ง, ํจ์จ์ ์ธ FE ์ค๊ณ
- ์ข์ ์ปดํฌ๋ํธ๋ ๋ฌด์์ธ๊ฐ? + Headless Pattern
- ํจ์จ์ ์ธ UI ์ปดํฌ๋ํธ ์คํ์ผ๋ง: Tailwind CSS + cn.ts
- Tailwind CSS๋ก ๋์์ธ ์์คํ ๋ฐ UI ์ปดํฌ๋ํธ ์ธํ
- ์น์์ผ ํด๋ผ์ด์ธํธ ๊ตฌํ๊ธฐ: React ํ๊ฒฝ์์ ํจ์จ์ ์ธ ์น์์ผ ์ํคํ ์ฒ
- ์น์์ผ ํด๋ผ์ด์ธํธ ์ฝ๋ ๋ถ์ ๋ฐ ๊ณต์
- 7. ํธ๋ฌ๋ธ ์ํ ๋ฐ ์ฑ๋ฅ/UX ๊ฐ์
- 1์ฃผ์ฐจ ๊ธฐ์ ๊ณต์
- 2์ฃผ์ฐจ ๋ฐ๋ชจ ๋ฐ์ด
- 3์ฃผ์ฐจ ๋ฐ๋ชจ ๋ฐ์ด
- 4์ฃผ์ฐจ ๋ฐ๋ชจ ๋ฐ์ด
- 5์ฃผ์ฐจ ๋ฐ๋ชจ ๋ฐ์ด
- WEEK 06 ์ฃผ๊ฐ ๊ณํ
- WEEK 06 ๋ฐ์ผ๋ฆฌ ์คํฌ๋ผ
- WEEK 06 ์ฃผ๊ฐ ํ๊ณ