49. Cookbook 2: 멀티 플레이 서버 추가하기

이미 구현된 싱글 플레이 게임을 어떤 방법으로 아이펀 엔진을 이용해서 멀티 플레이 게임을 추가하는지 설명합니다.

여기서는 다음을 가정합니다.

  • 이미 완성한 (싱글 플레이용) 게임 서버가 있습니다.
  • 게임 서버들은 개별 IP 혹은 로드밸런서 뒤에 존재 합니다.
  • 게임 서버에서 사용하는 데이터베이스가 존재합니다.
  • 해당 서버는 외부에 HTTP restful API를 제공합니다.
  • 해당 서버에서 아이펀 엔진 쪽으로 restful API를 호출할 수 있습니다.
  • 클라이언트와 HTTP 혹은 WebSocket 같은 양방향 연결로 통신할 수 있습니다.

49.1. 전체 서버군 구조

_images/sp-mp-architecture.png

여기서 게임 서버는 기존에 구현한 서버 프로세스, 멀티 플레이 서버는 아이펀 엔진으로 구현한 서버 프로세스를 말합니다.

  • 왼쪽의 싱글 플레이 서버 그룹 의 내용은 구현한 게임 서버에 대한 예시입니다.
  • 오른쪽의 멀티 플레이 서버 그룹 은 새로 구현할 서버 쪽 구조입니다. 로비, 매치메이킹 그리고 멀티플레이 서버는 아이펀 엔진으로 제작한 어플리케이션이고, Redis와 ZooKeeper 는 아이펀 엔진에서 사용하는 어플리케이션입니다.

49.2. 게임 서버와 멀티플레이 서버 연동하기

멀티플레이 서버로 클라이언트를 접속하게 하려면 다음과 같은 단계를 거칩니다.

  1. 클라이언트→ 게임 서버: 클라이언트가 게임 서버에 멀티플레이 서버 접속 요청

  2. 게임 서버 → 매치메이킹 서버: 게임 서버가 매치메이킹 서버에 HTTP API로 클라이언트 접속 요청.

    Important

    게임 서버는 이 단계에서 클라이언트에 해당하는 게임 유저의 정보를 보내거나, 해당 정보를 가져갈 수 있는 HTTP API를 제공해야 합니다.

  3. 매치 메이킹 서버→ 게임 서버: 매치메이킹 서버는 클라이언트가 접속할 주소와 토큰 정보를 반환합니다. 이 단계의 구현 예제는 여기를 참고해주세요.

  4. 게임 서버→ 클라이언트: 접속 주소와 토큰을 전달합니다.

  5. 클라이언트→ 매치메이킹 서버: 클라이언트가 접속 주소에 있는 매치메이킹 서버에 접속합니다. 접속한 후에 전달받은 토큰을 보내서 인증 처리를 수행합니다. 이 단계의 구현 예제는 여기를 참고해주세요.

