DB 마이그레이션(Database Migration)이란, DB의 구조나 데이터를 이전하거나 변경하는 작업을 뜻한다.
이는 비단 DB에만 관련된 개념이 아니며, 데이터나 SW를 기존 시스템에서 다른 시스템으로 이동하는 걸 마이그레이션 작업이라고 할 수 있다.
DB 마이그레이션의 경우, 기존 DB의 데이터를 새로운 DB로 옮기거나, DB의 스키마(테이블 구조, 컬럼 추가/삭제 등)를 수정하고 관리하기 위해 사용된다.
따라서 주요 목적은 다음과 같다.
- DB 구조 변경
새로운 테이블 추가, 기존 테이블 수정, 컬럼 추가/삭제, 데이터 형식 변경 등 DB 스키마 변경이 필요할 때 사용 - 환경 간 DB 이동
로컬 환경에서 작성한 DB 구조와 데이터를 테스트 환경 또는 실제 운영 환경으로 옮길 때 사용 - 데이터 이전
기존 DB에서 새로운 시스템으로 옮기거나, DB 종류를 변경할 때 사용 - 버전 관리
DB의 변화를 버전 단위로 관리하여, 특정 버전으로 되돌릴 수 있게 하거나, 여러 개발자 간의 변경 사항을 일관성 있게 유지할 수 있다.
이로 인해 DB 변경 이력을 코드로 남길 수 있어 DB를 효율적으로 관리할 수 있고, 이는 유지보수에도 유리해진다.
또한 여러 개발자가 동일한 DB 구조를 유지하며 협업할 수 있고, DB를 특정 버전으로 되돌릴 수 있어 문제 발생 시 안정성을 확보할 수 있다는 장점이 있다.
따라서 이번에 해볼 작업은 DB 마이그레이션으로, 이를 위해선 먼저 SQL 코드가 조금 존재하고 있어야 한다.
하여, '유저 테이블'과 유저 테이블과 관계를 맺고 있는 '게임 종료 테이블' 두 가지를 예시로 작성했다.
- db/sql/user_db.sql
이때, mySQL에서는 create table을 여러 개 동시에 보낼 수 없어 다음과 같이 작성한다.
// 만약 존재하지 않는다면 user 테이블을 만든다.
CREATE TABLE IF NOT EXISTS user
(
id VARCHAR(36) PRIMARY KEY,
device_id VARCHAR(255) UNIQUE NOT NULL,
last_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS game_end
(
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
end_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
score INT DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES user (id)
);
쿼리문을 작성했다면 이젠 실행할 차례이다.
sql 폴더 내의 파일을 읽어 실행을 하는데, 세미콜론(;)을 기준으로 순서대로 하나씩 쿼리를 실행한다.
- db/migrations/createSchemas.js
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import pools from '../database.js';
// import.meta.url = 현재 파일의 절대 경로. 즉, assets.js의 위치
const __filename = fileURLToPath(import.meta.url);
// 현재 파일이 위치한, 파일 이름을 제외한 경로. 즉, 위에서 디렉토리 경로만 추출
const __dirname = path.dirname(__filename);
const executeSqlFile = async (pool, filePath) => {
const sql = fs.readFileSync(filePath, 'utf8');
// 세미콜론 기준으로 쿼리를 쪼갠다.
const queries = sql
.split(';')
.map((query) => query.trim())
.filter((query) => query.length > 0);
// 쿼리 실행
for (const query of queries) {
await pool.query(query);
}
};
const createSchemas = async () => {
const sqlDir = path.join(__dirname, '../sql');
try {
await executeSqlFile(pools.USER_DB, path.join(sqlDir, 'user_db.sql'));
console.log('DB 테이블이 성공적으로 생성 완료');
} catch (error) {
console.error('DB 테이블 생성 중 오류 발생: ', error);
}
};
createSchemas()
.then(() => {
console.log('마이그레이션 완료');
process.exit(0);
})
.catch((error) => {
console.error('마이그레이션 중 오류 발생: ', error);
process.exit(1);
});
이후 편하게 실행하기 위해, package.json에 다음과 같은 스크립트를 추가한다.
"migrate": "node src/db/migrations/createSchemas.js"
이후 실행을 하면 다음과 같은 결과를 볼 수 있다.
유저 데이터 저장
여기까지 했다면, 이젠 데이터를 저장할 차례다.
- db/user/user.queries.js
해당 파일에서 device_id를 이용한 조회, 생성, 마지막 로그인 업데이트 하는 쿼리를 작성한다.
여기서 업데이트 쪽에, where 문이 빠졌다. 왜냐하면 아직 어떤 id를 업데이트 해야할지 모르기 때문이다. 해당 부분은 게임에서 사용할 유저ID도 따로 생성할 것이기 때문에, 해당 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 (?, ?)',
// WHERE id = ? 라고 한 이유는, 아직 어떤 id를 업데이트 해야할지 모르기 때문이다.
// 해당 부분은 이후 게임에서 사용할 유저ID로 업데이트 할 예정이다.
UPDATE_USER_LOGIN: 'UPDATE user SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
};
- db/user/user.db.js
여기서는 작성한 쿼리를 실행시킨다.
import { v4 as uuidv4 } from 'uuid';
import pools from '../database.js';
import { SQL_QUERIES } from './user.queries.js';
export const findUserByDeviceID = async (deviceId) => {
const [rows] = await pools.USER_DB.query(SQL_QUERIES.FIND_USER_BY_DEVICE_ID, [deviceId]);
return rows[0];
};
export const createUser = async (deviceId) => {
const id = uuidv4(); // 게임에서 사용할 ID
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]);
};
여기서 잠깐!!!!
db/user/user.db.js를 보면, 각각 pools.USER_DB.query 메서드에서 두 번째 매개변수가 존재한다.
이는 사용하고 있는 쿼리문에 들어갈 값으로, MySQL에서는 보안 및 성능상의 이유로 쿼리문에 값을 직접 포함하기보다, 플레이스홀더(?)라는 방법을 사용한다.
이를 통해 쿼리문에 들어갈 값을 배열로 따로 전달할 수 있으며, SQL 인젝션도 방지할 수 있다.
그런데 현재 DB 스키마는 스네이트케이스로 쓰여져 있다.
이로 인해 select 쿼리를 사용하면 스네이크케이스로 반환해줄 것인데, 이걸 카멜케이스로 사용하고자 한다. 그렇게 되면 코드 상에서는 무조건 카멜케이스로 통일되게 된다.
(해당 부분은 그리 중요하지 않으니 넘어가도 좋다.)
- utils/transformCase.js
import camelCase from 'lodash/camelCase.js';
export const toCamelCase = (obj) => {
if (Array.isArray(obj)) {
// 배열인 경우, 배열의 각 요소에 대해 재귀적으로 toCamelCase 함수를 호출
return obj.map((v) => toCamelCase(v));
} 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;
};
이후 select 문을 사용하는 곳에 카멜케이스를 다음과 같이 적용시킬 수 있다.
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로 조회를 하고, 만약 유저의 정보가 DB에 없다면 새로 쓰고, 있다면 해당 정보를 읽어 유저에게 유저ID를 반환해주는 로직을 작성해볼 것이다.
- initial.handler.js
모바일 환경에서는 deviceId 가 PK로 사용가능하다고 한 적 있다.
그러나 현재는 user를 만들고 있고, 따라서 그냥 user.Id를 사용해도 무방하다.
const initialHandler = async ({ socket, userId, payload }) => {
try {
const { deviceId } = payload;
let user = await findUserByDeviceID(deviceId);
if (!user) {
user = await createUser(deviceId);
}
// 중략
const initialResponse = createResponse(
// 중략
{ userId: user.id },
deviceId,
); // 중략
} // 중략
};
export default initialHandler;
변경된 클라이언트 코드는 다음과 같다.
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, 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);
});
결과를 확인하면 다음과 같다.
'프로젝트' 카테고리의 다른 글
24/11/02 - [실습] Node.js 플러스(2 - 11): 게임 로직 추가 (0) | 2024.11.02 |
---|---|
24/11/01 - [실습] Node.js 플러스(2 - 10): 유저 & 게임 클래스 (0) | 2024.11.01 |
24/10/31 - [실습] Node.js 플러스(2 - 8): 커넥션 풀(Connection Pool), DB 연동 (0) | 2024.10.31 |
24/10/30 - [실습] Node.js 플러스(2 - 7): 에러 핸들링 (0) | 2024.10.30 |
24/10/29 - [실습] Node.js 플러스(2 - 6): 세션 & 핸들러 추가, 패킷 검증, (0) | 2024.10.29 |