8. 네트워킹 Part 1: 세션

아이펀 엔진에서는 클라이언트와의 연결을 세션으로 표현합니다. 하나의 세션은 TCP, UDP, HTTP 중 하나 이상의 전송 프로토콜을 가질 수 있으며 두 가지 이상의 전송 프로토콜을 함께 쓸 수도 있습니다. 예를 들면 로그인, 결제 메시지는 HTTP 으로 주고 받고 인 게임 메시지는 TCP 로 주고 받을 수 있습니다.

세션은 새로운 클라이언트가 접속할 때 생성되고, 세션을 닫는 함수를 부르거나, 타임아웃으로 닫힐 때까지 유효합니다. 세션을 이용하여 손쉽게 JSON 이나 Protobuf 등 원하는 프로토콜로 메시지를 주고 받을 수 있습니다.

아이펀 엔진의 세션은 게임 서비스를 위해 반드시 필요한 다음의 기능을 제공하고 있습니다.

  • 메시지(패킷) 전송 기능
  • TCP 연결 복원 기능
  • Ping 측정 기능
  • 긴급 메시지 기능
  • 암호화 기능
  • 메시지 리플레이 공격 차단 기능

이 외에도 다양한 기능을 제공하며 이 챕터와 다음 챕터에서 사용 방법을 익히실 수 있습니다.

Note

아이펀 엔진의 네트워크 프로토콜을 자세히 이해하고 싶으시면 (고급) 아이펀 엔진의 네트워크 스택 을 참고하시기 바랍니다.

8.1. 세션 핸들러 등록

새로운 세션이 열리거나, 세션이 닫힐 때 불리는 핸들러 함수를 등록할 수 있습니다. 핸들러에는 새 세션이나 닫힌 세션이 인자로 전달 됩니다.

#include <funapi.h>

...

// 아래 Installer 는 프로젝트를 만들 때 자동으로 생성됩니다.
class MyServerInstaller : public Component {
  static bool Install(const ArgumentMap &/*arguments*/) {
    ...
    // OnSessionOpened 함수와 OnSessionClosed 함수를 등록합니다.
    HandlerRegistry::Install2(OnSessionOpened, OnSessionClosed);
    ...
    // OnTcpDisconnected 함수를 등록합니다. TCP 연결이 끊기면 불립니다.
    HandlerRegistry::RegisterTcpTransportDetachedHandler(OnTcpDisconnected);
    ...
  }
}

void OnSessionOpened(const Ptr<Session> &session) {
  // 새로운 클라이언트가 접속하여 세션이 만들어졌습니다.
  // 필요하다면 여기서 세션 초기화 처리를 할 수 있습니다.
  // (여기서 메시지 전송도 가능합니다)
  LOG(INFO) << "New session opened: " << session->id();
}

void OnSessionClosed(const Ptr<Session> &session, SessionClosedReason reason) {
  // 세션이 닫혔습니다.
  // (이미 닫힌 후에 불리기 때문에 메시지 전송은 불가능합니다.)
  LOG(INFO) << "Session closed: " << session->id();

  // 아래처럼 닫힌 원인에 따라 처리할 수 있습니다.
  if (reason == kClosedForServerDid) {
    // Session::Close() 함수를 호출하여 닫았습니다. 또는 비정상 클라이언트로
    // 판단되어 자동으로 Session::Close() 가 불렸을 수 도 있습니다.
    ...
  } else if (reason == kClosedForIdle) {
    // 세션이 타임아웃되어 닫혔습니다.
    ...
  }
}

void OnTcpDisconnected(const Ptr<Session> &session) {
  // 세션의 TCP 연결이 끊겼습니다.
  // 실시간 대전 등 TCP 연결에 민감한 처리가 있었다면 여기서 예외처리 할 수
  // 있습니다. (AI 전환 또는 대전에서 강퇴)
  ...
}

Tip

추가적으로 HandlerRegistry::RegisterTcpTransportDetachedHandler 를 이용하여 세션의 TCP 연결이 끊길 때 불리는 함수를 등록할 수 있습니다.

using funapi;

public class Server
{
  public static void Install(ArgumentMap arguments)
  {
    ...
    // OnSessionOpened 함수와 OnSessionClosed 함수를 등록합니다.
    NetworkHandlerRegistry.RegisterSessionHandler (
      new NetworkHandlerRegistry.SessionOpenedHandler (OnSessionOpened),
      new NetworkHandlerRegistry.SessionClosedHandler (OnSessionClosed));

    ...
  }

  public static void OnSessionOpened(Session session)
  {
    // 새로운 클라이언트가 접속하여 세션이 만들어졌습니다.
    // 필요하다면 여기서 세션 초기화 처리를 할 수 있습니다.
    // (여기서 메시지 전송도 가능합니다)
    Log.Info ("Session opened.");
  }

  public static void OnSessionClosed(Session session)
  {
    // 세션이 닫혔습니다.
    // (이미 닫힌 후에 불리기 때문에 메시지 전송은 불가능합니다.)
    Log.Info ("Session closed.");
  }
}

8.2. 세션에서 패킷 수신

아이펀 엔진으로 메시지(패킷)를 보내고 받을 때는 메시지가 어떤 메시지인지 구분하기 위해 메시지 타입이라는 문자열을 함께 전송하게 됩니다. 예를 들면 로그인 “login”, 아이템 구매 “buy_item”, 케릭터 이동 메시지 “move” 가 될 수 있습니다.

메시지를 수신하기 위해서는 수신된 메시지를 처리하는 함수를 등록해야합니다. 이 함수는 메시지 핸들러라고 부릅니다. 메시지 핸들러는 각 메시지 타입별로 등록할 수 있습니다. 이 함수는 메시지를 보낸 세션, 그리고 수신된 메시지가 인자로 전달되며 어떤 전송 프로토콜(TCP, UDP, HTTP)로 수신하든 불리게 됩니다. 메시지 핸들러 함수에서 메시지를 보낼 때 별도의 전송 프로토콜을 지정하지 않으면 자동으로 메시지를 수신한 전송 프로토콜로 보내게 됩니다.

아래는 “hello” 라는 메시지를 받아 처리하는 OnHello 함수를 등록하는 예제입니다.

Note

메시지 타입을 기재하는 것은 아이펀팩토리 Github 계정 에 있는 클라이언트 플러그인을 쓸 경우 알아서 세팅되니 별도로 신경쓰지 않아도 됩니다.