49.2.1. 게임 서버의 클라이언트 접속 요청 처리하기

  1. 게임 서버가 클라이언트의 데이터를 JSON으로 인코딩해서 멀티 플레이 서버에 전송. 예를 들어 id 가 42인 유저가 멀티 플레이 서버에 접속하고 싶은 경우, 아래와 같은 데이터를 넣어서 요청을 보낸다.

    {
      "user": {
        "id": 42,
        "name": "alice",
        "level": 2,
        "win": 4,
        "lose": 2,
        "draw": 1
      }
    }
    
  2. 매치메이킹 서버는 다음과 같은 작업을 실행합니다. 해당 작업의 구현 예는 코드 샘플을 참고해주세요.

    1. 매치메이킹 용 데이터를 받습니다.
    2. 클라이언트가 접속할 서버를 고릅니다. 아이펀 엔진의 특정 타입(flavor) 서버를 고르는 기능을 사용합니다.
    3. 인증 토큰을 생성합니다. (여기서는 랜덤 문자열)
    4. 해당 토큰을 key로 Redis에 값을 설정합니다. 이 값은 일정 시간 – 예를 들어 5분 – 후에 만료되도록 합니다. 해당 시간은 최대 로그인 허용 시간이 됩니다.
    static void OnUserConnectionRequest(
        const http::Request2 &request, const ApiService::MatchResult &,
        const ApiService::ResponseWriter &writer) {
      // 1. HTTP 요청을 확인
      // HTTP request body 에 JSON 형식으로 다음 유저 데이터가 있다고 가정
      fun::Json body;
      if (not body.FromString(request.body) ||
          not body.IsObject() ||
          not body.HasAttribute("user", fun::Json::kObject)) {
        LOG(ERROR) << "Request is not valid: body=" << request.body;
        auto response = boost::make_shared<http::Response>();
        SetErrorResponse(response, http::kBadRequest, 400, "Invalid request body");
        writer(response);
        return;
      }
    
      fun::Json user_data = body["user"];
      // 적어도 uid 필드는 있어야 한다. (문자열로 가정)
      if (not user_data.HasAttribute("uid", fun::Json::kString)) {
        LOG(ERROR) << "Missging uid field: user_data="
                   << user_data.ToString(false);
        auto response = boost::make_shared<http::Response>();
        SetErrorResponse(response, http::kBadRequest, 400, "Invalid uid field");
        writer(response);
        return;
      }
    
      // 2. 적당한 매치메이킹 서버를 찾는다.
      fun::Rpc::PeerMap peers;
      fun::Rpc::PeerId peer_id;
      if (0 < fun::Rpc::GetPeersWithTag(&peers, "lobby", true)) {
        // NOTE: 여기서는 단순히 랜덤하게 고른다.
        auto it = peers.begin();
        std::advance(it, fun::RandomGenerator::GenerateNumber(0, peers.size() - 1));
        peer_id = it->first;
      } else {
        // 매치메이킹 서버가 한 대도 없는 경우
        LOG(ERROR) << "No matchmaking lobby servers.";
        auto response = boost::make_shared<http::Response>();
        SetErrorResponse(response,
                         http::kInternalServerError,
                         500,
                         "Not enough matchmaking servers");
        writer(response);
        return;
      }
    
      const std::string host = fun::Rpc::GetPeerExternalHostname(peer_id);
      // NOTE: 아이펀 엔진은 여러 개의 포트를 열 수 있다. 여기선 단순히 TCP포트만
      //       쓰는 것처럼 가정한다. (TCP + protobuf)
      auto ports = fun::Rpc::GetPeerExternalPorts(peer_id);
      auto pi = ports.find(fun::HardwareInfo::kTcpPbuf);
      if (pi == ports.end()) {
        // TCP가 없는 경우 (설정 잘못한 경우)
        LOG(ERROR) << "No open TCP port.";
        auto response = boost::make_shared<http::Response>();
        SetErrorResponse(response,
                         http::kInternalServerError,
                         500,
                         "No open TCp port");
        writer(response);
        return;
      }
    
      uint16_t port = pi->second;
    
      // 3. 인증 토큰을 생성
      // TODO: 원하는 형식의 인증 토큰을 생성. 여기에선 단순 랜덤 토큰을 생성함.
      std::array<uint8_t, 16> token_buf;
      randombytes_buf(&token_buf[0], token_buf.size());
      std::string token;
      token.resize(token_buf.size() * 2 + 1);
      sodium_bin2hex(&token[0], token.size(), &token_buf[0], token_buf.size());
      token.resize(token.size() - 1);
    
      // 4. Redis 에 토큰 저장. 저장에 성공하면 반환.
      //    토큰은 최대 300초 동안 유지한다. (그 이후에는 토큰 없어져서 인증 실패)
      fun::Redis::SetExAsync(token, 300, body["user"].ToString(false),
          [writer, host, port, token](const fun::Redis::Result &result) {
            if (result != fun::Redis::kResultSuccess) {
              LOG(ERROR) << "Failed to set key in redis.";
              auto response = boost::make_shared<http::Response>();
              SetErrorResponse(response,
                               http::kInternalServerError,
                               500,
                               "Failed to update redis");
              writer(response);
            } else {
              fun::Json res;
              res.SetObject();
              res.AddAttribute("error_code", 0);
              res.AddAttribute("token", token);
              res.AddAttribute("host", host);
              res.AddAttribute("port", port);
    
              auto response = boost::make_shared<http::Response>();
              SetResponse(response, http::kOk, res);
              writer(response);
            }
          });
    }
    
    // 아래와 같이 핸들러가 등록되어야 한다.
    // NOTE: POST /v1/user-connection-request/ 에 해당한다.
    ApiService::RegisterHandler3(
        http::kPost, boost::regex("/v1/user-connection-request/"),
        OnUserConnectionRequest);
    
  3. 매치메이킹 서버가 인증 토큰과 접속 주소를 응답으로 게임 서버에게 전달합니다. 예를 들어, 멀티 플레이 서버가 matchmaking.example.com:8012 에 떠 있다면, 아래와 같이 응답합니다.

