[ 2024.11.25 TIL ] 최종 프로젝트 (대기방/로비)

박지영·2024년 11월 25일
0

Today I Learned

목록 보기
85/88

이번 최종 프로젝트에서 인프라를 제외한 처음 맡게된 기능은 로비 서버와 대기방 서버다.

redis I/O를 제외하고는 개발이 끝난 상태라서 먼저 정리해보려고 한다.

로비 서버

  • 로비와 관련된 기능을 담당할 서버
  1. 로비 입장, 퇴장

  2. 로비에 있는 유저 목록, 유저의 상세 정보

로비 클래스

  • 사용할 객체 타입 정의
/**
 * @typedef {Object} UserData
 * @property {number} userId
 * @property {string} nickname
 */

/**
 * @typedef {Object} LobbyResponse
 * @property {boolean} success
 * @property {Object} data
 * @property {number} failCode
 */
  • 로비에 입장한 유저들의 목록을 관리할 Map 객체
/** @type {Map<number, UserData>} userId -> userData */
this.users = new Map();
  • 로비 입장
/**
   * 유저 로비 입장
   * @param {UserData} userData - 입장할 유저의 데이터
   * @returns {LobbyResponse}
   */
  joinUser(userData) {
    if (this.users.has(userData.userId)) {
      return { success: false, data: {}, failCode: 1 };
    }

    this.users.set(userData.userId, userData);
    return { success: true, data: {}, failCode: 0 };
  }
  • 로비 퇴장
/**
   * 유저 로비 퇴장
   * @param {string} userId - 퇴장할 유저 ID
   * @returns {LobbyResponse}
   */
  leaveUser(userId) {
    if (!this.users.has(userId)) {
      return { success: false, data: {}, failCode: 1 };
    }

    this.users.delete(userId);
    return { success: true, data: {}, failCode: 0 };
  }
  • 로비에 접속 중인 유저 목록
/**
   * 현재 로비에 접속 중인 유저 목록
   * @returns {UserData[]} - 접속 중인 유저들의 정보들
   */
  getUserList() {
    return {
      success: true,
      data: { userList: Array.from(this.users.values()) },
      failCode: 0,
    };
  }
  • 유저의 상세 정보
/**
   * 유저 상세 정보 조회
   * @param {string} targetUserId - 조회할 유저 ID
   * @returns {LobbyResponse}
   */
  getUserDetail(targetUserId) {
    const user = this.users.get(targetUserId);
    if (!user) {
      return { success: false, data: {}, failCode: 1 };
    }

    // TODO: 레벨이 추가되거나 없어지게되면 수정 필요
    return {
      success: true,
      data: {
        userDetail: {
          userId: user.userId,
          nickname: user.nickname,
          level: '1', // 임시
        },
      },
      failCode: 0,
    };
  }

로비 매니저

로비 매니저는 싱글톤 형태로 여러 로비 인스턴스를 가지진 않고 하나의 로비만 관리하게 했다.

서버 확장이 필요한 경우 스케일 아웃을 고려하고 있다.

  • 로비 입장
/**
   * 유저 로비 입장
   * @param {UserData} userData - 입장할 유저의 데이터
   * @returns {LobbyResponse}
   */
  joinUser(userData) {
    return this.lobby.joinUser(userData);
  }
  • 로비 퇴장
/**
   * 유저 로비 퇴장
   * @param {string} userId - 퇴장할 유저 ID
   * @returns {LobbyResponse}
   */
  leaveUser(userId) {
    return this.lobby.leaveUser(userId);
  }
  • 로비에 접속한 유저 목록
/**
   * 현재 로비에 접속 중인 유저 목록
   * @returns {UserData[]} - 접속 중인 유저들의 정보들
   */
  getUserList() {
    return this.lobby.getUserList();
  }
  • 유저의 상세 정보
/**
   * 유저 상세 정보 조회
   * @param {string} targetUserId - 조회할 유저 ID
   * @returns {LobbyResponse}
   */
  getUserDetail(targetUserId) {
    return this.lobby.getUserDetail(targetUserId);
  }

