이 글은 (언리얼)네트워크에 대해서 조금이라도 지식이 있는 분들이 이해할 수 있도록 작성되었습니다. 네트워크에 관한 지식이 풍부하지 않다면 이해하는데 어려움이 있을 수 있습니다.
해당 글은 총 3부작으로 이루어져 있습니다. 이 글은 3부에 해당합니다.
2부가 궁금하시다면 여기서, 1부가 궁금하시다면 여기서 보실 수 있습니다.
언리얼 엔진에서는 멀티플레이어 게임을 개발할 때 RPC(Remote Procedure Call)
와 Replication(복제)
를 제공하여 멀티 환경에서 게임이 원활하게 동작하도록 지원해줍니다.
RPC와 Replication에 대해 간략하게 비교해보자면 RPC는 서버와 클라이언트 사이에서 메세지를 송수신하여 이벤트를 통해 클라이언트나 서버에게 명령을 보냅니다. 반면 Replication은 서버에서 클라이언트로만 변경된 정보를 송신하여 클라이언트들의 데이터를 동기화합니다.
이처럼 언리얼 엔진의 RPC와 Replication을 이용하여 멀티플레이에 맞는 적절한 네트워크 시스템을 구축하여 유연하게 컨텐츠를 개발할 수 있습니다.
언리얼의 RPC, Replication을 이해하려면 먼저 UFUNCTION, UPROPERTY에 대한 키워드를 이해하고 있어야합니다.
그럼 해당 키워드만 이해하고 있으면 되나요? 그럴리가요. UFUNCTION, UPROPERTY는 언리얼 엔진의 리플렉션 시스템
을 통해 관리되는 매크로로서 언리얼 헤더툴(UHT)
을 통해 클래스, 구조체, 함수, 변수 등을 탐색하거나 변경할 수 있게 도와주는 메타프로그래밍 기법입니다.
우선 언리얼 엔진의 리플렉션에 대해 알아봅시다.
리플렉션 시스템이란 컴파일 타임에 존재하는 정보들을 런타임에도 알 수 있게 해주는 시스템입니다.
예를 들면 APlayerCharacter클래스가 ACharacter를 상속받고 EquipWeapon이라는 함수를 가지고 있다고 했을 때, 객체지향의 다형성을 통해 다운캐스팅을 dynamic_cast(런타임)가 아닌 자체적인 Cast(컴파일 타임)를 통해 구현하여 컴파일 타임에 미리 생성된 정보를 바탕으로 런타임에서 빠르게 해당 타입을 알 수 있다거나, 해당 EuipWeapon의 함수가 존재하는지 여부 등을 파악하는 등이 있겠습니다.
언리얼의 리플렉션 시스템은 언리얼 헤더툴(UHT)을 통해 동작하며 이 과정은 프로젝트의 빌드 중에 실행됩니다.
언리얼 엔진은 이 과정을 VisualStudio를 통해 IDE실행 중 소스코드의 변경을 감지하고 UHT빌드를 수행하여 소스코드를 IDE런타임에 업데이트해줍니다.
그리고 해당 동작은 언리얼 엔진의 UhtCodeGenerator.cs에서 CodeGenerator 호출을 통해 실행됩니다.
namespace EpicGames.UHT.Exporters.CodeGen
{
[UnrealHeaderTool]
class UhtCodeGenerator
{
[UhtExporter(Name = "CodeGen", Description = "Standard UnrealEngine code generation", Options = UhtExporterOptions.Default,
CppFilters = new string[] { "*.generated.cpp", "*.generated.*.cpp", "*.gen.cpp", "*.gen.*.cpp" },
HeaderFilters = new string[] { "*.generated.h" })]
public static void CodeGenerator(IUhtExportFactory factory)
{
UhtCodeGenerator generator = new(factory);
generator.Generate();
}
}
}
특이한 점은 언리얼 헤더툴(UHT)과 언리얼 빌드툴(UBT)의 컴파일 과정에 C# 컴파일러가 개입한다는 점입니다.
자세한 부분은 저도 잘 모르지만 언리얼에서는 멀티 플랫폼 지원과 C# 형식의 간단한 스크립트 방식(build.cs처럼)을 제공하기 위해서 C# 컴파일러를 채택하지 않았나 싶습니다.
UHT은 코드에서 정의된 클래스를 분석하여 리플렉션 데이터를 생성하고 이 데이터는 언리얼 엔진에서 객체를 생성하고 조작하는 데 사용됩니다.
이때 생성된 메타데이터는 프로젝트 파일의 Intermediate폴더 내부에 .generated.h 파일로 생성됩니다.
한가지 예로 제가 만든소스코드 중 RPCvsReplicationCharacter.generated.h파일을 열어보면 아래와 같습니다.
RPCvsReplicationCharacter.genrated.h에선 테스트 환경 구축을 위해 UFUNCTION키워드를 붙인 RPC함수와 OnRep함수를 만들어두었기 때문에 해당 함수가 가상함수 형태로 선언된 것을 확인할 수 있습니다.
RPCvsReplicationCharacter.h에 선언한 UFUNCTION 함수들
UFUNCTION(Reliable, Server)
void ServerRPC_Attack();
UFUNCTION(Reliable, NetMulticast)
void MulticastRPC_Attack();
UFUNCTION()
void OnRep_AttackCount();
그리고 아래로 내려가면 생성자 관련로직을 볼 수 있습니다. 이동 및 복사 생성자를 private 접근 지정자로 막아뒀으며 상속을 위해서 소멸자를 virtual로 선언해 둔 것을 확인할 수있습니다.
뿐만 아니라 UCLASS와 GENERATED_BODY정보를 등록하기 위한 define과 템플릿 특수화를 통해 StaticClass를 선언한 것도 확인할 수 있습니다.
✅ UHT은 .generated.h를 생성할 때 UCLASS와 GENERATED_BODY매크로의 코드라인에 대한 정보를 기록해둡니다. RPCvsReplicationCharacter_h_18, RPCvsReplicationCharacter_h_21과 같이 말이죠. 굳이 번호를 사용하는 이유는 저도 정확히는 모르지만 식별용으로 쓰이지 않을까 싶습니다. 아시는 분이 계시다면 알려주세요.
StaticClass는 언리얼 리플렉션 시스템의 대표적인 기능으로 클래스에 대한 정적 객체를 반환하는 함수입니다. 해당 함수의 정의는 GENERATED_BODY매크로 내부에서 확인할 수 있습니다.
여기서 StaticClass는 GetPrivateStaticClass 함수를 반환합니다.
GetPrivateStaticClass함수는 IMPLEMENT_CLASS_NO_AUTO_REGISTRATION 매크로를 통해서 매크로 형태로 .generated.h의 cpp파일인 .gen.cpp에 구현되어 있습니다.
IMPLEMENT_CLASS_NO_AUTO_REGISTRATION에서는 GetPrivateStaticClass를 정의 및 구현하고 GetPrivateStaticClassBody 함수를 호출합니다.
해당 싱글톤 객체는 GetPrivateStaticClassBody함수를 통해 참조전달(Pass by reference)방식으로 TClass(UClass*) 포인터의 참조자를 반환합니다.
GetPrivateStaticClassBody 함수 내부에선 해당 클래스의 메모리 할당 및 Placement New를 통해 할당된 ReturnClass에 UClass 객체를 생성해줍니다.
그리고 InitializePrivateStaticClass
함수를 호출하면서 부모의 StaticClass호출(UnSuperClassFn())과 동시에 부모의 UClass정보를 SuperStruct로 등록해줍니다.
이 과정을 거치게 되면 런타임에서 컴파일 정보를 바탕으로 Cast할 수 있는 시스템이 구축되어 dynamic_cast보다 빠르게 다운캐스팅을 수행할 수 있게됩니다. 바로 리플렉션 시스템의 대표적인 예시라고 할 수 있죠.
마지막으로 GetPrivateStaticClassBody는 RegisterNativeFunc()
함수를 호출하게 되는데,
해당 함수는 StaticRegisternatives클래스이름()의 이름으로 호출됩니다.
내부 코드는 .gen.cpp파일에 있습니다.
제가 만든 클래스의 경우 UHT의 빌드 과정에서 UFUNCTION키워드를 붙인 함수를 key(함수이름), Value(함수 포인터) 쌍으로 생성해놓은 것을 알 수 있습니다.
마지막으로 FNativeFunctionRegistrar::RegisterFunctions 함수를 통해 Funcs의 수만큼 TCHAR의 형태로 NativeFunctionLoopupTable에 추가되는 것을 볼 수 있습니다.
NativeFunctionLookupTable에 함수 정보가 등록되어 이후에 해당 함수가 있는지 여부를 확인하거나 블루프린트에서 함께 사용되기도 합니다.
위 모든 로직은 단 한번의 StaticClass호출을 통해 이루어지게 됩니다.
StaticClass의 첫 호출은 에디터 시작시 모든 모듈을 로드하는 과정에서 일어나게 되므로 실제 게임에선 최초 1회 이상 호출됨을 알 수 있습니다.
❓리플렉션 시스템에 대해서 자세하게 뜯어봤으니 이제 RPC와 Replicate가 어떻게 호출되는지 알아봅시다.
언리얼의 RPC는 UFUNCTION 키워드를 통해 선언해서 사용할 수 있습니다.
언리얼 리플렉션 시스템에서 봤듯이 UFUNCTION 키워드에 Server, Client, NetMulticast 키워드를 넣어주게 되면 함수명_Implementation의 형태를 가진 함수가 UHT의 빌드 과정에서 생성됩니다.
Reliable의 NetFlag 정보들은 UHT의 리플렉션 시스템에 의해 수행되어 .gen.cpp파일에 비트플래그로 등록됩니다.
해당 플래그는 Script.h에서 확인할 수 있습니다.
그리고 Implementation이 없는 함수의 원형은 .gen.cpp에 구현되어 있습니다.
익숙한 형태의 FName이 보임과 동시에 리플렉션 시스템에서 저희는 StaticClass
를 호출하는 과정에서 Key, Value형태로 함수의 이름과 함수 포인터 정보를 맵핑해놓았다는 것을 기억하실겁니다.
StaticClass
를 통해서 등록된 요소와 일치하진 않지만 유사하게 동작합니다.
일반적(로컬환경)으로 RPC함수를 호출하면 .gen.cpp에 구현되어 있는 RPC함수를 호출하게 되고 내부에선 ProcessEvent
를 호출합니다.
RPC함수 호출 시 내부 로직에선 ProcessEvent
를 통해 리플렉션 데이터를 기반으로 특정 함수를 호출하게 됩니다.
그 전에 FindFunctionChecked
를 통해 FindFunctionByName
함수를 호출하여 FuncMap에서 Name에 맞는 UFunction*를 반환합니다.
UFunction 클래스는 함수포인터 정보를 담고있는 리플렉션 데이터로 UHT을 통해 생성됩니다.
FuncMap은 리플렉션 데이터를 Name, UFunction 형태로 저장해준 해시맵 자료구조 입니다. 이후에 더 자세하게 알아보게 됩니다.
FindFunctionByName 함수는 UHT으로 생성된 리플렉션 데이터(FuncMap)를 기반으로 해당 UObject가 InName(ServerAttack 등)에 맞는 함수 리플렉션 데이터(UFunction*)를 반환해주는 함수입니다.
UFunction* UClass::FindFunctionByName(FName InName, EIncludeSuperFlag::Type IncludeSuper) const
{
LLM_SCOPE(ELLMTag::UObject);
UFunction* Result = nullptr;
UE_AUTORTFM_OPEN(
{
UClass* SuperClass = GetSuperClass();
if (IncludeSuper == EIncludeSuperFlag::ExcludeSuper || ( Interfaces.Num() == 0 && SuperClass == nullptr ) )
{
// 단일 클래스인 경우
FUClassFuncScopeReadLock ScopeLock(FuncMapLock);
Result = FuncMap.FindRef(InName);
}
else
{
// 캐싱해둔 맵에서 우선적으로 찾기
bool bFoundInCache = false;
{
FUClassFuncScopeReadLock ScopeLock(AllFunctionsCacheLock);
if (UFunction** SuperResult = AllFunctionsCache.Find(InName))
{
Result = *SuperResult;
bFoundInCache = true;
}
}
// 캐시에 없으면
if (!bFoundInCache)
{
// FuncMap에서 최우선으로 찾기
{
FUClassFuncScopeReadLock ScopeLock(FuncMapLock);
Result = FuncMap.FindRef(InName);
}
if (Result)
{
// 찾으면 캐시에 등록
FUClassFuncScopeWriteLock ScopeLock(AllFunctionsCacheLock);
AllFunctionsCache.Add(InName, Result);
}
else
{
// 없으면 인터페이스 클래스에서 해당 함수 찾기
if (Interfaces.Num() > 0)
{
for (const FImplementedInterface& Inter : Interfaces)
{
Result = Inter.Class ? Inter.Class->FindFunctionByName(InName) : nullptr;
if (Result)
{
break;
}
}
}
// 그래도 없으면 부모 클래스로 재귀호출
if (Result == nullptr && SuperClass != nullptr )
{
Result = SuperClass->FindFunctionByName(InName);
}
}
}
}
});
return Result;
}
언리얼은 내부적으로 UClass 생성자를 리플렉션 시스템에 ZConstruct_UClass클래스이름의 형태로 생성해두고 UClass 생성 시에 일괄 호출하는 방식으로 동작합니다.
UClass* Z_Construct_UClass_ARPCvsReplicationCharacter()
{
if (!Z_Registration_Info_UClass_ARPCvsReplicationCharacter.OuterSingleton)
{
UECodeGen_Private::ConstructUClass(Z_Registration_Info_UClass_ARPCvsReplicationCharacter.OuterSingleton, Z_Construct_UClass_ARPCvsReplicationCharacter_Statics::ClassParams);
}
return Z_Registration_Info_UClass_ARPCvsReplicationCharacter.OuterSingleton;
}
해당 생성자에서는 Z_Construct_UClass_ARPCvsReplicationCharacter_Statics::ClassParams 형태의 파라미터를 전달하는데, 해당 파라미터는 아래와 같이 정의되어 있습니다.
여기서 주목해야할 부분은 FuncInfo입니다. FuncInfo는 FClassFunctionLinkInfo타입의 Key, Value쌍으로 함수의 주소와 문자열이 들어가 있는 것을 확인할 수 있습니다.
여기까지 이해하셨다면 리플렉션 시스템을 통해 생성된 FuncInfo를 생성자에서 FuncMap에 등록하게 될 것이라고 감이 잡히실 겁니다.
ConstructUClass는 아래와 같이 FClassParams를 매개변수로 받고 있습니다.
그리고 FClassParams는 앞서 바왔던 FClassFunctionLinkInfo타입의 FunctionLinkArray로 FuncInfo를 전달해주고 있습니다.
ConstructUClass 내부에선 FuncInfo를 순회하며 FuncMap에 함수이름과 포인터를 등록해주고 있습니다.
언리얼의 리플렉션 데이터의 최상단 부모는 UField클래스로 UFunction 클래스 역시 해당 UField 클래스를 상속받습니다.
그리고 해당 UField는 링크드 리스트 자료구조를 통해 NextField에 대한 정보를 저장하고 있습니다.
ScoreLock과 Write를 방지하는 WriteLock을 합친 ScopeWriteLock을 이용하여 멀티쓰레드 환경에서 안정적으로 Add할 수 있도록 구현하여 FuncMap에 Add하고 있습니다.
최종적으로 반환된 함수 포인터를 통해 AActor::ProcessEvent -> UObject::ProcessEvent의 흐름으로 호출하며 UFUnction* 에는 RPC함수 포인터를, Parms에는 NULL을 매개변수로 전달합니다.
UObject의 ProcessEvent
에는 먼저 호출할 함수를 해당 UObject에서 찾기 위해 매모리 정보를 수집합니다.
해당 파라미터로부터 매개변수의 크기를 통해 로컬 호출 스택 크기를 설정하고 해당 함수의 리턴주소를 Offset값을 통해 임의로 설정하여 UObject의 매모리 스택을 기준으로 해당 함수의 로컬 메모리 위치를 확보 또는 생성합니다.
Invoke함수에선 스택에 있는 Node와 Object정보를 통해 해당 RPC함수를 호출하게 됩니다.
여기서 호출되는 함수는 일반 RPC함수가 아닌 리플렉션 시스템에 등록된 exec함수가 호출됩니다.
exec함수에선 함수이름_Implemenation함수를 호출하여 최종적으로 구현한 RPC함수인 ServerRPC_Attack_Implementation가 호출됩니다.
멀티 플레이 환경에서 RPC의 호출에선 추가적인 로직이 수행됩니다.
ProcessEvent 과정에서 해당 함수가 Remote라면 해당 CallRemoteFunction을 호출하고 Local함수라면 CallRemoteFunction을 무시한 뒤, Function→Invoke를 호출합니다.
GetFunctionCallspace함수는 디폴트로 Remote를 반환합니다.
int32 AActor::GetFunctionCallspace( UFunction* Function, FFrame* Stack ) { ... 여러 조건문을 통해 FunctionCallspace 결정 모든 조건을 통과하면 Remote를 반환 // Call remotely DEBUG_CALLSPACE(TEXT("GetFunctionCallspace RemoteRole Remote %s"), *Function->GetName()); return FunctionCallspace::Remote; }
CallRemoteFunction함수 내부에는 해당 클라이언트의 NetDriver를 이용하여 ProcessRemoteFuction함수를 호출합니다.
ProcessRemoteFuction 함수는 UNetDriver내부 protected함수인 InternalProcessRemoteFuction 호출 → private함수인 InternalProcessRemoteFunctionPrivate를 호출하게 됩니다.(구조를 더럽게 복잡하게 만들어 놨네요.)
InternalProcessRemoteFunctionPrivate의 내부 코드는 복잡한 예외처리 과정을 거치고 ProcessRemoteFunctionForChannelPrivate을 호출하여 ActorChannel에서 Bunch로 직렬화하는 과정을 수행합니다.
void UNetDriver::InternalProcessRemoteFunctionPrivate(
AActor* Actor,
UObject* SubObject,
UNetConnection* Connection,
UFunction* Function,
void* Parms,
FOutParmRec* OutParms,
FFrame* Stack,
const bool bIsServer,
EProcessRemoteFunctionFlags& Flags)
{
// 가상 함수인 경우 해당 함수의 최상단 함수부터 호출하도록
while (Function->GetSuperFunction())
{
Function = Function->GetSuperFunction();
}
// 네트워크가 포화상태라면 Reliable하지 않은 녀석들은 무시
if (!(Function->FunctionFlags & FUNC_NetReliable) && (!(Function->FunctionFlags & FUNC_NetMulticast)) && (!Connection->IsNetReady(0)))
{
DEBUG_REMOTEFUNCTION(TEXT("Network saturated, not calling %s::%s"), *GetNameSafe(Actor), *GetNameSafe(Function));
return;
}
// 라우팅할 실제 Connection 셋팅
if (Connection->GetUChildConnection())
{
Connection = ((UChildConnection*)Connection)->Parent;
}
// 닫힌 NetConnection이라면 RPC 하지 않음
if (Connection->GetConnectionState() == USOCK_Closed)
{
DEBUG_REMOTEFUNCTION(TEXT("Attempting to call RPC on a closed connection. Not calling %s::%s"), *GetNameSafe(Actor), *GetNameSafe(Function));
return;
}
if (World == nullptr)
{
DEBUG_REMOTEFUNCTION(TEXT("Attempting to call RPC with a null World on the net driver. Not calling %s::%s"), *GetNameSafe(Actor), *GetNameSafe(Function));
return;
}
// If we have a subobject, thats who we are actually calling this on. If no subobject, we are calling on the actor.
UObject* TargetObj = SubObject ? SubObject : Actor;
// 네트워크용 클래스(리플렉션 데이터) 정보 설정
const FClassNetCache* ClassCache = NetCache->GetClassNetCache( TargetObj->GetClass() );
if (!ClassCache)
{
DEBUG_REMOTEFUNCTION(TEXT("ClassNetCache empty, not calling %s::%s"), *GetNameSafe(Actor), *GetNameSafe(Function));
return;
}
// 네트워크용 FField(리플렉션 데이터) 정보 설정
const FFieldNetCache* FieldCache = ClassCache->GetFromField(Function);
if (!FieldCache)
{
DEBUG_REMOTEFUNCTION(TEXT("FieldCache empty, not calling %s::%s"), *GetNameSafe(Actor), *GetNameSafe(Function));
return;
}
// 패킷 수신용 ActorChannel 찾기
UActorChannel* Ch = Connection->FindActorChannelRef(Actor);
if (!Ch)
{
if (bIsServer)
{
if (Actor->IsPendingKillPending())
{
// Don't try opening a channel for me, I am in the process of being destroyed. Ignore my RPCs.
return;
}
if (IsLevelInitializedForActor(Actor, Connection))
{
Ch = Cast<UActorChannel>(Connection->CreateChannelByName(NAME_Actor, EChannelCreateFlags::OpenedLocally));
}
else
{
UE_LOG(LogNet, Verbose, TEXT("Can't send function '%s' on actor '%s' because client hasn't loaded the level '%s' containing it"), *GetNameSafe(Function), *GetNameSafe(Actor), *GetNameSafe(Actor->GetLevel()));
return;
}
}
if (!Ch)
{
return;
}
if (bIsServer)
{
Ch->SetChannelActor(Actor, ESetChannelActorFlags::None);
}
}
// 실제 패킷 수신 함수
ProcessRemoteFunctionForChannelPrivate(Ch, ClassCache, FieldCache, TargetObj, Connection, Function, Parms, OutParms, Stack, bIsServer, ERemoteFunctionSendPolicy::Default, Flags);
}
ProcessRemoteFunctionForChannelPrivate 함수에 대해서는 간단히 의사코드 위주로 설명하겠습니다.
void UNetDriver::ProcessRemoteFunctionForChannelPrivate(
UActorChannel* Ch,
const FClassNetCache* ClassCache,
const FFieldNetCache* FieldCache,
UObject* TargetObj,
UNetConnection* Connection,
UFunction* Function,
void* Parms,
FOutParmRec* OutParms,
FFrame* Stack,
const bool bIsServer,
const ERemoteFunctionSendPolicy SendPolicy,
EProcessRemoteFunctionFlags& RemoteFunctionFlags)
{
if (Ch->Closing)
{
return;
}
// 채널의 인덱스가 -1이라면 유효하지 않은 상태이므로 종료
if (Ch->ChIndex == -1)
{
ensure(!bIsServer);
return;
}
// RPC용 번치 생성
FOutBunch Bunch(Ch, 0);
// Reliable 옵션 추가
if (Function->FunctionFlags & FUNC_NetReliable)
{
Bunch.bReliable = 1;
}
//네트워크 비트 직렬화 클래스
FNetBitWriter TempWriter( Bunch.PackageMap, 0 );
//직렬화 클래스를 이용하여 해더 및 파라미터 크기 계산
int32 HeaderBits = 해더 크기;
int32 ParameterBits = 파라미터 크기;
// 번치에 모든 패킷이 있는지 체크
check(Bunch.GetNumBits() == HeaderBits + ParameterBits);
//채널의 SendBunch함수 호출
Ch->SendBunch(&Bunch, true);
}
SendBunch
은 UChannel::SendRawBunch → UNetConnection::SendRawBunch 순으로 호출하여 UNetConnection의 Bunch의 헤더정보를 담은 데이터를 WriteBitsToSendBufferInternal
함수를 이용해 SendBuffer에 직렬화합니다.
WriteBitsToSendBufferInternal에서 직렬화 수행 전 SendRawBunch에서 Bunch 정보를 헤더와 데이터 패킷으로 나누어줍니다.
int32 UNetConnection::SendRawBunch(FOutBunch& Bunch, bool InAllowMerge, const FNetTraceCollector* BunchCollector)
{
//번치에서 헤더정보를 SendBunchHeader에 직렬화
...
//헤더정보는 SendBunchHeader, 바디정보는 Bunch에 들어 있습니다.
Bunch.PacketId = WriteBitsToSendBufferInternal(SendBunchHeader.GetData(), BunchHeaderBits, Bunch.GetData(), BunchBits, EWriteBitsDataType::Bunch);
}
WriteBitsToSendBufferInternal함수에선 SendBuffer에 헤더정보와 바디정보의 직렬화를 수행합니다.
🤔 직렬화된 SendBuffer는 어떻게 클라이언트로 송신될까요?
SendBunch에 대해 더 자세히 알고싶으시다면 2부를 참고하시면 도움이 되실 겁니다.
SendBuffer에 추가된 패킷정보는 UNetConnection의 Tick에서 LowLevelSend
함수를 이용하여 서버나 클라이언트로 전송합니다.
일반적으로는 UNetConnection::Tick → UNetConnection::FlushNet로 넘어가서 처리됩니다.
FlushNet
는 NetDriver에서 설정한 0.2초를 기본값으로 Flush를 발생시킵니다.
그리고 FlushNet에서 LowLevelSend함수를 수행합니다.
LowLevelSend은 서버 혹은 클라이언트 소켓의 비동기 SendTo함수를 호출합니다.
윈도우 운영체제의 경우 SendTo는 버클리소켓 기반의 WINSOCK API함수인 sendto 함수를 호출하여 최종적으로 서버나 클라이언트로 패킷을 보냅니다.
멀티 플레이 환경에서 클라이언트라면 RPC 수신 과정에서 ProcessEvent를 통해 이벤트를 전달받는 로컬환경과 유사하지만 추가적인 로직이 수행됩니다.
바로 ReceivedBunch
인데요. 해당 내용은 2부에서 다루고 있으니 관심 있으신분은 한번 보시면 좋을 것 같습니다.
RPC의 수신 호출 스택을 보면 다음과 같습니다.
ReceivedBunch에선 Bunch의 ID에 맞는 ActorChannel을 통해 Bunch에 있는 패킷 정보를 FFieldNetCache의 타입으로 역직렬화하여 FieldCache에 캐싱합니다.
Bunch를 역직렬화 후 패킷 인덱스 정보를 통해 ClassCache에서 리플렉션 Field 데이터를 FieldCache에 캐싱합니다.
bool UActorChannel::ReadFieldHeaderAndPayload( UObject* Object, const FClassNetCache* ClassCache, FNetFieldExportGroup* NetFieldExportGroup, FNetBitReader& Bunch, const FFieldNetCache** OutField, FNetBitReader& OutPayload ) const
{
*OutField = nullptr;
if ( Bunch.GetBitsLeft() == 0 )
{
return false; // We're done
}
//리플렉션 데이터의 인덱스를 통해 Bunch 패킷을 역직렬화
const int32 RepIndex = Bunch.ReadInt( ClassCache->GetMaxIndex() + 1 );
//해당 인덱스가 리플렉션 데이터의 인덱스 범주를 넘으면 false반환
if ( RepIndex > ClassCache->GetMaxIndex() )
{
return false;
}
//인덱스에 맞는 리플렉션 Field 데이터를 대입
*OutField = ClassCache->GetFromIndex( RepIndex );
}
FFieldNetCache에는 RPC 함수 정보를 포함하고 있습니다.
FFieldVariant는 union을 이용한 FField 또는 UObject 로 2가지 형태를 가질 수 있는 유니온 객체입니다.
FField는 해당 프로퍼티 혹은 함수의 이름을 FName으로 가지고 있기 때문에 비교연산 등에 활용될 수 있습니다.
실제로 디버깅해보면 아래처럼 RPC서버 함수 정보를 포함하고 있는 것을 확인할 수 있습니다.
ReceivedBunch호출 후엔 FieldCache를 매개변수로 ReceivedRPC
를 호출합니다.
ReceivedRPC함수는 싱글플레이와 동일하게 Object객체에서 리플렉션 함수 데이터를 모아둔 FuncMap에서 함수 포인터를 찾아 ProcessEvent를 호출합니다.
Parms에는 NULL값이 들어갑니다.
이처럼 RPC는 언리얼 리플렉션 시스템과 상호작용하며 ProcessEvent를 통해 나름 직접적(?)으로 호출되는 것을 알 수 있습니다. 물론 멀티 환경에선 패킷 간의 송수신 시간이 필요하기 때문에 네트워크 딜레이를 무시할 수 없습니다.
언리얼에서 Replicated는 아래처럼 UPROPERTY 매크로를 이용하여 선언할 수 있습니다.
해당 UPROPERTY는 RPC와 유사하게 RepAttackCount의 리플렉션 메타 데이터가 .gen.cpp에 생성됩니다. 하지만 ProcessEvent와 같은 과정을 거치지 않기 때문에 .generated.h에 함수 메타 데이터가 생기진 않습니다.
생성된 리플렉션 데이터에는 UPROPERTY(Replicated)와 같은 파라미터 정보를 바탕으로 EPropertyFlags가 함께 초기화 됩니다. (Replicated는 CPF_Net플레그로 해당 플레그에 포함되어 있습니다.)
해당 프로퍼티 메타데이터 역시 UHT의 컴파일 과정에서 리플렉션 시스템에 의해 생성됩니다.
이렇게 생성된 메타데이터 정보를 바탕으로 런타임에서 프로퍼티 정보를 등록하게 됩니다.
UHT은 Replicated 옵션이 있음으로 인해 GetLifetimeReplicatedProps 함수의 정의를 강제하게 합니다.
해당 내용은 UHT에서 .generated.h를 생성하는 과정에서 추가됩니다.
UHT의 AppendReplicateMacroData함수에서 실제로 매크로에 추가되는 로직을 확인할 수 있습니다.
UHT의 C#빌드 과정에서 ResulveSelf 함수를 호출하게 되는데, 이 과정에서 해당 프로퍼티의 값에 하나라도 Replicate옵션이 달려있다면 SelfHasReplicateProperties 플래그를 부여하여 GetLifetimeReplicatedProps함수를 .generated.h에 생성하게 되는 것이죠.
그래서 만약 Replicate옵션을 걸어두고 GetLifetimeReplicatedProps함수를 구현하지 않았다면 링킹 에러가 뜨게 되는 것입니다.
또한 선언부가 미리 만들어 지기 때문에 해당 함수의 선언부없이 바로 구현을 해도 아무런 문제가 없는 것이죠.(아래 빨간 밑줄은 아직 UHT이 빌드를 수행하지 않아 생긴 일시적인 경고입니다)
언리얼은 해당 Replicate변수를 등록하는 과정은 GetLifetimeReplicatedProps를 통해 런타임에서 이루어 집니다.
언리얼에서는 Replicate 프로퍼티를 위해 DOREPLIFETIME매크로를 지원해줍니다.
(DOREPLIFETIME를 설정 해주지 않은 Replicate변수는 일반 변수처럼 동작합니다.)
해당 매크로의 첫 줄에는 상속 여부를 확인해주는 ValidateReplicatedClassInheritance 함수를 통해 유효성을 체크합니다.
C++ 17버전의 is_base_of_v를 사용하여 체크
template<class BaseClass, class DerivedClass>
constexpr bool ValidateReplicatedClassInheritance()
{
return std::is_base_of_v<BaseClass, DerivedClass>;
}
그리고 GetReplicatedProperty → FindFieldChecked 순으로 호출하여 해당 Field의 Name을 비교하여 싱글톤 UCLASS 객체의 프로퍼티 속성값을 가져옵니다.
GetReplicatedProperty를 통해 리플렉션 데이터를 찾으면 해당 프로퍼티 데이터를 OutLifetimeProps에 추가하는 함수를 호출합니다.
FProperty속성값을 가져온 뒤, RegisterReplicatedLifetimeProperty함수를 통해 ReplicatedProperty값을 OutLifetimeProps에 추가합니다.
RegisterReplicatedLifetimeProperty함수는 프로퍼티를 하나씩 진행하며 OutLifetimeProps에 중복되지 않는 프로퍼티를 추가해줍니다.
RepIndex는 식별자처럼 쓰이며 등록된 프로퍼티들은 순차적인 Index값을 가집니다. 이후 유효성을 체크하는 과정에서 사용되므로 한번쯤 봐두시는 것이 좋습니다.
DOREPLIFETIME의 동작방식에 대해서는 이해했습니다. 그렇다면 GetLifetimeReplicatedProps
는 어디에서 호출되는 것일까요?
바로 UClass::ValidateRuntimeReplicationData()함수입니다.
처음 ClassReps에 등록되어 있는 프로퍼티와 LifetimeProps를 비교하여 RepIndex가 일치한 프로퍼티가 존재하지 않는다면 경고 문구를 띄워주게 됩니다.
DOREPLIFETIME를 사용하지 않았을 경우에 찍힌 로그 정보
LogClass: Warning: Property RPCvsReplicationCharacter::RepAttackCount (SourceClass: RPCvsReplicationCharacter) was not registered in GetLifetimeReplicatedProps. This property will not be replicated. Use DISABLE_REPLICATED_PROPERTY if not replicating was intentional.
전체적은 흐름은 아래와 같습니다.(대략적인 흐름으로 정확하진 않습니다)
UClass::ValidateRuntimeReplicationData →
자식클래스::GetLifetimeReplicatedProps → DOREPLIFETIME매크로 수행 → …
→ UObject::GetLifetimeReplicatedProps → 유효성 검사 및 로그
해당 ValidateRuntimeReplicationData는 에디터를 처음 시작했을 때 호출되며 프로퍼티 리플리케이션의 핵심 로직입니다.
ClassReps는 클래스의 Replication 프로퍼티들에 대한 정보를 FRepRecord로 래핑하여 가지고 있습니다.
ClassReps에 정보를 추가하는 흐름은 아래와 같습니다.
UClass::ValidateRuntimeReplicationData호출 전 SetupRuntimeReplicationData에서 클래스가 가지고 있는 모든 프로퍼티 중 CPF_Net(Replicated 프로퍼티 플래그)플레그를 가진 프로퍼티를 NetProperties에 추가합니다.
ClassRep에 NetProperties 데이터를 추가합니다.
전체 흐름은 아래와 같습니다.
SetupRuntimeReplicationData함수 내부에서 ClassReps에 프로퍼티 정보를 추가한 후 ValidateRuntimeReplicationData을 호출합니다.
변수의 Replicate는 OnRep함수 없이는 디버깅이 어렵습니다. 하지만 1부에 이어 2부를 보셨다면 대략적으로 리플리케이션이 어떻게 이루어지는지 추측할 수 있습니다.
바로 UNetDriver::ServerReplicateActors입니다. 디버깅을 통해 중단점을 걸어보면 다음과 같습니다.
ServerReplicateActors는 UNetDriver::TickFlush에서 호출되는데, 2부에서 비슷한 함수를 본적이 있었습니다.
바로 TickDispatch이죠. 언리얼은 패킷을 송수신을 2가지로 나누어서 처리하게 됩니다.
그 중 TickFlush는 송신, TickDispatch는 수신을 담당합니다.
Replicate 송신은 TickFlush의 ServerReplicateActors에서 호출됩니다. 서버이름에서 아시다시피 해당 함수는 서버에서만 호출됩니다.
Replicate변수의 변경여부를 TickFlush에서 서버인 경우에만 확인하고 있기때문에 클라이언트에서 아무리 변경해도 다른 클라이언트에게까지 전달되지 않는 이유가 여기서 나오는것이죠.
대략적인 호출 흐름은 다음과 같습니다.
ServerReplicateActors코드를 Replicate와 관련된 부분만 묶어서 요약하면 아래와 같습니다.
연결된 모든 클라이언트에게 연관성 있는 모든 액터를 수집하여 ServerReplicateActors_ProcessPrioritizedActorsRange
함수를 호출합니다.
int32 UNetDriver::ServerReplicateActors(float DeltaSeconds)
{
SCOPE_CYCLE_COUNTER(STAT_NetServerRepActorsTime);
CSV_SCOPED_TIMING_STAT_EXCLUSIVE(ServerReplicateActors);
#if WITH_SERVER_CODE
if ( ClientConnections.Num() == 0 )
{
return 0;
}
...
//연결된 모든 클라이언트에게 리플리케이트 함수를 호출합니다.
for ( int32 i=0; i < ClientConnections.Num(); i++ )
{
UNetConnection* Connection = ClientConnections[i];
check(Connection);
//ViewTarget은 플레이어 Actor입니다.
if (Connection->ViewTarget)
{
FActorPriority* PriorityList = NULL;
FActorPriority** PriorityActors = NULL;
// 해당 NetConnection과 연관성있는 모든 액터를 수집하여 PriorityList에는 해당 객체들을, PriorityActors에는 객체들의 주소를 보관합니다.
const int32 FinalSortedCount = ServerReplicateActors_PrioritizeActors(Connection, ConnectionViewers, ConsiderList, bCPUSaturated, PriorityList, PriorityActors);
// 실제 Replicate를 수행하는 로직입니다.
TInterval<int32> ActorsIndexRange(0, FinalSortedCount);
const int32 LastProcessedActor = ServerReplicateActors_ProcessPrioritizedActorsRange(Connection, ConnectionViewers, PriorityActors, ActorsIndexRange, Updated);
}
}
return Updated;
#else
return 0;
#endif // WITH_SERVER_CODE
}
✅ FActorPriority에는 우선순위변수, 액터래퍼 클래스, 액터채널 등을 멤버변수로 가지고 있는 구조체입니다.
FNetworkObjectInfo는 해당 액터 정보와 캐싱된 WeakActor를 멤버변수로 가지고 있습니다.
해당 함수에선 매개변수로 받은 변수 중 PriorityActors를 반복문을 통해 순회하며 UActorChannel::ReplicateActor를 호출하고 있습니다.
int32 UNetDriver::ServerReplicateActors_ProcessPrioritizedActorsRange( UNetConnection* Connection, const TArray<FNetViewer>& ConnectionViewers, FActorPriority** PriorityActors, const TInterval<int32>& ActorsIndexRange, int32& OutUpdated, bool bIgnoreSaturation )
{
//송신 패킷의 수용량 여부 확인
if (!Connection->IsNetReady( 0 ) && !bIgnoreSaturation)
{
return 0;
}
for ( int32 j = ActorsIndexRange.Min; j < ActorsIndexRange.Min + ActorsIndexRange.Max; j++ )
{
FNetworkObjectInfo* ActorInfo = PriorityActors[j]->ActorInfo;
// 액터 채널 체크 -> 신기한 조건문으로 마치 Channel이 무조건 유효하다는 것을 보장해주는 것처럼 짜여져 있습니다.
UActorChannel* Channel = PriorityActors[j]->Channel;
if ( !Channel || Channel->Actor )
{
AActor* Actor = ActorInfo->Actor;
bool bIsRelevant = false;
Replicate의 패킷 송신만을 확인할 것이기 때문에 불필요한 비교문은 모두 제거했습니다.
// 연관성 체크
const bool bIsRecentlyRelevant = bIsRelevant || ( Channel && RelevantTime 비교문);
if ( bIsRecentlyRelevant )
{
//해당 엑터의 Role이 스왑되는 것을 Lock해줍니다.
TOptional<FScopedActorRoleSwap> SwapGuard;
if (ActorInfo->bSwapRolesOnReplicate)
{
SwapGuard = FScopedActorRoleSwap(Actor);
}
if ( Channel )
{
// 리플리케이션 조건 체크 -> IsNetReady함수에선 전송할 패킷의 수용량이 충분한지 확인합니다.
if ( Channel->IsNetReady( 0 ) || bIgnoreSaturation)
{
// 리플리케이션을 수행하는 실제 함수입니다.
if ( Channel->ReplicateActor() )
{
...
}
}
}
}
}
}
return ActorsIndexRange.Max;
}
ReplicateActor함수는 Replicate 값이 변경되었는지 여부 등을 판단하여 해당 프로퍼티가 변경되었다면 SendBunch함수를 호출하여 패킷을 송신하는 로직을 담고 있습니다.
int64 UActorChannel::ReplicateActor()
{
// 전송할 패킷 정보를 담은 Bunch를 생성합니다.
FOutBunch Bunch( this, 0 );
if( Bunch.IsError() )
{
return 0;
}
// 최상위 NetConnection
UNetConnection* OwningConnection = Actor->GetNetConnection();
FReplicationFlags RepFlags;
RepFlags.bNetOwner = (OwningConnection == Connection || (OwningConnection != nullptr && OwningConnection->IsA(UChildConnection::StaticClass()) && ((UChildConnection*)OwningConnection)->Parent == Connection));
// ----------------------------------------------------------
// If initial, send init data.
// ----------------------------------------------------------
//RepFlag 설정
RepFlags.bNetSimulated = (Actor->GetRemoteRole() == ROLE_SimulatedProxy);
RepFlags.bRepPhysics = Actor->GetReplicatedMovement().bRepPhysics;
RepFlags.bReplay = bReplay;
RepFlags.bClientReplay = ActorWorld->IsRecordingClientReplay();
RepFlags.bForceInitialDirty = Connection->IsForceInitialDirty();
// ----------------------------------------------------------
// Replicate Actor and Component properties and RPCs
// ---------------------------------------------------
if (!bIsNewlyReplicationPaused /* false */)
{
// The Actor
{
//업데이트 해야할 정보가 있는지 없는지 여부를 확인합니다.
//즉 여기서 프로퍼티의 변경 여부를 확인하게 됩니다.
const bool bCanSkipUpdate = ActorReplicator->CanSkipUpdate(RepFlags);
if (UE::Net::bPushModelValidateSkipUpdate || !bCanSkipUpdate)
{
//실제 리플리케이션할 프로퍼티 정보를 Bunch에 직렬화합니다.
bWroteSomethingImportant |= ActorReplicator->ReplicateProperties(Bunch, RepFlags);
}
}
}
int64 NumBitsWrote = 0;
if (bWroteSomethingImportant)
{
// Bunch의 패킷정보를 송신합니다.
FPacketIdRange PacketRange = SendBunch( &Bunch, 1 );
NumBitsWrote = Bunch.GetNumBits();
}
return NumBitsWrote;
}
마지막으로 SendBunch
는 RPC의 SendBunch와 동일하게 WINSOCK 라이브러리를 통해 비동기 sendto함수를 통해 패킷을 전송하게 됩니다. SendBunch 호출 로직은 생략하겠습니다.
Replicate 수신은 RPC와 유사하게 RecievedBunch
를 통해 호출됩니다. ReceivedBunch에선 ReceiveProperties함수를 호출하여 해당 프로퍼티를 동기화합니다.
대략적인 수신 호출 흐름은 다음과 같습니다
ReceiveProperties에서 동기화를 수행하기 위해 전달된 매개변수를 정리하면 다음과 같습니다.
ReceiveProperties에선 FReceivePropertiesSharedParams와 FReceivePropertiesStackParams의 구조체 형태의 매개변수를 통해 ReceiveProperties_r을 호출합니다.
bool FRepLayout::ReceiveProperties(
UActorChannel* OwningChannel,
UClass* InObjectClass,
FReceivingRepState* RESTRICT RepState,
UObject* Object,
FNetBitReader& InBunch,
bool& bOutHasUnmapped,
bool& bOutGuidsChanged,
const EReceivePropertiesFlags ReceiveFlags) const
{
check(InObjectClass == Owner);
FRepObjectDataBuffer Data(Object);
//RepNotify 여부
const bool bEnableRepNotifies = EnumHasAnyFlags(ReceiveFlags, EReceivePropertiesFlags::RepNotifies);
FReceivePropertiesSharedParams Params{
bDoChecksum,
// We can skip swapping roles if we're not an Actor layout, or if we've been explicitly told we can skip.
EnumHasAnyFlags(ReceiveFlags, EReceivePropertiesFlags::SkipRoleSwap) || !EnumHasAnyFlags(Flags, ERepLayoutFlags::IsActor),
InBunch,
bOutHasUnmapped,
bOutGuidsChanged,
Parents,
Cmds,
NetSerializeLayouts,
Object,
OwningChannel->Connection->GetInTraceCollector()
};
FReceivePropertiesStackParams StackParams{
FRepObjectDataBuffer(Data),
FRepShadowDataBuffer(RepState->StaticBuffer.GetData()),
&RepState->GuidReferencesMap,
0,
Cmds.Num() - 1,
bEnableRepNotifies ? &RepState->RepNotifies : nullptr
};
// Params의 InBunch에서 ReadHadle값을 읽어옵니다.
ReadPropertyHandle(Params);
// 프로퍼티 동기화를 위한 초기단계로
// FReceivePropertiesSharedParams와 FReceivePropertiesStackParams로
// 랩핑한 파라미터를 매개변수로 받는 ReceiveProperties_r함수를 호출합니다.
if (ReceiveProperties_r(Params, StackParams))
{
if (0 != Params.ReadHandle)
{
UE_LOG(LogRep, Error, TEXT("ReceiveProperties: Invalid property terminator handle - Handle=%d"), Params.ReadHandle);
return false;
}
return true;
}
return false;
}
언리얼엔진 코드를 뜯어보면서 가장 많이 보이는 부분이 해당 변수 정보들을 한번에 담고있는 래퍼 구조체가 많이 존재한다는 점입니다. 평소에 래퍼 구조체를 많이 사용하지 않아서 신선하게 다가왔습니다.
FReceivePropertiesSharedParams 구조체는 아래와 같이 수신된 패킷 정보를 처리할 다양한 데이터를 담고 있습니다.
Cmds에는 모든 Replicate프로퍼티를 담고 있으며 저희가 만든 RepAttackCount가 맨 아래 위치한 것을 확인할 수 있습니다.
FReceivePropertiesStackParams 구조체는 해당 프로퍼티의 스택정보를 담고있는 구조체입니다. 객체의 전체 메모리 버퍼, Replicate프로퍼티 Array의 시작과 끝 인덱스 등을 예로 들 수 있습니다.
ReceiveProperties_r에선 해당 프로퍼티가 배열이면 해당 배열의 요소마다 ReceiveProperties_r함수를 재귀적으로 호출하여 직렬화하게 되고 배열이 아니라면 ReceivePropertyHelper 헬퍼 함수를 호출합니다.
static bool ReceiveProperties_r(FReceivePropertiesSharedParams& Params, FReceivePropertiesStackParams& StackParams)
{
check(StackParams.GuidReferences != nullptr);
for (int32 CmdIndex = StackParams.CmdStart; CmdIndex < StackParams.CmdEnd; ++CmdIndex)
{
const FRepLayoutCmd& Cmd = Params.Cmds[CmdIndex];
check(ERepLayoutCmdType::Return != Cmd.Type);
++StackParams.CurrentHandle;
//역직렬화 핸들값이 다르면 무시합니다 -> Bunch를 구분하기 위한 식별자? 같이 보입니다.
if (StackParams.CurrentHandle != Params.ReadHandle)
{
// 배열이면 전체 배열을 무시합니다.
if (ERepLayoutCmdType::DynamicArray == Cmd.Type)
{
CmdIndex = Cmd.EndCmd - 1;
}
}
else
{
const FRepParentCmd& Parent = Params.Parents[Cmd.ParentIndex];
//해당 프로퍼티가 배열이라면
if (ERepLayoutCmdType::DynamicArray == Cmd.Type)
{
//배열 요소마다 ReceiveProperties_r 호출
}
else
{
// 프로퍼티 동기화를 처리해주는 헬퍼 함수를 호출해줍니다.
if (ReceivePropertyHelper(
Params.Bunch,
StackParams.GuidReferences,
StackParams.ArrayElementOffset,
StackParams.ShadowData,
StackParams.ObjectData,
StackParams.RepNotifies,
StackParams.bShadowDataCopied,
Params.Parents,
Params.Cmds, /* Object의 프로퍼티들을 모두 담고 있는 배열 */
CmdIndex, /* 동기화할 프로퍼티 인덱스 */
Params.bDoChecksum,
Params.bOutGuidsChanged,
Params.bSkipRoleSwap,
Params.NetSerializeLayouts,
Params.OwningObject))
{
Params.bOutHasUnmapped = true;
}
}
}
}
return true;
}
✅ Cmds는 FReceivePropertiesSharedParams의 멤버변수로 FRepLayoutCmd을 배열로 가지고 있습니다.
✅ 프로퍼티 데이터를 가지고 있는 FRepLayoutCmd 클래스는 해당 프로퍼티 혹은 동적배열 프로퍼티를 포함한 래퍼 클래스입니다.
ReceivePropertyHelper 내부에선 Cmd와 CmdIndex
정보를 통해 변경된 Property에 Bunch의 데이터를 직렬화합니다.
static bool ReceivePropertyHelper(
FNetBitReader& Bunch,
FGuidReferencesMap* GuidReferencesMap,
const int32 ElementOffset,
FRepShadowDataBuffer ShadowData,
FRepObjectDataBuffer Data,
TArray<FProperty*>* RepNotifies,
const bool bShadowDataCopied,
const TArray<FRepParentCmd>& Parents,
const TArray<FRepLayoutCmd>& Cmds,
const int32 CmdIndex,
const bool bDoChecksum,
bool& bOutGuidsChanged,
const bool bSkipSwapRoles,
const TMap<FRepLayoutCmd*, TArray<FRepLayoutCmd>>& NetSerializeLayouts,
const UObject* OwningObject)
{
const FRepLayoutCmd& Cmd = Cmds[CmdIndex];
const FRepParentCmd& Parent = Parents[Cmd.ParentIndex];
//RepNotify함수가 있는 경우
if (RepNotifies != nullptr && INDEX_NONE != Parent.RepNotifyNumParams)
{
// 프로퍼티 동기화
Cmd.Property->NetSerializeItem(Bunch, Bunch.PackageMap, Data + SwappedCmd);
// 프로퍼티가 변경되었으면 RepNotifies에 FProperty 객체 추가
if (Parent.RepNotifyCondition == REPNOTIFY_Always || !PropertiesAreIdentical(Cmd, ShadowData + Cmd, Data + SwappedCmd, NetSerializeLayouts))
{
RepNotifies->AddUnique(Parent.Property);
}
else
{
UE_CLOG(LogSkippedRepNotifies > 0, LogRep, Display, TEXT("2 FReceivedPropertiesStackState Skipping RepNotify for property %s because local value has not changed."), *Cmd.Property->GetName());
}
}
else
{
Cmd.Property->NetSerializeItem(Bunch, Bunch.PackageMap, Data + SwappedCmd);
}
}
직렬화는 NetSerializeItem함수를 통해 수행됩니다.
이로써 모든 클라이언트는 전달된 패킷을 프로퍼티에 직렬화함으로써 서버와 동일한 값을 가지게 됩니다. 모든 클라이언트에게 프로퍼티 동기화가 수행되는 것이죠.
OnRep함수는 RPC와 동작원리가 비슷합니다. RPC는 ReceivedBunch에서 전달된 패킷정보를 바탕으로 ProcessEvent를 호출합니다.
이와 유사하게 ActorChannel에서 ReceivedBunch → PostReceivedBunch → CallRepNotifies를 통해 해당 프로퍼티의 Repnotify함수를 호출하게 됩니다.
간단한 의사코드
void UActorChannel::ProcessBunch( FInBunch & Bunch )
{
...
Replicator->ReceivedBunch( Reader, RepFlags, bHasRepLayout, bHasUnmapped );
...
ObjectReplicator->PostReceivedBunch();
}
그리고 해당 Bunch에는 호출할 Repnotify함수의 이름정보를 담고 있기 때문에 CallRepNotifies에서 리플렉션 데이터에서 생성된 Property정보를 통해 OnRep함수를 호출할 수 있게 되는 것입니다.
해당 RepNotifies의 Name정보를 바탕으로 리플렉션 시스템을 통해 함수를 찾아 호출할 수 있습니다.
마지막으로 ProcessEvent로 해당 OnRep함수를 호출합니다. RPC와 굉장히 유사하다는 점을 알 수 있죠. 네트워크 지연없이 클라이언트에서 곧바로 호출되는 RPC와 비슷하다고 할 수 있겠네요.
몇 주간 개인 일정이 많아 포스팅을 할 수 있는 시간이 거의 없다시피 했습니다. 그럼에도 3부를 작성해야겠다는 생각을 놓은 적은 없습니다.
멘토님께서 RPC는 즉각적인 호출(?)이 보장된다는 말씀에서 시작된 고민이였고 이 고민이 제 머릿 속을 계속 두드렸습니다. 그래서 RPC와 Replicated를 분석해야겠다는 단순 호기심에서 시작된 생각정리 글이였습니다.
하지만 글을 작성하다보니 알아야 할 것들이 한두가지가 아니였습니다. 코드를 분석하고 디버깅하면서 이렇게까지 해야할까(글을 다 작성하고보니 4만자 가까이 나오네요..)라는 고민이 많이 들었지만 그럼에도 분석하는 과정이 즐겁기도 했습니다.
인턴시절 팀장님이 네트워크에서 클라이언트(SimulatedProxy)는 NetRole이 Swap된다는 말씀을 해주신적이 있었는데, 코드 분석 과정에서 해당 로직을 직접 볼 수 있는 뜻 깊은 시간이기도 했습니다.(해당 관련 글은 1부에서 확인할 수 있습니다)
언리얼의 네트워크 심층분석 3부작은 여기까지 마치도록 하겠습니다.
이 글이 언리얼 네트워크를 공부하는데 있어서 많은 도움이 되었으면 합니다. 잘못된 부분에 대한 피드백은 언제나 환영입니다.
긴 글 읽어주셔서 감사합니다.이번 3부는 정말 길었습니다... 내잔이였습니다.
https://forums.unrealengine.com/t/difference-between-property-replication-and-rpc-technically/755737
https://www.slideshare.net/slideshow/c20-251161090/251161090
https://ericlemes.com/2018/11/23/understanding-unreal-build-tool/
https://www.programmersought.com/article/92643809878/