언리얼 Object Pooling

yys·2026년 6월 12일

TIL

목록 보기
58/76

Object Pooling


게임에서 액터를 동적으로 생성하고 파괴하는 작업은 생각보다 비용이 크다.
새 오브젝트를 만들 때는 메모리를 할당하고 초기화하는 과정을 거치고, 파괴할 때는 시스템이 사용하던 메모리 공간을 정리해야 한다.

총알처럼 동일한 형태의 오브젝트가 짧은 시간에 대량으로 생성·파괴되는 상황이라면 이 과정이 계속 반복되면서 CPU에 부하가 쌓이고, 순간적인 프레임 드랍으로 이어질 수 있다.

오브젝트 풀링(Object Pooling) 은 이런 비용을 줄이기 위한 패턴이다.

  • 게임 시작 시 필요한 오브젝트를 미리 생성해서 보관해 둔다.
  • 오브젝트가 필요해지면 보관 중인 것을 원하는 위치로 옮기고 활성화한다.
  • 사용이 끝나면 파괴하는 대신 비활성화해서 다시 보관함으로 되돌린다.

즉, Spawn/Destroy를 반복하는 대신 만들어 둔 오브젝트를 계속 재사용하는 방식이다.

Object Pool 관리법


풀을 관리하는 주체는 여러 형태로 구현할 수 있다.

  • 월드 서브시스템: 레벨 단위 생명주기. 어디서든 GetWorldSubsystem으로 접근 가능
  • 레벨 스크립트 액터: 해당 레벨 전용. 별도 클래스 없이 간단하게 구성
  • 게임모드에 컴포넌트: GameMode에 풀 로직을 부착. GameMode 의존성이 생김
  • 월드에 매니저 액터를 배치: 에디터 디테일 패널에서 설정하기 편하지만, 레벨마다 배치 필요

이번에는 World Subsystem 방식으로 구현했다.
매니저 액터를 레벨마다 배치할 필요가 없고, 엔진이 월드 생성 시 자동으로 인스턴스를 만들어 주기 때문에 어떤 블루프린트에서든 Get World Subsystem 노드 하나로 풀에 접근할 수 있다.

시스템 구조

세 개의 클래스가 역할을 나눠서 협력한다.

  • ObjectPoolSubsystem: 풀 전체를 관리하는 매니저. 배출/회수/재생성을 모두 담당
  • PooledObject: 풀링되는 액터마다 부착. 사용 중 여부를 추적하고, 스스로 반납을 요청할 수 있음
  • PooledObjectData: 풀 하나의 정보를 담고 있는 구조체.
// PooledObjectData.h
USTRUCT(BlueprintType)
struct FPooledObjectData
{
	GENERATED_BODY()

	FPooledObjectData()
	{
		ActorTemplate = nullptr;
		PoolSize = 1;
		bCanGrow = false;
		ActorName = "default";
	}

	//어떤 액터 스폰?
	UPROPERTY(EditAnywhere)
	TSubclassOf<AActor> ActorTemplate;

	//몇 개 스폰?
	UPROPERTY(EditAnywhere)
	int PoolSize;

	//없으면 추가로 생성할 예정인지?
	UPROPERTY(EditAnywhere)
	bool bCanGrow;

	//액터 이름
	UPROPERTY(EditAnywhere)
	FString ActorName;
};

PooledObject 코드보기

풀링되는 액터에 붙는 컴포넌트다.
이 컴포넌트가 실제로 사용 중인지 체크해 주고, 이 컴포넌트를 통해서 반납 요청이 가능하다.

// PooledObject.h
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class CONTAINERTEST_API UPooledObject : public UActorComponent
{
	GENERATED_BODY()

public:

	void Init(class UObjectPoolSubsystem* Owner);

	// 다시 풀로 넣어서 재활용
	UFUNCTION(BlueprintCallable)
	void RecycleSelf();
    
	// 풀이 사용 중인가?
	UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly)
	bool bIsPoolActive;

private:

	TObjectPtr<class UObjectPoolSubsystem> ObjectPool;

	virtual void OnComponentDestroyed(bool bDestroyingHierarchy) override;
};
// PooledObject.cpp
void UPooledObject::Init(UObjectPoolSubsystem* Owner)
{
	bIsPoolActive = false;
	ObjectPool = Owner;
}

void UPooledObject::RecycleSelf()
{
	ObjectPool->RecyclePooledObject(this);
}

void UPooledObject::OnComponentDestroyed(bool bDestroyingHierarchy)
{
	ObjectPool->OnPoolerCleanup.RemoveDynamic(this, &UPooledObject::RecycleSelf);
	Super::OnComponentDestroyed(bDestroyingHierarchy);
}

ObjectPoolSubsystem 코드보기

