Skip to content

๐Ÿชต 2. WebRTC ์‹ค์Šต (1) : 1:1 Mesh ๋ฐฉ์‹

ssum1ra edited this page Dec 5, 2024 · 1 revision

์‹ค์‹œ๊ฐ„ 1:1 ํ™”์ƒ์ฑ„ํŒ… ๊ตฌํ˜„ํ•˜๊ธฐ

๊ฐœ๋ฐœ ํ™˜๊ฒฝ

  • client : vite + react + ts
  • server : nestjs

์„œ๋ฒ„

main.ts

// 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();

app.module.ts

// server/src/app.module.ts
import { Module } from '@nestjs/common';
import { WebRTCGateway } from './webrtc.gateway';

@Module({
  providers: [WebRTCGateway],
})
export class AppModule {}

webrtc.gateway.ts

  1. ์ „์ฒด ์ฝ”๋“œ
// 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);
  }
}
  1. ์ฝ”๋“œ ์„ค๋ช…
@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 ํ›„๋ณด ์ •๋ณด๋ฅผ ๋‹ค๋ฅธ ํด๋ผ์ด์–ธํŠธ๋“ค์—๊ฒŒ ์ „๋‹ฌํ•˜์—ฌ ๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ์„ ๊ฐ•ํ™”ํ•œ๋‹ค.

ํด๋ผ์ด์–ธํŠธ

App.tsx

  1. ์ „์ฒด ์ฝ”๋“œ
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;
  1. ์ „์ฒด ํ๋ฆ„
  • Start Video ๋ฒ„ํŠผ ํด๋ฆญ
    • ๋กœ์ปฌ ๋น„๋””์˜ค ๋ฐ ์˜ค๋””์˜ค ์ŠคํŠธ๋ฆผ์„ ๊ฐ€์ ธ์™€ ํ™”๋ฉด์— ํ‘œ์‹œํ•˜๊ณ , WebRTC ์—ฐ๊ฒฐ์„ ์ดˆ๊ธฐํ™”ํ•œ๋‹ค.
  • Call ๋ฒ„ํŠผ ํด๋ฆญ
    • ์—ฐ๊ฒฐ ์ œ์•ˆ(offer)์„ ์ƒ์„ฑํ•˜์—ฌ ์ƒ๋Œ€๋ฐฉ์—๊ฒŒ ์ „์†กํ•œ๋‹ค.
  • ์ƒ๋Œ€๋ฐฉ์ด offer๋ฅผ ์ˆ˜์‹ ํ•˜๊ณ  answer๋กœ ์‘๋‹ต
    • ์—ฐ๊ฒฐ์ด ์™„๋ฃŒ๋˜๊ณ  ๋‘ ํด๋ผ์ด์–ธํŠธ ๊ฐ„์˜ P2P ๋น„๋””์˜ค ํ†ตํ™”๊ฐ€ ์‹œ์ž‘๋œ๋‹ค.
  • ํ†ตํ™” ์ค‘ ICE Candidate ๊ตํ™˜
    • ๋„คํŠธ์›Œํฌ ์ตœ์ ํ™” ์ •๋ณด๊ฐ€ ๊ตํ™˜๋˜์–ด ์•ˆ์ •์ ์ธ ์—ฐ๊ฒฐ์„ ์œ ์ง€ํ•œ๋‹ค.
  • Hang Up ๋ฒ„ํŠผ ํด๋ฆญ
    • WebRTC ์—ฐ๊ฒฐ ๋ฐ ๋น„๋””์˜ค ์ŠคํŠธ๋ฆผ์„ ์ •๋ฆฌํ•˜์—ฌ ํ†ตํ™”๋ฅผ ์ข…๋ฃŒํ•œ๋‹ค.
  1. ์ฝ”๋“œ ์„ค๋ช…

์ฃผ์š” ๋ณ€์ˆ˜

  • localVideoRef: ๋กœ์ปฌ(์ž์‹ )์˜ ๋น„๋””์˜ค ์ŠคํŠธ๋ฆผ์„ ํ‘œ์‹œํ•  <video> ์š”์†Œ์— ๋Œ€ํ•œ ์ฐธ์กฐ์ด๋‹ค.
  • remoteVideoRef: ์ƒ๋Œ€๋ฐฉ์˜ ๋น„๋””์˜ค ์ŠคํŠธ๋ฆผ์„ ํ‘œ์‹œํ•  <video> ์š”์†Œ์— ๋Œ€ํ•œ ์ฐธ์กฐ์ด๋‹ค.
  • peerConnectionRef: WebRTC RTCPeerConnection ์ธ์Šคํ„ด์Šค๋ฅผ ์ฐธ์กฐํ•˜๋ฉฐ, ํ”ผ์–ด ๊ฐ„ 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)๋กœ ์—ฐ๊ฒฐ ์ƒํƒœ๋ฅผ ์ดˆ๊ธฐํ™”ํ•œ๋‹ค.

์‹ค์‹œ๊ฐ„ 1:1 ๊ทธ๋ฆผํŒ ๋™๊ธฐํ™”

ํด๋ผ์ด์–ธํŠธ

