LobbyMaps에서 인원을 채우고 Start를 눌러 DemoMaps로 이동하면 Tagger/Hider 캐릭터 스폰과 RoleTag 배정까지는 로그로 정상 확인되는데 이동·점프·시점 조작이 전혀 먹지 않음

 

반면 DemoMaps를 PIE로 바로 띄워서 시작하면 캐릭터가 멀쩡히 움직임

1차 가설: PlayerController의 BeginPlay 타이밍 문제

레벨 전환(ServerTravel)이 일어나면 GameMode/GameState/PlayerController/Pawn이 전부 파괴되고 새로 생기니까

처음 의심한 건 AFDPlayerController::BeginPlay()에서 AddMappingContext를 호출하는 부분이었음

void AFDPlayerController::BeginPlay()
{
    Super::BeginPlay();
    if (!IsLocalController()) return;

    if (UEnhancedInputLocalPlayerSubsystem* Subsystem =
        ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetLocalPlayer()))
    {
        Subsystem->AddMappingContext(DefaultMappingContext, 0);
    }
}

 

Hard Travel(재접속) 과정에서 BeginPlay 호출 시점에 GetLocalPlayer()가 아직 준비되지 않아 Subsystem이 nullptr이 되고 매핑 등록 자체가 스킵되는 게 아닐까 싶어서 BeginPlay에 로그를 추가해서 확인해보니, IsLocalController: true, Subsystem 획득 성공, MappingContext 등록 완료까지 전부 정상적으로 찍힘

 

매핑 등록까지 다 성공했는데도 전부 안 움직이는 게 도저히 무슨 원인인지 몰랐는데, 팀원분이 원인을 발견함 

SetInputMode 때문이었음

 

Lobby 화면에서는 Start 버튼을 누르기 위해 의도적으로 입력 모드를 UI 전용으로 바꿔뒀었음

// AFDLobbyPlayerController::BeginPlay
FInputModeUIOnly InputMode;
InputMode.SetWidgetToFocus(LobbyWidget->TakeWidget());
SetInputMode(InputMode);
bShowMouseCursor = true;

 

FInputModeUIOnly는 키보드/게임패드 입력을 게임 월드(InputComponent)가 아니라 지정된 UI 위젯으로만 보내는 설정

즉 캐릭터의 Enhanced Input 매핑이 아무리 정상 등록돼 있어도, Slate 포커스 자체가 UI를 보고 있으면 입력은 거기서 멈춤

 

해결: Lobby 컨트롤러가 사라지기 전에 입력 모드를 되돌리기

AFDLobbyPlayerController가 파괴되기 직전 호출되는 EndPlay에서 명시적으로 복구하도록 추가

void AFDLobbyPlayerController::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    if (IsLocalController())
    {
        SetInputMode(FInputModeGameOnly());
        bShowMouseCursor = false;
    }

    GetWorldTimerManager().ClearTimer(RefreshTimerHandle);

    Super::EndPlay(EndPlayReason);
}

UFDLobbyWidget이라는 C++ 클래스를 부모로 두고, 그걸 상속한 WBP_LobbyWidget을 팀원이 디자인하도록 만들었음

 

UCLASS()
class FUNNYORDIE_API UFDLobbyWidget : public UUserWidget
{
    GENERATED_BODY()

protected:
    UPROPERTY(meta = (BindWidget))
    UButton* StartButton;

    UPROPERTY(meta = (BindWidget))
    UTextBlock* WaitingText;

    UFUNCTION()
    void OnStartButtonClicked();

public:
    UFUNCTION(BlueprintCallable, Category = "Lobby")
    void UpdateHostUI(bool bIsHost, bool bCanStart);
};

 

버튼 이름은 StartButton, 텍스트 이름은 WaitingText로만 맞춰주면 색깔/폰트/레이아웃/애니메이션은 자유롭게 해도 됨

(로직을 연결시켜두고 디자인만 맡기는 형태)

 

클릭 → 서버까지 가는 경로

클라이언트 화면에서 일어난 일이 서버의 GameMode에 닿으려면 PlayerController를 거쳐야 한다.

