분산처리 Part 2: 서버간 데이터 공유

아이펀 엔진은 Redis 를 통해 서버간에 JSON 데이터를 쉽게 공유할 수 있는 CrossServerStorage 라는 콤포넌트를 제공합니다.

이 콤포넌트를 이용하면 손쉽게 아래 예시와 같은 응용이 가능합니다.

  1. 인증키 발급을 통해 서버 이동 시 인증된 클라이언트인지 확인

  2. 각 게임 지역 또는 서버마다 유입되는 플레이어 수, 거래량 등을 공유

  3. 기타 게임 관련 정보 공유

이 챕터에서는 예제를 통해 인증키 발급을 통해 서버 이동 시 인증된 클라이언트인지 확인 하는 것을 다뤄보겠습니다.

Note

예제에서는 본 콤포넌트의 모든 인터페이스를 설명하고 있지 않습니다. 자세한 내용은 funapi/service/cross_server_storage.h 파일과 CrossServerStorage class API 문서 를 참고하시기 바랍니다.

예제 - 서버 이동시 클라이언트 인증 정보 공유하기

다음과 같은 시나리오를 가정하겠습니다.

  1. 클라이언트가 게임 서버에 로그인

  2. 대전 참여 요청

  3. 게임 서버는 CrossServerStorage 를 이용하여 key 를 발급하고 대전에 필요한 유저 정보를 저장한 뒤 Key 를 클라이언트로 전달

  4. 클라이언트가 대전 서버에 접속하고 게임 서버로부터 받은 Key 를 전달

  5. 대전 서버는 해당 Key 로 대전에 필요한 유저 정보를 얻고 대전 처리 진행

  6. (optional) 대전 서버에서 key 제거

즉, 게임 서버가 key 라는 인증키를 발급하고 이것으로 대전 서버가 인증된 클라이언트인지 검증한 뒤 대전을 할 수 있게끔 처리합니다.

Note

이 예제에서는 서버 측 코드만 제시하고 클라이언트 처리는 다루지 않습니다.

Important

쉬운 설명을 위해 동기 함수들만 다루고 있습니다. 실 서비스에서는 성능을 위해 비동기 함수를 사용하는 것이 좋습니다.

Important

예제에서 다루지 않는 CrossServerStorage::Write()CrossServerStorage::WriteSync() 함수는 서로 다른 서버가 동일한 key 로 write 할 때 처리 순서를 보장하지 않습니다. 그때문에 먼저 쓴 데이터가 덮어씌여질 수 있다는 점에 주의하세요.

프로젝트 준비

Flavor 를 통해 게임, 대전 서버 등록

CMakeLists.txt 파일에서 APP_FLAVORS 항목을 다음처럼 추가합니다.

set(APP_FLAVORS game pvp)

Note

Flavor 관련해서는 Flavor: 역할에 따라 서버 구분하기 를 참고하세요.

여기까지 진행한 상태에서 빌드를 하면 src/MANIFEST.game.jsonsrc/MANIFEST.pvp.json 파일이 생성됩니다.

MANIFEST.json 수정

다음처럼 MANIFEST 파일들을 수정합니다.

src/MANIFEST.game.json:

...

"SessionService": {
  "tcp_json_port": 8012,
  "udp_json_port": 0,
  "http_json_port": 0,
  "tcp_protobuf_port": 0,
  "udp_protobuf_port": 0,
  "http_protobuf_port": 0,
  ...
},
"Object": {
  "cache_expiration_in_ms": 3000,
  "copy_cache_expiration_in_ms": 700,
  "enable_database" : true,
  "db_mysql_server_address" : "tcp://10.10.10.10:3306",
  "db_mysql_id" : "funapi",
  "db_mysql_pw" : "funapi",
  "db_mysql_database" : "funapi",
  ...
},
"ApiService": {
  "api_service_port": 0,
  "api_service_event_tags_size": 1,
  "api_service_logging_level": 2
},
"Redis": {
  "enable_redis": true,
  "redis_mode": "redis",
  "redis_servers": {
    "cross_server_storage": {
      "address": "10.10.10.10:6379",
      "auth_pass": ""
    }
  },
  "redis_async_threads_size": 4
},
"RpcService": {
  "rpc_enabled": true,
  "rpc_threads_size": 4,
  "rpc_port": 8015,
  ...
},
"CrossServerStorage": {
  "enable_cross_server_storage": true,
  "redis_tag_for_cross_server_storage": "cross_server_storage"
},

