TIL: Unreal C++ 사이드프로젝트 31일차

박춘팔·2026년 5월 14일

언리얼 TIL

목록 보기
30/34

누적 학습 시간 : 308시간 34분

📅 2026-05-14

무한성이라고 불리던 스테이지를 일찍 탈출한 6명이서 사이드 프로젝트를 진행하기로 했다.
순전히 기능 공부를 위함이고 출시계획같은건 당연하게도 없다.

프로젝트 진행기간 : 26.05.11 ~ 26.05.21

주제는 3D 뱀파이어 서바이벌이다.
개발 진행상황은 레포지토리 참조

MVP 스팩을 크게 6가지로 나눴다.
스킬 / 캐릭터 / 적 / 시스템(웨이브, 스포너) / 아이템 / UI
나는 그 중 시스템(웨이브, 스포너)를 담당하게 됐다.

Enemy Spawn Object Pooling 구조로 바꾸기

이번 작업에서는 기존 Enemy 스폰 방식을 SpawnActor / Destroy 중심 구조에서 Object Pooling 기반 구조로 전환했다.

Vampire Survivors류 게임은 짧은 시간 안에 많은 적이 생성되고 사라진다. 이때 매번 Actor를 생성하고 파괴하면 성능 비용이 커질 수 있다.

그래서 Enemy가 죽었을 때 바로 Destroy()하지 않고, 비활성화한 뒤 풀에 반환하고, 다음 스폰 때 다시 꺼내 쓰는 구조를 추가했다.


기존 구조의 문제

기존 Enemy 흐름은 단순했다.

  1. Enemy Spawn
  2. SpawnedEnemiesList에 등록
  3. Enemy 사망
  4. Destroy() 호출
  5. CleanupInvalidEnemies()에서 !IsValid(Enemy) 기준으로 리스트 정리

기존에는 Actor가 죽으면 Destroy되었기 때문에 IsValid()만으로도 리스트 정리가 가능했다.

하지만 풀링 구조에서는 Enemy가 죽어도 Actor가 파괴되지 않는다.

대신 다음과 같은 상태가 된다.

  • Actor는 여전히 존재함
  • IsValid(Enemy) == true
  • 화면에서는 숨김 처리됨
  • Collision 비활성화
  • Tick 비활성화
  • Pool의 Idle 상태로 들어감

즉 기존처럼 !IsValid(Enemy)만 검사하면, 이미 죽어서 풀에 들어간 Enemy가 SpawnedEnemiesList에 계속 남는다.

나중에 리스트 기반으로 살아있는 적 수를 계산하거나 Wave 진행 조건을 판단하면, 죽은 Enemy도 살아있는 Enemy처럼 카운트될 수 있다.

이번 변경의 핵심은 이 문제를 해결하는 것이었다.


PoolSubsystem 추가

먼저 Actor Pool을 관리할 UPoolSubsystem을 추가했다.

PoolSubsystemUWorldSubsystem으로 구현했다. 그래서 GameMode나 Spawner에 컴포넌트처럼 붙이지 않는다.

필요한 곳에서 다음처럼 가져와 사용한다.

UPoolSubsystem* PoolSubsystem = GetWorld()->GetSubsystem<UPoolSubsystem>();

풀은 ActorClass 기준으로 관리한다.

USTRUCT()
struct FActorPool
{
	GENERATED_BODY()

	UPROPERTY()
	TArray<TObjectPtr<AActor>> IdlePool;
};
UPROPERTY()
TMap<TSubclassOf<AActor>, FActorPool> IdlePools;

BP_EnemyBase_C 풀, 다른 Enemy Blueprint 풀을 각각 따로 관리할 수 있다.


PrewarmPool

PrewarmPool()은 게임 시작 시 특정 ActorClass를 미리 생성해 IdlePool에 넣는 함수다.

void UPoolSubsystem::PrewarmPool(TSubclassOf<AActor> ActorClass, int32 Count)
{
	if (!ActorClass || Count <= 0 || !GetWorld())
	{
		return;
	}

	FActorPool& Pool = IdlePools.FindOrAdd(ActorClass);

	for (int32 i = 0; i < Count; ++i)
	{
		FActorSpawnParameters SpawnParams;
		SpawnParams.SpawnCollisionHandlingOverride =
			ESpawnActorCollisionHandlingMethod::AlwaysSpawn;

		AActor* Actor = GetWorld()->SpawnActor<AActor>(
			ActorClass,
			FVector::ZeroVector,
			FRotator::ZeroRotator,
			SpawnParams
		);

		if (!Actor)
		{
			UE_LOG(LogTemp, Warning, TEXT("[Pool] Prewarm spawn failed: %s"), *ActorClass->GetName());
			continue;
		}

		PrepareActorForPool(Actor);
		Pool.IdlePool.Add(Actor);
	}

	UE_LOG(LogTemp, Warning, TEXT("[Pool] Prewarm %s / Available: %d"),
		*ActorClass->GetName(),
		Pool.IdlePool.Num());
}

