본문 바로가기
Unreal 게임 개발/Unreal 강의 개인 정리

원격 Actor & Network 초기화, 오너십(소유) - Unreal Network MultiPlayer Framework

by daisy0461 2025. 3. 7.

원격 액터의 초기화

로컬 게임에서의 초기화는 PostInitializeComponents와 BeginPlay가 존재한다.
원격 클라이언트에서 네트워크 관련 설정의 초기화는 PostNetInit함수를 사용한다.

PostNetInit

클라이언트에서는 게임이 시작되기 전(BeginPlay 전)에, 서버에서 변경된 속성을 반영해야 하는 경우가 있다.

(ex. 폴가이즈에서 참가 인원 수가 계속 변해서 알려줘야하는 경우. 이 현재 접속한 플레이어 수를 GameState에 저장한다. &
Red or Blue 팀으로 변경했을 때 자신 및 타 플레이어가 어느 팀인지 UI로 보여줘야한다.)
변경된 속성들은 네트워크를 통해 동기화되어야 하며, 클라이언트가 올바르게 게임을 시작하기 위해 미리 받아야 한다.

그러나 PostInitializedComponents()는 액터의 기본 설정을 담당하는 함수로, 네트워크 동기화와는 관련이 없다.

따라서, Unreal Engine은 네트워크로 접속하는 클라이언트가 서버에서 복제된 데이터를 안전하게 사용할 수 있도록 PostNetInit() 함수를 제공한다.


흐름 예시

  1. 서버에서 GameState의 Player 값을 업데이트한다.
  2. 새로운 플레이어가 접속하면 서버의 GameState의 PlayerCount가 변경되며 해당 플레이어 클라이언트로 복제된다.
  3. 클라이언트에서 PostNetInit()이 실행되며 UI가 업데이트 된다.
  4. 새로운 플레이어들의 GameStete도 PlayeCount의 값이 변경되면 PostNetInit()이 실행.

PlayerController는 PostInitializeComponents에서 생성이 된다.
또한 BeginPlay전에 PostNetInit이 실행된다. 이 실행은 서버의 GameState가 최초에 클라이언트 GameState에 복제할 때 실행되는 과정이다. 위의 설명과 같이 서버의 GameState가 변경되어 클라이언트 GameState에 복제될 때도 실행된다.

 

Unreal Engine 커넥션 구성

통신 하이레벨 (High-Level)

  • 게임을 구성하는 핵심 요소인 Actor, Component, World 등의 오브젝트 상태와 속성을 관리하는 상위 개념.
  • 즉, 게임 로직과 데이터가 어떻게 변하는지를 정의하는 계층.
    ex)플레이어가 아이템을 먹으면 Pawn의 속성이 변경됨 (Health += 50 등).
  • GameState가 변경되면 모든 클라이언트가 동기화됨.

통신 로우레벨 (Low-Level)

  • 하이레벨에서 변경된 데이터를 네트워크를 통해 전달하기 위해 만들어진 데이터 스트림(Data Stream).
  • 변경된 속성 값을 네트워크 패킷(일련의 숫자 데이터)로 변환하여 전송하는 계층.
    ex)플레이어가 아이템을 먹으면 로우레벨에서 해당 변경 사항을 패킷 데이터로 변환하여 클라이언트로 전송.
  • 패킷은 숫자로 변환된 데이터 흐름이며, 클라이언트는 이를 받아 다시 Pawn의 속성을 업데이트함.

주요 Class

1. PlayerController Class 
네트워크 통신에 접근 가능한 게임 내 대표 Actor
클라이언트 입력을 서버로 전달하고, 서버에서 동기화된 데이터를 클라이언트에 적용하는 역할을 함.
즉, 클라이언트와 서버 간의 인터페이스 역할을 수행.

2. UNetConnection Class
네트워크 패킷을 인코딩/디코딩하고, 네트워크 트래픽을 조절하며, 채널을 관리하는 클래스.
하이레벨(PlayerController 등)의 데이터를 로우레벨(바이너리 데이터)로 변환하는 기초 작업을 수행.

 

3. UNetDriver Class

UNetConnection에서 처리한 데이터를 실제 네트워크를 통해 송수신하는 역할.

네트워크 통신을 담당하는 운전사(Driver)역할이다.


통신 순서
ex) 서버에서 클라이언트로 데이터 전송 (게임 상태 동기화)
Server PlayerController -> Server UNetConnection -> UNetDriver - 통신-> 클라이언트 UNetDriver ->
클라이언트 UNetConnection -> 클라이언트 Player Controller이다.
클라이언트에서 입력을 주고 서버로 넘겨줄 때(공격 입력 전송)는 반대방향이다.

 