핸들러 로직

  • try catch로 원인 모를 실패 케이스에도 실패 케이스의 response를 보내도록 했다.

  • 로비 입장

export const lobbyJoinRequestHandler = ({ socket, messageType, payload }) => {
  try {
    const { userData } = payload;
    const result = lobbyManager.joinUser(userData);

    const packet = createResponse(result, MESSAGE_TYPE.LOBBY_JOIN_RESPONSE);

    socket.write(packet);
  } catch (error) {
    console.error('[ lobbyJoinRequestHandler ] ====>  error ', error.message, error);
    socket.write(
      serialize(
        MESSAGE_TYPE.LOBBY_JOIN_RESPONSE,
        {
          success: false,
          failCode: 1,
        },
        0,
        getPayloadNameByMessageType,
      ),
    );
  }
};
  • 로비 퇴장
export const lobbyLeaveRequestHandler = ({ socket, messageType, payload }) => {
  try {
    const { userId } = payload;
    const result = lobbyManager.leaveUser(userId);

    const packet = createResponse(result, MESSAGE_TYPE.LOBBY_LEAVE_RESPONSE);

    socket.write(packet);
  } catch (error) {
    console.error('[ lobbyLeaveRequestHandler ] ====>  error ', error.message, error);
    socket.write(
      serialize(
        MESSAGE_TYPE.LOBBY_LEAVE_RESPONSE,
        {
          success: false,
          failCode: 1,
        },
        0,
        getPayloadNameByMessageType,
      ),
    );
  }
};
  • 로비 유저 목록
export const lobbyUserListRequestHandler = ({ socket, messageType, payload }) => {
  try {
    const result = lobbyManager.getUserList();

    const packet = createResponse(result, MESSAGE_TYPE.LOBBY_USER_LIST_RESPONSE);

    socket.write(packet);
  } catch (error) {
    console.error('[ lobbyUserListRequestHandler ] ====>  error ', error.message, error);
    socket.write(
      serialize(
        MESSAGE_TYPE.LOBBY_USER_LIST_RESPONSE,
        {
          success: false,
          userList: [],
          failCode: 1,
        },
        0,
        getPayloadNameByMessageType,
      ),
    );
  }
};
  • 유저 상세 정보
export const lobbyUserDetailRequestHandler = ({ socket, messageType, payload }) => {
  try {
    const { tartgetUserId } = payload;
    const result = lobbyManager.getUserDetail(tartgetUserId);

    const packet = createResponse(result, MESSAGE_TYPE.LOBBY_USER_DETAIL_RESPONSE);

    socket.write(packet);
  } catch (error) {
    console.error('[ lobbyUserDetailRequestHandler ] ====>  error ', error.message, error);
    socket.write(
      serialize(
        MESSAGE_TYPE.LOBBY_USER_DETAIL_RESPONSE,
        {
          success: false,
          userDetail: {},
          failCode: 1,
        },
        0,
        getPayloadNameByMessageType,
      ),
    );
  }
};

응답 생성

  • 각 응답 패킷마다 패킷의 구조가 조금씩 다르기때문에 하나의 함수에서 여러 구조를 대응할 수 있도록 메시지 타입에 따라 구조를 설정할 수 있게 구현했다.
export const createResponse = (result, messageType) => {
  let response = {
    success: result.success,
    failCode: result.failCode,
  };

  // 메시지 타입별로 응답 데이터 구조 설정
  switch (messageType) {
    case MESSAGE_TYPE.LOBBY_USER_LIST_RESPONSE:
      response = {
        ...response,
        userList: result.data.userList,
      };
      break;

    case MESSAGE_TYPE.LOBBY_USER_DETAIL_RESPONSE:
      response = {
        ...response,
        userDetail: result.data.userDetail,
      };
      break;

    default:
      break;
  }

  return serialize(messageType, response, 0, getPayloadNameByMessageType);
};