8.2.1. JSON 메시지 핸들러

HandlerRegistry::Register(메시지타입, 핸들러함수) 를 이용하여 JSON 메시지 핸들러를 등록할 수 있습니다.

#include <funapi.h>

// 아래 Installer 는 프로젝트를 만들 때 자동으로 생성됩니다.
class MyServerInstaller : public Component {
  static bool Install(const ArgumentMap &/*arguments*/) {
    ...
    // 아래처럼 처리하고 싶은 메시지 타입과 함수를 연동합니다.
    // 메시지 타입은 JSON 안에서 "_msgtype" 이라는 키 값에 들어있는 값입니다.
    HandlerRegistry::Register("hello", OnHello);
    ...
  }
}

void OnHello(const Ptr<Session> &session, const Json &message) {
  ...
  // hello 메시지를 수신하면 이 함수가 불립니다.

  // 예를 들어 hello 메시지가 아래와 같은 데이터를 포함했다면
  //
  // {
  //   "user_id": "my_user",
  //   "character": {
  //     "name": "my_character",
  //     "level": 99,
  //     ...
  //   }
  // }
  //
  // 아래와 같이 읽을 수 있습니다.
  // string user_id = message["user_id"].GetString();
  // string character_name = message["character"]["name"].GetString();
  // int64_t character_level = message["character"]["level"].GetInteger();
  ...
}

HandlerRegistry::RegisterMessageHandler(메시지타입, 핸들러함수) 를 이용하여 JSON 메시지 핸들러를 등록할 수 있습니다.

using funapi;

public class Server
{
  public static void Install(ArgumentMap arguments)
  {
    ...
    NetworkHandlerRegistry.RegisterMessageHandler (
        "hello",
        new NetworkHandlerRegistry.JsonMessageHandler (OnHello));
    ...
  }

  public static void OnHello (Session session, JObject message)
  {
    ...
    // hello 메시지를 수신하면 이 함수가 불립니다.

    // 예를 들어 hello 메시지가 아래와 같은 데이터를 포함했다면
    //
    // {
    //   "user_id": "my_user",
    //   "character": {
    //     "name": "my_character",
    //     "level": 99,
    //     ...
    //   }
    // }
    //
    // 아래와 같이 읽을 수 있습니다.
    // JObject character = (JObject) message["character"];

    // message 안의 user_id를 가져오겠습니다.
    // string user_id = (string) message["user_id"];
    //
    // 또, 다음과 같이 가져올 수도 있습니다.
    // string user_id = message["user_id"].Value<String>();

    // string character_name = (string) character["name"];
    // Int64 character_level = (Int64) character["level"];
    ...
  }

8.2.2. Protobuf 메시지 핸들러

HandlerRegistry::Register2(메시지타입, 핸들러함수) 를 이용하여 Google Protocol Buffers 메시지 핸들러를 등록할 수 있습니다.

// FunMessage 는 아이펀엔진의 Google Protocol Buffers 최상위 메시지입니다.
extend FunMessage {
  optional HelloMessage hello_message = 16;
  ...
}
message HelloMessage {
  required string user_id = 1;
  required Character character = 2;
}
message Character {
  required string name = 1;
  required int64 level = 2;
}
#include <funapi.h>

class MyServerInstaller : public Component {
  // Installer 는 프로젝트를 만들 때 자동으로 생성됩니다.
  static bool Install(const ArgumentMap &/*arguments*/) {
    ...
    // 아래처럼 처리하고 싶은 메시지 타입과 함수를 연동합니다.
    HandlerRegistry::Register2("hello", OnHello);
    //
    // 또는 아래와 같이 정수형 메세지 타입을 사용할 수 있습니다.
    // HandlerRegistry::Register2(1000, OnHello);
    //
    // C++에서는 다음과 같이 생성된 메세지 구조체를 메세지 타입으로 지정할 수 있습니다.
    // HandlerRegistry::Register2(hello_message, OnHello);
    ...
  }
}

void OnHello(const Ptr<Session> &session, const Ptr<FunMessage> &message) {
  ...
  // hello 메시지를 수신하면 이 함수가 불리며 아래와 같이 읽을 수 있습니다.
  //
  // if (not message->HasExtension(hello_message)) {
  //   LOG(ERROR) << "wrong message";
  //   ...
  //   return;
  // }
  //
  // const HelloMessage &hello = message->GetExtension(hello_message);
  //
  // string user_id = hello.user_id();
  // string character_name = hello.character().name();
  // int64_t character_level = hello.character().level();
  ...
}

NetworkHandlerRegistry.RegisterMessageHandler(메시지타입, 핸들러함수) 를 이용하여 Google Protocol Buffers 메시지 핸들러를 등록할 수 있습니다.

// FunMessage 는 아이펀엔진의 Google Protocol Buffers 최상위 메시지입니다.
extend FunMessage {
  optional HelloMessage hello_message = 16;
  ...
}
message HelloMessage {
  required string user_id = 1;
  required Character character = 2;
}
message Character {
  required string name = 1;
  required int64 level = 2;
}
using funapi;

public class Server
{
  public static void Install(ArgumentMap arguments)
  {
    ...
    NetworkHandlerRegistry.RegisterMessageHandler (
        "hello",
        new NetworkHandlerRegistry.ProtobufMessageHandler (OnHello));
    //
    // 또는 아래와 같이 정수형 메세지 타입을 지정할 수 있습니다.
    // NetworkHandlerRegistry.RegisterMessageHandler (
    //     1000,
    //     new NetworkHandlerRegistry.ProtobufMessageHandler (OnHello));
    ...
  }