{
  "error_code": 0,
  "host": "matchmaking.example.com",
  "port": 8012,
  "token": "ZjBiZjVhO...3MzZiNjQxYjQwNmQzMzY5ZDkzMmMzZGJiMwo"
}

여기서 받은 정보를 게임 서버가 클라이언트에게 전달하면, 클라이언트는 해당 서버에 접속하고, 인증 메시지를 전송합니다.

49.2.2. 매칭 메이킹 서버의 클라이언트 접속 인증 처리하기

  1. 클라이언트가 매치메이킹 서버에게 인증 메시지를 전송합니다. 아이펀 엔진에서는 엔진에서는 이 메시지를 받아서 해당 메시지 핸들러를 호출합니다.
  2. 매치메이킹 서버는 해당 key가 Redis 에 있는지 확인합니다.
  3. 존재하는 경우 해당 key의 값을 유저 데이터로 읽습니다.
  4. (분산 서버군 전체에서) 유저 인증을 시작합니다.
  5. 중복 로그인인 경우, 다른 서버에서 로그아웃 시킨 후 다시 인증합니다. 로그인에 성공한 경우 성공 메시지를 전송합니다.
// 로그인 메시지를 받으면 불립니다.
void OnAccountLogin(const Ptr<Session> &session, const Json &message) {
  string id = message["id"].GetString();
  string type = message["type"].GetString();

  if (not message.HasAttribute("token", fun::Json::kString)) {
    session->SendMessage("login", MakeResponse("nop", "Token is requied"),
                         kDefaultEncryption);
    return;
  }

  std::string token = message["token"].GetString();
  OnLoginFromSinglePlayServer(session, token, kJsonEncoding);
}


void OnLoginFromSinglePlayServer(const Ptr<Session> &session,
                                 const std::string &token,
                                 EncodingScheme encoding) {
  DLOG(INFO) << "OnLoginFromSinglePlayServer(token=" << token << ")";
  // 유효한 토큰이 있다면, redis에 해당 키로 유저 데이터를 가지고 있다.
  // 해당 데이터를 가져온다.
  fun::Redis::GetAsync(token,
    [session, token, encoding](const Redis::Result &result,
        const std::string &value) {
      if (result != Redis::kResultSuccess) {
        LOG(WARNING) << "Failed to get user data for token: " << token;
        if (encoding == kJsonEncoding) {
          session->SendMessage("login",
                               MakeResponse("nop", "Missing token"),
                               kDefaultEncryption);
        } else {
          Ptr<FunMessage> response(new FunMessage);
          auto *login_response = response->MutableExtension(lobby_login_repl);
          login_response->set_result("nop");
          login_response->set_msg("Missing token");
          session->SendMessage("login", response, kDefaultEncryption);
        }
        return;
      }

      // Redis 에서 토큰을 지운다.
      fun::Redis::DelAsync(token, [](const Redis::Result&, const size_t&) {});

      fun::Json user_data;
      if (not user_data.FromString(value) ||
          not user_data.IsObject() ||
          not user_data.HasAttribute("uid", Json::kString)) {
        LOG(WARNING) << "user data for token: " << token
                     << " does not have valid uid";
        if (encoding == kJsonEncoding) {
          session->SendMessage("login",
                               MakeResponse("nop", "Invalid user data"),
                               kDefaultEncryption);
        } else {
          Ptr<FunMessage> response(new FunMessage);
          auto *login_response = response->MutableExtension(lobby_login_repl);
          login_response->set_result("nop");
          login_response->set_msg("Invalid user data");
          session->SendMessage("login", response, kDefaultEncryption);
        }
        return;
      }

      std::string uid = user_data["uid"].GetString();
      AccountManager::CheckAndSetLoggedInAsync(uid, session,
          bind(&OnLoggedIn, _1, _2, _3, encoding));
  });
}