์ˆ˜์ •๋œ App.tsx

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 (์—ฐ๊ฒฐ ์‹œ์ž‘)

  1. ์—ฐ๊ฒฐ ์ดˆ๊ธฐํ™”
    • ํด๋ผ์ด์–ธํŠธ A๋Š” WebRTC ์—ฐ๊ฒฐ์„ ์„ค์ •ํ•˜๊ณ , ๋ฐ์ดํ„ฐ ์ฑ„๋„์„ ์ƒ์„ฑํ•œ๋‹ค.
  2. Offer ์ƒ์„ฑ ๋ฐ ์ „์†ก
    • WebRTC ์—ฐ๊ฒฐ ์š”์ฒญ์ธ Offer๋ฅผ ์ƒ์„ฑํ•˜๊ณ , ์ด๋ฅผ ์„œ๋ฒ„๋ฅผ ํ†ตํ•ด ํด๋ผ์ด์–ธํŠธ B์—๊ฒŒ ์ „์†กํ•œ๋‹ค.
  3. ICE ํ›„๋ณด ์ „์†ก
    • ๋„คํŠธ์›Œํฌ ๊ฒฝ๋กœ ์„ค์ •์„ ๋•๊ธฐ ์œ„ํ•ด ICE ํ›„๋ณด๋ฅผ ์„œ๋ฒ„๋ฅผ ํ†ตํ•ด ํด๋ผ์ด์–ธํŠธ B์—๊ฒŒ ์ „์†กํ•œ๋‹ค.
  4. ๋ฐ์ดํ„ฐ ์ „์†ก ์ค€๋น„
    • ๋ฐ์ดํ„ฐ ์ฑ„๋„์ด ์—ด๋ฆฌ๋ฉด, ํด๋ผ์ด์–ธํŠธ A๋Š” ์บ”๋ฒ„์Šค์—์„œ ๋ฐœ์ƒํ•˜๋Š” ๊ทธ๋ฆฌ๊ธฐ ๋™์ž‘์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋ฐ์ดํ„ฐ ์ฑ„๋„์„ ํ†ตํ•ด ํด๋ผ์ด์–ธํŠธ B๋กœ ์ „์†กํ•œ๋‹ค.

ํด๋ผ์ด์–ธํŠธ B (Offer ์ˆ˜์‹  ๋ฐ ์‘๋‹ต)

  1. Offer ์ˆ˜์‹  ๋ฐ ์ฒ˜๋ฆฌ
    • ํด๋ผ์ด์–ธํŠธ B๋Š” ์„œ๋ฒ„๋ฅผ ํ†ตํ•ด ๋ฐ›์€ Offer๋ฅผ WebRTC ์—ฐ๊ฒฐ ์„ค์ •์— ์‚ฌ์šฉํ•œ๋‹ค.
    • ํด๋ผ์ด์–ธํŠธ B๋Š” ์ƒ๋Œ€๋ฐฉ์˜ ๋ฐ์ดํ„ฐ ์ฑ„๋„์„ ๊ฐ์ง€ํ•˜๊ณ , ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ๋ฅผ ์„ค์ •ํ•œ๋‹ค.
  2. Answer ์ƒ์„ฑ ๋ฐ ์ „์†ก
    • Offer์— ๋Œ€ํ•œ ์‘๋‹ต์œผ๋กœ Answer๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์„œ๋ฒ„๋ฅผ ํ†ตํ•ด ํด๋ผ์ด์–ธํŠธ A์—๊ฒŒ ์ „์†กํ•œ๋‹ค.
  3. ICE ํ›„๋ณด ์ˆ˜์‹  ๋ฐ ์ฒ˜๋ฆฌ
    • ์„œ๋ฒ„๋ฅผ ํ†ตํ•ด ์ˆ˜์‹ ํ•œ ํด๋ผ์ด์–ธํŠธ A์˜ ICE ํ›„๋ณด๋ฅผ ์‚ฌ์šฉํ•ด ์ตœ์ ํ™”๋œ ๋„คํŠธ์›Œํฌ ๊ฒฝ๋กœ๋ฅผ ์„ค์ •ํ•œ๋‹ค.

์—ฐ๊ฒฐ ์™„๋ฃŒ ํ›„ (A โ†” B ์ƒํ˜ธ์ž‘์šฉ)

  • ์‹ค์‹œ๊ฐ„ ์บ”๋ฒ„์Šค ๋™๊ธฐํ™”
    • A์™€ B๋Š” ์„œ๋กœ ๋ฐ์ดํ„ฐ ์ฑ„๋„์„ ํ†ตํ•ด ๊ทธ๋ฆฌ๊ธฐ ๋ฐ์ดํ„ฐ๋ฅผ ์ฃผ๊ณ ๋ฐ›๋Š”๋‹ค.
    • ๊ฐ ํด๋ผ์ด์–ธํŠธ๋Š” ์ƒ๋Œ€๋ฐฉ์˜ ๊ทธ๋ฆฌ๊ธฐ ๋™์ž‘์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ˆ˜์‹ ํ•˜์—ฌ ์ž์‹ ์˜ ์บ”๋ฒ„์Šค์— ๋ฐ˜์˜ํ•œ๋‹ค.

๐Ÿ˜Ž ์›จ๋ฒ ๋ฒ ๋ฒ ๋ฒฑ

๐Ÿ‘ฎ๐Ÿป ํŒ€ ๊ทœ์น™

๐Ÿ’ป ํ”„๋กœ์ ํŠธ

๐Ÿชต ์›จ๋ฒ ๋ฒฑ ๊ธฐ์ˆ ๋กœ๊ทธ

๐Ÿช„ ๋ฐ๋ชจ ๊ณต์œ 

๐Ÿ”„ ์Šคํ”„๋ฆฐํŠธ ๊ธฐ๋ก

๐Ÿ“— ํšŒ์˜๋ก

Clone this wiki locally