언리얼 엔진 최적화: 오브젝트 풀링 (Object Pooling)
1. 오브젝트 풀링(Object Pooling)이란?
- 개념: 오브젝트가 필요할 때마다 새로 만들고(생성), 다 쓰면 파괴(삭제)하는 대신, 게임 시작 시 미리 왕창 만들어 두고(Pool) 필요할 때 꺼내 쓰고 다 쓰면 다시 보관함에 반납하는 기법입니다.
- 왜 사용하는가? (도입 이유)
- 동적 생성(메모리 할당)과 삭제(가비지 컬렉션)는 CPU를 많이 쓰고 느린 작업입니다.
- 총알 난사처럼 짧은 순간에 생성/삭제가 미친 듯이 반복되면 치명적인 프레임 드랍(렉)이 발생합니다.
- 작동 방식 (총알 예시):
- 게임 시작 전, 보관함(Pool)에 총알 100개를 미리 만들어 둡니다. (비활성화/숨김 상태)
- 플레이어가 총을 쏘면, 보관함에서 총알 하나를 꺼내 총구 위치로 순간이동시키고 활성화합니다.
- 총알이 목표물에 맞거나 멀리 날아가면, 파괴하지 않고 다시 비활성화하여 보관함으로 돌려보냅니다(Recycle).
2. 오브젝트 풀링 시스템 설계 구조 (3단계)
이번에 구현한 오브젝트 풀링 시스템은 3개의 주요 스크립트로 역할이 나뉘어 있습니다.
① FPooledObjectData (설정 데이터 구조체)
풀(Pool)에 어떤 액터를 몇 개나 만들어 둘 것인지 결정하는 레시피 역할입니다.
C++
USTRUCT(BlueprintType)
struct FPooledObjectData
{
GENERATED_BODY()
FPooledObjectData() { PoolSize = 1; CanGrow = false; ActorName = "default"; }
UPROPERTY(EditAnywhere) TSubclassOf<AActor> ActorTemplate; // 스폰할 액터 (붕어빵 틀)
UPROPERTY(EditAnywhere) int PoolSize; // 미리 만들어둘 개수
UPROPERTY(EditAnywhere) bool CanGrow; // 모자라면 더 만들 것인가?
UPROPERTY(EditAnywhere) FString ActorName; // 액터의 이름
};
② UPooledObject (추적 관리용 컴포넌트)
보관함에서 찍어낸 액터들에 찰싹 달라붙어, 자신이 현재 사용 중인지 아닌지를 기록하고 반납을 요청하는 네임택 역할을 합니다.
C++
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class UPooledObject : public UActorComponent
{
GENERATED_BODY()
public:
void Init(class AMyObjectPool* Owner); // 자신을 관리하는 보관함(Pool) 기억하기
UFUNCTION(BlueprintCallable)
void RecycleSelf(); // 다 썼으니 나 좀 다시 집어넣어 달라고 요청
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly)
bool bIsPoolActive; // 현재 맵에서 활성화되어 날아가고 있는가?
private:
TObjectPtr<class AMyObjectPool> ObjectPool; // 주소 기억용
};
③ AMyObjectPool (오브젝트 풀 매니저 액터)
레시피(FPooledObjectData)를 보고 실제로 액터들을 생성하여 품고 있는 보관함 본체입니다. 플레이어가 총알을 요구하면 꺼내주고, 반납을 처리합니다.
C++
USTRUCT(BlueprintType)
struct FSingleObjectPool
{
GENERATED_BODY()
// 만들어둔 컴포넌트들을 배열로 묶어서 관리 (총알용, 파티클용 등 분리)
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly)
TArray<TObjectPtr<class UPooledObject>> PooledObjects;
};
UCLASS()
class AMyObjectPool : public AActor
{
GENERATED_BODY()
public:
// 꺼내주기와 반납받기 함수들
UFUNCTION(BlueprintCallable) AActor* GetPooledActor(FString Name);
UFUNCTION(BlueprintCallable) void RecyclePooledObject(class UPooledObject* PoolCompRef);
UFUNCTION(BlueprintCallable) void RecycleActor(AActor* PooledActor);
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<FPooledObjectData> PooledObjectData; // 에디터에서 설정할 레시피들
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
TArray<FSingleObjectPool> Pools; // 실제 생성된 오브젝트들의 보관소
};
3. 핵심 로직: 게임 시작 시 미리 만들어두기 (BeginPlay)
가장 중요한 풀(Pool) 초기화 과정입니다. 레시피를 순회하며 런타임에 액터를 생성하고, 그 액터에 동적으로 UPooledObject 컴포넌트를 붙여주는 작업이 진행됩니다.
C++
void AMyObjectPool::BeginPlay()
{
Super::BeginPlay();
FActorSpawnParameters SpawnParams;
// 1. 등록된 레시피(풀 데이터) 종류만큼 반복
for (int PoolIndex = 0; PoolIndex < PooledObjectData.Num(); PoolIndex++)
{
FSingleObjectPool CurrentPoolIndex;
SpawnParams.Name = FName(*PooledObjectData[PoolIndex].ActorName);
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; // 겹쳐도 무조건 스폰
// 2. 설정한 PoolSize(개수)만큼 반복해서 액터 찍어내기
for (int ObjectIndex = 0; ObjectIndex < PooledObjectData[PoolIndex].PoolSize; ObjectIndex++)
{
// 액터 스폰
AActor* SpawnedActor = GetWorld()->SpawnActor(PooledObjectData[PoolIndex].ActorTemplate, &FVector::ZeroVector, &FRotator::ZeroRotator, SpawnParams);
SpawnedActor->SetActorLabel(SpawnedActor->GetName()); // 에디터에서 보기 좋게 이름 세팅
// 🌟 동적으로 액터 컴포넌트 생성 및 부착 (가장 중요)
UPooledObject* PoolComp = NewObject<UPooledObject>(SpawnedActor);
PoolComp->RegisterComponent();
SpawnedActor->AddInstanceComponent(PoolComp);
// 컴포넌트 초기화 및 보관함에 담기
PoolComp->Init(this);
CurrentPoolIndex.PooledObjects.Add(PoolComp);
// 3. 당장 쓸 게 아니니까 눈에 안 보이게 숨기고 기능 끄기
SpawnedActor->SetActorHiddenInGame(true);
SpawnedActor->SetActorEnableCollision(false);
SpawnedActor->SetActorTickEnabled(false);
// 보관함(매니저) 액터에 자식으로 깔끔하게 붙여두기
SpawnedActor->AttachToActor(this, FAttachmentTransformRules::SnapToTargetNotIncludingScale);
}
Pools.Add(CurrentPoolIndex);
}
}