...

src/MANIFEST.pvp.json:

...

"SessionService": {
  "tcp_json_port": 9012,
  "udp_json_port": 0,
  "http_json_port": 0,
  "tcp_protobuf_port": 0,
  "udp_protobuf_port": 0,
  "http_protobuf_port": 0,
  ...
},
"Object": {
  "cache_expiration_in_ms": 3000,
  "copy_cache_expiration_in_ms": 700,
  "enable_database" : true,
  "db_mysql_server_address" : "tcp://10.10.10.10:3306",
  "db_mysql_id" : "funapi",
  "db_mysql_pw" : "funapi",
  "db_mysql_database" : "funapi",
  ...
},
"ApiService": {
  "api_service_port": 0,
  "api_service_event_tags_size": 1,
  "api_service_logging_level": 2
},
"Redis": {
  "enable_redis": true,
  "redis_mode": "redis",
  "redis_servers": {
    "cross_server_storage": {
      "address": "10.10.10.10:6379",
      "auth_pass": ""
    }
  },
  "redis_async_threads_size": 4
},
"RpcService": {
  "rpc_enabled": true,
  "rpc_threads_size": 4,
  "rpc_port": 9015,
  ...
},
"CrossServerStorage": {
  "enable_cross_server_storage": true,
  "redis_tag_for_cross_server_storage": "cross_server_storage"
},

...

Flavor 별 처리 준비

Flavor 에 따른 처리를 위해 다음과 같이 파일을 수정합니다.

src/event_handlers.cc

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// PLEASE ADD YOUR EVENT HANDLER DECLARATIONS HERE.

#include "event_handlers.h"

#include <funapi.h>
#include <glog/logging.h>

#include "test_loggers.h"
#include "test_messages.pb.h"

DECLARE_string(app_flavor);


namespace test {

void SendErrorMessage(const Ptr<Session> &session, const string &msg_type,
                      const string &error_message) {
  Json response;
  response["error"] = true;
  response["error_message"] = error_message;
  session->SendMessage(msg_type, response);
}


void RegisterEventHandlers() {
  if (FLAGS_app_flavor == "game") {
  } else if (FLAGS_app_flavor == "pvp") {
  }
}

}  // namespace test

mono/server.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace Test
{

  public static void SendErrorMessage(
      Session session, string msg_type, string error_message)
  {
    JObject response = new JObject();
    response ["error"] = true;
    response ["error_message"] = error_message;
    session.SendMessage(msg_type, response);
  }


  public static void Install(ArgumentMap arguments)
  {
    ...
    if (Flags.GetString ("app_flavor") == "game")
    {
    }
    else if (Flags.GetString ("app_flavor") == "pvp")
    {
    }
  }
}

Game flavor 에 로그인 메시지 핸들러 등록

클라이언트 에서 서버로 cs_game_server_login 메시지를 보낸다고 가정하고 이를 처리하는 OnGameServerLogin() 핸들러를 등록합니다.

src/event_handlers.cc

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void OnGameServerLogin(const Ptr<Session> &session, const Json &message) {
  const string user_name = message["user_name"].GetString();

  Ptr<User> user = User::FetchByName(user_name);
  if (not user) {
    user = User::Create(user_name);
    if (not user) {
      SendErrorMessage(session, "sc_game_server_login", "Already exists.");
      return;
    }

    user->SetLevel(1);
  }

  LOG_ASSERT(user);

  if (not AccountManager::CheckAndSetLoggedIn(user_name, session)) {
    SendErrorMessage(session, "sc_game_server_login", "Already logged in");
    return;
  }

  Json response;
  response["error"] = false;
  response["level"] = user->GetLevel();
  session->SendMessage("sc_game_server_login", response);
}


