Blog

ENGINEERING NOTE

[Unreal Engine] Network DeepDive 3 - Channel

이전 단계에서 UNetConnection이 네트워크 패킷을 송수신하는 과정을 살펴보았다.하지만 실제로 리플리케이션 될 게임 데이터는 Connection이 직접 처리하지 않는다. 이러한 데이터는 Channel이라는 단위를 통해 분리되고 관리된다.이 과정에서 Connect

Unreal Engine

1. Channel

이전 단계에서 UNetConnection이 네트워크 패킷을 송수신하는 과정을 살펴보았다. 하지만 실제로 리플리케이션 될 게임 데이터는 Connection이 직접 처리하지 않는다. 이러한 데이터는 Channel이라는 단위를 통해 분리되고 관리된다.

이 과정에서 Connection은 다음과 같이 Channel을 초기화하고 매 프레임마다 갱신한다.

cpp
InitChannelData(); // Connection 생성 시 Channel 구조 초기화

...

for (int32 i = ChannelsToTick.Num()-1; i>=0; i--)
{
	ChannelsToTick[i]->Tick();
    
    ...
}

정리하자면 Connection이 하나의 네트워크 통신 단위라면, Channel은 그 내부에서 동작하는 데이터 처리 및 전송 단위라고 볼 수 있다.

1-1. Bunch

Channel 내부에서 실제로 전송되는 데이터의 최소 단위는 Bunch이다. UNetConnection이 패킷(Packet)을 구성하여 송수신한다면, Channel은 그 내부에서 데이터를 Bunch 단위로 나누어 처리한다.

구조적으로는 다음과 같이 쓰인다고 볼 수 있다.

js
Packet (UNetConnection)
   └── Bunch (UChannel)
           └── Data (RPC / Actor State / Property 등)

Bunch는 네트워크 전송을 위한 다양한 상태 정보를 함께 포함하고 있다.

cpp
FInBunch::FInBunch( UNetConnection* InConnection, uint8* Src, int64 CountBits )
:	FNetBitReader	(InConnection->PackageMap, Src, CountBits)
,	PacketId	( 0 )
,	Next ( nullptr )
,	Connection ( InConnection )
,	ChIndex ( 0 )
,	ChName ( NAME_None )
,	ChSequence ( 0 )
,	bOpen ( 0 )
,	bClose ( 0 )
,	bIsReplicationPaused( 0 )
,	bReliable ( 0 )
,	bPartial ( 0 )
,	bPartialInitial ( 0 )
,	bPartialFinal ( 0 )
,	bHasPackageMapExports ( 0 )
,	bHasMustBeMappedGUIDs ( 0 )
,	bPartialCustomExportsFinal( 0 )
,	bIgnoreRPCs ( 0 )
,	CloseReason( EChannelCloseReason::Destroyed )


FOutBunch::FOutBunch( UChannel* InChannel, bool bInClose )
:	FNetBitWriter	( InChannel->Connection->PackageMap, InChannel->Connection->GetMaxSingleBunchSizeBits())
,	Next		( nullptr )
,	Channel		( InChannel )
,	Time		( 0 )
,	ChIndex		( InChannel->ChIndex )
,	ChName		( InChannel->ChName )
,	ChSequence	( 0 )
,	PacketId	( 0 )
,	ReceivedAck	( 0 )
,	bOpen		( 0 )
,	bClose		( bInClose )
,	bIsReplicationPaused	( 0 )
,	bReliable	( 0 )
,	bPartial	( 0 )
,	bPartialInitial			( 0 )
,	bPartialFinal			( 0 )
,	bHasPackageMapExports	( 0 )
,	bHasMustBeMappedGUIDs	( 0 )
,	bPartialCustomExportsFinal	( 0 )
,	bOutWantsFullInitState	( 0 )
,	CloseReason( EChannelCloseReason::Destroyed )

Bunch의 이러한 속성을 통해 다음과 같은 기능을 수행할 수 있다.

  • Reliable 보장 여부
  • Channel 내부 순서 보장
  • 분할 패킷 여부
  • Channel 상태 제어 및 분리

