서버 데이터 모델 및 클라이언트 구조
온라인 게임이나 서비스를 만들 때 나는 DB 설계부터 시작하는 편이다.
게임 로직부터 만들기 시작하면 나중에 데이터 구조가 계속 바뀌고, 그때마다 서버 Model이나 클라이언트 Model까지 같이 수정해야 하는 경우가 많다. 그래서 처음에 ERD를 작성하고 데이터 구조를 어느 정도 확정한 다음 구현을 시작한다.
기본적인 개발 흐름은 다음과 같다.
ERD 작성 -> SQL 생성 -> DB 구성 -> SQL 파싱 -> Server Model 생성 -> DB/Redis Controller 구성 -> Repository 구성 -> Client Model 생성 -> MVP 구성
ERD와 SQL을 기준으로 Model 생성
ERD 작성이 끝나면 DB 생성 SQL을 만든다.
이 SQL은 그대로 DB에서 실행해서 테이블을 생성하고, 동시에 내가 만든 SQL 파싱 툴을 이용해서 Node.js에서 사용할 TypeScript class 또는 interface를 생성한다.
예전부터 반복적으로 사용하던 방식이라 프로젝트를 만들 때마다 Model을 직접 작성하지 않고 파싱 툴을 통해 생성했다.
이렇게 하는 이유는 DB와 코드의 데이터 구조를 최대한 동일하게 유지하기 위해서다.
DB의 컬럼이 다음과 같이 구성되어 있다면,
CREATE TABLE UserItem
(
userID VARCHAR(64) NOT NULL,
itemCode INT NOT NULL,
itemCount INT NOT NULL,
createdAt DATETIME,
PRIMARY KEY(userID, itemCode)
);
SQL을 파싱해서 대략 다음과 같은 TypeScript Model을 만든다.
export interface UserItem {
userID: string;
itemCode: number;
itemCount: number;
createdAt: Date;
}
클라이언트 역시 같은 SQL을 기준으로 C# Model을 생성한다.
public class UserItem
{
public string userID;
public int itemCode;
public int itemCount;
public DateTime createdAt;
}
이 구조를 사용하면 DB 스키마가 변경되었을 때 서버와 클라이언트 Model을 다시 생성할 수 있다.
물론 실제 프로젝트에서는 서버 전용 컬럼이나 보안상 클라이언트에 전달하면 안 되는 데이터가 있기 때문에 DB Model과 네트워크 DTO를 항상 동일하게 사용하는 것은 아니다. 필요한 경우 별도의 DTO를 만들어 분리한다.
서버 DB Controller
Node.js에서는 DB에 접근하는 코드가 여러 곳에 흩어지지 않도록 공통 Controller를 만들었다.
게임 데이터처럼 전체 데이터를 가져오는 경우에는 비교적 단순하다.
import { Quary } from "./mysql-client";
export class GameDataControllerBase<T> {
private _tbName: string;
constructor(tbName: string) {
this._tbName = tbName;
}
public GetAllData(): Promise<Array<T>> {
return new Promise<Array<T>>(async (resolve, reject) => {
let result: any =
await Quary(`SELECT * FROM ${this._tbName}`, undefined);
return resolve(result as Array<T>);
});
}
}
실제 게임 데이터 Controller는 이 클래스를 상속해서 사용할 수 있다.
export class ItemDataController
extends GameDataControllerBase<ItemData> {
constructor() {
super("ItemData");
}
}
이렇게 해두면 게임 데이터마다 SELECT * FROM ... 같은 코드를 반복해서 작성할 필요가 없다.
게임 데이터는 서버에서 자주 참조되는 데이터이기 때문에 처음부터 전체 데이터를 가져오는 형태를 기본으로 잡았다.
UserData Controller
유저 데이터는 조금 다르다.
대부분의 경우 UUID 하나로 데이터를 찾거나, UUID와 특정 Code를 같이 사용해서 데이터를 찾는다.
예를 들어 다음과 같은 구조다.
User UUID -> UserData
또는
User UUID + Code -> GameData
그래서 공통 Controller도 두 가지 Key를 받을 수 있도록 만들었다.
import { Quary } from "./mysql-client";
export class UserGameDataControllerBase<T> {
protected _tbName: string = "";
protected _primaryKey: string = "";
protected _subPrimaryKey: string = "";
protected _columList: string[];
protected _columnQuaryPart: string = "";
protected _questionMarkQuaryPart: string = "";
constructor(
tbName: string,
primaryKey: string,
subPrimaryKey: string,
template: T
) {
this._tbName = tbName;
this._primaryKey = primaryKey;
this._subPrimaryKey = subPrimaryKey;
this._columList =
Object.getOwnPropertyNames(template);
this._columnQuaryPart =
this.GenerateColumQuaryPart();
this._questionMarkQuaryPart =
this.GenerateQuestionMarkQuaryPart();
}
INSERT도 Model을 기준으로 자동으로 생성한다.
public async InsertData(entity: T) {
await Quary(
`INSERT INTO ${this._tbName}
${this._columnQuaryPart}
VALUES ${this._questionMarkQuaryPart};`,
this.GenerateEntityParams(entity)
);
}
여기서 중요한 것은 SQL 문자열에 실제 데이터를 직접 넣지 않고 ? 파라미터를 사용한다는 점이다.
private GenerateEntityParams(entity: T): Array<any> {
let params = new Array<any>();
for (let index = 0;
index < this._columList.length;
index++) {
const element = this._columList[index];
let descriptors =
Object.getOwnPropertyDescriptors(entity);
params.push(descriptors[element].value);
}
return params;
}
따라서 실제 SQL은 다음과 같은 형태로 실행된다.
INSERT INTO UserItem
(userID, itemCode, itemCount, createdAt)
VALUES (?, ?, ?, ?);
실제 값은 별도의 parameter 배열로 전달한다.
UserData 조회
유저와 코드 두 개를 사용하는 데이터라면 다음과 같이 조회한다.
public GetData(
primaryKey: string,
code: any
): Promise<T> {
return new Promise<T>(async (resolve, reject) => {
let params = new Array<any>();
params.push(primaryKey);
params.push(code);
let result: any = await Quary(
`SELECT * FROM ${this._tbName}
WHERE ${this._primaryKey} = ?
AND ${this._subPrimaryKey} = ?;`,
params
);
return resolve(result[0] as T);
});
}
이 구조를 사용하면 실제 게임 코드에서는 SQL을 직접 작성하지 않는다.
const item =
await userItemController.GetData(userID, itemCode);
필요한 경우 전체 데이터도 가져올 수 있다.
public async GetAllData(
primaryKey: string
): Promise<Array<T>> {
let params = new Array<any>();
params.push(primaryKey);
let result: any = await Quary(
`SELECT * FROM ${this._tbName}
WHERE ${this._primaryKey} = ?;`,
params
);
return result as Array<T>;
}
삭제도 동일하다.
public async DeleteData(
primaryKey: string,
code: any
) {
let params = new Array<any>();
params.push(primaryKey);
params.push(code);
await Quary(
`DELETE FROM ${this._tbName}
WHERE ${this._primaryKey} = ?
AND ${this._subPrimaryKey} = ?;`,
params
);
}
유저 전체 데이터를 삭제해야 하는 경우에는 UUID만 사용한다.
public async DeleteAllData(primaryKey: string) {
let params = new Array<any>();
params.push(primaryKey);
await Quary(
`DELETE FROM ${this._tbName}
WHERE ${this._primaryKey} = ?;`,
params
);
}
Update 역시 변경할 컬럼을 받아서 처리하도록 만들었다.
public async UpdateData(
primaryKey: string,
code: any,
entity: T,
...updateColumn: string[]
) {
let params = new Array<any>();
let updateColumnQuaryPart = "";
for (let index = 0;
index < updateColumn.length;
index++) {
const column = updateColumn[index];
updateColumnQuaryPart += `${column} = ? `;
let descriptors =
Object.getOwnPropertyDescriptors(entity);
params.push(descriptors[column].value);
}
params.push(primaryKey);
params.push(code);
await Quary(
`UPDATE ${this._tbName}
SET ${updateColumnQuaryPart}
WHERE ${this._primaryKey} = ?
AND ${this._subPrimaryKey} = ?;`,
params
);
}
이런 식으로 공통 CRUD를 만들어두면 실제 콘텐츠 개발에서는 DB 접근 자체보다 데이터의 의미와 게임 로직에 집중할 수 있다.
Redis 캐시
Redis도 DB Controller와 크게 다르지 않게 구성했다.
유저 데이터를 Key를 기준으로 저장하고 JSON으로 직렬화했다.
import IORedis from "ioredis";
export default class GameModelCacheBase<T> {
private _key: string = "";
private _redisClient: IORedis.Redis;
constructor(
key: string,
redisClient: IORedis.Redis
) {
this._key = key;
this._redisClient = redisClient;
}
public async SetData(
userID: string,
model: T
) {
await this._redisClient.set(
this._key + userID,
JSON.stringify(model)
);
}
public async GetData(
userID: string
): Promise<T> {
return new Promise<T>(async (resolve, reject) => {
return resolve(
JSON.parse(
await this._redisClient.get(
this._key + userID
) as string
) as T
);
});
}
public async ExistData(
userID: string
): Promise<boolean> {
return new Promise<boolean>(
async (resolve, reject) => {
resolve(
1 ==
await this._redisClient.exists(
this._key + userID
)
);
}
);
}
public async RemoveData(
userID: string
) {
await this._redisClient.del(
this._key + userID
);
}
}
실제 Redis Key는 프로젝트 규칙에 따라 다음과 같이 만들 수 있다.
UserData:{UUID}
Inventory:{UUID}
Character:{UUID}
Quest:{UUID}:{QuestCode}
결국 Redis에서도 데이터 성격에 따라 저장소를 나눌 수 있다.
GameData -> Dictionary / 공통 데이터
UserData -> User UUID
UserGameData -> User UUID + Code
DB와 Redis를 하나로 묶기
여기서 한 단계 더 나가면 DB Controller와 Redis Cache를 게임 코드에서 직접 사용하는 것이 아니라 하나의 Repository로 묶을 수 있다.
가장 단순한 형태는 다음과 같다.
export class UserDataRepository<T> {
private _db: UserGameDataControllerBase<T>;
private _cache: GameModelCacheBase<T>;
constructor(
db: UserGameDataControllerBase<T>,
cache: GameModelCacheBase<T>
) {
this._db = db;
this._cache = cache;
}
public async GetData(
userID: string,
code: any
): Promise<T> {
let cache =
await this._cache.GetData(userID);
if (cache != null) {
return cache;
}
let data =
await this._db.GetData(userID, code);
if (data != null) {
await this._cache.SetData(userID, data);
}
return data;
}
}
개념적으로는 다음과 같은 구조다.
Get -> Redis 확인 -> Hit -> 반환
Get -> Redis 확인 -> Miss -> DB 조회 -> Redis 저장 -> 반환
그러면 게임 로직에서는 Redis와 MySQL을 직접 구분할 필요가 없다.
const userData =
await userDataRepository.GetData(userID, code);
Set 역시 Repository에서 DB와 Redis를 같이 처리하도록 만들 수 있다.
public async SetData(
userID: string,
code: any,
entity: T
) {
await this._db.UpdateData(
userID,
code,
entity
);
await this._cache.SetData(
userID,
entity
);
}
다만 실제 서비스에서는 여기서 끝나지 않는다.
DB 저장은 성공했는데 Redis 저장이 실패하거나 반대 상황이 발생할 수 있기 때문에 장애 상황에서 어느 데이터를 기준으로 복구할지까지 결정해야 한다. 캐시 무효화 방식, TTL, Write Through, Cache Aside 같은 정책도 서비스 성격에 따라 선택할 수 있다.
내가 사용한 구조에서는 기본적으로 DB를 원본 데이터로 보고 Redis를 빠른 접근을 위한 캐시로 사용하는 형태에 가깝다.
유저 접속 시 데이터 흐름
이 구조를 실제 유저 세션에 적용하면 대략 다음과 같은 흐름이 된다.
Login
-> User UUID 확인
-> DB UserData 조회
-> Redis 캐싱
-> Session 시작
-> 게임 로직에서 Redis/Repository 접근
-> 데이터 변경
-> DB + Redis 반영
-> Session 종료
접속 중인 유저의 데이터를 계속 MySQL에서 읽는 대신 Redis를 중심으로 접근하게 만들기 때문에 반복적인 DB 접근을 줄일 수 있다.
특히 게임에서는 아이템, 퀘스트, 캐릭터 상태처럼 같은 유저 데이터를 짧은 시간 동안 반복해서 조회하는 경우가 많기 때문에 이런 캐싱 계층이 의미가 있다.
Client Model
서버에서 사용한 SQL을 그대로 클라이언트 Model 생성에도 사용했다.
SQL을 C# Model로 파싱해서 GameData와 UserData를 구성한다.
public class ItemData
{
public int code;
public string name;
public int price;
}
public class UserItem
{
public string userID;
public int itemCode;
public int itemCount;
}
GameData는 모든 유저가 공유하는 데이터이기 때문에 클라이언트에서 캐싱해둘 수 있다.
예를 들어 아이템 데이터라면 서버에서 매번 받아오는 것이 아니라 초기 다운로드 이후 로컬에서 사용하는 식이다.
GameData
-> Client Cache
-> 게임 실행 중 반복 사용
-> 버전 변경
-> 데이터 업데이트
버전 정보를 별도로 가지고 있다면 서버와 클라이언트의 버전을 비교해서 변경된 경우에만 업데이트하는 방식도 가능하다.
UserData는 유저마다 다르기 때문에 일반적으로 로그인 과정에서 가져온다.
Login
-> UserData Request
-> Server
-> UserData Response
-> Client Model 생성
-> 게임에서 사용
또는 클라이언트에 UserData를 저장해두고 해시를 비교해서 변경 여부를 확인하는 방법도 사용할 수 있다.
Local UserData
-> Hash
-> Server Hash 비교
-> 동일 -> Local Data 사용
-> 다름 -> Server Data Download
데이터 크기와 변경 빈도에 따라 적절한 방법을 선택하면 된다.
MVP 구조
Model이 만들어진 이후에는 클라이언트 개발을 MVP 구조로 연결했다.
기본적인 구조는 다음과 같다.
Model <-> Presenter <-> View
View는 UI 표현과 입력을 담당하고 Presenter는 View와 Model 사이의 연결을 담당한다.
나는 프로젝트에서 Presenter의 사용 범위에 따라 이름을 다르게 사용했다.
Presenter 하나가 하나의 View를 담당하는 경우에는 Ctrl이라고 이름을 지었다.
Model <-> Ctrl <-> View
예를 들어 인벤토리 하나를 담당한다면 다음과 같은 형태다.
public class InventoryCtrl
{
private UserData _model;
private InventoryView _view;
public void Initialize(
UserData model,
InventoryView view)
{
_model = model;
_view = view;
Refresh();
}
private void Refresh()
{
_view.SetItemCount(
_model.itemCount
);
}
}
반대로 하나의 Presenter가 여러 View를 관리하는 경우에는 Manager라는 이름을 사용했다.
Model <-> Manager <-> View1
<-> View2
<-> View3
예를 들어 게임 UI 전체를 관리해야 하는 경우다.
public class UIManager
{
private InventoryView _inventoryView;
private QuestView _questView;
private CharacterView _characterView;
public void OpenInventory()
{
_inventoryView.Show();
}
public void OpenQuest()
{
_questView.Show();
}
public void OpenCharacter()
{
_characterView.Show();
}
}
이것은 MVP 패턴 자체를 변형한 것이라기보다는 프로젝트 내부에서 역할을 구분하기 위한 네이밍 규칙에 가깝다.
Handler와 System
모든 로직을 Presenter에 넣지는 않았다.
여러 곳에서 공통적으로 사용하는 데이터 처리 로직이나 규모가 큰 게임 로직은 Handler 또는 System으로 분리했다.
View
-> Ctrl / Manager
-> Handler / System
-> Model
예를 들어 인벤토리 아이템 추가 같은 로직이 여러 UI에서 사용된다면 Presenter마다 직접 구현하지 않고 Handler로 분리한다.
public class InventoryHandler
{
public bool AddItem(
UserData userData,
int itemCode,
int count)
{
// 아이템 추가 로직
return true;
}
}
그러면 여러 Presenter에서 동일한 Handler를 사용할 수 있다.
public class InventoryCtrl
{
private InventoryHandler _handler;
public void AddItem(
UserData userData,
int itemCode,
int count)
{
_handler.AddItem(
userData,
itemCode,
count
);
}
}
규모가 큰 시스템이라면 Handler보다 System 단위로 분리했다.
예를 들어 전투, 인벤토리, 퀘스트, 캐릭터 성장 같은 기능을 독립적인 System으로 구성하는 방식이다.
View Generator
View도 필요할 때 직접 생성하는 코드가 반복되지 않도록 Generator를 사용했다.
Inspector에서 미리 연결되어 있는 View라면 그대로 사용하고, 런타임에 생성해야 하는 View라면 Generator가 생성한 뒤 Presenter에 주입한다.
public class InventoryGenerator
{
public InventoryCtrl Generate(
UserData model)
{
InventoryView view =
CreateView();
InventoryCtrl ctrl =
new InventoryCtrl();
ctrl.Initialize(
model,
view
);
return ctrl;
}
private InventoryView CreateView()
{
// View 생성
return new InventoryView();
}
}
결과적으로 View 생성 책임과 View 동작 책임도 분리된다.
Generator
-> View 생성
-> Presenter 생성
-> Model 주입
-> View 주입
-> 연결 완료
전체 구조
결국 이 구조에서 중요한 것은 각각의 Controller나 Manager 클래스 자체가 아니다.
처음 DB를 정의하는 단계부터 클라이언트 UI까지 데이터가 하나의 흐름으로 이어지도록 만드는 것이 핵심이었다.
ERD
-> SQL
-> Server Model
-> DB Controller
-> Redis Cache
-> Repository
-> DTO
-> Client Model
-> MVP
-> Ctrl / Manager
-> Handler / System
-> View
-> Generator
서버에서는 DB를 원본 데이터 저장소로 사용하고 Redis를 캐시 계층으로 사용한다.
DB 접근은 Controller로 추상화하고, Redis 역시 별도의 Cache 클래스로 추상화한다. 이후 Repository에서 둘을 묶어 실제 게임 로직에서는 저장소의 구현을 직접 알 필요가 없도록 만든다.
클라이언트에서는 같은 SQL을 기준으로 Model을 생성하고 GameData와 UserData를 분리한다. GameData는 캐싱하고 버전 기반으로 갱신하며 UserData는 로그인 과정에서 가져오거나 로컬 데이터와 해시를 비교해서 필요한 경우 갱신한다.
그 위에 MVP 구조를 적용하고 Presenter 하나가 하나의 View를 담당하면 Ctrl, 여러 View를 관리하면 Manager라는 이름으로 구분했다. 공통 로직은 Handler, 규모가 큰 게임 로직은 System으로 분리하고 런타임 View 생성은 Generator가 담당하도록 했다.
결국 내가 만들려고 했던 것은 단순한 CRUD 클래스가 아니라 DB 스키마를 기준으로 서버와 클라이언트의 데이터 구조를 맞추고, DB와 Redis를 추상화한 뒤, 그 데이터를 MVP 기반의 클라이언트 구조까지 연결하는 공통 개발 기반이었다.
프로젝트가 커질수록 콘텐츠 하나를 추가할 때마다 DB 접근 코드, 캐시 코드, Model, UI 연결 코드를 각각 새로 작성하는 것은 비효율적이다. 반대로 이 부분을 미리 공통화해두면 새로운 데이터 테이블이 추가되어도 SQL을 작성하고 Model을 생성한 뒤 필요한 Controller와 View를 연결하는 정도로 개발을 진행할 수 있다.
이 방식의 핵심은 결국 반복되는 데이터 처리 작업을 코드 생성과 공통 계층으로 밀어내고, 실제 개발자는 게임의 규칙과 콘텐츠에 집중하도록 만드는 것이었다.