void RegisterEventHandlers() {
  if (FLAGS_app_flavor == "game") {
    HandlerRegistry::Register("cs_game_server_login", OnGameServerLogin);
  } else if (FLAGS_app_flavor == "pvp") {
    // 대전 서버의 메시지 핸들러를 등록합니다.
  }
}

mono/server.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public static void OnGameServerLogin(
    Session session , JObject message)
{
  string user_name = (string) message ["user_name"];

  User user = User.FetchByName (user_name);
  if (user == null)
  {
    user = User.Create (user_name);

    if (user == null)
    {
      SendErrorMessage (session, "sc_game_server_login", "Already exists");
      return;
    }

    user.SetLevel (1);
  }

  Log.Assert (user != null);

  if (!AccountManager.CheckAndSetLoggedIn (user_name, session))
  {
    SendErrorMessage(session, "sc_game_server_login", "Already logged in");
    return;
  }

  JObject response = new JObject();
  response["error"] = false;
  response["level"] = user.GetLevel();
  session.SendMessage("sc_game_server_login", response);
}

public static void Install(ArgumentMap arguments)
{
  ...
  if (Flags.GetString ("app_flavor") == "game")
  {
    NetworkHandlerRegistry.RegisterMessageHandler (
        "cs_game_server_login",
        new NetworkHandlerRegistry.JsonMessageHandler (
            OnGameServerLogin));
  }
  else if (Flags.GetString ("app_flavor") == "pvp")
  {
    // 대전 서버의 메시지 핸들러를 등록합니다.
  }
}

Game flavor 에 PvP 모드 진입 메시지 핸들러 등록

클라이언트가 게임 서버로 cs_enter_pvp 메시지를 보낸다고 가정하고 이것을 처리하는 OnEnterPvp() 메시지 핸들러를 등록합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void OnEnterPvp(const Ptr<Session> &session, const Json &message) {
  const string user_name = AccountManager::FindLocalAccount(session);
  if (user_name.empty()) {
    session->Close();
    return;
  }

  Ptr<User> user = User::FetchByName(user_name);
  if (not user) {
    SendErrorMessage(session, "sc_enter_pvp", "No user data");
    return;
  }

  Json user_data;
  user_data["user_name"] = user_name;
  user_data["level"] = user->GetLevel();

  // 클라이언트에게 전달할 인증키를 생성하고 CrossServerStorage 에 저장합니다.
  CrossServerStorage::Key auth_key;
  const CrossServerStorage::Result result = CrossServerStorage::CreateSync(
      user_data,
      WallClock::FromSec(10),  // 만료시간을 10 초로 지정합니다.
      &auth_key);

  if (result != CrossServerStorage::kSuccess) {
    LOG(ERROR) << "CrossServerStorage::CreateSync() failed: "
               << "result=" << result;
    SendErrorMessage(session, "sc_enter_pvp", "Server error");
    return;
  }

  const string pvp_server_ip = "10.10.10.10";
  const int64_t pvp_server_port = 9012;

  Json response;
  response["error"] = false;
  response["pvp_server_ip"] = pvp_server_ip;
  response["pvp_server_port"] = pvp_server_port;
  response["auth_key"] = boost::lexical_cast<string>(auth_key);
  session->SendMessage("sc_enter_pvp", response);

  // 이제 클라이언트는 대전 서버에 접속하여 인증키를 전달할 것입니다.
  // 대전 서버는 전달받은 인증키로 인증받은 클라이언트인지 확인할 것입니다.
}