대기방

  • 대기방과 관련된 기능을 담당한 서버
  1. 대기방 생성, 참가, 퇴장, 삭제 (CRUD)

  2. 대기방 목록 조회, 대기방 정보 조회

  3. 대기방 내 유저의 준비 상태 설정, 방장 변경

  4. 대기방 상태 변경, 설정 변경

대기방 클래스

  • 사용할 객체 타입 정의
/**
 * @typedef {Object} UserData
 * @property {string} userId - 유저 ID
 * @property {string} nickname - 유저 닉네임
 */

/**
 * @typedef {Object} RoomResponse
 * @property {boolean} success - 성공 여부
 * @property {Object} data - 성공 시 반환할 데이터
 * @property {number} failCode - 실패 코드 (0: 성공)
 */
  • 대기방 속성
    • 방장, 대기방 이름, 상태, 참가한 유저 목록, 최대 인원 수, 준비한 유저 목록 관리
/**
   * @param {string} id - 대기방 ID
   * @param {string} ownerId - 방장 ID
   * @param {string} name - 대기방 이름
   */
  constructor(id, ownerId, name) {
    this.id = id;
    this.ownerId = ownerId;
    this.name = name;
    /** @type {'wait'|'prepare'|'board'|'mini'} */
    this.state = 'wait';
    /** @type {Map<string, UserData>} userId -> userData */
    this.users = new Map();
    this.maxUsers = 4; // TODO: 생성 때 변경가능해지면 수정 / option.maxUsers
    /** @type {Set<string>} userId */
    this.readyUsers = new Set();
    // TODO: 생성 때 변경가능해지면 수정 / this.password = option.password
  }
  • 대기방 참가
/**
   * 유저의 대기방 참가
   * @param {UserData} userData - 참가할 유저의 데이터
   * @returns {RoomResponse} 참가 결과
   */
  joinUser(userData) {
    if (!userData || !userData?.userId) {
      return { success: false, data: null, failCode: 1 };
    }

    if (this.users.size >= this.maxUsers) {
      return { success: false, data: null, failCode: 1 };
    }

    if (this.users.has(userId)) {
      return { success: false, data: null, failCode: 1 };
    }

    this.users.set(userData.userId, userData);

    const room = {
      id: this.id,
      ownerId: this.ownerId,
      name: this.name,
      state: this.state,
      users: Array.from(this.users.values()),
      maxUsers: this.maxUsers,
      readyUsers: Array.from(this.readyUsers),
    };

    return { success: true, data: room, failCode: 0 };
  }
  • 대기방 퇴장
/**
   * 유저의 대기방 퇴장
   * @param {string} userId - 퇴장할 유저의 ID
   * @returns {RoomResponse} 퇴장 결과
   */
  leaveUser(userId) {
    if (!this.users.has(userId)) {
      return { success: false, data: null, failCode: 1 };
    }

    this.users.delete(userId);
    this.readyUsers.delete(userId);

    return { success: true, data: null, failCode: 0 };
  }
  • 대기방 정보 조회
/**
   * 대기방 정보 조회
   * @returns {{
   *   id: string,
   *   ownerId: string,
   *   name: string,
   *   state: string,
   *   users: UserData[],
   *   maxUsers: number,
   *   readyUsers: string[]
   * }}
   */
  getRoomData() {
    return {
      id: this.id,
      ownerId: this.ownerId,
      name: this.name,
      state: this.state,
      users: Array.from(this.users.values()),
      maxUsers: this.maxUsers,
      readyUsers: Array.from(this.readyUsers),
    };
  }
  • 유저의 준비 상태 설정
