오늘은 로비에서 PlayerState의 닉네임을 서버에서 클라이언트로 안정적으로 동기화하는 과정에서 발생한 문제를 분석하고 해결한 과정을 정리했습니다.
Lobby 흐름 중 PlayerState가 자신의 닉네임(PendingNickname)을 서버에서 설정하고 이를 클라이언트로 복제하는 구조입니다.
목표는 플레이어가 어떤 시점이든 접속 했을 때, 기존 접속 유저와 추가 접속 유저의 정보가 리스트에 정상적으로 등록되는 것입니다.
언리얼 엔진(UE5)에서 APlayerState는 플레이어 간에 공유되어야 하는 정보(예: 닉네임, 점수, 팀 정보 등)를 관리하는 클래스입니다. 특히 멀티플레이 환경에서는 이 클래스의 Replicated 변수들이 클라이언트와 서버 간에 자동으로 동기화되는데, 여기서 중요한 점이 하나 있습니다.
PlayerState의 Replicated 변수들이 언제 동기화되는지는 게임의
BeginPlay()시점보다 빠르다고 보장할 수도, 느리다고 보장할 수도 없습니다.
UE의 네트워크 초기화 순서를 간단히 보면 다음과 같습니다.
APlayerController와 APlayerState를 생성합니다.BeginPlay()는 액터가 월드에 스폰되고 초기화 절차를 마친 후 호출됩니다.문제는, Replication 업데이트는 네트워크 패킷을 통해 비동기적으로 도착한다는 점입니다.
따라서 클라이언트 입장에서는 BeginPlay()가 호출될 때 이미 모든 PlayerState 변수가 최신 상태로 동기화되어 있을 수도 있고, 반대로 아직 도착하지 않아 기본값(초기값)으로 남아 있을 수도 있습니다.
예를 들어, 다음 코드처럼 BeginPlay()에서 PlayerState의 데이터를 읽는 경우를 생각해 봅시다.
void AMyCharacter::BeginPlay()
{
Super::BeginPlay();
if (APlayerState* PS = GetPlayerState())
{
UE_LOG(LogTemp, Warning, TEXT("Player Name: %s"), *PS->GetPlayerName());
}
}
이때 GetPlayerName()이 올바른 값(예: "Player1")을 출력할 수도 있지만,
아직 Replication이 끝나지 않았다면 빈 문자열("")이 나올 수도 있습니다.
이것이 바로 “동기화 시점을 보장할 수 없다”는 의미입니다.
OnRep_PendingNickname이 호출되더라도 GameState가 아직 초기화되지 않은 시점이라면 로비 리스트 추가가 실패하거나 무시되는 현상이 있었습니다.REPNOTIFY_Always 설정으로 인해 같은 PlayerState가 여러 번 리스트에 추가되는 중복 문제도 발생했습니다.네트워크 복제와 월드 초기화의 비동기성
PlayerState의 OnRep은 정상적으로 호출될 수 있지만, 그 시점에 GameState가 아직 준비되지 않았을 가능성이 있습니다.복제 알림의 재진입 / 중복 문제
REPNOTIFY_Always는 값이 동일하더라도 항상 OnRep을 트리거하기 때문에, 부수효과(리스트 추가)가 여러 번 실행될 수 있는 여지가 있었음DOREPLIFETIME_CONDITION_NOTIFY(..., REPNOTIFY_Always)로 클라이언트 측에서 항상 OnRep을 받도록 유지BeginPlay()에서 PendingNickname = GetName() 식으로 초기 값을 명확히 세팅해 복제 타이밍을 안정화GameState가 아직 준비되지 않았다면 즉시 처리하지 않고 SetTimerForNextTick 등을 사용해 다음 프레임으로 연기GameState 초기화가 완료된 이후에 로비 리스트 접근을 보장bAddedToLobby 플래그OnRep 재호출이나 지연 처리 상황에서도 멱등성을 보장서버 측
BeginPlay()에서 PendingNickname을 초기 설정GetLifetimeReplicatedProps()에 DOREPLIFETIME_CONDITION_NOTIFY(..., REPNOTIFY_Always) 등록클라이언트 측
OnRep_PendingNickname()에서 다음 순서로 처리했습니다
bAddedToLobby == true) 즉시 반환GameState 준비 여부 확인SetTimerForNextTick으로 재시도 예약bAddedToLobby = true
void ACMPlayerStateLobby::OnRep_PendingNickname()
{
if (PendingNickname.IsEmpty())
{
// 빈 문자열이면 FName이 NAME_None이 되므로 무시
return;
}
HandlePendingNicknameChanged();
UE_LOG(LogTemp, Log, TEXT("ACMPlayerStateLobby::OnRep_PendingNickname: %s"), *PendingNickname);
}
void ACMPlayerStateLobby::HandlePendingNicknameChanged()
{
if (PendingNickname.IsEmpty())
{
return;
}
if (bAddedToLobby)
{
return;
}
bAddedToLobby = true;
if (ACMGameStateLobby* GS = GetWorld()->GetGameState<ACMGameStateLobby>())
{
GS->LobbyPlayerJoined.Broadcast(FName(*PendingNickname));
}
else
{
UE_LOG(LogTemp, Warning, TEXT("GameStateLobby not found."));
GetWorld()->GetTimerManager().SetTimerForNextTick(FTimerDelegate::CreateUObject(this, &ACMPlayerStateLobby::HandlePendingNicknameChanged));
}
}
GameState 지연 생성 시에도 다음 틱 재시도로 안정적으로 등록되었습니다.REPNOTIFY_Always로 OnRep이 여러 번 호출되어도 bAddedToLobby로 인해 중복이 방지되었습니다.
본인을 제외한 다른 클라이언트들이 정상적으로 출력됩니다.
결국 OnRep 타이밍과 GameState 초기화 경합으로 인해 발생한 불안정·중복 문제는,
SetTimerForNextTick와 가드 플래그(bAddedToLobby)의 조합으로 간단하고 확실하게 해결되었습니다.
이 패턴은 로비처럼 복제 직후 다른 서브시스템과 상호작용이 필요한 환경에서 재사용성이 높고, 디버깅과 유지보수를 모두 단순화하는 매우 실용적인 접근 방식임을 확인했습니다.
다만, 초기화 시 발생하는 문제이므로 성능 상의 이슈가 크지는 않으나, 매 프레임마다 호출되는 것은 비효율적일 수 있으므로, 정해진 시간마다 호출하는 것을 고려할 수 있을 것 같습니다.