void RegisterEventHandlers() {
  if (FLAGS_app_flavor == "game") {
    HandlerRegistry::Register("cs_game_server_login", OnGameServerLogin);
    HandlerRegistry::Register("cs_enter_pvp", OnEnterPvp);
  } else if (FLAGS_app_flavor == "pvp") {
    // 대전 서버의 메시지 핸들러를 등록합니다.
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public static void OnEnterPvp(Session session , JObject message)
{
  string user_name = AccountManager.FindLocalAccount (session);
  if (user_name == String.Empty) {
    session.Close ();
    return;
  }

  User user = User.FetchByName (user_name);
  if (user == null) {
    SendErrorMessage (
        session, "sc_enter_pvp", "No user data");
    return;
  }

  JObject user_data = new JObject ();
  user_data["user_name"] = user_name;
  user_data["level"] = user.GetLevel ();

  // CrossServerStorage 를 이용해서 저장합니다.
  // 이때 인증키를 발급받아 클라이언트에게 전달합니다.
  Guid auth_key;
  CrossServerStorage.Result result = CrossServerStorage.CreateSync(
      user_data,
      WallClock.FromSec(10),  // 만료시간을 10 초로 지정합니다.
      out auth_key);

  if (result != CrossServerStorage.Result.kSuccess) {
    Log.Error ("CrossServerStorage::CreateSync() failed: result= {0}",
               result);
    SendErrorMessage (session, "sc_enter_pvp", "Server error");
    return;
  }

  string pvp_server_ip = "127.0.0.1";
  short pvp_server_port = 9012;

  JObject response = new JObject ();
  response ["error"] = false;
  response ["pvp_server_ip"] = pvp_server_ip;
  response ["pvp_server_port"] = pvp_server_port;
  response ["auth_key"] = auth_key.ToString();
  session.SendMessage ("sc_enter_pvp", response);

  // 이제 클라이언트는 대전 서버에 접속하여 인증키를 전달할 것입니다.
  // 대전 서버는 전달받은 인증키로 인증받은 클라이언트인지 확인할 것입니다.
}

public static void Install(ArgumentMap arguments)
{
  ...

  if (Flags.GetString ("app_flavor") == "game")
  {
    NetworkHandlerRegistry.RegisterMessageHandler (
        "cs_game_server_login",
        new NetworkHandlerRegistry.JsonMessageHandler (
            OnGameServerLogin));

    NetworkHandlerRegistry.RegisterMessageHandler (
        "cs_enter_pvp",
        new NetworkHandlerRegistry.JsonMessageHandler (
            OnEnterPvp));
  }
  else if (Flags.GetString ("app_flavor") == "pvp")
  {
    // 대전 서버의 메시지 핸들러를 등록합니다.
  }
}

Tip

분산 처리 기능인 서버 간 상태/정보 공유 를 이용하면 OnEnterPvp() 함수에서 접속할 대전 서버를 하드코딩하는 대신 여러 대전 서버들중 부하량이 낮은 서버를 선택할 수 있습니다.

PvP flavor 에 PvP 참여 메시지 핸들러 등록

클라이언트가 게임 서버로 cs_transfer_pvp_server 메시지를 보낸다고 가정하고 이것을 처리하는 OnTransferPvpServer() 메시지 핸들러를 등록합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
void OnTransferPvpServer(const Ptr<Session> &session, const Json &message) {
  if (not message.HasAttribute("auth_key", Json::kString) ||
      message["auth_key"].GetString().empty()) {
    session->Close();
    return;
  }

  CrossServerStorage::Key auth_key = CrossServerStorage::Key();
  try {
    auth_key =
        boost::lexical_cast<CrossServerStorage::Key>(
            message["auth_key"].GetString());
  } catch (const boost::bad_lexical_cast &) {
    session->Close();
    LOG(ERROR) << "인증키 얻기 실패. auth_key=" << message["auth_key"].GetString();
    return;
  }

  Json user_data;
  CrossServerStorage::Result result =
      CrossServerStorage::ReadSync(auth_key, &user_data);

  if (result != CrossServerStorage::kSuccess) {
    SendErrorMessage(session, "sc_transfer_pvp_server", "Failed to authenticate");
    return;
  }

  const string user_name = user_data["user_name"].GetString();
  const int64_t level = user_data["level"].GetInteger();

  LOG(INFO) << "user_name=" << user_name << ", level=" << level;

  // 이 예제에서는 인증키 만료시간이 10초이므로 명시적으로 삭제하지 않습니다.

  // 만약 명시적으로 인증키를 삭제해야 한다면 다음 함수로 삭제할 수 있습니다.
  // CrossServerStorage::DeleteSync(auth_key);

  // 인증키를 계속 사용할 수도 있습니다. (게임 서버가 대전 결과가 필요한 경우)
  // 아래 함수를 이용하면 인증키 만료 시간을 갱신할 수 있습니다.
  // CrossServerStorage::RefreshSync(auth_key, WallClock::FromSec(60));
}


void RegisterEventHandlers() {
  if (FLAGS_app_flavor == "game") {
    HandlerRegistry::Register("cs_game_server_login", OnGameServerLogin);
    HandlerRegistry::Register("cs_enter_pvp", OnEnterPvp);
  } else if (FLAGS_app_flavor == "pvp") {
    HandlerRegistry::Register("cs_transfer_pvp_server", OnTransferPvpServer);
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public static void OnTransferPvpServer(
    Session session , JObject message)
{
  if (message ["auth_key"] == null)
  {
    session.Close ();
    return;
  }
  else if ( (string) message ["auth_key"] == String.Empty)
  {
    session.Close ();
  }

  Guid auth_key;

  try {
    auth_key = new Guid ((string) message ["auth_key"]);
  } catch (Exception exception) {
    session.Close();
    Log.Error ("인증키 얻기 실패. auth_key= {0}",
               (string)  message ["auth_key"]);
    return;
  }

  JObject user_data;
  CrossServerStorage.Result result =
      CrossServerStorage.ReadSync(auth_key, out user_data);

  if (result != CrossServerStorage.Result.kSuccess)
  {
    SendErrorMessage (session, "sc_transfer_pvp_server", "Failed to authenticate");
    return;
  }

  string user_name = (string) user_data ["user_name"];
  ulong level = (ulong) user_data ["level"];

  Log.Info ("user_name= {0}, level= {1}", user_name, level);

  // 이 예제에서는 인증키 만료시간이 10초이므로 명시적으로 삭제하지 않습니다.

  // 만약 명시적으로 인증키를 삭제해야 한다면 다음 함수로 삭제할 수 있습니다.
  // CrossServerStorage.DeleteSync(auth_key);

  // 인증키를 계속 사용할 수도 있습니다. (게임 서버가 대전 결과가 필요한 경우)
  // 아래 함수를 이용하면 인증키 만료 시간을 갱신할 수 있습니다.
  // CrossServerStorage.RefreshSync(auth_key, WallClock.FromSec(60));
}

public static void Install(ArgumentMap arguments)
{
  ...

  if (Flags.GetString ("app_flavor") == "game")
  {
    NetworkHandlerRegistry.RegisterMessageHandler (
        "cs_game_server_login",
        new NetworkHandlerRegistry.JsonMessageHandler (
            OnGameServerLogin));

    NetworkHandlerRegistry.RegisterMessageHandler (
        "cs_enter_pvp",
        new NetworkHandlerRegistry.JsonMessageHandler (
            OnEnterPvp));
  }
  else if (Flags.GetString ("app_flavor") == "pvp")
  {
    NetworkHandlerRegistry.RegisterMessageHandler (
        "cs_transfer_pvp_server",
        new NetworkHandlerRegistry.JsonMessageHandler (
            OnEnterPvp));
  }
}

CrossServerStorage 기능의 설정 파라미터

  • enable_cross_server_storage: 해당 기능을 활성화시킬지 여부. redis-manifestenable_redis 도 true 로 세팅해야됨.(type=bool, default=false)

  • redis_tag_for_cross_server_storage: 서버간 데이터 공유를 위한 스토리지로 사용될 Redis 서버의 태그 값. (태그(Tag) 로 레디스 서버 구분 참조) (type=string, default=””)