Skip to content

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

ssum1ra edited this page Dec 5, 2024 · 1 revision

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

์„œ๋ฒ„ (์‹œ๊ทธ๋„๋ง ์„œ๋ฒ„)

์ฃผ์š” ํ•จ์ˆ˜

@WebSocketGateway({ cors: { origin: '*' } })
export class WebRTCGateway implements OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;
  • WebSocket ์„œ๋ฒ„ ๊ตฌ์„ฑ: @WebSocketGateway ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋กœ ์„ค์ •์„ ์ถ”๊ฐ€ํ•˜์—ฌ ์„œ๋ฒ„๋ฅผ ์„ค์ •ํ•˜๊ณ , @WebSocketServer()๋กœ ์„œ๋ฒ„ ์ธ์Šคํ„ด์Šค๋ฅผ ๋งŒ๋“ค์–ด WebSocket ํ†ต์‹ ์„ ๊ด€๋ฆฌํ•œ๋‹ค.
  • CORS ์„ค์ •: ๋‹ค์–‘ํ•œ ๋„๋ฉ”์ธ์—์„œ ์„œ๋ฒ„์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋„๋ก ํ—ˆ์šฉํ•˜์—ฌ ์—ฌ๋Ÿฌ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ด ์„œ๋ฒ„์— ์ ‘์†ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค.
  • ํด๋ผ์ด์–ธํŠธ ์—ฐ๊ฒฐ ๊ด€๋ฆฌ: OnGatewayDisconnect๋ฅผ ๊ตฌํ˜„ํ•˜์—ฌ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์—ฐ๊ฒฐ์„ ์ข…๋ฃŒํ•  ๋•Œ ๋ฐœ์ƒํ•˜๋Š” ์ž‘์—…์„ ์ •์˜ํ•˜๊ณ , ์„œ๋ฒ„ ์ธ์Šคํ„ด์Šค(server)๋ฅผ ํ†ตํ•ด ์—ฐ๊ฒฐ๋œ ํด๋ผ์ด์–ธํŠธ๋“ค์—๊ฒŒ ์ด๋ฒคํŠธ๋ฅผ ์ „์†กํ•  ์ˆ˜ ์žˆ๋„๋ก ์ค€๋น„ํ•œ๋‹ค.
handleConnection(client: Socket) {
    console.log(`Client connected: ${client.id}`);
  }

  handleDisconnect(client: Socket) {
    console.log(`Client disconnected: ${client.id}`);

    // ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์†ํ•œ ๋ชจ๋“  ๋ฐฉ์— ์—ฐ๊ฒฐ ์ข…๋ฃŒ ์•Œ๋ฆผ
    this.rooms.forEach((clients, roomId) => {
      if (clients.has(client.id)) {
        clients.delete(client.id);
        client.to(roomId).emit('user-left', client.id);
        console.log(`Notified room ${roomId} about user ${client.id} leaving`);
      }
    });
  }
  • handleConnection
    • ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์„œ๋ฒ„์— ์—ฐ๊ฒฐ๋˜๋ฉด ID๋ฅผ ์ฝ˜์†”์— ํ‘œ์‹œํ•œ๋‹ค.
  • handleDisconnect
    • ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์—ฐ๊ฒฐ์„ ๋Š์œผ๋ฉด, ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์†ํ•œ ๋ฐฉ์„ ์ฐพ์•„ ์ œ๊ฑฐํ•˜๊ณ , ๋ฐฉ์˜ ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž์—๊ฒŒ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋– ๋‚ฌ๋‹ค๋Š” ์‚ฌ์‹ค์„ ์•Œ๋ฆฐ๋‹ค.
    • user-left ์ด๋ฒคํŠธ๋ฅผ ํ†ตํ•ด ๋ฐฉ์˜ ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๋“ค์—๊ฒŒ ์—ฐ๊ฒฐ ์ข…๋ฃŒ๋ฅผ ํ†ต์ง€ํ•œ๋‹ค.
@SubscribeMessage('joinRoom')
  async handleJoinRoom(
    @MessageBody() roomId: string,
    @ConnectedSocket() client: Socket,
  ) {
    console.log(`Client ${client.id} joining room: ${roomId}`);

    // ๋ฐฉ ์ฐธ์—ฌ
    await client.join(roomId);

    // ๋ฐฉ ์‚ฌ์šฉ์ž ๋ชฉ๋ก ๊ด€๋ฆฌ
    if (!this.rooms.has(roomId)) {
      this.rooms.set(roomId, new Set());
    }
    const roomClients = this.rooms.get(roomId);

    // ํ˜„์žฌ ๋ฐฉ์— ์žˆ๋Š” ๋ชจ๋“  ์‚ฌ์šฉ์ž ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ
    const currentUsers = Array.from(roomClients);

    // ์‹ ๊ทœ ์‚ฌ์šฉ์ž์—๊ฒŒ ํ˜„์žฌ ๋ฐฉ ์‚ฌ์šฉ์ž ๋ชฉ๋ก ์ „์†ก
    client.emit('room-users', currentUsers);
    console.log(`Sent current users to ${client.id}:`, currentUsers);

    // ๋ฐฉ์— ์ƒˆ ์‚ฌ์šฉ์ž ์ถ”๊ฐ€
    roomClients.add(client.id);

    // ๋ฐฉ์˜ ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๋“ค์—๊ฒŒ ์ƒˆ ์‚ฌ์šฉ์ž ์•Œ๋ฆผ
    client.to(roomId).emit('user-joined', client.id);
    console.log(`Notified room about new user ${client.id}`);

    // ์ฐธ์—ฌ ์™„๋ฃŒ ์•Œ๋ฆผ
    client.emit('joinedRoom', roomId);
  }
  • ๋ฐฉ ์ฐธ์—ฌ ์š”์ฒญ: ํด๋ผ์ด์–ธํŠธ๊ฐ€ joinRoom ์š”์ฒญ์„ ๋ณด๋ƒ„์œผ๋กœ์จ ํŠน์ • ๋ฐฉ์— ์ฐธ์—ฌํ•  ์ˆ˜ ์žˆ๋‹ค.
  • ํ˜„์žฌ ์‚ฌ์šฉ์ž ๋ชฉ๋ก ์ „์†ก: ์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž์—๊ฒŒ ํ˜„์žฌ ๋ฐฉ์— ์žˆ๋Š” ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž ๋ชฉ๋ก์„ ์ „์†กํ•˜์—ฌ, ๋ฐฉ์— ๋ˆ„๊ฐ€ ์žˆ๋Š”์ง€ ์•Œ ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค.
  • ๋ฐฉ์— ์‚ฌ์šฉ์ž ์ถ”๊ฐ€ ๋ฐ ์•Œ๋ฆผ: ๋ฐฉ ๋ชฉ๋ก์— ์ƒˆ ์‚ฌ์šฉ์ž๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๋“ค์—๊ฒŒ ์ƒˆ ์‚ฌ์šฉ์ž๊ฐ€ ์ฐธ์—ฌํ–ˆ์Œ์„ ์•Œ๋ฆฐ๋‹ค.
  • ์ฐธ์—ฌ ์™„๋ฃŒ ์•Œ๋ฆผ: ์ƒˆ ์‚ฌ์šฉ์ž๊ฐ€ ๋ฐฉ์— ์ •์ƒ์ ์œผ๋กœ ์ฐธ์—ฌํ–ˆ์Œ์„ ์•Œ๋ฆฌ๋Š” joinedRoom ์ด๋ฒคํŠธ๋ฅผ ๋ณด๋‚ธ๋‹ค.
