데이터 기반 설계

김혁·2025년 8월 15일

챌린지

목록 보기
5/14

레퍼런스

하드 레퍼런스

특징

  • 참조하는 에셋이 항상 같이 로드됨
  • 에디터 상에서 직접 변수에 에셋을 넣는 경우 대부분 하드 레퍼런스
  • 패키지 빌드 시 레퍼런스 체인을 통해 의존성이 자동 포함됨

장점

  • 즉시 사용 가능 (로딩이 되어있음)
  • 런타임에서 null 참조 문제를 줄여줌

단점

  • 메모리 사용량 증가 (안 쓰는 에셋도 무조건 로드)
  • 초기 로딩 시간 증가 (참조가 많을수록 느려짐)
  • 대규모 프로젝트에서 의도치 않은 참조 체인이 생기면 용량 폭발

예시

UCLASS()
class AMyCharacter : public ACharacter
{
	UPROPERTY(EditDefaultsOnly)
    USkeletalMesh* CharacterMesh;
    
    UPROPERTY(EditDefaultsOnly)
    UTexture2D* CharacterIcon;
}

소프트 레퍼런스

특징

  • 참조는 하지만, 즉시 로드하지 않음
  • 참조는 문자열 경로나 TSoftObjectPtr / TSoftClassPtr로 저장됨
  • 필요할 때 LoadSynchronous() 또는 StreamableManager를 통해 비동기 로드 가능

장점

  • 초기 메모리 사용량 절감 (필요할 때만 로드)
  • 대규모 프로젝트에서 의존성 최소화
  • DLC나 모듈형 콘텐츠 관리에 유리

단점

  • 사용 전 반드시 로드 과정을 거쳐야 함
  • 로딩 타이밍을 잘못 설계하면 프레임 드랍 발생 가능

예시

UCLASS()
class AMyCharacter : public ACharacter
{
	UPROPERTY(EditDefaultsOnly)
    TSoftObjectPtr<USkeletalMesh> CharacterMesh;
    
    UPROPERTY(EditDefaultsOnly)
    TSoftObjectPtr<UTexture2D> CharacterIcon;
}

사용법

1. 동기(Synchronous) 로드

  • 해당 리소스를 로딩하는 시간을 기다리기 때문에 게임 실행이 멈춤
  • 보장된 시점에 리소스를 사용할 수 있기 때문에 안전함
  • 대용량 리소스 로드 시 프레임이 끊기거나 멈춤 현상이 발생
  • 따라서 로딩 화면, 맵 전환 시점 같이 플레이어가 기다려도 되는 구간에서 사용
void ALyraCharacter::EquipCharacterMesh()
{
	if (!CharacterMesh.IsNull())
    {
    	USkeletalMesh* LoadedMesh = CharacterMesh.LoadSynchronous();
        
        if (LoadedMesh)
        {
        	GetMesh()->SetSkeletalMesh(LoadedMesh);
        }
    }
}

2. 비동기(Asynchronous) 로드

  • 로드 과정이 백그라운드에서 진행됨
  • 완료 콜백을 통해 로드가 끝난 시점에만 사용 가능
  • 로드 중에도 게임이 계속 진행되기 때문에 끊김이 최소화됨
  • 따라서 게임이 진행되는 동안 필요한 리소스를 미리 불러오거나, 긴급하지 않은 리소스 로드 시 사용됨
void ALyraCharacter::EquipCharacterMeshAsync()
{
    if (!CharacterMesh.IsNull())
    {
        FStreamableManager& Streamable = UAssetManager::GetStreamableManager();
        
        UE_LOG(LogTemp, Log, TEXT("메시 로드 시작..."));
        
        Streamable.RequestAsyncLoad(
            CharacterMesh.ToSoftObjectPath(),
            FStreamableDelegate::CreateWeakLambda(this, [this]()
            {
                // 로드가 끝나면 실행
                if (USkeletalMesh* LoadedMesh = CharacterMesh.Get())
                {
                    GetMesh()->SetSkeletalMesh(LoadedMesh);
                    UE_LOG(LogTemp, Log, TEXT("메시 로드 완료!"));
                }
                else
                {
                    UE_LOG(LogTemp, Error, TEXT("메시 로드 실패!"));
                }
            })
        );
    }
}

언리얼의 효율적인 자원 관리

Primary Asset

개념

  • Asset Manager가 특별 관리하는 중요 에셋
  • 프로젝트에서 주요하게 식별되고 관리될 대상으로 고유한 ID를 가지게 됨

