KoreanFoodie's Study

[C++ 게임 서버] 4-10. PacketSession 본문

Game Dev/Game Server

[C++ 게임 서버] 4-10. PacketSession

GoldGiver 2023. 12. 14. 18:50

Rookiss 님의 '[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 를 들으며 배운 내용을 정리하고 있습니다. 관심이 있으신 분은 꼭 한 번 들어보시기를 추천합니다!

[C++ 게임 서버] 4-10. PacketSession

핵심 :

1. 간단하게 패킷을 만들어 보자. 사실 데이터에 size 와 id(프로토콜) 을 헤더로 붙이면, 그게 기본적인 패킷의 형태다.

2. Session 을 제거하고 넣을 때는 기존 컨테이너의 정합성이 깨지지 않도록 주의하자. 이를 회피하기 위해 클라가 종료될 때, 바로 세션을 제거하지 않고, 대신 Disconnect 이벤트를 등록만 한 다음 다른 쓰레드가 Dispatch 될 때 세션을 제거하게 만들 수도 있다. 

저번까지는 버퍼에 우리가 보내려고 하는 문자열을 그냥 담아서 보냈다. 그런데 실제 게임에서는 이 데이터가 무슨 데이터인지에 대한 정보가 필요한데, 흔히 우리는 이것을 패킷이라고 한다.

즉, 해당 정보에 대한 메타 정보(흔히 헤더 부분에 붙인다)와 실제 정보를 합치면, 간단한 패킷이 탄생하게 된다.

어떤 게임이냐에 따라, 또 플젝에 따라 패킷의 구조와 사이즈는 천차만별이겠지만... 일단 여기서는 기본적으로 size 와 id 에 대한 값을 패킷의 헤더 부에 붙여서 주고 받는 식으로 만들어 보자.

Session 을 상속한 PacketSession 을 다음과 같이 만들 것이다 :

PacketSession.h

struct PacketHeader
{
	uint16 size;
	uint16 id; // 프로토콜ID (ex. 1=로그인, 2=이동요청)
};

class PacketSession : public Session
{
public:
	PacketSession();
	virtual ~PacketSession();

	PacketSessionRef	GetPacketSessionRef() { return static_pointer_cast<PacketSession>(shared_from_this()); }

protected:
	virtual int32		OnRecv(BYTE* buffer, int32 len) sealed;
	virtual int32		OnRecvPacket(BYTE* buffer, int32 len) abstract;
};

앞서 설명한 size 와 id 가 PakcetHeader 로 들어가는 것을 볼 수 있다. 이때 id 는 일반적으로 프로토콜의 ID 를 상징할 것이다! 😁

 

PacketSession.cpp

PacketSession::PacketSession()
{
}

PacketSession::~PacketSession()
{
}

// [size(2)][id(2)][data....][size(2)][id(2)][data....]
int32 PacketSession::OnRecv(BYTE* buffer, int32 len)
{
	int32 processLen = 0;

	while (true)
	{
		int32 dataSize = len - processLen;
		// 최소한 헤더는 파싱할 수 있어야 한다
		if (dataSize < sizeof(PacketHeader))
			break;

		PacketHeader header = *(reinterpret_cast<PacketHeader*>(&buffer[processLen]));
		// 헤더에 기록된 패킷 크기를 파싱할 수 있어야 한다
		if (dataSize < header.size)
			break;

		// 패킷 조립 성공
		OnRecvPacket(&buffer[processLen], header.size);

		processLen += header.size;
	}

	return processLen;
}

OnRecv 함수를 보자. 일단 위의 주석에 패킷이 어떤 식으로 생겨 먹었는지가 쓰여 있다. 🤣

본문에서는, dataSize 를 받아서 PacketHeader 의 사이즈보다 큰지, 패킷의 크기가 PakcetHeader 명세에 쓰여 있는 사이즈보다 작지 않은지를 검사하고, OnRecvPacket 을 호출해 패킷을 조립한다.

결국 Buffer 에서 processLen 의 위치를 읽어나가면서 패킷을 처리하는 것이다.

일단 OnRecvPacket 은, 다음과 같이 로그 정보만 찍어주도록 간단히 만들어 볼 것이다. 아래는 GameServer 에서 사용하는 GameSession 이고,

int32 GameSession::OnRecvPacket(BYTE* buffer, int32 len)
{
	PacketHeader header = *((PacketHeader*)buffer);
	cout << "Packet ID : " << header.id << "Size : " << header.size << endl;

	return len;
}

 

DummyClient 에서 사용하는 ServerSession 의 경우, 다음과 같다 :

