서버 개발자로 근무하면서 가장 비중이 큰 업무를 하나 고르라고 한다면 데이터 관리를 고를 것이다.
비즈니스에는 다양한 유형의 데이터가 사용된다. 크게 정형 데이터, 비정형 데이터로 분류되고 정형 데이터는 사용자 데이터, 메타 데이터 등 사전 정의된 데이터, 비정형 데이터는 텍스트, 이미지 등으로 분류될 수 있다.
본 게시글은 이 수많은 데이터 유형 중 비즈니스 로직의 메타 데이터를 정의한 '데이터 테이블'을 현업 게임 개발자의 관점에서 다룬다. 또한 본 게시글에서 사용되는 '메타 데이터', '테이블', '콜렉션'과 같은 용어는 실제 의미와 조금 다를 수 있음을 감안해주길 바란다.
게임을 구성하는데 있어 메타 데이터는 반드시 필요한 요소이다.
몬스터가 단 한 종류만 있는 RPG라도 해당 몬스터를 정의하는 데이터가 있기 마련이다.
다른 조직은 이러한 메타 데이터를 어떻게 관리하는지 모르겠다. 다만 내가 속했던 모든 조직은 규모와 상관없이 엑셀과 같은 테이블로 관리했다.
데이터를 테이블로 관리하면 데이터간의 관계에 집중하여 구조 파악이 수월하다. 관계형 데이터베이스에 익숙하다면 이러한 테이블 구조가 쉽게 눈에 들어올 것이다. 프로그래머 뿐만 아니라 직접적으로 테이블을 관리하는 기획자 입장에서도 데이터를 정의하기 훨씬 수월할 것이다.
다만 프로젝트가 진행됨에 따라 규모가 커지면서 테이블이 서로 복잡하게 얽히기 시작한다면 그 때부터 지옥도가 펼쳐질 것이다. (당장 우리 프로젝트만 하더라도 사용되는 테이블이 50개가 넘어간다...)
물론 처음부터 프로젝트의 규모를 생각해서 테이블 구조를 설계한다면 OK다. 하지만 인간은 결코 신이 아니고 미래를 예측할 수 없다. 기획은 언제든 바뀔 수 있으며 새로운 스펙이 추가되거나 삭제될 수 있다. 그에 따라 자연스럽게 데이터 모델이 변경되고 테이블 또한 수정될 수 있다. 또한 필자의 프로젝트와 같이 테이블이 지나치게 많아진다면 자체적인 다이어트(...)를 진행할 수도 있을 것이다.
그렇다면 여기서 한가지 고려할 점이 생긴다.
만약 처음부터 기획 레벨에서 정의된 데이터 테이블 구조를 비즈니스 로직에서 사용할 경우 어떤 일이 벌어질까?
아래의 테이블들을 예시로 들겠다.
- EntityTable
| UID | TypeID | StatID |
|---|---|---|
| MN_000000 | Goblin_00 | Stat_0000 |
- EntityTypeTable
| TypeID | Type | Gender |
|---|---|---|
| Goblin_00 | Human | Men |
- EntityStatTable
| StatID | Atk | Def |
|---|---|---|
| Stat_0000 | 10 | 5 |
해당 테이블의 값을 직접 로직에서 사용한다면 코드의 흐름은 아래와 같을 것이다.
var uid = "MN_000000";
var tid = EntityTable[uid].TypeID;
var sid = EntityTable[uid].StatID;
var type = EntityTypeTable[tid].Type;
var gender = EntityTypeTable[tid].Gender;
var atk = EntityStatTable[sid].Atk;
var def = EntityStatTable[sid].Def;
/* Game Logic */
3개의 테이블에 직접 접근하여 ID로 필요한 값을 읽어와 로직에 사용한다.
굉장히 정석적인 사용법이지만 데이터 테이블에 컬럼 하나가 추가/제거될 때마다 매번 로직을 수정해줘야 한다. 비즈니스 로직과 메타 데이터간의 직접적인 의존성이 생겨버리는 꼴이다.
이러한 문제를 해결하려면 기획 레벨의 관리용 데이터와 프로그래밍 레벨의 로직용 데이터를 분리해야 한다. 실제로 데이터 테이블의 구조를 로직에서 직접 사용하기엔 다소 어색한 경우가 종종 있을 수 있기에 이 편이 훨씬 이롭다.
아래는 위 코드를 개선한 형태다.
class Entity
{
string uid;
string type;
string gender;
int atk;
int def;
}
var uid = "MN_000000";
var tid = EntityTable[uid].TypeID;
var sid = EntityTable[uid].StatID;
var type = EntityTypeTable[tid].Type;
var gender = EntityTypeTable[tid].Gender;
var atk = EntityStatTable[sid].Atk;
var def = EntityStatTable[sid].Def;
var entity = new Entity
{
uid,
type,
gender,
atk,
def
}
/* Game Logic with Entity */
데이터 테이블에서 정의한 각각의 속성을 합쳐 하나의 개체(Entity)를 생성하여 로직에서 사용한다.
게임 로직은 Entity와의 의존성만 가질 뿐 데이터 테이블의 수정에 영향이 없다. 이는 데이터 테이블이 수정되더라도 Entity의 구조만 수정하면 된다는 뜻이기도 하다.
만약 사전에 Entity를 생성해둔다면 코드는 더욱 아름다워진다.
class Entity
{
string uid;
string type;
string gender;
int atk;
int def;
}
var uid = "MN_000000";
var entity = EntityCollection[uid];
/* Game Logic with Entity */
이로써 게임 로직은 데이터 테이블로부터 완전히 자유로워졌다! (기획 수정에서 한걸음 자유로워졌다)
이제 우리는 데이터 테이블에서 정의한 속성으로 Entity의 콜렉션을 만들어 로직에 사용할 것이다.
가볍게 아래와 같이 정의할 수 있을 것이다.
class Entity
{
string uid;
string type;
string gender;
int atk;
int def;
}
class EntityCollection
{
Dictionary<string, Entity> entityDict;
Entity this[string uid] => entityDict[uid];
}
var uid = "MN_000000";
var entity = EntityCollection[uid];
지금의 구조도 충분히 좋다. 현업에서 사용되어도 손색없을 정도다.
다만 한가지 더 고려하면 좋을 것 같다.
아래 테이블 예시를 보자.
- EntityTable
| UID | TypeID | StatID |
|---|---|---|
| MN_000000 | Goblin_00 | Stat_0000 |
| MN_000001 | Goblin_00 | Stat_0001 |
| MN_000002 | Goblin_00 | Stat_0002 |
| MN_000003 | Goblin_01 | Stat_0000 |
| MN_000004 | Goblin_01 | Stat_0001 |
| MN_000005 | Goblin_01 | Stat_0002 |
- EntityTypeTable
| TypeID | Type | Gender |
|---|---|---|
| Goblin_00 | Human | Men |
| Goblin_01 | Human | Women |
- EntityStatTable
| StatID | Atk | Def |
|---|---|---|
| Stat_0000 | 10 | 5 |
| Stat_0001 | 20 | 7 |
| Stat_0002 | 25 | 10 |
위 데이터 테이블 정의로 인해 6개의 Entity가 생성될 것이다.
"MN_000000", "MN_000001", "MN_000002"는 "Goblin_00"를 공유하고 "MN_000003", "MN_000004", "MN_000005"는 "Goblin_01"를 공유한다. Stat 역시 마찬가지로 공유하는 Entity가 존재한다.
이러한 객체를 미리 생성해둘 경우 불필요하게 메모리를 차지할 수 있다. 게임 로직의 입장에서야 별 차이 없겠지만 이왕이면 최대한 최적화를 해주는게 서버 관리 입장에서 좋지 않겠는가?
콜렉션을 사용하는 입장에서 내부 구현은 중요하지 않다. 그저 콜렉션은 uid를 입력하면 Entity를 반환하는 자판기와 같다. 그렇기에 일단 EntityCollection을 아래와 같이 추상화 할 수 있다.
class EntityCollection
{
/* Models */
Entity this[string uid] => /* Entity */;
}
이제 불필요한 중복은 제거하고 데이터를 최대한 압축하되, 입출력은 유지한 형태로 개선해보자.
class Entity
{
string uid;
string type;
string gender;
int atk;
int def;
}
class EntityBase
{
string uid;
string tid;
string sid;
}
class EntityType
{
string type;
string gender;
}
class EntityStat
{
int atk;
int def;
}
class EntityCollection
{
Dictionary<string, EntityBase> entityBaseDict;
Dictionary<string, EntityType> entityTypeDict;
Dictionary<string, EntityStat> entityStatDict;
Entity this[string uid] => new Entity
{
uid,
entityTypeDict[entityBase[uid].tid].type;
entityTypeDict[entityBase[uid].tid].gender;
entityStatDict[entityBase[uid].sid].atk;
entityStatDict[entityBase[uid].sid].def;
};
}
EntityBase, EntityType, EntityStat은 사실상 데이터 테이블 구조와 동일하다. 애초에 데이터 테이블 자체가 중복을 최소화하고 필요한 속성만 표시한 최적화된 형태이기에 어찌보면 당연한 결과이기도 하다. 하지만 몇몇 테이블이 추가되고 테이블간의 관계가 복잡해지면 지금의 구조 역시 정답이 아닐 수 있다. 그 때가 되면 콜렉션이 확장될 수도, 분리될 수도, 새로운 콜렉션이 추가될 수도 있다. 최적화에 끝은 없기에 이는 프로그래머가 충분히 고민해보고 해결해 나가야 할 과제이다.
콜렉션을 다룸에 있어 권장(필자는 필수라고 생각한다)되는 사항이 있다.
콜렉션은 비즈니스 로직에서 메타 데이터로서 사용되기에 불변성(Immutable)이 보장되어야 한다.
즉 다음과 같은 코드는 사실상 지양해야 한다.
class EntityCollection
{
Dictionary<string, Entity> entityDict;
Entity this[string uid] => entityDict[uid];
}
Entity가 value type일 경우는 상관없지만 reference type일 경우 외부에서 수정될 가능성이 있다.
이 경우 대신 아래와 같이 새 객체를 생성해서 반환해주자.
class EntityCollection
{
Dictionary<string, Entity> entityDict;
Entity this[string uid] => new Entity
{
uid,
entityDict[uid].type,
entityDict[uid].gender,
entityDict[uid].atk,
entityDict[uid].def
};
}
혹은 콜렉션 모델용 인터페이스를 정의하여 사용해도 좋다. (불편하지만, 유지보수와 휴먼에러 방지를 위해 적극 권장한다.)
interface ICollectionModel<T>
{
T Copy();
}
class Entity : ICollectionModel<Entity>
{
string uid;
string type;
string gender;
int atk;
int def;
Entity Copy() => new Entity
{
uid,
type,
gender,
atk,
def
};
}
class EntityCollection
{
Dictionary<string, Entity> entityDict;
Entity this[string uid] => entityDict[uid].Copy();
}
콜렉션 외부에서 콜렉션의 필드로의 직접적인 접근은 지양하는게 좋다.
var entity = EntityCollection.entityDict["MN_000000"];
불변성이 보장되지 않을 뿐더러 콜렉션 구조와 비즈니스 로직의 직접적인 의존성이 생긴다.
반드시 헬퍼 메서드를 통해 데이터에 접근하고 필드 자체를 private나 readonly로 설정하는 것이 안전하다.
필자가 애용하는 콜렉션 구조의 예시이다.
interface ICollectionModel<T>
{
T Copy();
T Get();
}
class Entity : ICollectionModel<Entity>
{
string uid;
string type;
string gender;
int atk;
int def;
Entity Copy() => new Entity
{
uid,
type,
gender,
atk,
def
};
Entity Get() => this;
}
class EntityBase
{
string uid;
string tid;
string sid;
}
class EntityType
{
string type;
string gender;
}
class EntityStat
{
int atk;
int def;
}
class EntityCollection
{
readonly Dictionary<string, EntityBase> entityBaseDict;
readonly Dictionary<string, EntityType> entityTypeDict;
readonly Dictionary<string, EntityStat> entityStatDict;
[JsonConstructor]
EntityCollection(
Dictionary<string, EntityBase> entityBaseDict,
Dictionary<string, EntityType> entityTypeDict,
Dictionary<string, EntityStat> entityStatDict)
{
this.entityBaseDict = entityBaseDict;
this.entityTypeDict = entityTypeDict;
this.entityStatDict = entityStatDict;
}
public static Create(Action<
Dictionary<string, EntityBase>,
Dictionary<string, EntityType>,
Dictionary<string, EntityStat>> factory)
{
var entityBaseDict = new Dictionary<string, EntityBase>();
var entityTypeDict = new Dictionary<string, EntityType>();
var entityStatDict = new Dictionary<string, EntityStat>();
factory(entityBaseDict, entityTypeDict, entityStatDict);
return new EntityCollection(entityBaseDict, entityTypeDict, entityStatDict);
}
IReadOnlyList<ICollectionModel<Entity>> Query(/* condition */) => /* query */
}
팩토리 패턴으로 콜렉션 생성과 동시에 필드를 외부에서 주입받고, 이후 수정이 불가능한 구조이다.
헬퍼 메서드로 반환하는 값 역시 불변성이 보장되는 것을 볼 수 있다.