더미 클라이언트로 스트레스 테스트하기

이창준, Changjoon Lee·2025년 9월 6일
0

Game Server Hyperion 🎮

목록 보기
11/14

문제

언리얼 클라이언트 실행파일로 4개의 동접자까진 테스트를 완료했다.

두시간 가량 틀어뒀는데 메모리가 튀지도 않고 CPU 부하도 일정하게 유지됐다.
메모리 덤프도 떠 놨으니 나중에 궁금할 때 찾아보라.
feat/obop 브랜치에서 커밋 로그는 dumped at‎Friday, ‎September ‎5, ‎2025, ‏‎11:05:53 PM

다만 다량의 동시 접속자를 실험해볼 순 없다.
이 방법으로는 GPU 렌더가 필요하기에 현재 내 컴퓨터의 사양으로는 다섯명이 한계다.

해결

동접자 스트레스 테스트가 가능하도록 더미 클라이언트를 만들 것이다.
GPU 렌더가 없는 헤드리스이고, 사용자가 실제 보낸 메시지를 수집해서 서버에 전송하게 만들어볼 것이다.

목표

  1. 하나의 실행파일로 여러명의 접속을 만들어낸다.
  2. 가장 먼저는 서버 부하를 실험해볼 것이고,
  3. 그 다음엔 언리얼 클라이언트 프로그램의 Replication 로직 최적화를 실험해볼 것이다.
  4. 그 과정에서 (서버 테스트의 경우) 레이턴시도 측정할 수 있다면 좋겠고, (언리얼 클라이언트의 경우) 프레임 드랍을 측정해볼 수 있으면 좋겠다.
  5. 현재 단기적인 목표는 100개 세션이다. 장기적으로 심리스 방식 등의 최적화를 적용하여 서버-클라 모두 3000 세션을 감당하도록 할 것이다.

Specification

  1. Hyperion 게임 서버 구상에서 언급했듯이, 앞으로 전송하는 패킷 구조가 수정될 수도 있다. 따라서 서버가 수신한 패킷을 수집하고 저장하는 로직은 일반성이 있어야 한다.
  2. 또, 추후에 DB에 패킷을 저장하는 과정이 있을 수도 있는데, 그 부분에도 쓰일 수 있어야 한다.
  3. 클라이언트가 주기적으로 로그아웃/로그인을 반복한다.

how-to

우선 난 파일을 두개 만들거다.
많은 경우 한쪽에 메타데이터, 다른 한쪽에 찐 데이터, 이런 식으로 나눠 저장한다.
그 파일에 담을 내용을 다음 벡터에 저장한다.

vector<char*> m_Data;
vector<size_t> m_Meta;

여기에 (서버의) 프레임 마다 저장하고,
서버에서 End() 호출이 되면 바이너리 파일에 저장한다.

이 저장된 걸 다시 원래 벡터에 로드하고 전송하도록 더미 클라이언트를 만들면 된다.
일단 한명의 더미를 만들도록 해보자.
서버에서도 쓸 수 있으니 수신 패킷을 샘플링하는 클래스는 DLL로 노출시킨다.

#pragma once

#define SERVERHYPERION_EXPORT

#ifdef SERVERHYPERION_EXPORT
#define SERVERHYPERION_API __declspec(dllexport)
#else
#define SERVERHYPERION_API __declspec(dllimport)
#endif

#include <string>
#include <fstream>
#include <windows.h>
#include <vector>
#include "Packet.h"

using namespace std;

constexpr const char* SAMPLE_DATA_PATH = "C:/Unreal/Projects/ServerHyperion/ServerHyperion/DummyClient/SampledPacket.spd";
constexpr const char* SAMPLE_META_PATH = "C:/Unreal/Projects/ServerHyperion/ServerHyperion/DummyClient/SampledPacket.spm";

/// <summary>
/// it samples byte data from server while it is running 
/// sample byte data is saved and gonna be used with dummy client
/// </summary>
class PacketSampler
{
public:
	PacketSampler();
	~PacketSampler();

	bool ReadFile();
	int WriteToFile();

	void Sample(char* _pInChar, size_t _InSize);

private:
	bool ModSessIdx(char* _pInChar, size_t _InSize);

public:
	vector<char*>  m_Data;
	vector<size_t> m_Meta;

private:
	string m_DataFilePath{ SAMPLE_DATA_PATH };
	string m_MetaFilePath{ SAMPLE_META_PATH };


};

PacketSampler::PacketSampler()
{
}

PacketSampler::~PacketSampler()
{
	for (auto pData : m_Data)
		delete[] pData;

}