@SubscribeMessage('offer')
  handleOffer(
    @MessageBody()
    data: {
      roomId: string;
      userId: string;
      offer: RTCSessionDescriptionInit;
    },
    @ConnectedSocket() client: Socket,
  ) {
    console.log(`Received offer from ${client.id} to ${data.userId}`);
    // userId๋กœ ํŠน์ • ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ offer ์ „์†ก
    this.server.to(data.userId).emit('offer', {
      offer: data.offer,
      senderId: client.id,
    });
  }
  • offer ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ: offer ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ํŠน์ • ์‚ฌ์šฉ์ž์—๊ฒŒ WebRTC offer๋ฅผ ์ „์†กํ•œ๋‹ค.
  • ์ „์†ก ๊ตฌ์กฐ
    • senderId๋ฅผ ํฌํ•จํ•˜์—ฌ offer๋ฅผ ๋ณด๋‚ธ ์‚ฌ์šฉ์ž๊ฐ€ ๋ˆ„๊ตฌ์ธ์ง€ ์•Œ ์ˆ˜ ์žˆ๋„๋ก ํ•œ๋‹ค.
    • ๋Œ€์ƒ ์‚ฌ์šฉ์ž๋Š” offer๋ฅผ ์ˆ˜์‹ ํ•˜์—ฌ WebRTC ์—ฐ๊ฒฐ์„ ์œ„ํ•œ answer๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค.
  • ํŠน์ • ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ ์ „๋‹ฌ: userId๋กœ ์ง€์ •๋œ ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ offer๊ฐ€ ์ „์†ก๋˜๋ฏ€๋กœ, ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๋Š” ์ด ์ด๋ฒคํŠธ๋ฅผ ์ˆ˜์‹ ํ•˜์ง€ ์•Š๋Š”๋‹ค.
@SubscribeMessage('answer')
  handleAnswer(
    @MessageBody()
    data: {
      roomId: string;
      userId: string;
      answer: RTCSessionDescriptionInit;
    },
    @ConnectedSocket() client: Socket,
  ) {
    console.log(`Received answer from ${client.id} to ${data.userId}`);
    // userId๋กœ ํŠน์ • ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ answer ์ „์†ก
    this.server.to(data.userId).emit('answer', {
      answer: data.answer,
      senderId: client.id,
    });
  }
  • answer ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ: answer ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ํŠน์ • ์‚ฌ์šฉ์ž์—๊ฒŒ WebRTC answer๋ฅผ ์ „์†กํ•œ๋‹ค.
  • ์ „์†ก ๊ตฌ์กฐ
    • senderId๋ฅผ ํฌํ•จํ•˜์—ฌ answer๋ฅผ ๋ณด๋‚ธ ์‚ฌ์šฉ์ž๊ฐ€ ๋ˆ„๊ตฌ์ธ์ง€ ์•Œ ์ˆ˜ ์žˆ๋„๋ก ํ•œ๋‹ค.
    • ๋Œ€์ƒ ์‚ฌ์šฉ์ž๋Š” answer๋ฅผ ์ˆ˜์‹ ํ•˜์—ฌ WebRTC ์—ฐ๊ฒฐ์„ ํ™•๋ฆฝํ•  ์ˆ˜ ์žˆ๋‹ค.
  • ํŠน์ • ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ ์ „๋‹ฌ: userId๋กœ ์ง€์ •๋œ ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ answer๊ฐ€ ์ „์†ก๋˜๋ฏ€๋กœ, ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๋Š” ์ด ์ด๋ฒคํŠธ๋ฅผ ์ˆ˜์‹ ํ•˜์ง€ ์•Š๋Š”๋‹ค.
@SubscribeMessage('ice-candidate')
  handleIceCandidate(
    @MessageBody()
    data: {
      roomId: string;
      userId: string;
      candidate: RTCIceCandidateInit;
    },
    @ConnectedSocket() client: Socket,
  ) {
    console.log(`Received ICE candidate from ${client.id} to ${data.userId}`);
    // userId๋กœ ํŠน์ • ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ candidate ์ „์†ก
    this.server.to(data.userId).emit('ice-candidate', {
      candidate: data.candidate,
      senderId: client.id,
    });
  }
  • ICE ํ›„๋ณด ์ฒ˜๋ฆฌ: ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ƒˆ๋กœ์šด ICE ํ›„๋ณด๋ฅผ ๋ฐœ๊ฒฌํ•  ๋•Œ๋งˆ๋‹ค ์ƒ๋Œ€๋ฐฉ์—๊ฒŒ ์ด ํ›„๋ณด ์ •๋ณด๋ฅผ ์ „์†กํ•˜์—ฌ ์ตœ์ ์˜ ๋„คํŠธ์›Œํฌ ๊ฒฝ๋กœ๋ฅผ ์ฐพ๋Š”๋‹ค.
  • ์ „์†ก ๊ตฌ์กฐ
    • senderId๋ฅผ ํฌํ•จํ•˜์—ฌ ICE ํ›„๋ณด๋ฅผ ๋ณด๋‚ธ ์‚ฌ์šฉ์ž๊ฐ€ ๋ˆ„๊ตฌ์ธ์ง€ ์•Œ ์ˆ˜ ์žˆ๋„๋ก ํ•œ๋‹ค.
    • ๋Œ€์ƒ ์‚ฌ์šฉ์ž๋Š” ์ด ICE ํ›„๋ณด๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์—ฐ๊ฒฐ์„ ์„ค์ •ํ•œ๋‹ค.
  • ํŠน์ • ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ ์ „๋‹ฌ: userId๋กœ ์ง€์ •๋œ ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ ICE ํ›„๋ณด๊ฐ€ ์ „์†ก๋˜๋ฏ€๋กœ, ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๋Š” ์ด ์ด๋ฒคํŠธ๋ฅผ ์ˆ˜์‹ ํ•˜์ง€ ์•Š๋Š”๋‹ค.

์ „์ฒด ์ฝ”๋“œ

import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  MessageBody,
  ConnectedSocket,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@WebSocketGateway({ cors: { origin: '*' } })
