기왕이면 강의랑 똑같이 진행되길 바래서 게임서버(1)에서 만든 서버가 아닌 이 강의에서 제공하는 소스를 다운받아 사용했다.
06. 언리얼 엔진과 Protobuf 연동
게임서버에서 더미클라이언트는 많은 동접자를 테스트하려고 ServerCore를 사용했다.
언리얼 엔진은 좀 얘기가 다르다. 방법이 여러가지가 있는데 가장 최소한의 노력의 간단한 방법은 iocp서버 코어를 언리얼엔진에 이식하는 것이다. 이때는 장단점이 있다. iocp가 윈도우 환경에서만 되는게 아쉽고, 클라 하나에 붙이기엔 좀 과하다는 점이다. 다른방법으로는 윈도우 버전에는 winsock을 쓰고 다른버전일때는 select등을 쓰는 방법.
강의에서는 언리얼 엔진에서 제공하는 FScoket 라이브러리를 사용할 것이다. 당연히 윈도우에서만 돌아가는건 아닐 것.
언리얼에서 새 프로젝트를기본, c++로 만들자. 이름은 S1 위치는 기존 서버폴더가 있는 폴더에.


콘텐츠 - Maps 폴더 만들기 - 컨트롤 N 으로 빈레벨 만들고 Maps에 저장하기.

S1/Source에 ProtobufCore 폴더 만들고 그 안에 Include, Lib폴더 만들어준다. ProtobufCore.Build.cs 파일도 만들어준다. 적당히 S1빌드cs 복붙해서 이름만 바꿔놓자.
Include에는 서버에있던 구글 프로토버프를 그대로 복붙해서 쓰도록 하자.
(강의에서는 정확히 어느 경로에서 가져오는지 명확치 않음)


그리고 lib파일도 아래와 같이 복사해준다.(강의에서는 정확히 어느 경로에서 가져오는지 명확치 않음)


ProtobufCore.Build.cs 는 아래와 같이 내용을 바꿔준다. C# 문법이라는 것 같다.
vs에서 프로젝트 우클릭 속성 들어가서 막 만져줬던 그런게 언리얼에서는 이렇게 한다는 듯 하다.
using System.IO;
using UnrealBuildTool;
public class ProtobufCore : ModuleRules
{
public ProtobufCore(ReadOnlyTargetRules Target) : base(Target)
{
Type = ModuleType.External;
PublicSystemIncludePaths.Add(Path.Combine(ModuleDirectory, "Include"));
PublicIncludePaths.Add(Path.Combine(ModuleDirectory, "Include"));
PublicAdditionalLibraries.Add(Path.Combine(ModuleDirectory, "Lib", "Win64", "libprotobuf.lib"));
PublicDefinitions.Add("GOOGLE_PROTOBUF_NO_RTTI=1");
}
}
그리고 uproject 우클릭해서 generate 해준다. (뭐하는건지는 모름)


그리고 S1.build.cs에서 아래와 같이 ProtobufCore모듈을 추가해주면 에디터에서 연결된걸 확인할 수 있다.
이걸 안해주면 회색으로 제외됨 이라고 뜬다
...
PrivateDependencyModuleNames.AddRange(new string[] { "ProtobufCore" });
...

서버에서 enum, protocol, struct 의 proto 파일을 GenPackets.exe에 집어넣어서 Enum.pb.h , Enum.pb.cc같은 파일을 생성했었는데, 일단은 이 파일들을 그대로 언리얼 디렉토리로 복사하자. Network 폴더를 만들어서 방금 옮긴것중에 clientpackethandler 빼고 다 넣어놓자.


이 상태로 빌드해보면 빌드가 실패할 것이다. 아래 코드처럼 추가해준다.
using UnrealBuildTool;
public class S1 : ModuleRules
{
public S1(ReadOnlyTargetRules Target) : base(Target)
{
/*...*/
PrivateIncludePaths.AddRange(new string[]
{
"S1/",
"S1/Network/",
});
}
}
아직도 빌드가 실패하는데 일단 프로토버프 라이브러리 컴파일에 잘 적용되는지 확인만 하는거니까 일단 clientpackethandler c, h는 모두 주석처리하고 빌드해보자.
- pch.h는 서버쪽에서만 사용하던거니까 헤더를 지워준다.
- BufferReader.h 를 사용하는데 이것도 서버쪽에서 가져오자.

Protocol.pb.cc(317, 76): [C4800] 'uint64_t'에서 부울로 암시적 변환입니다. 정보가 손실될 수 있습니다.
조금 알아보니 warning 수준의 에러를 컴파일러 옵션으로 인해 (아마 -Warning) 발생하는 에러인 듯 하다.
발생한 파일은 protobuf 자동화로 재생성되는 파일이기 때문에 이 파일 내에서 수정으로 해결하는건 의미가 없고 파일에 보면 아래와 같은 include가 있는데 해당 파일 최하단에
#include <google/protobuf/port_def.inc>
#ifdef _MSC_VER
#pragma warning(disable: 4800)
#endif
를 추가해 해결했다. (다른 에러가 발생하면 에러코드를 좀 바꾸면 될 듯 하다)