1-2. ReceivedRawBunch

ReceivedRawBunch()는 Channel 단위에서 수신된 Bunch를 처리하는 진입점으로 신뢰성 처리를 담당한다.

특히 Reliable 전송의 경우, 네트워크 지연이나 패킷 순서 뒤바뀜(out-of-order)을 고려하여 즉시 처리하지 않고 내부 큐에 저장한 뒤, 순서가 맞는 시점에 처리하는 구조를 가진다.

cpp
void UChannel::ReceivedRawBunch( FInBunch & Bunch, bool & bOutSkipAck )
{
	// Channel 단위 수신 처리 시작

	// 1. NetGUID (Object Mapping) 먼저 처리
	if ( Bunch.bHasPackageMapExports && !Connection->IsInternalAck() )
	{
		// Actor / Object 식별을 위한 NetGUID 데이터 처리
		Cast<UPackageMapClient>( Connection->PackageMap )->ReceiveNetGUIDBunch( Bunch );

		if ( Bunch.IsError() )
		{
			// GUID 처리 실패 → 연결 종료
			...
			return;
		}
	}

	// Replay 또는 깨진 Channel 처리
	if ( Connection->IsInternalAck() && Broken )
	{
		if (Bunch.bClose && (!Bunch.bPartial || Bunch.bPartialFinal))
		{
			// Channel 종료 처리
			ConditionalCleanUp(true, Bunch.CloseReason);
		}
		return;
	}

	// 현재 Channel과 일치하는지 검증
	check(Connection->Channels[ChIndex]==this);


	// 2. Reliable 순서 검사
	if ( Bunch.bReliable && Bunch.ChSequence != Connection->InReliable[ChIndex] + 1 )
	{
		// 아직 도착하지 않은 이전 패킷이 있음 → 큐에 저장

		// 순서 기반 삽입 (정렬된 큐)
		FInBunch** InPtr;
		...

		// 큐에 삽입
		FInBunch* New = new FInBunch(Bunch);
		New->Next     = *InPtr;
		*InPtr        = New;
		NumInRec++;

		// 큐 overflow → 오류 처리
		if ( NumInRec >= RELIABLE_BUFFER )
		{
			Bunch.SetError();
			...
			return;
		}
	}
	else
	{
		// 3. 정상 순서 → 즉시 처리
		bool bDeleted = ReceivedNextBunch( Bunch, bOutSkipAck );

		...

		// 4. 큐에 쌓여있던 Bunch 처리 (순서 맞으면 순차 실행)
		while( InRec )
		{
			// 다음 순서가 아니면 중단
			if( InRec->ChSequence!=Connection->InReliable[ChIndex]+1 )
				break;

			// 큐에서 꺼냄
			FInBunch* Release = InRec;
			InRec = InRec->Next;
			NumInRec--;

			Release->Next = nullptr;

			bool bLocalSkipAck = false;

			// 실제 처리
			bDeleted = ReceivedNextBunch( *Release, bLocalSkipAck );

			...
		}
	}
}

1-3. ReceivedNextBunch

ReceivedNextBunch()는 채널 단위에서 데이터의 순서를 확정하고, 필요하다면 여러 조각으로 나뉜 Bunch를 하나로 재조립한 뒤, 최종적으로 실제 게임 로직에서 처리 가능한 형태로 넘겨주는 단계이다.

여기서는

  • Reliable 메시지의 순서 보장
  • Partial Bunch의 재조립
  • Channel Open 상태 검증

등을 수행하며, 이렇게 정리된 데이터를 ReceivedSequencedBunch()ReceivedBunch()로 전달하여 Actor 리플리케이션이나 RPC 실행으로 이어지게 한다.