여기서 중요한 부분은 AlwaysSpawn이다.

Prewarm 시점에는 Actor를 생성한 뒤 바로 숨기고 Collision을 끌 것이기 때문에, 스폰 위치 충돌 때문에 생성이 실패할 필요가 없다.

SpawnParams.SpawnCollisionHandlingOverride =
	ESpawnActorCollisionHandlingMethod::AlwaysSpawn;

GetActorFromPool

Enemy를 스폰할 때는 직접 SpawnActor하지 않고 PoolSubsystem에서 Actor를 꺼낸다.

AActor* UPoolSubsystem::GetActorFromPool(TSubclassOf<AActor> ActorClass, const FTransform& SpawnTransform)
{
	if (!ActorClass || !GetWorld())
	{
		return nullptr;
	}

	FActorPool& Pool = IdlePools.FindOrAdd(ActorClass);

	AActor* Actor = nullptr;

	while (Pool.IdlePool.Num() > 0)
	{
		Actor = Pool.IdlePool.Pop();

		if (IsValid(Actor))
		{
			break;
		}

		Actor = nullptr;
	}

	if (!Actor)
	{
		Actor = GetWorld()->SpawnActor<AActor>(ActorClass, SpawnTransform);
	}

	if (!Actor)
	{
		return nullptr;
	}

	PrepareActorForUse(Actor, SpawnTransform);

	UE_LOG(LogTemp, Warning, TEXT("[Pool] Get %s / Available: %d / Active: %d"),
		*ActorClass->GetName(),
		Pool.IdlePool.Num(),
		IdlePools.Num());

	return Actor;
}

흐름은 다음과 같다.

  1. IdlePool에 남은 Actor가 있으면 꺼낸다.
  2. 유효하지 않은 Actor면 버린다.
  3. IdlePool이 비어 있으면 새로 Spawn한다.
  4. Actor를 사용할 수 있는 상태로 전환한다.

PrewarmCount는 최대 제한이 아니라 “미리 만들어둘 개수”다.

예를 들어 PrewarmCount = 300인데 동시에 301번째 Enemy가 필요하면, 301번째는 그 순간 새로 Spawn된다. 이후 죽으면 다시 Pool에 들어가므로 Pool 크기는 301개까지 늘어날 수 있다.


ReturnActorToPool

Enemy가 죽으면 Destroy하지 않고 Pool로 반환한다.

void UPoolSubsystem::ReturnActorToPool(AActor* Actor)
{
	if (!IsValid(Actor))
	{
		return;
	}

	PrepareActorForPool(Actor);

	IdlePools.FindOrAdd(Actor->GetClass()).IdlePool.Add(Actor);
}

Actor를 Pool에 넣기 전에는 비활성 상태로 만든다.

void UPoolSubsystem::PrepareActorForPool(AActor* Actor)
{
	if (!IsValid(Actor))
	{
		return;
	}

	if (IPoolable* Poolable = Cast<IPoolable>(Actor))
	{
		Poolable->ReturnToPool();
	}

	Actor->SetActorHiddenInGame(true);
	Actor->SetActorEnableCollision(false);
	Actor->SetActorTickEnabled(false);
}

반대로 Pool에서 꺼낼 때는 다시 활성화한다.

void UPoolSubsystem::PrepareActorForUse(AActor* Actor, const FTransform& SpawnTransform)
{
	if (!IsValid(Actor))
	{
		return;
	}

	Actor->SetActorTransform(SpawnTransform);
	Actor->SetActorHiddenInGame(false);
	Actor->SetActorEnableCollision(true);
	Actor->SetActorTickEnabled(true);

	if (IPoolable* Poolable = Cast<IPoolable>(Actor))
	{
		Poolable->GetFromPool();
	}
}

IPoolable 인터페이스

PoolSubsystem은 모든 Actor의 내부 상태를 알 수 없다.

Enemy는 풀에서 나올 때 체력을 초기화해야 하고, 접촉 데미지 대상 목록도 비워야 한다. Projectile은 Projectile 나름의 초기화가 필요할 수 있다.