export class WebRTCGateway implements OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  private rooms = new Map<string, Set<string>>();

  handleConnection(client: Socket) {
    console.log(`Client connected: ${client.id}`);
  }

  handleDisconnect(client: Socket) {
    console.log(`Client disconnected: ${client.id}`);

    // ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์†ํ•œ ๋ชจ๋“  ๋ฐฉ์— ์—ฐ๊ฒฐ ์ข…๋ฃŒ ์•Œ๋ฆผ
    this.rooms.forEach((clients, roomId) => {
      if (clients.has(client.id)) {
        clients.delete(client.id);
        client.to(roomId).emit('user-left', client.id);
        console.log(`Notified room ${roomId} about user ${client.id} leaving`);
      }
    });
  }

  // ๋ฐฉ์— ์‚ฌ์šฉ์ž ์ถ”๊ฐ€ ๋ฐ ํ˜„์žฌ ์‚ฌ์šฉ์ž ๋ชฉ๋ก ์ „์†ก
  @SubscribeMessage('joinRoom')
  async handleJoinRoom(
    @MessageBody() roomId: string,
    @ConnectedSocket() client: Socket,
  ) {
    console.log(`Client ${client.id} joining room: ${roomId}`);

    // ๋ฐฉ ์ฐธ์—ฌ
    await client.join(roomId);

    // ๋ฐฉ ์‚ฌ์šฉ์ž ๋ชฉ๋ก ๊ด€๋ฆฌ
    if (!this.rooms.has(roomId)) {
      this.rooms.set(roomId, new Set());
    }
    const roomClients = this.rooms.get(roomId);

    // ํ˜„์žฌ ๋ฐฉ์— ์žˆ๋Š” ๋ชจ๋“  ์‚ฌ์šฉ์ž ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ
    const currentUsers = Array.from(roomClients);

    // ์‹ ๊ทœ ์‚ฌ์šฉ์ž์—๊ฒŒ ํ˜„์žฌ ๋ฐฉ ์‚ฌ์šฉ์ž ๋ชฉ๋ก ์ „์†ก
    client.emit('room-users', currentUsers);
    console.log(`Sent current users to ${client.id}:`, currentUsers);

    // ๋ฐฉ์— ์ƒˆ ์‚ฌ์šฉ์ž ์ถ”๊ฐ€
    roomClients.add(client.id);

    // ๋ฐฉ์˜ ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๋“ค์—๊ฒŒ ์ƒˆ ์‚ฌ์šฉ์ž ์•Œ๋ฆผ
    client.to(roomId).emit('user-joined', client.id);
    console.log(`Notified room about new user ${client.id}`);

    // ์ฐธ์—ฌ ์™„๋ฃŒ ์•Œ๋ฆผ
    client.emit('joinedRoom', roomId);
  }

  @SubscribeMessage('offer')
  handleOffer(
    @MessageBody()
    data: {
      roomId: string;
      userId: string;
      offer: RTCSessionDescriptionInit;
    },
    @ConnectedSocket() client: Socket,
  ) {
    console.log(`Received offer from ${client.id} to ${data.userId}`);
    // userId๋กœ ํŠน์ • ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ offer ์ „์†ก
    this.server.to(data.userId).emit('offer', {
      offer: data.offer,
      senderId: client.id,
    });
  }

  @SubscribeMessage('answer')
  handleAnswer(
    @MessageBody()
    data: {
      roomId: string;
      userId: string;
      answer: RTCSessionDescriptionInit;
    },
    @ConnectedSocket() client: Socket,
  ) {
    console.log(`Received answer from ${client.id} to ${data.userId}`);
    // userId๋กœ ํŠน์ • ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ answer ์ „์†ก
    this.server.to(data.userId).emit('answer', {
      answer: data.answer,
      senderId: client.id,
    });
  }

  @SubscribeMessage('ice-candidate')
  handleIceCandidate(
    @MessageBody()
    data: {
      roomId: string;
      userId: string;
      candidate: RTCIceCandidateInit;
    },
    @ConnectedSocket() client: Socket,
  ) {
    console.log(`Received ICE candidate from ${client.id} to ${data.userId}`);
    // userId๋กœ ํŠน์ • ์‚ฌ์šฉ์ž์—๊ฒŒ๋งŒ candidate ์ „์†ก
    this.server.to(data.userId).emit('ice-candidate', {
      candidate: data.candidate,
      senderId: client.id,
    });
  }
}

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

์ฃผ์š” ํ•จ์ˆ˜

const canvasRef = useRef<HTMLCanvasElement>(null);
  const peerConnections = useRef<Map<string, RTCPeerConnection>>(new Map());
  const dataChannels = useRef<Map<string, RTCDataChannel>>(new Map());
  const pendingCandidates = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
  const socketRef = useRef<SocketType>();
  const [isDrawing, setIsDrawing] = useState(false);
  const myIdRef = useRef<string>('');
  • canvasRef: ์บ”๋ฒ„์Šค ์š”์†Œ์— ์ ‘๊ทผํ•˜์—ฌ ๊ทธ๋ฆผ์„ ๊ทธ๋ฆฌ๊ธฐ ์œ„ํ•œ ์ฐธ์กฐ
  • peerConnections ๋ฐ dataChannels: ๊ฐ ์‚ฌ์šฉ์ž์™€์˜ WebRTC ์—ฐ๊ฒฐ ๋ฐ ๋ฐ์ดํ„ฐ ์ฑ„๋„์„ ๊ด€๋ฆฌํ•˜์—ฌ ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ๋ฅผ ์ฃผ๊ณ ๋ฐ›์Œ
  • pendingCandidates: WebRTC ์—ฐ๊ฒฐ ์„ค์ • ์ „ ๋ฐ›์€ ICE ํ›„๋ณด๋ฅผ ์ž„์‹œ ์ €์žฅ
  • socketRef: ์„œ๋ฒ„์™€์˜ WebSocket ์—ฐ๊ฒฐ ๊ด€๋ฆฌ
  • isDrawing: ์‚ฌ์šฉ์ž๊ฐ€ ๊ทธ๋ฆผ์„ ๊ทธ๋ฆฌ๋Š” ์ƒํƒœ๋ฅผ ์ถ”์ 
  • myIdRef: ํ˜„์žฌ ํด๋ผ์ด์–ธํŠธ์˜ ๊ณ ์œ  ID๋ฅผ ์ €์žฅ