void UFDLobbyWidget::OnStartButtonClicked()
{
    if (AFDLobbyPlayerController* LobbyPC = Cast<AFDLobbyPlayerController>(GetOwningPlayer()))
    {
        LobbyPC->ServerRPC_RequestStartMatch();
    }
}

 

GetOwningPlayer()로 이 위젯이 누구의 화면인지를 자동으로 찾아오고, 그 컨트롤러의 Server RPC를 호출하면 네트워크를 타고 서버 쪽 _Implementation이 실행됨

문제 (이후 수정 예정)

1초 간격 타이머로 PlayerController가 직접 bIsHost와 현재 인원수를 확인해 위젯에 반영하게 함

(인원이 다 차야 start가 눌리니까)

- 이후에 로직 수정 예정

GetWorldTimerManager().SetTimer(RefreshTimerHandle, this,
    &AFDLobbyPlayerController::RefreshLobbyUI, 1.0f, true);

 

 

그래도 기본 골격은 만들어놓고 넘기는 게 피차 편할 것 같아서 WBP 골격을 만듦

 

  • Canvas 깔고 그 위에 button과 text 놓고 이름을 정확히 StartButton, WaitingText로 변경
  • AFDLobbyPlayerController의 LobbyWidgetClass 슬롯에 WBP_LobbyWidget 끼워넣기

 

일단 이렇게 만들어두고 나머지 디자인을 팀원에게 맡김

 

기존에는 AFDGameMode 하나에 PostLogin에서 5초 타이머로 AssignRoles()를 호출하는 구조

여기에 로비 화면을 추가하려고 하다 보니 Lobby에서 Start 버튼 누르면 Demo 레벨로 이동한다는 로직을 어디에 구현해야 할지 모르겠다는 생각이 들었음

 

처음엔 하나의 AFDGameMode 안에서 현재 레벨 이름을 보고 분기하는 방식을 생각했는데 레벨이 ServerTravel로 전환되면 그 시점에 존재하던 GameMode 인스턴스는 파괴되고, 새 레벨에 새 GameMode 인스턴스가 생긴다는 걸 알게됨

 

그래서 레벨마다 전담 GameMode를 따로 두는 구조로 설정

StartMaps  -> AFDStartGameMode   (메뉴, 거의 로직 없음)
LobbyMaps  -> AFDLobbyGameMode   (인원 체크, 방장 관리, Start 처리)
DemoMaps   -> AFDGameMode        (기존 본게임 로직 그대로 유지)

 

World Settings에서 레벨별로 GameMode Override를 지정해두면 Unreal이 알아서 그 레벨에 맞는 GameMode를 띄워줌

 

에디터 작업

  • StartMaps World Settings -> GameMode Override -> BP_StartGameMode
  • LobbyMaps World Settings -> GameMode Override -> BP_LobbyGameMode

로비 GameMode: 방장 전용 Start, 서버 권위 검증

  1. 누가 들어오고 나가는지 추적
  2. 첫 입장자를 방장으로 지정
  3. 방장이 Start를 누르고 인원이 충분하면 본게임으로 이동

bIsHost는 PlayerState에 Replicated로 선언해 서버가 정한 값을 클라이언트가 그대로 받아 화면에만 반영하게 했고 Start 요청이 들어왔을 때 GameMode가 다시 한번 진짜 방장이 보낸 요청이 맞는지 검증하도록 함

void AFDLobbyGameMode::TryStartMatch(APlayerController* Requester)
{
    const AFDPlayerState* FDPS = Requester ? Requester->GetPlayerState<AFDPlayerState>() : nullptr;
    if (!FDPS || !FDPS->bIsHost) return; // 방장 아니면 그냥 무시
    if (GameState->PlayerArray.Num() < MinPlayersToStart) return; // 인원 부족도 무시

    GetWorld()->ServerTravel(NextLevelName);
}

 

클라이언트(위젯)는 PlayerController를 거쳐 Server RPC로 이 함수를 호출할 뿐 실제 판단은 전부 서버에서 이뤄진다

클라이언트가 UI를 변조해서 요청을 보내더라도 서버가 다시 검증하기 때문에 안전하다

방장이 도중에 나가는 경우도 처리했다

Logout에서 나간 사람이 방장이었는지 확인하고 맞다면 남은 사람 중 한 명에게 방장을 위임한다