/**
   * 유저의 준비 상태 설정
   * @param {string} userId - 준비/취소할 유저의 ID
   * @param {boolean} isReady - true: 준비, false: 준비 취소
   * @returns {RoomResponse} 준비 결과
   */
  updateReady(userId, isReady) {
    if (!this.users.has(userId)) {
      return { success: false, data: null, failCode: 1 };
    }

    if (isReady) {
      this.readyUsers.add(userId);
    } else {
      this.readyUsers.delete(userId);
    }

    return { success: true, data: this.readyUsers.has(userId), failCode: 0 };
  }
  • 모든 유저 준비 여부
/**
   * 모든 유저가 준비했는지 확인
   * @returns {boolean} 모든 유저가 준비되었는지 여부
   */
  isAllReady() {
    if (this.readyUsers.size === this.users.size) {
      this.state = 'prepare';
      return true;
    }

    return false;
  }
  • 방장 변경
// TODO: 수동 변경시 패킷 추가가 필요해보임
  /**
   * 방장 변경
   * @param {string} newOwnerId - 새로운 방장 ID
   * @returns {RoomResponse} 방장 변경 결과
   */
  changeOwner(newOwnerId) {
    if (!this.users.has(newOwnerId)) {
      return { success: false, data: null, failCode: 1 };
    }

    this.ownerId = newOwnerId;

    return { success: true, data: null, failCode: 0 };
  }
  • 대기방 상태 변경
/**
   * 대기방 상태 변경
   * @param {'wait'|'prepare'|'board'|'mini'} state - 변경할 상태
   */
  updateState(state) {
    this.state = state;
  }
  • 대기방 설정 변경
// TODO: 패킷 추가가 필요해보임
  /**
   * 대기방 설정 변경
   * @param {{name: string}} info - 변경할 방 정보
   */
  updateInfo(info) {
    this.name = info.name;
    // TODO: maxUsers, password 등도 추가 가능성 있음
  }
  • 유저가 없는지 확인
/**
   * 유저가 없는지 확인
   * @returns {boolean} 방이 비어있는지 여부
   */
  isEmpty() {
    return this.users.size === 0;
  }

대기방 매니저

  • 대기방의 목록과 유저가 접속한 대기방을 관리할 대기방 매니저를 싱글톤 형태로 구현

  • 속성 - 대기방 목록과 유저와 대기방을 맵핑한 목록

/** @type {Map<string, Room>} roomId -> Room */
this.rooms = new Map();
/** @type {Map<string, string>} userId -> roomId */
this.userRooms = new Map(); // 유저가 참여한 대기방
  • 대기방 생성
/**
   * 대기방 생성
   * @param {UserData} userData - 생성할 방장 정보
   * @param {string} name - 대기방 이름
   * @returns {RoomResponse} 생성 결과
   */
  createRoom(userData, roomName) {
    const roomId = uuidv4();
    const room = new Room(roomId, userData.userId, roomName);
    room.users.set(userData.userId, userData);

    this.rooms.set(roomId, room);
    this.userRooms.set(userData.userId, roomId);

    return { success: true, data: room.getRoomData(), failCode: 0 };
  }
  • 대기방 참가
/**
   * 대기방 참가
   * @param {UserData} userData - 참가할 유저 정보
   * @param {string} roomId - 방 ID
   * @returns {RoomResponse} 참가 결과
   */
  joinRoom(userData, roomId) {
    const room = this.rooms.get(roomId);
    if (!room) {
      return { success: false, data: null, failCode: 1 };
    }

    if (this.userRooms.has(userData.userId)) {
      return { success: false, data: null, failCode: 1 };
    }

    if (room.users.size >= room.maxUsers) {
      return { success: false, data: null, failCode: 1 };
    }

    if (room.state !== 'wait') {
      return { success: false, data: null, failCode: 1 };
    }

    const result = room.joinUser(userData);
    if (result.success) {
      this.userRooms.set(userData.userId, roomId);
    }

    return result;
  }
  • 대기방 퇴장
