KoreanFoodie's Study

[C++ 게임 서버] 4-4. Session #1 본문

Game Dev/Game Server

[C++ 게임 서버] 4-4. Session #1

GoldGiver 2023. 12. 6. 18:06

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

[C++ 게임 서버] 4-4. Session #1

핵심 :

1. 게임 세션에 대한 기본 동작과 구현을 숙지하자. 제일 중요한 것은, 흐름이다.

2. 세션에 대한 Reference Count 는 상황에 맞게 잘 처리해 주어야, IocpObject 나 Session 에서 Memory Leak 이 발생하지 않는다.

이번 글에서는, Session 클래스를 만들고 기본적인 Accept 동작을 할 수 있도록 구현을 진행해 보겠다.

먼저 Session 클래스의 구현을 한 번 보자.

 

Session.h

#pragma once
#include "IocpCore.h"
#include "IocpEvent.h"
#include "NetAddress.h"

class Service;

/*--------------
	Session
---------------*/

class Session : public IocpObject
{
	friend class Listener;
	friend class IocpCore;
	friend class Service;

public:
	Session();
	virtual ~Session();

public:
	void				Disconnect(const WCHAR* cause);

	shared_ptr<Service>	GetService() { return _service.lock(); }
	void				SetService(shared_ptr<Service> service) { _service = service; }

public:
						/* 정보 관련 */
	void				SetNetAddress(NetAddress address) { _netAddress = address; }
	NetAddress			GetAddress() { return _netAddress; }
	SOCKET				GetSocket() { return _socket; }
	bool				IsConnected() { return _connected; }
	SessionRef			GetSessionRef() { return static_pointer_cast<Session>(shared_from_this()); }

private:
						/* 인터페이스 구현 */
	virtual HANDLE		GetHandle() override;
	virtual void		Dispatch(class IocpEvent* iocpEvent, int32 numOfBytes = 0) override;

private:
						/* 전송 관련 */
	void				RegisterConnect();
	void				RegisterRecv();
	void				RegisterSend();

	void				ProcessConnect();
	void				ProcessRecv(int32 numOfBytes);
	void				ProcessSend(int32 numOfBytes);

	void				HandleError(int32 errorCode);

protected:
						/* 컨텐츠 코드에서 오버로딩 */
	virtual void		OnConnected() { }
	virtual int32		OnRecv(BYTE* buffer, int32 len) { return len; }
	virtual void		OnSend(int32 len) { }
	virtual void		OnDisconnected() { }

public:
	// TEMP
	char _recvBuffer[1000];

private:
	weak_ptr<Service>	_service;
	SOCKET				_socket = INVALID_SOCKET;
	NetAddress			_netAddress = {};
	Atomic<bool>		_connected = false;

private:
	USE_LOCK;

	/* 수신 관련 */

	/* 송신 관련 */

private:
						/* IocpEvent 재사용 */
	RecvEvent			_recvEvent;
};

기본적으로 정보를 Get/Set 하는 부분을 정의하고, Disconnect 하는 것도 만들어 놓는다.

GetHandle 이나 Dispatch 같은 함수는 파생 클래스에서 오버라이드하도록 만들어야 한다. 추후 이 부분에 대해서는 흐름을 좀 더 자세히 파악해 볼 것이다.

Register.../Process... 가 Prefix 로 붙은 함수는 전송 및 수신과 관련된 함수이다.

마지막으로, OnConnected 같은 이벤트 함수를 만들어서, 파생 클래스에서 알아서 커스터마이징 할 수 있도록 하자.

public:
	// TEMP
	char _recvBuffer[1000];

private:
	weak_ptr<Service>	_service;
	SOCKET				_socket = INVALID_SOCKET;
	NetAddress			_netAddress = {};
	Atomic<bool>		_connected = false;

private:
	USE_LOCK;

	/* 수신 관련 */

	/* 송신 관련 */

private:
						/* IocpEvent 재사용 */
	RecvEvent			_recvEvent;

아래 부분에는 _recvBuffer 가 임시로 선언되어 있다. 일단 버퍼를 세션에 저장해서 사용할 것이다.

나머지 해당 세션이 속한 서비스나 소켓, NetAddress 등에 대한 정보도 Session 이 갖고 있다.

 

이제 실질적인 구현을 한 번 보자.

 

Session.cpp

#include "pch.h"
#include "Session.h"
#include "SocketUtils.h"
#include "Service.h"

/*--------------
	Session
---------------*/

Session::Session()
{
	_socket = SocketUtils::CreateSocket();
}

Session::~Session()
{
	SocketUtils::Close(_socket);
}

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

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

	OnDisconnected(); // 컨텐츠 코드에서 오버로딩
	SocketUtils::Close(_socket);
	GetService()->ReleaseSession(GetSessionRef());
}

HANDLE Session::GetHandle()
{
	return reinterpret_cast<HANDLE>(_socket);
}