빌드를 해보면 잘 된다!
이제 아까 주석처리하고 대충 넘어간것들이 제대로 동작할 수 있도록 해야한다.
07. PacketSession
네트워크 코드를 클라에 옮겨보자.
그런데 그냥 클라에서 네트워크 코드를 따로 만들도록 하자. 대부분 회사에서도 이렇게 한다고 한다.
먼저 S1.build.cs에서 Sockets와 Networking을 추가하자.
public class S1 : ModuleRules
{
public S1(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{ "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "Sockets", "Networking" });
PrivateDependencyModuleNames.AddRange(new string[] { "ProtobufCore" });
PrivateIncludePaths.AddRange(new string[]
{
"S1/",
"S1/Network/",
});
}
}
만약 FSocket 라이브러리가 블로킹 방식으로만 지원한다 라고하면 게임이 네트워크 패킷을 다 보낼때까지 멈춰서 계속 프리징 현상이 발생하니 쓰레드를 써야할 것이다. 쓰레드에 send와 recv를 담당시키는 것.
네트워크는 어느 타이밍에 서버에 연결하는게 좋을까?
보통은 로그인 할 때부터 연결한다고 한다. 언리얼을 키자마자 연결하는게 아니라 함수를 만들어서 해야할 것.
그런걸 담당하는 매니저를 만들어서 하면 좋은데 언리얼에서 사용할 수 있는 매니저 클래스를 만들어줬다.
에디터에서 GameInstance 클래스로 새 c++ 클래스를 만들어보자.
GameInstance는 게임 실행동안 한번만 생성되고 맵이 바뀌어도 살아있는 전역 객체이다.
GameMode, GameState, PlayerController는 맵이 바뀌면 새로 만들어지지만 GameInstance는 유지된다.
우리는 GameInstance를 네트워크 연결 관리자로 사용한다

이 클래스에서 게임 서버에 접속해 연결하는 것, 디스커넥트 하는것, 이런것들을 할 예정이다.
저번에 주석처리로 넘어갔던 clientpackethandler에서 packetsession이 있었는데 session이 무엇인가?
클라에서 session이 있다면 그건 서버측의 대사관이고
서버측에서는 클라의 대사관이 session이다.
지금은 클라를 만들고있는데 클라에는 마주할 서버가 하나 뿐이니 굳이 세션을 만들 필요가 있나 싶다.
그런데 클라에서 다중으로 접속할 필요가 있을 때가 있다. 예를 들어 로비 서버와 인게임 서버가 분리되어있다던지.
그런 경우를 생각하면 session을 두는게 좋다고 한다.
강의에서는 세션은 연결을 한 뒤에 만드는거니까 서버의 주소, 포트번호는 따로 빼놓지 않는다.
일단 서버에 연결하기위한 작업을 해보자. 언리얼의 fsocket 라이브러리를 사용하는데 대충 비슷하다.
UCLASS()
class S1_API US1GameInstance : public UGameInstance
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable)
void ConnectToGameServer();
UFUNCTION(BlueprintCallable)
void DisconnectFromGameServer();
public:
class FSocket* Socket;
FString IpAddress = TEXT("127.0.0.1");
int16 Port = 7777;
};
#include "Sockets.h"
#include "Common/TcpSocketBuilder.h"
#include "Serialization/ArrayWriter.h"
#include "SocketSubsystem.h"
void US1GameInstance::ConnectToGameServer()
{
Socket = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateSocket(TEXT("Stream"),TEXT("Client Socket"));
FIPv4Address Ip;
FIPv4Address::Parse(IpAddress, Ip);
TSharedRef<FInternetAddr> InternetAddr = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
InternetAddr->SetIp(Ip.Value);
InternetAddr->SetPort(Port);
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("Connecting To Server"));
bool Connected = Socket->Connect(*InternetAddr);
if (Connected)
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("Connection Successed"));
// Session
}
else
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("Connection Failed"));
}
}
void US1GameInstance::DisconnectFromGameServer()
{
if (Socket)
{
ISocketSubsystem* SocketSubsystem = ISocketSubsystem::Get();
SocketSubsystem->DestroySocket(Socket);
Socket = nullptr;
}
}
FSocket은 블로킹 방식이다. 그래서 해당 함수가 불러와질 때 렉걸리는 것 처럼 되는데 이렇게 단발적인거는 봐줄만 하니 그냥 둔다고 한다.
우선 이 상태로도 접속이 되긴 하는지 확인할 수 있으니 해보자.
- 언리얼 에디터의 프로젝트 세팅 - 맵/모드 에서 게임 인스턴스에 우리가 만든 클래스를 넣어준다.