/**
   * 대기방 퇴장
   * @param {string} userId - 퇴장할 유저 ID
   * @returns {RoomResponse} 퇴장 결과
   */
  leaveRoom(userId) {
    const room = this.getRoomByUserId(userId);
    if (!room) {
      return { success: false, data: null, failCode: 1 };
    }

    const result = room.leaveUser(userId);
    if (result.success) {
      this.userRooms.delete(userId);

      // 방장이 나간 경우
      if (room.ownerId === userId && room.users.size > 0) {
        const newOwnerId = Array.from(room.users.keys())[0];
        room.changeOwner(newOwnerId);
      }

      // 빈 방이 된 경우
      if (room.isEmpty()) {
        this.rooms.delete(room.id);
      }
    }

    return result;
  }
  • 대기방 목록 조회
/**
   * 대기방 목록 조회
   * @returns {RoomResponse} 대기방 목록
   */
  getRoomList() {
    const rooms = Array.from(this.rooms.values()).map((room) => room.getRoomData());
    if (!rooms) {
      return { success: false, data: [], failCode: 1 };
    }

    return {
      success: true,
      data: rooms,
      failCode: 0,
    };
  }
  • 유저 준비 상태 설정
/**
   * 유저의 준비 상태 설정
   * @param {string} userId - 준비/취소할 유저 ID
   * @param {boolean} isReady - true: 준비, false: 준비 취소
   * @returns {RoomResponse} 준비 결과
   */
  updateReady(userId, isReady) {
    const room = this.getRoomByUserId(userId);
    if (!room) {
      return { success: false, data: { isReady: false }, failCode: 1 };
    }

    return room.updateReady(userId, isReady);
  }
  • 대기방 상태 변경
/**
   * 대기방 상태 변경
   * @param {string} roomId - 대기방 ID
   * @param {string} state - 변경할 상태
   */
  updateRoomState(roomId, state) {
    const room = this.rooms.get(roomId);
    if (!room) {
      return;
    }
    room.updateState(state);
  }
  • 대기방 설정 변경
/**
   * 대기방 설정 변경
   * @param {string} roomId
   * @param {Object} info
   */
  updateRoomInfo(roomId, info) {
    const room = this.rooms.get(roomId);
    if (!room) {
      return;
    }

    room.updateInfo(info);
  }
  • 유저 ID로 대기방 조회
/**
   * 유저 ID로 대기방 조회
   * @param {string} userId - 조회할 유저 ID
   * @returns {Room} room - 대기방
   */
  getRoomByUserId(userId) {
    const roomId = this.userRooms.get(userId);
    if (!roomId) {
      return;
    }

    const room = this.rooms.get(roomId);
    if (!room) {
      return;
    }

    return room;
  }
  • 유저 ID로 대기방 ID 조회
/**
   * 유저 ID로 대기방 ID 조회
   * @param {string} userId - 조회할 유저 ID
   * @returns {string} roomId - 대기방 ID
   */
  getRoomIdByUserId(userId) {
    const roomId = this.userRooms.get(userId);
    if (!roomId) {
      return;
    }

    return roomId;
  }

핸들러 로직

  • try catch로 원인 모를 실패 케이스에도 실패 케이스의 response를 보내도록 구현

  • 대기방 생성

export const createRoomRequestHandler = ({ socket, messageType, payload }) => {
  try {
    const { userData, roomName } = payload;
    const result = roomManager.createRoom(userData, roomName);

    // TODO: noti 구분 추가 필요
    const packet = createResponse(result, MESSAGE_TYPE.CREATE_ROOM_RESPONSE);

    socket.write(packet);
  } catch (error) {
    console.error('[ createRoomRequestHandler ] ====>  error ', error.message, error);
    socket.write(
      serialize(
        MESSAGE_TYPE.CREATE_ROOM_RESPONSE,
        {
          success: false,
          room: {},
          failCode: 1,
        },
        0,
        getPayloadNameByMessageType,
      ),
    );
  }
};
  • 대기방 참가