그래서 풀링 대상 Actor가 직접 초기화 로직을 구현할 수 있도록 IPoolable 인터페이스를 추가했다.

UINTERFACE(MinimalAPI)
class UPoolable : public UInterface
{
	GENERATED_BODY()
};

class VAMPIRESURVIVAL_API IPoolable
{
	GENERATED_BODY()

public:
	virtual void GetFromPool()
		PURE_VIRTUAL(IPoolable::GetFromPool,);

	virtual void ReturnToPool()
		PURE_VIRTUAL(IPoolable::ReturnToPool,);
};

이제 PoolSubsystem은 Actor가 IPoolable이면 다음 두 타이밍에 알려준다.

  • Pool에서 꺼낼 때: GetFromPool()
  • Pool로 반환할 때: ReturnToPool()

PoolableComponent 추가

풀링 구조에서 중요한 문제는 “Actor가 유효한가?”와 “게임상 살아있는가?”가 다르다는 점이다.

풀에 들어간 Enemy는 여전히 유효하다.

IsValid(Enemy) == true

하지만 게임상으로는 죽은 Enemy다.

그래서 별도의 상태값이 필요했다. 이를 위해 PoolableComponent를 추가했다.

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class VAMPIRESURVIVAL_API UPoolableComponent : public UActorComponent
{
	GENERATED_BODY()

public:
	UPoolableComponent();

	void SetPoolStatus(bool Status);
	bool GetPoolStatus() const;

protected:
	bool isIdle = true;
};
bool UPoolableComponent::GetPoolStatus() const
{
	return isIdle;
}

void UPoolableComponent::SetPoolStatus(bool Status)
{
	isIdle = Status;
}

현재는 bool로 idle 상태만 관리한다.

  • true: Pool 안에 있음
  • false: 현재 사용 중

나중에는 이름을 IsIdle() / SetIdle()처럼 바꾸면 더 읽기 좋아질 수 있다.


EnemyBase 변경

EnemyBase는 이제 IPoolable을 구현한다.

class VAMPIRESURVIVAL_API AEnemyBase : public ACharacter, public IHitable, public IPoolable

그리고 PoolableComponent를 가진다.

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Enemy|Component")
TObjectPtr<UPoolableComponent> PoolableComponent;

생성자에서는 컴포넌트를 생성한다.

PoolableComponent = CreateDefaultSubobject<UPoolableComponent>(TEXT("PoolableComponent"));

Death에서 Destroy 대신 Pool 반환

기존에는 Enemy가 죽으면 바로 Destroy()했다.

풀링 구조에서는 먼저 PoolSubsystem에 반환을 시도한다.

void AEnemyBase::Death()
{
	if (UWorld* World = GetWorld())
	{
		if (UPoolSubsystem* PoolSubsystem = World->GetSubsystem<UPoolSubsystem>())
		{
			PoolSubsystem->ReturnActorToPool(this);
			return;
		}
	}

	Destroy();
}

PoolSubsystem을 가져오지 못한 경우에는 fallback으로 Destroy()를 호출한다.


EnemyBase의 GetFromPool / ReturnToPool

Enemy가 Pool에서 다시 나올 때는 전투 상태를 초기화해야 한다.

void AEnemyBase::GetFromPool()
{
	ContactDamageTargets.Empty();

	if (PoolableComponent)
	{
		PoolableComponent->SetPoolStatus(false);
	}

	if (HitableComponent)
	{
		HitableComponent->Initialize(MaxHP);
	}

	TargetActor = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0);
}

풀에서 나올 때 하는 일은 다음과 같다.

  • 접촉 데미지 대상 목록 초기화
  • idle 상태 해제
  • 체력 재초기화
  • 플레이어 타겟 재설정

반대로 Pool로 돌아갈 때는 임시 상태를 비운다.

void AEnemyBase::ReturnToPool()
{
	ContactDamageTargets.Empty();
	TargetActor = nullptr;

	if (PoolableComponent)
	{
		PoolableComponent->SetPoolStatus(true);

		UE_LOG(LogTemp, Warning, TEXT("Enemy Returned To Pool: %s"), *GetName());
	}
}

여기서 PoolableComponent의 상태를 idle로 바꾸는 것이 중요하다.

이 값은 나중에 SpawnedEnemiesList 정리에 사용된다.


EnemySpawner 변경

기존 EnemySpawner는 Enemy를 직접 Spawn했다.

AEnemyBase* SpawnedEnemy = GetWorld()->SpawnActor<AEnemyBase>(...);