  public static void OnHello(Session session, FunMessage message)
  {
    ...
    // hello 메시지를 수신하면 이 함수가 불리며 아래와 같이 읽을 수 있습니다.
    //
    // HelloMessage hello_message;
    // if (!message.TryGetExtension_hello_message (
    //     out hello_message))
    // {
    //   Log.Error ("OnEchoPbuf: Wrong message.");
    //   return;
    // }
    //
    // string user_id = hello_message.user_id;
    // string character_name = hello_message.character.name;
    // long character_level = hello_message.character.level;
    ...
  }
}

8.3. 세션에 패킷 송신

세션에 메시지(패킷)을 보내기 위해서는 Session::SendMessage() 또는 분산 서버를 지원하는 AccountManager::SendMessage() 함수를 사용하면 됩니다. (AccountManager::SendMessage()다른 서버의 클라이언트에게 패킷 보내기 을 참고해주세요)

메시지 전송함수는 아래와 같은 인자를 가지고 있습니다.

Session::SendMessage(메시지타입, 메시지 [, 암호화방식] [, 전송프로토콜])
  • 메세지 타입: 메세지 구분 식별자로 사용됩니다. 문자열 또는 정수 형태로 사용 가능합니다.
  • 메세지: 보낼 메세지를 지정합니다. Json 또는 FunMessage 형태로 지정할 수 있습니다.
  • 암호화방식: 생략하거나 kDefaultEncryption 을 지정하면 기본 값이 사용됩니다. 자세한 설명은 메시지 암호화 을 참고 하시기 바랍니다.
  • 전송 프로토콜: kTcp, kUdp, kHttp 중 하나가 될 수 있으며 생략하면 자동으로 선택됩니다. 자세한 설명은 멀티 프로토콜 를 참고하시기 바랍니다.

Note

ambiguous transport protocol for sending 'testtype' message. candidates: Tcp, Http 와 같은 오류 로그가 출력되며 전송되지 않을 경우 멀티 프로토콜 의 설명을 참고하시기 바랍니다.

Note

SendMessage ignored. no 'Tcp' transport 로그는 해당 전송 프로토콜에 대한 연결이 없는 경우이며(kTcp 로 보내려 했으나 TCP 연결이 끊긴 상태 등) 정상적인 상황에서도 흔하게 발생합니다.

Tip

Session::SendBackMessage() 함수를 이용하면 메시지 타입을 생략할 수 있으며 자동으로 마지막으로 수신된 메시지 타입으로 보내게 됩니다. 예를 들어 “login” 이란 메시지를 받는 핸들러에서 SendBackMessage() 를 호출한다면 SendMessage("login", ...) 와 같이 동작합니다.

8.3.1. JSON 메시지 전송

이 예제에서는 아래 JSON 메시지를 “world” 라는 메시지 타입으로 전송합니다.

{
   "user_id": "my_user",
   "character": {
     "name": "my_character",
     "level": 99,
     ...
   }
 }
Json message;
message["user_id"] = "my_user";
message["character"]["name"] = "my_character";
message["character"]["level"] = 99;

session->SendMessage("world", message);

// 명시적으로 HTTP 로 전송
session->SendMessage("world", message, kDefaultEncryption, kHttp);

// ChaCha20 으로 암호화하여 전송
session->SendMessage("world", message, kChacha20Encryption);

// 명시적으로 TCP 로 AES128 로 암호화하여 전송
session->SendMessage("world", message, kAes128Encryption, kTcp);

이 예제에서는 아래 JSON 메시지를 “world” 라는 메시지 타입으로 전송합니다.

{
   "user_id": "my_user",
   "character": {
     "name": "my_character",
     "level": 99,
     ...
   }
 }
JObject message = new JObject();
message["user_id"] = "my_user";
message["character"] = new JObject();
message["character"]["name"] = "my_character";
message["character"]["level"] = 99;

session.SendMessage ("world", message);

// 명시적으로 HTTP 로 전송
session.SendMessage (
    "hello",
    message,
    Session.Encryption.kDefault,
    Session.Transport.kHttp);

// ChaCha20 으로 암호화하여 전송
session.SendMessage (
    "hello", message, Session.Encryption.kChaCha20);

// 명시적으로 TCP 로 AES128 로 암호화하여 전송
session.SendMessage (
    "hello",
    message,
    Session.Encryption.kAes128,
    Session.Transport.kTcp);

8.3.2. Protobuf 메시지 전송

이 예제에서는 아래 HelloMessage 메시지를 “world” 라는 메시지 타입으로 전송합니다.

extend FunMessage {
  optional HelloMessage hello_message = 16;
  ...
}
message HelloMessage {
  required string user_id = 1;
  required Character character = 2;
}
message Character {
  required string name = 1;
  required int64 level = 2;
}
Ptr<FunMessage> message(new FunMessage);
HelloMessage *hello = message->MutableExtension(hello_message);
hello->set_user_id("my_user");
Character *character = hello->mutable_character();
character->set_name("my_character");
character->set_level(99);

session->SendMessage("world", message);

// 명시적으로 HTTP 로 전송
session->SendMessage("world", message, kDefaultEncryption, kHttp);

// ChaCha20 으로 암호화하여 전송
session->SendMessage("world", message, kChacha20Encryption);

// 명시적으로 TCP 로 AES128 로 암호화하여 전송
session->SendMessage("world", message, kAes128Encryption, kTcp);

// 정수형 메세지 타입을 지정한 TCP 전송
session->SendMessage(1000, message, kDefaultEncryption, kTcp);

// C++에서는 메세지 타입으로 Protobuf 구조체도 사용할 수 있습니다.
session->SendMessage(hello_message, message, kDefaultEncryption, kTcp);

이 예제에서는 아래 HelloMessage 메시지를 “world” 라는 메시지 타입으로 전송합니다.

extend FunMessage {
  optional HelloMessage hello_message = 16;
  ...
}
message HelloMessage {
  required string user_id = 1;
  required Character character = 2;
}
message Character {
  required string name = 1;
  required int64 level = 2;
}
FunMessage message = new FunMessage();

HelloMessage hello_message = new HelloMessage();
hello_message.user_id = "my_user";
hello_message.character = new Character();
hello_message.character.name = "my_character";
hello_message.character.level = 99;
message.AppendExtension_hello_message (hello_message)
session.SendMessage ("world", message);

// 명시적으로 HTTP 로 전송
session.SendMessage ("world", message, kDefaultEncryption, kHttp);

// ChaCha20 으로 암호화하여 전송
session.SendMessage ("world", message, kChacha20Encryption);

// 명시적으로 TCP 로 AES128 로 암호화하여 전송
session.SendMessage ("world", message, kAes128Encryption, kTcp);

// 정수형 메세지 타입을 지정한 TCP 전송
session.SendMessage (1000, message, kDefaultEncryption, kTcp);

Important

트랜잭션 에 설명된 대로, 아이펀 엔진의 오브젝트 서브시스템은 잡고 있던 모든 락을 풀고 작업을 롤백하는 방식으로 데드락을 방지합니다. 작업을 롤백한다는 이야기는 패킷 핸들러 (메시지 핸들러) 가 여러번 중복 실행될 수 있다는 것을 의미합니다. 그런데 이런 중복 실행은 되돌릴 수 없는 작업을 수행하는 함수들에게는 문제가 됩니다. 그리고 SendMessage() 함수는 그런 되돌릴 수 없는 작업에 해당됩니다. 이를 피하기 위해서는 중복실행이 되서는 안되는 코드를 롤백 가능한 것들 뒤에 위치시키면 됩니다.

아이펀 엔진은 이런 함수들이 실수로 롤백 가능한 코드 앞에 위치해서 의도하지 않은 중복 실행이 되는 것을 방지하기 위하여 롤백이 발생하면 assertion 을 발생하게끔 되어있습니다. 개발 과정에서 이런 assertion 은 쉽게 발견될 수 있고, 코드 위치를 바꿔서 이를 피해주시면 됩니다.

8.3.3. 서버의 모든 세션에 메시지 전송하기

아이펀 엔진에서는 모든 세션에 메시지를 전송하는 기능을 제공하고 있습니다. 로컬 서버에 접속한 모든 세션에 메시지를 전송하기 위해서는 Session::BroadcastLocally() 함수를 사용하면 됩니다. 함수 인자는 세션에 패킷 송신 에서 설명하는 Session::SendMessage() 함수와 동일합니다. 함수 인자중에 TransportProtocolkTcpkUdp 타입만 사용할 수 있습니다.

Tip

분산 환경에서 로컬 서버뿐만 아니라 다른 서버에 접속한 모든 세션에도 메시지를 전송하기 위해서는 로그인 여부 상관없이 서버의 모든 세션에 패킷 보내기 에 설명된 Session::BroadcastGlobally() 함수를 사용하면 됩니다.

Note

Session::BroadcastLocally()Session::BroadcastGlobally() 는 로그인 처리 완료 여부와 상관없이 모든 세션에 메시지를 전송합니다. 로그인 처리된 유저들에게만 메시지를 전송하는 것은 서버의 로그인한 모든 클라이언트에 패킷 보내기 를 참고하세요.

JSON 메시지 전송

아래 코드는 로컬 서버에 접속한 모든 세션에 다음의 JSON 메시지를 전송하는 예제입니다.

{
  "user_id": "my_user",
  "character": {
    "name": "my_character",
    "level": 99,
    ...
  }
}
void BroadcastToAllLocalSessions() {
  Json message;
  message["user_id"] = "my_user";
  message["character"]["name"] = "my_character";
  message["character"]["level"] = 99;

  // TCP 로 접속한다고 가정하겠습니다.
  // 만약 UDP 로 접속한다면 kUdp 로 지정하세요.
  Session::BroadcastLocally("world", message, kDefaultEncryption, kTcp);
}
public void BroadcastToAllLocalSessions()
{
  JObject message = new JObject ();
  message["user_id"] = "my_user";
  message["character"] = new JObject ();
  message["character"]["name"] = "my_character";
  message["character"]["level"] = 99;

  // TCP 로 접속한다고 가정하겠습니다.
  // 만약 UDP 로 접속한다면 kUdp 로 지정하세요.
  Session.BroadcastLocally ("world",
                            message,
                            Session.Encryption.kDefault,
                            Session.Transport.kTcp);
}

Protobuf 메시지 전송

아래 코드는 로컬 서버에 접속한 모든 세션에 다음의 Protobuf 메시지를 전송하는 예제입니다.

extend FunMessage {
  optional HelloMessage hello_message = 16;
  ...
}
message HelloMessage {
  required string user_id = 1;
  required Character character = 2;
}
message Character {
  required string name = 1;
  required int64 level = 2;
}
void BroadcastToAllLocalSessions() {
  Ptr<FunMessage> message(new FunMessage);
  HelloMessage *hello = message->MutableExtension(hello_message);
  hello->set_user_id("my_user");
  Character *character = hello->mutable_character();
  character->set_name("my_character");
  character->set_level(99);
  // TCP 로 접속한다고 가정하겠습니다.
  // 만약 UDP 로 접속한다면 kUdp 로 지정하세요.
  Session::BroadcastLocally("world", message);
  // 또는 다음과 같이 정수형 메세지 타입을 사용할 수 있습니다.
  // Session::BroadcastLocally(1000, message);
  // C++에서는 Protobuf 구조체를 메세지 타입으로 사용할 수 있습니다.
  // Session::BroadcastLocally(hello_message, message);

}
public void BroadcastToAllLocalSessions()
{
  FunMessage message = new FunMessage ();
  HelloMessage hello = new HelloMessage ();
  hello.user_id = "my_user";
  hello.character = new Character ();
  hello.character.level = 99;
  hello.character.name = "my_character";
  message.AppendExtension_hello_message (hello);

  // TCP 로 접속한다고 가정하겠습니다.
  // 만약 UDP 로 접속한다면 kUdp 로 지정하세요.
  Session.BroadcastLocally ("world",
                            message,
                            Session.Encryption.kDefault,
                            Session.Transport.kTcp);
  // 또는 다음과 같이 정수형 메세지 타입을 사용할 수 있습니다.
  // Session.BroadcastLocally (1000,
  //                           message,
  //                           Session.Encryption.kDefault,
  //                           Session.Transport.kTcp);
}

Important

Session::BroadcastLocally() 함수는 오브젝트 트랜잭션 롤백이 발생할 경우 assertion 을 일으키는 코드를 내장하고 있습니다. 자세한 내용은 트랜잭션 을 참고하세요.

8.4. 세션 종료

세션은 아래 3 가지 원인으로 닫힐 수 있습니다.

