클라이언트가 서버에 접속하고 그에 따른 이밴트를 처리하기 위해
이밴트를 처리할 Handler를 만들어보겠습니다.
src 폴더에 session 폴더를 만들어주고,
game.session.js와 session.js를 만들어줍니다.
먼저, session.js파일에는
export const userSessions = [];
// 유저 세션
export const gameSessions = [];
// 게임 세션
위와 같이 유저 세션과 게임 세션이라는 배열을 만들어줍니다.
이제 user.session.js에 필요한 내용을 적어보겠습니다. 먼저,
export const addUser = (socket, uuid) => {
// 유저를 추가하는 함수
const user = { socket, id: uuid, sequence: 0 };
userSessions.push(user);
return user;
};
으로, 유저가 게임 세션(방)에 접속했을 경우 초기화된 유저의 상태를 저장해줍니다.
그 다음으로,
export const removeUser = (socket) => {
const index = userSessions.findIndex((user) => user.socket === socket);
if (index !== -1) {
return userSessions.splice(index, 1)[0];
}
};
다음으로는 유저가 게임 세션(방)에서 나갔을 경우 동작하는 함수로 socket을 통해 해당 유저를 제거하고 남은 유저 정보를 반환해줍니다.
export const getUserById = (id) => {
return userSessions.find((user) => user.id === id);
};
getUserById는 id를 바탕으로 유저의 정보를 찾아 반환시켜 줍니다.
이제, handler들을 추가해보겠습니다. src 폴더에 handlers 폴더를 만들어주고, user폴더를 만든 후, inital.handler.js 파일을 만들어줍니다. 그리고 handlers 폴더 안에 index.js 파일을 만들어줍니다.
index.js 파일에는 먼저
handlers 객체를 먼저 만들어줍니다. 다음으로
export const getHandlerById = (handlerId) => {
if (!handlers[handlerId]) {
console.error(`핸들러를 찾을 수 없습니다 : ID ${handlerId}`);
}
return handlers[handlerId];
};
getHandlerById 함수를 만들어 handlerId를 바탕으로 해당 handlerId가 존재하는지 확인할 수 있는 함수를 만들어줍니다.
안에는 handlerId를 작성해줘야 하는데 handlerId만 따로 상수화해서 관리할 수 있도록
constants 폴더 안에 handlerIds.js 파일을 만들어줍니다. handlerIds.js 파일 안에
export const RESPONSE_SUCCESS_CODE = 0;
export const HANDLER_IDS = {
INITIAL: 0,
};
위와 같은 코드로 유저가 최초로 게임에 접속 했을 때 HanderId를 INITIAL로 작성해둡니다. 그리고 응답에 성공했다면 응답에 성공했다는 코드 또한 작성해둡니다.
그리고 user/inital.handler.js 파일에 가서
const initialHandler = () => {
};
export default initialHandler;
함수를 만들어주고
다시 handdlers/index.js로 넘어가
를
const handlers = {
[HANDLER_IDS.INITIAL]: {
handler: initialHandler,
protoType : 'initial.InitialPacket'
},
};
위와 같이 바꿔줍니다.
그 후, protoType의 initail.InitialPacket을 작성하기 위해
protobuf/initial.proto 파일을 만들어주고,
syntax = "proto3";
package initial;
// 패키지는 initial
// 최초 패킷 구조
message InitialPacket {
string deviceId = 1;
// 모바일 게임에서는 각각의 기계들에 고유한 넘버가 존재하는데
// 이 넘버를 PK(Primary Key)로 사용합니다. 그래서 그것을 사용한다는 가정하에
// 만들어보겠습니다.
}
위와 같이 초기 패킷 구조를 작성해줍니다.
protobuf/packetName.js에
export const packetNames = {
common: {
Packet: 'common.Packet',
Ping: 'common.Ping',
},
initial: {
InitialPacket: 'initial.InitialPacket',
},
response: {
Response: 'response.Response',
}
};
위와 같이 initial을 추가해줍니다. 그 다음에는 handlers/index.js의
getProtoTypeNameByHandlerId가 현재
export const getProtoTypeNameByHandlerId = (handlerId) => {};
위와 같이 돼 있을 것인데,
export const getProtoTypeNameByHandlerId = (handlerId) => {
if (!handlers[handlerId]) {
console.error(`프로토타입을 찾을 수 없습니다: ID ${handlerId}`);
}
return handlers[handlerId].protoType;
};
로 내용을 채워줍니다.
그리고, 기존 getHandlerById는
export const getHandlerById = (handlerId) => {
if (!handlers[handlerId]) {
console.error(`핸들러를 찾을 수 없습니다 : ID ${handlerId}`);
}
return handlers[handlerId].protoType;
};
return 값을
return handlers[handlerId].handler;
으로 handler를 반환할 수 있게 해줍니다.
다시 handlers/user/initial.handler.js에 돌아와
const initialHandler = () => {
};
의 매개변수로 들어올 값은
onData.js 함수에서
export const onData = (socket) => async (data) => {
// 기존 버퍼에 새로 수신된 데이터를 추가
socket.buffer = Buffer.concat([socket.buffer, data]);
// 패킷의 총 헤더 길이 (패킷 길이 정보 + 타입 정보)
const totalHeaderLength = config.packet.totalLength + config.packet.typeLength;
// 버퍼에 최소한 전체 헤더가 있을 때만 패킷을 처리
while (socket.buffer.length >= totalHeaderLength) {
// 1. 패킷 길이 정보 수신 (4바이트)
const length = socket.buffer.readUInt32BE(0);
// 2. 패킷 타입 정보 수신 (1바이트)
const packetType = socket.buffer.readUInt8(config.packet.totalLength);
// 3. 패킷 전체 길이 확인 후 데이터 수신
if (socket.buffer.length >= length) {
// 패킷 데이터를 자르고 버퍼에서 제거
const packet = socket.buffer.slice(totalHeaderLength, length);
socket.buffer = socket.buffer.slice(length);
switch (packetType) {
case PACKET_TYPE.PING:
break;
case PACKET_TYPE.NORMAL:
const { handlerId, userId, payload, sequence } = packetParser(packet);
console.log(`handlerId : ${handlerId}`);
console.log(`userId : ${userId}`);
console.log(`payload : ${payload}`);
console.log(`sequence : ${sequence}`);
break;
}
} else {
// 아직 전체 패킷이 도착하지 않음
break;
}
}
};
case PACKET_TYPE.NORMAL 의 부분으로,
우리는 packetParser로 부터 객체분해할당된
userId와 payload그리고, onData에서 넘어오는 socket을 인자로 제공해줄 것 입니다.
const initialHandler = ({socket, userId, payload}) => {
};
그래서 위와 같이 객체 분해할당으로 socket, userId, payload를 가져옵니다.
const initialHandler = ({ socket, userId, payload }) => {
const { deviceId } = payload;
addUser(socket, deviceId);
// 뭔가 처리가 끝났을 때 보내는 것
socket.write('');
};
export default initialHandler;
그리고, addUser(socket, deviceId)로 해당 유저의 socket과 모바일 기기의 고유 Id를
export const addUser = (socket, uuid) => {
const user = new User(uuid, socket);
userSessions.push(user);
return user;
};
uuid( Universally Unique Identifier )로 제공해
class User {
constructor(id, socket) {
this.id = id;
this.socket = socket;
this.x = 0;
this.y = 0;
this.sequence = 0;
this.lastUpdateTime = Date.now();
}
유저의 고유 id (Primary Key)가 될 수 있게 해줍니다.
그리고 위와 같이 동작 시키기 위해 onData.js 파일에
case PACKET_TYPE.NORMAL:
const { handlerId, sequence, payload, userId } = packetParser(packet);
const user = getUserById(userId);
// 유저가 접속해 있는 상황에서 시퀀스 검증
if (user && user.sequence !== sequence) {
throw new CustomError(ErrorCodes.INVALID_SEQUENCE, '잘못된 호출 값입니다. ');
}
const handler = getHandlerById(handlerId);
await handler({
socket,
userId,
payload,
});
break;
위와 같이 getHandlerById 함수를 호출해 handlerId를 인자로 넘겨줍니다.
export const getHandlerById = (handlerId) => {
if (!handlers[handlerId]) {
throw new CustomError(
ErrorCodes.UNKNOWN_HANDLER_ID,
`핸들러를 찾을 수 없습니다: ID ${handlerId}`,
);
}
return handlers[handlerId].handler;
};
getHandlerById는
const handlers = {
[HANDLER_IDS.INITIAL]: {
handler: initialHandler,
protoType: 'initial.InitialPacket',
}
};
handlers 객체의 handlerId에 따른 handler(key)와 initialHandler메서드를 (value) 호출하고 있습니다.
그래서 initialHandler 함수가 호출되게 됩니다. 그런데 현재
await handler({
socket,
userId,
payload,
});
handler는 await로 비동기 처리를 기다리도록 바꿔놨기 때문에,
const initialHandler = ({ socket, userId, payload }) => {
const { deviceId } = payload;
addUser(socket, deviceId);
// 뭔가 처리가 끝났을 때 보내는 것
socket.write('');
};
export default initialHandler;
현재 initialHandler 함수는 async로 비동기 함수로 만들어줘야 합니다. 따라서,
const initialHandler = async ({ socket, userId, payload }) => {
const { deviceId } = payload;
addUser(socket, deviceId);
// 뭔가 처리가 끝났을 때 보내는 것
socket.write('');
};
export default initialHandler;
위와 같이 수정되게 됩니다.
잠시 packetParser.js로 넘어와
export const packetParser = (data) => {
const protoMessages = getProtoMessages();
// 공통 패킷 구조를 디코딩
const Packet = protoMessages.common.Packet;
let packet;
try {
packet = Packet.decode(data);
} catch (error) {
throw new CustomError(ErrorCodes.PACKET_DECODE_ERROR, '패킷 디코딩 중 오류가 발생했습니다.');
}
const handlerId = packet.handlerId;
const userId = packet.userId;
const clientVersion = packet.clientVersion;
const sequence = packet.sequence;
console.log(`clientVersion : ${clientVersion}`);
return { handlerId, userId, payload, sequence };
};
코드를 살펴보면,
payload가 파싱이 돼 있지 않고 있습니다.
현재는
message Packet {
uint32 handlerId = 1; // 핸들러 ID (4바이트)
string userId = 2; // 유저 ID (UUID, 16바이트)
string clientVersion = 3; // 클라이언트 버전 (문자열)
uint32 sequence = 4; // 유저의 호출 수 (42억)
bytes payload = 5; // 실제 데이터
}
common.proto 파일의 위의 Packet의 handlerId, userId, clientVersion, sequence 만 파싱이 된 상태입니다.
그래서 payload를 파싱할 필요성이 있기 때문에 먼저
// 핸들러 ID에 따라 적절한 payload 구조를 디코딩
const protoTypeName = getProtoTypeNameByHandlerId(handlerId);
getProtoTypeNameByHandlerId로 protoTypeName 가져옵니다.
const handlers = {
[HANDLER_IDS.INITIAL]: {
handler: initialHandler,
protoType: 'initial.InitialPacket',
},
그래서, protoTypeName에는 initial.InitialPacket이 들어오게 될 것 입니다.
const [namespace, typeName] = protoTypeName.split('.');
그래서, split('.')으로 initial과 InitialPacket을 분리해
initial은 namespace에 넣고, InitialPacket은 typeName에 넣어줍니다.
그 후,
const PayloadType = protoMessages[namespace][typeName];
으로 .proto 파일의 자료들을 읽어 담은 객체인 protoMessages에서 namespace와 typeName에 따른 값을
PayloadType에 가져와줍니다.
let payload;
try {
payload = PayloadType.decode(packet.payload);
} catch (error) {
console.error(e);
}
가져온 PayloadType을 decode로 pakcet.payload를 디코딩해 가져옵니다.
수정된 코드 :
export const packetParser = (data) => {
const protoMessages = getProtoMessages();
// 공통 패킷 구조를 디코딩
const Packet = protoMessages.common.Packet;
let packet;
try {
packet = Packet.decode(data);
} catch (error) {
console.error(error);
}
const handlerId = packet.handlerId;
const userId = packet.userId;
const clientVersion = packet.clientVersion;
const sequence = packet.sequence;
// 핸들러 ID에 따라 적절한 payload 구조를 디코딩
const protoTypeName = getProtoTypeNameByHandlerId(handlerId);
if (!protoTypeName) {
console.error(`알 수 없는 핸들러 ID: ${handlerId}`);
}
const [namespace, typeName] = protoTypeName.split('.');
const PayloadType = protoMessages[namespace][typeName];
let payload;
try {
payload = PayloadType.decode(packet.payload);
} catch (error) {
console.error(e);
}
return { handlerId, userId, payload, sequence };
};
현재 위의 코드에서 clientVersion을 체크하지 않고 있기 때문에 해주도록 하겠습니다.
const handlerId = packet.handlerId;
const userId = packet.userId;
const clientVersion = packet.clientVersion;
const sequence = packet.sequence;
// clientVersion 검증
if (clientVersion !== config.client.version) {
console.error('클라이언트 버전이 일치하지 않습니다.')
}
const sequence 밑에 서버가 가지고 있는 버전과 클라이언트가 보낸 버전을 비교해주는 부분을 넣어줍니다.
다시
let payload;
try {
payload = PayloadType.decode(packet.payload);
} catch (error) {
console.error(e);
}
으로 돌아와
decode한 패킷에 문제가 생겼을 수 있기 때문에
// 필드가 비어 있거나, 필수 필드가 누락된 경우 처리
const expectedFields = Object.keys(PayloadType.fields);
// 무조건 들어온다는 가정으로 PayloadType.fields
// .proto의 모든 필드를 가져옵니다.
PayloadType.fields에는
common.proto 파일의
message Packet {
uint32 handlerId = 1; // 핸들러 ID (4바이트)
string userId = 2; // 유저 ID (UUID, 16바이트)
string clientVersion = 3; // 클라이언트 버전 (문자열)
uint32 sequence = 4; // 유저의 호출 수 (42억)
bytes payload = 5; // 실제 데이터
}
message Packet의 모든 fields를 가져온다는 코드 입니다.
그래서, const expectedFields 모두 넘어올 Packet의 필드 정보가 담기게 됩니다.
const actualFields = Object.keys(payload);
그리고 실제 client가 보내는 payload를 바탕으로 실제 packet으로 제공 받은 필드를 가져옵니다.
const missingFields = expectedFields.filter((field) => !actualFields.includes(field));
그 후, 두 객체가 가지고 있는 정보를 비교하며 누락된 부분을 찾아옵니다.
만약, 실제로 누락이 발생했다면 missingFields의 길이가 0이 1이상의 숫자가 올 것이기 때문에
if (missingFields.length > 0) {
console.error(`필수 필드가 누락됐습니다.${missingFields.join(',')}`);
}
비교문으로 판단하고 누락이 있다면 출력해줍니다.
마지막으로 우리는 sequence(유저의 호출 수) 검증을 안 하고 있기 때문에,
onData.js의
case PACKET_TYPE.NORMAL:
const { handlerId, sequence, payload, userId } = packetParser(packet);
const handler = getHandlerById(handlerId);
await handler({
socket,
userId,
payload,
});
break;
에서
const user = getUserById(userId);
getUserById로 유저 정보를 가져와
if (user && user.sequence !== sequence) {
console.error('잘못된 호출 값 입니다.');
}
유저가 있고 sequence가 다르다면 오류 메시지를 출력해줍니다. 그리고
유저의 sequence 번호를 올려줄 코드가 필요하기 때문에
user.session.js에서
export const getNextSequence = (id) => {
const user = getUserById(id);
if (user) {
return ++user.sequence;
}
return null;
};
위와 같은 함수를 만들어줍니다. 우리가 만드는 서버는 TCP 서버이기 때문에
Client가 보낸 packet에 대해 Server가 수신할 경우 sequence 번호 현재 수신 대상을 확인하고
sequence 번호 +1로 다음 packet을 보내 달라고 Client에게 요청을 보내는 것 입니다.
이렇게 클라이언트가 보내는 이밴트를 서버가 수신과 오류 처리를 했으니 서버가 응답을 보내는 것을 만들어보겠습니다.
utils/response 폴더를 만들어주고, createResponse.js 파일을 만들어줍니다.
export const createResponse = (handlerId, responseCode, data = null, userId) => {
const protoMessages = getProtoMessages();
const Response = protoMessages.response.Response;
const responsePayload = {
handlerId,
responseCode,
timestamp: Date.now(),
data: data ? Buffer.from(JSON.stringify(data)) : null,
sequence: userId ? getNextSequence(userId) : 0,
};
으로 서버는 클라이언트에게 응답을 보내줍니다.
data가 기본으로 null인 이유는 클라이언트의 요청에 따른 서버의 응답에서
서버가 클라이언트에게 data를 줄 필요가 없는 경우도 있기 때문입니다. 그후,
const buffer = Response.encode(responsePayload).finish();
버퍼에 응답으로 제공할 payload를 16진수로 변환해 넣어주고,
패킷의 정보를 담은 해더를 만들어보겠습니다.
// 패킷 길이 정보를 포함한 버퍼 생성
const packetLength = Buffer.alloc(config.packet.totalLength);
으로 packet의 길이만큼 Buffer 사이즈를 생성해주고,
packetLength.writeUInt32BE(
buffer.length + config.packet.totalLength + config.packet.typeLength,
0,
); // 패킷 길이에 타입 바이트 포함
패킷의 길이 정보를 모두 더해 packetLenght에 넣어줍니다.
const packetType = Buffer.alloc(config.packet.typeLength);
packetType.writeUInt8(PACKET_TYPE.NORMAL, 0);
마찬가지로 pakcetType에 대한 버퍼 사이즈를 생성해주고,
패킷 타입에 맞는 내용을 넣어줍니다.
// 길이 정보, 타입 정보, 메시지를 함께 전송
return Buffer.concat([packetLength, packetType, buffer]);
마지막 반환으로는 packet의 길이 정보와 타입 정보 실제 메시지를 이어 붙인 Buffer를 반환해줍니다.
이렇게 생성된 패킷이 클라이언트에게 전송되기 위해,
initial.handler.js의
const initialHandler = async ({ socket, userId, payload }) => {
const { deviceId } = payload;
addUser(socket, deviceId);
socket.write(initialResponse);
};
export default initialHandler;
에서
const initialHandler = async ({ socket, userId, payload }) => {
const { deviceId } = payload;
addUser(socket, deviceId);
// 유저 정보 응답 생성
const initialResponse = createResponse(
HANDLER_IDS.INITIAL,
RESPONSE_SUCCESS_CODE,
{ userId: deviceId },
deviceId,
);
// 소켓을 통해 클라이언트에게 응답 메시지 전송
socket.write(initialResponse);
};
export default initialHandler;
으로 응답 정보를 생성해
socket.write로 응답 정보를 담아 클라이언트에게 제공해줍니다.
(작성 도중 3번 날려 먹어서 오래 걸렸습니다ㅜㅜ)