이제는 PoolSubsystem에서 꺼내온다.

UPoolSubsystem* PoolSubsystem = GetWorld()->GetSubsystem<UPoolSubsystem>();
if (!PoolSubsystem)
{
	return;
}

AActor* PooledActor = PoolSubsystem->GetActorFromPool(
	EnemyData->EnemyClass,
	FTransform(FRotator::ZeroRotator, SpawnLocation)
);

AEnemyBase* SpawnedEnemy = Cast<AEnemyBase>(PooledActor);

여기서 중요한 점은 EnemyData->EnemyClass를 기준으로 Pool에서 꺼낸다는 것이다.

현재 프로젝트는 Bat, Slime 같은 Enemy 차이를 별도 ActorClass로 나누기보다 EnemyDataAsset으로 주입하는 구조다. 따라서 Pool에서 꺼낸 뒤에는 현재 EnemyData를 다시 적용한다.

if (SpawnedEnemy)
{
	SpawnedEnemy->InitializeFromData(EnemyData);
	SpawnedEnemiesList.AddUnique(SpawnedEnemy);

	UE_LOG(LogTemp, Warning, TEXT("SpawnedEnemiesList Count After Spawn: %d"), SpawnedEnemiesList.Num());
}

Add() 대신 AddUnique()를 사용한 이유는 풀링 구조에서는 같은 Actor가 여러 번 재사용되기 때문이다.

만약 정리 타이밍이 어긋나면 같은 Enemy Actor가 리스트에 중복 등록될 수 있다. AddUnique()는 이 문제를 막는 안전장치다.


SpawnedEnemiesList 정리 방식 변경

기존 정리 방식은 단순했다.

SpawnedEnemiesList.RemoveAll([](const TObjectPtr<AActor>& Enemy)
{
	return !IsValid(Enemy);
});

하지만 풀링 구조에서는 이 조건만으로 부족하다. 풀로 돌아간 Enemy는 여전히 유효하기 때문이다.

그래서 PoolableComponent의 idle 상태도 함께 확인한다.

void AEnemySpawner::CleanupInvalidEnemies()
{
	const int32 BeforeCount = SpawnedEnemiesList.Num();

	SpawnedEnemiesList.RemoveAll([](const TObjectPtr<AActor>& Enemy)
	{
		if (!IsValid(Enemy))
		{
			return true;
		}

		const UPoolableComponent* PoolableComponent = Enemy->FindComponentByClass<UPoolableComponent>();
		if (PoolableComponent && PoolableComponent->GetPoolStatus())
		{
			return true;
		}

		return false;
	});

	const int32 AfterCount = SpawnedEnemiesList.Num();

	UE_LOG(LogTemp, Warning, TEXT("Cleanup SpawnedEnemiesList: %d -> %d"), BeforeCount, AfterCount);
}

이제 제거 조건은 두 가지다.

  1. Actor가 더 이상 유효하지 않다.
  2. Actor는 유효하지만 Pool에 들어간 idle 상태다.

이 변경으로 SpawnedEnemiesList는 “현재 활성화된 Enemy 목록”에 가까운 의미를 갖게 된다.


Prewarm 위치 조정

처음에는 Wave가 바뀔 때마다 WaveData를 읽어서 필요한 EnemyClass들을 Prewarm하는 방식도 고려했다.

하지만 현재 프로젝트 구조에서는 대부분의 Enemy가 같은 BP_EnemyBase_C를 사용한다.

즉 Bat, Slime, Skeleton이 서로 다른 ActorClass가 아니라 같은 EnemyBase Blueprint를 공유하고, 실제 차이는 EnemyDataAsset으로 적용된다.

그래서 Wave마다 Prewarm할 필요가 없다.

현재 방향은 EnemySpawner에 기본 Prewarm 설정을 두고, BeginPlay()에서 한 번만 Pool을 생성하는 것이다.

UPROPERTY(EditAnywhere, Category = "Spawner|Pool")
TSubclassOf<AEnemyBase> PrewarmEnemyClass;

UPROPERTY(EditAnywhere, Category = "Spawner|Pool")
int32 PrewarmCount = 300;
void AEnemySpawner::BeginPlay()
{
	Super::BeginPlay();

	CachedPlayerCharacter = Cast<APlayerCharacter>(UGameplayStatics::GetPlayerCharacter(this, 0));
	if (!CachedPlayerCharacter)
	{
		UE_LOG(LogTemp, Error, TEXT("Player character not found"));
	}

	if (!PrewarmEnemyClass || PrewarmCount <= 0)
	{
		return;
	}

	UPoolSubsystem* PoolSubsystem = GetWorld()->GetSubsystem<UPoolSubsystem>();
	if (!PoolSubsystem)
	{
		return;
	}

	PoolSubsystem->PrewarmPool(PrewarmEnemyClass, PrewarmCount);
}

