강의는 sql server를 쓰고, 나는 postgresql을 사용하기때문에 쿼리 쓰는 부분에서 이상한게 있을 수 있음 없을수도 있고


db를 편리하게 사용하기 위한 준비 작업을 해봤다.

개인 프로젝트로 따지면 이정도로도 충분하고도 넘친다고 한다.

 

만약 큰프로젝트나 게임출시 후 라이브 병행까지 한다 하면 여기서 추가해 생각할게 많다.

특히 버전관리가 큰 문제라고 한다.

 

버전 관리시스템? git. (쓰고 있긴 하다.)

이게 왜 라이브에서 중요하냐면, 항상 최신 버전을 배포하는게 아니다. 한번 안정화 되어서 버그가 어느정도 수정되면 그걸 라이브로 내보내고, 다음 컨텐츠를 이어서 작업하게 되는 것. 만약 지금 0.5 버전을 작업하고있는데 0.1 버전에 버그가 생긴다면 0.1버전으로 가서 버그를 수정하고 배포(?)를 한다고 한다.

 

근데 데이터베이스가 들어가면 버전관리가 애매해진다.

db서버에 데이터가 들어가지만 그걸 git에 올리고 있지는 않다. 중요한 정보가 많이 db에 저장되어있을텐데 db를 날리고 다시 생성하는것은 말이 안된다. 어떤식으로든 내가 작업하는 소스코드와 db 버전을 맞춰줄 필요가 있는 것.

 

ORM 기술을 사용하면 쉽게 해결할 수 있다고 한다

더보기

강의자의 이전 강의 C# 서버에서는 Entity Framework core ORM을 이용해서 db관리를 했다고 한다.

- GPT

쉽게 말하면 객체 지향 프로그래밍 언어의 객체와 관계형 데이터베이스의 테이블을 자동으로 매핑해주는 기술이에요.

ORM은 **“SQL을 직접 쓰는 대신, 프로그래밍 언어의 객체와 메서드로 데이터베이스를 조작할 수 있게 해주는 기술”**

핵심 개념

  • 객체 ↔ 테이블
    클래스(객체)와 DB의 테이블을 연결
  • 속성 ↔ 컬럼
    클래스의 필드(멤버 변수)와 DB의 컬럼을 연결
  • 인스턴스 ↔ 행(row)
    객체의 인스턴스와 DB의 레코드를 연결

즉, SQL을 직접 작성하지 않고도, 객체를 다루듯이 DB 데이터를 다룰 수 있도록 도와줘요.

장점

  1. 개발 생산성 향상
    SQL을 직접 쓰지 않고도 CRUD(Create, Read, Update, Delete) 작업을 할 수 있음.
  2. 유지보수 편리
    데이터베이스 구조가 바뀌더라도 코드 수정이 단순해짐.
  3. 객체지향적 접근
    데이터베이스 작업도 코드 스타일이 통일돼 직관적으로 이해 가능.

단점

  1. 성능 문제
    ORM이 내부적으로 SQL을 생성하기 때문에, 최적화된 SQL보다 느릴 수 있음.
  2. 복잡한 쿼리 한계
    조인(join)이나 집계 같은 복잡한 SQL은 ORM만으로 표현하기 어려워서 결국 SQL을 써야 할 때가 있음.
  3. 학습 곡선
    프레임워크마다 규칙이 달라서 익숙해지려면 시간이 필요.

대표적인 ORM 프레임워크

  • Java: Hibernate, JPA(Spring Data JPA)
  • Python: SQLAlchemy, Django ORM
  • C#/.NET: Entity Framework
  • JavaScript/Node.js: Sequelize, TypeORM

만약 db 버전과 소스코드 버전이 안맞으면 소스코드에서는 id와 gold만 다루는데, 쿼리에서는 name date까지 있을것으로 예상하여 쿼리를 날리면 문제가 발생할 것( 적절한 예시는 아닌 것 같음 ).

 

아무튼 이를 해결하기위한 2가지 스타일이 있다.

1. up과 down같은 느낌의 파일을 손수 관리하는 것(?). 보통 DBA 직군의 사람이 담당해서 하는 일이라고 한다.

db_01.sql에 첫번째 버전에서 해야하는 행동들을 다 넣어주는 것.

CREATE TABLE public.Version
(
	version FLOAT NOT NULL
);


CREATE TABLE public.gold
(
	id SERIAL PRIMARY KEY, 
	gold INT NULL,
	name VARCHAR(50) NULL,
	createDate TIMESTAMP NULL
);
...

버전 관리 테이블 만들고

gold라는 테이블 만듦

(insert같은 쿼리들도 다 저기에 들어갈 것.)

 