useEffect(() => {
    socketRef.current = io(SOCKET_SERVER);

    socketRef.current.on('connect', () => {
      console.log('Connected to server');
      myIdRef.current = socketRef.current?.id || '';
      socketRef.current?.emit('joinRoom', 'canvas-room');
    });

    // ๋ฐฉ์— ์ฐธ์—ฌํ–ˆ์„ ๋•Œ ํ˜„์žฌ ๋ฐฉ์— ์žˆ๋Š” ๋‹ค๋ฅธ ์œ ์ €๋“ค์˜ ๋ชฉ๋ก์„ ๋ฐ›์Œ
    socketRef.current.on('room-users', (users: string[]) => {
      console.log('Current users in room:', users);
      // ์ž์‹ ์„ ์ œ์™ธํ•œ ๊ฐ ์œ ์ €์™€ ์—ฐ๊ฒฐ ์„ค์ •
      users.forEach(userId => {
        if (userId !== myIdRef.current && !peerConnections.current.has(userId)) {
          createPeerConnection(userId, true);
        }
      });
    });

    // ์ƒˆ๋กœ์šด ์œ ์ €๊ฐ€ ๋“ค์–ด์™”์„ ๋•Œ
    socketRef.current.on('user-joined', (userId: string) => {
      console.log('New user joined:', userId);
      if (userId !== myIdRef.current && !peerConnections.current.has(userId)) {
        createPeerConnection(userId, false);
      }
    });

    socketRef.current.on('offer', async ({ offer, senderId }) => {
      console.log('Received offer from:', senderId);
      await handleOffer(offer, senderId);
    });

    socketRef.current.on('answer', async ({ answer, senderId }) => {
      console.log('Received answer from:', senderId);
      await handleAnswer(answer, senderId);
    });

    socketRef.current.on('ice-candidate', async ({ candidate, senderId }) => {
      await handleIceCandidate(candidate, senderId);
    });

    socketRef.current.on('user-left', (userId: string) => {
      console.log('User left:', userId);
      cleanupPeerConnection(userId);
    });

    return () => {
      peerConnections.current.forEach((_, userId) => {
        cleanupPeerConnection(userId);
      });
      socketRef.current?.disconnect();
    };
  }, []);
  • ์„œ๋ฒ„ ์—ฐ๊ฒฐ: ํด๋ผ์ด์–ธํŠธ๊ฐ€ Socket.io ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•˜๊ณ , ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ํ• ๋‹น๋ฐ›์€ ๊ณ ์œ  ID๋ฅผ myIdRef์— ์ €์žฅํ•œ๋‹ค.
  • ๋ฐฉ ์ฐธ์—ฌ ์š”์ฒญ: ํด๋ผ์ด์–ธํŠธ๊ฐ€ canvas-room์ด๋ผ๋Š” ๋ฐฉ์— ์ฐธ์—ฌํ•˜๊ธฐ ์œ„ํ•ด joinRoom ์ด๋ฒคํŠธ๋ฅผ ์„œ๋ฒ„๋กœ ๋ณด๋‚ธ๋‹ค.
  • ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ํ˜„์žฌ ๋ฐฉ(canvas-room)์— ์žˆ๋Š” ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž ID ๋ชฉ๋ก(users)์„ ๋ฐ›๋Š”๋‹ค.
  • ํ”ผ์–ด ์—ฐ๊ฒฐ ์ƒ์„ฑ: myIdRef(์ž์‹ ์˜ ID)๊ฐ€ ์•„๋‹Œ ์‚ฌ์šฉ์ž๋“ค์—๊ฒŒ๋งŒ ์ƒˆ๋กœ์šด ํ”ผ์–ด ์—ฐ๊ฒฐ์„ ์„ค์ •ํ•œ๋‹ค.
  • ์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž๊ฐ€ ์ฐธ์—ฌํ•  ๋•Œ ๊ธฐ์กด ์‚ฌ์šฉ์ž๋“ค์€ ์•Œ๋ฆผ์„ ๋ฐ›๋Š”๋‹ค.
  • ํ”ผ์–ด ์—ฐ๊ฒฐ ์ƒ์„ฑ: ๋ฐฉ์— ์ƒˆ๋กœ ๋“ค์–ด์˜จ ์‚ฌ์šฉ์ž๊ฐ€ ๊ธฐ์กด ์‚ฌ์šฉ์ž์™€์˜ ์—ฐ๊ฒฐ์„ ์‹œ์ž‘ํ•˜๋ฏ€๋กœ, ๊ธฐ์กด ์‚ฌ์šฉ์ž๋Š” ์—ฐ๊ฒฐ ์š”์ฒญ์„ ๊ธฐ๋‹ค๋ฆฐ๋‹ค.
  • Offer: ์—ฐ๊ฒฐ ์š”์ฒญ์„ ์ˆ˜์‹ ํ–ˆ์„ ๋•Œ handleOffer๋ฅผ ํ†ตํ•ด WebRTC offer๋ฅผ ์ฒ˜๋ฆฌํ•œ๋‹ค.
  • Answer: ์—ฐ๊ฒฐ ์š”์ฒญ์„ ์ˆ˜๋ฝํ•œ ์ƒ๋Œ€๋ฐฉ์ด ๋ณด๋‚ธ answer๋ฅผ handleAnswer๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค.
  • ICE ํ›„๋ณด: handleIceCandidate๋ฅผ ํ†ตํ•ด WebRTC ์—ฐ๊ฒฐ์— ํ•„์š”ํ•œ ๋„คํŠธ์›Œํฌ ๊ฒฝ๋กœ ์ •๋ณด๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค.
  • ์‚ฌ์šฉ์ž๊ฐ€ ๋ฐฉ์„ ๋– ๋‚  ๋•Œ user-left ์ด๋ฒคํŠธ๋ฅผ ์ˆ˜์‹ ํ•˜๊ณ , ์—ฐ๊ฒฐ๋œ ํ”ผ์–ด์˜ ๋ฆฌ์†Œ์Šค๋ฅผ ์ •๋ฆฌํ•œ๋‹ค.
  • cleanupPeerConnection: WebRTC ์—ฐ๊ฒฐ์„ ์ข…๋ฃŒํ•˜๊ณ , ๊ด€๋ จ ๋ฆฌ์†Œ์Šค๋ฅผ ํ•ด์ œํ•œ๋‹ค.
  • ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์–ธ๋งˆ์šดํŠธ๋  ๋•Œ ๋ชจ๋“  ํ”ผ์–ด ์—ฐ๊ฒฐ์„ ์ •๋ฆฌํ•˜๊ณ  ์„œ๋ฒ„ ์—ฐ๊ฒฐ์„ ์ข…๋ฃŒํ•œ๋‹ค.
const cleanupPeerConnection = (userId: string) => {
    const peerConnection = peerConnections.current.get(userId);
    if (peerConnection) {
      peerConnection.close();
      peerConnections.current.delete(userId);
    }
    
    const dataChannel = dataChannels.current.get(userId);
    if (dataChannel) {
      dataChannel.close();
      dataChannels.current.delete(userId);
    }
    
    pendingCandidates.current.delete(userId);
  };
  • ํ”ผ์–ด ์—ฐ๊ฒฐ ์ข…๋ฃŒ: peerConnections์—์„œ ํ•ด๋‹น ์‚ฌ์šฉ์ž ID์˜ ์—ฐ๊ฒฐ์„ ๋‹ซ๊ณ  ์‚ญ์ œํ•œ๋‹ค.
  • ๋ฐ์ดํ„ฐ ์ฑ„๋„ ์ข…๋ฃŒ: dataChannels์—์„œ ํ•ด๋‹น ID์˜ ๋ฐ์ดํ„ฐ ์ฑ„๋„์„ ๋‹ซ๊ณ  ์‚ญ์ œํ•œ๋‹ค.
  • ๋Œ€๊ธฐ ์ค‘์ธ ICE ํ›„๋ณด ์‚ญ์ œ: pendingCandidates์—์„œ ํ•ด๋‹น ID์˜ ํ•ญ๋ชฉ์„ ์‚ญ์ œํ•œ๋‹ค.
