기존 구조 파악

기존 구조는 3개의 소켓(Chat, Game, Draw)을 Socket Config - Socket Store - Domain Store - Handler , Hook 으로 나누어서 사용하고 있었다.

개선 사항

이런 상황에서 Shared Worker를 이용하기 위해서는 Socket Store를 수정할 필요가 있었다. 우리가 원하는 것은 연결하는 로직을 Shared Worker를 통해 관리하는 것을 원하기 때문이다.

따라서 우선 아래와 같이 Shared Worker를 만들어 Socket Store의 기능을 완전히 대체할 수 있도록 만들었다.

Shared Worker

import { io, Socket } from 'socket.io-client';
import { SocketError } from '@troublepainter/core';
import { SocketNamespace, SocketAuth, SOCKET_CONFIG, SocketType } from '@/stores/socket/socket.config';
import type { GameSocket, DrawingSocket, ChatSocket } from '@/types/socket.types';

// type NamespaceSocketMap = {
//   game: GameSocket;
//   drawing: DrawingSocket;
//   chat: ChatSocket;
// };

class SocketManager {
  private sockets: Map<SocketNamespace, Socket>;
  private ports: Set<MessagePort>;
  private connected: Record<SocketNamespace, boolean>;

  constructor() {
    this.sockets = new Map();
    this.ports = new Set();
    this.connected = {
      game: false,
      drawing: false,
      chat: false,
    };
  }

  private createSocket<T extends SocketType>(namespace: SocketNamespace, auth?: SocketAuth): T {
    const options = auth ? { ...SOCKET_CONFIG.BASE_OPTIONS, auth } : SOCKET_CONFIG.BASE_OPTIONS;
    // console.log(`${SOCKET_CONFIG.URL}${SOCKET_CONFIG.PATHS[namespace]}`);
    return io(`${SOCKET_CONFIG.URL}${SOCKET_CONFIG.PATHS[namespace]}`, options) as T;
  }

  private broadcast(message: any) {
    this.ports.forEach((port) => {
      port.postMessage(message);
    });
  }

  private updateConnectionState(namespace: SocketNamespace, isConnected: boolean) {
    this.connected[namespace] = isConnected;
    this.broadcast({
      type: 'connection_update',
      namespace,
      connected: isConnected,
    });
  }

  private setupSocketEvents(socket: Socket, namespace: SocketNamespace) {
    socket.on('connect', () => {
      this.updateConnectionState(namespace, true);
    });

    socket.on('disconnect', () => {
      this.updateConnectionState(namespace, false);
    });

    socket.on('error', (error: SocketError) => {
      this.broadcast({
        type: 'socket_error',
        namespace,
        error,
      });
    });

    socket.onAny((eventName, ...args) => {
      this.broadcast({
        type: 'socket_event',
        namespace,
        event: eventName,
        args,
      });
    });
  }

  connect(namespace: SocketNamespace, auth?: SocketAuth) {
    const currentSocket = this.sockets.get(namespace);

    if (currentSocket?.connected) return;

    if (currentSocket) {
      currentSocket.disconnect();
      this.sockets.delete(namespace);
    }

    let socket: Socket;

    switch (namespace) {
      case SocketNamespace.GAME:
        socket = this.createSocket<GameSocket>(namespace);
        break;
      case SocketNamespace.DRAWING:
        socket = this.createSocket<DrawingSocket>(namespace, auth);
        break;
      case SocketNamespace.CHAT:
        socket = this.createSocket<ChatSocket>(namespace, auth);
        break;
      default:
        throw new Error(`Unknown namespace: ${namespace}`);
    }

    this.setupSocketEvents(socket, namespace);
    socket.connect();
    this.sockets.set(namespace, socket);
  }

  emit(namespace: SocketNamespace, event: string, ...args: any[]) {
    const socket = this.sockets.get(namespace);
    if (!socket) {
      console.warn(`No socket found for namespace: ${namespace}`);
      return;
    }

    if (!socket.connected) {
      console.warn(`Socket for namespace ${namespace} is not connected`);
      return;
    }
    socket.emit(event, ...args);
  }

  disconnect(namespace: SocketNamespace) {
    const socket = this.sockets.get(namespace);
    if (!socket) return;

    socket.disconnect();
    this.sockets.delete(namespace);
    this.updateConnectionState(namespace, false);
  }

  disconnectAll() {
    Array.from(this.sockets.keys()).forEach((namespace) => {
      this.disconnect(namespace as SocketNamespace);
    });
  }

  addPort(port: MessagePort) {
    this.ports.add(port);
    port.postMessage({
      type: 'init',
      connected: this.connected,
    });
  }

  removePort(port: MessagePort) {
    this.ports.delete(port);
    if (this.ports.size === 0) {
      this.disconnectAll();
    }
  }
}

const manager = new SocketManager();