// OnLoggedIn 에서는 인증이 끝난 후의 중복 로그인 처리, 성공 메시지 전송등을
// 처리한다.
// AccountManager 의 로그인 처리가 끝나면 불립니다.
void OnLoggedIn(const string &id, const Ptr<Session> &session, bool success,
                EncodingScheme encoding) {
  if (not success) {
    // 로그인에 실패 응답을 보냅니다. 중복 로그인이 원인입니다.
    // (1. 같은 ID 로 이미 다른 Session 이 로그인 했거나,
    //  2. 이 Session 이 이미 로그인 되어 있는 경우)
    LOG(INFO) << "Failed to login: id=" << id;

    if (encoding == kJsonEncoding) {
      session->SendMessage("login", MakeResponse("nop", "fail to login"),
                           kDefaultEncryption);
    } else {
      Ptr<FunMessage> response(new FunMessage);
      LobbyLoginReply *login_response = response->MutableExtension(lobby_login_repl);
      login_response->set_result("nop");
      login_response->set_msg("fail to login");
      session->SendMessage("login", response, kDefaultEncryption);
    }

    // 아래 로그아웃 처리를 한 후 자동으로 로그인 시킬 수 있지만
    // 일단 클라이언트에서 다시 시도하도록 합니다.

    // 1. 이 ID 의 로그인을 풀어버립니다.(로그아웃)
    auto logout_cb = [](const string &id, const Ptr<Session> &session,
                        bool success) {
      if (success) {
        if (session) {
          // 같은 서버에 로그인 되어 있었습니다.
          LOG(INFO) << "Logged out(local) by duplicated login request: "
                    << "id=" << id;
          session->Close();
        } else {
          // 다른 서버에 로그인 되어 있었습니다.
          // 해당 서버의 OnLoggedOutRemotely() 에서 처리합니다.
          LOG(INFO) << "Logged out(remote) by duplicated login request: "
                    << "id=" << id;
        }
      }
    };
    AccountManager::SetLoggedOutGlobalAsync(id, logout_cb);

    // 2. 이 Session 의 로그인을 풀어버립니다.(로그아웃)
    string id_logged_in = AccountManager::FindLocalAccount(session);
    if (not id_logged_in.empty()) {
      // OnSessionClosed 에서 처리합니다.
      LOG(INFO) << "Close session. by duplicated login request: id=" << id;
      session->Close();
    }

    return;
  }

  LOG(INFO) << "Succeed to login: id=" << id;

  // 로그인 Activitiy Log 를 남깁니다.
  logger::PlayerLoggedIn(to_string(session->id()), id, WallClock::Now());

  // Session 에 Login 한 ID 를 저장합니다.
  session->AddToContext("id", id);

  // 응답을 보냅니다.
  if (encoding == kJsonEncoding) {
    Json response = MakeResponse("ok");
    response["id"] = id;
    session->SendMessage("login", response, kDefaultEncryption);
  } else {
    Ptr<FunMessage> response(new FunMessage);
    LobbyLoginReply *login_response = response->MutableExtension(
        lobby_login_repl);
    login_response->set_result("ok");
    login_response->set_id(id);
    session->SendMessage("login", response, kDefaultEncryption);
  }
}

49.3. 멀티 플레이 서버 측 작업