const createPeerConnection = (userId: string, initiator: boolean): RTCPeerConnection => {
    console.log(`Creating peer connection with ${userId} (initiator: ${initiator})`);
    const peerConnection = new RTCPeerConnection({
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
    });

    peerConnection.onicecandidate = (event) => {
      if (event.candidate) {
        console.log('Sending ICE candidate to:', userId);
        socketRef.current?.emit('ice-candidate', {
          candidate: event.candidate,
          roomId: 'canvas-room',
          userId
        });
      }
    };

    peerConnection.oniceconnectionstatechange = () => {
      console.log(`ICE Connection State with ${userId}: ${peerConnection.iceConnectionState}`);
    };

    peerConnection.ondatachannel = (event) => {
      console.log('Received data channel from:', userId);
      const dataChannel = event.channel;
      dataChannels.current.set(userId, dataChannel);
      setupDataChannelListeners(dataChannel, userId);
    };

    if (initiator) {
      console.log('Creating data channel as initiator for:', userId);
      const dataChannel = peerConnection.createDataChannel("canvas", {
        ordered: true
      });
      dataChannels.current.set(userId, dataChannel);
      setupDataChannelListeners(dataChannel, userId);
      
      // Initiator creates and sends offer
      createAndSendOffer(peerConnection, userId);
    }

    peerConnections.current.set(userId, peerConnection);
    return peerConnection;
  };
  • ํ”ผ์–ด ์—ฐ๊ฒฐ ์ƒ์„ฑ: ICE ์„œ๋ฒ„๋ฅผ ์„ค์ •ํ•˜์—ฌ RTCPeerConnection์„ ์ƒ์„ฑํ•˜๊ณ , peerConnections์— ์ €์žฅํ•œ๋‹ค.
  • ICE ํ›„๋ณด ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ: ์ƒˆ๋กœ์šด ICE ํ›„๋ณด๊ฐ€ ์ƒ์„ฑ๋˜๋ฉด ์„œ๋ฒ„์— ์ „์†กํ•˜์—ฌ P2P ์—ฐ๊ฒฐ์„ ์„ค์ •ํ•œ๋‹ค.
    • onicecandidate ์ด๋ฒคํŠธ๋Š” ICE candidate๊ฐ€ ์ƒ์„ฑ๋  ๋•Œ๋งˆ๋‹ค ํ˜ธ์ถœ๋œ๋‹ค.
  • ๋ฐ์ดํ„ฐ ์ฑ„๋„ ์ˆ˜์‹ : ์ƒ๋Œ€๋ฐฉ์ด ๋ฐ์ดํ„ฐ ์ฑ„๋„์„ ์ƒ์„ฑํ•œ ๊ฒฝ์šฐ ํ•ด๋‹น ์ฑ„๋„์„ ์ˆ˜์‹ ํ•˜๊ณ  ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ๋ฅผ ์„ค์ •ํ•œ๋‹ค.
    • ondatachannel์€ ์ƒ๋Œ€๋ฐฉ์ด Data Channel์„ ์ƒ์„ฑํ•ด ๋ณด๋ƒˆ์„ ๋•Œ ํ˜ธ์ถœ๋œ๋‹ค.
  • initiator: initiator์ผ ๊ฒฝ์šฐ ๋ฐ์ดํ„ฐ ์ฑ„๋„์„ ์ƒ์„ฑํ•˜๊ณ , ์˜คํผ๋ฅผ ๋งŒ๋“ค์–ด ์ƒ๋Œ€๋ฐฉ์—๊ฒŒ ์ „์†กํ•œ๋‹ค.
    • initiator๊ฐ€ true์ธ ๊ฒฝ์šฐ, ํ˜„์žฌ ์‚ฌ์šฉ์ž๊ฐ€ ์—ฐ๊ฒฐ์„ ์‹œ์ž‘ํ•˜๋Š” ์ชฝ์ด๋‹ค.
const createAndSendOffer = async (peerConnection: RTCPeerConnection, userId: string) => {
    try {
      const offer = await peerConnection.createOffer();
      console.log('Created offer for:', userId);
      await peerConnection.setLocalDescription(offer);
      console.log('Set local description (offer) for:', userId);
      
      socketRef.current?.emit('offer', {
        offer,
        roomId: 'canvas-room',
        userId
      });
    } catch (error) {
      console.error('Error creating offer:', error);
    }
  };
  • Offer ์ƒ์„ฑ: peerConnection์—์„œ Offer๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
  • Local Description ์„ค์ •: ์ƒ์„ฑํ•œ Offer๋ฅผ Local Description์œผ๋กœ ์„ค์ •ํ•˜์—ฌ ์—ฐ๊ฒฐ ์‹œ์ž‘ ์ค€๋น„๋ฅผ ์™„๋ฃŒํ•œ๋‹ค.
  • Offer ์ „์†ก: ์†Œ์ผ“์„ ํ†ตํ•ด ํŠน์ • userId์™€ ๋ฐฉ(roomId)์œผ๋กœ Offer๋ฅผ ์ „์†กํ•ฉ๋‹ˆ๋‹ค
const handleOffer = async (offer: RTCSessionDescriptionInit, userId: string) => {
    try {
      let peerConnection = peerConnections.current.get(userId);
      if (!peerConnection) {
        console.log('Creating new peer connection for offer from:', userId);
        peerConnection = createPeerConnection(userId, false);
      }

      if (peerConnection.signalingState !== "stable") {
        console.log('Ignoring offer in non-stable state');
        return;
      }

      await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
      console.log('Set remote description (offer) for:', userId);
      
      const answer = await peerConnection.createAnswer();
      await peerConnection.setLocalDescription(answer);
      console.log('Set local description (answer) for:', userId);
      
      socketRef.current?.emit('answer', {
        answer,
        roomId: 'canvas-room',
        userId
      });

      // Process any pending ICE candidates
      const candidates = pendingCandidates.current.get(userId) || [];
      for (const candidate of candidates) {
        await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
      }
      pendingCandidates.current.delete(userId);
    } catch (error) {
      console.error('Error handling offer:', error);
    }
  };
  • Peer Connection ํ™•์ธ: userId์— ๋Œ€ํ•œ Peer Connection์ด ์—†์œผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑํ•œ๋‹ค.
  • ์‹œ๊ทธ๋„๋ง ์ƒํƒœ ํ™•์ธ: stable ์ƒํƒœ์ผ ๋•Œ๋งŒ Offer๋ฅผ ์ฒ˜๋ฆฌํ•œ๋‹ค.
    • stable ์ƒํƒœ: WebRTC ์—ฐ๊ฒฐ์—์„œ ์•ˆ์ •๋œ ์ƒํƒœ๋ฅผ ์˜๋ฏธํ•˜๋ฉฐ, ์ƒˆ๋กœ์šด Offer๋ฅผ ์ฒ˜๋ฆฌํ•  ์ค€๋น„๊ฐ€ ๋˜์–ด ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋‚ธ๋‹ค.
  • Remote Description ์„ค์ •: ์ƒ๋Œ€๋ฐฉ์˜ Offer๋ฅผ ์ ์šฉํ•œ๋‹ค.
  • Answer ์ƒ์„ฑ ๋ฐ ์ „์†ก: Answer๋ฅผ ์ƒ์„ฑํ•˜์—ฌ Local Description์œผ๋กœ ์„ค์ •ํ•˜๊ณ , ์ƒ๋Œ€๋ฐฉ์—๊ฒŒ ์ „์†กํ•œ๋‹ค.
  • ICE ํ›„๋ณด ์ฒ˜๋ฆฌ: ๋Œ€๊ธฐ ์ค‘์ธ ICE ํ›„๋ณด๊ฐ€ ์žˆ์œผ๋ฉด Peer Connection์— ์ถ”๊ฐ€ํ•œ๋‹ค.