cpp
bool UChannel::ReceivedNextBunch( FInBunch & Bunch, bool & bOutSkipAck )
{
	// 이 시점의 보장 상태
	// - Reliable이라면 순서는 맞음
	// - Partial 여부는 아직 확정되지 않음
	// → 최종적으로 처리 가능한 Bunch 형태로 만드는 단계

	// 1. Reliable Sequence 갱신
	if ( Bunch.bReliable )
	{
		check( Bunch.ChSequence == Connection->InReliable[Bunch.ChIndex] + 1 );
		Connection->InReliable[Bunch.ChIndex] = Bunch.ChSequence;
	}

	FInBunch* HandleBunch = &Bunch;

	// 2. Partial Bunch 처리 (핵심)
	if (Bunch.bPartial)
	{
		HandleBunch = NULL;

		// 2-1. Partial 시작
		if (Bunch.bPartialInitial)
		{
			// 기존 Partial이 남아있으면 정리
			if (InPartialBunch != NULL)
			{
				...
				delete InPartialBunch;
				InPartialBunch = NULL;
			}

			// 새로운 Partial 생성
			InPartialBunch = new FInBunch(Bunch, false);

			if ( InPartialBunch!=nullptr )
			{
				// 실제 데이터 append
				if ( !Bunch.bHasPackageMapExports && Bunch.GetBitsLeft() > 0 )
				{
					// byte align 검증
					if ( !Bunch.bPartialCustomExportsFinal && Bunch.GetBitsLeft() % 8 != 0 )
					{
						Bunch.SetError();
						...
						return false;
					}

					InPartialBunch->AppendDataFromChecked( Bunch.GetDataPosChecked(), Bunch.GetBitsLeft() );
				}
			}
		}
		else
		{
			// 2-2. Partial 이어붙이기 (Merge)

			bool bSequenceMatches = false;

			if (InPartialBunch)
			{
				const bool bReliableMatch = Bunch.ChSequence == InPartialBunch->ChSequence + 1;
				const bool bUnreliableMatch = bReliableMatch || (Bunch.ChSequence == InPartialBunch->ChSequence);

				// Reliable은 strict sequence
				// Unreliable은 같은 sequence 허용
				bSequenceMatches = InPartialBunch->bReliable ? bReliableMatch : bUnreliableMatch;
			}

			// 정상 merge 조건
			if ( InPartialBunch && !InPartialBunch->bPartialFinal && bSequenceMatches && InPartialBunch->bReliable == Bunch.bReliable )
			{
				// 데이터 이어붙이기
				if ( !Bunch.bHasPackageMapExports && Bunch.GetBitsLeft() > 0 )
				{
					InPartialBunch->AppendDataFromChecked( Bunch.GetDataPosChecked(), Bunch.GetBitsLeft() );
				}

				// 중간 chunk는 byte align 보장
				if (!Bunch.bHasPackageMapExports && !Bunch.bPartialCustomExportsFinal && !Bunch.bPartialFinal && (Bunch.GetBitsLeft() % 8 != 0))
				{
					Bunch.SetError();
					...
					return false;
				}

				// sequence 진행
				InPartialBunch->ChSequence = Bunch.ChSequence;

				// 2-3. Partial 완료
				if (Bunch.bPartialFinal)
				{
					if ( Bunch.bHasPackageMapExports )
					{
						Bunch.SetError();
						...
						return false;
					}

					// 이제 하나의 완전한 Bunch로 처리 가능
					HandleBunch = InPartialBunch;

					InPartialBunch->bPartialFinal = true;
					InPartialBunch->bClose = Bunch.bClose;
					InPartialBunch->CloseReason = Bunch.CloseReason;
					InPartialBunch->bHasMustBeMappedGUIDs = Bunch.bHasMustBeMappedGUIDs;
				}
			}
			else
			{
				// Merge 실패 → Partial 폐기
				bOutSkipAck = true;

				...
				if (InPartialBunch)
				{
					delete InPartialBunch;
					InPartialBunch = NULL;
				}
			}
		}

		// 2-4. 크기 제한 검사
		if (InPartialBunch && IsBunchTooLarge(Connection, InPartialBunch))
		{
			Bunch.SetError();
			...
			return false;
		}

		// 2-5. Custom Export 처리 (NetGUID 확장 데이터)
		if (!bOutSkipAck && Bunch.bPartialCustomExportsFinal)
		{
			if (InPartialBunch && !Connection->IsInternalAck())
			{
				CastChecked<UPackageMapClient>(Connection->PackageMap)->ReceiveCustomExportsBunch(*InPartialBunch);

				if (InPartialBunch->IsError())
				{
					...
					return false;
				}

				// export 제거 후 순수 데이터만 유지
				InPartialBunch->ResetData(*InPartialBunch, 0, InPartialBunch->GetNumBits());
				InPartialBunch->bPartialCustomExportsFinal = false;
			}
		}
	} // bPartial

	// 3. 최종 처리 단계
	if ( HandleBunch != NULL )
	{
		const bool bBothSidesCanOpen = ...;

		// 3-1. Channel Open 처리
		if ( HandleBunch->bOpen )
		{
			...
			OpenPacketId.First = HandleBunch->PacketId;
			OpenPacketId.Last = Bunch.PacketId;
			OpenAcked = true;
		}

		// 3-2. Channel open 이전 패킷 차단
		if ( !bBothSidesCanOpen )
		{
			if ( !OpenedLocally && !OpenAcked )
			{
				if ( HandleBunch->bReliable )
				{
					Bunch.SetError();
					...
					return false;
				}

				bOutSkipAck = true;
				return false;
			}

			check( OpenPacketId.First != INDEX_NONE );
			check( OpenPacketId.Last != INDEX_NONE );
		}

		// 3-3. 최종 처리 (핵심)
        // ReceivedSequencedBunch는 ReceivedBunch를 호출한다.
		return ReceivedSequencedBunch( *HandleBunch );
	}

	return false;
}