void Session::Dispatch(IocpEvent* iocpEvent, int32 numOfBytes)
{
	switch (iocpEvent->eventType)
	{
	case EventType::Connect:
		ProcessConnect();
		break;
	case EventType::Recv:
		ProcessRecv(numOfBytes);
		break;
	case EventType::Send:
		ProcessSend(numOfBytes);
		break;
	default:
		break;
	}
}

void Session::RegisterConnect()
{
}

void Session::RegisterRecv()
{
	if (IsConnected() == false)
		return;

	_recvEvent.Init();
	_recvEvent.owner = shared_from_this(); // ADD_REF

	WSABUF wsaBuf;
	wsaBuf.buf = reinterpret_cast<char*>(_recvBuffer);
	wsaBuf.len = len32(_recvBuffer);

	DWORD numOfBytes = 0;
	DWORD flags = 0;
	if (SOCKET_ERROR == ::WSARecv(_socket, &wsaBuf, 1, OUT &numOfBytes, OUT &flags, &_recvEvent, nullptr))
	{
		int32 errorCode = ::WSAGetLastError();
		if (errorCode != WSA_IO_PENDING)
		{
			HandleError(errorCode);
			_recvEvent.owner = nullptr; // RELEASE_REF
		}
	}
}

void Session::RegisterSend()
{
}

void Session::ProcessConnect()
{
	_connected.store(true);

	// 세션 등록
	GetService()->AddSession(GetSessionRef());

	// 컨텐츠 코드에서 오버로딩
	OnConnected();

	// 수신 등록
	RegisterRecv();
}

void Session::ProcessRecv(int32 numOfBytes)
{
	_recvEvent.owner = nullptr; // RELEASE_REF

	if (numOfBytes == 0)
	{
		Disconnect(L"Recv 0");
		return;
	}

	// TODO
	cout << "Recv Data Len = " << numOfBytes << endl;

	// 수신 등록
	RegisterRecv();
}

void Session::ProcessSend(int32 numOfBytes)
{
}

void Session::HandleError(int32 errorCode)
{
	switch (errorCode)
	{
	case WSAECONNRESET:
	case WSAECONNABORTED:
		Disconnect(L"HandleError");
		break;
	default:
		// TODO : Log
		cout << "Handle Error : " << errorCode << endl;
		break;
	}
}

위에서부터 하나하나 살펴보자.

Session 의 경우, 소멸자가 불릴 때는 연결된 소켓도 닫아 주어야 할 것이다. 또한 클라이언트를 갑자기 종료한다던지 하는 식으로 세션 연결이 끊길 때도, 비슷한 처리를 해 주어야 한다.

Disconnect 함수에서 exchange 는, 원래 값이 인자로 준 값과 다를 경우에만 true 를 리턴하고, 바꾸려는 값과 원본 값이 같으면 false 를 리턴한다. 즉, 값을 딱 1번만 바꾸고 싶을 때 exchange 를 활용하면 좋다.

연결이 끝기면, OnDisconnedted 이벤트 함수를 호출하고, 현재 속한 서비스에서 현재 세션에 대한 Reference Count 를 감소 시켜야 한다. 그래야 세션 메모리가 제대로 해제될 수 있을 것이다.

 

사실 Dispatch 가 어떻게 보면 현재 구조에서의 핵심이라고 볼 수 있는데, 전달된 IocpEvent 에 맞게 Connect, Recv, Send 등의 다양한 동작을 지원하게 된다.

Recv 는 이전에 했던 것처럼 WSARecv 를 호출하고, Connect 되었을 경우 서비스에 세션을 등록하고 수신 등록을 실시한다. 나머지는 한번 쓱 보면 이해가 갈 것이다 😂

아, 참고로 RegisterRecv 함수에서 소켓 에러가 발생했는데 이것이 'Pending' 상태로 인한 것이 아니라면, 뭔가 심각하게 잘못되었다는 뜻이므로 _recvEvent.owner 를 nullptr 로 만들어서 Reference Count 를 줄어줘야 한다. 그래야 _recvEvent 가 사용이 끝났을 때, 메모리가 제대로 해제될 것이다! 😄

 

위에서는 Session 클래스의 기본적인 구현에 대한 이야기를 했지만, 사실 흐름을 파악하는 것이 제일 중요하다.

따라서 GameServer 의 main 에서부터 우리가 만든 Session 이 어떤 식으로 사용되는지 흐름을 파악해 보자.

 

GameServer.cpp

int main()
{
	ServerServiceRef service = MakeShared<ServerService>(
		NetAddress(L"127.0.0.1", 7777),
		MakeShared<IocpCore>(),
		MakeShared<GameSession>, // TODO : SessionManager 등
		100);

	ASSERT_CRASH(service->Start());

	for (int32 i = 0; i < 5; i++)
	{
		GThreadManager->Launch([=]()
			{
				while (true)
				{
					service->GetIocpCore()->Dispatch();
				}				
			});
	}	

	GThreadManager->Join();
}

