Unreal Engine

Unreal Engine - 데디케이티드 서버 2

gbleem 2025. 4. 3. 16:01

1. 로그를 통한 흐름 분석


1 - 1. 로그인 흐름 분석

GameModeBase와 PlayerController에서 로그를 찍어보면 아래와 같이 정리해볼 수 있다.

  • 맨 처음 네모는 서버에서만 생성되는 GameMode 로직이다.
  • 아래 네모는 Client01번의 PlayerController가 서버에서 생성되고, Client로 복제되는 과정이다.
  • 마지막 네모는 Client02번이 생성 및 복제되는 과정이다.

 

1 - 2. NetConnection 관련 로그 추가

NetConnection에 존재하는 ClientConnection과 ServerConnection 관련 로그를 찍어보자

  • ClientConnection
    • 서버가 가지고 있는 Connection이므로, GameMode에서 로그를 작성해야 한다.
    • 참고)
      • ClientConnection은 Login 관련 함수에서 확인하면 좋다고 한다.
      • 그 이유는 Login 관련 함수는 클라이언트가 로그인 할 때 마다 호출되기 때문이다.
    • 로그 코드
더보기
void ADXGameModeBase::PostLogin(APlayerController* NewPlayer)
{
	DX_LOG_NET(LogDXNet, Log, TEXT("Begin"));

	Super::PostLogin(NewPlayer);

	UNetDriver* NetDriver = GetNetDriver();
	if (IsValid(NetDriver))
	{
		if (NetDriver->ClientConnections.Num() == 0)
		{
			DX_LOG_NET(LogDXNet, Log, TEXT("There is no ClientConnection"));
		}
		else
		{
			for (const auto& ClientConnection : NetDriver->ClientConnections)
			{
				if (IsValid(ClientConnection))
				{
					DX_LOG_NET(LogDXNet, Log, TEXT("ClientConnection Name: %s"), *ClientConnection->GetName());
				}
			}
		}
	}
	else
	{
		DX_LOG_NET(LogDXNet, Log, TEXT("There is no NetDriver"));
	}

	DX_LOG_NET(LogDXNet, Log, TEXT("End"));
}
  • ServerConnection 
    • 클라이언트가 가지고 있는 커넥션 이기 때문에, PlayerController에서 작성해야 한다.
    • 참고)
      • 서버 커넥션을 확인하기 가장 적합한 함수가 PostNetInit 함수라고 한다.
      • 그 이유는 해당 함수가 액터의 네트워크 관련 속성이 모두 초기화된 후 호출되는 함수이기 때문이다.
    • 추가)
      • IsLocalPlayerController() 를 통해서, 이 컨트롤러가 Local에 존재하는 컨트롤러인지 확인한다.
      • 이유는 Controller는 서버에도 있기 때문이다.
    • 코드
더보기
void ADXPlayerController::PostNetInit()
{
	DX_LOG_NET(LogDXNet, Log, TEXT("Begin"));

	Super::PostNetInit();

	if (IsLocalPlayerController())
	{
		UNetDriver* NetDriver = GetNetDriver();
		if (IsValid(NetDriver))
		{
			UNetConnection* ServerConnection = NetDriver->ServerConnection;
			if (IsValid(ServerConnection))
			{
				DX_LOG_NET(LogDXNet, Log, TEXT("ServerConnection Name : %s"), * ServerConnection->GetName());
			}
			else
			{
				DX_LOG_NET(LogDXNet, Log, TEXT("There is no serverconnection"));
			}
		}
	}
	DX_LOG_NET(LogDXNet, Log, TEXT("End"));
}

실행 결과

  • 서버 커넥션은 하나 클라이언트 커넥션은 두 개인 것을 확인할 수 있다.

 

1 - 3. 참고 (PreLogin 접속 막기)

  • PreLogin함수 매개변수인 ErrorMessage에 문자열을 넣어주면 연결이 거부된다.
  • 결과적으로 NetMode 스탠드얼론으로 플레이된다.
더보기
void ADXGameModeBase::PreLogin(const FString& Options, const FString& Address, const FUniqueNetIdRepl& UniqueId, FString& ErrorMessage)
{
	DX_LOG_NET(LogDXNet, Log, TEXT("Begin"));

	Super::PreLogin(Options, Address, UniqueId, ErrorMessage);

	//ErrorMessage = TEXT("Server is full. try again");

	DX_LOG_NET(LogDXNet, Log, TEXT("End"));
}

 