1-4. ReceivedBunch

ActorChannel::ReceivedBunch()는 채널로 들어온 Actor 단위 데이터를 즉시 처리할 수 있는지 판단하고, 가능할 때만 실제 리플리케이션 처리로 넘기는 역할을 한다.

cpp
void UActorChannel::ReceivedBunch( FInBunch & Bunch )
{
	...

	TArray<TPair<FNetworkGUID, UObject*>> QueuedObjectsToTrack;

	// 1. 서버 / 클라이언트별 MustBeMappedGUID 처리
	if (Connection->Driver->IsServer())
	{
		if (Bunch.bHasMustBeMappedGUIDs)
		{
			// 클라이언트가 이 플래그를 보내는 것은 잘못된 경우
			Bunch.SetError();
			...
			return;
		}
	}
	else
	{
		if (Bunch.bHasMustBeMappedGUIDs)
		{
			// 반드시 매핑되어야 하는 GUID 목록 읽기
			uint16 NumMustBeMappedGUIDs = 0;
			Bunch << NumMustBeMappedGUIDs;

			for (int32 i = 0; i < NumMustBeMappedGUIDs; i++)
			{
				FNetworkGUID NetGUID;
				Bunch << NetGUID;

				// GUID → UObject 매핑 확인
				if (...)
				{
					// 이미 로드된 경우 → 추적
					QueuedObjectsToTrack.Emplace(NetGUID, Object);
				}
				else if (...)
				{
					// 아직 로드되지 않음 → resolve 대기
					PendingGuidResolves.Add(NetGUID);

					// 이후 Tick에서 재처리하도록 등록
					Connection->StartTickingChannel(this);

					QueuedBunchObjectReferences.Add(...);
				}
				else
				{
					// 등록되지 않은 GUID → 오류
					Bunch.SetError();
					...
					return;
				}
			}
		}

		// 2. Actor 생성(Open) 시 GUID 미리 확인
		if (Actor == NULL && Bunch.bOpen)
		{
			// ActorNetGUID 미리 추출 (peek)
			...
			if (Connection->IsInternalAck())
			{
				Connection->NotifyActorNetGUID(this);
			}
		}

		// 3. 즉시 처리 불가능한 경우 → 큐잉
		if (PendingGuidResolves.Num() > 0 || QueuedBunches.Num() > 0 || ... )
		{
			// 아직 필요한 객체가 준비 안됨 → 나중에 처리

			if (QueuedBunches.Num() == 0)
			{
				QueuedBunchStartTime = FPlatformTime::Seconds();
			}

			QueuedBunches.Add(new FInBunch(Bunch));

			// 이후 Tick에서 다시 처리
			Connection->StartTickingChannel(this);

			// PackageMap에 큐 상태 등록
			if (UPackageMapClient* PackageMapClient = Cast<UPackageMapClient>(Connection->PackageMap))
			{
				PackageMapClient->SetHasQueuedBunches(ActorNetGUID, true);
			}

			// GUID 추적 등록
			for (TPair<FNetworkGUID, UObject*>& NetGUIDObjectPair : QueuedObjectsToTrack)
			{
				QueuedBunchObjectReferences.Add(...);
			}

			return;
		}
	}

	// 4. 모든 조건 만족 → 실제 처리
	ProcessBunch(Bunch);
}