- 게임모드 블루프린트에서 아래와 같이 설정해 connect to game server 호출


- 서버측 게임세션 onconnect에서도 확인



이번 강의 목표는 주석처리 해놨던 clientpackethandler를 다시 쓸 수 있게 만드는것.
먼저 PacketSessionRef 이라는게 없어서 생기는 오류부터 해결해보자.
서버측에서는 PacketSession이 클라이언트 담당이었지만, 지금 만드는건 클라이언트이니 서버를 PacketSession으로 하면 될 것 같다. 그러니 당장 Session이라는 개념이 필요하다.
언리얼에서 새 클래스 PacketSession을 만들자. 아무것도 상속받지 않는다.
이 세션에서 send recv 로 데이터를 주고받아야 할텐데 나중에 보면 알겠지만 FSocket에서 send recv 함수가 블로킹으로 만들어져있다. 그래서 어쩔 수 없이 쓰레드를 만들어야 한다. 그것도 미리 만들자. 아무것도 상속받지 않는 NetworkWorker 클래스를 추가한다.
PacketSession 클래스는 만들었는데, PacketSessionRef가 없어서 오류가 생기는거였다. Ref는 쉐어드포인터였는데 언리얼에서 쉐어드포인터를 서버에서처럼 사용할 수 있을까? 전혀 상관이 없다. 다만 c++표준의 스마트 포인터가 아닌 언리얼에서 제공하는걸 사용해야 한다. shared_ptr 대신 Shared_Ptr 을 사용한다.
그리고 shared_ptr로 만든 객체에서 자기 자신을 보낼 때 shared_from_this 를 사용했고, 이를 위해 public enable_shared_from_this<> 를 상속시켰는데, 언리얼에서도 TSharedFromThis<> 를 상속시키고, 사용하면 된다.
- 지금까지의 클라측 PacketSession 코드
class S1_API PacketSession : public TSharedFromThis<PacketSession>
{
public:
PacketSession(class FSocket* Socket);
~PacketSession();
void Run();
void Disconnect();
public:
class FSocket* Socket;
};
PacketSession::PacketSession(class FSocket* Socket) : Socket(Socket)
{
}
PacketSession::~PacketSession()
{
Disconnect();
}
void PacketSession::Run()
{
}
void PacketSession::Disconnect()
{
}
패킷 세션에 나중에 Recv Send 함수를 만든다고 하면 어쨌든 FSocket의 멤버함수 Send와 Recv를 사용하게 될텐데 문제는 그것들이 blocking 방식이다. 그래서 Socket->Recv() 를 패킷세션에서 그냥 호출하는건 안되고 네트워크 통신들을 메인(게임) 쓰레드에서 하지 않고 새 쓰레드를 만들어서 메인쓰레드는 Job으로 등록만 하고, 네트워크 쓰레드는 그걸 소모하는 방식으로 작업하게 될 것 이다. 그래서 Recv, Send는 PacketSession에서 만들지 않고 NetworkWorker 쪽에 만들어줄 예정이다.
08. 쓰레드
이제 NetworkWorker 를 작업해보자. h파일에 FRunnable을 상속받는 RecvWorker와 SendWorker 클래스를 각각 만든다.
FRunnable의 virtual 함수 몇가지를 구현해야 한다. Init, Run Exit.
class S1_API RecvWorker : public FRunnable
{
public:
RecvWorker();
~RecvWorker();
virtual bool Init() override;
virtual uint32 Run() override;
virtual void Exit() override;
protected:
FRunnableThread* Thread = nullptr;
};
RecvWorker::RecvWorker()
{
Thread = FRunnableThread::Create(this, TEXT("RecvWorkerThread"));
}
bool RecvWorker::Init()
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Green, TEXT("Recv Thread Init"));
return true;
}
uint32 RecvWorker::Run()
{
while (Running)
{
}
return 0;
}
c++에서 std::thread t(ThreadMain); 이렇게 쓰레드를 생성하면서 콜백함수를 실행하던거를 언리얼 FRunnableThrad 객체를 써서 해준다. bool Running은 언리얼이 꺼지면 신호를 줘서 Running을 false로 만들어 무한반복을 종료하기 위한 용도.
언리얼에서 쓰레드는 FRunnableThread::Create() 가 호출되면 새 쓰레드에서 아래 순서로 자동 호출된다.
- FRunnable::Init()
- FRunnable::Run() -> 실제 쓰레드 작업 시작
- FRunnable::Exit() -> 쓰레드 종료 시
RecvWorker는 패킷을 받아서 조립하는 역할이니 그걸 해주는 함수들도 만들어주자.
class S1_API RecvWorker : public FRunnable
{
public:
/*...*/
RecvWorker(FSocket* Socket);
private:
bool ReceivePacket(TArray<uint8>& OutPayload);
bool ReceiveDesireBytes(uint8* Results, int32 Size);
protected:
/*...*/
FSocket* Socket;
};
bool RecvWorker::ReceiveDesireBytes(uint8* Results, int32 Size)
{
uint32 PendingDataSize;
//연결 종료되면 packetrecvsize 0으로 옴 -> 연결 끊겼으면 종료.
if (Socket->HasPendingData(PendingDataSize) == false || PendingDataSize <= 0)
return false;
int32 Offset = 0;
while (Size > 0)
{
int32 NumRead = 0;
Socket->Recv(Results + Offset, Size, OUT NumRead);
check(NumRead <= Size);//읽은게 size보다 크면 크래시
if (NumRead <= 0)
return false;
Offset += NumRead;
Size -= NumRead;
}
return true;
}
RecvDesireBytes 함수를 만든다. 소켓에 들어온걸 Size만큼만 읽어서 Results에 넣어주는 함수다.
HasPendingData로 소켓에 데이터가 있는지 확인한 후에 recv를 계속 해서 size만큼 들어올때까지 돌리는 것.
recv가 blocking 함수다. (setnonblocking으로 설정할 수 있다고는 한다.)
그런데 TCP 특성 상 항상 Size 이상 만큼 들어오지는 않을테니 저번에 했던 것 처럼 패킷의 Header를 사용해야한다.
헤더 struct를 만들자.
struct S1_API FPacketHeader
{
FPacketHeader() : PacketSize(0), PacketID(0)
{
}
FPacketHeader(uint16 PacketSize, uint16 PacketID) : PacketSize(PacketSize), PacketID(PacketID)
friend FArchive& operator<<(FArchive& Ar, FPacketHeader& Header)
{
Ar << Header.PacketSize;
Ar << Header.PacketID;
return Ar;
}
uint16 PacketSize;
uint16 PacketID;
};
friend ~~ 는 나중에 알아본다고 한다.
헤더에는 Size와 ID 정보가 담겨있는데 Size가 헤더를 포함한 사이즈인지, 데이터의 사이즈만 말하는건지는 우리가 정하는 것.
이제 ReceivePacket을 만든다.
bool RecvWorker::ReceivePacket(TArray<uint8>& OutPacket)
{
// 패킷 헤더 파싱
const int32 HeaderSize = sizeof(FPacketHeader);
TArray<uint8> HeaderBuffer;
HeaderBuffer.AddZeroed(HeaderSize);
if (ReceiveDesireBytes(HeaderBuffer.GetData(), HeaderSize) == false)
return false;
//Id, Size 추출
FPacketHeader Header;
{
FMemoryReader Reader(HeaderBuffer);
Reader << Header;
UE_LOG(LogTemp, Log, TEXT("Recv PacketId : %d, PacketSize : %d"), Header.PacketID, Header.PacketSize);
}
//패킷 헤더 복사
OutPacket = HeaderBuffer;
//패킷 내용 파싱
TArray<uint8> PayloadBuffer;
const uint32 PayloadSize = Header.PacketSize - HeaderSize;
OutPacket.AddZeroed(PayloadSize);
if (ReceiveDesireBytes(&OutPacket[HeaderSize], PayloadSize))
return true;
return false;
}
- 헤더만큼만 먼저 읽어서 헤더버퍼에 채운다.
- FMemoryReader를 사용해 HeaderBuffer에 적힌걸 역직렬화 하여 Header 구조체에 넣어준다.
- Header에 적힌 Size - 헤더크기 만큼 더 읽을게 있으니까 그만큼을 다 읽어서 PayloadBuffer에 채워준다.
이제 Run에서는 receivepacket을 불러와서 하나의 완성된 패킷을 받을 수 있을것이다. (헤더, 직렬데이터)
그 다음에 프로토버프의 객체로 변환해야할것인데, 그건 clientpackethandler의 handlepacket으로 할 수 있다.
근데 그렇게 하기엔 이르다. 왜냐면 run에서 그걸 바로 처리해서 컨텐츠 코드를 처리하려 한다면 크래시가 난다.
언리얼에서는 별도의 쓰레드를 만든 다음에 거기서 메인쓰레드 접근하려 하면 크래시를 내버리기 때문.
조립만 해놓고 어디다 넣어놓으면 메인쓰레드에서 꺼내서 쓰는 식으로 만들어야 한다.
큐를 만들어서 넣어놓도록 하자.
세션마다(연결된 서버마다) Recv, Send 큐가 따로 있을테니까 PacketSession에 큐를 만들자.
class S1_API PacketSession : public TSharedFromThis<PacketSession>
{
public:
PacketSession(class FSocket* Socket);
~PacketSession();
void Run();
void Disconnect();
public:
class FSocket* Socket;
TSharedPtr<class RecvWorker> RecvWorkerThread;
TSharedPtr<class SendWorker> SendWorkerThread;
TQueue<TArray<uint8>> RecvPacketQueue;
};
RecvRorker에서 이 큐를 알고있어야 하기 때문에 RecvWorker생성자에서 큐를 받아줘야 한다.
단 조심해야 할 점은 일반 포인터나 레퍼런스를 받아주면 안된다. 만약 처리도중에 연결이 끊겨 Session이 없어지면 잘못된 포인터에 접근하게 되기 때문. 그러니 sharedptr을 받아서 RecvWorker에 weakptr로 저장하도록 수정하자.
weakptr로 저장하는 이유는 순환문제때문
그리고 run에서 queue에 등록하도록 하면 끝.
class S1_API RecvWorker : public FRunnable
{
public:
RecvWorker(FSocket* Socket, TSharedPtr<PacketSession> Session);
~RecvWorker();
virtual bool Init() override;
virtual uint32 Run() override;
virtual void Exit() override;
private:
bool ReceivePacket(TArray<uint8>& OutPacket);
bool ReceiveDesireBytes(uint8* Results, int32 Size);
protected:
FRunnableThread* Thread = nullptr;
bool Running = true;
FSocket* Socket;
TWeakPtr<PacketSession> SessionRef;
};
RecvWorker::RecvWorker(FSocket* Socket, TSharedPtr<PacketSession> Session) : Socket(Socket), SessionRef(Session)
{
Thread = FRunnableThread::Create(this, TEXT("RecvWorkerThread"));
}
uint32 RecvWorker::Run()
{
while (Running)
{
TArray<uint8> Packet;
if (ReceivePacket(OUT Packet))
{
if (TSharedPtr<PacketSession> Session = SessionRef.Pin())
{
Session->RecvPacketQueue.Enqueue(Packet);
}
}
}
return 0;
}
RecvWorker의 생성자가 바뀌었으니 생성자를 사용하는 부분도 변경한다.
AsShared는 언리얼에서 shared_from_this()를 사용하는 것과 같다.
void PacketSession::Run()
{
RecvWorkerThread = MakeShared<RecvWorker>(Socket, AsShared());
}
09. RecvWorker, SendWorker
쓰레드 개념을 알면 언리얼이나 c++나 별 다를게 없으니 대칭적으로 생각하면 편하다.
RecvPacketQueue에 넣은거는 누가 처리해줄까? 메인쓰레드에서 처리해준다.
메인쓰레드의 PacketSession에서 HandlePackets 함수를 만들어서 큐에 쌓인 일감을 하나씩 빼서 처리한다.
recv는 이제 역직렬화 해서 일처리하는것만 하면 끝이다.
class S1_API PacketSession : public TSharedFromThis<PacketSession>
{
public:
/*...*/
UFUNCTION(blueprintCallable)
void HandleRecvPackers();
/*...*/
};
void PacketSession::HandleRecvPackets()
{
while (true)
{
TArray<uint8> Packet;
if (RecvPacketQueue.Dequeue(OUT Packet) == false)
break;
// TODO
//Clientpackethandler::HandlePacket(Packet);
}
}
그리고 Packet처리도 네트워크 관리자인 GameInstance에 추가한다.
class S1_API US1GameInstance : public UGameInstance
{
public:
/*...*/
UFUNCTION(BlueprintCallable)
void HandleRecvPackets();
public:
/*...*/
TSharedPtr<PacketSession> GameServerSession;
};
void US1GameInstance::ConnectToGameServer()
{
/* 서버와 소켓 연결 */
if (Connected)
{// 연결 성공하면 GameServerSession 만들고, 쓰레드 파서 패킷 처리 시작
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("Connection Successed"));
// Session
GameServerSession = MakeShared<PacketSession>(Socket);
GameServerSession->Run();
}
else
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("Connection Failed"));
}
}
void US1GameInstance::HandleRecvPackets()
{
if (Socket == nullptr || GameServerSession == nullptr)
return;
GameServerSession->HandleRecvPackets();
}
그럼 Send는 어떻게 할까?
마찬가지로 GameInstance에서 담당하게 될것.
근데 지금까지 만든 라이브러리 특성 상 Sendbuffer라는걸 이용했다. (서버쪽에서 가져온)
클라랑 서버랑 비슷하게 만들어야 관리하기 편함.
일단은 S1.h에 작업한다. clientpackethandler를 되살릴 때 문제되는 부분을 S1.h에 다 넣어보자.
struct PacketHeader
{
uint16 size;
uint16 id;
};
class Sendbuffer : public TSharedFromThis<Sendbuffer>
{
public:
Sendbuffer(int32 bufferSize);
~Sendbuffer();
BYTE* Buffer() { return _buffer.GetData(); }
int32 WriteSize() { return _writeSize; }
int32 Capacity() { return static_cast<int32>(_buffer.Num()); }
void CopyData(void* data, int32 len);
void Close(uint32 writeSize);
private:
TArray<BYTE> _buffer;
int32 _writeSize;
};
#define USING_SHARED_PTR(name) using name##Ref = TSharedPtr<name>;
class Session;
class PacketSession;
USING_SHARED_PTR(Session);
USING_SHARED_PTR(PacketSession);
USING_SHARED_PTR(SendBuffer);
Sendbuffer::Sendbuffer(int32 bufferSize)
{
_buffer.SetNum(bufferSize);
}
Sendbuffer::~Sendbuffer()
{
}
void Sendbuffer::CopyData(void* data, int32 len)
{
::memcpy(_buffer.GetData(), data, len);
_writeSize = len;
}
void Sendbuffer::Close(uint32 writeSize)
{
_writeSize = writeSize;
}
/* 서버쪽의 SendBuffer class
class SendBuffer : enable_shared_from_this<SendBuffer>
{
public:
SendBuffer(int32 bufferSize);
~SendBuffer();
BYTE* Buffer() { return _buffer.data(); }
int32 WriteSize() { return _writeSize; }
int32 Capacity() { return static_cast<int32>(_buffer.size()); }
void CopyData(void* data, int32 len);
void Close(uint32 writeSize);
private:
vector<BYTE> _buffer;
int32 _writeSize = 0;
};
*/
서버쪽이랑 비교하면 다 똑같은데 vector만 TArray로 바꿔줬다.
SendBuffer를 만든 이유는 ClientPacketHandler에서 MakeSendBuffer 함수로 SendBuffer를 만드는 부분이 있기 때문.
이제 SendPacket을 위한 함수들을 만들어주자.
- 네트워크 관리자인 GameInstance
class PacketSession;
UCLASS()
class S1_API US1GameInstance : public UGameInstance
{
public:
/*...*/
void SendPacket(SendBufferRef SendBuffer);
/*...*/
};
void US1GameInstance::SendPacket(SendBufferRef SendBuffer)
{
if (Socket == nullptr || GameServerSession == nullptr)
return;
GameServerSession->SendPacket(SendBuffer);
}
- 세션
recv에서는 워커쓰레드에서 recv버퍼를 담아서 큐에 넣어주고, 메인쓰레드는 꺼내서 역직렬화 및 처리를 담당했다.
send는 메인쓰레드에서 직렬화한 SendBuffer를 큐에 넣어주고, 워커쓰레드는 꺼내서 sned 작업을 담당한다.
class S1_API PacketSession : public TSharedFromThis<PacketSession>
{
public:
/*...*/
void SendPacket(SendBufferRef SendBuffer);
/*...*/
public:
class FSocket* Socket;
TSharedPtr<class RecvWorker> RecvWorkerThread;
TSharedPtr<class SendWorker> SendWorkerThread;
//GameThread와 NetworkThread가 데이터 주고 받는 공동 큐
TQueue<TArray<uint8>> RecvPacketQueue;
TQueue<SendBufferRef> SendPacketQueue;
};
void PacketSession::SendPacket(SendBufferRef SendBuffer)
{
SendPacketQueue.Enqueue(SendBuffer);
}
void PacketSession::Disconnect()
{
if (RecvWorkerThread)
{
RecvWorkerThread->Destroy();
RecvWorkerThread = nullptr;
}
if (SendWorkerThread)
{
SendWorkerThread->Destroy();
SendWorkerThread = nullptr;
}
}
void PacketSession::Run()
{
RecvWorkerThread = MakeShared<RecvWorker>(Socket, AsShared());
SendWorkerThread = MakeShared<SendWorker>(Socket, AsShared());//추가됨
}
- SendWorker
RecvWorker와 거의 똑같다.
class S1_API SendWorker : public FRunnable
{
public:
SendWorker(FSocket* Socket, TSharedPtr<PacketSession> Session);
~SendWorker();
virtual bool Init() override;
virtual uint32 Run() override;
virtual void Exit() override;
bool SendPacket(SendBufferRef SendBuffer);
void Destroy();
private:
bool SendDesiredBytes(const uint8* Buffer, int32 Size);
protected:
FRunnableThread* Thread = nullptr;
bool Running = true;
FSocket* Socket;
TWeakPtr<PacketSession> SessionRef;
};
SendWorker::SendWorker(FSocket* Socket, TSharedPtr<PacketSession> Session) : Socket(Socket), SessionRef(Session)
{
Thread = FRunnableThread::Create(this, TEXT("SendWorkerThread"));
}
SendWorker::~SendWorker()
{
}
bool SendWorker::Init()
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, FString::Printf(TEXT("Send Thread Init")));
return true;
}
uint32 SendWorker::Run()
{
while (Running)
{
SendBufferRef SendBuffer;
if (TSharedPtr<PacketSession> Session = SessionRef.Pin())
{
if (Session->SendPacketQueue.Dequeue(OUT SendBuffer))
{
SendPacket(SendBuffer);
}
}
// Sleep?
}
return 0;
}
void SendWorker::Exit()
{
}
bool SendWorker::SendPacket(SendBufferRef SendBuffer)
{
if (SendDesiredBytes(SendBuffer->Buffer(), SendBuffer->WriteSize()) == false)
return false;
return true;
}
void SendWorker::Destroy()
{
Running = false;
}
bool SendWorker::SendDesiredBytes(const uint8* Buffer, int32 Size)
{
while (Size > 0)
{
int32 BytesSent = 0;
if (Socket->Send(Buffer, Size, BytesSent) == false)
return false;
Size -= BytesSent;
Buffer += BytesSent;
}
return true;
}
이제 서버를 키고 접속과 recv send 쓰레드가 잘 작동하는 확인해보자.
서버측 메인쓰레드에서 아래와 같은 코드를 만들어 1초마다 연결된 클라이언트들에 브로드캐스트 하도록 한다.
while (true)
{
Protocol::S_CHAT pkt;
pkt.set_msg("HelloWorld!");
auto sendBuffer = ServerPacketHandler::MakeSendBuffer(pkt);
GSessionManager.Broadcast(sendBuffer);
this_thread::sleep_for(1s);
}


