영상을 불러오지 못했습니다

Unreal Engine PIE에서 클라이언트와 서버 값을 한 줄로 비교하기

2126 단어
11 분
Unreal Engine PIE에서 클라이언트와 서버 값을 한 줄로 비교하기
Unreal Engine PIE에서 클라이언트와 서버 값을 한 줄로 비교하기

멀티플레이 디버깅을 하다 보면 클라이언트가 가진 값과 서버가 가진 값을 같은 시점에 비교해야 할 때가 있다. 서버와 클라이언트에서 각각 로그를 남기면 출력이 섞이고 두 줄이 같은 시점의 값이라는 보장도 없다.

PIE에서 Run Under One Process가 켜져 있으면 서버 월드와 클라이언트 월드가 한 프로세스 안에 같이 있다. 그래서 에디터 빌드에서는 클라이언트 코드가 서버 월드에 있는 같은 플레이어의 객체를 직접 읽을 수 있고 두 값을 한 줄에 같이 찍을 수 있다. Lyra 샘플의 LyraDevelopmentStatics에도 PIE의 권한 월드를 찾는 같은 방식의 함수가 있다.

이 글은 Third Person C++ 템플릿에서 이 방식을 직접 구현하고 네트워크 에뮬레이션으로 차이를 만들어 숫자로 확인한 기록이다.

지연 200ms에서 Client 1 창에 표시된 클라이언트와 서버 위치 비교
지연 200ms에서 Client 1 창에 표시된 클라이언트와 서버 위치 비교

환경#

  • 검증 버전: Unreal Engine 5.8, Third Person C++ 템플릿
  • PIE Net Mode: Play As Client(같은 프로세스 안의 Dedicated Server), Number of Players 2
  • Run Under One Process 켜짐, Launch Separate Server 꺼짐

서버 월드 찾기#

PIE 월드는 GEngine->GetWorldContexts()에 EWorldType::PIE로 들어 있다. 한 PIE 세션 안에서 NM_Client가 아닌 월드는 Dedicated Server, Listen Server, Standalone 중 하나뿐이므로 그 월드를 찾으면 된다.

UWorld* PIEDebug::FindAuthorityWorld()
{
#if WITH_EDITOR
if (GEngine == nullptr)
{
return nullptr;
}
// A PIE session owns at most one world that is not a client: the dedicated or listen server, or a standalone world
for (const FWorldContext& Context : GEngine->GetWorldContexts())
{
UWorld* World = Context.World();
if (Context.WorldType == EWorldType::PIE && World != nullptr && World->GetNetMode() != NM_Client)
{
return World;
}
}
#endif
return nullptr;
}

FWorldContext::RunAsDedicated로 Dedicated Server를 먼저 고르는 방법도 있지만 여기서는 “클라이언트가 아닌 PIE 월드”라는 조건 하나로 줄였다. 서버 월드가 다른 프로세스에 있으면 찾을 월드가 없으므로 nullptr이 반환된다.

같은 플레이어의 서버 측 PlayerController 찾기#

서버 월드를 찾았으면 그 안에서 클라이언트 PlayerController와 같은 플레이어를 찾아야 한다. 객체 이름으로는 비교할 수 없다. 아래는 PIE 월드와 PlayerController를 덤프하는 콘솔 명령(tp.Debug.DumpPIEPlayers)의 출력이다.

LogPIEDebug: Authority world: Dedicated Server
LogPIEDebug: [Dedicated Server] PIEInstance=0 RunAsDedicated=1
LogPIEDebug: BP_ThirdPersonPlayerController_C_0 (PlayerId=256, UniqueId=NULL:DESKTOP-MH1K636-C7AC..., Pawn=BP_ThirdPersonCharacter_C_0)
LogPIEDebug: BP_ThirdPersonPlayerController_C_1 (PlayerId=257, UniqueId=NULL:DESKTOP-MH1K636-CFD2..., Pawn=BP_ThirdPersonCharacter_C_1)
LogPIEDebug: [Client 1] PIEInstance=1 RunAsDedicated=0
LogPIEDebug: BP_ThirdPersonPlayerController_C_0 (PlayerId=256, ...)
LogPIEDebug: match by PlayerId -> BP_ThirdPersonPlayerController_C_0 (PlayerId=256, ...)
LogPIEDebug: match by UniqueId -> BP_ThirdPersonPlayerController_C_0 (PlayerId=256, ...)
LogPIEDebug: [Client 2] PIEInstance=2 RunAsDedicated=0
LogPIEDebug: BP_ThirdPersonPlayerController_C_0 (PlayerId=257, ...)
LogPIEDebug: match by PlayerId -> BP_ThirdPersonPlayerController_C_1 (PlayerId=257, ...)
LogPIEDebug: match by UniqueId -> BP_ThirdPersonPlayerController_C_1 (PlayerId=257, ...)