주의할 점은 PrewarmEnemyClassEnemyData->EnemyClass가 반드시 같아야 한다는 것이다.

예를 들어 Prewarm은 C++ AEnemyBase로 하고, 실제 스폰은 BP_EnemyBase_C로 하면 서로 다른 풀로 취급된다.

따라서 에디터에서 EnemySpawner의 PrewarmEnemyClass에는 실제 EnemyDataAsset들이 사용하는 BP_EnemyBase_C를 넣어야 한다.


디버그 로그로 확인한 흐름

테스트 중에는 다음 흐름을 로그로 확인했다.

[Pool] Prewarm BP_EnemyBase_C / Available: 300
[Pool] Get BP_EnemyBase_C / Available: 299
SpawnedEnemiesList Count After Spawn: 1
[Pool] Get BP_EnemyBase_C / Available: 298
SpawnedEnemiesList Count After Spawn: 2

Available이 줄어든다는 것은 새로 Spawn하는 것이 아니라, 미리 생성해둔 Pool에서 꺼내고 있다는 뜻이다.

Enemy가 죽으면 다음 흐름을 기대한다.

Enemy Returned To Pool: BP_EnemyBase_C_12
Cleanup SpawnedEnemiesList: 10 -> 9

이렇게 나오면 Enemy가 Destroy되지 않고 Pool로 돌아가며, SpawnedEnemiesList에서도 제거되고 있다는 뜻이다.


현재 구조의 주의점

현재 GetActorFromPool() 로그의 Active 값은 실제 Active Enemy 수가 아니다.

IdlePools.Num()

은 활성 Actor 수가 아니라 Pool에 등록된 ActorClass 종류 개수에 가깝다.

따라서 정확한 사용량을 보려면 나중에 다음과 같은 값이 필요하다.

TMap<TSubclassOf<AActor>, int32> TotalCreatedCounts;

그 후 다음처럼 계산할 수 있다.

Active = TotalCreated - Available;

또한 현재 PrewarmCount는 최대 제한이 아니다.

PrewarmCount = 300이어도 301번째 Enemy가 필요하면 새로 Spawn된다. 그리고 그 Enemy가 죽으면 Pool에 들어가므로 Pool 크기는 301까지 늘어날 수 있다.

즉 현재 구조는 고정 크기 풀이 아니라 동적 확장 풀이다.


정리

이번 변경으로 Enemy 스폰 구조는 단순 생성/파괴 방식에서 재사용 가능한 Pooling 구조로 바뀌었다.

핵심은 단순히 Destroy()를 없애는 것이 아니었다.

풀링 구조에서는 Actor가 살아 있어도 게임상으로는 죽은 Enemy일 수 있다. 따라서 게임 로직이 이 상태를 이해할 수 있어야 한다.

이를 위해 다음 요소를 추가했다.

  • UPoolSubsystem
  • IPoolable
  • UPoolableComponent
  • EnemyBase의 Pool 반환 처리
  • EnemySpawner의 Pool 기반 스폰 처리
  • SpawnedEnemiesList의 idle Enemy 정리

결과적으로 Enemy는 죽어도 Actor가 파괴되지 않고 Pool로 돌아가며, 다음 스폰 때 재사용된다.

또한 PoolableComponent의 idle 상태를 기준으로 SpawnedEnemiesList를 정리해, 죽은 Enemy가 살아있는 Enemy처럼 카운트되는 문제도 막을 수 있게 됐다.


다음 개선 방향

아직 개선할 부분도 남아 있다.

  • 개발용 로그 정리
  • Active 수 계산 개선
  • PrewarmPool()을 부족분만 생성하는 방식으로 변경
  • Pool 최대 크기 제한 검토
  • SpawnedEnemiesList 타입을 AActor에서 AEnemyBase 중심으로 변경
  • ReturnToPool 시 Spawner에 이벤트로 알리는 방식 검토

현재 단계에서는 Enemy Pooling의 핵심 흐름이 잡혔다.
다음 단계에서는 디버그용 코드를 정리하고, Pool 사용량을 더 정확하게 추적하는 방향으로 개선하면 좋을 것 같다.

profile
이것 저것 다해보는 삶

0개의 댓글