inline bool PacketSampler::ReadFile()
{
	////////////////////////////////////////////////////////////////////////////////
	// read meta data
	////////////////////////////////////////////////////////////////////////////////
	ifstream ifs(m_MetaFilePath, ios::binary);
	size_t count;
	ifs.read(reinterpret_cast<char*>(&count), sizeof(count));
	m_Meta.resize(count);
	ifs.read(reinterpret_cast<char*>(m_Meta.data()), count * sizeof(size_t));
	////////////////////////////////////////////////////////////////////////////////
	////////////////////////////////////////////////////////////////////////////////
	////////////////////////////////////////////////////////////////////////////////

	ifstream Is(m_DataFilePath, ifstream::binary);
	if (Is)
	{
		// seekg를 이용한 파일 크기 추출
		Is.seekg(0, Is.end);
		int Len = (int)Is.tellg();
		Is.seekg(0, Is.beg);

		// malloc으로 메모리 할당
		char* pData = (char*)malloc(Len);

		// read data as a block:
		Is.read((char*)pData, Len);
		Is.close();

		m_Data.reserve(m_Meta.size());
		// do something with pData
		size_t CurDataFileIdx = 0;
		for (size_t i = 0; i < m_Meta.size(); ++i)
		{
			char* pElem = new char[m_Meta[i]];
			CopyMemory(pElem, pData + CurDataFileIdx, m_Meta[i]);

			m_Data.push_back(pElem);

			CurDataFileIdx += m_Meta[i];
		}
	}

	return true;
}

inline int PacketSampler::WriteToFile()
{
	if (m_DataFilePath.empty() || m_MetaFilePath.empty()) return 1;
	if (m_Data.size() != m_Meta.size()) return 2;

	streamsize TotalLen = 0;
	for (const auto PackLen : m_Meta)
		TotalLen += PackLen;

	char* pData = new char[TotalLen];
	size_t CurDataFileIdx = 0;
	for (size_t i = 0; i < m_Meta.size(); ++i)
	{
		for (size_t j = 0; j < m_Meta[i]; ++j)
		{
			pData[CurDataFileIdx] = m_Data[i][j];
			CurDataFileIdx++;
		}
	}
	
	ofstream Fout;
	Fout.open(m_DataFilePath, ios::out | ios::binary | ios::trunc);

	if (Fout.is_open())
	{
		Fout.write((const char*)pData, TotalLen);
		Fout.close();
	}

	delete[] pData;

	////////////////////////////////////////////////////////////////////////////////
	// writh meta data
	////////////////////////////////////////////////////////////////////////////////
	ofstream ofs(m_MetaFilePath, ios::binary | ios::trunc); // ios::trunc : clear file when it is opened
	size_t count = m_Meta.size();
	ofs.write(reinterpret_cast<const char*>(&count), sizeof(count));
	ofs.write(reinterpret_cast<const char*>(m_Meta.data()), count * sizeof(size_t));
	////////////////////////////////////////////////////////////////////////////////
	////////////////////////////////////////////////////////////////////////////////
	////////////////////////////////////////////////////////////////////////////////

	return 0;
}

inline void PacketSampler::Sample(char* _pInChar, size_t _InSize)
{
	char* pSampleBuf = new char[_InSize];
	CopyMemory(pSampleBuf, _pInChar, _InSize);

	m_Data.push_back(pSampleBuf);
	m_Meta.push_back(_InSize);
}

inline bool PacketSampler::ModSessIdx(char* _pInChar, size_t _InSize)
{
	// do something


	return true;
}

그리고 저장된 패킷을 송수신하는 더미 클라이언트.

#pragma once

#include <WinSock2.h>
#include <Windows.h>
#include <MSWSock.h>
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
#include <WS2tcpip.h> 
#include "PacketSampler.h"

#pragma comment(lib, "ws2_32.lib")

constexpr const char* SERVER_IP   = "127.0.0.1";
constexpr int         SERVER_PORT = 11021;

class Headless
{
public:
    Headless() : m_hIOCP(NULL), m_hSocket(INVALID_SOCKET) {}
    ~Headless() { Cleanup(); }

    bool Init()
    {
        WSADATA wsaData;
        if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
        {
            cerr << "WSAStartup failed\n";
            return false;
        }
        return true;
    }

