하기는 금방 했는데, 인생 한 치 앞을 알 수가 없게 돼서 이제야 올린다.
1. DB 설치
MariaDB를 사용하기로 했다.
Sapphire 프로젝트에서 MariaDB를 사용하고 있기도 하고,
나중에 샤딩을 위해서도 좋지 않을까 싶었다.
C++에서 마리아 DB와 연동하기 위해선 MariaDB Connector/C++가 필요하다.
설치 후 포함 및 라이브러리 경로를 프로젝트 설정에서 잡아주면 끝.
2. DB 구조 잡기
유저들은 무작위 16진수 문자열 배열을 ID이자 닉네임으로 사용하게 된다.
로그인도 필요 없고 서버에서 임의로 부여한다.
오고 가는 것은 클라이언트가 접속했는지, 어떤 채팅을 쳤는지 정도일 뿐이다.
- 사용자 정보
- 부여받은 ID
- IP 주소
- 최초/최근 접속 시간 - 대화 정보
- 메시지를 보낸 사용자의 ID
- 그 내용
- 보낸 시간
위와 같이 2개의 테이블로 나눠서 정보를 저장하기로 했다.
3. DB 생성
DB 및 테이블을 생성하기 위해 아래와 같은 쿼리문을 작성할 수 있다.
기초 쿼리문을 작성 후 실행해 DB를 생성한 후, SQL 내보내기를 통해 생성된 쿼리문을 보기 좋게 정리한 것이다.
-- 데이터베이스 생성
CREATE DATABASE IF NOT EXISTS `boost_chat`
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
USE `boost_chat`;
-- user 테이블 생성
CREATE TABLE IF NOT EXISTS `user` (
`UserId` VARCHAR(255) NOT NULL DEFAULT '0',
`IP` VARCHAR(50) DEFAULT NULL,
`FirstAccessTime` DATETIME DEFAULT NULL,
`LastAccessTime` DATETIME DEFAULT NULL,
PRIMARY KEY (`UserId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- usercontent 테이블 생성
CREATE TABLE IF NOT EXISTS `usercontent` (
`ContentId` INT NOT NULL AUTO_INCREMENT,
`UserId` VARCHAR(255) DEFAULT NULL,
`Content` TEXT DEFAULT NULL,
`Date` DATETIME DEFAULT NULL,
PRIMARY KEY (`ContentId`),
KEY `FK_UserContent_User` (`UserId`)
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
정상적으로 DB와 테이블이 만들어졌다.
4. 연결 클래스 작성
이제 서버를 DB와 연동하기 위한 코드를 작성해야 한다.
기본적인 커넥션을 수행하는 클래스를 만들어 보자.
#ifndef DBCONNECTION_H_
#define DBCONNECTION_H_
#include <mariadb/conncpp.hpp>
class DBConnection
{
public:
void Connect();
void UpdateUser(std::string id, std::string ip);
void UpdateContent(std::string id, std::string content);
private:
sql::Driver* driver_;
sql::Connection* con_;
void Query(std::string db_query);
};
#endif DBCONNECTION_H_
헤더는 이렇게 구성됐다.
4-1. Connect()
void DBConnection::Connect()
{
driver_ = sql::mariadb::get_driver_instance();
con_ = driver_->connect("tcp://localhost:3306", "root", "root");
con_->setSchema("boost_chat");
}
드라이버 인스턴스를 가져온 후 이를 통해 연결을 수행한다.
연결이 완료되면 boost_chat
이라는 스키마를 지정한다.
4-2. UpdateUser()
void DBConnection::UpdateUser(std::string id, std::string ip)
{
sql::SQLString a = id.c_str();
sql::SQLString b = ip.c_str();
std::cout << a << " " << b << "\n";
try {
std::unique_ptr<sql::PreparedStatement> pstmt(con_->prepareStatement(
"INSERT INTO user (UserId, IP, FirstAccessTime, LastAccessTime) "
"VALUES (?, ?, NOW(), NOW()) "
"ON DUPLICATE KEY UPDATE LastAccessTime = NOW(), IP = ?"
));
pstmt->setString(1, a);
pstmt->setString(2, b);
pstmt->setString(3, b);
pstmt->executeUpdate();
}
catch (sql::SQLException& e) {
std::cout << "Error occurred: " << e.what() << std::endl;
}
}
사용자의 정보를 업데이트하기 위한 함수다.
pstmt
를 사용하여 재사용성을 높이고, SQL 인젝션을 예방할 수도 있다.
std::string
을 그냥 넘겨주면 인코딩 문제로 인해 제대로 넘어가지 않는다.
c_str()
을 사용 후 넘겨주면 인코딩 문제가 발생하지 않는다.
4-3. UpdateContent()
void DBConnection::UpdateContent(std::string userId, std::string content)
{
try {
std::unique_ptr<sql::PreparedStatement> pstmt(con_->prepareStatement(
"INSERT INTO usercontent (UserId, Content, Date) "
"VALUES (?, ?, NOW())"
));
pstmt->setString(1, userId.c_str());
pstmt->setString(2, content.c_str());
pstmt->executeUpdate();
}
catch (sql::SQLException& e) {
std::cout << "Error occurred: " << e.what() << std::endl;
}
}
대화 내용을 업데이트 하기 위한 부분이다.
동일하게 c_str()
을 사용하고 넘겨준다.
Query()
는 테스트용 임시 함수니 넘어간다.
5. 커넥션 풀
시스템 자원의 효율적 사용을 위해 객체 등을 풀로 관리하는 것은 중요한 요소가 되었다.
DB 연결도 그 예외는 아니다.
심플하게 이를 구현해 보자.
#ifndef DBCONNECTIONPOOL_H
#define DBCONNECTIONPOOL_H
#include <concurrent_queue.h>
#include "DBConnection.h"
class DBConnectionPool {
public:
~DBConnectionPool();
static DBConnectionPool* getInstance();
static void initializeInstance();
void initializePool();
void closePool();
DBConnection* getConnection();
void releaseConnection(DBConnection* connection);
private:
DBConnection* createConnection();
concurrency::concurrent_queue<DBConnection*> connections;
int minSize;
int maxSize;
static DBConnectionPool* instance;
DBConnectionPool(int min, int max);
};
#endif // DBCONNECTIONPOOL_H
프로젝트에서 편하게 연결을 끌어올 수 있게 하기 위해 싱글톤 패턴을 적용했다.
어디서나 인스턴스만 가져와서 DB와의 연결을 수행할 수 있다.
실제로 풀의 기능을 수행하는 부분의 구현은 아래와 같다.
DBConnection* DBConnectionPool::getConnection() {
DBConnection* connection;
if (connections.try_pop(connection)) {
return connection;
}
else if (connections.unsafe_size() < maxSize) {
connection = createConnection();
return connection;
}
else {
return nullptr;
}
}
void DBConnectionPool::releaseConnection(DBConnection* connection) {
connections.push(connection);
}
DBConnection* DBConnectionPool::createConnection() {
DBConnection* connection = new DBConnection();
connection->Connect();
return connection;
}
지금은 쌩 포인터를 사용하지만 스마트 포인터를 사용하도록 한다면 확실한 수명 관리가 가능할 것이다.
또한 여러 스레드에서 이에 접근할 가능성이 있기 때문에,
concurrent_queue
를 사용해 스레드 안전하게 처리할 수 있게 했다.
6. 서버 코드에 적용
이제 실제 서버 코드에 올려보자.
풀의 초기화는 프로그램 시작부터 이루어진다.
int main() {
try {
// ...
DBConnectionPool::initializeInstance();
// ...
클라이언트의 접속에 관한 정보도 DB에 올라가기 때문에 미리 초기화가 이루어져야 한다.
클라이언트 정보 업데이트는 Session
의 초기화 시에 이루어진다.
void Session::Init()
{
// ...
auto con = DBConnectionPool::getInstance()->getConnection();
con->UpdateUser(GetPlayerID(), GetIpV4());
DBConnectionPool::getInstance()->releaseConnection(con);
}
인스턴스를 가져온 후 유저 정보를 업데이트하고 나면 연결을 다시 풀에 반납한다.
메시지 정보 업데이트는 MessageHandler
에서 이루어진다.
else
{
auto room = session_->GetCurrentRoom();
if (room) {
room->Broadcast(MakeMessage(message), session_);
auto con = DBConnectionPool::getInstance()->getConnection();
con->UpdateContent(session_->GetPlayerID(), message);
DBConnectionPool::getInstance()->releaseConnection(con);
}
}
이전과 동일하게 DB 처리가 이루어지는 것을 확인할 수 있다.
지금은 명령어가 아닌 단순 채팅에 대해서만 업데이트하게 했지만,
명령어 입력도 정보를 갖고 있다면 좋을 지도 모른다.
7. DB 확인
실제로 데이터가 올라갔나 확인해 보자.
7-1. user
제대로 반영이 되었다.
7-2. usercontent
시행착오의 흔적이 보인다...
여하튼 의도한 대로 잘 적용이 됐다.
DB가 참 만만하지 않은 것 같다...
'Study > C++ & C#' 카테고리의 다른 글
[C#] ###Clicker 동작 개선 (0) | 2024.05.05 |
---|---|
[C++] ChatRoom 구현 (0) | 2024.04.09 |
[C++] Session 다중 접속 (0) | 2024.04.05 |
[C++] Boost.Asio 에코 서버 (0) | 2024.03.29 |
[C#] ###Clicker 개선판 (0) | 2024.03.18 |