이때 Super::Logout() 호출 이후에 위임 로직을 둬야 한다는 게 중요함 

 

PlayerArray에서 나가는 사람을 실제로 제거하는 처리가 부모 클래스 안에서 일어나기 때문에 순서를 바꾸면 막 나간 사람이 다시 방장으로 뽑히는 버그가 생긴다

 

PlayerController도 레벨별로 분리

  • AFDStartPlayerController : 메뉴 위젯만
  • AFDLobbyPlayerController : Start RPC 처리
  • AFDPlayerController (본게임) : 여러가지 로직들

 

 

알게된 것

  • GameInstance: 닉네임, 매칭 설정, 다음 맵으로 이동하는 트리거, 로딩화면 표시/해제 같은 것
  • 각 레벨의 GameMode: 그 레벨 안에서만 의미 있는 규칙 (Scouting 60초, InGame 300초 등)
  • PlayerState: 같은 매치 세션 안에서 레벨 넘어가도 유지되는 개인 데이터 (RoleTag, bIsAlive 등) 
    단, Seamless Travel 켜져 있을 때만

로딩화면은 보통 GameInstance가 레벨 이동을 시작/완료하는 시점(PreLoadMap/PostLoadMap 델리게이트)에 맞춰 위젯을 띄웠다 내리는 식으로 구현됨

 

 

  • 포인터/레퍼런스로만 다룰 때 (AFDStartPlayerController*, TWeakObjectPtr<APlayerState>) -> 전방선언으로 충분
  • 멤버에 접근하거나(FDPS->bIsHost), Cast<>하거나, 상속하거나, 값으로 객체를 만들 때 -> 풀 #include 필요

접속한 플레이어들 중 한 명을 랜덤으로 술래로, 나머지를 숨는자로 배정하고 그 역할에 맞는 캐릭터로 정확히 스폰해야함

 

팀원이 Tagger와 Hider 캐릭터를 구현해둔 상태라 PIE를 돌려봤는데 의도한 Tagger/Hider 캐릭터가 아니라 엔진 기본 DefaultPawn이 스폰됨 (GameMode를 만들어둔 걸로 설정해두지 않았을 뿐더러 PostLogin, AssignRoles 둘 다 구현 전이라 역할을 정해주는 로직 자체가 없었기 때문이라서 당연함)

구현 방향

PostLogin 시점에는 아직 누가 술래인지 모르므로 역할이 정해지기 전엔 아무 캐릭터도 스폰하지 않도록 막아두고(기본으로 DefaultPawn을 설정하니까 그걸 nullptr로 막음)

AssignRoles에서 역할이 확정된 후에 그 역할에 맞는 캐릭터로 다시 스폰을 요청함

  • GameMode 생성자: PlayerStateClass를 AFDPlayerState로 지정 DefaultPawnClass는 nullptr로 비워서 역할 미배정 상태에서는 스폰이 일어나지 않게 함
  • GetDefaultPawnClassForController_Implementation 오버라이드: 컨트롤러에게 어떤 Pawn을 줄지 정하는 곳
    PlayerState.RoleTag를 보고 TaggerClass/HiderClass 중 하나를 리턴하도록 분기
    RoleTag가 아직 None이면 Super 호출 -> DefaultPawnClass(nullptr) 리턴
  • AssignRoles(): GameState->PlayerArray에서 현재 접속자 전체를 가져와 랜덤으로 한 명을 Tagger로, 나머지를 Hider로 RoleTag 설정
  • 모든 RoleTag 설정이 끝난 뒤에 한꺼번에 RestartPlayer를 호출해 재스폰