서버와의 연결도 잘 되고
1초마다 메시지도 잘 온다. 버퍼를 받아서
이제 clientpackethandler의 주석을 해제해보자.
보면 PacketSessionRef를 사용하기 위해 S1.h 를 include 해야한다.
그런데 clientpackethandler는 자동화로 생성하는 코드이기 때문에 이렇게 직접 넣어주는게 좀 아쉽다.
언리얼 일때만 처리하는 ifdef를 사용할 수 있다면 좋을 것 같다. 그래서 강의자는 아래와 같이 해결했다.
언리얼에 이미 정의된 define을 이용한 것.
#if UE_BUILD_DEBUG + UE_BUILD_DEVELOPMENT + UE_BUILD_TEST + UE_BUILD_SHIPPING >= 1
#include "S1.h"
#endif
그리고 make_shared(c++표준) MakeShared(언리얼) 의 차이도 비슷하게 해결한다.
#if UE_BUILD_DEBUG + UE_BUILD_DEVELOPMENT + UE_BUILD_TEST + UE_BUILD_SHIPPING >= 1
SendBufferRef sendBuffer = MakeShared<SendBuffer>(packetSize);
#else
SendBufferRef sendBuffer = make_shared<SendBuffer>(packetSize);
#endif
ASSERT_CRASH는 서버에서만 사용했던 건데 여기도 구현하거나 비슷하게 처리해준다.
일단 이건 중요한건 아니니 그냥 주석처리하고 직렬화 코드만 뺐다. 빌드 해보면 문제없이 잘 된다.