1 - 4. 초기화를 위한 게임 시작 이벤트 함수 정리

  • PreLogin()
    • 클라이언트의 접속 요청을 수락할지 거절할지 처리하는 함수
    • 서버의 역할하는 PC 경우 호출하지 않는다.
  • Login()
    • 접속을 허용한 클라이언트에 대응하는 플레이어 컨트롤러를 만드는 함수
    • APlayerController를 return 한다.
  • PostLogin()
    • 클라이언트가 서버에 성공적으로 접속한 후 호출
    • 플레이어 입장을 위해 플레이어에 필요한 기본 설정을 하는 함수
  • StartPlay()
    • 게임의 시작을 지시하는 함수
    • 게임의 시작과 로그인은 다르다
    • ex) 로비에 접속해서 대기하는 사람의 경우는 로그인만 된 것이지 게임을 시작하는 것은 아니다
  • BeginPlay()
    • 게임모드의 StartPlay 함수를 통해 게임이 시작될 때 모든 액터에서 호출되는 함수

참고) BeginPlay의 설명에서 게임모드의 StartPlay함수를 통해서 호출된다고하는데, 어떻게 액터(클라이언트 쪽)들은 이 신호를 알고 호출이 가능할까?

  • 클라이언트에는 게임모드가 없기 때문에 클라이언트에서 게임모드의 StartPlay 함수를 받아올 방법이 없어 보인다.
  • 그러나 서버의 "게임 스테이트"는 각 클라이언트에게 복제되기 때문에, 게임모드가 게임 스테이트에게 명령을 내리고 게임 스테이트가 각 클라이언트에게 알려주고 BeginPlay를 호출하게 된다.

 

1 - 5. GameState와 BeginPlay 

  • GameModeBase에서 StartPlay() 함수를 호출 (서버) 하면
  • GameStateBase의 HandleBeginPlay() 함수를 호출한다.

 

엔진 코드를 통해 BeginPlay() 흐름 살펴보기

// GameStateBase.cpp
void AGameStateBase::HandleBeginPlay()
{
	bReplicatedHasBegunPlay = true;        // 클라 모든 액터의 BeginPlay() 함수가 호출되게끔 함

	GetWorldSettings()->NotifyBeginPlay(); // 서버 모든 액터의 BeginPlay() 함수가 호출되게끔 함
	...
}

// WorldSettings.cpp
void AWorldSettings::NotifyBeginPlay()
{
	...
	if (!World->bBegunPlay)
	{
		for (FActorIterator It(World); It; ++It)
		{
			...
			It->DispatchBeginPlay(bFromLevelLoad);
		}

		...
	}
}

// Actor.cpp
void AActor::DispatchBeginPlay(bool bFromLevelStreaming)
{
	...

	if (World)
	{
		...

		BeginPlay();

		...
	}
}

// GameStateBase.cpp
void AGameStateBase::OnRep_ReplicatedHasBegunPlay()
{
	//HandleBeginPlay에서 세팅한 값이 true이며, 서버 아닌 경우
	if (bReplicatedHasBegunPlay && GetLocalRole() != ROLE_Authority)
	{
		GetWorldSettings()->NotifyBeginPlay();
		...
	}
}

 

1 - 6. 게임 시작 관련 이벤트 함수

  • PostInitializeComponents()
    • 액터에 부착된 컴포넌트들이 모두 준비된 상태에서 호출
  • PostNetInit()
    • 서버에서 액터의 변경된 세팅이 완료된 후 클라이언트에서 호출되는 함수
    • 멀티플레이에서
      • 액터의BeginPlay()가 호출되기 위해서는
      • 해당 액터에 대한 서버에서의 처리가 완료되어야 한다.
      • 결론) PostNetInit()이 호출된 후 StartPlay() 에 의해 BeginPlay() 가 호출된다.
    • 위에서 ServerConnection 출력을 위해 사용했던 함수
  • BeginPlay()
    • 게임플레이에 필요한 액터의 초기화 로직 담당

 

 

2. NetConnection