const handleAnswer = async (answer: RTCSessionDescriptionInit, userId: string) => {
    try {
      const peerConnection = peerConnections.current.get(userId);
      if (!peerConnection) {
        console.error('No peer connection found for user:', userId);
        return;
      }

      const currentState = peerConnection.signalingState;
      console.log(`Current signaling state for ${userId}:`, currentState);

      if (currentState === "stable") {
        console.log('Connection already stable with:', userId);
        return;
      }

      if (currentState !== "have-local-offer") {
        console.log(`Unexpected signaling state ${currentState} for answer from:`, userId);
        return;
      }

      await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
      console.log('Set remote description (answer) for:', userId);
      
      // Process any pending ICE candidates
      const candidates = pendingCandidates.current.get(userId) || [];
      for (const candidate of candidates) {
        await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
      }
      pendingCandidates.current.delete(userId);
    } catch (error) {
      console.error('Error handling answer:', error);
    }
  };
  • Peer Connection ํ™•์ธ
    • userId์— ํ•ด๋‹นํ•˜๋Š” Peer Connection์ด ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค.
    • ์—†์œผ๋ฉด ์˜ค๋ฅ˜ ์ถœ๋ ฅ ํ›„ ์ข…๋ฃŒํ•œ๋‹ค.
  • ์‹œ๊ทธ๋„๋ง ์ƒํƒœ ํ™•์ธ
    • ์—ฐ๊ฒฐ์ด ์ด๋ฏธ "stable" ์ƒํƒœ๋ผ๋ฉด, Answer๋ฅผ ๋ฌด์‹œํ•œ๋‹ค.
    • ์ƒํƒœ๊ฐ€ "have-local-offer"์ธ ๊ฒฝ์šฐ์—๋งŒ Answer๋ฅผ ์ฒ˜๋ฆฌํ•œ๋‹ค.
  • Remote Description ์„ค์ •: Answer๋ฅผ Remote Description์œผ๋กœ ์„ค์ •ํ•œ๋‹ค.
  • ๋Œ€๊ธฐ ์ค‘์ธ ICE ํ›„๋ณด ์ฒ˜๋ฆฌ: Answer ์ดํ›„์—๋Š” ๋Œ€๊ธฐ ์ค‘์ธ ICE ํ›„๋ณด๋“ค์„ Peer Connection์— ์ถ”๊ฐ€ํ•œ๋‹ค.
const handleIceCandidate = async (candidate: RTCIceCandidateInit, userId: string) => {
    try {
      const peerConnection = peerConnections.current.get(userId);
      if (!peerConnection) {
        console.log('No peer connection found for ICE candidate from:', userId);
        return;
      }

      if (peerConnection.remoteDescription && peerConnection.remoteDescription.type) {
        console.log('Adding ICE candidate for:', userId);
        await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
      } else {
        console.log('Queueing ICE candidate for:', userId);
        if (!pendingCandidates.current.has(userId)) {
          pendingCandidates.current.set(userId, []);
        }
        pendingCandidates.current.get(userId)?.push(candidate);
      }
    } catch (error) {
      console.error('Error handling ICE candidate:', error);
    }
  };
  • ํ”ผ์–ด ์—ฐ๊ฒฐ ํ™•์ธ: ํ”ผ์–ด ์—ฐ๊ฒฐ์ด ์—†์œผ๋ฉด ์ข…๋ฃŒํ•˜๊ณ , ์žˆ์œผ๋ฉด ๋‹ค์Œ ๋‹จ๊ณ„๋กœ ์ง„ํ–‰ํ•œ๋‹ค.
  • ICE ํ›„๋ณด ์ถ”๊ฐ€ ๋˜๋Š” ํ์— ์ €์žฅ: ์›๊ฒฉ ์„ค๋ช…์ด ์„ค์ •๋œ ๊ฒฝ์šฐ ๋ฐ”๋กœ ICE ํ›„๋ณด๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ํ›„๋ณด๋ฅผ ๋Œ€๊ธฐ ํ์— ์ €์žฅํ•œ๋‹ค.
  • ์—๋Ÿฌ ์ฒ˜๋ฆฌ: ICE ํ›„๋ณด ์ถ”๊ฐ€ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ์ฝ˜์†”์— ์˜ค๋ฅ˜๋ฅผ ์ถœ๋ ฅํ•œ๋‹ค.
const setupDataChannelListeners = (dataChannel: RTCDataChannel, userId: string) => {
    dataChannel.onopen = () => {
      console.log(`Data channel opened with ${userId}`);
    };
    
    dataChannel.onclose = () => {
      console.log(`Data channel closed with ${userId}`);
    };
    
    dataChannel.onerror = (error) => {
      console.error(`Data channel error with ${userId}:`, error);
    };
    
    dataChannel.onmessage = (event) => {
      const { type, x, y } = JSON.parse(event.data);
      handleRemoteDrawing(type, x, y);
    };
  };
  • onmessage ์ด๋ฒคํŠธ: ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๋กœ๋ถ€ํ„ฐ ๊ทธ๋ฆฌ๊ธฐ ๋ฐ์ดํ„ฐ ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ›์œผ๋ฉด JSON์œผ๋กœ ํŒŒ์‹ฑํ•˜์—ฌ type, x, y ๊ฐ’์„ ์ถ”์ถœํ•˜๊ณ , ์ด๋ฅผ handleRemoteDrawing ํ•จ์ˆ˜์— ์ „๋‹ฌํ•˜์—ฌ ์›๊ฒฉ ๊ทธ๋ฆฌ๊ธฐ ์ž‘์—…์„ ์ฒ˜๋ฆฌํ•œ๋‹ค.
const sendDrawingData = (type: string, x: number, y: number) => {
    const message = JSON.stringify({ type, x, y });
    dataChannels.current.forEach((dataChannel) => {
      if (dataChannel.readyState === "open") {
        dataChannel.send(message);
      }
    });
  };
  • type, x, y ๊ฐ’์„ JSON ํ˜•์‹์˜ ๋ฉ”์‹œ์ง€๋กœ ๋งŒ๋“ ๋‹ค.
  • ์—ด๋ ค ์žˆ๋Š” ๋ชจ๋“  ๋ฐ์ดํ„ฐ ์ฑ„๋„์— ์ด ๋ฉ”์‹œ์ง€๋ฅผ ์ „์†กํ•œ๋‹ค.