  1. void Session::Close() 함수를 호출하면 즉시 세션이 닫힙니다.
  2. 지정된 시간 동안 메시지 송수신이 없을 경우 자동으로 닫힙니다. (네트워킹 기능 설정 파라미터 에 설명된 session_timeout_in_second 로 그 시간을 설정할 수 있습니다.)
  3. 비정상적인 클라이언트로 판단될 경우 자동으로 닫힙니다.

세션이 닫히게 되면 세션 핸들러 등록 에 설명된 세션 닫힘 핸들러가 호출됩니다. 세션 닫힘 핸들러로 전달되는 닫힘 원인은 1 번과 3 번은 kClosedForServerDid, 2 번은 kClosedForIdle 로 됩니다.

그리고 bool Session::IsOpened() const 함수로 열려있는지 확인할 수 있습니다. 메시지 핸들러가 호출될 때 전달되는 세션은 열려 있기 때문에 별도의 검사가 필요 없지만, 세션을 전역 변수에 저장하여 사용할 경우 열려 있는지 확인이 필요할 때가 있습니다. (하지만 이 경우도 별도의 검사 없이 세션 닫힘 핸들러에서 전역 변수 등에 저장된 세션을 정리하는 것이 더 나을 수 있습니다.)

Note

닫힌 세션으로 메시지를 전송하면 SendMessage ignored. closed session 로그가 출력되며 전송되지 않고 무시됩니다. (정상적인 상황에서도 나올 수 있으며 오류를 나타내는 로그가 아닙니다.)

세션은 남겨두고 붙어있는 연결만 종료하기

세션은 남겨두고 세션에 붙어있는 연결만 종료시킬 수 있습니다. 이 경우 세션 타임아웃 시간 이전에 클라이언트가 다시 연결을 맺게 되면 해당 세션을 계속해서 사용할 수 있습니다.

void Session::CloseTransport([닫을 전송 프로토콜]) 함수를 활용합니다. 닫을 전송 프로토콜 인자는 TCP, UDP, HTTP 가 가능하며 생략하면 모든 전송 프로토콜을 닫게 됩니다.

bool Session::IsTransportAttached([전송 프로토콜]) const 함수로 전송 프로토콜이 연결되어 있는지 확인할 수 있습니다. 전송 프로토콜 인자를 생략하면 종류에 관계 없이 아무 것이나 연결된 것이 있는지 확인합니다.

TCP 의 경우 연결이 끊길 때 불리는 함수를 등록할 수 있습니다. 세션 핸들러 등록 의 설명을 참고하기 바랍니다.

Ptr<Session> session = ...;

// session 에 TCP 연결이 있는지 확인합니다.
if (session->IsTransportAttached(kTcp)) {
  ...
}

// session 에 TCP, UDP, HTTP 가 있는지 확인합니다.
if (session->IsTransportAttached()) {
  ...
}

// session 에 TCP 연결이 있다면 끊습니다.
session->CloseTransport(kTcp);

// session 의 TCP, UDP, HTTP 를 모두 닫습니다.
session->CloseTransport();
session = ...;

// session 에 TCP 연결이 있는지 확인합니다.
if (session.IsTransportAttached (Session.Transport.kTcp)) {
  ...
}

// session 에 TCP, UDP, HTTP 가 있는지 확인합니다.
if (session.IsTransportAttached ()) {
  ...
}

// session 에 TCP 연결이 있다면 끊습니다.
session.CloseTransport (Session.Transport.kTcp);

// session 의 TCP, UDP, HTTP 를 모두 닫습니다.
session.CloseTransport ();

8.5. 세션 데이터

8.5.1. 세션 태그

세션에 태그를 붙이고 태그로 세션을 검색할 수 있습니다.