클라이언트가 서버에 접속 -> NetConnection 생성 -> PlayerController 소유 -> Pawn 소유

 

언리얼 네트워크의 데이터 단위

  • NetConnection
    • 멀티플레이 관련 데이터들이 드나드는 통로
    • 서버는 클라이언트 갯수만큼 "클라이언트커넥션"을 가짐
    • 클라이언트는 "서버 커넥션" 하나를 가짐
  • Channel
    • NetConnection을 좀 더 세분화 시킨 것
    • ActorChannel : Actor Replication 과 관련이 있다.
    • ControlChannel
    • VoiceChannle 
  • Packet
    • 네트워크에서 사용하는 통상적인 데이터 단위
  • Bunch
    • 언리얼에서 사용하는 특별한 패킷 단위
    • 예시) 하나의 RPC, 하나의 Property Replication

 

NetConnection의 쓰임새

  • 어떤 플레이어(캐릭터)가 다른 PC의 화면에서도 보이게 하기 위해서는 NetConnection에 소유된 플레이어(캐릭터)여야 한다.
    • 그 이유는 NetConnection이 소유하는 Controller에 빙의된 Pawn(캐릭터)라면 다른 PC로 복제가 되기 때문
    • 플레이어 컨트롤러(NetConnection 소유) 통신 가능 -> 빙의된 pawn 통신 가능 -> pawn이 소유하는 액터 통신 가능
  • Relevancy
    • 어떤 클라이언트가 특정 액터의 정보를 업데이트 받을지 말지 결정할 때 NetConnection 필요
  • RPC
    • RPC를 실행할지 무시할지 결정할 때 NetConnection 필요

NetConnection 관련 함수 살펴보기

  • AActor::GetNetConnection 함수
    • Owner가 있어야 NetConnection을 가져올 수 있음
  • APawn::GetNetConnection() 함수
    • 컨트롤러가 있어야 NetConnection을 가져올 수 있음
    • 없으면, AActor::GetNetConnection 실행 (하나 상위 클래스의 함수 실행)
  • APlayerController::GetNetConnection 함수
    • Player가 있어야 NetConnection 리턴
// AActor.cpp
UNetConnection* AActor::GetNetConnection() const
{
	return Owner ? Owner->GetNetConnection() : nullptr;
}

//APawn.cpp
class UNetConnection* APawn::GetNetConnection() const
{
	// if have a controller, it has the net connection
	if ( Controller )
	{
		return Controller->GetNetConnection();
	}
	return Super::GetNetConnection();
}

//APlayerController.cpp
UNetConnection* APlayerController::GetNetConnection() const
{
	// A controller without a player has no "owner"
	return (Player != NULL) ? NetConnection : NULL;
}

 

 

3. Role


3 - 1. Role 기본 개념 다시 정리

기본적으로 클라이언트-서버 모델에서는 서버에 있는 액터만이 신뢰된다.

  • 신뢰되는 액터는 Authority
  • 복제된 액터는 Proxy

Role이 필요한 이유는 "액터의 특성"을 알기 위해서이다.

  • 이 액터가 서버에 존재하는지
  • 클라이언트에 존재하는지
  • 복제되었는지 (복제된 후 어떤 상태인지)
  • 등등

Role은 Remote Role과 Local Role로 나뉜다. 나누는 이유는 아래와 같다.

  • 서버에서 스폰한 A라는 캐릭터와 서버에 접속한 캐릭터 B를 구별할 수 없다.
  • 두 경우 모두 서버의 입장에서는 Authority이기 때문에 구별이 불가능하다. (Local Role)
  • 그러나 A의 경우는 Remote Role은 None이고, B의 경우 Remote Role이 Autonomous Proxy이므로 구별 가능하다.

 

3 - 2. 액터 Role을 파악하는 함수

  • HasAuthority()
    • 로컬 롤이 Authority인지를 return 해준다.
  • IsLocalController(), IsLocallyControlled()
    • 해당 액터가 컨트롤이 가능한지 체크한다. 
    • HasAuthority()가 false이고 위의 두 함수가 true인 경우 Autonomous Proxy로 판별할 수 있다.
  • 사용 예시
    • 게임모드에서는 HasAuthority() 사용할 필요 없다.
    • UI 같은 경우 IsLocalPlayerController 체크를 해서 클라이언트에만 출력되도록 해준다.

 

 