그러고 다음 버전이 필요하다. DB에 바뀜이 있었다 하면 DBA가 쿼리나 테이블을 추가하는걸 만든 다음에 DB_02 이런 느낌으로 관리하는 것.

 

서버도 일종의 config 파일을 갖고있다. db커넥션 스트링, 서버 포트, ip등등 정보들을 config에 따로 빼서 관리한다.

거기에다가 현재 서버가 연동해야하는 db버전도 같이 기입하는 것. 현재 버전 02번이다 이렇게. 요구하는 db버전이 2번인데, db를 접근해서 버전을 확인했더니 1번이라 한다면 버전이 안맞네? 하고 띄우지도 않고 crash를 내버리는 것. 이런식으로 버전을 맞추는게 한가지 방법이다.

1->2나 2->1로 버전이 넘어갈 때에 대한 처리를 다 만들어야 하고 스크립트를 만들어야할게 아주 많다.

 

2. 

위 방법이 나쁘지는 않지만 DBA가 따로 있는게 아니라 서버직군 프로그래머가 db까지 관리하는 곳도 더러 있다. 그런경우는 이렇게 버전 관리 하는것은 굉장히 귀찮은 일이다.

그래서 오늘 알아볼것은 일종의 간단한 orm을 직접 만드는 것이다. 그래서 db원본 설계(?)는 xml파일로 관리한다음에 그걸 자동화툴로 때려서 만들어주면 DBBind같은 계열의 클래스도 자동으로 만들어줄 뿐만 아니라 db버전 관리도 쉽게 업데이트해줄 수 있는 스크립트도 자동으로 생성해주는 그런게 있다. (당장은 뭔소린지 잘 모르겠다.)

 

일단 GameDB.xml을 만들어 데이터베이스 설계를 해보도록 하자.

<?xml version="1.0" encoding="utf-8"?>
<GameDB>
	<Table name="Gold">
		<Column name="id" type="int" notnull="true"/>
		<Column name="gold" type="int" notnull="false"/>
		<Column name="name" type="nvarchar[50]" notnull="false"/>
		<Column name="createDate" type="datetime" notnull="false"/>
		<index type="clustered">
			<PrimaryKey/>
			<Column name="id"/>
		</index>
	</Table>
</GameDB>

이런식으로 만들면 테이블을 xml로 묘사 하게 된 것. 그래서 xml 자체를 커밋해서 테이블이 이렇게 만들어져야 한다 하고 알려주는 것. 그리고 xml을 토대로 나중에 DBBind같은 애들도 자동으로 생성해주게 될 것.

 

그리고 지금은 table을 만든 다음에 INSERT를 할때 장황하게 뭐시기뭐시기 쓰는데 이거를 매번 쿼리 자체를 날리는게 아니라 프로시저로 만들어주게 될것

더보기

- gpt

📌 프로시저(Stored Procedure)란?

데이터베이스에 저장해 두고 재사용할 수 있는 SQL 코드 묶음입니다.
일종의 **DB 안에서 실행되는 함수(메서드)**라고 생각하면 이해가 쉬워요.

  • 일반 SQL: 한 번 실행하고 끝.
  • 프로시저: DB 안에 저장해두고 필요할 때 이름만 불러서 실행.
CREATE OR REPLACE PROCEDURE add_user(name TEXT, gold INT)
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO users (name, gold) VALUES (name, gold);
END;
$$;

이렇게 저장해두면

CALL add_user('Alice', 100);

나중에 호출만 하면 됨.

🛠️ 특징

  • 입력 매개변수 / 출력 매개변수를 가질  수 있음.
  • 테이블 수정(INSERT, UPDATE, DELETE) 같은 작업을 자동화할 수 있음.
  • 복잡한 로직을 DB 내부에서 처리 가능.

⚖️ 장점

  • 반복되는 SQL을 재사용 → 개발 편리
  • DB 안에서 실행 → 네트워크 트래픽 감소
  • 보안 강화 → 프로시저만 권한 부여 가능

⚠️ 단점

  • DB에 의존적 → 이식성 낮음 (DBMS마다 문법 다름)
  • 로직이 DB에 들어가서 → 애플리케이션 코드와 분리될 수 있음
  • 너무 복잡하게 만들면 → 유지보수 어려움 
<?xml version="1.0" encoding="utf-8"?>
<GameDB>
	/*...*/
	<Procedure name="spInsertGold">
		<Param name="@gold" type="int"/>
		<Param name="@name" type="nvarchar(50)"/>
		<Param name="@createDate" type="datetime"/>
		<Body>
			<![CDATA[
			INSERT INTO public.Gold (gold, name, createDate) VALUES (@gold, @name, @createDate);
			]]>
		</Body>
	</Procedure>
	
	<Procedure name="spGetGold">
		<Param name="@gold" type="int"/>
		<Body>
			<![CDATA[
			SELECT id,gold,name,createDate FROM public.gold WHERE gold = (@gold);
			]]>
		</Body>
	</Procedure>
