4. ORM을 이용하여 자동으로 DB 처리하기

4.1. 아이펀 엔진의 ORM 기능 소개

게임 개발 중에는 DB 스키마가 수시로 바뀌게 마련입니다. 그리고 DB 스키마가 바뀌면 DB 서버와 게임 서버도 바꿔줘야 됩니다. 이 작업은 굉장히 번거롭고 실수하기 좋은 과정입니다. 특히나 SQL 쿼리문은 프로그램 내부에서 문자열로 처리되기 때문에, 쿼리 문이 갱신되지 않거나 혹은 오류가 있더라도 컴파일러가 이걸 발견해내지 못하기 때문에 실제 실행해볼 때까지 버그를 발견하지 못하게 됩니다.

그래서 iFun Engine 은 이런 귀찮은 작업을 ORM (Object Relational Mapping) 을 통해 해결합니다. 아이펀 엔진이 DB 스키마에 맞게 자동으로 C++ 클래스를 생성하고, 스키마가 바뀌면 C++ 클래스를 자동으로 갱신해줍니다.

즉, 아이펀 엔진을 사용하여 게임 오브젝트를 다룰 때에는 다음과 같은 순서가 됩니다.

  1. 서버 프로그래머는 DB 를 직접 건드리는 대신 게임에서 사용할 오브젝트를 JSON 으로 기술합니다.
  2. 엔진이 JSON 명세에 따라 클래스를 만들어줍니다. 각 클래스 메소드는 DB 에서 오브젝트를 불러오고, DB 에 업데이트하는 작업이 담겨 있습니다. 만일 특정 오브젝트가 다른 서버에 의해 이미 읽힌 경우, DB 에 접근하는 대신 해당 서버에서 오브젝트를 lease 해옵니다. 이 분산 캐싱 동작은 DB 의 부하를 줄이는 역할을 합니다. 그렇기 때문에 서버 프로그래머는 직접 SQL 쿼리문을 쓸 필요도 없고 분산 락 처리를 구현할 필요도 없습니다.
  3. 엔진은 게임 서버가 구동될 때 DB 에 접속해서 JSON 명세대로 테이블이 정의 되어 있는지 확인합니다. 만일 누락된 내용이 있다면 테이블/컬럼 등을 추가합니다.

참고

안전을 위해서 아이펀 엔진은 JSON 에 기술되어있지 않은 컬럼이라도 기존에 존재하는 테이블이나 컬럼은 손대지 않습니다. 따라서 생성되는 DB 에 index 와 같은 다른 항목이나, 고객 지원 시스템 처럼 외부 시스템에서 사용할 필드나 테이블을 자유롭게 추가하실 수 있습니다.

4.2. DB 기능 활성화하기

먼저 MANIFEST.json 에 DB 설정을 켜주고 접속 정보를 입력합니다. 여기서는 개발 서버에 MySQL 이나 MariaDB 가 같에 돌고 있고 funapi 라는 DB 유저가 funapi 라는 스키마에 접근할 권한이 있는 경우를 가정하겠습니다.

"Object": {
  ...
  "enable_database" : true,
  "db_mysql_server_address" : "tcp://127.0.0.1:3306",
  "db_mysql_id" : "funapi",
  "db_mysql_pw" : "funapi",
  "db_mysql_database" : "funapi",
  ...
},

enable_databasetrue 로 바꿔주시면 됩니다.

참고

mysql server 가 설치되어있지 않다면 다음처럼 하시면 됩니다.

$ sudo apt-get install mysql-server

$ mysql -u root -p

이제 mysql shell 이 뜨면 다음처럼 입력하세요. mysql> 이후 부분을 입력하시면 됩니다.

mysql> create user 'funapi'@'localhost' identified by 'funapi';

mysql> grant all privileges on *.* to 'funapi'@'localhost';

mysql> create database funapi;

4.3. 사용할 게임 오브젝트를 위한 JSON 명세 기술하기

이제 게임 오브젝트를 기술하고 있는 JSON 파일을 보겠습니다. hello_world-source/src/object_model/example.json 을 보시면 다음과 같은 내용이 있을 겁니다.

{
  "User": {
    "Id": "String KEY",
    "MyCharacter": "Character"
  },
  "Character": {
    "Name": "String KEY",
    "Exp": "Integer",
    "Level": "Integer",
    "Hp": "Integer",
    "Mp": "Integer"
  }
}

이 내용이 게임 오브젝트의 JSON 명세입니다. 이에 따르면, User 라는 클래스와 Character 라는 클래스가 있고, User 는 키로 사용되는 문자열의 Id 를 갖게 되고, Character 타입의 MyCharacter 를 소유하고 있습니다. 그리고 Character 타입은 이름, 경험치, 레벨, HP, MP 같은 기본 정보를 갖고 있네요.