4. Replication


4 - 1. Replication 기본 개념 정리

특정 클라이언트에서 생성된 정보를 다른 클라이언트로 복제하는 작업을 리플리케이션이라고 한다.

서버-클라이언트 구조에 있어서는 서버에서 클라이언트로 복제되는 것이 규칙이다.

 

리플리케이션에는 두가지 방식이 존재한다.

  • Property Replication
    • 자동으로 값이 바뀌는 방식
    • 단, 서버에서 클라이언트로의 방향만 존재한다.
  • RPC
    • 직접적으로 값을 바꾸는 방식
    • 서버 -> 클라, 클라 -> 서버 둘 다 가능하다.

 

4 - 2. NetLocalOnClient

레벨에 존재하는 모든 액터의 정보를 서버가 모든 클라이언트에게 복제하는 것은 비효율적이다.

  • 고정적으로 배치되는 액터(레벨 디자인된 액터) 의 경우 NetLocalOnclient 속성을 true로 지정하여 클라이언트가 스스로 스폰하도록 할 수 있다.
  • 단, 동적으로 생성하는 액터는 사용하면 안된다. (Character, Pawn 등)

 

NetLoadOnClient 속성을 false로 하면 아래와 같이 cube가 보이지 않는다.

 

4 - 3. Property Replication

회전하는 물체를 만들어보자

  • RotationYaw를 Replicated되는 속성으로 만들고 Tick을 돌려서 해당 정보를 클라이언트로 계속 가져오도록 하였다.
  • 서버에서 값을 계산한 다음 replicated된 값을 통해 클라이언트에서 회전을 해주는 방식
더보기
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "DXCube.generated.h"

class USceneComponent;
class UStaticMeshComponent;
class FLifetimeProperty;

UCLASS()
class SCC_DEDICATEDX_API ADXCube : public AActor
{
	GENERATED_BODY()
	
public:	
	ADXCube();

	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

	virtual void Tick(float Deltaseconds) override;

protected:
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
	TObjectPtr<USceneComponent> SceneRoot;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
	TObjectPtr<UStaticMeshComponent> Mesh;

	UPROPERTY(Replicated)
	float ServerRotationYaw;

	float RotationSpeed;
};

 

#include "Gimmick/DXCube.h"

#include "Net/UnrealNetwork.h"

ADXCube::ADXCube()
	:ServerRotationYaw(0.f)
	,RotationSpeed(30.f)
{ 
	PrimaryActorTick.bCanEverTick = true;
	bReplicates = true;

	SceneRoot = CreateDefaultSubobject<USceneComponent>(TEXT("SceneRoot"));
	SetRootComponent(SceneRoot);

	Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
	Mesh->SetupAttachment(SceneRoot);
	Mesh->SetRelativeLocation(FVector(-50.f, -50.f, 50.f));
}

void ADXCube::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(ThisClass, ServerRotationYaw);
}

void ADXCube::Tick(float Deltaseconds)
{
	Super::Tick(Deltaseconds);

	if (HasAuthority())
	{
		AddActorLocalRotation(FRotator(0.f, RotationSpeed * Deltaseconds, 0.f));
		ServerRotationYaw = RootComponent->GetComponentRotation().Yaw;
	}
	else
	{
		SetActorRotation(FRotator(0.f, ServerRotationYaw, 0.f));
	}
}

 

이 방식으로 구현해도 동작은 제대로 하지만 Tick을 계속 돌면서 복제된 값을 가져와야 하기에 오버헤드가 크다

 

오버헤드를 줄이기 위해, 리플리케이트된 값이 수정되면 call 되는 함수가 존재한다. -> OnRep_ 접두사 붙인 함수

 

OnRep_ 함수

  • ReplicatingUsing 키워드와 OnRep_ 접두사를 붙여서 아래와 같이 수정할 수 있다.
  • 이 함수의 특징은 서버에서는 클라이언트에서만 호출된다는 점이다.
  • 지금은 큰 차이가 없다고 느껴질 수 있지만, 만약 서버 회전값 수정을 Tick에서 하지 않는다면 오버헤드 차이가 클 것이다.