virtual int32 OnRecvPacket(BYTE* buffer, int32 len) override
{
    PacketHeader header = *((PacketHeader*)buffer);
    //cout << "Packet ID : " << header.id << "Size : " << header.size << endl;

    char recvBuffer[4096];
    ::memcpy(recvBuffer, &buffer[4], header.size - sizeof(PacketHeader));
    cout << recvBuffer << endl;

    return len;
}

그럼 행복하게 패킷을 주고 받으면서 패킷을 조립하고 읽으면 될 것 같은데... GameServer 의 main 함수에서 생 Buffer 가 아니라 Packet 을 보내주도록 약간의 수정만 가해주면 된다!

while (true)
{
    SendBufferRef sendBuffer = GSendBufferManager->Open(4096);

    BYTE* buffer = sendBuffer->Buffer();
    ((PacketHeader*)buffer)->size = (sizeof(sendData) + sizeof(PacketHeader));
    ((PacketHeader*)buffer)->id = 1; // 1 : Hello Msg
    ::memcpy(&buffer[4], sendData, sizeof(sendData));
    sendBuffer->Close((sizeof(sendData) + sizeof(PacketHeader)));

    GSessionManager.Broadcast(sendBuffer);

    this_thread::sleep_for(250ms);
}

원래는 sendBuffer 만 보내줬는데, 여기에 PacketHeader 를 끼워서 보내주고 있다. 😂

PacketHeader 에서 size 와 id 는 각각 uint16 으로 2Byte 니까, 2 + 2 = 4 이므로, &buffer[4] 위치에 우리가 실제로 보내려고 하는 sendData 를 ::memcpy 해주고 있다. 😉

 

그럼 이제 아무런 문제가 없을 것처럼 보인다.

실제로 프로젝트를 실행한 후 Client 를 닫아 버리면, GameSession 의 BroadCast 에서 크래시가 난다. 😅

void GameSessionManager::Broadcast(SendBufferRef sendBuffer)
{
	WRITE_LOCK;
	for (GameSessionRef session : _sessions)
	{
		session->Send(sendBuffer);
	}
}

코드를 보면, 왠지 Loop 를 도는 와중 아이템을 하나 삭제해서 Container 가 깨진 것 같다는 생각이 드는데... 실제로 코드를 계속 파고 들어가다 보면,

GameSession::Send -> GameSession::RegisterSend(Send 한 번 보냈으니까 다시 낚싯대를 걸어 준다) -> Session::HandleError(SendEvent 를 다시 등록해야 하는데, 클라이언트를 강제 종료했으니 뭔가 에러가 발생할 것이다) -> Session::DisConnect 를 호출하게 된다.

문제는, Session::DisConnect 함수가 다음과 같다는 것이다 :

void Session::Disconnect(const WCHAR* cause)
{
	if (_connected.exchange(false) == false)
		return;

	// TEMP
	wcout << "Disconnect : " << cause << endl;

	RegisterDisconnect();

	OnDisconnected(); // 컨텐츠 코드에서 재정의
	GetService()->ReleaseSession(GetSessionRef());
}

RegisterDisconnect 를 해서 Disconnect 처리를 해 주는 부분은 좋은데, 문제는 OnDisconnected 부분 부터이다.

void GameSession::OnDisconnected()
{
	GSessionManager.Remove(static_pointer_cast<GameSession>(shared_from_this()));
}

OnDisConnected 함수는 자신이 속한 SessionManager 를 이용해 자신의 세션을 세션의 Set (Set<GameSessionRef> _sessions 로 정의되어 있음) 으로부터 제거하게 된다.

그러면, 실질적으로 연결이 끊겼을때, _sessions 컨테이너에서 자기 자신을 제거하게 되므로, 결과적으로 다음과 같은 동작을 하게 된다 :

void GameSessionManager::Broadcast(SendBufferRef sendBuffer)
{
	WRITE_LOCK;
	for (GameSessionRef session : _sessions)
	{
		session->Send(sendBuffer);
		_sessions.erase(session);
	}
}

 

그럼 이걸 해결하기 위해서, OnDisconnected 함수가 등장하는 곳을 옮겨주도록 하자. 이사할 위치는... 바로 ProcessDisconnect 되시겠다.

void Session::ProcessDisconnect()
{
	_disconnectEvent.owner = nullptr; // RELEASE_REF

	OnDisconnected(); // 컨텐츠 코드에서 재정의
	GetService()->ReleaseSession(GetSessionRef());
}

이렇게 옮겨주면, 이제 클라가 강제 종료 되더라도 Session 이 바로 제거되지 않고, Disconnect 이벤트만 Register 되어 있다가, 나중에 다른 쓰레드가 Dispatch 될 때 실질적인 Disconnect 처리를 진행하므로써 안정적으로 Session 이 _sessions 로부터 제거될 것이다 😄

Comments