  • 태그 추가: void Session::Tag(const string &tag)
  • 태그 제거: void Session::Untag(const string &tag)
  • 태그 유무 확인: bool Session::HasTag(const string &tag)
  • 태그 리스트 추출: std::set<string> Session::GetTags()
  • 특정 태그를 가진 모든 세션 추출: static SessionsSet Session::FindWithTag(const string &tag)

아래는 태그 기능을 활용하여 PvP 대전에 참여중인 유저들에게만 공지를 보내는 예입니다.

// PvP 대전이 시작될 때 불리는 핸들러라고 하겠습니다.
void OnPvPStarted(const Ptr<Session> &session, const Json &message) {
  ...
  // PvP 대전 태그를 붙입니다.
  session->Tag("pvp");
}

// 운영자가 PvP 대전을 진행중인 유저에게 공지를 보내는 함수라고 하겠습니다.
void NoticeToPvP(const string &notice_message) {
  // SessionsSet 은 boost::unordered_set<Ptr<Session>> 의 typedef 입니다.
  Session::SessionsSet sessions = Session::FindWithTag("pvp");
  for (const Ptr<Session> &session: sessions) {
    // 공지 메시지를 전달합니다.
    session->SendMessage(...);
  }
}
// PvP 대전이 시작될 때 불리는 핸들러라고 하겠습니다.
public void OnPvPStarted(Session session, JObject message)
{
  // PvP 대전 태그를 붙입니다.
  session.Tag("pvp");
}

// 운영자가 PvP 대전을 진행중인 유저에게 공지를 보내는 함수라고 하겠습니다.
public void NoticeToPvP(string notice_message)
{
  List<Session> sessions = Session.FindWithTag("pvp");
  foreach(Session session in sessions)
  {
    // 공지 메시지를 전달합니다.
    session.SendMessage (...);
  }
}

8.5.2. 세션 콘텍스트

각 세션 별로 고유의 상태, 데이터를 저장할 수 있습니다.

  • 컨텍스트 쓰기: void Session::SetContext(const Json &context)
  • 컨텍스트 읽기: Json &Session::GetContext()

Important

위 두 함수는 thread-safe 하지 않기 때문에 주의 하셔야 합니다. 필요할 경우 아래 함수들로 뮤텍스 락을 걸 수 있습니다.

  • void Session::LockContext()
  • void Session::UnlockContext()

또는

  • boost::mutex &Session::GetContextMutex() 로 직접 뮤텍스 객체를 얻을 수 있습니다.

더 편한 방법은 세션 객체를 뮤텍스로 사용하는 것이며, 아래 예제는 이것을 이용해 세션이 닫힐 때 세션 콘텍스트로 로그인 상태인지 판단하여 로그아웃 처리를 합니다.

각 세션 별로 고유의 상태, 데이터를 저장할 수 있습니다.