더보기
//DXCube.h
UCLASS()
class SCC_DEDICATEDX_API ADXCube : public AActor
{
	GENERATED_BODY()
	
public:	

	UFUNCTION()
	void OnRep_ServerRotationYaw();

protected:
	...
	UPROPERTY(ReplicatedUsing = OnRep_ServerRotationYaw)
	float ServerRotationYaw;
};

//DXCube.cpp
void ADXCube::Tick(float Deltaseconds)
{
	Super::Tick(Deltaseconds);

	if (HasAuthority())
	{
		AddActorLocalRotation(FRotator(0.f, RotationSpeed * Deltaseconds, 0.f));
		ServerRotationYaw = RootComponent->GetComponentRotation().Yaw;
	}
	else
	{
		//SetActorRotation(FRotator(0.f, ServerRotationYaw, 0.f));
	}
}

void ADXCube::OnRep_ServerRotationYaw()
{
	SetActorRotation(FRotator(0.f, ServerRotationYaw, 0.f));
}

 

4 - 4. Frequency

Frequency란 액터 리플리케이션 빈도를 뜻한다. 

 

NetUpdateFrequency

  • 액터 리플리케이션 빈도의 최대치 (1초에 몇번의 리플리케이션을 시도할지)
  • 기본값은 100이다.
  • 이 값은 단순히 최대치 일 뿐 보장하지는 않는다.
    • 만약 빈도보다 서버의 성능이 낮으면, 서버의 성능으로 복제된다.
    • 그래서 서버의 성능이 중요하며, 데디케이티드 서버의 경우가 더 좋은 성능을 낸다(그래픽적 요소 없으니까)
  • 주요 액터의 빈도값
    • Actor, Pawn, PlayerController : 100
    • GameState : 10
    • PlayerState : 1

결국 Frequency의 요점은, 서버의 네트워크 부하를 줄이는 것에 있다.

  • 네트워크 부하를 줄이는 방법
    • 기존의 방식은 매 틱마다 서버의 위치가 변경되고, 그 값이 리플리케이션 하여 클라이언트로 보내진다. 
    • 개선된 방식은 NetUpdateFrequency를 줄이고, 받아온 값을 토대로 클라이언트에서 보간하는 방법이 있다.
  • 코드
    • 값을 업데이트할 주기(NetUpdatePeriod) 설정 후
    • 리플리케이션 된 순간부터 변수(Acc..)에 Deltaseconds를 더한 값을 저장하여
    • 다음 리플리케이션 되기 전까지 존재하는 중간 값들을 보간을 통해서 찾아낸다.
      • 리플리케이션 된 순간의 정보를 가지고, NetUpdateFrequency를 통해 비율을 계산한다.
      • 이후 다시 리플리케이션 되면 0부터 다시 시작한다.
더보기

변수 두개 추가

float NetUpdatePeriod;
float AccDeltaSecondSinceReplicated = 0.f;

 

ADXCube::ADXCube()
{ 
	...
  	//NetUpdateFrequency 설정
	const static float CubeActorNetUpdateFrequency = 1.f;
	SetNetUpdateFrequency(CubeActorNetUpdateFrequency);
	NetUpdatePeriod = CubeActorNetUpdateFrequency / GetNetUpdateFrequency();
}

void ADXCube::Tick(float Deltaseconds)
{
	Super::Tick(Deltaseconds);

	if (HasAuthority())
	{
		...
	}
	else
	{
		if (NetUpdatePeriod < KINDA_SMALL_NUMBER)
		{
			return;
		}
		AccDeltaSecondSinceReplicated += Deltaseconds;
		const float LerpRatio = FMath::Clamp(AccDeltaSecondSinceReplicated / NetUpdateFrequency, 0.f, 1.f);
		const float NextServerRotationYaw = ServerRotationYaw + RotationSpeed * NetUpdatePeriod;
		const float EstimatedClientRotationYaw = FMath::Lerp(ServerRotationYaw, NextServerRotationYaw, LerpRatio);
		SetActorRotation(FRotator(0.f, EstimatedClientRotationYaw, 0.f));		
	}
}

void ADXCube::OnRep_ServerRotationYaw()
{
	SetActorRotation(FRotator(0.f, ServerRotationYaw, 0.f));
	AccDeltaSecondSinceReplicated = 0.f;
}