일단 GameServer 로부터 시작할 것이다. 위를 보면, 서버가 서비스를 만들고, 세션을 넣어주는 것을 알 수 있다.

그 후, 루프를 돌면서 서비스에 대해 Dispatch 를 해 주게 되는데...

그럼 IocpCore::Dispatch 가 불리게 된다.

bool IocpCore::Dispatch(uint32 timeoutMs)
{
	DWORD numOfBytes = 0;
	ULONG_PTR key = 0;	
	IocpEvent* iocpEvent = nullptr;

	if (::GetQueuedCompletionStatus(_iocpHandle, OUT &numOfBytes, OUT &key, OUT reinterpret_cast<LPOVERLAPPED*>(&iocpEvent), timeoutMs))
	{
		IocpObjectRef iocpObject = iocpEvent->owner;
		iocpObject->Dispatch(iocpEvent, numOfBytes);
	}
	else
	{
		int32 errCode = ::WSAGetLastError();
		switch (errCode)
		{
		case WAIT_TIMEOUT:
			return false;
		default:
			// TODO : 로그 찍기
			IocpObjectRef iocpObject = iocpEvent->owner;
			iocpObject->Dispatch(iocpEvent, numOfBytes);
			break;
		}
	}

	return true;
}

그리고 나서 iocpEvent 의 owner 를 찾아 해당 오브젝트에 대해 Dispatch 를 불러주는데... 사실 이 iocpObject 가 바로 Listener 가 된다.

 

Listener.cpp

void Listener::Dispatch(IocpEvent* iocpEvent, int32 numOfBytes)
{
	ASSERT_CRASH(iocpEvent->eventType == EventType::Accept);
	AcceptEvent* acceptEvent = static_cast<AcceptEvent*>(iocpEvent);
	ProcessAccept(acceptEvent);
}

Listener 는 다시 ProcessAccept 를 호출한다.

void Listener::ProcessAccept(AcceptEvent* acceptEvent)
{
	SessionRef session = acceptEvent->session;

	if (false == SocketUtils::SetUpdateAcceptSocket(session->GetSocket(), _socket))
	{
		RegisterAccept(acceptEvent);
		return;
	}

	SOCKADDR_IN sockAddress;
	int32 sizeOfSockAddr = sizeof(sockAddress);
	if (SOCKET_ERROR == ::getpeername(session->GetSocket(), OUT reinterpret_cast<SOCKADDR*>(&sockAddress), &sizeOfSockAddr))
	{
		RegisterAccept(acceptEvent);
		return;
	}

	session->SetNetAddress(NetAddress(sockAddress));
	session->ProcessConnect();
	RegisterAccept(acceptEvent);
}

그럼 여기서 AcceptEvent 의 세션을 조회하고, Session 에 대해 ProcessConnect 를 불러준다.

 

Session.cpp

void Session::ProcessConnect()
{
	_connected.store(true);

	// 세션 등록
	GetService()->AddSession(GetSessionRef());

	// 컨텐츠 코드에서 오버로딩
	OnConnected();

	// 수신 등록
	RegisterRecv();
}

그럼 세션을 등록하고, RegisterRecv 함수를 호출한다.

void Session::RegisterRecv()
{
	if (IsConnected() == false)
		return;

	_recvEvent.Init();
	_recvEvent.owner = shared_from_this(); // ADD_REF

	WSABUF wsaBuf;
	wsaBuf.buf = reinterpret_cast<char*>(_recvBuffer);
	wsaBuf.len = len32(_recvBuffer);

	DWORD numOfBytes = 0;
	DWORD flags = 0;
	if (SOCKET_ERROR == ::WSARecv(_socket, &wsaBuf, 1, OUT &numOfBytes, OUT &flags, &_recvEvent, nullptr))
	{
		int32 errorCode = ::WSAGetLastError();
		if (errorCode != WSA_IO_PENDING)
		{
			HandleError(errorCode);
			_recvEvent.owner = nullptr; // RELEASE_REF
		}
	}
}

여기서 이제 _recvEvent 의 owner 를 Session 으로 바꾸어 주게 되고, 앞으로는 세션이 살아있는 한, Dispatch 의 로직은 Session 의 로직이 불릴 것이다! 😉

RegisterRecv 이후에는, 세션은 클라가 보낸 정보를 받는 역할을 하므로, Dispatch 에서 ProcessRecv 가 불릴 것이다.

 

그럼 이제 ProcessRecv 에서 데이터 전송 관련한 처리가 진행되게 된다! 😁

void Session::ProcessRecv(int32 numOfBytes)
{
	_recvEvent.owner = nullptr; // RELEASE_REF

	if (numOfBytes == 0)
	{
		Disconnect(L"Recv 0");
		return;
	}

	// TODO
	cout << "Recv Data Len = " << numOfBytes << endl;

	// 수신 등록
	RegisterRecv();
}
Comments