์ „์ฒด ์ฝ”๋“œ

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 peerConnections = useRef<Map<string, RTCPeerConnection>>(new Map());
  const dataChannels = useRef<Map<string, RTCDataChannel>>(new Map());
  const pendingCandidates = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
  const socketRef = useRef<SocketType>();
  const [isDrawing, setIsDrawing] = useState(false);
  const myIdRef = useRef<string>('');

  useEffect(() => {
    socketRef.current = io(SOCKET_SERVER);

    socketRef.current.on('connect', () => {
      console.log('Connected to server');
      myIdRef.current = socketRef.current?.id || '';
      socketRef.current?.emit('joinRoom', 'canvas-room');
    });

    // ๋ฐฉ์— ์ฐธ์—ฌํ–ˆ์„ ๋•Œ ํ˜„์žฌ ๋ฐฉ์— ์žˆ๋Š” ๋‹ค๋ฅธ ์œ ์ €๋“ค์˜ ๋ชฉ๋ก์„ ๋ฐ›์Œ
    socketRef.current.on('room-users', (users: string[]) => {
      console.log('Current users in room:', users);
      // ์ž์‹ ์„ ์ œ์™ธํ•œ ๊ฐ ์œ ์ €์™€ ์—ฐ๊ฒฐ ์„ค์ •
      users.forEach(userId => {
        if (userId !== myIdRef.current && !peerConnections.current.has(userId)) {
          createPeerConnection(userId, true);
        }
      });
    });

    // ์ƒˆ๋กœ์šด ์œ ์ €๊ฐ€ ๋“ค์–ด์™”์„ ๋•Œ
    socketRef.current.on('user-joined', (userId: string) => {
      console.log('New user joined:', userId);
      if (userId !== myIdRef.current && !peerConnections.current.has(userId)) {
        // ์ƒˆ๋กœ์šด ์œ ์ €๊ฐ€ ๋“ค์–ด์™”์„ ๋•Œ๋Š” ๊ธฐ์กด ์œ ์ €๋“ค์ด ์—ฐ๊ฒฐ์„ ์‹œ์ž‘ํ•˜์ง€ ์•Š์Œ
        // ์ƒˆ๋กœ์šด ์œ ์ €๊ฐ€ ๊ฐ ๊ธฐ์กด ์œ ์ €์—๊ฒŒ ์—ฐ๊ฒฐ์„ ์‹œ์ž‘ํ•  ๊ฒƒ์ž„
        createPeerConnection(userId, false);
      }
    });

    socketRef.current.on('offer', async ({ offer, senderId }) => {
      console.log('Received offer from:', senderId);
      await handleOffer(offer, senderId);
    });

    socketRef.current.on('answer', async ({ answer, senderId }) => {
      console.log('Received answer from:', senderId);
      await handleAnswer(answer, senderId);
    });

    socketRef.current.on('ice-candidate', async ({ candidate, senderId }) => {
      await handleIceCandidate(candidate, senderId);
    });

    socketRef.current.on('user-left', (userId: string) => {
      console.log('User left:', userId);
      cleanupPeerConnection(userId);
    });

    return () => {
      peerConnections.current.forEach((_, userId) => {
        cleanupPeerConnection(userId);
      });
      socketRef.current?.disconnect();
    };
  }, []);

  const cleanupPeerConnection = (userId: string) => {
    const peerConnection = peerConnections.current.get(userId);
    if (peerConnection) {
      peerConnection.close();
      peerConnections.current.delete(userId);
    }
    
    const dataChannel = dataChannels.current.get(userId);
    if (dataChannel) {
      dataChannel.close();
      dataChannels.current.delete(userId);
    }
    
    pendingCandidates.current.delete(userId);
  };

  const createPeerConnection = (userId: string, initiator: boolean): RTCPeerConnection => {
    console.log(`Creating peer connection with ${userId} (initiator: ${initiator})`);
    const peerConnection = new RTCPeerConnection({
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
    });

    peerConnection.onicecandidate = (event) => {
      if (event.candidate) {
        console.log('Sending ICE candidate to:', userId);
        socketRef.current?.emit('ice-candidate', {
          candidate: event.candidate,
          roomId: 'canvas-room',
          userId
        });
      }
    };

    peerConnection.oniceconnectionstatechange = () => {
      console.log(`ICE Connection State with ${userId}: ${peerConnection.iceConnectionState}`);
    };

    peerConnection.ondatachannel = (event) => {
      console.log('Received data channel from:', userId);
      const dataChannel = event.channel;
      dataChannels.current.set(userId, dataChannel);
      setupDataChannelListeners(dataChannel, userId);
    };

    if (initiator) {
      console.log('Creating data channel as initiator for:', userId);
      const dataChannel = peerConnection.createDataChannel("canvas", {
        ordered: true
      });
      dataChannels.current.set(userId, dataChannel);
      setupDataChannelListeners(dataChannel, userId);
      
      // Initiator creates and sends offer
      createAndSendOffer(peerConnection, userId);
    }

    peerConnections.current.set(userId, peerConnection);
    return peerConnection;
  };

  const createAndSendOffer = async (peerConnection: RTCPeerConnection, userId: string) => {
    try {
      const offer = await peerConnection.createOffer();
      console.log('Created offer for:', userId);
      await peerConnection.setLocalDescription(offer);
      console.log('Set local description (offer) for:', userId);
      
      socketRef.current?.emit('offer', {
        offer,
        roomId: 'canvas-room',
        userId
      });
    } catch (error) {
      console.error('Error creating offer:', error);
    }
  };

  const handleOffer = async (offer: RTCSessionDescriptionInit, userId: string) => {
    try {
      let peerConnection = peerConnections.current.get(userId);
      if (!peerConnection) {
        console.log('Creating new peer connection for offer from:', userId);
        peerConnection = createPeerConnection(userId, false);
      }

      if (peerConnection.signalingState !== "stable") {
        console.log('Ignoring offer in non-stable state');
        return;
      }

      await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
      console.log('Set remote description (offer) for:', userId);
      
      const answer = await peerConnection.createAnswer();
      await peerConnection.setLocalDescription(answer);
      console.log('Set local description (answer) for:', userId);
      
      socketRef.current?.emit('answer', {
        answer,
        roomId: 'canvas-room',
        userId
      });

      // Process any pending ICE candidates
      const candidates = pendingCandidates.current.get(userId) || [];
      for (const candidate of candidates) {
        await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
      }
      pendingCandidates.current.delete(userId);
    } catch (error) {
      console.error('Error handling offer:', error);
    }
  };

  const handleAnswer = async (answer: RTCSessionDescriptionInit, userId: string) => {
    try {
      const peerConnection = peerConnections.current.get(userId);
      if (!peerConnection) {
        console.error('No peer connection found for user:', userId);
        return;
      }

      const currentState = peerConnection.signalingState;
      console.log(`Current signaling state for ${userId}:`, currentState);

      if (currentState === "stable") {
        console.log('Connection already stable with:', userId);
        return;
      }

      if (currentState !== "have-local-offer") {
        console.log(`Unexpected signaling state ${currentState} for answer from:`, userId);
        return;
      }

      await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
      console.log('Set remote description (answer) for:', userId);
      
      // Process any pending ICE candidates
      const candidates = pendingCandidates.current.get(userId) || [];
      for (const candidate of candidates) {
        await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
      }
      pendingCandidates.current.delete(userId);
    } catch (error) {
      console.error('Error handling answer:', error);
    }
  };

  const handleIceCandidate = async (candidate: RTCIceCandidateInit, userId: string) => {
    try {
      const peerConnection = peerConnections.current.get(userId);
      if (!peerConnection) {
        console.log('No peer connection found for ICE candidate from:', userId);
        return;
      }

      if (peerConnection.remoteDescription && peerConnection.remoteDescription.type) {
        console.log('Adding ICE candidate for:', userId);
        await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
      } else {
        console.log('Queueing ICE candidate for:', userId);
        if (!pendingCandidates.current.has(userId)) {
          pendingCandidates.current.set(userId, []);
        }
        pendingCandidates.current.get(userId)?.push(candidate);
      }
    } catch (error) {
      console.error('Error handling ICE candidate:', error);
    }
  };

  const setupDataChannelListeners = (dataChannel: RTCDataChannel, userId: string) => {
    dataChannel.onopen = () => {
      console.log(`Data channel opened with ${userId}`);
    };
    
    dataChannel.onclose = () => {
      console.log(`Data channel closed with ${userId}`);
    };
    
    dataChannel.onerror = (error) => {
      console.error(`Data channel error with ${userId}:`, error);
    };
    
    dataChannel.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');
    if (!context) return;
    
    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');
    if (!context) return;
    
    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 });
    dataChannels.current.forEach((dataChannel) => {
      if (dataChannel.readyState === "open") {
        dataChannel.send(message);
      }
    });
  };

  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}
      />
    </div>
  );
}