  • 컨텍스트 객체 얻기: JObject Session.Context()

Important

위 함수는 thread-safe 하지 않기 때문에 주의 하셔야 합니다. 필요할 경우에 아래처럼 동기화 하여 사용합니다.

lock (session)
{
  ...
}
// 이 함수는 로그인 요청이 올 때 불린다고 하겠습니다.
void OnLogin(const Ptr<Session> &session, ...) {
  ...
  // 로그인 처리를 합니다.
  ...

  // 로그인이 완료 되었으면 아래 처리를 합니다.
  {
    // 뮤텍스 락을 걸어 동시에 쓰는 것을 보호합니다.
    boost::mutex::scoped_lock lock(*session);
    // 또는 이렇게도 가능합니다. (데드락 주의)
    // session->LockContext() 반드시 sesion->UnlockContext() 도 불러야합니다.

    // 세션이 로그인 상태임을 저장합니다.
    session->GetContext()["login"] = true;
  }
}

// 이 함수는 세션이 닫힐 때 불리는 핸들러라고 하겠습니다.
void OnSessionClosed(const Ptr<Session> &session) {
  ...

  // 로그인 되어 있던 세션이면 로그아웃 처리를 합니다.
  bool logged_in = false;
  {
    // 뮤텍스 락을 걸어 동시에 쓰는 것을 보호합니다.
    boost::mutex::scoped_lock lock(*session);

    const Json &ctxt = session->GetContext();
    if (ctxt.HasAttribute("login", Json::kBoolean)) {
      logged_in = ctxt["login"].GetBool();
    }
  }

  if (logged_in) {
    // 로그인 상태의 세션입니다. 로그아웃 처리를 합니다.
    ...
  }
}
// 이 함수는 로그인 요청이 올 때 불린다고 하겠습니다.
public void OnLogin(Session session, ...)
{
  ...
  // 로그인 처리를 합니다.
  ...

  // session의 context를 직접 제어할 때는 thread-safe하지 않으므로
  // 다음과 같이 해당 세션을 대상으로 락을 잡아서 처리합니다.
  lock (session)
  {
    session.Context ["login"] = true;
  }
}

// 이 함수는 세션이 닫힐 때 불리는 핸들러라고 하겠습니다.
public void OnSessionClosed(Session session)
{
  ...

  // 로그인 되어 있던 세션이면 로그아웃 처리를 합니다.
  bool logged_in = false;

  lock (session)
  {
    if (session.Context ["login"] != null)
    {
      JToken token = session.Context.GetValue ("login");
      if (token.Type == JTokenType.Boolean)
      {
        logged_in = true;
      }
      else
      {
        Log.Error ("wrong json type 'login'. type= {0}",
                   token.Type.ToString());
      }
    }
    else
    {
      Log.Error ("wrong json attribute 'login'. json string= {0}",
                 sesson.Context.ToString());
    }
  }

  if (logged_in) {
    // 로그인 상태의 세션입니다. 로그아웃 처리를 합니다.
    ...
  }
}

Tip

아래 함수를 사용하면 세션 콘텍스트를 조금 더 편리하게 쓸 수 있습니다. thread-safe 하며 콘텍스트 JSON 에 지정된 key 에 값을 쓰고 읽습니다.