49.3.1. 매치메이킹

  1. 클라이언트가 매치메이킹 서버에 매치메이킹 요청을 보냅니다. 이 부분은 콘텐츠 지원 Part 2: 매치메이킹 에서 다룹니다.
  2. 매치메이커에서 클라이언트를 모아서 게임 준비가 되면 게임에서 사용할 데이터를 정리한 후 다른 서버로 이동할 때 넘깁니다.
  3. 클라이언트 이동 기능을 이용해서 해당 클라이언트들을 같은 게임 서버로 보냅니다. 이 부분은 클라이언트를 다른 서버로 옮기기 에서 다룹니다.
// 매치 메이킹 요청을 수행합니다.
void OnMatchmaking(const Ptr<Session> &session, const Json &/*message*/) {
  // 실제 matchmaking 구현을 호출한다.
  StartMatchmaking(session, kJsonEncoding);
}


void StartMatchmaking(const Ptr<Session> &session, EncodingScheme encoding) {
  // Matchmaking 최대 대기 시간은 10 초입니다.
  static const WallClock::Duration kTimeout = WallClock::FromSec(10);

  // 로그인 한 Id 를 가져옵니다.
  string id;
  if (not session->GetFromContext("id", &id)) {
    LOG(WARNING) << "Failed to request matchmaking. Not logged in.";

    if (encoding == kJsonEncoding) {
      session->SendMessage("error", MakeResponse("fail", "not logged in"));
    } else {
      Ptr<FunMessage> response(new FunMessage);
      PongErrorMessage *error = response->MutableExtension(pong_error);
      error->set_result("fail");
      error->set_msg("not logged in");
      session->SendMessage("error", response);
    }
    return;
  }

  // Matchmaking 결과를 처리할 람다 함수입니다.
  auto match_cb = [session, encoding](const string &player_id,
                                      const MatchmakingClient::Match &match,
                                      MatchmakingClient::MatchResult result) {
    Json json_response;
    Ptr<FunMessage> pbuf_response(new FunMessage);
    LobbyMatchReply *pbuf_match_reply
        = pbuf_response->MutableExtension(lobby_match_repl);

    if (result == MatchmakingClient::kMRSuccess) {
      // Matchmaking 에 성공했습니다.
      LOG(INFO) << "Succeed in matchmaking: id=" << player_id;

      fun::Json game_ctxt;
      game_ctxt.SetObject();
      game_ctxt.AddAttribute(
          "game_id", boost::lexical_cast<std::string>(match.match_id));

      BOOST_ASSERT(HasJsonStringAttribute(match.context, "A"));
      BOOST_ASSERT(HasJsonStringAttribute(match.context, "B"));

      const string player_a_id = match.context["A"].GetString();
      const string player_b_id = match.context["B"].GetString();

      game_ctxt["users"].SetArray();
      game_ctxt["users"].PushBack(player_a_id);
      game_ctxt["users"].PushBack(player_b_id);

      string opponent_id = match.context["A"].GetString();
      if (opponent_id == player_id) {
        opponent_id = match.context["B"].GetString();
      }

      if (encoding == kJsonEncoding) {
        json_response = MakeResponse("Success");
        json_response["A"] = player_a_id;
        json_response["B"] = player_b_id;
      } else {
        pbuf_match_reply->set_result("Success");
        pbuf_match_reply->set_player1(player_a_id);
        pbuf_match_reply->set_player2(player_b_id);
      }

      if (player_id == player_a_id) {
        session->AddToContext("opponent", player_b_id);
      } else {
        session->AddToContext("opponent", player_a_id);
      }
      session->AddToContext("matching", "done");

      // 유저를 Game 서버로 보냅니다.
      MoveServerByTag(session, "game", game_ctxt);
      FreeUser(session, encoding);
    } else if (result == MatchmakingClient::kMRAlreadyRequested) {
      // Matchmaking 요청을 중복으로 보냈습니다.
      LOG(INFO) << "Failed in matchmaking. Already requested: id="
                << player_id;
      session->AddToContext("matching", "failed");

      if (encoding == kJsonEncoding) {
        json_response = MakeResponse("AlreadyRequested");
      } else {
        pbuf_match_reply->set_result("AlreadyRequested");
      }
    } else if (result == MatchmakingClient::kMRTimeout) {
      // Matchmaking 처리가 시간 초과되었습니다.
      LOG(INFO) << "Failed in matchmaking. Timeout: id=" << player_id;
      session->AddToContext("matching", "failed");

      if (encoding == kJsonEncoding) {
        json_response = MakeResponse("Timeout");
      } else {
        pbuf_match_reply->set_result("Timeout");
      }
    } else {
      // Matchmaking 에 오류가 발생했습니다.
      LOG(ERROR) << "Failed in matchmaking. Erorr: id=" << player_id;
      session->AddToContext("matching", "failed");

      if (encoding == kJsonEncoding) {
        json_response = MakeResponse("Error");
      } else {
        pbuf_match_reply->set_result("Error");
      }
    }

    if (encoding == kJsonEncoding) {
      session->SendMessage("match", json_response, kDefaultEncryption);
    } else {
      session->SendMessage("match", pbuf_response, kDefaultEncryption);
    }
  };

  // 빈 Player Context 를 만듭니다. 지금 구현에서는 Matchmaking 서버가
  // 조건 없이 Matching 합니다. Level 등의 조건으로 Matching 하려면
  // 여기에 Level 등의 Matching 에 필요한 정보를 넣습니다.
  Json empty_player_ctxt;
  empty_player_ctxt.SetObject();

  // Matchmaking 을 요청합니다.
  MatchmakingClient::StartMatchmaking(
      kMatch1vs1, id, empty_player_ctxt, match_cb,
      MatchmakingClient::kMostNumberOfPlayers,
      MatchmakingClient::kNullProgressCallback, kTimeout);
}