특징

  • 고유한 Primary Asset ID로 식별됨
  • AssetManager에서 중앙 관리하고, 보통 UPrimaryDataAsset을 상속한 클래스 인스턴스로 사용
  • 필요할 때만 로드 가능 (소프트 레퍼런스 기반)

장점

  • Soft Object Path로만 참조 -> 필요할 때 LoadPrimaryAsset으로 로드
  • 로딩/언로딩 타이밍 제어 가능
  • DLC나 라이브 서비스 환경에서 유연한 리소스 패치 가능

예시

class UWeaponDataAsset : public UPrimaryDataAsset
{
    GENERATED_BODY()
    
public:
    virtual FPrimaryAssetId GetPrimaryAssetId() const override
    {
        return FPrimaryAssetId(TEXT("Weapon"), GetFName());
    }
    
    UPROPERTY(EditDefaultsOnly)
    TSoftObjectPtr<USkeletalMesh> WeaponMesh;
    
    UPROPERTY(EditDefaultsOnly)
    TSoftObjectPtr<UTexture2D> WeaponIcon;
};

Bundle System

개념

  • 하나의 Primary Asset 안에서 로드할 리소스를 묶어서 관리

예시 상황

  • 예를 들어 "AK47" 무기 데이터가 있다면
    • 게임플레이에 필요한 최소 리소스: 총기 메시, 사운드
    • 프리뷰 모드에서만 필요한 리소스: 텍스처, HUD 아이콘
  • 이를 Bundle로 구분해 관리할 수 있다.

장점

  • 특정 상황에서만 필요한 리소스만 로드
  • 메모리 절약
  • 로딩 속도 최적화

예시

namespace EAssetBundle
{
	const FName Mesh("Mesh");
    const FName Sound("Sound");
    const FName Text2D("Text2D");
    const FName HUDIcon("HUDIcon");
}

// 게임 플레이 상황
UAssetManager::Get().LoadPrimaryAsset(
	WeaponId,
    {EAssetBundle::Mesh, EAssetBundle::Sound}
);

// 프리뷰 모드
UAssetManager::Get().LoadPrimaryAsset(
	WeaponId,
    {EAssetBundle::Text2D, EAssetBundle::HUDIcon}
);

Lyra의 데이터 중심 설계

Level 1 - 값 추출

  • 목표 : 하드코딩된 숫자를 외부 파일로 분리

기존 방식의 문제

: 숫자가 코드에 있어서 수정하려면 직접 코드를 보거나, 에디터에서 수정을 해야 함

class AWeapon : public AActor
{
    float Damage = 100.0f; 
    float FireRate = 0.5f; 
    float Range = 1000.0f; 
};

해결법

1. 구조체 정의

USTRUCT(BlueprintType)
struct FWeaponStats : public FTableRowBase  // 중요! FTableRowBase 상속
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere)
    float Damage = 10.0f;

    UPROPERTY(EditAnywhere)
    float FireRate = 1.0f;

    UPROPERTY(EditAnywhere)
    float Range = 500.0f;
};

2. CSV 파일 생성

Name,Damage,FireRate,Range
Pistol,25,0.3,800
Rifle,40,0.1,1500
Shotgun,100,1.0,300

3. 에디터에서 임포트

  • CSV 파일을 DataTable로 변환해서 저장
  • Row Structure에서 FWeaponStats 선택해서 import

4. 코드에서 사용

class AWeapon : public AActor
{
    UPROPERTY(EditDefaultsOnly)
    UDataTable* WeaponStatsTable;

    UPROPERTY(EditAnywhere)
    FName WeaponType = "Pistol";

    float Damage;
    float FireRate;
    float Range;

    void BeginPlay()
    {
        if (WeaponStatsTable)
        {
            FWeaponStats* Stats = WeaponStatsTable->FindRow<FWeaponStats>(
                WeaponType,
                TEXT("WeaponStats")
            );

            if (Stats)
            {
                Damage = Stats->Damage;
                FireRate = Stats->FireRate;
                Range = Stats->Range;
            }
        }
    }
};

Level 2 - 동작 추상화

  • 목표 : 행동 방식 자체를 데이터로 교체

문제 상황

void AWeapon::Fire()
{
    if (WeaponType == "Pistol")
    {
        SpawnProjectile();
    }
    else if (WeaponType == "Rifle")
    {
        for (int i = 0; i < 3; i++)
        {
            SpawnProjectile();
            Wait(0.1f);
        }
    }
    else if (WeaponType == "Shotgun")
    {
        for (int i = 0; i < 8; i++)
        {
            SpawnProjectileWithSpread();
        }
    }
}