</GameDB>

쿼리를 직접 날리는게 아니라 위와같이 만들어진 스토어드 프로시저를 호출하게 유도할 것이다.

 

이제부터 우리만의 ORM 시스템을 만들어볼건데 갈 길이 멀다.

일단 xml파일을 파싱해서 정보들을 추출할 수 있어야한다.

그런데 c++에는 표준에서 xml을 파싱하는 라이브러리가 없다. xml파서를 만드는건 미친짓이고, 외부 라이브러리를 가져와야 한다.

구글의 래피드XML을 쓸건데, 굳이 가서 받지말고 그냥 강의자료에서 갖다쓰라 한다. 이게 좋은게 따로 라이브러리 파일 필요 없이 소스코드만 복붙하면 사용할 준비가 끝난다.


파읽 읽고 뭐 하는 작업들 하는 FileUtils클래스, Xml 파싱 도와줄 XmlParser클래스 만들기

파일 입출력이나 Xml파싱 부분은 대단한 부분이 아니기때문에 그냥 복붙하고, 설명만 좀 하겠다고 한다.

filesystem은 c++17에 추가됐으니 서버코어 속성에서 언어 - 언어표준을 c++17으로 변경하자.

#include "pch.h"
#include "FileUtils.h"
#include <filesystem>
#include <fstream>

/*-----------------
	FileUtils
------------------*/

namespace fs = std::filesystem;

Vector<BYTE> FileUtils::ReadFile(const WCHAR* path)
{
	Vector<BYTE> ret;

	fs::path filePath{ path };

	const uint32 fileSize = static_cast<uint32>(fs::file_size(filePath));
	ret.resize(fileSize);

	basic_ifstream<BYTE> inputStream{ filePath };
	inputStream.read(&ret[0], fileSize);

	return ret;
}

String FileUtils::Convert(string str)
{
	const int32 srcLen = static_cast<int32>(str.size());

	String ret;
	if (srcLen == 0)
		return ret;

	const int32 retLen = ::MultiByteToWideChar(CP_UTF8, 0, reinterpret_cast<char*>(&str[0]), srcLen, NULL, 0);
	ret.resize(retLen);
	::MultiByteToWideChar(CP_UTF8, 0, reinterpret_cast<char*>(&str[0]), srcLen, &ret[0], retLen);

	return ret;
}

 

readfile은 경로를 받아서 ifstream을 이용해서 쫙 긁어와서 저장해 뱉어줌.

참고로 xml파일이 utf-8로 인코딩 되어있는데 다른걸로 바꿔주고싶으면 다른이름으로 저장해서 저장옆에 화살표 눌러서 인코딩하여 저장으로 바꿀 수 있다. 그런데 파일같은 경우는 utf8을 사용하는경우가 더 많다고 한다. 영문이 더 많으면utf8이 더 이득이기도 하고. 어쨋든 그래서 readfile은 utf8을 이용해서 작동할 것.

 

그리고 Container.h에서 WString과 String 구분돼있는데 이것을 String을 버리고 WString을 String으로 바꾸자. WChar만 쓸거니까.

 

그리고 그 내용물을 utf16으로 변경하기 위해 convert함수가 있다.

 

xmlparser

복잡해 보일 수 있는데 XmlParser를 만들어서 ParseFromFile을 호출하면 path를 이용해서 파일을 읽고 convert로 wchar로 변환한 다음 _document를 만들어주고 (래피드XML에서 지원) parse호출(이것도). 그러면 firstnode를 root로 꺼내서 리턴한다. xml은 트리 구조라서 root만 있으면 다른것들도 다 접근할 수 있다는 듯 하다.

 

무튼 이런 기능들을 이용해서 gameserver에서 간단하게 파싱을 해보자. 이것도 그냥 복붇하라 한다.