UWorldSubsystem을 상속받기 때문에 레벨에 배치할 필요가 없다.
대신 액터 버전과 달리 에디터 디테일 패널이 없으므로, 풀 구성은 InitializePools()를 통해 외부에서 전달받는다.

// ObjectPoolSubsystem.h
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FPoolerCleanupSignature);

USTRUCT(BlueprintType)
struct FSingleObjectPool
{
	GENERATED_BODY()

	UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly)
	TArray<TObjectPtr<class UPooledObject>> PooledObjects;
};

UCLASS()
class CONTAINERTEST_API UObjectPoolSubsystem : public UWorldSubsystem
{
	GENERATED_BODY()

public:

	UPROPERTY()
	FPoolerCleanupSignature OnPoolerCleanup;

	// 레벨 블루프린트 등 외부에서 풀 레시피를 넘겨 초기화
	UFUNCTION(BlueprintCallable)
	void InitializePools(const TArray<FPooledObjectData>& InPoolData);

	UFUNCTION(BlueprintCallable)
	void Broadcast_PoolerCleanup();

	UFUNCTION(BlueprintCallable)
	AActor* GetPooledActor(FString Name);

	UFUNCTION(BlueprintCallable)
	void RecyclePooledObject(class UPooledObject* PoolCompRef);

	UFUNCTION(BlueprintCallable)
	void RecycleActor(AActor* PooledActor);

	virtual void Deinitialize() override;

protected:

	UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
	TArray<FPooledObjectData> PooledObjectData;

	UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
	TArray<FSingleObjectPool> Pools;

private:

	void SpawnSingleActor(int32 PoolIndex, FSingleObjectPool& PoolEntry);
	void RegenItem(int32 PoolIndex, int32 PositionIndex);
};
// ObjectPoolSubsystem.cpp
void UObjectPoolSubsystem::InitializePools(const TArray<FPooledObjectData>& InPoolData)
{
	PooledObjectData = InPoolData;
	Pools.Reset();

	for (int32 PoolIndex = 0; PoolIndex < PooledObjectData.Num(); PoolIndex++)
	{
		FSingleObjectPool NewPool;
		for (int32 i = 0; i < PooledObjectData[PoolIndex].PoolSize; i++)
		{
			SpawnSingleActor(PoolIndex, NewPool);
		}
		Pools.Add(NewPool);
	}
}

void UObjectPoolSubsystem::SpawnSingleActor(int32 PoolIndex, FSingleObjectPool& PoolEntry)
{
	FActorSpawnParameters SpawnParams;
	SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;

	AActor* SpawnedActor = GetWorld()->SpawnActor(
		PooledObjectData[PoolIndex].ActorTemplate,
		&FVector::ZeroVector, &FRotator::ZeroRotator,
		SpawnParams
	);
	if (!SpawnedActor) { return; }

	UPooledObject* PoolComp = NewObject<UPooledObject>(SpawnedActor);
	PoolComp->RegisterComponent();
	SpawnedActor->AddInstanceComponent(PoolComp);
	PoolComp->Init(this);
	PoolEntry.PooledObjects.Add(PoolComp);

	//보관 상태로 전환 : 숨기고, 충돌 끄고, 틱 끄고, 물리 끄기
	SpawnedActor->SetActorHiddenInGame(true);
	SpawnedActor->SetActorEnableCollision(false);
	SpawnedActor->SetActorTickEnabled(false);
	if (UPrimitiveComponent* PrimComp = Cast<UPrimitiveComponent>(SpawnedActor->GetRootComponent()))
	{
		PrimComp->SetSimulatePhysics(false);
	}
}

void UObjectPoolSubsystem::Broadcast_PoolerCleanup()
{
	OnPoolerCleanup.Broadcast();
}