Client 2의 PlayerController 이름은 _C_0이지만 서버에서 대응하는 객체는 _C_1이다. 각 클라이언트 월드에는 자기 PlayerController 하나만 있기 때문이다.

대신 PlayerState의 값으로 비교한다. PlayerId는 서버가 할당해 클라이언트로 복제하므로 같은 플레이어의 두 사본은 값이 같다.

APlayerController* PIEDebug::FindAuthorityPlayerController(const APlayerController* ClientController)
{
#if WITH_EDITOR
UWorld* ServerWorld = FindAuthorityWorld();
const APlayerState* ClientPlayerState = ClientController ? ClientController->PlayerState.Get() : nullptr;
if (ServerWorld == nullptr || ClientPlayerState == nullptr)
{
return nullptr;
}
for (FConstPlayerControllerIterator It = ServerWorld->GetPlayerControllerIterator(); It; ++It)
{
APlayerController* ServerController = It->Get();
const APlayerState* ServerPlayerState = ServerController ? ServerController->PlayerState.Get() : nullptr;
// PlayerId is assigned by the server and replicated to clients, so both copies of the same player share it
if (ServerPlayerState && ServerPlayerState->GetPlayerId() == ClientPlayerState->GetPlayerId())
{
return ServerController;
}
}
#endif
return nullptr;
}

PlayerState->GetUniqueId()로 비교할 수도 있다. 위 덤프처럼 이 환경에서는 Null OSS가 NULL: ID를 채워 주기 때문에 두 방식의 결과가 같았다.

다만 FUniqueNetIdWrapper의 operator==는 두 ID가 모두 invalid이면 true를 반환한다(CoreOnline.h). 서버와 클라이언트 양쪽 UniqueId가 비어 있다면 모든 클라이언트가 서버 월드의 첫 번째 PlayerController로 매칭된다. PlayerId 비교에는 이 문제가 없어서 이쪽을 기본으로 두었다.

클라이언트에 PlayerState가 아직 복제되지 않은 시점도 있어서 null 검사를 앞에 둔다. Pawn은 서버 PlayerController에서 꺼낸다.

template <typename T>
T* PIEDebug::FindAuthorityPawn(const APawn* ClientPawn)
{
const APlayerController* ClientController = ClientPawn ? Cast<APlayerController>(ClientPawn->GetController()) : nullptr;
const APlayerController* ServerController = FindAuthorityPlayerController(ClientController);
return ServerController ? Cast<T>(ServerController->GetPawn()) : nullptr;
}

어느 월드의 로그인지 붙이기#

PIE 로그는 서버와 클라이언트의 출력이 한 Output Log에 섞인다. GetDebugStringForWorld()는 월드의 NetMode와 PIE 인스턴스 번호로 Dedicated Server, Listen Server, Client 1 같은 문자열을 만들어 준다. 선언은 UnrealEngine.h에 있고 WITH_EDITOR와 상관없이 정의되어 있다.

