빠른 메시지 만들기

11 Feb 2014

c++ message

Cap'n Proto와 같은 무한대(?)로 빠른 메시지를 설계한다고 생각해보자. 그렇다면 우리는 어떤 점을 고민해야 할까?

이를 위해 잠깐 네트워크로 정보를 전달하는 과정을 생각해보자.

  1. end-point #1은 정보를 패킷에 담아 네트워크로 전송한다. (serialize)
  2. end-point #2는 네트워크에서 바이트를 읽어 메시지를 구성하고, 그 메시지로부터 정보를 읽는다. (deserialize)

당연한 이야기지만 메시지 serialize/deserialize 비용을 최소화하는 것이 정답이란 소리다. 그러면 이것을 어떻게 최적화할 수 있을까? 이는 메시지에 어떠한 내용이 들어가냐를 고민해봄으로써 좀 더 자세히 생각해볼 수 있다.

다음과 같은 메시지가 있다고 해보자.

struct player_message_t {
    float hp, mp;
    int atk, def, luck;
};

위 경우 serialize, deserialize 과정이 필요가 없다. 왜냐하면,

// send
send(reinterpret_cast<const byte*>(&player_stat));

// receive
player_message_t* message = reinterpret_cast<const player_message_t*>(buffer);

c/c++의 struct는 바로 byte로 mapping될 수 있기 때문데 send할 때 그냥 보내고, receive할 때 그냥 casting해서 읽으면 되기 때문이다. 이 경우 필요한 추가 정보라고 해봐야 message dispatch를 위한 id와, message의 경계를 확인하기 위한 message size 정도이다.

struct message_header_t {
    int message_id;
    int message_size;
};

하지만 위와 같은 단순한 경우일 때 message_size는 단순히 sizeof(T) 같은 형태로 얻어낼 수 있으니 더욱 구조가 간단해질 수 있다.

좀 더 복잡한 메시지를 고려해보자. 소위 말하는 가변 길이 메시지인데 보통은 list 때문에 발생한다. (string의 경우 char의 list)

이 경우 가장 간단한 방법은 size를 기록하는 것이다.

struct player_message_t {
    float hp, mp;
    int name_size;
    char* name_bytes;
    int atk, def, luck; 
};

하지만 이 경우 첫 번째 경우와 같은 serialize/deserialize 성능을 보이기가 어렵다. 그 이유는 name_bytes 때문인데, 이 때문에 쓸 때 따로 write하는 구문을 추가해야할 뿐만 아니라 읽을 때도 name_size만큼 메모리를 할당한 다음 메시지를 읽도록 구현해야 하기 때문이다.

하지만 위 문제를 해결할 수 있는 간단한 방법이 있다. 바로 offset과 null-terminated를 사용하는 것이다.

struct player_message_t {
    float hp, mp;
    int name_offset;
    int atk, def, luck;
    const char* name() const {
        return reinterpret_cast<const char*>(this) + name_offset;
    }
};

이 경우 메시지의 bytes 구조는 다음과 같아진다.

  • hp mp name_offset atk def luck name-bytes

위와 같이 작성할 경우 serialize 입장에서는 큰 차이가 없다. hp, mp, atk, def, luck를 모두 write한 다음 name_offset의 위치를 기록하고, 마지막 부분에 name-bytes를 기록한다. 하지만 deserialize에서 큰 차이가 나타난다. 첫 번째 소개한대로 읽은 message buffer에 대해 단순히 player_message_t로 casting만 해서도 관련 정보를 모두 읽을 수 있는 구조이기 때문에, deserialize 비용이 필요 없어진다.

list도 위와 같은 방법으로 구현할 수 있다. 차이점이 있다면 string의 경우 char type에 대해 구현한 것이고, list의 경우 어떤 T type에 대해 구현한다는 것이다.

struct player_message_t {
    float hp, mp;
    int name_offset;
    int inven_offset;
    int inven_count;
    int atk, def, luck;
    struct inven_t {
        int item_id;
        int item_data_id;
    };
    const char* name() const {
        return reinterpret_cast<const char*>(this) + name_offset;
    }
    const inven_t* begin() const {
        return reinterpret_cast<const inven_t*>(
                    reinterpret_cast<const char*>(this) + inven_offset);
    }
    const inven_t* end() const {
        return reinterpret_cast<const inven_t*>(
                    reinterpret_cast<const char*>(this) + inven_offset)
                        + inven_count;
    }
};

이 경우 메시지의 구조는 다음처럼 된다.

  • hp mp name_offset inven_offset inven_count atk def luck name-bytes inven_t-bytes

그냥 string랑 똑같은데 begin(), end() method를 나누어 구현했다고 보면 된다. 다만, end() 함수에서 inven_count를 더하는 부분이 inven_t 크기가 고정일 경우를 가정하고 있는데, 이 부분은 null-terminated 등을 써서 가변에 대해서도 쉽게 적용할 수 있을 것이라 보기 때문에 더 이상의 자세한 설명은 생략한다. (iterator를 직접 구현하여 사용할 경우 간단히 해결할 수 있는데 그렇게까지 글을 쓰면 너무 무의미하게 길어진다.)

대체 왜 이런 글을 쓰는지 지금도 잘 모르겠지만 어쨌든 글을 마무리 짓자면, 발상의 전환을 통해 어렵지 않은 방법으로 성능 개선을 할 수 있는 방법은 무궁무진하니 이미 알고 있는 내용일지라도 여러 방면으로 고민하여 기술을 갈고 닦았으면 좋겠다.

comments powered by Disqus