1-5. ProcessBunch / ProcessBunchInternal

ProcessBunchInternal()은 앞 단계에서 정합성이 보장된 Bunch를 받아, Actor 생성(또는 기존 Actor 바인딩)부터 시작해 실제 리플리케이션 데이터를 적용하는 핵심 처리 단계이다.

채널에 Actor가 없는 경우에는 NetGUID를 기반으로 Actor를 생성하거나 복원하고, 이후에는 Content Block 단위로 데이터를 읽어 FObjectReplicator를 통해 프로퍼티 동기화와 RPC 실행을 수행한다.

마지막으로 새로 생성된 Actor의 경우 PostNetInit()을 호출해 네트워크 기반 초기화를 완료한다.

cpp
void UActorChannel::ProcessBunchInternal(FInBunch & Bunch)
{
	if ( Broken )
	{
		return; // 채널이 이미 망가진 상태면 처리 중단
	}

	// 프레임당 네트워크 처리 시간 측정 (성능 제한용)
	uint64 StartTimeCycles = FPlatformTime::Cycles64();
	ON_SCOPE_EXIT
	{
		if (Connection->Driver->GetIncomingBunchFrameProcessingTimeLimit() > 0.0f)
		{
			...
		}
	};

	FReplicationFlags RepFlags;

	// 1. Actor 초기 생성 / 바인딩 단계
	bool bSpawnedNewActor = false;

	if( Actor == NULL )
	{
		// 아직 Actor가 없는 채널 → 반드시 Open 패킷이어야 함
		if( !Bunch.bOpen )
		{
			...
			return;
		}

		// NetGUID 기반 Actor 생성 or 찾기
		AActor* NewChannelActor = NULL;
		bSpawnedNewActor = Connection->PackageMap->SerializeNewActor(Bunch, this, NewChannelActor);

		// 생성 실패
		if (!IsValid(NewChannelActor))
		{
			// 이미 파괴된 Actor에 대한 close일 수도 있음
			if (!bSpawnedNewActor && Bunch.bReliable && Bunch.bClose && Bunch.AtEnd())
			{
				SetChannelActor(nullptr, ESetChannelActorFlags::None);
				return;
			}

			// 동기화 실패 → 채널 Broken
			Broken = 1;

			// 서버에 실패 알림
			if (!Connection->IsInternalAck() ...)
			{
				FNetControlMessage<NMT_ActorChannelFailure>::Send(Connection, ChIndex);
			}
			return;
		}
		else
		{
			// TearOff Actor 예외 처리
			if (UE::Net::bDiscardTornOffActorRPCs && NewChannelActor->GetTearOff())
			{
				Broken = 1;

				if (!Connection->IsInternalAck() ...)
				{
					FNetControlMessage<NMT_ActorChannelFailure>::Send(Connection, ChIndex);
				}
				return;
			}
		}

		// 채널에 Actor 바인딩
		ESetChannelActorFlags Flags = ESetChannelActorFlags::None;
		if (GSkipReplicatorForDestructionInfos != 0 && Bunch.bClose && Bunch.AtEnd())
		{
			Flags |= ESetChannelActorFlags::SkipReplicatorCreation;
		}

		SetChannelActor(NewChannelActor, Flags);

		// Channel Open 이벤트
		NotifyActorChannelOpen(Actor, Bunch);

		// 최초 replication 플래그
		RepFlags.bNetInitial = true;

		// 초기 상태 적용
		Actor->CustomTimeDilation = CustomTimeDilation;
	}
	else
	{
		// 이미 Actor가 존재 → 일반 업데이트
		...
	}

	// 2. Replication 상태 설정
	bool bLatestIsReplicationPaused = Bunch.bIsReplicationPaused != 0;
	if (bLatestIsReplicationPaused != IsReplicationPaused())
	{
		Actor->OnReplicationPausedChanged(bLatestIsReplicationPaused);
		SetReplicationPaused(bLatestIsReplicationPaused);
	}

	// Owner 여부 설정
	UNetConnection* ActorConnection = Actor->GetNetConnection();
	if (ActorConnection == Connection || ...)
	{
		RepFlags.bNetOwner = true;
	}

	RepFlags.bIgnoreRPCs = Bunch.bIgnoreRPCs;
	RepFlags.bSkipRoleSwap = bSkipRoleSwap;

	// 3. Content Block 처리 (핵심 루프)
	while ( !Bunch.AtEnd() && Connection != NULL && Connection->GetConnectionState() != USOCK_Closed )
	{
		FNetBitReader Reader( Bunch.PackageMap, 0 );
		bool bHasRepLayout = false;

		// 하나의 Object 단위 데이터 읽기
		UObject* RepObj = ReadContentBlockPayload( Bunch, Reader, bHasRepLayout );

		// 읽기 실패
		if ( Bunch.IsError() )
		{
			if ( Connection->IsInternalAck() )
			{
				Broken = 1;
				break;
			}

			Connection->Close(...);
			return;
		}

		// 빈 블록 (삭제/생성 등)
		if ( Reader.GetNumBits() == 0 )
		{
			continue;
		}

		// Object 찾기 실패
		if ( !IsValid(RepObj) )
		{
			if ( !IsValid(Actor) )
			{
				Broken = 1;
			}
			continue;
		}

		// 3-1. Replicator 처리 (핵심)
		TSharedRef< FObjectReplicator > & Replicator = FindOrCreateReplicator( RepObj );

		bool bHasUnmapped = false;

		// 실제 프로퍼티 / RPC 적용
		if ( !Replicator->ReceivedBunch( Reader, RepFlags, bHasRepLayout, bHasUnmapped ) )
		{
			if ( Connection->IsInternalAck() )
			{
				Broken = 1;
				continue;
			}

			Connection->Close(...);
			return;
		}

		// Actor가 처리 중 파괴된 경우
		if ( !IsValid(Actor) )
		{
			Broken = 1;
			break;
		}

		// 아직 매핑 안된 데이터 → 나중에 재처리
		if ( bHasUnmapped )
		{
			Connection->Driver->UnmappedReplicators.Add( &Replicator.Get() );
		}
	}

	// 4. Replicator 후처리
	for (auto RepComp = ReplicationMap.CreateIterator(); RepComp; ++RepComp)
	{
		TSharedRef<FObjectReplicator>& ObjectReplicator = RepComp.Value();

		if (!IsValid(ObjectReplicator->GetObject()))
		{
			if (!Connection->Driver->IsServer())
			{
				RepComp.RemoveCurrent();
			}
			continue;
		}

		// 한 프레임 처리 완료 후 후처리
		ObjectReplicator->PostReceivedBunch();
	}

	...

	// 5. Actor 초기화 완료 (BeginPlay 트리거)
	if (Actor && bSpawnedNewActor)
	{
		Actor->PostNetInit(); // 네트워크 기반 BeginPlay
	}
}

