기존 상황

Game Socket 에는 다양한 역할들이 들어가 있었다.

우선 roomJoin을 클라이언트에서 서버로 보내게 되면 소켓을 통해 서버에서 roonJoin 응답(?)이 오게 된다. 이 응답에는 PlayerID가 들어가 있고 해당 정보를 바탕으로 클라이언트에서는 로컬스토리지에 PlayerID를 저장하여 이용하게 된다.

이런 roomJoin 외에도 재연결 로직, 게임 시작, 호스트 정보, 게임방 설정, 방 상태 등등 많은 정보들이 Game Socket을 통해서 전달되고 Game Socket Handler, Game Socket Hook 을 통해서 이벤트가 등록되고 사용되었다.

개선 사항

메시지 전송/ 수신의 로직만 존재하던 Chat Socket에 비해 많은 이벤트 전송/수신이 필요하기 때문에 Game Socket에 해당 하는 Domain Manager는 복잡할 것으로 예상된다. 하지만 전체적인 Shared Worker 구조를 이전에 그렸기 때문에 이를 기반으로 구현을 하면 다음과 같다.

1차 구현

import { SocketNamespace } from '@/stores/socket/socket.config';
import {
  CheckAnswerRequest,
  CRDTSyncMessage,
  JoinRoomRequest,
  JoinRoomResponse,
  PlayerLeftResponse,
  ReconnectRequest,
  RoomEndResponse,
  RoundEndResponse,
  RoundStartResponse,
  TimerSyncResponse,
  UpdateSettingsRequest,
  UpdateSettingsResponse,
} from '@troublepainter/core';

type GameEventMap = {
  joinedRoom: (response: JoinRoomResponse) => void;
  playerJoined: (response: JoinRoomResponse) => void;
  playerLeft: (response: PlayerLeftResponse) => void;
  settingsUpdated: (response: UpdateSettingsResponse) => void;
  drawingGroupRoundStarted: (response: RoundStartResponse) => void;
  guesserRoundStarted: (response: RoundStartResponse) => void;
  timerSync: (response: TimerSyncResponse) => void;
  drawingTimeEnded: () => void;
  roundEnded: (response: RoundEndResponse) => void;
  gameEnded: (response: RoomEndResponse) => void;
};

class GameWorkerManager {
  private static instance: GameWorkerManager;
  private worker: SharedWorker | null = null;
  private connected: boolean = false;
  private eventHandlers: Map<keyof GameEventMap, Set<Function>> = new Map();
  private constructor() {
    console.log('Initializing ChatWorkerManager...');
    try {
      this.worker = new SharedWorker(new URL('./socketWorker.ts', import.meta.url), {
        type: 'module',
        name: 'socket-worker',
      });

      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;

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

      switch (type) {
        case 'init':
          this.connected = e.data.connected[SocketNamespace.GAME] || false;
          break;
        case 'connection_update':
          console.log('Game connection status:', connected);
          this.connected = connected;
          break;
        case 'socket_event':
          const handlers = this.eventHandlers.get(event as keyof GameEventMap);
          if (handlers) {
            handlers.forEach((handler) => handler(...args));
          }
          break;
        case 'socket_error':
          console.error('Game socket error:', e.data.error);
          break;
      }
    };
  }

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

  // 이벤트 리스너 등록 메서드
  public on<T extends keyof GameEventMap>(event: T, handler: GameEventMap[T]) {
    if (!this.eventHandlers.has(event)) {
      this.eventHandlers.set(event, new Set());
    }
    this.eventHandlers.get(event)!.add(handler);
  }

  // 이벤트 리스너 제거 메서드
  public off<T extends keyof GameEventMap>(event: T, handler: GameEventMap[T]) {
    const handlers = this.eventHandlers.get(event);
    if (handlers) {
      handlers.delete(handler);
    }
  }

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

    console.log('Connecting to game socket');

    this.worker.port.postMessage({
      type: 'connect',
      payload: {
        namespace: SocketNamespace.GAME,
      },
    });
  }

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

  // Event handling
  private emitEvent(event: string, ...args: any[]) {
    if (!this.worker) {
      console.error('Worker not initialized');
      return;
    }

    if (!this.connected) {
      console.warn('Game socket is not connected');
      return;
    }

    this.worker.port.postMessage({
      type: 'emit',
      payload: {
        namespace: SocketNamespace.GAME,
        event,
        args,
      },
    });
  }

  public joinRoom(request: JoinRoomRequest) {
    this.emitEvent('joinRoom', request);
  }

  public reconnectRoom(request: ReconnectRequest) {
    this.emitEvent('reconnect', request);
  }

  public updateSetting(request: UpdateSettingsRequest) {
    this.emitEvent('updateSettings', request);
  }

  public gameStart() {
    this.emitEvent('gameStart');
  }

  public checkAnswer(request: CheckAnswerRequest) {
    this.emitEvent('checkAnswer', request);
  }

  public submittedDrawing(drawing: CRDTSyncMessage) {
    this.emitEvent('submittedDrawing', { drawing });
  }
}

export const gameWorkerManager = GameWorkerManager.getInstance();
export const onGameEvent = <T extends keyof GameEventMap>(event: T, handler: GameEventMap[T]) =>
  gameWorkerManager.on(event, handler);
export const offGameEvent = <T extends keyof GameEventMap>(event: T, handler: GameEventMap[T]) =>
  gameWorkerManager.off(event, handler);
export const gameSocketConnect = () => gameWorkerManager.connect();
export const gameSocketDisconnect = () => gameWorkerManager.disconnect();
export const gameWorkerJoinRoom = (request: JoinRoomRequest) => gameWorkerManager.joinRoom(request);
export const gameWorkerReconnectRoom = (request: ReconnectRequest) => gameWorkerManager.reconnectRoom(request);
export const gameWorkerUpdateSetting = (request: UpdateSettingsRequest) => gameWorkerManager.updateSetting(request);
export const gameWorkerGameStart = () => gameWorkerManager.gameStart();
export const gameWorkerCheckAnswer = (request: CheckAnswerRequest) => gameWorkerManager.checkAnswer(request);
export const gameWorkerSubmitDrawing = (drawing: CRDTSyncMessage) => gameWorkerManager.submittedDrawing(drawing);

문제 1 : 게임 소켓 연결 시간.

이제 해당 로직을 HookHandler 에 적용시켜 사용했는데 Game Socket에 연결하는 시간이 걸리는 것으로 확인 되었다.

연결을 요청하고 바로 joinRoom 이벤트를 요청하면 소켓 연결이 되어있지 않기 때문에 이벤트를 보낼 수 없다고 나오는 것을 확인했다. 따라서 joinRoom 의 로직에 setTimeout 을 이용해 100ms 의 딜레이를 주었다.

문제 2: 플레이어가 2명씩 생기는 문제.

앞서 해결한 내용과 관련되어 StrictMode 에 의해 코드가 2번씩 실행되기 때문에 한가지 문제가 발생하게 된다. 문제 상황에 대해 간략하게 적으면 다음과 같다.