addEventListener('connect', (e: Event) => {
  const connectEvent = e as MessageEvent;
  const port = connectEvent.ports[0];
  manager.addPort(port);

  port.onmessage = (e: MessageEvent) => {
    const { type, payload } = e.data;
    console.log('shared worker에서 받은 값', type, payload);
    switch (type) {
      case 'connect':
        manager.connect(payload.namespace, payload.auth);
        break;
      case 'disconnect':
        manager.disconnect(payload.namespace);
        break;
      case 'disconnect_all':
        manager.disconnectAll();
        break;
      case 'emit':
        manager.emit(payload.namespace, payload.event, ...payload.args);
        break;
      default:
        console.warn('Unknown message type:', type);
    }
  };

  port.start();
  port.onmessageerror = () => manager.removePort(port);
});

Shared Worker를 위와 같이 만들어도 바로 사용할 수 없다. 그 이유는 Shared Worker는 postMessageonmessage 를 통해서만 데이터를 주고 받기 때문에 소켓에서 사용되는 이벤트들마다 필터링이 필요하기 때문이다.

따라서 각각의 소켓(Chat, Game, Draw)마다 Manager를 두어 이런 필터링 작업을 하게 만들었고 Shared Worker와 기존 코드(Store, Hook, Handler)의 연결다리 역할을 하게 만들었다.

Chat Worker

// chatWorker.ts
import { SocketNamespace } from '@/stores/socket/socket.config';

interface ChatAuth {
  roomId: string;
  playerId: string;
}

class ChatWorkerManager {
  private static instance: ChatWorkerManager;
  private worker: SharedWorker | null = null;
  private messageHandlers: Set<(e: MessageEvent) => void> = new Set();
  private connected: boolean = false;
  private auth: ChatAuth | null = null;

  private constructor() {
    console.log('Initializing ChatWorkerManager...');
    try {
      this.worker = new SharedWorker(new URL('./socketWorker.ts', import.meta.url), {
        type: 'module',
        name: 'socket-worker',
      });

      this.messageHandlers = new Set();
      this.setupWorkerListeners();
      this.worker.port.start();
    } catch (error) {
      console.error('Error initializing ChatWorkerManager:', error);
    }
  }

  private setupWorkerListeners() {
    if (!this.worker) return;
    this.worker.port.onmessage = (e) => {
      const { type, namespace, connected, event, args } = e.data;

      // CHAT 네임스페이스 이벤트만 처리
      if (type === 'connection_update' || type === 'socket_event' || type === 'socket_error') {
        if (namespace !== SocketNamespace.CHAT) return;
      }

      switch (type) {
        case 'init':
          this.connected = e.data.connected[SocketNamespace.CHAT] || false;
          break;
        case 'connection_update':
          console.log('Chat connection status:', connected);
          this.connected = connected;
          break;
        case 'socket_event':
          if (event === 'messageReceived') {
            console.log('Chat message received:', args[0]);
          }
          break;
        case 'socket_error':
          console.error('Chat socket error:', e.data.error);
          break;
      }

      this.messageHandlers.forEach((handler) => handler(e));
    };
  }

  public static getInstance(): ChatWorkerManager {
    if (!ChatWorkerManager.instance) {
      ChatWorkerManager.instance = new ChatWorkerManager();
    }
    return ChatWorkerManager.instance;
  }

  public connect(auth: ChatAuth) {
    if (!this.worker) {
      console.error('Worker not initialized');
      return;
    }

    this.auth = auth;

    this.worker.port.postMessage({
      type: 'connect',
      payload: {
        namespace: SocketNamespace.CHAT,
        auth: {
          roomId: auth.roomId,
          playerId: auth.playerId,
        },
      },
    });
  }

  public sendChatMessage(message: string) {
    if (!this.worker) {
      console.error('Worker not initialized');
      return;
    }

    if (!this.connected || !this.auth) {
      console.warn('Chat is not connected or auth is missing');
      return;
    }

    console.log('Sending chat message:', message);
    this.worker.port.postMessage({
      type: 'emit',
      payload: {
        namespace: SocketNamespace.CHAT,
        event: 'sendMessage',
        args: [{ message: message.trim() }],
      },
    });
  }

  public disconnect() {
    if (!this.worker) return;
    console.log('Disconnecting from chat...');
    this.worker.port.postMessage({
      type: 'disconnect',
      payload: {
        namespace: SocketNamespace.CHAT,
      },
    });
    this.connected = false;
    this.auth = null;
  }

  public addMessageHandler(handler: (e: MessageEvent) => void) {
    this.messageHandlers.add(handler);
  }

  public removeMessageHandler(handler: (e: MessageEvent) => void) {
    this.messageHandlers.delete(handler);
  }
}

export const chatWorkerManager = ChatWorkerManager.getInstance();

export const connectChat = (auth: ChatAuth) => chatWorkerManager.connect(auth);
export const sendChatMessage = (message: string) => chatWorkerManager.sendChatMessage(message);
export const addMessageHandler = (handler: (e: MessageEvent) => void) => chatWorkerManager.addMessageHandler(handler);
export const removeMessageHandler = (handler: (e: MessageEvent) => void) =>
  chatWorkerManager.removeMessageHandler(handler);
export const disconnectChat = () => chatWorkerManager.disconnect();