void UActorChannel::ProcessBunch(FInBunch& Bunch)
{
	// Transactional 실행 여부에 따라 wrapper 처리
	if (Connection && Connection->Driver && Connection->Driver->ShouldReplicateTransactionally())
	{
		...
	}
	else
	{
		ProcessBunchInternal(Bunch); // 실제 로직
	}
    
    마지막으로 새로 생성된 Actor의 경우 PostNetInit()을 호출해 네트워크 기반 초기화(실질적인 BeginPlay)를 완료한다.
}

1-6. ReplicateActor

ReplicateActor()에서는 서버에서 Actor의 현재 상태를 평가하고, 변경된 프로퍼티 및 RPC, SubObject 정보를 Bunch에 직렬화하여 클라이언트로 전송하는 함수이다.

cpp
int64 UActorChannel::ReplicateActor()
{
	using namespace UE::Net;
	using namespace UE::Net::Private;

	...

	// 1. Outgoing Bunch 생성
	FOutBunch Bunch(this, 0);

	UE::Net::FNetTokenExportScope NetTokenExportScope(...);

	if (Bunch.IsError())
	{
		return 0;
	}

	// Subsystem 캐싱
	DataChannelInternal::CachedNetworkSubsystem = ActorWorld->GetSubsystem<UNetworkSubsystem>();
	ON_SCOPE_EXIT
	{
		DataChannelInternal::CachedNetworkSubsystem = nullptr;
	};

	// Replication Pause 상태라면 Reliable로 전송
	if (bIsNewlyReplicationPaused)
	{
		Bunch.bReliable = true;
		Bunch.bIsReplicationPaused = true;
	}

	...

	FGuardValue_Bitfield(bIsReplicatingActor, true);
	FScopedRepContext RepContext(Connection, Actor);

	FReplicationFlags RepFlags;

	// 2. 초기 Actor 생성 데이터 처리
	if (OpenPacketId.First != INDEX_NONE && ...)
	{
		if (!SpawnAcked && OpenAcked)
		{
			// Spawn 이후 unreliable 데이터 재전송 보정
			for (auto RepComp = ReplicationMap.CreateIterator(); RepComp; ++RepComp)
			{
				RepComp.Value()->ForceRefreshUnreliableProperties();
			}
		}
	}
	else
	{
		// NetInitial 설정 및 초기 패킷 구성
		RepFlags.bNetInitial = true;
		Bunch.bClose = Actor->bNetTemporary;
		Bunch.bReliable = true;
	}

	// 3. Owner / Role / 상태 플래그 설정
	UNetConnection* OwningConnection = Actor->GetNetConnection();

	RepFlags.bNetOwner = (...);

	if (RepFlags.bNetInitial && OpenedLocally)
	{
		// Actor Spawn 정보 전송
		Connection->PackageMap->SerializeNewActor(Bunch, this, static_cast<AActor*&>(Actor));
		bWroteSomethingImportant = true;

		Actor->OnSerializeNewActor(Bunch);

		RepFlags.bForceInitialDirty = Bunch.bOutWantsFullInitState;
	}

	// Role downgrade (필요 시)
	if (Connection->Driver->CanDowngradeActorRole(Connection, Actor))
	{
		...
	}

	// Replication Flag 세팅
	RepFlags.bNetSimulated = (...);
	RepFlags.bRepPhysics = ...;
	RepFlags.bReplay = ...;
	RepFlags.bForceInitialDirty |= ...;

	...

	FMemMark MemMark(FMemStack::Get());

	// 4. Actor / SubObject 프로퍼티 복제
	if (!bIsNewlyReplicationPaused)
	{
		// Actor 자체 replication
		{
			const bool bCanSkipUpdate = ActorReplicator->CanSkipUpdate(RepFlags);

			if (UE::Net::bPushModelValidateSkipUpdate || !bCanSkipUpdate)
			{
				bWroteSomethingImportant |= ActorReplicator->ReplicateProperties(Bunch, RepFlags);
			}
		}

		// SubObject replication
		bWroteSomethingImportant |= DoSubObjectReplication(Bunch, RepFlags);

		// 재전송 모드 처리
		if (Connection->ResendAllDataState != EResendAllDataState::None)
		{
			int64 NumBitsWrote = 0;

			if (bWroteSomethingImportant)
			{
				SendBunch(&Bunch, 1);
				NumBitsWrote = Bunch.GetNumBits();
			}

			MemMark.Pop();
			...
			return NumBitsWrote;
		}

		// 삭제된 SubObject 반영
		bWroteSomethingImportant |= UpdateDeletedSubObjects(Bunch);
	}

	// 5. 실제 전송
	int64 NumBitsWrote = 0;

	if (bWroteSomethingImportant)
	{
		FPacketIdRange PacketRange = SendBunch(&Bunch, 1);

		// 전송 후 Replicator 후처리
		if (!bIsNewlyReplicationPaused)
		{
			for (auto RepComp = ReplicationMap.CreateIterator(); RepComp; ++RepComp)
			{
				RepComp.Value()->PostSendBunch(PacketRange, Bunch.bReliable);
			}

			// SubObject ACK 추적
			...

			// NetTemporary Actor 추적
			if (Actor->bNetTemporary)
			{
				Connection->SentTemporaries.Add(Actor);
			}
		}

		NumBitsWrote = Bunch.GetNumBits();
	}

	...
    
	// 마지막 업데이트 시간 기록
	LastUpdateTime = Connection->Driver->GetElapsedTime();

	MemMark.Pop();

	bForceCompareProperties = false;

	// 통계 업데이트
	Connection->GetDriver()->GetMetrics()->IncrementInt(...);

	return NumBitsWrote;
}