export const joinRoomRequestHandler = ({ socket, messageType, payload }) => {
  try {
    const { userData, roomId } = payload;
    const result = roomManager.joinRoom(userData, roomId);

    // TODO: noti 구분 추가 필요
    const packet = createResponse(result, MESSAGE_TYPE.JOIN_ROOM_RESPONSE);

    socket.write(packet);
  } catch (error) {
    console.error('[ joinRoomRequestHandler ] ====>  error ', error.message, error);
    socket.write(
      serialize(
        MESSAGE_TYPE.JOIN_ROOM_RESPONSE,
        {
          success: false,
          room: {},
          failCode: 1,
        },
        0,
        getPayloadNameByMessageType,
      ),
    );
  }
};
  • 대기방 퇴장
export const leaveRoomRequestHandler = ({ socket, messageType, payload }) => {
  try {
    const { userId } = payload;
    const result = roomManager.leaveRoom(userId);

    // TODO: noti 구분 추가 필요
    const packet = createResponse(result, MESSAGE_TYPE.LEAVE_ROOM_RESPONSE);

    socket.write(packet);
  } catch (error) {
    console.error('[ leaveRoomRequestHandler ] ====>  error ', error.message, error);
    socket.write(
      serialize(
        MESSAGE_TYPE.LEAVE_ROOM_RESPONSE,
        {
          success: false,
          failCode: 1,
        },
        0,
        getPayloadNameByMessageType,
      ),
    );
  }
};
  • 대기방 목록 조회
export const roomListHandler = ({ socket, messageType, payload }) => {
  try {
    const result = roomManager.getRoomList();

    // TODO: noti 구분 추가 필요
    const packet = createResponse(result, MESSAGE_TYPE.ROOM_LIST_RESPONSE);

    socket.write(packet);
  } catch (error) {
    console.error('[ roomListHandler ] ====>  error ', error.message, error);
    socket.write(
      serialize(
        MESSAGE_TYPE.ROOM_LIST_RESPONSE,
        {
          success: false,
          rooms: [],
          failCode: 1,
        },
        0,
        getPayloadNameByMessageType,
      ),
    );
  }
};
  • 유저 준비 상태 설정
export const gamePrepareRequestHandler = ({ socket, messageType, payload }) => {
  try {
    const { userId, isReady } = payload;
    const result = roomManager.updateReady(userId, isReady);

    // TODO: noti 구분 추가 필요
    const packet = createResponse(result, MESSAGE_TYPE.GAME_PREPARE_RESPONSE);

    socket.write(packet);
  } catch (error) {
    console.error('[ gamePrepareRequestHandler ] ====>  error ', error.message, error);
    socket.write(
      serialize(
        MESSAGE_TYPE.GAME_PREPARE_RESPONSE,
        {
          success: false,
          isReady: false,
          failCode: 1,
        },
        0,
        getPayloadNameByMessageType,
      ),
    );
  }
};

응답 생성

  • 각 응답 패킷마다 패킷의 구조가 조금씩 다르기때문에 하나의 함수에서 여러 구조를 대응할 수 있도록 메시지 타입에 따라 구조를 설정할 수 있게 구현
export const createResponse = (result, messageType) => {
  let response = {
    success: result.success,
    failCode: result.failCode,
  };

  // 메시지 타입별로 응답 데이터 구조 설정
  switch (messageType) {
    case MESSAGE_TYPE.ROOM_LIST_RESPONSE:
      response = {
        ...response,
        rooms: result.data,
      };
      break;

    case MESSAGE_TYPE.CREATE_ROOM_RESPONSE:
      response = {
        ...response,
        room: result.data,
      };
      break;

    case MESSAGE_TYPE.JOIN_ROOM_RESPONSE:
      response = {
        ...response,
        room: result.data,
      };
      break;

    case MESSAGE_TYPE.GAME_PREPARE_RESPONSE:
      response = {
        ...response,
        isReady: result.data,
      };
      break;

    default:
      break;
  }

  return serialize(messageType, response, 0, getPayloadNameByMessageType);
};
profile
신입 개발자

0개의 댓글

관련 채용 정보