이제 clientpackethandler를 사용하기 전에 Init 을 불러와야 하는데, PacketSession 생성자에 넣어주자.
서버와 연결될때마다 Init을 하는건 좀 이상하긴 한데 (여러 서버와 연결하는 경우도 있다 했음) 일단 그렇게 한다
PacketSession::PacketSession(class FSocket* Socket) : Socket(Socket)
{
ClientPacketHandler::Init();
}
recv쪽에서 이전에 TODO로 남겨놨던 clientpackethandler::handlepacket을 불러오는 부분도 완성해준다.
void PacketSession::HandleRecvPackets()
{
while (true)
{
TArray<uint8> Packet;
if (RecvPacketQueue.Dequeue(OUT Packet) == false)
break;
PacketSessionRef ThisPtr = AsShared();
ClientPacketHandler::HandlePacket(ThisPtr, Packet.GetData(), Packet.Num());
}
}
clientpackethandler::handlepacket에 세션셰어드포인터, 패킷, 길이를 넣으면
그안에서 알아서 헤더를 읽어서 헤더 id를 구하고 데이터를 역직렬화 한 뒤에 그거에 맞는 커스텀 핸들러에 넣어서 호출해준다.
커스텀핸들러는 내가 구현해야 하는 부분.
테스트를 해보자
언리얼 에디터를 키고 테스트 엑터클래스를 하나 만든다. 이름은 MyActor
먼저 클라 -> 서버 데이터 보내기
#include "MyActor.h"
#include "Protocol.pb.h"
#include "S1GameInstance.h"
#include "ClientPacketHandler.h"
/*...*/
// Called every frame
void AMyActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
Protocol::C_CHAT msg;
msg.set_msg("Hello World");
SendBufferRef Sendbuffer = ClientPacketHandler::MakeSendBuffer(msg);
Cast<US1GameInstance>(GetGameInstance())->SendPacket(Sendbuffer);
}
프토로콜 class 객체 만들기 - 내용물 채우기 - ClientPacketHandler로 직렬화 해주기 - 직렬화된 SendBuffer 보내기
서버에서 확인해보면 클라이언트에서 보낸 HelloWorld가 잘 출력된다.

