반응형
🎮 구현 목표
로비에서 플레이어 목록을 실시간으로 동기화합니다. 누군가 입장/퇴장하거나 준비 버튼을 누를 때마다 변경된 플레이어 정보만 네트워크로 전송하여 대역폭을 절약합니다.
🚨 문제 상황
배열 전체 복제의 낭비
일반적인 TArray Replication:
UPROPERTY(Replicated)
TArray<FLobbyPlayerInfo> PlayerList; // 10명 전체 복제
문제:
상황: 10명 접속 중, 1명이 준비 버튼 클릭
↓
Replicated Array 감지: "배열이 변경됨!"
↓
네트워크 전송: 10명 전체 정보 복제 ❌
↓
실제 필요: 1명 정보만 업데이트하면 충분
실제 측정 결과:
상황: 플레이어 10명, 각 정보 100바이트
- 일반 복제: 1,000바이트 전송 (10명 × 100바이트)
- 필요한 양: 100바이트 (1명만)
→ 900바이트 낭비 (90%) ❌
로비에서 준비 버튼을 연타하거나, 플레이어가 빈번하게 입/퇴장하면 불필요한 전송이 급격히 증가합니다.
💭 해결 방법
Fast Array Serializer: "변경된 것만 보낸다"
핵심 아이디어:
- 배열 원소마다 "변경 여부" 추적
- 변경된 원소만
MarkItemDirty()로 표시 - 언리얼이 자동으로 변경된 것만 복제
1. 구조체에 FastArraySerializer 상속
// LobbyPlayerInfo.h
USTRUCT(BlueprintType)
struct FLobbyPlayerInfo : public FFastArraySerializerItem
{
GENERATED_BODY()
UPROPERTY()
FString Username{};
UPROPERTY()
bool bIsReady = false;
// 변경 콜백
void PostReplicatedAdd(const FLobbyPlayerInfoArray& InArraySerializer);
void PreReplicatedRemove(const FLobbyPlayerInfoArray& InArraySerializer);
void PostReplicatedChange(const FLobbyPlayerInfoArray& InArraySerializer);
};
USTRUCT()
struct FLobbyPlayerInfoArray : public FFastArraySerializer
{
GENERATED_BODY()
UPROPERTY()
TArray<FLobbyPlayerInfo> Items;
UPROPERTY()
AGameState* GameState;
// 필수 함수
bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParams)
{
return FastArrayDeltaSerialize<FLobbyPlayerInfo, FLobbyPlayerInfoArray>(
Items, DeltaParams, *this);
}
void AddPlayer(const FLobbyPlayerInfo& NewPlayerInfo);
void RemovePlayer(const FString& Username);
void SetPlayerReady(const FString& Username, bool IsReady);
};
// 필수 템플릿 특수화
template<>
struct TStructOpsTypeTraits<FLobbyPlayerInfoArray>
: public TStructOpsTypeTraitsBase2<FLobbyPlayerInfoArray>
{
enum { WithNetDeltaSerializer = true };
};
2. GameState에서 사용
// DS_GameState.h
UCLASS()
class ADS_GameState : public AGameState
{
GENERATED_BODY()
public:
UPROPERTY(ReplicatedUsing = OnRep_PlayerList)
FLobbyPlayerInfoArray PlayerList;
UPROPERTY(BlueprintAssignable)
FOnPlayerListUpdated OnPlayerListUpdated;
void ADS_GameState::OnRep_PlayerList()
{
PlayerList.SetOwner(this);
OnPlayerListUpdated.Broadcast(); // UI 갱신
}
};
3. 플레이어 추가/제거/수정
// LobbyPlayerInfo.cpp
void FLobbyPlayerInfoArray::AddPlayer(const FLobbyPlayerInfo& NewPlayerInfo)
{
int32 Index = Items.Add(NewPlayerInfo);
// 핵심: 추가된 원소만 Dirty 표시
MarkItemDirty(Items[Index]);
// 서버에서도 콜백 호출 (UI 갱신용)
Items[Index].PostReplicatedAdd(*this);
}
void FLobbyPlayerInfoArray::SetPlayerReady(const FString& Username, bool IsReady)
{
for (FLobbyPlayerInfo& PlayerInfo : Items)
{
if (PlayerInfo.Username == Username)
{
PlayerInfo.bIsReady = IsReady;
// 핵심: 변경된 원소만 Dirty 표시
MarkItemDirty(PlayerInfo);
break;
}
}
}
void FLobbyPlayerInfoArray::RemovePlayer(const FString& Username)
{
for (int32 PlayerIndex = 0; PlayerIndex < Items.Num(); ++PlayerIndex)
{
FLobbyPlayerInfo& PlayerInfo = Items[PlayerIndex];
if (PlayerInfo.Username == Username)
{
PlayerInfo.PreReplicatedRemove(*this);
Items.RemoveAtSwap(PlayerIndex);
// 삭제는 배열 구조 변경이므로 전체 Dirty
MarkArrayDirty();
break;
}
}
}
4. 클라이언트에서 변경 감지 (UI 갱신)
// LobbyPlayerInfo.cpp
void FLobbyPlayerInfo::TriggerUpdate(const FLobbyPlayerInfoArray& InArraySerializer)
{
if (ADS_GameState* GameState = Cast<ADS_GameState>(InArraySerializer.GetOwner()))
{
// Delegate 발행 → UI 자동 갱신
GameState->OnPlayerListUpdated.Broadcast();
}
}
void FLobbyPlayerInfo::PostReplicatedAdd(const FLobbyPlayerInfoArray& InArraySerializer)
{
// 새 플레이어 추가됨
UE_LOG(LogTemp, Log, TEXT("Player joined: %s"), *Username);
TriggerUpdate(InArraySerializer);
}
void FLobbyPlayerInfo::PostReplicatedChange(const FLobbyPlayerInfoArray& InArraySerializer)
{
// 플레이어 정보 변경됨 (준비 상태 등)
UE_LOG(LogTemp, Log, TEXT("Player ready changed: %s = %d"), *Username, bIsReady);
TriggerUpdate(InArraySerializer);
}
void FLobbyPlayerInfo::PreReplicatedRemove(const FLobbyPlayerInfoArray& InArraySerializer)
{
// 플레이어 퇴장
UE_LOG(LogTemp, Log, TEXT("Player left: %s"), *Username);
TriggerUpdate(InArraySerializer);
}
동작 흐름:
[서버]
1. Player A 준비 버튼 클릭
2. PlayerList.SetPlayerReady("PlayerA", true)
3. Items[0].bIsReady = true
4. MarkItemDirty(Items[0]) ← 0번만 Dirty 표시
↓
[네트워크]
5. Items[0] 정보만 전송 (100바이트) ✅
↓
[클라이언트]
6. PostReplicatedChange() 호출
7. OnPlayerListUpdated.Broadcast()
8. UI 위젯: "PlayerA [준비 완료]" 표시
🔧 시행착오
Owner 설정 누락
PostReplicatedAdd() 콜백에서 GameState에 접근하려 했지만 nullptr 크래시:
void FLobbyPlayerInfo::PostReplicatedAdd(...)
{
// ❌ InArraySerializer.GameState가 nullptr
ADS_GameState* GS = Cast<ADS_GameState>(InArraySerializer.GameState);
}
해결: BeginPlay()와 OnRep_PlayerList()에서 Owner 설정:
void ADS_GameState::BeginPlay()
{
if (HasAuthority())
{
PlayerList.SetOwner(this); // 서버
}
}
void ADS_GameState::OnRep_PlayerList()
{
PlayerList.SetOwner(this); // 클라이언트
}
✅ 결과
✅ 네트워크 대역폭 90% 절감: 10명 중 1명만 변경 시 100바이트만 전송
✅ 자동 UI 동기화: PostReplicatedChange() 콜백으로 UI 자동 갱신
✅ 확장성: 준비 상태뿐 아니라 팀 선택, 캐릭터 선택 등 추가 가능
"배열 복제는 Fast Array Serializer로"
일반 TArray Replication:
- 배열 변경 시 전체 전송
- 10명 중 1명 변경 = 10명 전송
- 대역폭 낭비
Fast Array Serializer:
- 변경된 원소만 전송
- 10명 중 1명 변경 = 1명 전송
MarkItemDirty()만 추가하면 끝
반응형
'프로젝트 회고' 카테고리의 다른 글
| [리팩토링] OCP(개방 폐쇄 원칙) 기반 설계: 상속에서 인터페이스로 전환 (0) | 2025.12.03 |
|---|---|
| PlayerController가 입력을 처리하는게 적절한가? (0) | 2025.12.03 |
| [UE5 액션] [리팩토링] CPU 성능 최적화: Tick 의존성 해소, 이벤트 기반(Event-Driven Architecture)으로의 전환 (0) | 2025.12.02 |
| [DirectX 11] Unity 리소스 메타데이터 파싱을 통한 스프라이트 시트 관리 (0) | 2025.12.02 |
| [UE5 팀 프로젝트] Pull-Request 시행착오와 교훈 (0) | 2025.12.02 |