export default App;

์ „์ฒด ํ๋ฆ„

  1. ๊ฐ ์‚ฌ์šฉ์ž ๋ฐฉ ์ž…์žฅ
    • ๊ฐ ์‚ฌ์šฉ์ž๋Š” useEffect์—์„œ socketRef.current?.emit('joinRoom', 'canvas-room')์„ ํ†ตํ•ด "canvas-room"์— ์ฐธ์—ฌํ•œ๋‹ค.
    • socketRef.current๋Š” ์„œ๋ฒ„์— ์—ฐ๊ฒฐ๋œ ์†Œ์ผ“ ํด๋ผ์ด์–ธํŠธ๋กœ, joinRoom ์ด๋ฒคํŠธ๋ฅผ ์„œ๋ฒ„์— ๋ณด๋‚ธ๋‹ค.
  2. ํ˜„์žฌ ๋ฐฉ์— ์žˆ๋Š” ์œ ์ €๋“ค ์ •๋ณด ์ˆ˜์‹ 
    • ๋ฐฉ์— ์ž…์žฅํ•œ ํ›„, ๊ฐ ์‚ฌ์šฉ์ž๋Š” "room-users" ์ด๋ฒคํŠธ๋กœ ํ˜„์žฌ ๋ฐฉ์— ์žˆ๋Š” ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž์˜ ID ๋ชฉ๋ก์„ ๋ฐ›๋Š”๋‹ค.
    • ๊ฐ ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์„ ์ œ์™ธํ•œ ์‚ฌ์šฉ์ž๋“ค๊ณผ์˜ ์—ฐ๊ฒฐ์„ ์œ„ํ•ด createPeerConnection์„ ํ˜ธ์ถœํ•˜์—ฌ peerConnection์„ ์ƒ์„ฑํ•œ๋‹ค.
  3. ์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž ์ž…์žฅ ์ฒ˜๋ฆฌ
    • ๊ธฐ์กด ์‚ฌ์šฉ์ž๋Š” "user-joined" ์ด๋ฒคํŠธ๋ฅผ ํ†ตํ•ด ์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž๊ฐ€ ๋ฐฉ์— ๋“ค์–ด์™”์Œ์„ ๊ฐ์ง€ํ•œ๋‹ค.
    • ์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž๋Š” createPeerConnection ํ•จ์ˆ˜๋กœ ๊ธฐ์กด ์‚ฌ์šฉ์ž๋“ค๊ณผ ์—ฐ๊ฒฐ ์„ค์ •์„ ์‹œ์ž‘ํ•œ๋‹ค.
  4. Offer-Answer ๊ตํ™˜ (์‹œ๊ทธ๋„๋ง ๊ณผ์ •)
    • ์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž๊ฐ€ ๊ธฐ์กด ์‚ฌ์šฉ์ž์—๊ฒŒ createAndSendOffer๋ฅผ ํ†ตํ•ด Offer๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์ „์†กํ•œ๋‹ค.
    • ๊ธฐ์กด ์‚ฌ์šฉ์ž๋Š” "offer" ์ด๋ฒคํŠธ๋กœ Offer๋ฅผ ๋ฐ›์œผ๋ฉฐ, handleOffer๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ๋ฐ›์€ Offer์— ๋Œ€ํ•œ Answer๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์ƒ๋Œ€๋ฐฉ์—๊ฒŒ ์ „์†กํ•œ๋‹ค.
    • ์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž๋Š” "answer" ์ด๋ฒคํŠธ๋ฅผ ํ†ตํ•ด ๊ธฐ์กด ์‚ฌ์šฉ์ž์˜ Answer๋ฅผ ๋ฐ›๊ณ  handleAnswer ํ•จ์ˆ˜๋กœ ์ด๋ฅผ ์ฒ˜๋ฆฌํ•˜์—ฌ WebRTC ์—ฐ๊ฒฐ์„ ์™„๋ฃŒํ•œ๋‹ค.
  5. ICE Candidate ๊ตํ™˜
    • ๋„คํŠธ์›Œํฌ ๊ฒฝ๋กœ ์„ค์ •์„ ์œ„ํ•ด ICE Candidate๊ฐ€ ๊ตํ™˜๋œ๋‹ค.
    • ๊ฐ Peer Connection์€ onicecandidate ์ด๋ฒคํŠธ๋ฅผ ํ†ตํ•ด ์ž์‹ ์˜ ICE Candidate๋ฅผ ์ƒ์„ฑํ•˜์—ฌ ์ƒ๋Œ€๋ฐฉ์—๊ฒŒ ์ „์†กํ•œ๋‹ค.
    • ๊ฐ ์‚ฌ์šฉ์ž๋Š” "ice-candidate" ์ด๋ฒคํŠธ๋ฅผ ํ†ตํ•ด ๋ฐ›์€ ICE Candidate๋ฅผ handleIceCandidate ํ•จ์ˆ˜๋กœ Peer Connection์— ์ถ”๊ฐ€ํ•œ๋‹ค.
  6. Data Channel ์„ค์ • ๋ฐ ๋ฉ”์‹œ์ง€ ์ „์†ก
    • ์—ฐ๊ฒฐ์ด ์™„๋ฃŒ๋˜๋ฉด Data Channel์„ ํ†ตํ•ด ๊ทธ๋ฆผ ๋ฐ์ดํ„ฐ๊ฐ€ ์ „์†ก๋œ๋‹ค.
    • ์‚ฌ์šฉ์ž๊ฐ€ ์บ”๋ฒ„์Šค์—์„œ ๊ทธ๋ฆผ์„ ๊ทธ๋ฆฌ๋ฉด startDrawing, draw, stopDrawing ํ•จ์ˆ˜๊ฐ€ ํ˜ธ์ถœ๋˜๋ฉฐ, sendDrawingData๋กœ ๊ฐ ์œ„์น˜ ์ขŒํ‘œ๊ฐ€ Data Channel์„ ํ†ตํ•ด ์ „๋‹ฌ๋œ๋‹ค.
    • ๋ฐ์ดํ„ฐ๊ฐ€ ์ˆ˜์‹ ๋˜๋ฉด onmessage ์ด๋ฒคํŠธ๊ฐ€ ํŠธ๋ฆฌ๊ฑฐ๋˜์–ด handleRemoteDrawing์ด ํ˜ธ์ถœ๋˜๊ณ , ์บ”๋ฒ„์Šค์— ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋ฐ˜์˜๋œ๋‹ค.

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

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

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

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

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

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

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

Clone this wiki locally