handlerecv는 gamemode블루프린트에서 호출해준다.

서버측에서 send하는 코드
while (true)
{
Protocol::S_CHAT pkt;
pkt.set_msg("HelloWorld!");
auto sendBuffer = ServerPacketHandler::MakeSendBuffer(pkt);
GSessionManager.Broadcast(sendBuffer);
this_thread::sleep_for(1s);
}
클라쪽에서 처리하는 코드
bool Handle_S_CHAT(PacketSessionRef& session, Protocol::S_CHAT& pkt)
{
std::string Msg = pkt.msg();
UE_LOG(LogTemp, Log, TEXT("%s"), UTF8_TO_TCHAR(Msg.c_str()));
return true;
}

서버에서 보내는 helloworld! 를 잘 받고있다.
11. 자동화 처리
마지막으로 clientpackethandler같은 자동화코드를 생성할 때 정상적으로 처리되도록 만들어보자.
현재 서버쪽에서 빌드이벤트가 걸려있다. GenPacket.bat을 실행하면서 이것저것 만들고 복사해준다.

pushd %~dp0
protoc.exe -I=./ --cpp_out=./ ./Enum.proto
protoc.exe -I=./ --cpp_out=./ ./Struct.proto
protoc.exe -I=./ --cpp_out=./ ./Protocol.proto
GenPackets.exe --path=./Protocol.proto --output=ClientPacketHandler --recv=S_ --send=C_
GenPackets.exe --path=./Protocol.proto --output=ServerPacketHandler --recv=C_ --send=S_
IF ERRORLEVEL 1 PAUSE
XCOPY /Y Enum.pb.h "../../../GameServer\"
XCOPY /Y Enum.pb.cc "../../../GameServer\"
XCOPY /Y Struct.pb.h "../../../GameServer\"
XCOPY /Y Struct.pb.cc "../../../GameServer\"
XCOPY /Y Protocol.pb.h "../../../GameServer\"
XCOPY /Y Protocol.pb.cc "../../../GameServer\"
XCOPY /Y ServerPacketHandler.h "../../../GameServer\"
XCOPY /Y Enum.pb.h "../../../DummyClient\"
XCOPY /Y Enum.pb.cc "../../../DummyClient\"
XCOPY /Y Struct.pb.h "../../../DummyClient\"
XCOPY /Y Struct.pb.cc "../../../DummyClient\"
XCOPY /Y Protocol.pb.h "../../../DummyClient\"
XCOPY /Y Protocol.pb.cc "../../../DummyClient\"
XCOPY /Y ClientPacketHandler.h "../../../DummyClient\"
XCOPY /Y Enum.pb.h "../../../../S1/Source/S1/Network\"
XCOPY /Y Enum.pb.cc "../../../../S1/Source/S1/Network\"
XCOPY /Y Struct.pb.h "../../../../S1/Source/S1/Network\"
XCOPY /Y Struct.pb.cc "../../../../S1/Source/S1/Network\"
XCOPY /Y Protocol.pb.h "../../../../S1/Source/S1/Network\"
XCOPY /Y Protocol.pb.cc "../../../../S1/Source/S1/Network\"
XCOPY /Y ClientPacketHandler.h "../../../../S1/Source/S1\"
DEL /Q /F *.pb.h
DEL /Q /F *.pb.cc
DEL /Q /F *.h
PAUSE
proto.exe로 proto파일들을 토대로 protocol.pb.cc와 같은 파일을 만들어주고
GenPackets.exe는 파이썬파일을 exe로 만든것. 파이썬파일을 보면 jinja2 argparse ProtoParser를 사용해서 탬플릿 PacketHandler.h 파일을 토대로 clientpackethandlr와 serverpackethandler를 생성한다.
그다음 만들어준 파일들을 적절한 위치로 복붙해준다.
아까 클라이언트쪽에서 clientpackethandler를 수정해서 사용할 수 있게 만들었는데, 위 bat을 실행할 때 덮어씌워져서 변경내용이 없어지지 않도록 탬플릿 파일도 수정해주자.