// 클라이언트를 다른 서버로 이동시킵니다.
void MoveServerByTag(const Ptr<Session> session,
                     const string &tag,
                     const fun::Json &data) {
  // 아이디를 가져옵니다.
  string id;
  session->GetFromContext("id", &id);

  // tag 에 해당하는 서버중 하나를 무작위로 고릅니다.
  Rpc::PeerId target = PickServerRandomly(tag);
  if (target.is_nil()) {
    LOG(ERROR) << "Client redirecting failure. No target server: id=" << id
               << ", tag=" << tag;
    return;
  }

  // session context 를 JSON string 으로 저장합니다. 서버 이동 후
  // 새로운 session 으로 옮기기 위함입니다.
  fun::Json extra_data;
  extra_data.SetObject();
  if (not data.IsNull()) {
    extra_data.AddAttribute("game_context", data);
  }

  {
    boost::mutex::scoped_lock lock(*session);
    extra_data.AddAttribute("context", session->GetContext());
  }

  std::string extra_data_str = extra_data.ToString(false);

  // session 을 target 서버로 이동시키며 extra_data 를 함께 전달합니다.
  if (AccountManager::RedirectClient(session, target, extra_data_str)) {
    LOG(INFO) << "Client redirecting: id=" << id << ", tag="
              << tag << " server";
  } else {
    // 로그인하지 않았거나 tag 에 해당하는 서버가 없으면 발생합니다.
    LOG(ERROR) << "Client redirecting failure. Not logged in or "
                  "No target server: id=" << id << ", tag=" << tag;
  }
}

49.3.2. 게임 진행

클라이언트들을 모아서 플레이하는 방을 아이펀 엔진을 활용해서 구현하는 방법에 대해서는 Cookbook 1: 방 기반 MO 게임 제작 을 참고해주세요. 해당 개념을 이용해서 아래와 같이 구현합니다.

  1. 클라이언트들이 멀티 플레이하는 서버로 접속합니다. 이 때 extra_data 라는 원래 접속했던 서버에서 보낸 데이터를 받아서 처리합니다.
  2. 만약 이미 세션이 만들어져 있다면 (=다른 클라이언트가 먼저 도착한 경우) 해당 세션에 들어갑니다.
  3. 세션이 없다면 새로 만들고 진입합니다.
  4. 시간 내에 모든 클라이언트가 오면 게임을 시작합니다.
    • 접속한 클라이언트를 무한정 기다리는 것은 유저가 느끼기에 좋지 않습니다.
    • 만약 일정 시간이 지나도 게임을 시작할 수 없다면, 해당 클라이언트를 로그아웃 처리하고, 나머지 클라이언트는 다시 매치메이킹 서버로 이동하고 다시 매치메이킹을 시작합니다.
    • 여기서도 클라이언트 이동 기능을 사용할 수 있습니다.