UClass* AFDGameMode::GetDefaultPawnClassForController_Implementation(AController* InController)
{
	if (const AFDPlayerState* FDPS = InController ? InController->GetPlayerState<AFDPlayerState>() : nullptr)
	{
		if (FDPS->RoleTag == EFDRole::Tagger && TaggerClass) return TaggerClass;
		if (FDPS->RoleTag == EFDRole::Hider && HiderClass) return HiderClass;
	}
	return Super::GetDefaultPawnClassForController_Implementation(InController);
}
void AFDGameMode::AssignRoles()
{
	TArray<APlayerState*> Players = GameState->PlayerArray;
	if (Players.Num() == 0) return;

	const int32 TaggerIndex = FMath::RandRange(0, Players.Num() - 1);

	// 1단계: RoleTag 전부 먼저 확정
	for (int32 i = 0; i < Players.Num(); ++i)
	{
		AFDPlayerState* FDPS = Cast<AFDPlayerState>(Players[i]);
		if (!FDPS) continue;
		FDPS->RoleTag = (i == TaggerIndex) ? EFDRole::Tagger : EFDRole::Hider;
	}

	// 2단계: 확정된 뒤 한꺼번에 재스폰 요청
	for (APlayerState* PS : Players)
	{
		if (AController* Controller = PS->GetOwningController())
		{
			RestartPlayer(Controller);
		}
	}
}

 

트러블슈팅: RoleTag 복제(OnRep)가 한 명당 2번씩 호출되는 문제

PlayerState.RoleTag에 OnRep 콜백을 달아 클라이언트 복제 확인용 디버그 메시지를 띄웠는데 화면에 같은 값이 한 명당 2번씩 찍히는 현상이 발생

 

 

디버깅 과정

OnRep_RoleTag에 중단점을 걸고 디버깅 해봤는데 처음 중단점에 걸렸을 땐 화면에 아무것도 뜨지 않음 -> F5를 한 번 더 누르니까 메시지 2개가 동시에 나타남

원인을 모르겠어서 ReStart 호출될 때 Tag Replication이 한 번 더 되나 싶어서 RoleTag 만들어질 때 디버깅 찍어봤는데 서버에서는 한 번 설정되고 있는 게 맞음

 

원인을 모르겠어서 튜터님을 찾아뵙고 여쭤보니 PIE 멀티플레이어 테스트 설정에서 "Run Under One Process"가 체크되어 있던 것이 원인이었음

Run Under One Process 설정은 로그가 하나의 화면에 다 찍히는 걸 원할 때 (디버깅할 때) 설정해놓고 하는거지 실제 상황과 같은 상황을 구현할 땐 꺼놓고 해야됨

안 그러면 이 옵션이 켜진 상태에서는 위처럼 중복으로 처리되는 오류가 생길 수 있음

NetLoadOnClient 속성

레벨에 정적으로 배치되고 값이 변하지 않는 액터는 서버가 굳이 복제해줄 필요가 없다

이런 액터는 NetLoadOnClient 속성을 true로 설정해서 각 클라이언트가 레벨을 로드할 때 알아서 스폰하게 만들 수 있다

 

동기화는 곧 네트워크 트래픽이고 비용이다

안 변하는 건 굳이 동기화하지 않는 게 효율적이다

 

주의할 점

NetLoadOnClient와 bReplicates는 같이 켜는 조합이 아니라 둘 중 하나를 선택하는 관계다.

  • bReplicates = true → 서버가 액터를 만들고 클라에게 복제해준다
  • NetLoadOnClient = true → 서버와 클라가 각자 레벨 로드 시점에 알아서 액터를 만든다 (복제 불필요)

둘 다 true로 켜면 클라이언트는 레벨 로드하면서 액터를 하나 만들고, 동시에 서버가 복제로 또 하나를 만들려고 시도하기 때문에 같은 액터가 중복으로 생기는 문제가 발생한다

 

그래서 NetLoadOnClient를 실습할 때는 bReplicates를 false로 둬야 한다

 

Property Replication 복습 - 회전값 동기화

Property Replication 3단계 패턴

  1. bReplicates = true - 액터 자체를 복제 대상으로 만든다
  2. UPROPERTY(Replicated) - 변수를 복제 후보로 선언한다
  3. DOREPLIFETIME - 실제로 복제 목록에 등록한다 (선언만 하고 등록하지 않으면 복제되지 않는다)
// DXBox.h

class DEDICATEDX_API ADXBox : public AActor
{
public:
	virtual void GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const override;
	virtual void Tick(float DeltaSeconds) override;

protected:
	UPROPERTY(Replicated)
	float ServerRotationYaw;

	float RotationSpeed;
};
// DXBox.cpp

#include "Net/UnrealNetwork.h"

