바쁜 학기가 끝나고 다시 게임 서버쪽 개발을 시작했다. 이번엔 동적 폰 클래스를 스폰하는 과정을 개발하고 있었다. 직업마다 스폰시켜야 할 폰(아처, 워리어 등..)을 달리 해야 하기 때문이다. 여기서 문제가 발생했다.
내가 구상한 로직은 다음과 같다.
클라이언트 접속 시도 요청(C_Login) -> 서버측 승인(S_Login) -> 클라이언트의 방 접속 시도(C_EnterRoom) -> 서버측 승인 후 스폰 브로드캐스트(S_Spawn)
이 때, S_Spawn에는 플레이어의 리스트가 담겨져 있다. 문제는 이곳에서 일어났다.

콜스택을 뒤져보니, 알 수 없는 필드라는 에러가 발생했다. S_Spawn 내용을 확인해보겠다.

S_Spawn의 구조체 내용을 보도록 하자
// S_Spawn
message S_Spawn
{
repeated ObjectInfo players = 1;
}
// ObjectInfo
message ObjectInfo {
uint64 object_id = 1;
ObjectType object_type = 2;
PosInfo pos_info = 3;
}
S_Spawn의 직렬화는 서버측에서 하기 때문에, 부분 소스코드를 분석해봤다.
// 지금 접속한 플레이어의 초기화
bool success = AddObject(object);
object->posInfo->set_x(RandomUtil::GetRandom(0.f, 500.f));
object->posInfo->set_y(RandomUtil::GetRandom(0.f, 500.f));
object->posInfo->set_z(100.f);
object->posInfo->set_yaw(RandomUtil::GetRandom(0.f, 100.f));
// 이미 접속한 클라이언트에게 브로드캐스트
message::S_Spawn spawnPkt;
spdlog::info("{} Player에게 전달할 info 개수 : {}", object->objectInfo->object_id(), _objects.size());
for (auto& item : _objects)
{
if (dynamic_pointer_cast<Player>(item.second) == nullptr) continue;
message::ObjectInfo* playerInfo = spawnPkt.add_players();
playerInfo->CopyFrom(*item.second->objectInfo);
}
// 방금 접속한 플레이어에게 스폰 패킷 전송(플레이어들 동기화) <- 여기가 문제임을 발견
message::S_Spawn spawnPkt;
spdlog::info("{} Player에게 전달할 info 개수 : {}", object->objectInfo->object_id(), _objects.size());
for (auto& item : _objects)
{
if (dynamic_pointer_cast<Player>(item.second) == nullptr) continue;
message::ObjectInfo* playerInfo = spawnPkt.add_players();
playerInfo->CopyFrom(*item.second->objectInfo);
}
중단점으로 일일히 확인한 결과, 스폰 패킷을 접속한 플레이어에게 전달하는 부분에서 UnknownField 에러가 발생함을 찾아냈다.
그렇다면, 브로드캐스트하는 패킷과 접속한 플레이어에게 전달하는 패킷의 차이점을 확인해보자.
첫번째 브로드캐스트

첫번째 접속한 플레이어의 패킷

두번째 브로드캐스트

두번째 접속한 플레이어의 패킷

중단점을 찍으면서 테스트를 하면 정상적으로 작동된다.

슬슬 머리가 아파진다. 클라이언트 사이드도 캡처해본다.
첫번째 S_Spawn 수신

두번째 S_Spawn 수신

갑자기 사이즈가 확 달라진다. 중단점에 따라 분기가 나뉘므로, 예측하기엔 멀티 스레드 환경으로 인한 동기화 문제일 것으로 보인다.
그렇다면, 서버&클라 중단점을 잡은 결과와 클라 사이드만 중단점을 잡은 결과로 비교를 해보겠다. 또한 사이즈가 70 이상이 되면 서버측에서 로그를 남기도록 했다.
char* rawBuffer = new char[requiredSize];
auto sendBuffer = asio::buffer(rawBuffer, requiredSize);
PacketUtil::Serialize(sendBuffer, message::HEADER::PLAYER_SPAWN_RES, spawnPkt);
if (sendBuffer.size() > 70)
{
spdlog::info("에러 분기점!");
}
session->Send(sendBuffer);
둘 다 중단점을 잡은 결과

클라만 중단점을 잡은 결과

결론 내리길, tcp 통신 중 2개 이상의 패킷을 동시에 클라이언트측에서 읽어버려서 이렇게 된 것으로 보인다. 이는 Nagle 알고리즘에 의해 작은 패킷들을 한 번에 보내고 있어서 발생한 것으로 보인다.
따라서, Nagle 알고리즘을 꺼보도록 하겠다.
asio::ip::tcp::no_delay option(true);
_socket.set_option(option);
허나, 작동되지 않았다. 이러면 패킷을 모았다가 보낸 것이 아니라, 이전에 수신받은 패킷이 아직 recvBuffer에 남아 있는 상태에서 다른 패킷이 들어왔을 때, 패킷 간 경계가 애매모호해져 파싱이 안됐다는 뜻이 된다. 드디어 문제의 코드를 찾을 수 있었다.
private:
template<typename PacketType, typename ProcessFunc>
static bool HandlePacket(ProcessFunc func, PacketSessionRef& session, asio::mutable_buffer& buffer, PacketHeader& header, int& offset)
{
PacketType pkt;
if ( !PacketUtil::Parse(pkt, buffer, buffer.size() - offset, offset) ) return false;
return func(session, pkt);
}
};
이 코드는 패킷 타입(pkt), buffer와 사이즈값으로 파싱이 가능하면 pkt에 역직렬화하는 과정이다. 주목할 곳은 바로 이것이다.
static bool Parse(google::protobuf::Message& msg, const asio::mutable_buffer& buffer, const int payloadSize, int& offset)
이 함수를 호출하면 패킷 클래스 msg에 버퍼데이터를 payload만큼 역직렬화하는 것이다. 그렇다면, 경계를 정해주는 곳은 payload와 offset인데, 이곳이 잘못됐다는 뜻이다. 수상한 부분을 찾았다. buffer.size() - offset가 의미하는 것은 무엇일까?
첫 offset 시작은 4이다. 왜냐하면 Parse함수는 header가 파싱된 다음 호출되는데 header의 사이즈는 4이다. 전체 패킷의 크기가 40이라고 가정한다면, 헤더의 위치는 0~3, payload의 위치는 4~39이다.
그래서 이 코드를 작성할 당시, "전체 버퍼 크기에서 헤더의 사이즈(=offset)만큼 빼주면 그게 payload 크기 아니야?"라는 나의 안일한 생각으로 이렇게 작성해버린 것이다. 만약 패킷이 40, 50 사이즈의 2개가 recvBuffer에 있다면, 첫번째 패킷에 대해 Parse를 요청할 경우 90 - 4 = 86이 되어버리는 것이다. 이러니 당연히 파싱이 안되지.
따라서 다음 코드로 변경했다.
if ( !PacketUtil::Parse(pkt, buffer, header.Length, offset) ) return false;
header는 headerCode와 headerLength를 갖고 있다. header를 직렬화하는 곳이 다음과 같다.
static bool Serialize(const asio::mutable_buffer& buffer, const short packetCode, const google::protobuf::Message& msg)
{
const size_t requiredSize = RequiredSize(msg);
if (buffer.size() < requiredSize)
return false;
PacketHeader header;
header.Length = static_cast<short>(msg.ByteSizeLong());
header.Code = packetCode;
...
패킷을 생성하는 송신부에서 이미 payload를 선언해주기 때문에, header.Length를 그대로 사용해도 된다. 이거때문에 주말 하루를 날려버리다니..