프로젝트 회고 / / 2025. 12. 3. 00:23

[Dedicated Server] 네트워크 복제 대역폭 최적화: ```Fast Array Serializer```를 활용한 배열 요소 단위 델타 복제(Delta Replication)

반응형

🎮 구현 목표

로비에서 플레이어 목록을 실시간으로 동기화합니다. 누군가 입장/퇴장하거나 준비 버튼을 누를 때마다 변경된 플레이어 정보만 네트워크로 전송하여 대역폭을 절약합니다.

 

🚨 문제 상황

 

배열 전체 복제의 낭비

 

 

일반적인 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()만 추가하면 끝
반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유