// 클라이언트가 다른 서버에서 이동해 왔을 때 불립니다.
void OnClientRedirected(
    const std::string &account_id, const Ptr<Session> &session, bool success,
    const std::string &extra_data) {
  if (not success) {
    LOG(WARNING) << "Client redirection failed. Blocked by the engine: id="
                 << account_id;
    session->Close();
    return;
  }

  // 이전 서버의 Session Context 를 적용합니다.
  Json context;
  context.FromString(extra_data);
  if (context.HasAttribute("context", fun::Json::kObject)) {
    boost::mutex::scoped_lock lock(*session);
    session->SetContext(context["context"]);
  }

  Json game_context;
  if (context.HasAttribute("game_context", fun::Json::kObject)) {
    game_context = context["game_context"];
  } else {
    LOG(ERROR) << "Client(id=" << account_id << ") does not have match_data";
    session->Close();
    return;
  }

  if (not game_context.HasAttribute("game_id", fun::Json::kString) ||
      not game_context.HasAttribute("users", fun::Json::kArray)) {
    LOG(ERROR) << "game_context does not have required field(s).";
    session->Close();
    return;
  }

  fun::Uuid game_id;
  try {
    game_id = boost::lexical_cast<Uuid>(game_context["game_id"].GetString());
  } catch (const boost::bad_lexical_cast &) {
    LOG(ERROR) << "game_context has invalid game_id: "
               << game_context["game_id"].GetString();
    session->Close();
    return;
  }

  const size_t user_len = game_context["users"].Size();
  std::vector<std::string> users;
  users.reserve(user_len);
  for (size_t i = 0; i < user_len; ++i) {
    if (game_context["users"][i].IsString()) {
      users.emplace_back(game_context["users"][i].GetString());
    } else {
      LOG(ERROR) << "game_context has non-string user";
      session->Close();
      return;
    }
  }

  LOG(INFO) << "Client redirected: id=" << account_id << ", game=" << game_id;

  // TODO(jinuk): match_data 를 토대로 매치 생성
  fun::Event::Invoke([session, game_id, users]() {
      // game 에 해당하는 EventTag 로 실행한다.
      boost::unique_lock<boost::shared_mutex> lock(the_game_lock);
      // 유저 추가하고 리턴
      auto found = the_games.find(game_id);
      Ptr<GameSession> game;
      if (found != the_games.end()) {
        game = found->second;
        if (game->Join(session)) {
          _SetSessionToGame(session, game);
        }
      } else {
        const int32_t cache_index = _GetEmptyGameCacheSlotIndex();
        game = GameSession::Create(the_scheme, cache_index, game_id, users);

        // 신규 생성한 방이라서 [정원초과, 이미 시작함] 에러가 생기지는 않음.
        if (game->Join(session)) {
          _RegisterGame(game);
          _SetSessionToGame(session, game);
        }
      }
    }, game_id);
}

49.3.3. 게임 종료 처리

  1. 게임 결과를 싱글 플레이 게임 서버 쪽으로 전송합니다.

    • 해당 처리를 위해서 싱글 플레이 게임 서버는 매치 결과를 받을 HTTP API를 작성해야 합니다..

    • 게임 결과를 HTTP API에 넣어 호출합니다.

      {
        "game_id": "f6b72229-408b-4d3b-96f5-27c9cd0f3cbd",
        "users": [
          {
            "id": 42,
            "result": "win",
            "exp": 32768
          },
          {
            "id": 63,
            "result": "lose",
            "exp": 16384
          }
        ]
      }
      

    여기서 각 게임 세션을 구분하기 위해서 유일한 game_id 같은 값을 사용하면 편리합니다.

  2. 개별 접속자를 아이펀 엔진의 클라이언트 이동 기능을 이용해서 매치메이킹 서버로 보냅니다.