1-7. Summary

Channel은 UNetConnection 내부에서 동작하며, 데이터를 Bunch 단위로 나누어 여러 형태의 네트워크 데이터를 송수신하고 처리하는 역할을 한다.

전체 흐름은 크게 수신(Receive)과 송신(Send) 두 방향으로 나뉜다.

수신 흐름 (Client 기준)

text
Packet → Bunch → ReceivedRawBunch
        → 순서 정렬 / Reliable 보장
        → Partial Bunch 재조립
        → ReceivedBunch (처리 가능 여부 판단)
        → ProcessBunch
        → Actor 생성 / 프로퍼티 적용 / RPC 실행

송신 흐름 (Server 기준)

text
Actor 상태 변경 감지
        → ReplicateActor
        → 변경된 Property / RPC 직렬화
        → Bunch 생성
        → SendBunch
        → Packet 전송

Conclusion

결론적으로 Unreal Engine의 리플리케이션 시스템 네트워크 데이터 흐름이 NetDriver → Connection → Channel → Bunch로 이어지는 계층 구조를 알아보았다.

  • NetDriver는 전체 네트워크 시스템을 관리하며 Connection의 생성과 수명을 제어하고,
  • netConnection은 실제 패킷(Packet) 단위의 송수신을 담당한다.
  • Channel은 데이터의 종류에 따라 통신을 분리하는 논리적 단위로 동작하며, 각 Channel은 독립적으로 상태를 관리하고 데이터를 처리한다.
  • Bunch는 Channel 내부에서 실제로 전송되는 데이터 단위로, Packet에 담겨 전달되며 순서, Reliable 여부, 분할 상태 등의 정보를 포함해 데이터의 정합성을 보장한다.
이번 포스팅을 통해 언리얼 엔진의 소스 코드를 직접 분석하면서 엔진에서 네트워크 시스템이 어떤 식으로 송수신이 되는 지 자세히 파악할 수 있는 좋은 경험이 되었다.
다만 해당 포스팅들은 실제 엔진 소스코드를 기반으로 정리한 내용이지만, 분석 과정에서 AI의 도움을 병행하여 정리한 부분이 있기 때문에 일부 세부 동작이나 표현에는 부정확한 해석이 포함되어 있을 수 있다.
따라서 참고 자료로 활용하되, 정확한 동작은 공식 소스코드 및 디버깅을 통해 추가 검증하는 것을 권장한다.
참고 자료

https://dev.epicgames.com/documentation/unreal-engine/detailed-actor-replication-flow?application_version=4.27 https://youtu.be/JOJP0CvpB8w?si=TFLhKqHtA0VvLnac