int main()
{
	cout << "SERVER" << endl;

	XmlNode root;
	XmlParser parser;
	if (parser.ParseFromFile(L"GameDB.xml", OUT root) == false)
		return 0;

	Vector<XmlNode> tables = root.FindChildren(L"Table");
	for (XmlNode& table : tables)
	{
		String name = table.GetStringAttr(L"name");
		String desc = table.GetStringAttr(L"desc");

		Vector<XmlNode> columns = table.FindChildren(L"Column");
		for (XmlNode& column : columns)
		{
			String colName = column.GetStringAttr(L"name");
			String colType = column.GetStringAttr(L"type");
			bool nullable = !column.GetBoolAttr(L"notnull", false);
			String identity = column.GetStringAttr(L"identity");
			String colDefault = column.GetStringAttr(L"default");
			// Etc...
		}

		Vector<XmlNode> indices = table.FindChildren(L"Index");
		for (XmlNode& index : indices)
		{
			String indexType = index.GetStringAttr(L"type");
			bool primaryKey = index.FindChild(L"PrimaryKey").IsValid();
			bool uniqueConstraint = index.FindChild(L"UniqueKey").IsValid();

			Vector<XmlNode> columns = index.FindChildren(L"Column");
			for (XmlNode& column : columns)
			{
				String colName = column.GetStringAttr(L"name");
			}
		}
	}

	Vector<XmlNode> procedures = root.FindChildren(L"Procedure");
	for (XmlNode& procedure : procedures)
	{
		String name = procedure.GetStringAttr(L"name");
		String body = procedure.FindChild(L"Body").GetStringValue();

		Vector<XmlNode> params = procedure.FindChildren(L"Param");
		for (XmlNode& param : params)
		{
			String paramName = param.GetStringAttr(L"name");
			String paramType = param.GetStringAttr(L"type");
			// TODO..
		}
	}

	ASSERT_CRASH(GDBConnectionPool->Connect(1, L"Driver={PostgreSQL Unicode(x64)};Server=localhost;Port=5432;Database=opentutorials;Uid=postgres;Pwd=timeisgold12!@;"));

	ClientPacketHandler::Init();

	ServerServiceRef service = MakeShared<ServerService>(
		NetAddress(L"127.0.0.1", 7777),
		MakeShared<IocpCore>(),
		MakeShared<GameSession>, // TODO : SessionManager 등
		100);

	ASSERT_CRASH(service->Start());

	for (int32 i = 0; i < 5; ++i)
	{
		GThreadManager->Launch([&service]()
			{
				DoWorkerJob(service);
			});
	}
	DoWorkerJob(service);
	GThreadManager->Join();
}

 

GameDB.xml을 파싱하면 root가 채워질 것.

findChildren으로 table을 싸그리 찾아 Vector에 넣는다.

그리고 테이블 하나하나를 순회하는데 stringattr를 찾는다. name이 있는지. 그런식으로 추출한다.

desc는 주석같은것으로,  </> 안에 desc="골드 테이블" 이런식으로 넣는 것.

그리고 내부에 컬럼을 찾아서 순회하면서 거기의 정보들도 하나씩 추출해 메모리에 들고있어서 해당 테이블의 구조를 정확하게 파악할 수 있도록 할 것이다.

같은 방식으로 Index도 찾고있는데 지금은 clustered 인덱스만 있는데 경우에 따라 많아질 수 있으니 전부 확인한다.

더보기

Index란?

데이터를 빠르게 찾기 위해 사용하는 일종의 목차(색인) 구조이며, 특정 컬럼을 기준으로 정렬된 자료구조(B-Tree 등)로 만들어진다.

Clustered Index: 테이블 자체가 인덱스 기준으로 물리적으로 정렬되어 저장된다. (테이블당 하나만 가능, 보통 Primary Key)

Non-Clustered Index: 별도의 목차 구조로, 인덱스 컬럼과 실제 데이터의 위치(포인터)를 저장한다. (여러 개 생성 가능)

 

table을 clustered Index 없이 생성하면 (일반 Index가 있어도) 그냥 들어오는 대로 정렬되어있다.(Heap)

만약 clustered Index가 없는 상태에서 추가한다면, DB는 알아서 heap구조에서 B-Tree 구조로 재생성한다.

일반 Index는 따로 만들어져 있고 Row 주소를 따라가서 데이터를 가져온다고 한다.

CREATE INDEX ix_gold_name ON public.gold (name);

이런식으로 name 인덱스를 만들어두면 추후에 gold 테이블에서 "철수" 를 찾을 때 name 인덱스를 이용해 매우 빠르게 찾아낼 수 있는것.

 

프로시저도 마찬가지로 하나씩 순회하면서 파싱을 한다.

다음 시간에는 이런 정보를 추출하고 끝내는게 아니라 이걸 이용해서 실질적으로 db를 복원하는 작업. 이 테이블이랑 프로시저 등등을 만드는 작업을 해보도록 하자.

'강의 수강 > 게임서버(1)' 카테고리의 다른 글

79. ORM  (0) 2025.09.10
77. DB Bind  (0) 2025.09.05
76. DB Connection  (0) 2025.09.01
75. JobTimer  (0) 2025.08.25
74. JobQueue #5  (0) 2025.08.25

+ Recent posts