이번 최종 프로젝트에서 인프라를 제외한 처음 맡게된 기능은 로비 서버와 대기방 서버다.
redis I/O를 제외하고는 개발이 끝난 상태라서 먼저 정리해보려고 한다.
로비 입장, 퇴장
로비에 있는 유저 목록, 유저의 상세 정보
/**
* @typedef {Object} UserData
* @property {number} userId
* @property {string} nickname
*/
/**
* @typedef {Object} LobbyResponse
* @property {boolean} success
* @property {Object} data
* @property {number} failCode
*/
/** @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);
};
대기방 생성, 참가, 퇴장, 삭제 (CRUD)
대기방 목록 조회, 대기방 정보 조회
대기방 내 유저의 준비 상태 설정, 방장 변경
대기방 상태 변경, 설정 변경
/**
* @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로 대기방 조회
* @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 조회
* @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);
};