0. How to Replication System Initialize
언리얼엔진에서 멀티플레이에서 서버와 클라이언트 간 통신을 할 때, 리플리케이션이 사용된다.
리플리케이션은 단순히 데이터를 복사하는 기능이 아니라, 서버에서 관리하는 게임 상태를 여러 클라이언트에 동기화하여 동일한 게임 세계를 공유하도록 만드는 핵심 시스템이다.
예를 들어, 한 플레이어가 이동하거나 공격을 수행했을 때, 이 변화는 서버에서 처리되고, 그 결과가 다른 모든 클라이언트에 전파되어야 한다. 이 과정을 담당하는 것이 한 축이 바로 리플리케이션 시스템이다.
그렇다면 이러한 시스템은 어떠한 방식으로 동작하는 걸까? 리플리케이션 시스템은 엔진의 핵심 루프인 World의 생명주기와 밀접하게 결합되어 있으며, 월드의 생성과 초기화 과정에서부터 함께 준비된다.
이때 리플리케이션 초기화는 UWorld가 생성되고 초기화되는 시점에 트리거된다. 엔진은 현재 실행 모드(Dedicated Server, Listen Server, Client)를 판단하여 적절한 네트워크 드라이버를 생성하고 연결 설정을 시작한다.
Replication Init
Unreal Engine에서 네트워크 시스템의 시작점은 UNetDriver이다. 이 객체는 월드(UWorld)가 네트워크 플레이를 시작할 때 생성된다.
다음은 엔진 소스 코드 중 UWorld::Listen의 일부이다.
bool UWorld::Listen( FURL& InURL )
{
#if WITH_SERVER_CODE
...
// 사용할 NetDriver 정의 결정 (기본은 GameNetDriver)
FName NetDriverDefinition = NAME_GameNetDriver;
if (InURL.HasOption(TEXT("NetDriverDef=")))
{
// URL 옵션을 통해 다른 NetDriver 지정 가능
NetDriverDefinition = InURL.GetOption(TEXT("NetDriverDef="), nullptr);
}
// NetDriver 생성 시도
if (GEngine->CreateNamedNetDriver(this, NAME_GameNetDriver, NetDriverDefinition))
{
// 생성된 NetDriver를 찾아서 참조 획득
NetDriver = GEngine->FindNamedNetDriver(this, NAME_GameNetDriver);
// NetDriver를 현재 World와 연결
NetDriver->SetWorld(this);
...
// 동적 레벨 컬렉션에도 NetDriver 연결
if (SourceCollection)
{
SourceCollection->SetNetDriver(NetDriver);
}
...
// 정적 레벨 컬렉션에도 NetDriver 연결
if (StaticCollection)
{
StaticCollection->SetNetDriver(NetDriver);
}
}
...
// 네트워크 수신 초기화 (핵심 단계)
if( !NetDriver->InitListen( this, InURL, bReuseAddressAndPort, Error ) )
{
// 초기화 실패 시 네트워크 오류 브로드캐스트
GEngine->BroadcastNetworkFailure(this, NetDriver, ENetworkFailure::NetDriverListenFailure, Error);
...
// 생성된 NetDriver 제거
GEngine->DestroyNamedNetDriver(this, NetDriver->NetDriverName);
NetDriver = nullptr;
...
// 연결된 LevelCollection에서도 NetDriver 제거
if (SourceCollection)
{
SourceCollection->SetNetDriver(nullptr);
}
...
if (StaticCollection)
{
StaticCollection->SetNetDriver(nullptr);
}
return false;
}
...
// 서버 전환 이후 Travel 관련 설정
NextSwitchCountdown = NetDriver->ServerTravelPause;
// 성공적으로 네트워크 서버 상태로 전환
return true;
#else
return false;
#endif // WITH_SERVER_CODE
}1. NetDriver
NetDriver는에서는 엔진의 메인 루프에서 두 가지 중요한 틱 단계를 거치며 데이터를 처리한다.
1-1. TickDispatch (수신 단계)
매 프레임 시작 시 호출되며, 네트워크로부터 들어온 패킷을 처리하는 단계이다. 이 단계에서는 소켓에서 데이터를 읽고, 적절한 Connection으로 분배하는 역할을 수행한다.
void UNetDriver::TickDispatch( float DeltaTime )
{
SendCycles=0;
// 실시간 경과 시간 계산 및 히치 감지
{
const double CurrentRealtime = FPlatformTime::Seconds();
const float DeltaRealtime = CurrentRealtime - LastTickDispatchRealtime;
LastTickDispatchRealtime = CurrentRealtime;
constexpr float TickLogThreshold = 5.0f;
bDidHitchLastFrame = (DeltaTime > TickLogThreshold || DeltaRealtime > TickLogThreshold);
}
// 누적 시간 업데이트
ElapsedTime += DeltaTime;
// 수신된 Bunch 처리 시간 초기화
IncomingBunchProcessingElapsedFrameTimeMS = 0.0f;
...
if (ServerConnection == nullptr)
{
// 클라이언트 연결 정리 및 상태 업데이트
for (int32 ConnIdx=ClientConnections.Num()-1; ConnIdx>=0; ConnIdx--)
{
UNetConnection* CurConn = ClientConnections[ConnIdx];
if (IsValid(CurConn))
{
if (CurConn->GetConnectionState() == USOCK_Closed)
{
// 종료된 연결 정리
CurConn->CleanUp();
}
else
{
// 연결별 수신 준비
CurConn->PreTickDispatch();
}
}
}
...
}
else if (IsValid(ServerConnection))
{
// 서버 Connection 수신 준비
ServerConnection->PreTickDispatch();
}
}1-2. PostTickDispatch (송신 단계)
월드의 모든 액터 틱이 끝난 후 호출되며, 리플리케이션 데이터를 실제로 구성하고 전송하는 단계이다.
void UNetDriver::PostTickDispatch()
{
...
// 서버 Connection 송신 후처리
if (ServerConnection != nullptr)
{
if (IsValid(ServerConnection))
{
ServerConnection->PostTickDispatch();
}
}
// 모든 클라이언트 Connection에 대해 송신 후처리 수행
TArray<UNetConnection*> ClientConnCopy = ClientConnections;
for (UNetConnection* CurConn : ClientConnCopy)
{
if (IsValid(CurConn))
{
CurConn->PostTickDispatch();
}
}
// 실제 패킷 전송 트리거
PostDispatchSendUpdate();
// ReplicationDriver 후처리
if (ReplicationDriver)
{
ReplicationDriver->PostTickDispatch();
}
...
}1-3. PostDispatchSendUpdate (Iris Replication)
이 시점에서 실제 송신 경로는 ReplicationSystem(Iris) 혹은 ServerReplicateActors(Legacy)로 분기된다.
기존의 Actor 단위 전송 방식과 달리, Iris에서는 ReplicationSystem이 중심이 되어 전송할 데이터를 구성하고, 이를 Connection 단위로 전달하는 구조를 가진다.
void UNetDriver::PostDispatchSendUpdate()
{
// Iris Replication이 활성화되어 있지 않으면 아무 작업도 수행하지 않음
if (!ReplicationSystem)
{
return;
}
// Iris는 ActorChannel이 아닌 DataStreamChannel을 사용하기 때문에 해당 채널이 존재하는지 확인
if (!IsKnownChannelName(NAME_DataStream))
{
return;
}
// 이전 프레임에서 수신된 데이터에 대한 후처리 수행
// (수신 상태 정리 및 내부 상태 업데이트)
ReplicationSystem->TickPostReceive();
// 실제로 전송할 데이터를 구성하고, Connection 단위로 분배하는 단계
// Legacy처럼 Actor를 순회하지 않고, 이미 구성된 데이터 스트림을 기반으로 전송함
ReplicationSystem->SendUpdate([this](TArrayView<uint32> ConnectionsToSend)
{
const int32 DataStreamChannelIndex = ChannelDefinitionMap[NAME_DataStream].StaticChannelIndex;
// 전송 대상 Connection 목록을 순회
for (uint32 ConnId : ConnectionsToSend)
{
// Connection 핸들을 통해 실제 UNetConnection 획득
UNetConnection* NetConnection = GetConnectionByHandle(UE::Net::FConnectionHandle(ConnId));
// 해당 Connection에 DataStreamChannel이 존재하는지 확인
if (NetConnection && NetConnection->Channels.IsValidIndex(DataStreamChannelIndex))
{
// DataStreamChannel을 통해 실제 전송 수행
if (UDataStreamChannel* DataStreamChannel = Cast<UDataStreamChannel>(NetConnection->Channels[DataStreamChannelIndex]))
{
// 이 호출 시점에서 실제 패킷 전송이 트리거됨
DataStreamChannel->PostTickDispatch();
}
}
}
});
// 송신 이후 상태 정리 및 내부 상태 업데이트
ReplicationSystem->PostSendUpdate();
}함수의 흐름을 살펴보면, 먼저 ReplicationSystem이 활성화되어 있는지 확인한 뒤 DataStreamChannel이 존재하는지를 검증한다. 이는 Iris가 ActorChannel이 아닌 별도의 DataStreamChannel을 통해 데이터를 전송하기 때문이다.
따라서 Iris 기반 리플리케이션은 Actor 중심의 반복 처리 구조가 아니라, 데이터 스트림 기반으로 구성된 결과를 Connection에 분배하는 방식으로 동작한다.
1-4. ServerReplicateActors (Legacy Replication)
기존(legacy) 리플리케이션 시스템은 ServerReplicateActors를 중심으로 동작한다.
이 방식에서는 서버가 매 프레임마다 모든 Connection을 순회하며 각 클라이언트에 대해 어떤 Actor를 전송할지 직접 계산하고, 그 결과를 기반으로 리플리케이션을 수행한다.
int32 UNetDriver::ServerReplicateActors(float DeltaSeconds)
{
...
// ReplicationDriver가 존재하면 해당 구현으로 위임
// (커스텀 Replication 시스템이 있는 경우)
if (ReplicationDriver)
{
return ReplicationDriver->ServerReplicateActors(DeltaSeconds);
}
...
// 모든 Connection을 순회하며 각 클라이언트에 대해 Replication 수행
for ( int32 i=0; i < ClientConnections.Num(); i++ )
{
UNetConnection* Connection = ClientConnections[i];
check(Connection);
// Dormancy 검증 (디버그/검증용)
// Dormant 상태의 Actor가 마지막 상태와 일치하는지 확인
if ( GNetDormancyValidate == 2 )
{
auto ValidateFunction = [](FObjectKey OwnerActorKey, FObjectKey ObjectKey, const TSharedRef<FObjectReplicator>& ReplicatorRef)
{
FObjectReplicator& Replicator = ReplicatorRef.Get();
// Actor 및 SubObject 상태 검증
if (Replicator.OwningChannel != nullptr)
{
Replicator.ValidateAgainstState(Replicator.OwningChannel->GetActor());
}
};
Connection->ExecuteOnAllDormantReplicators(ValidateFunction);
}
// 이번 프레임에서 이 Connection을 업데이트하지 않는 경우 (부하 분산)
if (i >= NumClientsToTick)
{
// 아직 전송되지 않은 Actor들을 다음 프레임에서 다시 고려할 수 있도록 설정
for (int32 ConsiderIdx = 0; ConsiderIdx < CurrentConsiderList.Num(); ConsiderIdx++)
{
AActor* Actor = CurrentConsiderList[ConsiderIdx]->Actor;
// 이미 다른 Connection에서 처리되지 않은 경우
if (Actor != NULL && !CurrentConsiderList[ConsiderIdx]->bPendingNetUpdate)
{
// 해당 Actor에 대한 Channel 찾기
UActorChannel* Channel = Connection->FindActorChannelRef(CurrentConsiderList[ConsiderIdx]->WeakActor);
// Actor의 최신 상태가 아직 전송되지 않았다면
if (Channel != NULL && Channel->LastUpdateTime < CurrentConsiderList[ConsiderIdx]->LastNetUpdateTimestamp)
{
// 다음 프레임에서 전송되도록 플래그 설정
CurrentConsiderList[ConsiderIdx]->bPendingNetUpdate = true;
}
}
}
// 불필요한 추가 패킷 전송 방지
Connection->TimeSensitive = false;
}
// 실제 Replication 수행 구간
else if (Connection->ViewTarget)
{
// Connection 기준 Replication 파라미터 구성
UE::Net::FServerReplicateActors_ForConnectionParams Params =
{
.Connection = Connection,
.ConnectionViewers = WorldSettings->ReplicationViewers,
.DeltaSeconds = DeltaSeconds,
.ConsiderList = CurrentConsiderList,
.InOutUpdated = Updated,
.bCPUSaturated = bCPUSaturated
};
// Transactional 모드 여부에 따라 실행 방식 분기
if (bUseGranularTransactions)
{
const FName TransactionalWorkName = Connection->ViewTarget.GetFName();
// 작업을 큐에 넣고 나중에 실행 (비동기/분산 처리)
UE::RemoteExecutor::EnqueueWork(TransactionalWorkName, true, [this, ConstParams = Params]()
{
UE::Net::FServerReplicateActors_ForConnectionParams Params = ConstParams;
// Connection 하나에 대한 실제 Replication 수행
ServerReplicateActors_ForConnection(Params);
});
}
else
{
// 동기적으로 Replication 수행
ServerReplicateActors_ForConnection(Params);
}
}코드에서 볼 수 있듯이, 먼저 각 Connection에 대해 해당 프레임에서 실제로 업데이트를 수행할지 여부를 판단한다.
실제로 리플리케이션이 수행되는 구간은 ServerReplicateActors_ForConnection 호출 부분이다.
이 함수에서는 특정 Connection을 기준으로 해당 클라이언트에게 필요한 Actor를 선별하고, Relevancy, Priority 등의 기준을 적용하여 실제로 전송할 Actor를 결정하게 된다.
따라서 legacy 방식에서는 Actor를 기준으로 전송 대상을 선별하고 Connection마다 반복적으로 계산을 수행하며 그 결과를 즉시 네트워크로 전송하는 구조를 가진다.
1-5. Iris vs Legacy Replication 비교
| 구분 | Iris Replication | Legacy Replication |
|---|---|---|
| 처리 기준 | 데이터 중심 (Data) | Actor 중심 |
| 처리 방식 | 데이터 구성 → Connection 분배 | Actor 순회 → Connection별 계산 |
| 송신 구조 | DataStreamChannel | ActorChannel |
| 핵심 함수 | ReplicationSystem::SendUpdate() | ServerReplicateActors() |
2. Summary
리플리케이션이 동작하기 위해 먼저 월드 초기화 단계에서는
- NetDriver를 생성
- World와 연결
을 통해 네트워크 환경을 준비했다.
이후 생성된 NetDriver에서는
TickDispatch를 통해 패킷을 수신하고 Connection에 분배PostTickDispatch를 통해 송신을 위한 데이터를 구성하고 전송을 트리거
했다.
이 과정에서 실제 리플리케이션 수행 방식에 따라
- Iris의 경우
ReplicationSystem을 통해 데이터 중심으로 전송되거나 - Legacy의 경우
ServerReplicateActors를 통해 Actor 중심으로 계산되어 전송되었다.
이를 통해 NetDriver는
- 네트워크 입출력의 흐름을 제어
- 리플리케이션 시스템(Iris / Legacy)을 적절히 호출
- 각 Connection에 데이터를 전달하는 전체 파이프라인을 총괄하는 역할을 담당
등의 기능을 수행하고 있다.
NetDriver가 구성한 이 데이터는 실제로 어떤 경로를 통해 클라이언트와 주고받게 될까?
이러한 내용은 UNetConnection의 내부 동작을 통해 알아보도록 하겠다.
참고 자료
https://dev.epicgames.com/documentation/unreal-engine/detailed-actor-replication-flow?application_version=4.27 https://youtu.be/JOJP0CvpB8w?si=TFLhKqHtA0VvLnac