    bool Connect(const char* ip, int port)
    {
        m_hSocket = socket(AF_INET, SOCK_STREAM, 0);
        if (m_hSocket == INVALID_SOCKET)
        {
            cerr << "socket() failed\n";
            return false;
        }

        SOCKADDR_IN addr;
        addr.sin_family = AF_INET;
        addr.sin_port = htons(port);

        // ANSI 버전 사용 (ip는 const char* 이므로 변환 불필요)
        if (InetPtonA(AF_INET, ip, &addr.sin_addr) <= 0) {
            cerr << "InetPton failed for ip: " << ip << "\n";
            return false;
        }

        if (connect(m_hSocket, (SOCKADDR*)&addr, sizeof(addr)) == SOCKET_ERROR)
        {
            cerr << "connect() failed\n";
            return false;
        }

        // IOCP 생성
        m_hIOCP = CreateIoCompletionPort((HANDLE)m_hSocket, NULL, (ULONG_PTR)this, 0);
        if (!m_hIOCP)
        {
            cerr << "CreateIoCompletionPort failed\n";
            return false;
        }

        // 수신용 스레드 시작
        m_Worker = thread(&Headless::WorkerThread, this);

        return true;
    }

    void SendLoop(PacketSampler& sampler)
    {
        size_t idx = 0;
        while (true)
        {
            if (idx >= sampler.m_Data.size()) idx = 0;

            size_t len = sampler.m_Meta[idx];
            char* buf  = sampler.m_Data[idx];

            int ret = send(m_hSocket, buf, (int)len, 0);
            if (ret == SOCKET_ERROR)
            {
                cerr << "send failed: " << WSAGetLastError() << "\n";
                break;
            }

            // 30Hz → 33ms 간격
            this_thread::sleep_for(chrono::milliseconds(33));
            idx++;
        }
    }

    void WorkerThread()
    {
        char buf[4096];
        while (true)
        {
            int ret = recv(m_hSocket, buf, sizeof(buf), 0);
            if (ret > 0)
            {
                cout << "[Recv] size=" << ret << "\n";
            }
            else if (ret == 0)
            {
                cout << "Server closed connection\n";
                break;
            }
            else
            {
                cerr << "recv error: " << WSAGetLastError() << "\n";
                break;
            }
        }
    }

    void Cleanup()
    {
        if (m_hSocket != INVALID_SOCKET)
        {
            closesocket(m_hSocket);
            m_hSocket = INVALID_SOCKET;
        }
        if (m_Worker.joinable()) m_Worker.join();
        WSACleanup();
    }

private:
    HANDLE m_hIOCP;
    SOCKET m_hSocket;
    thread m_Worker;
};

여기까지 구현했을 때, 다음 사진처럼 내 조작 없이도 다른 한명의 클라이언트가 알아서 뛰어다니도록 테스트를 돌릴 수 있었다.
다른 한손으로는 카메라를 들고 있었다.

하지만 여기서 또 해결해야 하는 것.
1. 다량의 접속자를 만들어야 한다.
2. 그러기 위해선 각각의 접속자가 하나의 수집된 파일의 바이트에서 세션 인덱스를 바꿔가며 송신해야 한다.
3. 당연히, 각 접속자는 다른 포트를 써야 한다. 프로세스 하나에서 돌아가는 세션이 여러개라... 구현하는 방법을 찾아봐야 할 듯.
4. 더미 클라 내에서도 메모리 단편화는 없어야 한다. 서버가 터지는지 측정하려고 만든건데 테스트를 돌려주는 애가 터지면 곤란하다.
5. 캐릭터끼리 부딪히지 않도록 초기 스폰 위치를 바둑판 식으로 배치해야 한다.
6. 정말 추후에는 PacketSampler도 메모리 최적화를 해야 한다. 지금은 한번만 수집하고 로드하기에 동적할당을 신나게 썼다.

2, 5를 위해 서버에서 샘플링한 패킷을 수정해야 한다.

2를 위해서는 처음 서버에서 받는 패킷, 즉 클라에게 서버가 부여한 세션 인덱스를 담은 패킷을 해석하고,
해당 클라가 그 받은 패킷 이후로 송신하는 패킷들에 그 부여된 세션 인덱스를 반영해야 한다.
그러기 위해 각 클라는 condition_variable을 멤버로 가져 블락을 걸고 자신에게 인덱스가 부여되기까지 블락을 거는 방법을 적용했다.
아래는 더미 클라이언트.

#include <WinSock2.h>
#include <Windows.h>
#include <MSWSock.h>
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
#include <WS2tcpip.h> 
#include <condition_variable>

#include "PacketSampler.h"
#include "Packet.h"
#include "Define.h"

#pragma comment(lib, "ws2_32.lib")

constexpr const char* SERVER_IP   = "127.0.0.1";
constexpr int         SERVER_PORT = 11021;

class Headless
{
public:
    Headless() : m_hIOCP(NULL), m_hSocket(INVALID_SOCKET) {}
    ~Headless() { Cleanup(); }

