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

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

게임 개발 중에는 DB 스키마가 수시로 바뀌게 마련입니다. 그리고 DB 스키마가 바뀌면 DB 서버와 게임 서버도 바꿔줘야 됩니다. 이 작업은 굉장히 번거롭고 실수하기 좋은 과정입니다.

특히나 SQL 쿼리문은 프로그램 내부에서 문자열로 처리되기 때문에, 쿼리 문이 갱신되지 않거나 혹은 오류가 있더라도 컴파일러가 이걸 발견해내지 못하기 때문에 실제 실행해볼 때까지 버그를 발견하지 못하게 됩니다.

그래서 아이펀 엔진은 이런 귀찮은 작업을 대신 수행하는 ORM (Object Relational Mapping) 기능을 제공합니다.

아이펀 엔진이 DB 스키마에 맞게 자동으로 C++ 클래스를 생성하고, 스키마가 바뀌면 C++ 클래스를 자동으로 갱신해줍니다.

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

  1. 서버 프로그래머는 DB 를 직접 건드리는 대신 게임에서 사용할 오브젝트를 JSON 으로 기술합니다.

    JSON 파일을 오브젝트 정의라고 부르겠습니다.

  2. 서버를 빌드하는 과정에서 오브젝트 정의에 따라서 각각의 오브젝트에 해당하는 클래스 코드를 생성해 줍니다.

    각 오브젝트의 클래스 코드에는 DB 에서 오브젝트를 불러오고, DB 에 업데이트하는 작업이 담겨 있습니다. 만일 특정 오브젝트가 다른 서버에 의해 이미 읽힌 경우, DB 에 접근하는 대신 해당 서버에서 오브젝트를 lease 하기도 하는데 이런 분산 캐싱 동작은 DB 의 부하를 줄이는 역할을 합니다. 그렇기 때문에 서버 프로그래머는 직접 SQL 쿼리문을 쓸 필요도 없고 분산 락 처리를 구현할 필요도 없습니다.

  3. 엔진은 게임 서버가 구동될 때 *DB* 에 접속해서 오브젝트 정의와 현재 테이블 스키마가 일치하는지 확인합니다.

    만일 다른 부분이 있다면 테이블이나 컬럼을 추가합니다.

안전을 위해서 아이펀 엔진은 오브젝트 명세에 기술되어있지 않은 컬럼이라도 기존에 존재하는 테이블이나 컬럼은 변경 또는 삭제하지 않습니다.

따라서 생성되는 DB 에 인덱싱 용도로 사용하기 위한 컬럼을 직접 추가하거나 고객 지원 시스템과 같은 외부 시스템에서 사용할 컬럼이나 테이블을 자유롭게 추가해서 사용할 수 있습니다.

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",
  ...
},

MySQL Server 또는 Mariadb Server 가 설치되어있지 않다면 다음 명령으로 설치해 주시기 바랍니다.

$ sudo apt-get install mysql-server

$ sudo mysql -u root

이제 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 파일을 보겠습니다.

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 코드 확인하기

위의 오브젝트 정의를 가지고 서버를 빌드합니다.

$ 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 hello_world_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 테이블이 생성된 것을 확인하실 수 있을 겁니다.

이제 Character 오브젝트 타입에 Sp 필드을 추가한다고 가정해볼까요? 보통의 경우는 수작업으로 DBSQL 로 테이블을 수정하고, 서버 코드에서 Sp 를 사용하기 위한 추가 코드도 작성해야 할 것입니다. 그러나 아이펀 엔진에서는 오브젝트 정의를 수정하기만 하면 됩니다.

다음처럼 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);
  ...
};

이제 서버를 다시 띄워볼까요? DBCharacter 테이블에 자동으로 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 으로 생성된 코드 호출하기

마지막으로 이렇게 정의한 오브젝트가 코드상에서 어떻게 생성되는지 확인해봅시다. 앞서 만든 OnHelloCharacter 오브젝트를 생성하는 코드를 넣어보겠습니다. 즉, 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 이나 FirefoxRESTful 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 도 연동을 했습니다. 이제 어느덧 게임 서버 다워졌네요.

그러나 현실 세계에서는 서버는 여러대로 확장이 되어야 합니다. 다음 두 챕터에서는 이와 관련해 서버의 종류를 나누는 방법과 분산 시스템 구성에 대해 알아보겠습니다.