AActor* UObjectPoolSubsystem::GetPooledActor(FString Name)
{
	int32 CurrentPoolIndex = -1;

	//요청한 이름이 존재하는지 확인
	for (int32 i = 0; i < Pools.Num(); i++)
	{
		if (PooledObjectData[i].ActorName == Name)
		{
			CurrentPoolIndex = i;
			break;
		}
	}

	//존재하지않다면 나가기
	if (CurrentPoolIndex == -1) { return nullptr; }

	int32 FirstAvailable = -1;
	int32 PooledObjectCount = Pools[CurrentPoolIndex].PooledObjects.Num();

	//Active중이지 않은 첫번째 애를 찾기
	for (int32 i = 0; i < PooledObjectCount; i++)
	{
		if (Pools[CurrentPoolIndex].PooledObjects[i] != nullptr)
		{
			if (!Pools[CurrentPoolIndex].PooledObjects[i]->bIsPoolActive)
			{
				FirstAvailable = i;
				break;
			}
		}
		else //비어있다면 황급히 그 인덱스에 만들기
		{
			RegenItem(CurrentPoolIndex, i);
			FirstAvailable = i;
			break;
		}
	}

	if (FirstAvailable >= 0)
	{
		UPooledObject* ToReturn = Pools[CurrentPoolIndex].PooledObjects[FirstAvailable];
		ToReturn->bIsPoolActive = true;

		//회수 예약
		OnPoolerCleanup.AddUniqueDynamic(ToReturn, &UPooledObject::RecycleSelf);

		//보관하며 꺼두었던 상태를 전부 복원
		AActor* ToReturnActor = ToReturn->GetOwner();
		ToReturnActor->SetActorHiddenInGame(false);
		ToReturnActor->SetActorEnableCollision(true);
		ToReturnActor->SetActorTickEnabled(true);
		if (UPrimitiveComponent* PrimComp = Cast<UPrimitiveComponent>(ToReturnActor->GetRootComponent()))
		{
			PrimComp->SetSimulatePhysics(true);
		}
		return ToReturnActor;
	}

	//CanGrow가 켜져있다면 새로 만들어서 주는 로직 수행
	if (!PooledObjectData[CurrentPoolIndex].bCanGrow) { return nullptr; }

	SpawnSingleActor(CurrentPoolIndex, Pools[CurrentPoolIndex]);

	UPooledObject* NewComp = Pools[CurrentPoolIndex].PooledObjects.Last();
	if (!NewComp) { return nullptr; }

	NewComp->bIsPoolActive = true;
	OnPoolerCleanup.AddUniqueDynamic(NewComp, &UPooledObject::RecycleSelf);

	AActor* NewActor = NewComp->GetOwner();
	NewActor->SetActorHiddenInGame(false);
	NewActor->SetActorEnableCollision(true);
	NewActor->SetActorTickEnabled(true);
	if (UPrimitiveComponent* PrimComp = Cast<UPrimitiveComponent>(NewActor->GetRootComponent()))
	{
		PrimComp->SetSimulatePhysics(true);
	}
	return NewActor;
}

//최종적으로 정리해주는 로직
void UObjectPoolSubsystem::RecyclePooledObject(UPooledObject* PoolCompRef)
{
	OnPoolerCleanup.RemoveDynamic(PoolCompRef, &UPooledObject::RecycleSelf);
	PoolCompRef->bIsPoolActive = false;

	AActor* ReturningActor = PoolCompRef->GetOwner();
	ReturningActor->SetActorHiddenInGame(true);
	ReturningActor->SetActorEnableCollision(false);
	ReturningActor->SetActorTickEnabled(false);
	if (UPrimitiveComponent* PrimComp = Cast<UPrimitiveComponent>(ReturningActor->GetRootComponent()))
	{
		PrimComp->SetSimulatePhysics(false);
	}
}

//블루프린트나 외부에서 호출해주면 반납해줌
void UObjectPoolSubsystem::RecycleActor(AActor* PooledActor)
{
	if (UPooledObject* PoolCompRef = Cast<UPooledObject>(PooledActor->GetComponentByClass(UPooledObject::StaticClass())))
	{
		RecyclePooledObject(PoolCompRef);
	}
}

//아이템 만들어주기
void UObjectPoolSubsystem::RegenItem(int32 PoolIndex, int32 PositionIndex)
{
	FSingleObjectPool TempPool;
	SpawnSingleActor(PoolIndex, TempPool);
	if (TempPool.PooledObjects.Num() > 0)
	{
		Pools[PoolIndex].PooledObjects.Insert(TempPool.PooledObjects[0], PositionIndex);
	}
}

void UObjectPoolSubsystem::Deinitialize()
{
	OnPoolerCleanup.Clear();
	Pools.Reset();
	PooledObjectData.Reset();
	Super::Deinitialize();
}

매니저 액터 방식에서는 보관할 때 풀 액터에 AttachToActor로 매달아 아웃라이너에서 한눈에 볼 수 있었지만, Subsystem은 액터가 아니므로 Attach 없이 Hidden + Collision/Tick/Physics Off 상태로 월드에 그대로 보관한다.

테스트

테스트를 위해 물리 시뮬레이션이 켜진 BP_Actor를 하나 만들고, 레벨 블루프린트에서 다음과 같이 구성했다.

  • BeginPlayGet World Subsystem(ObjectPoolSubsystem)Initialize Pools에 레시피 배열을 전달해 한 번만 초기화
  • 타이머(1초 루프)Get Pooled Actor("default") → Return Value를 Set Actor Location의 Target에 연결해 TargetPoint 위치로 이동

실행해 보면 풀에서 꺼내진 액터가 1초마다 TargetPoint 위치에 나타나는 것을 확인할 수 있다.

profile
게임 개발 지망생

0개의 댓글