  • void Session::AddToContext(const string &key, const string &value)
  • void Session::AddToContext(const string &key, int64_t value)
  • bool Session::GetFromContext(const string &key, string *ret)
  • bool Session::GetFromContext(const string &key, int64_t *ret)
  • bool Session::DeleteFromContext(const string &key)

이 함수들을 이용하여 위 예제를 아래처럼 간단하게 할 수 있습니다.

// 이 함수는 로그인 요청이 올 때 불린다고 하겠습니다.
void OnLogin(const Ptr<Session> &session, ...) {
  ...
  // 로그인 처리를 합니다.
  ...

  // 로그인이 완료 되었으면 아래 처리를 합니다.
  session->AddToContext("login", 1);
}

// 이 함수는 세션이 닫힐 때 불리는 핸들러라고 하겠습니다.
void OnSessionClosed(const Ptr<Session> &session) {
  ...

  // 로그인 되어 있던 세션이면 로그아웃 처리를 합니다.
  int64_t logged_in = 0;
  if (session->GetFromContext("login", &logged_in) && logged_in == 1)
  {
    // 로그인 상태의 세션입니다. 로그아웃 처리를 합니다.
    ...
    session->DeleteFromContext("login");
  }
}
// 이 함수는 로그인 요청이 올 때 불린다고 하겠습니다.
public void OnLogin(Session session, ...)
{
  ...
  // 로그인 처리를 합니다.
  ...

  // 로그인이 완료 되었으면 아래 처리를 합니다.
  session.AddToContext ("login", 1);
}

// 이 함수는 세션이 닫힐 때 불리는 핸들러라고 하겠습니다.
public void OnSessionClosed(Session session)
{
  ...

  Int64 logged_in = 0;
  if (session.GetFromContext ("login", out logged_in) && logged_in == 1)
  {
    // 로그인 상태의 세션입니다. 로그아웃 처리를 합니다.
    ...
    session.DeleteFromContext ("login");
  }
}

8.6. 세션 Ping(RTT)

아이펀 엔진은 클라이언트와의 round-trip time(RTT) 를 얻는 기능을 제공합니다. 또한, 일정 시간 동안 핑 요청에 응답하지 않은 클라이언트와의 연결을 끊는 기능도 제공합니다.

Note

현재 버전에서는 TCP Transport 에서만 작동됩니다.

8.6.1. 측정

Session::SetPingSamplingInterval() 함수로 측정 주기를 설정할 수 있습니다. 기본 값은 아래 네트워크 기능 설정 파라미터ping_sampling_interval_in_second 입니다. 0 는 사용하지 않음을 의미합니다.

8.6.2. 값 얻기

Session::GetPing() 함수로 Ping(RTT) 값을 얻을 수 있습니다.

8.6.3. 응답하지 않는 연결 끊기

Session::SetPingTimeout() 함수로 설정된 시간 동안 응답이 안 오면 연결을 끊습니다. 세션이 닫히는 것은 아니며 TCP 연결만 끊습니다. 기본 값은 아래 네트워크 기능 설정 파라미터ping_timeout_in_second 입니다. 0 는 사용하지 않음을 의미합니다.

8.6.4. 예제

// 서버 Install 함수입니다.
static bool Install(const ArgumentMap &) {
  // 세션 열림 핸들러를 등록합니다.
  HandlerRegistry::Install2(OnSessionOpened, ...);

  // TCP 연결 끊김 핸들러를 등록합니다.
  HandlerRegistry::RegisterTcpTransportDetachedHandler(OnTcpDisconnected);

  HandlerRegistry::Register(...);
  ...
}

// 세션이 열리면 불리는 핸들러입니다.
void OnSessionOpened(const Ptr<Session> &session) {
  // 10 초 주기로 RTT 를 측정하며 5 초 이상 응답하지 않으면 연결을 닫습니다.
  session->SetPingSamplingInterval(10);  // 이 코드는 MANIFEST 의 기본 값을 설정해두면 필요 없습니다.
  session->SetPingTimeout(5);

  ...
}

// TCP 연결이 끊기면 불리는 핸들러입니다.
void OnTcpDisconnected(const Ptr<Session> &session) {
  // TCP 연결이 끊기면 이 함수가 불립니다.
  ...
}

// 임의의 사용자 함수
void OnXYZ(const Ptr<Session> &session, ...) {
  // 필요할 때 아래와 같이 RTT 값을 얻을 수 있습니다.
  Session::Ping ping = session->GetPing();
  if (ping.second == 0) {
    // 측정된 rtt 가 하나도 없습니다.
    return;
  }

  int64_t rtt_in_ms = ping.first / 1000;

  LOG(INFO) << "rtt=" << rtt_in_sec << " ms";
}
// 서버 Install 함수입니다.
public static void Install(ArgumentMap arguments)
{
  // 세션 열림 핸들러를 등록합니다.
  NetworkHandlerRegistry.RegisterSessionHandler (
    new NetworkHandlerRegistry.SessionOpenedHandler (OnSessionOpened),
    new NetworkHandlerRegistry.SessionClosedHandler (OnSessionClosed));

  HandlerRegistry::Register(...);
  ...
}

// 세션이 열리면 불리는 핸들러입니다.
public static void OnSessionOpened(Session session)
{

  // 10 초 주기로 RTT 를 측정하며 5 초 이상 응답하지 않으면 연결을 닫습니다.
  session.SetPingSamplingInterval(10);  // 이 코드는 MANIFEST 의 기본 값을 설정해두면 필요 없습니다.
  session.SetPingTimeout(5);
}

// 임의의 사용자 함수
public static void OnXYZ(Session session)
{
  // 필요할 때 아래와 같이 RTT 값을 얻을 수 있습니다.
  Session.Ping ping = session.GetPing();
  TimeSpan rtt_span = ping.RoundtripTime;

  if (ping.SamplingCount <= 0)
  {
    // 측정된 rtt 가 하나도 없습니다.
    return;
  }

  Log.Info ("rtt= {0}ms", rtt_span.Milliseconds);
}

8.7. 세션 메시지 핸들러 후킹

핸들러 후킹 함수를 이용하면 메세지 핸들러가 호출되기 전(Pre)/후(Post) 시점에서 핸들러 호출을 제어할 수 있으며 Protobuf와 Json 핸들러 모두 지원합니다.

호출되기 전(Pre) 핸들러는 bool 타입을 리턴 값으로 받는데 이 값이 false일 경우 핸들러와 호출 후(Post) 핸들러가 불리지 않습니다. 또한 두 핸들러 모두 NULL일 경우에는 에러가 발생합니다.

typedef function<bool(
    const Ptr<Session> &/*session*/,
    const Ptr<const FunMessage> &/*message*/,
    const string &/*message type*/)> ProtobufPreMessageHandlerHook;

typedef function<void(
    const Ptr<Session> &/*session*/,
    const Ptr<const FunMessage> &/*message*/,
    const string &/*message type*/)> ProtobufPostMessageHandlerHook;

typedef function<bool(
    const Ptr<Session> &/*session*/,
    const Json &/*message*/,
    const string & /*message type*/)> JsonPreMessageHandlerHook;

typedef function<void(
    const Ptr<Session> &/*session*/,
    const Json &/*message*/,
    const string &/*message type*/)> JsonPostMessageHandlerHook;

void InstallProtobufMessageHandlerHook(
    const ProtobufPreMessageHandlerHook &protobuf_pre_message_handler_hook,
    const ProtobufPostMessageHandlerHook &protobuf_post_message_handler_hook);

void InstallJsonMessageHandlerHook(
    const JsonPreMessageHandlerHook &json_pre_message_handler_hook,
    const JsonPostMessageHandlerHook &json_post_message_handler_hook);
class Session
{
  ...
  public delegate bool ProtobufPreMessageHandlerHook(Session session,
                                                     FunMessage message,
                                                     string message_type);

  public delegate void ProtobufPostMessageHandlerHook(Session session,
                                                      FunMessage message,
                                                      string message_type);

  public delegate bool JsonPreMessageHandlerHook(Session session,
                                                 JObject message,
                                                 string message_type);

  public delegate void JsonPostMessageHandlerHook(Session session,
                                                  JObject message,
                                                  string message_type);

  public static void InstallProtobufMessageHandlerHook (
      ProtobufPreMessageHandlerHook pre_hook,
      ProtobufPostMessageHandlerHook post_hook);

  public static void InstallJsonMessageHandlerHook (
      JsonPreMessageHandlerHook pre_hook,
      JsonPostMessageHandlerHook post_hook);
  ...
}

아래 예제는 핸들러 호출 전 Json 오브젝트를 검사하여 test 라는 항목이 있을 경우에만 호출하는 코드입니다.

...
bool OnJsonPreMessageHandle(
    const Ptr<fun::Session> &session,
    const fun::Json &json,
    const string &message_type) {
  if (json.HasAttribute("test")) {
    return true;
  }

  return false;
}
...

void RegisterEventHandlers() {
  ...
  InstallJsonMessageHandlerHook(OnJsonPreMessageHandle, NULL);
  ...
}
public class Server
{
  ...
  static bool OnPreMessageHandle(Session session,
                                 JObject json,
                                 string message_type) {
    if (json["test"] != null) {
      return true;
    }

    return false;
  }
  ...
  public static bool Install(ArgumentMap arguments)
  {
    ...
    Session.InstallJsonMessageHandlerHook (OnPreMessageHandle, null);
    ...
  }
}

8.8. 세션 메시지 전송 후킹

서버 성능 통계 목적으로 SendMessage() 가 불릴 때 불리는 함수를 등록할 수 있습니다. 이 함수와 마지막 메시지 수신 시간 를 이용하면 메시지를 수신하고 응답하는데 걸린 시간을 측정할 수 있습니다.

아래 함수로 훅 함수를 등록할 수 있습니다.

typedef function<void(const Ptr<Session> &/*session*/,
                      const Ptr<const FunMessage> &/*message*/,
                      const string &/*message_type*/,
                      size_t message_size)> ProtobufMessageSendHook;

typedef function<void(const Ptr<Session> &/*session*/,
                      const Json &/*message*/,
                      const string &/*message_type*/,
                      size_t message_size)> JsonMessageSendHook;

void InstallProtobufMessageSendHook(const ProtobufMessageSendHook &hook);
void InstallJsonMessageSendHook(const JsonMessageSendHook &hook);
class Session
{
  ...
  public delegate void ProtobufMessageSendHook(Session session,
                                               FunMessage message,
                                               string message_type,
                                               ulong message_size);