ADXBox::ADXBox()
	: ServerRotationYaw(0.0f)
	, RotationSpeed(30.0f)
{
	PrimaryActorTick.bCanEverTick = true;
	bReplicates = true;
}

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

	DOREPLIFETIME(ThisClass, ServerRotationYaw);
}

void ADXBox::Tick(float DeltaSeconds)
{
	Super::Tick(DeltaSeconds);

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

 

 

  • 서버: 실제로 회전을 계산해서 돌리고 그 결과값을 ServerRotationYaw에 저장한다. 이 값이 자동으로 클라에게 복제된다
  • 클라이언트: 복제로 받은 ServerRotationYaw 값을 그대로 받아서 그 각도로 맞추기만 한다

 

매 틱 읽기의 비효율성

네트워크 복제는 매 틱마다 오는 게 아니라 서버가 일정 주기로 보내준다

하지만 클라이언트의 Tick()은 그것과 상관없이 매 프레임 계속 돌면서 SetActorRotation(ServerRotationYaw)를 호출한다

Replication Notify (OnRep)

이 매 틱 확인 문제를 해결하는 게 Replication Notify

서버에서 속성 값 변경에 따라 복제가 발생할 때 호출되는 콜백 함수

  • 해당 속성의 UPROPERTY()에는 ReplicatedUsing 키워드가 붙어야 한다
  • 콜백 함수에는 UFUNCTION() 매크로와 OnRep_ 접두사가 붙어야 한다 (엔진이 자동으로 찾아서 호출해주는 네이밍 규칙)
// DXBox.h

private:
	UFUNCTION()
	void OnRep_ServerRotationYaw();

protected:
	UPROPERTY(ReplicatedUsing = OnRep_ServerRotationYaw)
	float ServerRotationYaw;
// DXBox.cpp

void ADXBox::Tick(float DeltaSeconds)
{
	if (HasAuthority() == true)
	{
		AddActorLocalRotation(FRotator(0.f, RotationSpeed * DeltaSeconds, 0.f));
		ServerRotationYaw = RootComponent->GetComponentRotation().Yaw;
	}
	else
	{
		// SetActorRotation(FRotator(0.f, ServerRotationYaw, 0.f));  // Tick에서 빼버림
	}
}

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

 

이제 새 값이 도착한 순간에만 OnRep_ServerRotationYaw()가 자동으로 호출되면서 회전을 맞춰준다

Tick()은 더 이상 회전 처리를 매번 하지 않는다

Replication Notify의 특징

서버에서는 호출되지 않고 클라에서만 호출된다 

OnRep은 복제를 받았다는 신호에 묶인 콜백인데 서버는 복제를 받는 입장이 아니라 보내는 입장이라서 호출될 이유가 없다

만약 서버에서도 같은 로직이 필요하다면 직접 명시적으로 호출해줘야 한다

if (HasAuthority() == true)
{
	AddActorLocalRotation(FRotator(0.f, RotationSpeed * DeltaSeconds, 0.f));
	ServerRotationYaw = RootComponent->GetComponentRotation().Yaw;

	OnRep_ServerRotationYaw();  // 서버도 직접 호출
}

 

 

Conditional Property Replication

레플리케이션에 등록된 프로퍼티를 조건식을 통해 더 세밀하게 조정할 수 있게 해준다

단점은 조건식 값이 너무 자주 바뀌면 오버헤드가 발생할 수 있다는 점이다

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

	// DOREPLIFETIME(ThisClass, ServerRotationYaw);
	DOREPLIFETIME_CONDITION(ThisClass, ServerRotationYaw, COND_InitialOnly);
}

 

COND_InitialOnly는 "이 액터가 처음 클라에 나타날 때만 복제하고, 그 이후로는 값이 바뀌어도 복제하지 않는다"는 조건이다

한 번 정해지면 절대 안 바뀌는 값(캐릭터 초기 스폰 위치 등)에는 유용하다

 

 

 

 

'학습 > Unreal' 카테고리의 다른 글

멀티플레이 디버깅  (0) 2026.06.24
게임플레이 프레임워크  (0) 2026.06.24
Property Replication (기초)  (0) 2026.06.22
서버를 거치는 통신 구조  (0) 2026.06.22
Remote Procedure Call 기초 (RPC 기초)  (0) 2026.06.19

+ Recent posts