이제 게임 서버는 구동될 때 MANIFEST.json 에 지정된 DB 에 접속해서 User 라는 테이블과 Character 라는 테이블이 있는지 확인하고 없으면 새로 생성합니다. 그리고 생성된 DB 테이블들에 앞에서 언급된 것들이 필드로 존재하는지 확인 후에 없으면 새로 추가해줍니다.

4.4. 생성된 ORM 코드 확인하기

위의 JSON 명세를 가지고 서버를 빌드합니다.

$ make

hello_world-source/src/object_model/ 이라는 디렉토리 아래에 user.h, character.h 같은 파일이 생성된 것을 아실 수 있을 겁니다. 각 파일은 UserCharacter 에 대응하는 클래스를 정의합니다.

$ ls ../../hello_world-source/src/object_model
character.h  common.h  example.json  item.h  test_object.cc  user.h

그리고 각 클래스에는 Create(..)Fetch(...) 라는 메소드가 있습니다. 각각 DB 상에 오브젝트를 만들고, 만들어진 것을 불러오는 메소드입니다. 또한 각 필드별로 GetXXX(), SetXXX() 라는 형태로 메소드들도 생성됩니다. 각 필드값을 읽고 쓰는데 쓰입니다. 아래는 hello_world-source/src/object_model/character.h 의 예시입니다.

class Character : public ObjectProxy {
  ...

  // Create
  static Ptr<Character> Create(const string &name);

  // Fetch by object id
  static Ptr<Character> Fetch(
      const Object::Id &id,
      LockType lock_type = kWriteLock);
  static void Fetch(
      const std::vector<Object::Id> &ids,
      std::vector<std::pair<Object::Id, Ptr<Character> > > *result,
      LockType lock_type = kWriteLock);

  // Fetch by Name
  static Ptr<Character> FetchByName(
      const string &value,
      LockType lock_type = kWriteLock);
  static void FetchByName(
      const std::vector<string> &values,
      std::vector<std::pair<string, Ptr<Character> > > *result,
      LockType lock_type = kWriteLock);

  ...

  // Getter/Setter for 'Name' attribute
  string GetName() const;
  void SetName(const string &value);

  // Getter/Setter for 'Exp' attribute
  int64_t GetExp() const;
  void SetExp(const int64_t &value);

  // Getter/Setter for 'Level' attribute
  int64_t GetLevel() const;
  void SetLevel(const int64_t &value);

  // Getter/Setter for 'Hp' attribute
  int64_t GetHp() const;
  void SetHp(const int64_t &value);

  // Getter/Setter for 'Mp' attribute
  int64_t GetMp() const;
  void SetMp(const int64_t &value);

  ...
};

4.5. 엔진이 자동으로 생성한 DB 테이블 확인하기

이제 MANIFEST.jsonenable_database 를 켰기 때문에 다시 게임 서버를 실행해보겠습니다. 앞의 설명대로라면 게임 서버가 MySQL DB 에 테이블들을 생성해야됩니다.

$ ./hello_world-local  (실행 후에는 Ctrl+c 로 서버를 죽입니다.)
$ mysql -u funapi -p funapi

mysql> show tables;
+-----------------------+
| Tables_in_funapi      |
+-----------------------+
| tb_Key_Character_Name |
| tb_Key_User_Id        |
| tb_ObjectLock         |
| tb_Object_Character   |
| tb_Object_User        |
+-----------------------+
5 rows in set (0.00 sec)

mysql> desc tb_Object_Character;
+----------------+---------------+------+-----+------------------+-------+
| Field          | Type          | Null | Key | Default          | Extra |
+----------------+---------------+------+-----+------------------+-------+
| col__ObjectId_ | binary(16)    | NO   | PRI |                  |       |
| col_Name       | varchar(4096) | YES  | UNI | NULL             |       |
| col_Exp        | bigint(8)     | YES  |     | NULL             |       |
| col_Level      | bigint(8)     | YES  |     | NULL             |       |
| col_Hp         | bigint(8)     | YES  |     | NULL             |       |
| col_Mp         | bigint(8)     | YES  |     | NULL             |       |
| col__tag       | varchar(4096) | YES  |     | NULL             |       |
+----------------+---------------+------+-----+------------------+-------+
7 rows in set (0.00 sec)

DB 가 자동으로 생성된 것을 확인하실 수 있을 겁니다. 이제 CharacterSp 를 추가한다고 가정해볼까요? 보통의 경우는 수작업으로 DB 에 테이블을 넣어주고, 서버코드도 Sp 를 쓰기 위해서 뭔가 수작업을 해야될 겁니다. 그러나 iFun Engine 에서는 JSON 명세만 갱신해주면 됩니다. 다음처럼 hello_world-source/src/object_model/example.json 을 갱신합니다.

