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

[UE5 팀 프로젝트] 지연 초기화: 런타임 액터 스폰, BeginPlay 호출 전 DataTable 데이터 무결성 확보

반응형

🎮 구현 목표

플레이어가 스폰 테이블과 상호작용하면 재료 액터를 동적으로 생성합니다. 재료는 타입(토마토, 양파 등)에 따라 다른 메시와 데이터를 가지며, 모든 플레이어에게 동일하게 보여야 합니다.

 

 

💭 구현 방법

재료 시스템을 어떻게 설계할 것인가?

 

재료마다 고유한 메시, 아이콘, 조리 상태가 필요한데, 이를 어떻게 관리할지 두 가지 방안을 고민했습니다.

 

A안: BP 클래스를 여러 개 만들기

BP_Tomato (메시, 아이콘, 상태 모두 내장)
BP_Onion (메시, 아이콘, 상태 모두 내장)
BP_Lettuce (메시, 아이콘, 상태 모두 내장)
...

장점:

  • 구현 간단: BP에서 모든 값 설정
  • 에디터에서 바로 확인 가능

 

단점:

  • ❌ 재료마다 BP 클래스 추가 (30종 → 30개 BP)
  • ❌ 공통 로직 수정 시 모든 BP 수정 필요
  • ❌ 스폰 테이블도 재료 수만큼 매칭 필요 
  • // 스폰 테이블마다 하드코딩 if (TableType == ESpawnTableType::Tomato) SpawnActor(BP_Tomato); else if (TableType == ESpawnTableType::Onion) SpawnActor(BP_Onion); // 30개 분기문...

 

B안: 데이터 기반 설계

재료 클래스: 1개 (껍데기, Type과 State만 가짐)
DataTable: 재료 정보 모두 등록 (메시, 아이콘, 조리 데이터)

 

장점:

  • ✅ 재료 추가 시 DataTable에 Row 추가만
  • ✅ 밸런싱/수정이 엑셀 작업처럼 간편
  • ✅ 코드 수정 없이 기획 변경 대응

단점:

  • 초기 구현 복잡도 높음
  • 런타임에 DataTable 조회 필요

그럼에도 데이터 기반으로 설계를 하는 것이 추후 확장성을 고려했을 때 더 적절한 판단이라고 생각했습니다.

 

 

 

🚨 문제 상황

데이터 기반 설계의 함정

// 설계: 재료는 Type만 가지고, 나머지는 DataTable에서 조회
class AIngredient
{
    UPROPERTY(Replicated)
    EIngredientType IngredientType;  // Enum만 복제
};

// BeginPlay에서 DataTable 조회
void AIngredient::BeginPlay()
{
    FIngredientData* Data = DataTable->FindRow(IngredientType);
    SetMesh(Data->Mesh);
    SetTexture(Data->Icon);
}

 

 

스폰 흐름:

1. 스폰 테이블 상호작용 (클라이언트)
2. Server RPC 호출
3. 서버가 재료 스폰 (SpawnActor)
4. 재료의 Type 설정
5. BeginPlay 호출 → DataTable 조회

 

발생한 버그:
스폰을 요청한 클라이언트 화면에만 재료가 보이고, 다른 플레이어들은 안 보임.

디버깅 결과, 다른 클라이언트에서는 IngredientType == None으로 조회되어 메시가 설정되지 않았습니다.

 

 

🔧 구현

 

네트워크 복제 타이밍 문제

 

원인:

서버: SpawnActor → Type 설정 → BeginPlay → DataTable 조회 ✅
클라: SpawnActor → BeginPlay → Type 복제 (아직 안 됨!) ❌

 

클라이언트는 BeginPlay가 먼저 호출되고, Type은 그 이후에 복제되므로 DataTable 조회가 None으로 실패합니다.

 

 

 

해결: SpawnActorDeferred

 

"BeginPlay 전에 프로퍼티를 먼저 설정"할 수 있는 지연 스폰을 사용:

AIngredient* USpawnManageComponent::SpawnIngredientActor(
    TSubclassOf IngredientClass, 
    EIngredientType Type)
{
    // 1. 메모리만 할당 (BeginPlay 호출 안 함)
    FTransform SpawnTransform;
    AIngredient* Ingredient = GetWorld()->SpawnActorDeferred(
        IngredientClass, 
        SpawnTransform
    );

    // 2. BeginPlay 전에 Type 설정 (Replicated 변수)
    Ingredient->SetType(Type);

    // 3. 이제 BeginPlay 호출 → 모든 클라에 Type이 이미 복제됨
    Ingredient->FinishSpawning(SpawnTransform);

    return Ingredient;
}

 

 

실행 흐름:

서버: SpawnDeferred → SetType → FinishSpawning → BeginPlay ✅
클라: (복제 대기) → Type 복제 완료 → BeginPlay ✅

FinishSpawning 호출 전에 SetType으로 Replicated 변수를 설정하면, BeginPlay가 호출될 때 이미 모든 클라이언트에 Type이 복제된 상태입니다.

 

 

// Ingredient.cpp
void AIngredient::SetType_Implementation(EIngredientType Type)
{
    // Replicated 변수 설정 (BeginPlay 전)
    IngredientType = Type;
}

void AIngredient::BeginPlay()
{
    // 이제 모든 클라에서 IngredientType이 올바르게 설정됨
    FIngredientData* Data = DataTable->FindRow(IngredientType);
    SetMesh(Data->Mesh);
    SetTexture(Data->Icon);
}

 

 

✅ 결과

 

모든 클라이언트에 재료 정상 표시
데이터 기반 설계 유지: 재료 BP 클래스 1개 + DataTable로 모든 재료 타입 관리
네트워크 복제 타이밍 이해: BeginPlay 전후의 실행 순서 파악

일반 SpawnActor는 즉시 BeginPlay를 호출하므로, Replicated 변수 설정이 복제보다 늦을 수 있습니다. SpawnActorDeferred를 사용하면:

  1. 메모리 할당
  2. 프로퍼티 설정 (복제 준비)
  3. FinishSpawning → BeginPlay 호출

이 순서로 실행되어 BeginPlay 시점에 이미 모든 데이터가 복제된 상태를 보장할 수 있습니다. 동적 스폰이 필요한 멀티플레이어 게임에서 필수 패턴입니다.

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유