언리얼 클라이언트 실행파일로 4개의 동접자까진 테스트를 완료했다.
두시간 가량 틀어뒀는데 메모리가 튀지도 않고 CPU 부하도 일정하게 유지됐다.
메모리 덤프도 떠 놨으니 나중에 궁금할 때 찾아보라.
feat/obop
브랜치에서 커밋 로그는 dumped atFriday, September 5, 2025, 11:05:53 PM
다만 다량의 동시 접속자를 실험해볼 순 없다.
이 방법으로는 GPU 렌더가 필요하기에 현재 내 컴퓨터의 사양으로는 다섯명이 한계다.
동접자 스트레스 테스트가 가능하도록 더미 클라이언트를 만들 것이다.
GPU 렌더가 없는 헤드리스이고, 사용자가 실제 보낸 메시지를 수집해서 서버에 전송하게 만들어볼 것이다.
우선 난 파일을 두개 만들거다.
많은 경우 한쪽에 메타데이터, 다른 한쪽에 찐 데이터, 이런 식으로 나눠 저장한다.
그 파일에 담을 내용을 다음 벡터에 저장한다.
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회/초
}
}
}
이렇게 하여 여러명의 접속자를 시뮬레이션해볼 수 있었다.