서버의 네트워크 초기화 과정

  • 현재 월드에 NetDriver가 있으면 클라이언트 - 서버 없으면 스탠드얼론으로 판단한다.
    UWorld::InternalGetNetMode()함수를 통해 판단
  • 서버는 월드의 Listen 함수를 호출해 NetDriver를 생성함으로 네트워크 기능을 시작한다.
    (UWorld::Listen()함수를 통해 네트워크 기능 시작)

아래 코드의 흐름을 보면 알 수 있는데
서버의 Listen 함수에서 서버의 NetDriver를 생성하여 네트워크가 활성화된다.
이후 NetDriver가 존재한다면 GetNetMode() 호출 시 리슨 서버인지 데디케이티드 서버인지 판단을 한다.
또한 Listen에 의해 NetDriver를 생성하기 전에 GetNetMode로 판단하면 스탠드얼론 상태로 나타난다.

클라이언트의 NetDriver는 서버에 연결 요청을 보내면 생성되며 GetNetMode()를 호출하면 클라이언트로 판단한다.

처음 시작 시 No NetDriver가 출력된다. NetDriver가 활성화 되지 않았기 때문이고 Listen이 호출된 뒤에는 Listen Server로 출력된다.
이후 새로운 클라이언트가 들어오면 PostLogin에서 커넥션이 하나가 출력된다. 클라이언트에선 IpConnection_04로 서버 커넥션이 만들어졌다. 커넥션은 아에 설명한다.

 

ENetMode UWorld::InternalGetNetMode() const
{
	//NetDriver가 설정이 되어있으면 클라이언트 or 서버 둘 중 하나라고 return한다.
	if (NetDriver != nullptr)		
	{
		const bool bIsClientOnly = IsRunningClientOnly();
		return bIsClientOnly ? NM_Client : NetDriver->GetNetMode();
	}
...
}

GetNetMode()는 데디케이티드 서버인지 리슨 서버인지 반환한다.

ENetMode UNetDriver::GetNetMode() const
{
	// Special case for PIE - forcing dedicated server behavior
#if WITH_EDITOR
	if (World && World->WorldType == EWorldType::PIE && IsServer())
	{
		//@todo: world context won't be valid during seamless travel CopyWorldData
		FWorldContext* WorldContext = GEngine->GetWorldContextFromWorld(World);
		if (WorldContext && WorldContext->RunAsDedicated)		//데디케이티드 서버로 실행이 되었다.
		{
			return NM_DedicatedServer;
		}
	}
#endif

	// Normal
    //GIsClient : 내가 플레이어로서 게임에 참여한다.
	return (IsServer() ? (GIsClient ? NM_ListenServer : NM_DedicatedServer) : NM_Client);
}

Listen 함수에서는 NetDriver를 생성한다.

bool UWorld::Listen( FURL& InURL )
{
...
	// Create net driver.
	if (GEngine->CreateNamedNetDriver(this, NAME_GameNetDriver, NAME_GameNetDriver))
	{
		NetDriver = GEngine->FindNamedNetDriver(this, NAME_GameNetDriver);
		NetDriver->SetWorld(this);
		FLevelCollection* const SourceCollection = FindCollectionByType(ELevelCollectionType::DynamicSourceLevels);
		if (SourceCollection)
		{
			SourceCollection->SetNetDriver(NetDriver);
		}
		FLevelCollection* const StaticCollection = FindCollectionByType(ELevelCollectionType::StaticLevels);
		if (StaticCollection)
		{
			StaticCollection->SetNetDriver(NetDriver);
		}
	}
    ...
}

 

 

NetDriver의 커넥션 관리

NetDriver는 네트워크 모드에 따라 다수의 커넥션을 소유한다.

커넥션이란 서버와 클라이언트가 서로의 데이터를 주고받는 네트워크 채널이다.
서버는 클라이언트와 연결을 맺는 다수의 커넥션을 가지고 클라이언트는 서버에만 연결되므로 하나의 커넥션만 가진다.
Unreal Engine은 이러한 특징을 사용해서 현재 어플리케이션이 서버 모드인지 클라이언트 모드인지 구분한다.
서버인지 아닌지는 UNetDrivet::IsServer()함수로 판단한다.

bool UNetDriver::IsServer() const
{
	//ServerConnection은 클라이언트가 InitConnect()될 때 가지고 있다.
	//해당 특징으로 서버인지 클라이언트인지 판단한다.
	return ServerConnection == NULL;
}

 

 

Unreal Engine에서 데이터 관리

네트워크에서 주고 받는 데이터는 다음과 같은 고도화 작업을 거친다.

  • 커넥션(Connection) : 모든 데이터를 전달하는 네트워크 통로
  • 패킷(Packet) : 네트워크를 통해 전달되는 단위 데이터. 숫자 or 문자로 구성
  • 채널(Channel) : Unreal Engine 아키텍쳐(용도)에 따라 구분된 데이터를 전달하는 논리적인 통로.
    하나의 커넥션은 다수 채널을 가진다.
  • 번치(Bunch) : 채널에서 아키텍쳐(용도)에 맞게 정리된 데이터 묶음을 번치라고 한다.