#define PIE_LOG(WorldContextObject, Verbosity, Format, ...) \
UE_LOG(LogPIEDebug, Verbosity, TEXT("[%s] ") Format, *GetDebugStringForWorld((WorldContextObject)->GetWorld()), ##__VA_ARGS__)

클라이언트와 서버 값 비교#

로컬 클라이언트 캐릭터의 위치·속도와 서버 측 사본의 값을 한 줄로 만든다. 화면 메시지는 매 프레임 갱신하고 로그는 0.5초 간격으로 남긴다.

void PIEDebug::CompareWithAuthority(const ACharacter* ClientCharacter)
{
#if WITH_EDITOR
using namespace PIEDebug::Private;
if (!CVarCompareWithAuthority.GetValueOnGameThread() || ClientCharacter == nullptr
|| !ClientCharacter->IsLocallyControlled() || ClientCharacter->GetNetMode() != NM_Client)
{
return;
}
const ACharacter* ServerCharacter = FindAuthorityPawn<ACharacter>(ClientCharacter);
if (ServerCharacter == nullptr)
{
return;
}
const UWorld* ClientWorld = ClientCharacter->GetWorld();
const int32 PIEInstance = GetPIEInstance(ClientWorld);
const FVector ClientLocation = ClientCharacter->GetActorLocation();
const FVector ServerLocation = ServerCharacter->GetActorLocation();
const FString Comparison = FString::Printf(TEXT("Delta=%5.1f cm | Client Loc=(%7.1f, %7.1f) Speed=%5.1f | Server Loc=(%7.1f, %7.1f) Speed=%5.1f"),
FVector::Dist(ClientLocation, ServerLocation),
ClientLocation.X, ClientLocation.Y, ClientCharacter->GetVelocity().Size2D(),
ServerLocation.X, ServerLocation.Y, ServerCharacter->GetVelocity().Size2D());
// One keyed line per client, refreshed every frame. On-screen messages are shared by every PIE viewport
GEngine->AddOnScreenDebugMessage(static_cast<uint64>(7000 + PIEInstance), 1.f, FColor::Yellow,
FString::Printf(TEXT("[%s] %s"), *GetDebugStringForWorld(ClientWorld), *Comparison));
// Log at most once per interval for each client
static TMap<int32, double> LastLogTimes;
double& LastLogTime = LastLogTimes.FindOrAdd(PIEInstance, 0.0);
const double Now = ClientWorld->GetRealTimeSeconds();
if (Now < LastLogTime || Now - LastLogTime >= CVarCompareLogInterval.GetValueOnGameThread())
{
LastLogTime = Now;
PIE_LOG(ClientCharacter, Log, TEXT("%s"), *Comparison);
}
#endif
}

AddOnScreenDebugMessage의 메시지는 모든 PIE 뷰포트가 공유한다. 그래서 키를 PIE 인스턴스마다 다르게 주면 한 창에서 Client 1과 Client 2의 줄을 함께 볼 수 있다.

호출은 캐릭터 Tick에서 한다.

void AThirdPersonCharacter::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
// editor-only: compare the predicted client movement against the server-side copy of this character
PIEDebug::ApplyAutoMove(this);
PIEDebug::CompareWithAuthority(this);
}

ApplyAutoMove는 측정용 헬퍼다. tp.Debug.AutoMove 60을 주면 로컬 캐릭터가 초당 60도씩 방향을 바꾸며 최고 속도(500cm/s)로 원을 그린다. 손으로 조작하지 않아도 같은 조건에서 반복해 측정할 수 있다.

차이를 만들어 확인하기#

PIE의 Network Emulation 설정으로 지연을 주고 등속 구간(양쪽 Speed=500.0)의 Delta만 모았다. 조건마다 약 10초, 샘플은 26~29개다.

조건평균 Delta범위
에뮬레이션 없음9.5 cm5.8 ~ 15.1 cm
Clients Only, Outgoing 100ms63.2 cm56.9 ~ 73.2 cm
Clients Only, Outgoing 200ms116.2 cm106.5 ~ 144.9 cm
Server Only, Outgoing 100ms11.1 cm5.6 ~ 34.4 cm

200ms와 Server Only 조건의 최댓값은 이동을 시작한 직후의 샘플이다.

에뮬레이션 없음
에뮬레이션 없음

클라이언트 송신 지연 100ms
클라이언트 송신 지연 100ms

서버 송신 지연 100ms
서버 송신 지연 100ms

클라이언트 송신 지연을 100ms 늘릴 때마다 평균 차이가 약 50cm씩 늘었다. 500cm/s × 0.1s = 50cm이므로 이동 속도와 지연을 곱한 값과 맞는다.

CharacterMovement는 클라이언트가 먼저 이동하고 서버는 클라이언트가 보낸 이동을 받은 뒤에 같은 이동을 재현한다. 그래서 서버 측 위치는 클라이언트→서버 지연만큼 뒤처진다. 같은 100ms를 서버 송신 쪽에 주었을 때는 기준선과 거의 같았다. 서버→클라이언트 지연은 서버 측 사본이 갱신되는 시점과 관계가 없기 때문으로 보인다.

에뮬레이션이 없을 때 남는 10cm 안팎은 측정 당시 약 80fps에서 500cm/s로 1~2프레임 이동한 거리로 보인다.

에디터가 백그라운드에 있으면 Editor Preferences > General > Performance > Use Less CPU when in Background 때문에 PIE가 초당 몇 프레임으로 떨어진다. 이 상태에서는 프레임 시간이 차이에 그대로 섞인다. 처음 측정할 때 에뮬레이션 없이도 62cm가 나와서 이 설정을 끄고 다시 측정했다.