해결법

DataAsset 정의

UCLASS(BlueprintType)
class UWeaponDataAsset : public UPrimaryDataAsset
{
    GENERATED_BODY()

public:
    UPROPERTY(EditDefaultsOnly, Category = "Info")
    FText WeaponName;

    UPROPERTY(EditDefaultsOnly, Category = "Info")
    FText Description;

	// 주 발사
    UPROPERTY(EditDefaultsOnly, Category = "Combat")
    TSoftClassPtr<UGameplayAbility> PrimaryFireAbility;  

	// 보조 발사
    UPROPERTY(EditDefaultsOnly, Category = "Combat")
    TSoftClassPtr<UGameplayAbility> SecondaryFireAbility; 

    // 비주얼
    UPROPERTY(EditDefaultsOnly, Category = "Visual")
    TSoftObjectPtr<UStaticMesh> WeaponMesh;

    // 애니메이션
    UPROPERTY(EditDefaultsOnly, Category = "Animation")
    TSoftObjectPtr<UAnimMontage> FireMontage;

    // 이펙트
    UPROPERTY(EditDefaultsOnly, Category = "Effects")
    TSoftObjectPtr<UNiagaraSystem> MuzzleFlash;

    // Primary Asset ID
    virtual FPrimaryAssetId GetPrimaryAssetId() const override
    {
        return FPrimaryAssetId(TEXT("Weapon"), GetFName());
    }
};

다양한 발사 Ability 만들기

UCLASS()
class UGA_Fire_Single : public UGameplayAbility
{
    void ActivateAbility(...)
    {
        SpawnProjectile();
    }
};

UCLASS()
class UGA_Fire_Burst : public UGameplayAbility
{
    void ActivateAbility(...)
    {
        for (int i = 0; i < 3; i++)
        {
            SpawnProjectile();
            Wait(0.1f);
        }
    }
};

무기 장착 시 적용

void ACharacter::EquipWeapon(UWeaponDataAsset* WeaponData)
{
    if (!WeaponData) return;

    // 1. Ability 로드 및 부여
    if (!WeaponData->PrimaryFireAbility.IsNull())
    {
        // 소프트 레퍼런스 로드
        TSubclassOf<UGameplayAbility> AbilityClass =
            WeaponData->PrimaryFireAbility.LoadSynchronous();

        if (AbilityClass)
        {
            // 기존 발사 능력 제거
            if (CurrentFireAbilityHandle.IsValid())
            {
                AbilitySystemComponent->ClearAbility(CurrentFireAbilityHandle);
            }

            // 새 발사 능력 부여
            FGameplayAbilitySpec AbilitySpec(AbilityClass, 1, 0, this);
            CurrentFireAbilityHandle = AbilitySystemComponent->GiveAbility(AbilitySpec);

            UE_LOG(LogTemp, Warning, TEXT("Fire ability granted!"));
        }
    }

    // 2. 비주얼 로드
    if (!WeaponData->WeaponMesh.IsNull())
    {
        UStaticMesh* Mesh = WeaponData->WeaponMesh.LoadSynchronous();
        if (Mesh && WeaponMeshComponent)
        {
            WeaponMeshComponent->SetStaticMesh(Mesh);
        }
    }

    // 3. 애니메이션 저장
    if (!WeaponData->FireMontage.IsNull())
    {
        CurrentFireMontage = WeaponData->FireMontage.LoadSynchronous();
    }
}

Level 3 - 정의 데이터화

  • 목표 : 게임 자체를 데이터로 정의

계층 구조

Experience (게임 모드) : 게임 모드를 새로 정의
   ├── PawnData (캐릭터 정의)
   │   ├── Abilities
   │   ├── InputConfig
   │   └── Components
   └── GameFeatures

Experience 정의

UCLASS()
class ULyraExperienceDefinition : public UPrimaryDataAsset
{
    GENERATED_BODY()

public:
    // 이 게임 모드의 이름
    UPROPERTY(EditDefaultsOnly, Category = "Info")
    FText ExperienceName;

    // 사용 가능한 캐릭터들
    UPROPERTY(EditDefaultsOnly, Category = "Gameplay")
    TMap<FGameplayTag, UPawnDataAsset*> AvailablePawns;

    // 게임 규칙
    UPROPERTY(EditDefaultsOnly, Category = "Rules")
    TSubclassOf<AGameMode> GameModeClass;

    // 추가 기능들 (리스폰, 점수, 스톰 등)
    UPROPERTY(EditDefaultsOnly, Category = "Features")
    TArray<FString> GameFeaturesToLoad;

