1. 문제의 시작: PlayerState와 AttributeComponent의 역할 분리
저희 프로젝트는 언리얼 엔진의 권장 아키텍처를 따르기 위해 플레이어의 핵심 데이터를 다음과 같이 분리했습니다.
- UAFAttributeComponent (Character/Pawn 소속): 체력, 마나 등의 실제 계산 로직 (피해 적용, 치유, 자원 소모)을 담당합니다. 이는 캐릭터의 생명 주기와 밀접하게 연결됩니다.
- AAFPlayerState (PlayerController 소속): 플레이어의 영속적인 상태 데이터 (스코어, 레벨, 체력/마나의 복제본)를 담고, 클라이언트의 UI 갱신을 담당합니다.
이러한 분리는 깔끔한 설계이지만, AttributeComponent의 초기값 (100)을 PlayerState의 복제 변수로 전달하는 과정에서 치명적인 '시점 문제(Timing Issue)'가 발생했습니다.
2. 발생한 오류: "PlayerState를 찾을 수 없습니다"
서버는 캐릭터가 스폰되자마자 AttributeComponent의 초기 체력 값(100)을 PlayerState로 전달하여 클라이언트에게 복제시키려 했습니다.
APlayerCharacter::OnRep_PlayerState()나 UAFAttributeComponent::BeginPlay() 시점에 동기화 함수 (SyncHealthToPlayerState)를 호출했습니다. 하지만 서버 콘솔에는 계속해서 다음 로그가 발생했습니다.
LogTemp: Warning: Try Attribute Sync
LogTemp: Warning: Failed Attribute Sync2: No Base PlayerState found yet.
이는 서버의 동기화 함수가 실행되는 시점에도, PawnOwner->GetPlayerState()가 nullptr을 반환했다는 것을 의미합니다.
원인 분석: 언리얼 엔진은 서버에서 캐릭터(APawn)를 생성한 후 PlayerState를 할당하는 과정이 비동기적으로 진행되거나, 복제 로직이 완료되는 데 한 프레임 이상의 시간이 소요될 수 있습니다. C++ 코드가 너무 빠르게 실행되어, 엔진이 PlayerState를 Pawn에 완전히 연결하기 전에 값을 찾아보려 했기 때문에 실패한 것입니다.
3. 해결 전략: '강제 반복 확인' 타이머 도입 (Retry Timer Pattern)
가장 확실하고 안전한 해결책은 'PlayerState가 유효해질 때까지 서버에서 반복적으로 시도'하는 패턴을 사용하는 것이었습니다.
이 로직은 UAFAttributeComponent 내부의 SyncHealthToPlayerState 함수에 구현되었습니다.
A. 타이머 로직 개요
- 초기 호출: APlayerController::OnPossess (가장 신뢰성 높은 서버 이벤트) 또는 APlayerCharacter::OnRep_PlayerState에서 SyncHealthToPlayerState()를 한 번만 호출하여 타이머를 시작시킵니다.
- 반복 시도: SyncHealthToPlayerState() 함수 내부에서 GetPlayerState()를 시도하고, 실패할 경우 FTimerHandle을 이용하여 0.2초마다 자기 자신을 다시 호출하도록 설정합니다.
- 성공 시 종료: GetPlayerState()가 성공하는 순간, GetWorld()->GetTimerManager().ClearTimer(HealthSyncTimerHandle)를 호출하여 타이머를 즉시 중지하고 초기값을 설정합니다.
B. 코드 (UAFAttributeComponent::SyncHealthToPlayerState)
void UAFAttributeComponent::SyncHealthToPlayerState()
{
// 1. 서버 권한 및 Pawn 확인 (생략)
if (APawn* PawnOwner = Cast<APawn>(GetOwner()))
{
if (AAFPlayerState* PS = PawnOwner->GetPlayerState<AAFPlayerState>())
{
// ★ 성공: PlayerState를 찾았으므로 타이머를 멈추고 값을 설정합니다.
GetWorld()->GetTimerManager().ClearTimer(HealthSyncTimerHandle);
PS->SetHealth(Health, MaxHealth);
UE_LOG(LogTemp, Warning, TEXT("Attribute Sync: SUCCESS! Timer Cleared."));
return;
}
}
// 2. 실패: PlayerState가 아직 없으므로 0.2초 뒤 재시도를 예약합니다.
if (!GetWorld()->GetTimerManager().IsTimerActive(HealthSyncTimerHandle))
{
GetWorld()->GetTimerManager().SetTimer(
HealthSyncTimerHandle,
this,
&UAFAFAttributeComponent::SyncHealthToPlayerState,
0.2f, // 0.2초마다 반복
true // 반복 실행
);
}
}
4. 최종 결과 및 복제 문제 해결
이 'Retry Timer' 패턴을 적용한 후, 로그는 다음과 같이 변경되었습니다.
LogTemp: Warning: Sync FAILED. Retrying in 0.2 seconds.
LogTemp: Warning: Attribute Sync: SUCCESS! Timer Cleared.
시점 문제 해결 성공!
다만, 시점 문제 해결 후에는 MaxHealth가 클라이언트에서 0.00으로 보이는 복제 설정 문제가 추가로 발견되었지만, 이는 DOREPLIFETIME(AAFPlayerState, MaxHealth) 설정을 추가하는 것으로 간단히 해결되었습니다.
이처럼 멀티플레이어 개발에서는 '언제(When)' 초기화하는지가 '무엇을(What)' 초기화하는 것만큼 중요하며, 논리적인 시점조차 실패할 경우 타이머를 이용한 '안전망 패턴'을 사용하는 것이 핵심입니다.
'언리얼엔진5 공부' 카테고리의 다른 글
| 2025-12-18 TIL: 멀티플레이어 게임 UI 시스템 및 리스폰 로직 구현 (0) | 2025.12.18 |
|---|---|
| Unreal Engine: 객체지향 설계의 핵심, Actor Component (0) | 2025.12.17 |
| [UE5] Property Replication vs RPC (0) | 2025.12.10 |
| [UE5] RepNotify vs Delegate (0) | 2025.12.09 |
| [UE5] 멀티플레이 복제 RepNotify / OnRep / ReplicatedUsing (0) | 2025.12.08 |