제약#

  • 모든 코드는 #if WITH_EDITOR 안에 있다. 에디터가 아닌 게임 타깃(ThirdPerson Win64 Development)도 빌드해서 컴파일되는 것을 확인했고 이 빌드에서는 함수가 nullptr을 반환하거나 아무것도 하지 않는다.
  • 서버와 클라이언트 월드가 같은 프로세스에 있을 때만 동작한다. Run Under One Process를 끄거나 Launch Separate Server를 켜면 이 전제가 깨진다.
  • 클라이언트 코드가 리플리케이션을 거치지 않고 서버 객체를 읽는 방식이다. 값을 읽어 비교하는 용도로만 쓰고 서버 객체의 상태를 바꾸는 데 쓰지 않는다.

글 공유하기

이 글이 도움이 되셨다면 다른 사람과 공유해 주세요!

Unreal Engine PIE에서 클라이언트와 서버 값을 한 줄로 비교하기
https://hon454.github.io/posts/unreal-engine-pie-client-server-value-logging/
작성자
Jihoon Jeon
게시일
2026-10-02
라이선스
CC BY-NC-SA 4.0
관련 게시글스마트
1
Unreal Engine - Replication Graph
Unreal EngineUE 5.8.3 소스와 테스트 프로젝트 실측으로 Replication Graph가 기본 드라이버의 무엇을 줄이고 무엇을 줄이지 않는지 확인한다. 액터 5,000개와 클라이언트 16개까지의 측정, 효과가 있는 설정값과 이름과 반대로 동작하는 설정값, 커스텀 그래프의 라우팅과 Team Node, 5.8.3에서 재현한 버그 다섯 가지, 도입 기준과 Iris와의 관계를 다룬다.
2
면접 오답노트: 말하지 못한 주요 개념, Net Relevancy
Unreal Engine면접에서 제대로 답하지 못한 Net Relevancy를 다시 정리한다. AActor::IsNetRelevantFor의 판정 순서와 문서와 다른 부분, Owner가 판정을 바꾸는 방식, 연관성을 잃고 되찾을 때 RelevantTimeout과 액터 파괴, BeginPlay, OnRep, RPC에 생기는 일, ForceNetRelevant와 Replication Graph, Iris에서의 주의점, Dormancy, Priority와의 차이를 다룬다.
3
Unreal Engine - Push Model Networking
Unreal EngineUE 5.8.3 기준으로 레거시 복제의 Push Model이 켜지는 조건, dirty 비트가 소비되는 경로, net.PushModelSkipUndirtiedReplication이 건너뛰는 범위를 엔진 소스로 확인하고, ThirdPerson 템플릿에 액터 1000개를 띄워 Unreal Insights, Networking Insights, Network Profiler로 CPU 시간, 대역폭, 비교 횟수를 측정한다. 같은 조건에서 Dormancy와도 비교한다.
4
휴면 액터 깨우기: FlushNetDormancy, ForceNetUpdate, SetNetDormancy
Unreal EngineUE 5.8.3 엔진 소스 기준으로 FlushNetDormancy, ForceNetUpdate, SetNetDormancy가 각각 무엇을 바꾸는지 비교한다. 휴면 정책과 휴면 상태의 차이, ForceNetUpdate가 덤으로 하는 flush가 빠지는 조건, 값을 바꾼 뒤 깨우면 FastArray 변경이 사라지는 문제, 이를 막는 setter 패턴과 Push Model을 함께 쓸 때의 주의점을 정리한다.
5
언리얼 엔진의 리플리케이션 및 RPC 순서 보장
Unreal Engine프로퍼티 복제와 OnRep, RPC의 순서 보장 범위를 살펴보고, 일시적인 상태 불일치와 액터 간 준비 순서를 처리하는 방법을 예시로 정리한다.
무작위 게시글무작위

댓글

Profile Image of the Author
Jihoon Jeon
안녕하세요! 서브컬처와 자전거를 사랑하는 개발자 전지훈입니다.
공지사항
제 블로그에 오신 것을 환영합니다! 예시 공지입니다.
카테고리
태그
최신 일상
사이트 통계
게시글
73
카테고리
10
태그
143
총 단어 수
133,527
운영 일수
0일
최근 활동
0일 전
사이트 정보
빌드 플랫폼
GitHub Actions
블로그 버전
Firefly v6.16.9
라이선스
CC BY-NC-SA 4.0
목차