  public delegate void JsonMessageSendHook(Session session,
                                           JObject message,
                                           string message_type,
                                           ulong message_size);

  public static void InstallProtobufMessageSendHook(
      ProtobufMessageSendHook hook);

  public static void InstallJsonMessageHook(
      JsonMessageSendHook hook);
  ...
}

아래는 메시지 응답 시간을 로그로 출력하는 예제입니다. 이 예제에서는 클라이언트가 보내는 메시지의 메시지 타입은 cs_ prefix 가 서버가 보내는 것은 sc_ prefix 가 붙는다고 가정하겠습니다.

auto send_hook = [](const Ptr<Session> &session, const Json &message,
                    const string &message_type, size_t message_size) {
  // 클라와 서버간 메시지 타입이 prefix 로 인해 다르기 때문에
  // prefix 를 제거하여 공통 메시지 타입을 얻습니다.
  const char *common_message_type = &message_type[3];

  // 메시지 수신 시간을 얻어 응답하는데 걸린 시간을 출력합니다.
  WallClock::Value request_time;
  if (session->GetLastReceiveTime(string("cs_") + common_message_type,
                                  &request_time)) {
    string ip_address;
    session->GetRemoteEndPoint(kHttp, &ip_address);
    size_t response_time_in_ms = (WallClock::Now() - request_time).total_milliseconds();
    LOG(INFO) << "Response: ip_address=" << ip_address
              << ", msgtype=" << common_message_type
              << ", response_time=" << response_time_in_ms
              << ", response_size=" << message_size
              << ", error_code=" << message["error_code"].GetInteger();
  } else {
    // request 없는 send
  }
};

// 서버 Install 시점에 이 함수를 등록합니다.
fun::InstallJsonMessageSendHook(send_hook);
C# 예제는 추후 업데이트 됩니다.

8.9. 세션의 메시지 전송 안정성

모바일 환경은 인터넷 연결이 끊기고 재연결되는 경우가 빈번하여 안정적인 게임 서비스를 위하여 이에 대한 대비가 필요합니다. 아이펀 엔진은 순간적인 인터넷 재연결은 유저가 인지할 수 없는 수준을 넘어서 게임 개발자도 인지하지 않아도 되도록 구현되어 있습니다.

TCP 연결이 끊겼을 때 연결을 다시 맺는 것은 어렵지 않습니다. 하지만 재연결 처리 과정에 보낸 메시지는 유실되며 어느 패킷들이 유실되었는지 알아내기도 어렵습니다. 유실된 패킷을 복원하지 못하면 게임 진행을 위한 콘텍스트가 망가져 정상적으로 게임을 진행할 수 없습니다.(메인 메뉴로 강제로 보내거나, 앱을 재시작하게 해야합니다.) 일반적으로 이 유실된 패킷을 알아내고 복구하는 것은 쉽지 않으며 게임 서버와 클라이언트의 소스를 매우 복잡하게 만듭니다.

아래의 configuration 과 제공되는 클라이언트 플러그인의 session reliability 옵션을 활성화하면 아이펀 엔진이 알아서 재연결된 후 유실된 패킷들을 순서에 맞게 전달합니다.

8.10. 세션 편의 기능

8.10.1. 세션 검색

아래 함수를 이용하여 세션 아이디로 세션을 얻을 수 있습니다.

static Ptr<Session> Session::Find(const SessionId &session_id)
static Session Find(System.Guid session_id)

세션 아이디는 아래 함수로 얻을 수 있습니다.

const SessionId &Session::id() const
System.Guid Id

8.10.2. 세션 주소

아래 함수를 이용하여 세션에 연결된 클라이언트의 IP/Port 를 얻을 수 있습니다.

bool Session::GetRemoteEndPoint(TransportProtocol protocol, string *ip, uint16_t *port = NULL)

port 인자는 생략할 수 있습니다.

아래는 로그인 시 클라이언트의 IP 주소를 로그로 출력하는 예제입니다.

// 로그인 시 불리는 핸들러라고 하겠습니다.
void OnLogin(const Ptr<Session> &session, ...) {
  ...
  string ip;
  if (session->GetRemoteEndPoint(kTcp, &ip)) {
    LOG(INFO) << "client_ip_address=" << ip;
  } else {
    // 이 핸들러 처리 중 TCP 연결이 끊긴 경우입니다.
  }
}
// 로그인 시 불리는 핸들러라고 하겠습니다.
void OnLogin(Session session, ...) {
  ...
  string ip;
  if (session.GetRemoteEndpoint (Session.Transport.kTcp, out ip)) {
    Log.Info ("client_ip_address={0}", ip);
  } else {
    // 이 핸들러 처리 중 TCP 연결이 끊긴 경우입니다.
  }
}

8.10.3. 마지막 송수신 메시지 타입

아래 두 함수로 마지막 송수신된 메시지 타입을 얻을 수 있습니다.

const string &Session::LastSentMessageType() const
const string &Session::LastReceivedMessageType() const
string LastSentMessageType
string LastReceivedMessageType

8.10.4. 마지막 메시지 수신 시간

아래 함수로 지정된 메시지 타입의 마지막 수신 시간을 얻을 수 있습니다. 이 기능은 세션 메시지 전송 후킹 에 설명된 훅 함수를 등록할 때만 작동합니다.

bool Session::GetLastReceiveTime(const string &msg_type, WallClock::Value *receive_time) const

통계 목적으로 제공되는 기능이며 세션 메시지 전송 후킹 의 설명을 참고하시기 바랍니다.