데이터 통신을 관리하기 위한 대표 Actor로 PlayerController가 주로 사용된다.
커넥션을 담당하는 대표 Actor는 커넥션에 대한 오너십을 가진다고 표현한다.

 

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/actor-owner-and-owning-connection-in-unreal-engine

 

해당 문서에서 "액터의 접속을 소유한다"는 말은 특정 Actor가 PlayerController를 통해 서버와 통신한다는 의미다.

각 클라이언트는 하나의 PlayerController를 가지고 있으며, 서버와 데이터를 주고받는 역할을 한다.

이전에 설명한 것처럼, 네트워크 데이터량을 최적화하기 위해 실제로 필요한 데이터만 전송된다.

그와 마찬가지로 어떤 Actor가 PlayerController에 빙의(Possess)되어 있는 경우에만 UNetConnection을 통해 서버와 통신할 수 있다.

또한, 이 Actor가 소유한 아이템(무기, 장비 등)도 동일한 접속(Connection)에 속하며, 해당 PlayerController를 통해 서버와 데이터를 주고받는다.

컴포넌트의 경우, 이를 직접 소유하지 않지만, 자신이 속한 OwnerActor의 오너가 PlayerController인지 확인하여 네트워크 연결 여부를 판단할 수 있다.

결국 A라는 Actor가 PlayerController에 빙의되어 있다면 A Actor가 소유한 아이템, 컴포넌트도 서버와 통신 가능하다.

액터의 접속을 소유하기 위해선 PlayerController에서
OnPossess(APawn* InPawn) or OnPossess(APlayerController)안에 있는 함수인 PossessedBy()를 사용한다.

void APlayerController::OnPossess(APawn* PawnToPossess)
{
...
		PawnToPossess->PossessedBy(this);
...
}

 

한번 실험을 통해 알아보자.

Character에 다음과 같은 함수를 추가하고 로그를 찍어보자.

void AABCharacterPlayer::PossessedBy(AController* NewController)
{
	AB_LOG(LogABNetwork, Display, TEXT("%s"), TEXT("Begin"));
	AActor* OwnerActor = GetOwner();
	if (OwnerActor) {
		AB_LOG(LogABNetwork, Display, TEXT("Owner : %s"), *OwnerActor->GetName());
	}
	else {
		AB_LOG(LogABNetwork, Display, TEXT("%s"), TEXT("No Owner"));
	}

	Super::PossessedBy(NewController);

	OwnerActor = GetOwner();
	if (OwnerActor) {
		AB_LOG(LogABNetwork, Display, TEXT("Owner : %s"), *OwnerActor->GetName());
	}
	else {
		AB_LOG(LogABNetwork, Display, TEXT("%s"), TEXT("No Owner"));
	}
	AB_LOG(LogABNetwork, Display, TEXT("%s"), TEXT("End"));
}

Super::PossessedBy(NewController)가 실행되기 전엔 Owner가 없고 실행된 후엔 존재하는 것을 확인할 수 있다.

위에서 작성한 Player(일반 Actor)같은 경우엔 빙의가 되지 않는다. 즉, OnPossess()함수가 호출되지 않는다.

그렇다면 언제 Pawn이 Owner를 설정하는 것일까??
자동으로 호출되는 OnRep_Owner()를 통해 설정된다.

흐름을 보자.

  1. 클라이언트가 서버에 접속
  2. 서버에서 PlayerController를 생성하고 서버와 클라이언트에 Pawn이 Spawn
  3. 서버에서 Possess()를 호출하여 Pawn의 Owner를 PlayerController로 설정
  4. 서버의 Pawn에서 최초이기에 OnPossess가 실행된다.
  5. 서버의 Pawn의 Onwer가 변경되고 Owner는 Replicated속성이므로 자동으로 네트워크를 통해 클라이언트에 복제된다.
  6. 클라이언트의 OnRep_Owner()가 실행되며 클라이언트의 Owner도 변경

그렇다면 PlayerController가 조종할 Pawn을 변경하는 경우는 (자동차 탑승)

  1. 클라이언트에서 E키 입력 감지
  2. 서버에 Pawn 변경 요청
  3. 기존 Pawn을 제거하기 위해 UnPossess() 호출
  4. 새로운 Pawn을 위한 Possess() 호출
  5. 서버의 Pawn에서 Owner 변경 이후 동일

리슨 서버에서 직접 호스팅을 하는 플레이어는 로컬 객체이므로 Owner값을 서버를 통해 복제를 할 필요가 없어서 OnRep_Owner()가 실행되지 않는다.