{
  "User": {
    "Id": "String KEY",
    "MyCharacter": "Character"
  },
  "Character": {
    "Name": "String KEY",
    "Exp": "Integer",
    "Level": "Integer",
    "Hp": "Integer",
    "Mp": "Integer",
    "Sp": "Integer"
  }
}

이제 다시 빌드합니다.

$ make

Character 클래스에 Sp 관련된 내용이 추가된 것을 보실 수 있을 겁니다.

class Character : public ObjectProxy {
  ...
  // Getter/Setter for 'Sp' attribute
  int64_t GetSp() const;
  void SetSp(const int64_t &value);
  ...
};

이제 서버를 다시 띄워볼까요? DB 의 Character 테이블에 자동으로 Sp 라는 컬럼이 추가되어야 합니다. 확인해봅시다.

$ ./hello_world-local  (실행 후에는 Ctrl+c 로 서버를 죽입니다.)
$ mysql -u funapi -p funapi

mysql> desc tb_Object_Character;
+----------------+---------------+------+-----+------------------+-------+
| Field          | Type          | Null | Key | Default          | Extra |
+----------------+---------------+------+-----+------------------+-------+
| col__ObjectId_ | binary(16)    | NO   | PRI |                  |       |
| col_Name       | varchar(4096) | YES  | UNI | NULL             |       |
| col_Exp        | bigint(8)     | YES  |     | NULL             |       |
| col_Level      | bigint(8)     | YES  |     | NULL             |       |
| col_Hp         | bigint(8)     | YES  |     | NULL             |       |
| col_Mp         | bigint(8)     | YES  |     | NULL             |       |
| col_Sp         | bigint(8)     | YES  |     | NULL             |       |
| col__tag       | varchar(4096) | YES  |     | NULL             |       |
+----------------+---------------+------+-----+------------------+-------+
7 rows in set (0.00 sec)

보셨죠? DB 스키마가 바뀌어도 게임 내 오브젝트 클래스를 수작업으로 바꾸지 않아도 되고, 또한 DB 도 자동으로 갱신됩니다.

4.6. 게임 서버 코드 안에서 ORM 으로 생성된 코드 호출하기

마지막으로 실제로 Object 가 어떻게 생성되는지 확인해봅시다. 앞서 만든 OnHello 에 Character 를 생성하는 코드를 넣어보겠습니다. 즉, hello 를 호출하면 이제는 Character 인스턴스를 하나 만들고 DB 에 쓰게 되는 것입니다.

void OnHello(const Ptr<Session> &session, const Json &message) {
  Ptr<Character> ch = Character::Create("ifun");
  if (!ch) {
    LOG(ERROR) << "Already exists";
  } else {
    LOG(INFO) << "Created";
  }

  Json empty_response;
  session->SendMessage("world", empty_response);
}

이제 hello 를 받으면 서버는 ifun 이라는 Character 를 만들게 됩니다. Character 의 JSON 명세에 Name 이 키로 정의된 것에 주목해주세요. 명세에 키 필드가 있을 경우, Create 함수는 이 필드들을 모두 인자로 취해야됩니다. 아래 코드는 ifun 이라는 이름의 character 를 만드는 코드입니다.

게임 서버를 다시 빌드하고 다음 명령을 이용해서 결과를 확인해보겠습니다. (쉘 명령 대신 앞에서처럼 Chrome 이나 Firefox 의 RESTful API 플러그인을 쓰셔도 됩니다.)

$ wget -qO- --post-data="{}" http://localhost:8018/v1/messages/hello

MySQL 에서 확인해보겠습니다.

$ mysql -u funapi -p funapi
mysql> select * from tb_Object_Character;
+------------------+----------+---------+-----------+--------+--------+--------+----------+
| col__ObjectId_   | col_Name | col_Exp | col_Level | col_Hp | col_Mp | col_Sp | col__tag |
+------------------+----------+---------+-----------+--------+--------+--------+----------+
| ???8?F?????[?&           | ifun     |       0 |         0 |      0 |      0 |      0 |          |
+------------------+----------+---------+-----------+--------+--------+--------+----------+
1 row in set (0.01 sec)

사용자가 잘 만들어졌네요!

지금까지 클라이언트로부터 오는 패킷도 붙였고, DB 도 연동을 했습니다. 이제 어느덧 게임 서버 다워졌네요. 그러나 현실 세계에서는 서버는 여러대로 확장이 되어야 합니다. 다음 두 섹션에서는 이와 관련해 서버의 종류를 나누는 방법과 분산 시스템 구성에 대해 알아보겠습니다.