void GameSession::SetResult(const Ptr<Session> &session) {
  BOOST_ASSERT(GetCurrentEventTag() == id_);

  // 이 메시지는 패배한 쪽에서 전달한다.
  // FIXME(jinuk): 서버에서 relay 메시지를 분석해서 승패 확인
  std::string winner, loser;

  for (auto &user : users_) {
    if (user.session_ == session) {
      // 패배 메시지 전달
      loser = user.uid_;
      SendResultMessage(user.session_, false, scheme_);
    } else {
      // 승리 메시지 전달
      winner = user.uid_;
      SendResultMessage(user.session_, true, scheme_);
    }
  }

  if (not winner.empty()) {
    IncreaseCurWinCount(winner);
  }
  if (not loser.empty()) {
    ResetCurWinCount(loser);
  }

  fun::Event::Invoke([game=shared_from_this(), winner, loser](){
      FetchAndUpdateMatchRecord(winner, loser);
      game->DestroySession();
    }, id_);

  // 매치 결과를 싱글 플레이 서버로 전송
  /*
   * 여기서 HttpClient 를 만들고,
   * header 에 application/json 설정
   * body 에 game_id / 승리한 유저 아이디 / 패배한 유저 아이디 기록
   * 해당 header+body 를 싱글 플레이 웹 서버 주소에 전송
   */
  if (FLAGS_pong_game_result_url.empty()) {
    LOG(ERROR) << "pong_game_result_url is not set in MANIFEST.json";
    return;
  }
  Ptr<HttpClient> client = boost::make_shared<HttpClient>();
  client->SetHeader("Content-Type", "applicatoin/json");
  fun::Json result;
  result.SetObject();
  result.AddAttribute("game_id", boost::lexical_cast<string>(id_));
  result.AddAttribute("winner", winner);
  result.AddAttribute("loser", loser);
  auto callback = [client](const CURLcode code, const http::Response &res) {
      if (code != CURLE_OK) {
        LOG(ERROR) << "Failed to post match result to "
                   << FLAGS_pong_game_result_url
                   << "; curl=" << static_cast<int>(code);
        return;
      }

      if (res.status_code != fun::http::kOk) {
        LOG(ERROR) << "Failed to post match result to "
                   << FLAGS_pong_game_result_url
                   << "; HTTP status=" << static_cast<int>(res.status_code);
        return;
      }
    };
  client->PostAsync(FLAGS_pong_game_result_url, result, callback, 30000);
}

49.4. 부하에 대응하기

위 시나리오에서 게임 컨텐츠에 따라서 하나의 멀티 플레이 서버가 감당할 수 있는 유저 수 혹은 게임 세션(=방) 수가 충분하지 않을 수 있습니다. 이 때에는 멀티 플레이 세션을 담당하는 서버 flavor를 추가로 띄워주시면 됩니다. 이 때에는 멀티 플레이 세션을 담당하는 서버 flavor를 추가로 띄워주시면 됩니다.

멀티 플레이 서버를 추가로 띄우면, 기존 서버들은 해당 서버를 자동으로 인식하게 됩니다. 그리고 새로 띄운 서버를 인식하고 나면, 해당 서버는 이동할 서버를 고르는 부분에서 선택할 수 있게 됩니다 – 위 예제에서는 다루지 않았지만, 존재하는 게임 세션 수 등을 기준으로 서버를 선택하게 하는게 좋습니다.

Note

아이펀 엔진은 데이케이티드 서버에 대해서 클라우드 환경을 이용하여 자동으로 지정된 정책에 따라서 가상 머신 수를 증감합니다. 이와 유사한 기능이 아이펀 엔진으로 만든 서버 자체를 늘리고 줄이는 방식도 곧 제공할 예정입니다.