언리얼 멀티플레이 매치 생명주기: 서버 접속, 상태 전환, 결과 UI와 리셋
멀티플레이 게임 한 판은 전투나 점수 계산만으로 완성되지 않는다. 클라이언트가 타이틀 화면에서 서버를 선택하고, 서버가 접속을 승인하며, 필요한 인원이 모이면 게임을 시작하고, 종료 결과를 각 플레이어에게 전달한 뒤 다음 판을 준비해야 한다.
이 흐름에서 가장 중요한 원칙은 서버가 규칙과 상태 전환을 결정하고, 클라이언트는 복제된 상태를 화면에 표현한다는 것이다. 레벨 이동과 UI는 로컬에서 일어나지만, 누가 참가할 수 있는지, 언제 게임이 시작되고 끝나는지, 누가 몇 등인지는 서버가 확정해야 한다.
Title
-> 서버 주소 입력
-> Client Travel
-> PreLogin에서 참가 가능 여부 검사
-> PostLogin 이후 PlayerController 사용
-> Waiting
-> Playing
-> Ending
-> 결과 UI 표시
-> 클라이언트는 Title로 복귀
-> 서버는 논리 상태 또는 월드를 초기화
한 판의 책임을 Gameplay Framework에 나누기
접속부터 종료까지의 모든 코드를 한 클래스에 넣으면 서버 규칙, 복제 상태, 개인 UI가 뒤섞인다. 언리얼의 Gameplay Framework는 이 책임을 이미 나누어 놓았다.
| 클래스 | 네트워크 범위 | 세션 생명주기에서의 책임 |
| GameMode | 서버에만 존재 | 접속 승인, 시작 조건, 상태 전환, 승패 판정, 서버 리셋을 결정한다. |
| GameState | 서버에 존재하고 모든 클라이언트에 복제 | 현재 매치 단계, 남은 시간, 생존 인원처럼 모두가 알아야 하는 상태를 전달한다. |
| PlayerController | 서버와 해당 소유 클라이언트 | 서버 접속 요청, 개인 결과 UI, 강제 복귀처럼 특정 플레이어를 대상으로 한 동작을 처리한다. |
| PlayerState | 서버에 존재하고 관련 클라이언트에 복제 | 플레이어 이름, 점수, 탈락 여부, 순위처럼 플레이어별 상태를 보관한다. |
| UserWidget | 각 로컬 클라이언트 | 입력을 받고 복제 상태와 RPC 결과를 화면에 표시한다. |
GameMode는 원격 클라이언트에 복제되지 않는다. 따라서 클라이언트 UI가 GameMode의 변수를 직접 읽는 구조는 데디케이티드 서버 환경에서 성립하지 않는다. 서버 전용 규칙은 GameMode에 두고, 화면에 필요한 결과만 GameState나 PlayerState에 복제해야 한다.
타이틀 레벨은 게임 월드와 분리한다
타이틀 레벨의 목적은 캐릭터를 스폰하는 것이 아니라 서버 주소를 입력받고 연결을 시작하는 것이다. 따라서 타이틀 전용 GameMode를 만들고 DefaultPawnClass를 None으로 지정하면 불필요한 Pawn 생성을 막을 수 있다. 타이틀 전용 PlayerController는 로컬 UI만 생성한다.
void ANBTitlePlayerController::BeginPlay()
{
Super::BeginPlay();
if (!IsLocalController() || !IsValid(TitleWidgetClass))
{
return;
}
TitleWidget = CreateWidget<UUserWidget>(this, TitleWidgetClass);
if (!IsValid(TitleWidget))
{
return;
}
TitleWidget->AddToViewport();
FInputModeUIOnly InputMode;
InputMode.SetWidgetToFocus(TitleWidget->TakeWidget());
SetInputMode(InputMode);
bShowMouseCursor = true;
}
IsLocalController() 검사는 서버에 존재하는 원격 플레이어의 Controller 인스턴스에서 화면 코드를 실행하지 않게 한다. 데디케이티드 서버에는 Viewport가 없으므로 Widget 생성은 항상 로컬 Controller 경로로 제한해야 한다.
BeginPlay()를 재정의할 때는 Super::BeginPlay()를 먼저 호출한다. 입력과 PlayerController 내부 초기화에 의존하는 코드가 있기 때문에 이를 누락하면 레벨 전환 뒤 입력이 동작하지 않는 것처럼 보일 수 있다.
Widget은 주소를 수집하고 PlayerController가 이동한다
Widget 버튼이 직접 네트워크 연결의 세부 구현을 가지게 만들기보다, Widget은 입력한 주소를 PlayerController에 전달하는 역할만 맡는 편이 좋다. 이렇게 하면 UI 교체와 접속 구현 변경이 서로 분리된다.
void UNBTitleWidget::OnJoinButtonClicked()
{
ANBTitlePlayerController* PC =
GetOwningPlayer<ANBTitlePlayerController>();
if (IsValid(PC) && IsValid(ServerAddressText))
{
PC->JoinServer(ServerAddressText->GetText().ToString());
}
}
void ANBTitlePlayerController::JoinServer(const FString& InAddress)
{
const FString Address = InAddress.TrimStartAndEnd();
if (Address.IsEmpty())
{
return;
}
ClientTravel(Address, TRAVEL_Absolute);
}
클라이언트에서 호출한 APlayerController::ClientTravel()은 IP 주소나 맵 URL로 이동한다. UGameplayStatics::OpenLevel()에 주소를 전달하는 방법도 첫 연결에서는 동작하지만, ClientTravel()을 사용하면 현재 클라이언트가 다른 서버로 이동한다는 의도가 코드에 명확히 드러난다.
실제 서비스에서는 자유 입력 문자열을 바로 사용하지 않고 다음 항목을 함께 처리해야 한다.
- 빈 문자열과 앞뒤 공백을 제거한다.
- 주소와 포트 형식이 허용 범위인지 검사한다.
- 접속 버튼을 연속으로 누르지 못하게 잠근다.
- 접속 실패와 네트워크 끊김 Delegate를 받아 타이틀 UI를 복구한다.
- Online Subsystem을 사용한다면 검색 결과의 Connect String을 받아 이동한다.
접속 생명주기는 PreLogin과 PostLogin을 구분한다
클라이언트가 서버 URL로 이동했다고 곧바로 정상 참가자가 되는 것은 아니다. GameMode에는 접속을 검사하고 Controller를 초기화하는 단계가 따로 있다.
| 단계 | 의미 | 적합한 작업 |
PreLogin() |
PlayerController를 만들기 전 참가를 승인하거나 거절한다. | 정원, 버전, 밴 목록, 진행 중 참가 가능 여부 검사 |
Login() |
접속에 사용할 PlayerController를 생성하고 기본 정보를 설정한다. | 일반 게임 로직보다 엔진의 로그인 생성 절차 |
PostLogin() |
로그인 성공 뒤 호출되며 PlayerController RPC를 안전하게 사용할 수 있다. | 참가자 등록, 초기 상태 설정, 개인 환영 메시지 전달 |
Logout() |
Controller가 게임에서 나가거나 파괴될 때 호출된다. | 배열 제거, 턴과 생존자 정리, 승리 조건 재평가 |
게임이 이미 시작된 뒤 PostLogin()에서 새 Controller의 수명을 짧게 설정해 제거하는 방식은 접속을 일단 성공시킨 뒤 쫓아내는 우회 방법이다. 참가를 허용하지 않을 규칙이라면 PreLogin()에서 오류 문자열을 설정해 로그인 자체를 거절하는 편이 정확하다.
void ANBGameModeBase::PreLogin(
const FString& Options,
const FString& Address,
const FUniqueNetIdRepl& UniqueId,
FString& ErrorMessage)
{
Super::PreLogin(Options, Address, UniqueId, ErrorMessage);
if (!ErrorMessage.IsEmpty())
{
return;
}
const ANBGameStateBase* GS = GetGameState<ANBGameStateBase>();
if (IsValid(GS) && GS->GetSessionPhase() != ENBSessionPhase::Waiting)
{
ErrorMessage = TEXT("The match is already in progress.");
}
}
접속 중 상태가 바뀔 수 있으므로 최종 참가 등록 시점에도 조건을 다시 확인해야 한다. 관전이나 중도 참가를 지원한다면 무조건 거절하는 대신 URL 옵션과 게임 규칙에 따라 Spectator로 받아들이는 정책을 둘 수 있다.
현재 NumberBaseball 프로젝트는 OnPostLogin(AController*)에서 Controller를 배열에 추가하고 이름과 채팅 색상을 배정한 뒤 ClientRPCSetNotificationText()를 호출한다. 이 시점에 개인 Client RPC를 사용하는 것은 올바른 방향이다. 반대로 접속 허용 여부는 이 단계보다 앞선 PreLogin()에서 결정해야 한다.
PlayerController 배열과 GameState의 PlayerArray는 목적이 다르다
서버 GameMode가 생존 PlayerController와 탈락 PlayerController를 따로 보관하면 특정 대상에게 Client RPC를 보내기 쉽다. 다만 이 배열은 서버 전용 런타임 인덱스이며, 클라이언트가 전체 참가자 정보를 읽는 복제 저장소는 아니다.
| 구조 | 장점 | 주의점 |
| GameMode의 PlayerController 배열 | 서버 규칙 처리와 대상별 Client RPC 호출이 직접적이다. | Logout()에서 반드시 제거하고 유효성을 재검사해야 한다. |
GameState의 PlayerArray |
전체 참가자의 PlayerState 목록을 서버와 클라이언트가 공통으로 조회할 수 있다. | 생존, 탈락, 관전 같은 게임별 분류는 PlayerState 프로퍼티가 추가로 필요하다. |
| PlayerState의 참가 상태 | 플레이어별 상태가 복제되므로 순위표와 관전 UI에 적합하다. | 서버만 값을 변경하도록 제한해야 한다. |
순위표를 모든 클라이언트가 지속적으로 봐야 한다면 PlayerState에 bEliminated, Rank, Score를 복제하는 편이 좋다. 결과 화면을 당사자에게 한 번만 띄우는 목적이라면 서버의 Controller 배열과 Client RPC만으로 충분하다.
상태 전환은 GameMode, 현재 상태 공개는 GameState
Waiting, Playing, Ending은 단순한 UI 문자열이 아니라 서버 규칙을 여닫는 상태다. 상태를 GameState에 저장하더라도 전환 결정을 클라이언트가 내려서는 안 된다.
GameMode
최소 인원 충족 여부 검사
-> Waiting 종료 결정
-> Playing 진입
-> 승리 조건 검사
-> Ending 진입
GameState
현재 Phase 복제
종료 예정 서버 시간 복제
생존 인원 복제
-> 각 클라이언트 UI가 표시
프로젝트 전용 상태가 필요하면 엔진의 이름과 혼동되지 않게 별도의 enum과 복제 구조체를 둘 수 있다.
UENUM(BlueprintType)
enum class ENBSessionPhase : uint8
{
Waiting,
Playing,
Ending
};
USTRUCT(BlueprintType)
struct FNBSessionState
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly)
ENBSessionPhase Phase = ENBSessionPhase::Waiting;
UPROPERTY(BlueprintReadOnly)
int32 AlivePlayerCount = 0;
UPROPERTY(BlueprintReadOnly)
double PhaseEndServerTimeSeconds = 0.0;
};
UPROPERTY(ReplicatedUsing = OnRep_SessionState)
FNBSessionState SessionState;
서버 Setter는 Authority를 확인한 뒤 값을 변경하고, 클라이언트는 RepNotify에서 로컬 Delegate를 발생시켜 Widget을 갱신한다.
void ANBGameStateBase::OnRep_SessionState()
{
OnSessionStateChanged.Broadcast();
}
이 패턴은 현재 프로젝트의 FNBTurnState와 같다. ANBGameModeBase가 턴을 결정하고, ANBGameStateBase는 TurnState를 ReplicatedUsing으로 전달하며, OnRep_TurnState()가 로컬 Delegate를 통해 턴 UI를 갱신한다.
AGameModeBase의 커스텀 상태와 AGameMode의 Match State
언리얼은 AGameMode와 AGameState 조합에 표준 매치 상태 머신을 제공한다. AGameModeBase는 더 단순하므로 프로젝트가 직접 상태를 구성한다.
| 선택 | 적합한 경우 | 주요 특징 |
AGameModeBase와 커스텀 상태 |
턴제 미니게임처럼 단계가 단순하거나 고유한 흐름이 필요한 경우 | 상태, 전환 조건, 복제 필드를 직접 정의한다. |
AGameMode와 AGameState |
전형적인 대기, 진행, 종료 매치 흐름을 엔진 훅과 함께 사용할 경우 | WaitingToStart, InProgress, WaitingPostMatch 등의 상태와 시작·종료 훅을 제공한다. |
이미 AGameModeBase 기반으로 구현된 NumberBaseball은 단지 표준 상태 이름을 쓰기 위해 부모 클래스를 바꿀 필요가 없다. 현재 턴과 라운드 규칙에 맞는 ENBSessionPhase를 추가하고, 공개해야 할 상태만 GameState에 복제하는 방식이 더 작은 변경이다.
Tick 대신 서버 Timer로 상태를 진행한다
대기 시간이나 종료 유예 시간처럼 초 단위로 변하는 로직을 매 프레임 Tick()에서 실행할 필요는 없다. 서버의 FTimerManager를 사용하면 필요한 주기로만 조건을 검사하거나 전환 함수를 호출할 수 있다.
void ANBGameModeBase::BeginWaitingCountdown()
{
ANBGameStateBase* GS = GetGameState<ANBGameStateBase>();
if (!IsValid(GS))
{
return;
}
const double EndTime =
GS->GetServerWorldTimeSeconds() + WaitingDurationSeconds;
GS->SetSessionPhase(ENBSessionPhase::Waiting, EndTime);
GetWorldTimerManager().SetTimer(
WaitingTimerHandle,
this,
&ThisClass::StartMatch,
WaitingDurationSeconds,
false);
}
플레이어 수가 최소 인원보다 다시 작아지면 대기 타이머를 지우고 종료 시각을 초기화한다. 종료 단계에 들어가면 진행 타이머를 지우는 식으로 상태마다 활성 Timer가 무엇인지 명확히 유지해야 한다.
void ANBGameModeBase::CancelWaitingCountdown()
{
GetWorldTimerManager().ClearTimer(WaitingTimerHandle);
if (ANBGameStateBase* GS = GetGameState<ANBGameStateBase>())
{
GS->SetSessionPhase(ENBSessionPhase::Waiting, 0.0);
}
}
Actor가 파괴되면 그 객체에 연결된 Timer 콜백은 자동으로 취소되지만, 상태 전환이나 EndPlay()에서 Handle을 명시적으로 정리하면 중복 Timer와 잘못된 단계 진입을 예방하기 쉽다.
남은 정수를 매초 복제하는 대신 종료 시각을 복제할 수 있다
강의형 예제에서는 서버가 15, 14, 13처럼 남은 시간을 매초 줄이고 복제해도 이해하기 쉽다. 플레이어가 많거나 여러 타이머가 존재하면 종료 예정 서버 시각을 한 번 복제하고 각 클라이언트가 남은 시간을 계산하는 방식이 효율적이다.
int32 UNBSessionWidget::GetRemainingSeconds() const
{
if (!IsValid(CachedGameState))
{
return 0;
}
const double Remaining =
CachedGameState->GetPhaseEndServerTimeSeconds()
- CachedGameState->GetServerWorldTimeSeconds();
return FMath::Max(0, FMath::CeilToInt(Remaining));
}
AGameStateBase::GetServerWorldTimeSeconds()는 클라이언트에서도 서버 시간에 맞춰진 값을 제공한다. 서버는 정확한 전환을 Timer로 수행하고, 클라이언트의 표시 타이머는 복제된 종료 시각을 읽어 화면만 갱신한다. 클라이언트 UI의 1초 Timer가 잠시 늦더라도 실제 게임 단계는 서버 상태로 수렴한다.
| 방식 | 장점 | 적합한 상황 |
| 남은 초를 매초 복제 | 구현과 디버깅이 단순하다. | 소규모 게임, 낮은 빈도의 단일 타이머 |
| 종료 서버 시각을 복제 | 상태 진입 때 한 번만 보내도 로컬에서 계속 계산할 수 있다. | 플레이어와 동시 타이머가 많거나 네트워크 갱신을 줄이고 싶은 경우 |
현재 NumberBaseball의 턴 타이머는 RemainingTimeSeconds를 1초마다 변경하고 ForceNetUpdate()로 즉시 알린다. 작은 턴제 프로젝트에서는 충분히 명확한 선택이다. 이후 타이머 종류가 늘어나면 턴 종료 시각을 복제하는 구조로 확장할 수 있다.
게임플레이 규칙도 현재 매치 단계에서 닫아야 한다
클라이언트 UI에서 공격 버튼이나 채팅 입력을 비활성화하는 것만으로는 게임 규칙을 보호할 수 없다. 클라이언트는 지연되거나 변조될 수 있으므로 최종 진입점은 서버가 현재 단계를 검사해야 한다.
float ANBPlayerCharacter::TakeDamage(
float DamageAmount,
const FDamageEvent& DamageEvent,
AController* EventInstigator,
AActor* DamageCauser)
{
if (!HasAuthority())
{
return 0.0f;
}
const ANBGameStateBase* GS = GetWorld()->GetGameState<ANBGameStateBase>();
if (!IsValid(GS) || GS->GetSessionPhase() != ENBSessionPhase::Playing)
{
return 0.0f;
}
return Super::TakeDamage(
DamageAmount,
DamageEvent,
EventInstigator,
DamageCauser);
}
같은 원칙으로 NumberBaseball의 숫자 제출도 서버 GameMode에서 bRoundEnding, 현재 턴, 남은 시간, 제출 횟수를 다시 검사한다. UI 차단은 사용자 경험이고, GameMode 검사는 권위 있는 규칙이다.
결과는 서버가 계산하고 소유 Client RPC로 전달한다
개인 순위 결과는 모든 클라이언트에서 실행되는 NetMulticast보다 PlayerController의 Client RPC에 잘 맞는다. PlayerController의 Owning Connection이 어느 클라이언트에서 실행할지를 결정하기 때문이다.
UFUNCTION(Client, Reliable)
void ClientShowMatchResult(int32 Rank, int32 TotalPlayerCount);
void ANBPlayerController::ClientShowMatchResult_Implementation(
int32 Rank,
int32 TotalPlayerCount)
{
if (!IsLocalController() || !IsValid(ResultWidgetClass))
{
return;
}
UNBResultWidget* ResultWidget =
CreateWidget<UNBResultWidget>(this, ResultWidgetClass);
if (IsValid(ResultWidget))
{
ResultWidget->SetResult(Rank, TotalPlayerCount);
ResultWidget->AddToViewport(10);
FInputModeUIOnly InputMode;
InputMode.SetWidgetToFocus(ResultWidget->TakeWidget());
SetInputMode(InputMode);
bShowMouseCursor = true;
}
}
매치 결과는 유실되면 화면 자체가 뜨지 않으므로 Reliable을 사용할 근거가 있다. 다만 Reliable RPC도 영구 데이터베이스는 아니다. 재접속 뒤에도 결과를 복구해야 한다면 순위와 결과를 PlayerState나 별도 백엔드에 저장해야 한다.
| 결과 데이터 | 권장 전달 방식 | 이유 |
| 당사자에게 한 번 띄울 순위 창 | PlayerController의 Reliable Client RPC | 특정 Owning Client에 일회성 UI 명령을 보낼 수 있다. |
| 모두가 보는 실시간 순위표 | PlayerState 프로퍼티 복제 | 늦은 접속과 relevancy 재진입에도 최신 상태를 받을 수 있다. |
| 일회성 연출과 사운드 | 관련 대상 RPC | 지속 상태가 아니라 순간적인 표현이다. |
탈락 순위 계산에서는 배열 변경 순서가 규칙이 된다
생존자가 네 명일 때 한 명이 탈락하면 그 플레이어의 순위는 4위다. 따라서 제거하기 전 생존자 수를 순위로 저장하고, 배열에서 제거한 뒤 남은 인원으로 승자를 판단한다.
void ANBGameModeBase::EliminatePlayer(ANBPlayerController* EliminatedPC)
{
const int32 PlayerIndex = AlivePlayers.IndexOfByKey(EliminatedPC);
if (PlayerIndex == INDEX_NONE)
{
return;
}
const int32 EliminatedRank = AlivePlayers.Num();
EliminatedPC->ClientShowMatchResult(
EliminatedRank,
AlivePlayers.Num() + DeadPlayers.Num());
AlivePlayers.RemoveAt(PlayerIndex);
DeadPlayers.Add(EliminatedPC);
if (AlivePlayers.Num() == 1 && IsValid(AlivePlayers[0]))
{
AlivePlayers[0]->ClientShowMatchResult(
1,
AlivePlayers.Num() + DeadPlayers.Num());
EnterEndingPhase();
}
else if (AlivePlayers.IsEmpty())
{
EnterEndingPhase();
}
}
AlivePlayers[0]을 읽기 전에 배열 크기를 검사해야 한다. 마지막 두 플레이어가 거의 동시에 나가거나 서버가 Controller를 정리하는 순간에는 생존자가 0명일 수 있다. 종료와 Logout()이 겹쳐도 같은 플레이어에게 결과를 두 번 보내지 않도록 탈락 여부나 현재 Phase를 함께 검사하는 것이 안전하다.
결과 UI는 로컬 객체이고 네트워크로 복제하지 않는다
UUserWidget을 복제하는 것이 아니라, 서버가 결과 데이터만 보내고 각 클라이언트가 자신의 Widget을 생성한다. BindWidget 포인터는 Widget Blueprint의 이름과 타입이 맞아야 하며, 버튼 Delegate는 Widget이 다시 Construct될 가능성을 고려해 중복 바인딩을 막아야 한다.
void UNBResultWidget::NativeConstruct()
{
Super::NativeConstruct();
if (IsValid(ReturnToTitleButton))
{
ReturnToTitleButton->OnClicked.AddUniqueDynamic(
this,
&ThisClass::OnReturnToTitleClicked);
}
}
서버는 결과 Widget의 존재 여부를 알 필요가 없다. 서버가 아는 것은 순위와 종료 단계뿐이며, Widget 생성 실패나 화면 배치는 로컬 표현 계층의 문제다.
한 클라이언트의 복귀와 서버 전체 이동을 구분한다
레벨 이동 함수는 호출 위치에 따라 영향 범위가 달라진다. 이 차이를 놓치면 타이틀 버튼을 눌렀는데 서버까지 맵을 바꾸거나, 서버만 새 맵으로 이동하고 클라이언트가 남는 문제가 생긴다.
| 이동 방식 | 호출 주체 | 결과 |
ClientTravel() |
클라이언트의 로컬 PlayerController | 해당 클라이언트가 맵 또는 다른 서버 URL로 이동한다. |
ClientTravel() |
서버가 특정 PlayerController에 호출 | 그 Owning Client에게 이동을 지시한다. |
ServerTravel() |
서버 | 서버가 새 월드로 이동하고 연결된 클라이언트가 함께 따라간다. |
OpenLevel() 또는 Browse 성격의 이동 |
현재 World | 비연속 이동으로 월드를 다시 열며 서버에서는 기존 연결을 끊는 하드 리셋 성격이 강하다. |
결과 화면의 복귀 버튼은 로컬 클라이언트를 타이틀 맵으로 이동시키면 된다.
void UNBResultWidget::OnReturnToTitleClicked()
{
if (APlayerController* PC = GetOwningPlayer())
{
PC->ClientTravel(
TEXT("/Game/NumberBaseball/Maps/Title"),
TRAVEL_Absolute);
}
}
종료 제한 시간이 지나 서버가 강제로 내보낼 때도 각 PlayerController에 Client Travel을 지시할 수 있다. 반대로 ServerTravel()로 타이틀 맵을 열면 서버 자신도 타이틀 맵으로 이동하고 모든 연결 클라이언트가 따라간다. 게임 서버는 게임 맵에 남기고 플레이어만 로컬 타이틀로 보낼 목적에는 맞지 않는다.
다음 판 준비는 리셋 범위를 먼저 결정한다
서버 초기화에는 하나의 정답이 없다. 게임 규칙만 초기화할지, 같은 연결을 유지한 채 새 맵으로 이동할지, 모든 연결을 끊고 월드를 다시 만들지에 따라 구현이 달라진다.
| 리셋 정책 | 유지되는 것 | 적합한 경우 |
| 같은 World에서 논리 상태만 초기화 | 연결, Controller, PlayerState, 맵 Actor | 같은 참가자와 빠르게 다음 라운드를 반복하는 게임 |
| Server Travel | 설정에 따라 연결과 일부 Actor를 유지할 수 있음 | 로비에서 게임 맵으로 이동하거나 모든 참가자가 다음 맵을 함께 로드하는 게임 |
| 비연속 맵 재로드 | 대부분의 현재 World 상태를 유지하지 않음 | 참가자를 내보낸 뒤 서버 월드를 완전히 새로 만들고 싶은 경우 |
NumberBaseball의 현재 ResetGame()은 첫 번째 정책을 사용한다. 비밀 숫자, 추측 횟수, 제출 여부, UI 상태를 초기화하고 같은 접속자와 다음 턴을 시작한다. 작은 턴제 게임에서는 맵 재로드 비용과 재접속 문제를 만들지 않는 효율적인 방식이다.
맵 Actor까지 완전히 초기화해야 하는 액션 게임이라면 Server Travel이나 월드 재로드를 검토한다. 이때 클라이언트 RPC로 타이틀 이동을 요청한 직후 서버 World를 즉시 닫으면 전송과 연결 종료가 경쟁할 수 있다. 종료 유예 시간을 두고 클라이언트 이동을 먼저 지시한 뒤 서버 리셋을 수행하거나, 아예 모든 클라이언트가 함께 이동하는 Server Travel 구조로 정책을 통일하는 편이 예측 가능하다.
NumberBaseball의 현재 구조에 대입하기
현재 프로젝트는 이미 세션 생명주기의 핵심 패턴을 상당 부분 사용하고 있다.
| 현재 구현 | 역할 | 세션 단위로 확장할 지점 |
ANBGameModeBase::OnPostLogin() |
접속자 등록, 이름과 색상 배정, 개인 알림 전달 | 정원이나 진행 중 참가 거부가 필요하면 PreLogin() 정책 추가 |
AllPlayerControllers |
서버에서 전체 참가자와 RPC 대상을 관리 | 탈락과 관전이 생기면 PlayerState에 지속 상태를 함께 보관 |
FNBTurnState |
활성 플레이어, 남은 시간, 입력 가능 상태를 복제 | 대기, 진행, 종료를 모두가 봐야 하면 세션 Phase를 GameState에 추가 |
TurnTimerHandle |
서버가 1초 단위로 턴을 진행 | 타이머 종류가 늘면 종료 서버 시각 복제로 전송량 감소 검토 |
bRoundEnding |
서버에서 입력과 중복 종료를 차단 | 클라이언트 UI도 알아야 하면 공개 상태를 GameState에 복제 |
ClientRPCSetNotificationText() |
소유 클라이언트에 일회성 알림 전달 | 전용 결과 Widget이 필요하면 순위 인자를 가진 Client RPC로 분리 |
ResetGame() |
같은 연결을 유지한 논리 라운드 리셋 | 타이틀 복귀형 세션과 라운드 반복형 세션 중 제품 정책을 명확히 선택 |
현재 코드는 라운드 종료 중 새 플레이어가 들어오면 접속을 허용하되 채팅 입력을 막고 남은 재시작 시간을 보여 준다. 이것은 잘못이라기보다 다음 라운드 대기 참가를 허용하는 정책이다. 게임 중 참가를 완전히 막고 싶을 때만 PreLogin() 거절로 바꿔야 한다.
전체 실행 흐름
1. 로컬 Title PlayerController가 Title Widget을 생성한다.
2. 플레이어가 서버 주소를 입력하고 Join 버튼을 누른다.
3. 로컬 PlayerController가 ClientTravel로 서버에 접속한다.
4. 서버 GameMode가 PreLogin에서 참가 가능 여부를 검사한다.
5. 로그인 성공 뒤 PostLogin 계열 훅에서 참가자를 등록한다.
6. GameMode가 최소 인원과 대기 시간을 검사한다.
7. GameMode가 Playing으로 전환하고 GameState가 공개 상태를 복제한다.
8. 클라이언트 UI는 RepNotify와 로컬 Delegate로 상태를 표시한다.
9. 서버가 승패와 순위를 확정한다.
10. 각 PlayerController의 Client RPC가 개인 결과 Widget을 연다.
11. 클라이언트는 직접 또는 제한 시간 뒤 Title로 이동한다.
12. 서버는 논리 리셋, Server Travel, 월드 재로드 중 선택한 정책으로 다음 판을 준비한다.
디버깅 체크리스트
| 증상 | 확인할 항목 |
| 타이틀 화면에서 Pawn이 생성된다. | 타이틀 GameMode의 DefaultPawnClass와 World Settings의 GameMode Override를 확인한다. |
| 데디케이티드 서버에서 Widget 관련 오류가 난다. | Widget 생성 경로에 IsLocalController() 검사가 있는지 확인한다. |
| 서버 접속 뒤 이동 입력이 되지 않는다. | Super::BeginPlay(), 게임용 Input Mode, Enhanced Input Mapping Context 등록을 확인한다. |
| 게임 진행 중에도 새 플레이어가 잠깐 스폰된다. | PostLogin()에서 제거하지 말고 PreLogin()에서 참가를 거절하는지 확인한다. |
| 클라이언트가 GameMode 상태를 읽지 못한다. | GameMode는 서버 전용이므로 공개 상태가 GameState에 복제되는지 확인한다. |
| 남은 시간이 클라이언트마다 다르게 보인다. | 서버만 상태를 변경하는지, 서버 종료 시각과 GetServerWorldTimeSeconds()를 사용하는지 확인한다. |
| 결과 UI가 다른 플레이어에게 뜬다. | Client RPC를 올바른 PlayerController 인스턴스에 호출했는지와 Owning Connection을 확인한다. |
| 마지막 플레이어가 나갈 때 서버가 크래시한다. | AlivePlayers[0] 접근 전에 배열 크기와 Controller 유효성을 검사한다. |
| 타이틀 복귀를 눌렀는데 모든 플레이어가 맵을 이동한다. | 개별 Client Travel 대신 Server Travel을 호출한 것은 아닌지 확인한다. |
| 다음 판에 이전 Timer가 다시 실행된다. | 상태 전환, 리셋, EndPlay()에서 모든 관련 Timer Handle을 정리하는지 확인한다. |
'UE > 멀티플레이' 카테고리의 다른 글
| UE C++ 언리얼 패키징 관련 (+AWS) (1) | 2026.08.12 |
|---|---|
| UE C++ 숫자야구 (0) | 2026.08.10 |
| UE C++ 멀티플레이 동기화 (0) | 2026.08.07 |
| UE C++ RPC오너십, 프로퍼티 리플리케이션 (1) | 2026.08.06 |
| UE C++ 액터 리플리케이션 (0) | 2026.08.05 |