db 폴더 아래 user 폴더를 만들어줍니다.
user 폴더에는 user.queries.js 파일을 만들어 유저가 서버에 최초 접속할 경우 유저 정보를 DB에 저장하기 위한 쿼리
CREATE_USER: 'INSERT INTO user (id, device_id) VALUES (?,?)',
와 생성 전 해당 유저가 이미 존재 했는지 조회하는 쿼리
FIND_USER_BY_DEVICE_ID: 'SELECT * FROM user WHERE device_id = ?',
그리고 이미 존재하는 유저가 다시 서버에 접속 했다면 마지막으로 접속한 로그인 날짜, 시간을 갱신해줄
UPDATE_USER_LOGIN: 'UPDATE user SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
쿼리를 하나의 객체로 묶어 만들어줍니다.
export const SQL_QUERIES = {
FIND_USER_BY_DEVICE_ID: 'SELECT * FROM user WHERE device_id = ?',
CREATE_USER: 'INSERT INTO user (id, device_id) VALUES (?,?)',
UPDATE_USER_LOGIN: 'UPDATE user SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
};
다시 user폴더에 user.db.js 파일을 생성해 위의 객체에 따른 Key로 해당 쿼리를 실행시켜줄 함수를 만들어줍니다.
FIND_USER_BY_DEVICE_ID: 'SELECT * FROM user WHERE device_id = ?',
해당 쿼리가 실행될 함수는
export const findUserByDeviceID = async (deviceId) => {
const [rows] = await pools.USER_DB.query(SQL_QUERIES.FIND_USER_BY_DEVICE_ID, [deviceId]);
return toCamelCase(rows[0]);
};
으로 우리는 유저가 접속한 deviceId(그냥 id라고 생각해도 된다.)로 유저를 구분할 것이기 때문에 매개변수로 deviceId를 가져옵니다.
toCamelCase는 현재 DB의 Colum이 snake_case로 작성 돼 있기 때문에 CamelCase로 변형해주기 위한 함수로
import { toCamelCase } from '../../utils/transformCase.js';
utils의 transformCase.js 파일에 존재하고
import camelCase from 'lodash/camelCase.js';
export const toCamelCase = (obj) => {
if (Array.isArray(obj)) {
// 배열인 경우, 배열의 각 요소에 대해 재귀적으로 toCamelCase 함수를 호출
return obj.map((element) => toCamelCase(element));
} else if (obj !== null && typeof obj === 'object' && obj.constructor === Object) {
// 객체인 경우, 객체의 키를 카멜케이스로 변환하고, 값에 대해서도 재귀적으로 toCamelCase 함수를 호출
return Object.keys(obj).reduce((result, key) => {
result[camelCase(key)] = toCamelCase(obj[key]);
return result;
}, {});
}
// 객체도 배열도 아닌 경우, 원본 값을 반환
return obj;
};
위와 같이 작성 돼 있습니다. 코드를 살펴보면,
lodash를 이용해 snake_case를 CamelCase로 바꾸고 있는걸 볼 수 있습니다.
import pools from '../database.js';
pools 안에 우리가 사용하고자 하는 USER_DB가 존재하기 때문에 pools를 import 해주고
pools.USER_DB.query로 조회하고자 하는 deviceId가 존재하는지 rows에 담아줍니다.
CREATE_USER: 'INSERT INTO user (id, device_id) VALUES (?,?)',
를 실행시킬 함수로는
export const createUser = async (deviceId) => {
const id = uuidv4();
await pools.USER_DB.query(SQL_QUERIES.CREATE_USER, [id, deviceId]);
return { id, deviceId };
};
같으며 우리는 DB의 PK(Primary Key)를 생성하기 위해
import { v4 as uuidv4 } from 'uuid';
uuid를 import 해주고 uuid에 존재하는 v4를 uuidv4라는 이름으로 사용할 것 입니다. 그 다음
const id = uuidv4()로 PK를 생성하고 실질적인 유저의 id가 될 deviceId를 가져와
pools.User_DB.query로 id와 deviceId를 넣어줘 생성해줍니다.
UPDATE_USER_LOGIN: 'UPDATE user SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
를 실행시킬 함수로는
export const updateUserLogin = async (id) => {
await pools.USER_DB.query(SQL_QUERIES.UPDATE_USER_LOGIN, [id]);
};
으로 해당 유저의 id를 바탕으로 마지막 로그인 시간을 다시 기록하는 것을 볼 수 있습니다.
정리된 코드 :
import { v4 as uuidv4 } from 'uuid';
import pools from '../database.js';
import { SQL_QUERIES } from './user.queries.js';
import { toCamelCase } from '../../utils/transformCase.js';
export const findUserByDeviceID = async (deviceId) => {
const [rows] = await pools.USER_DB.query(SQL_QUERIES.FIND_USER_BY_DEVICE_ID, [deviceId]);
return toCamelCase(rows[0]);
};
export const createUser = async (deviceId) => {
const id = uuidv4();
await pools.USER_DB.query(SQL_QUERIES.CREATE_USER, [id, deviceId]);
return { id, deviceId };
};
export const updateUserLogin = async (id) => {
await pools.USER_DB.query(SQL_QUERIES.UPDATE_USER_LOGIN, [id]);
};
이제, 위의 함수들이 사용될 위치에 위의 함수들을 호출해보겠습니다.
src/handlers/user/inital.handler.js 파일의
import { addUser } from '../../session/user.session.js';
import { HANDLER_IDS, RESPONSE_SUCCESS_CODE } from '../../constants/handlerIds.js';
import { createResponse } from '../../utils/response/createResponse.js';
import { handleError } from '../../utils/error/errorHandler.js';
import { createUser, findUserByDeviceID, updateUserLogin } from '../../db/user/user.db.js';
const initialHandler = async ({ socket, userId, payload }) => {
try {
const { deviceId } = payload;
addUser(socket, deviceId);
// 유저 정보 응답 생성
const initialResponse = createResponse(
HANDLER_IDS.INITIAL,
RESPONSE_SUCCESS_CODE,
{ userId: user.id },
deviceId,
);
// 소켓을 통해 클라이언트에게 응답 메시지 전송
socket.write(initialResponse);
} catch (error) {
handleError(socket, error);
}
};
export default initialHandler;
은 유저가 처음 서버에 접속하면 동작하는 이밴트 처리 핸들러로
const initialHandler = async ({ socket, userId, payload }) => {
try {
const { deviceId } = payload;
let user = await findUserByDeviceID(deviceId);
if (!user) {
user = await createUser(deviceId);
} else {
await updateUserLogin(user.id);
}
위와 같이 내용을 추가해줍니다. 위의 코드를 요약하자면 deviceId로 DB에 유저의 존재 유무를 확인하고,
유저가 없으면 유저 정보를 DB에 생성해주고, 유저가 존재했다면 마지막 로그인 시간을 바꿔줍니다.
마지막으로
addUser(socket, user.id);
// 유저 정보 응답 생성
const initialResponse = createResponse(
HANDLER_IDS.INITIAL,
RESPONSE_SUCCESS_CODE,
{ userId: user.id },
deviceId,
);
으로 코드를 수정해줬는데 addUser는 기존에 deviceId로 넣어주고 있었으나
let user = await findUserByDeviceID(deviceId);
으로 DB에 유저 존재 확인 및
user = await createUser(deviceId);
존재하지 않을 경우 생성해주고 있기 때문에
addUser(socket, user.id);
user.id로 바꿔줍니다.
export const addUser = (socket, uuid) => {
const user = new User(uuid, socket);
userSessions.push(user);
return user;
};
addUser는 해당 user의 id를 바탕으로 해당 유저의 socket과 함께 유저의 객체를 생성해
유저의 요청에 따른 세션을 관리할 수 있도록 해주는 함수 입니다.
마찬가지로
// 유저 정보 응답 생성
const initialResponse = createResponse(
HANDLER_IDS.INITIAL,
RESPONSE_SUCCESS_CODE,
{ userId: deviceId },
deviceId,
);
응답으로
{ userId : deviceId }로 유저의 응답해줄 userId를 만들어주고 있었는데,
DB에서 유저 정보를 보관 및 기록, 없다면 생성해주고 있어서
// 유저 정보 응답 생성
const initialResponse = createResponse(
HANDLER_IDS.INITIAL,
RESPONSE_SUCCESS_CODE,
{ userId: user.id },
deviceId,
);
{ userId : user.id } 로 변경해줍니다.
클라이언트와 서버의 동작을 확인하기 위해 서버와 클라이언트를 동작 시키기 전 client.js를 수정해주겠습니다.
import net from 'net';
import { getProtoMessages, loadProtos } from './src/init/loadProtos.js';
const TOTAL_LENGTH = 4; // 전체 길이를 나타내는 4바이트
const PACKET_TYPE_LENGTH = 1; // 패킷타입을 나타내는 1바이트
let userId;
let sequence;
const createPacket = (handlerId, payload, clientVersion = '1.0.0', type, name) => {
const protoMessages = getProtoMessages();
const PayloadType = protoMessages[type][name];
if (!PayloadType) {
throw new Error('PayloadType을 찾을 수 없습니다.');
}
const payloadMessage = PayloadType.create(payload);
const payloadBuffer = PayloadType.encode(payloadMessage).finish();
return {
handlerId,
userId: '1',
clientVersion,
sequence: 0,
payload: payloadBuffer,
};
};
const sendPacket = (socket, packet) => {
const protoMessages = getProtoMessages();
const Packet = protoMessages.common.Packet;
if (!Packet) {
console.error('Packet 메시지를 찾을 수 없습니다.');
return;
}
const buffer = Packet.encode(packet).finish();
// 패킷 길이 정보를 포함한 버퍼 생성
const packetLength = Buffer.alloc(TOTAL_LENGTH);
packetLength.writeUInt32BE(buffer.length + TOTAL_LENGTH + PACKET_TYPE_LENGTH, 0); // 패킷 길이에 타입 바이트 포함
// 패킷 타입 정보를 포함한 버퍼 생성
const packetType = Buffer.alloc(PACKET_TYPE_LENGTH);
packetType.writeUInt8(1, 0); // NORMAL TYPE
// 길이 정보와 메시지를 함께 전송
const packetWithLength = Buffer.concat([packetLength, packetType, buffer]);
socket.write(packetWithLength);
};
// 서버에 연결할 호스트와 포트
const HOST = 'localhost';
const PORT = 5555;
const client = new net.Socket();
client.connect(PORT, HOST, async () => {
console.log('Connected to server');
await loadProtos();
const successPacket = createPacket(0, { deviceId: 'xxxxx' }, '1.0.0', 'initial', 'InitialPacket');
sendPacket(client, successPacket);
});
client.on('data', (data) => {
// 1. 길이 정보 수신 (4바이트)
const length = data.readUInt32BE(0);
const totalHeaderLength = TOTAL_LENGTH + PACKET_TYPE_LENGTH;
// 2. 패킷 타입 정보 수신 (1바이트)
const packetType = data.readUInt8(4);
const packet = data.slice(totalHeaderLength, length); // 패킷 데이터
if (packetType === 1) {
const protoMessages = getProtoMessages();
const Response = protoMessages.response.Response;
try {
const response = Response.decode(packet);
if (response.handlerId === 0) {
const responseData = JSON.parse(Buffer.from(response.data).toString());
userId = responseData.userId;
console.log('응답 데이터:', responseData);
}
sequence = response.sequence;
} catch (e) {
console.log(e);
}
}
});
client.on('close', () => {
console.log('Connection closed');
});
client.on('error', (err) => {
console.error('Client error:', err);
});
으로 수정해줍니다.
서버와 클라이언트를 동작 시켜 확인해보면
정상 동작하는 것을 확인할 수 있습니다.