    // UI
    UPROPERTY(EditDefaultsOnly, Category = "UI")
    TSoftClassPtr<UHUD> HUDClass;

    // 기본 맵
    UPROPERTY(EditDefaultsOnly, Category = "Level")
    TSoftObjectPtr<UWorld> DefaultMap;

    virtual FPrimaryAssetId GetPrimaryAssetId() const override
    {
        return FPrimaryAssetId(TEXT("Experience"), GetFName());
    }
};

Experience 로드 및 적용

void LoadExperience(FString ExperienceName)
{
    // Primary Asset ID로 찾기
    FPrimaryAssetId ExpId(TEXT("Experience"), ExperienceName);

    // Asset Manager에게 로드 요청
    UAssetManager& Manager = UAssetManager::Get();

	// Experience 로드
    Manager.LoadPrimaryAsset(ExpId, {},
        FStreamableDelegate::CreateLambda([this, ExpId]()
        {
            OnExperienceLoaded(ExpId);
        })
    );
}

void OnExperienceLoaded(FPrimaryAssetId ExpId)
{
    // 로드된 Experience 가져오기
    ULyraExperienceDefinition* Experience =
        UAssetManager::Get().GetPrimaryAssetObject<ULyraExperienceDefinition>(ExpId);

    if (!Experience)
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to load experience!"));
        return;
    }

    // 1. 게임 규칙 적용
    UE_LOG(LogTemp, Warning, TEXT("Setting game mode..."));
    World->SetGameMode(Experience->GameModeClass);

    // 2. 사용 가능한 캐릭터 설정
    AvailableCharacters = Experience->AvailablePawns;
    UE_LOG(LogTemp, Warning, TEXT("Available characters: %d"),
        AvailableCharacters.Num());

    // 3. 게임 기능 활성화
    for (const FString& Feature : Experience->GameFeaturesToLoad)
    {
        /** GameFeature 활성화 코드 **/
    }

    // 4. HUD 생성
    if (!Experience->HUDClass.IsNull())
    {
        TSubclassOf<UHUD> HUDClass = Experience->HUDClass.LoadSynchronous();
        /** HUD 생성 코드 **/
    }

    CurrentExperience = Experience;
}

PawnData 정의

UCLASS()
class UPawnDataAsset : public UPrimaryDataAsset
{
    GENERATED_BODY()

public:
    // 캐릭터 이름
    UPROPERTY(EditDefaultsOnly, Category = "Info")
    FText CharacterName;

    // 붙일 컴포넌트들
    UPROPERTY(EditDefaultsOnly, Category = "Components")
    TArray<TSoftClassPtr<UActorComponent>> Components;

    // 캐릭터 능력들
    UPROPERTY(EditDefaultsOnly, Category = "Abilities")
    TArray<TSoftClassPtr<UGameplayAbility>> Abilities;

    // 입력 설정
    UPROPERTY(EditDefaultsOnly, Category = "Input")
    UInputConfig* InputConfig;

    virtual FPrimaryAssetId GetPrimaryAssetId() const override
    {
        return FPrimaryAssetId(TEXT("PawnData"), GetFName());
    }
};

PawnData 적용

void ApplyPawnData(ALyraCharacter* Character, UPawnDataAsset* PawnData)
{
    // 1. Component 붙이기
    for (TSoftClassPtr<UActorComponent> CompClass : PawnData->Components)
    {
        if (!CompClass.IsNull())
        {
            TSubclassOf<UActorComponent> ComponentClass = CompClass.LoadSynchronous();

            if (ComponentClass)
            {
                UActorComponent* NewComp = NewObject<UActorComponent>(
                    Character, ComponentClass
                );

                // 월드에 등록 (중요!)
                NewComp->RegisterComponentWithWorld(GetWorld());
            }
        }
    }

    // 2. 능력 부여
    for (TSoftClassPtr<UGameplayAbility> AbilityClass : PawnData->Abilities)
    {
        if (!AbilityClass.IsNull())
        {
            TSubclassOf<UGameplayAbility> Ability = AbilityClass.LoadSynchronous();

            if (Ability)
            {
                Character->AbilitySystemComponent->GiveAbility(
                    FGameplayAbilitySpec(Ability, 1, INDEX_NONE, Character)
                );
            }
        }
    }

    // 3. 입력 설정
    if (PawnData->InputConfig)
    {
        Character->SetInputConfig(PawnData->InputConfig);
    }
}


출처: 팀스파르타 내일배움캠프
profile
게임 개발자를 향해..

0개의 댓글