    bool Connect(const char* ip, int port) 
    {
        m_hSocket = socket(AF_INET, SOCK_STREAM, 0);
        if (m_hSocket == INVALID_SOCKET) return false;

        SOCKADDR_IN addr{};
        addr.sin_family = AF_INET;
        addr.sin_port = htons(port);
        if (InetPtonA(AF_INET, ip, &addr.sin_addr) <= 0) return false;

        if (connect(m_hSocket, (SOCKADDR*)&addr, sizeof(addr)) == SOCKET_ERROR) return false;

        m_Worker = thread(&Headless::WorkerThread, this);
        return true;
    }

    void WaitIfNotInited()
    {
        unique_lock<mutex> Lock(m_ConVarLock);
        m_ConVar.wait(Lock, [this]
            {
                return m_SessIdx != 0xFFFFFFFF; // if false, it sleeps
            });
    }

    void Send(const char* data, size_t len) 
    {
        int ret = send(m_hSocket, data, (int)len, 0);
        if (ret == SOCKET_ERROR) 
        {
            cerr << "send() failed\n";
        }
    }

    void WorkerThread() 
    {
        char buf[1024];
        Packet Pack;

        while (true) 
        {
            int recvLen = recv(m_hSocket, buf, sizeof(buf), 0);
            Pack.Read(buf, recvLen);

            if (MsgType::MSG_INIT == Pack.GetMsgType())
            {
                if (0xFFFFFFFF == m_SessIdx)
                {
                    m_SessIdx = Pack.GetSessionIdx();

                    unique_lock<mutex> Lock(m_ConVarLock);
                    m_ConVar.notify_all();
                }
            }

            if (recvLen <= 0) 
            {
                break; // 연결 끊김
            }
            // 서버 응답 처리 (원하면 로깅 가능)

            this_thread::sleep_for(chrono::milliseconds(1));
        }
    }

    void Cleanup()
    {
        if (m_hSocket != INVALID_SOCKET)
        {
            closesocket(m_hSocket);
            m_hSocket = INVALID_SOCKET;
        }
        if (m_Worker.joinable()) 
            m_Worker.join();
    }

    inline UINT32 GetSessIdx() { return m_SessIdx; }

private:
    condition_variable  m_ConVar;
    mutex               m_ConVarLock;

    UINT32              m_SessIdx{ 0xFFFFFFFF };

    HANDLE              m_hIOCP;
    SOCKET              m_hSocket;
    thread              m_Worker;
};

그리고 이 클라이언트의 메시지 송신을 명령해주는 매니저.

#include "Headless.h"
#include "Packet.h"
#include "PacketSampler.h"

class HeadlessManager 
{
public:
    HeadlessManager(const char* _InIp, int _InServerPort, PacketSampler* _InSampler)
        : m_Ip          (_InIp)
        , m_ServerPort  (_InServerPort)
        , m_Sampler     (_InSampler)
    {
    }

    void Run(int numClients);

private:
    const char* m_Ip;
    int m_ServerPort;
    PacketSampler* m_Sampler;
    vector<unique_ptr<Headless>> m_Clients;
};

void HeadlessManager::Run(int NumClients) 
{
    for (int i = 0; i < NumClients; ++i) 
    {
        auto client = make_unique<Headless>();
        if (client->Connect(m_Ip, m_ServerPort)) 
        {
            m_Clients.push_back(move(client));
        }
    }

    cout << "Connected " << m_Clients.size() << " clients.\n";
    if (m_Clients.size() < NumClients)
    {
        printf("Connection Errors in some Clients...");
        return;
    }

    Packet PackBuf;
    char* CharBuf = nullptr;

    // 30fps 루프
    while (true) 
    {
        for (size_t i = 0; i < m_Sampler->m_Data.size(); ++i)
        {
            for (size_t j = 0; j < m_Clients.size(); ++j)
            {
                m_Clients[j]->WaitIfNotInited();

                PackBuf.Read(m_Sampler->m_Data[i], m_Sampler->m_Meta[i]);

                PackBuf.SetSessionIdx(m_Clients[j]->GetSessIdx());

                // if it has x, y, z data in its body, 
                //  u must re-assign its val
                if (PackBuf.GetHeader()[2])
                {
                    PackBuf.SetPosX(PackBuf.GetPosX() + (70.0 * (int)(j / 10)));
                    PackBuf.SetPosY(PackBuf.GetPosY() + (70.0 * (int)(j % 10)));
                }

                size_t PackLen = PackBuf.Write(CharBuf);
                //printf("Session Index : %d\n", m_Clients[j]->GetSessIdx());
                m_Clients[j]->Send(CharBuf, PackLen);
            }
            
            this_thread::sleep_for(chrono::milliseconds(33)); // 약 30회/초
        }
    }
}

이렇게 하여 여러명의 접속자를 시뮬레이션해볼 수 있었다.

profile
C++ Game Developer

0개의 댓글