언리얼엔진은 복잡한 구조의 네트워크 구조를 가지고 있습니다. 이 글에서는 전체적인 흐름을 이해하는 입장에서 작성되었으므로 PushModel
이나 Iris
와 같은 향상된 네트워크 시스템을 다루고 있진 않습니다.
그러나 언리얼에 대한 일반적인 지식이 있다는 가정하에 진행하는 부분이 있기에 소켓프로그래밍 기초나 언리얼 네트워크 기초에 대해서 공부하셨다면 이해하기 편합니다.
이 글은 (언리얼)네트워크에 대해서 조금이라도 지식이 있는 분들이 이해할 수 있도록 작성되었습니다. 네트워크에 관한 지식이 풍부하지 않다면 이해하는데 어려움이 있을 수 있습니다.
언리얼 엔진은 멀티플레이 게임에서 클라이언트-서버 모델을 사용합니다. 하나의 서버가 존재하고 플레이어는 클라이언트로 서버에 연결합니다. 서버는 클라이언트의 데이터를 복제(Replicate)하여 다른 클라이언트에게 시뮬레이션합니다.
이때 서버와 클라의 객체에는 Replication Role Flag
가 붙는데 서버에 있는 모든 객체들은 Authority(권위를 가진 객체들) , 클라가 직접 컨트롤하는 액터는 AutonomouseProxy(자율성있는 객체들) , 클라에 있는 그 외의 것들은 SimulatedProxy(시뮬레이션할 객체들) 의 Role
을 부여합니다.
언리얼 엔진은 위 네트워크 Role Flag
를 이용해 서버와 클라에 역할을 부여하여 각 객체들을 관리하게 됩니다.
🤔 그럼 위
Role Flag
는 어디서 설정되는 것일까요?
Role
은 Actor를 상속받는 클래스의 멤버변수입니다. Actor의 생성자에서 기본값으로 Role = Role_Authority
, RemoteRole = Role_None
으로 셋팅됩니다.
그리고 세부적인 정보는 Controller가 Spawn될 때 결정됩니다.
컨트롤러의 스폰은 UWorld::NotifyControlMessage(UNetConnection* Connection, uint8 MessageType, class FInBunch& Bunch)
에서 MessageType가 NMT_Join일 때 생성되는데,
여기서 NotifyControlMessage() 함수는 언리얼의 UDP HandShaking을 수행하는 함수로 서버와 클라이언트의 로직을 모두 가지고 있습니다.
간단히 말하면 서버가 NMT_Join 패킷을 수신받았을 때, 클라이언트의 Controller 스폰을 수행하는 것입니다.
UDP의 HandShaking과정은 아래처럼 Request와 Response를 통해 이루어지는데, 이 과정에서 패킷에 대한 Flag
를 패킷 헤더에 포함하여 전달하게 됩니다. (소켓 프로그래밍에 대한 부분은 여기 참고)
언리얼에서 HandShaking에 사용되는 패킷 Flag(MessageType) 는 Hello, Welcome부터 Login, Join까지 다양하고 DEFINE_CONTROL_CHANNEL_MESSAGE
매크로를 통해 정의됩니다.
먼저 클라이언트는 UPendingNetGame::InitNetDriver() → UpendingNetGame::SendInitialJoin()
순으로 호출니다 그리고 NMT_Hello 패킷을 서버에게 보냅니다.
NetDriver는 언리얼 네트워크 통신에서 가장 기초가 되는 클래스로 서버 그리고 각각의 클라이언트는 개별의 NetDriver를 가지고 있습니다.
(NetDriver에 관해서는 2부에서 다룰 예정)
Server의 UWorld::NotifyControlMessage()가 NMT_Hello를 수신하고 NMT_Challenge를 보냅니다.
void UWorld::NotifyControlMessage(UNetConnection* Connection, uint8 MessageType, class FInBunch& Bunch)
{
if( NetDriver->ServerConnection )
{
//클라이언트 처리
}
esle
{
switch (MessageType)
{
//서버가 패킷을 받음
case NMT_Hello:
{
if (FNetControlMessage<NMT_Hello>::Receive(Bunch, IsLittleEndian, RemoteNetworkVersion, EncryptionToken, RemoteNetworkFeatures))
{
//네트워크 호환성 체크
const bool bIsCompatible = FNetworkVersion::IsNetworkCompatible(LocalNetworkVersion, RemoteNetworkVersion) && FNetworkVersion::AreNetworkRuntimeFeaturesCompatible(LocalNetworkFeatures, RemoteNetworkFeatures);
if (!bIsCompatible)
{
}
else
{
//자세한 부분은 알 수 없지만 암호화된 토큰이 없는 경우에 패킷을 전송합니다.
if (EncryptionToken.IsEmpty())
{
if (!bEncryptionRequired)
{
여기서 NMT_Challenge 패킷을 송신합니다!!
Connection->SendChallengeControlMessage();
}
}
}
}
break;
}
}
}
Client의 UpendingNetGame::NotifyControlMessage() 가 NMT_Challenge를 수신하고 NMT_Login에서 데이터를 다시 보냅니다.
Server의 UWorld::NotifyControlMessage()가 NMT_Login을 수신하고 수신된 데이터를 확인한 다음 AGameModeBase::PreLogin을 호출합니다.
Case NMT_Login 추가 하위 로직
if(bReceived) 내부 로직
PreLoginAsync의 PreLogin이 오류를 보고하지 않으면 서버는 UWorld::WelcomePlayer()
를 호출하고 AGameModeBase::GameWelcomePlayer()
를 호출하여 맵 정보와 함께 NMT_Welcome을 보냅니다.
PreLoginComplete는 위에서 bRecieved실행 중 델리게이트로 등록합니다. PreLogin이 성공적으로 수행되면 PreLoginComplete를 호출합니다.
Client의 UpendingNetGame::NotifyControlMessage()가 NMT_Welcome을 수신하고 맵 정보를 읽어 나중에 맵을 로드할 수 있도록 합니다.
클라이언트가 NMT_Welcome 패킷을 받는 곳을 보면 bSuccessfullyConnected = true;
로직을 볼 수 있습니다.
아래 이미지가 잘 안보이실 수 있지만 UEngine의 TickWorldTravel 함수 내에서 연결 패킷 성공 여부를 체크하고 연결이 성공하면 클라이언트의 LoadMap 함수를 호출하게 됩니니다.
간단히 요약하면 아래의 그림과 같은 흐름을 가집니다.
성공적으로 맵을 로드하고 맵으로의 이동(Travel)이 완료되면 아래 로직의 SendJoin 함수를 통해 서버로 NMT_Join 패킷을 송신합니다.
이때 서버는 UWorld::NotifyControlMessage에서 SpawnPlayActor를 통해 클라이언트를 스폰(정확히는 컨트롤러)해주게 됩니다!!
드디어 서버에 클라이언트 컨트롤러 스폰로직이 등장했습니다.
SpawnPlayActor에서는 생성된 클라이언트의 Login함수를 호출해주고 Role_None
이였던 RemoteRole을 AutonomousProxy로 셋팅해줍니다.
이 로직을 끝으로 서버의 Role 설정은 마무리됩니다.
클라이언트는 약간 다른 방식으로 Role
을 셋팅합니다. 서버가 SpawnPlayActor를 성공적으로 수행하게 되면 클라이언트는 ActorChannel을 통해 값이 초기화되지 않았을 때(최초 1회) Role
과 RemoteRole
을 설정해줍니다.
여기서 ActorChannel은 해당 액터가 가지고 있는 Replicate 정보를 관리해주는 클래스입니다. 수신된 패킷의 처리도 이 클래스에서 이루어집니다.
(ActorChannel에 대한 자세한 정보는 2부에서 다룰 예정)
전체적인 플로우는 아래와 같습니다.(아래에서부터 읽으면 됩니다)
먼저 패킷이 수신되어 처리되는 과정(Dispatch) 이 어떻게 이루어지는지 봅시다.
NetDriver 는 생성된 후 RegisterTickEvents 함수를 통해서 World의 TickDispatchEvent(델리게이트)에 InternalTickDispatch 함수를 바인딩합니다.
그리고 바인딩된 InternalTickDispatch 함수는 World의 BroadcastTickDispatch 함수를 통해서 호출됩니다.
BroadcastTickDispatch는 World의 Tick에서 지속적으로 업데이트 되게 됩니다.
void UWorld::Tick( ELevelTick TickType, float DeltaSeconds )
{
...
{
SCOPE_CYCLE_COUNTER(STAT_NetWorldTickTime);
CSV_SCOPED_TIMING_STAT_EXCLUSIVE(NetworkIncoming);
CSV_SCOPED_SET_WAIT_STAT(NetworkTick);
SCOPE_TIME_GUARD(TEXT("UWorld::Tick - NetTick"));
LLM_SCOPE(ELLMTag::Networking);
여기서 송신이 이루어집니다!!!
BroadcastTickDispatch(DeltaSeconds);
BroadcastPostTickDispatch();
if( NetDriver && NetDriver->ServerConnection )
{
TickNetClient( DeltaSeconds );
}
}
}
즉 반복적으로 수신된 패킷을 처리하는 것이죠
매 틱마다 로직을 수행하는 과정에서 ActorChannel의 ProcessBunch(수신된 패킷데이터를 처리하는 함수)가 최초 호출되었을 때(액터가 초기화되지 않았을 때(최초 1회) 설정한다는 부분) UNetConnection::HandleClientPlayer 함수를 호출합니다.
(NetDriver와 ActorChannel에 관해서는 2부에서 다룰 예정)
HandleClientPlayer에서는 PC(PlayerController) 의 Role
을 AutonomousProxy로 셋팅합니다.
Role
은 셋팅 되었습니다. 그런데 RemoteRole
은 어디로 간걸까요?
아시다시피 일반적으로 클라이언트의 Role
은 AutonomousProxy 또는 SimulatedProxy이고 RemoteRole
은 Authority입니다.
여기에는 약간의 트릭이 숨겨져 있는데요. 액터의 초기화 과정을 자세히 보시면 Role
이 Authority이고 RemoteRole
이 None인걸 볼 수 있습니다.
이유는 알 수 없지만 언리얼 엔진은 액터의 스폰과정에서 Role
와 RemoteRole
을 Swap해주게 됩니다. Swap 후에 Role
만 따로 셋팅하게 됩니다.
즉
Role
은 None이 되고RemoteRole
은 Authority가 되는 것이죠.
Swap은 클라이언트의 SpawnActor 함수를 통해 이루어지게 됩니다.
그리고 HandleClientPlayer가 호출되어 Role이 AutonomousProxy로 변경됩니다.
이 로직을 끝으로 클라이언트의 Role 설정도 마무리됩니다.
언리얼 네트워크는 플랫폼마다 연결하는 인터페이스가 방식이 다르기 때문에 HAL(Hardward Abstraction Layer) 에 따라 서버와 클라이언트의 소켓 생성 방법이 다릅니다. Windows에선 FSocketSubsystemBSD::CreateSocket 약칭 버클리 소켓 클래스에서 생성됩니다.
아시다시피 언리얼은 Reliable UDP이기 때문에 UDP 기반으로 소켓이 생성됩니다. 아래 코드를 통해 소켓이 생성되는 로직을 확인할 수 있습니다.
최초로 소켓이 생성되는 플로우는 서버가 UWorld::Listen을 호출할 때입니다. 이때 생성하는 소켓은 SocketType이 DGram으로 입력되어 UCP 소켓(ListenSocket)이 생성됩니다.
이와 유사하게 클라이언트는 UEngine::Browse의 호출을 통해 소켓을 생성합니다. 이때 생성되는 클라이언트 소켓 역시 UDP입니다.
Browse의 매개변수 URL의 값을 살펴보면 127.0.0.1의 로컬 IP주소와 17777포트를 통해 서버와 통신을 하는 것을 알 수 있습니다.
약간의 짤막한 플로우에 대해 더 알아보면 클라이언트의 GameInstance가 생성될 때, UGameInstance::StartPlayInEditorGameInstance에서 Browse를 호출합니다.
실제 패키징된 게임에서는 일부 로직의 살짝 달라질 순 있겠지만 UEngine::Browse를 통해 소켓이 생성되는 것은 변함이 없습니다.
TCP의 경우 서버와 클라이언트는 아래와 같이 소켓을 생성(socket), bind, listen, connect을 통해서 서버는 IP와 포트를 소켓이 바인딩(bind) 하고 클라이언트의 요청을 기다립니다(listen) .
그리고 클라이언트는 connect를 요청하여 서버와 연결을 유지합니다.(이미지에 있는 read와 write는 recv와 send를 뜻합니다.)
실제 Bind와 Connect의 소스코드가 언리얼에도 존재하는 것을 알 수 있습니다.
반면 UDP의 경우 비신뢰성 프로토콜로 listen, connect 과정이 필요가 없습니다.
언리얼의 경우 Socket을 생성해주고 이전에 봤던 UEngine::Browse의 매개변수 URL의 정보에 맞게 데이터를 송수신을 해주면 되는 것이죠.
이번 포스팅은 작성하는데 있어서 정신이 나가는 줄 알았습니다. 내용이 너무 방대하고 복잡하게 구성되어 있어서 디버깅하는 과정이 쉽지 않았습니다. 원래라면 1부에서 모든 내용을 다 써내려갈려고 했지만 너무 내용이 많아질 것 같아 1부와 2부로 쪼개기로 했습니다.
다음 2부에서는 NNC(NetDriver, NetConnection, Channel), Reliable UDP, Replicate와 RPC, 중 뭐가 더 빠를까?에 대해 적어볼까 합니다.
이 글에는 약간의 단어가 잘못 표시될 수 있습니다. 혹시나 잘못된 정보라면 알려주시면 감사하겠습니다. 긴 글 읽어주셔서 감사합니다. 2부에서 뵙겠습니다.
https://codingfarm.tistory.com/536
https://forum.amebaiot.com/t/sharing-socket-programming-101-udp/1159
https://zhuanlan.zhihu.com/p/663883544