본문 바로가기
내배캠/CH3 팀프로젝트(ProjectFT)

[UE5 C++] WorldSubsystem과 GameplayMessage를 활용한 드롭 아이템 스폰 및 오브젝트 풀링(Pooling) 최적화

by 4.41p._.m 2026. 7. 9.

📘 [UE5 C++] 인벤토리 학습 노트 - 4단계: 글로벌 스폰 및 풀링 시스템 [UFTItemPoolSubsystem]

월드 상에 드롭되는 아이템 액터들을 중앙에서 일괄 제어하고 최적화하는 아이템 풀서브시스템의 동작 원리를 이해하고, 전역 매니저로서 왜 UWorldSubsystem을 채택했는지, 라인트레이스 지형 탐색, 그리고 가비지 컬렉터 부하를 최소화하는 오브젝트 풀링의 실제 구현부와 트러블 슈팅을 다룬다.

 


1. 월드 서브시스템 도입 이유

왜 싱글톤이나 전역 매니저 액터를 배치하지 않았는가?

월드 상의 스폼을 관리할 매니저를 개발할 때 흔히 사용되는 대안과 그 한계는 다음과 같다.

 

1. 정적 싱글톤 패턴:

엔진 라이프사이클과 맞물려 있어 레벨이 언로드되거나 월드가 전환될 때 데이터가 좀비처럼 남아 메모리 누수를 유발한다. 또한 멀티플레이 세션 연결 시 세션 데이터 분리가 어렵다.

2. 레벨 배치용 매니저 액터:

레벨마다 매니저 액터를 디자이너가 직접 배치해두어야 작동하므로 실수가 발생하기 쉽고, 레벨이 바뀔 때마다 참조 복구가 까다롭다.

 

UWorldSubsystem

언리얼 5의 UWorldSubsystem은 위 한계들을 극복할 수 있는 전역 매니저 클래스이다.

· 월드 동기화 수명 주기: 월드가 로드될 때 엔진에 의해 자동으로 인스턴스가 생성되고, 레벨이 닫히거나 게임이 종료될 때 자동으로 파괴되어 누수를 차단한다.

· 배치 생략: 별도의 액터 배치나 복잡한 초기화 바인딩 없이 `GetWorld()->GetSubsystem<UFTItemPoolSubsystem>()` 한 줄만으로 언제 어디서든 접근할 수 있다.

 


2. GameplayMessage를 활용한 전역 드롭 요청 라우팅

인벤토리 컴포넌트에서 발행한 드롭 요청 메시지(`TAG_FT_Request_DropItem`)를 수신하기 위한 바인딩 원리이다.

수명 주기 연동 리스너 등록

void UFTItemPoolSubsystem::OnWorldBeginPlay(UWorld& InWorld)
{
	Super::OnWorldBeginPlay(InWorld);

	// 1. 에디터 프리뷰 월드 등이 아닌 게임 월드(PIE 포함)일 때만 가동
	if (InWorld.IsGameWorld())
	{
		UGameplayMessageSubsystem& MessageSubsystem = UGameplayMessageSubsystem::Get(this);
		
		// 2. 드롭 요청 채널("Gameplay.Request.DropItem") 구독 등록
		DropItemListenerHandle = MessageSubsystem.RegisterListener<FFTMessagePayloadStruct>(
			TAG_FT_Request_DropItem,
			this,
			&UFTItemPoolSubsystem::HandleDropItemMessage
		);
		
		UE_LOG(LogFTItem, Log, TEXT("UFTItemPoolSubsystem: DropItem 요청 구독 등록 완료 (Game World)"));
	}
}

 


3. 지형 인식 드롭 위치 계산 (Line Trace)

플레이어가 아이템을 버렸을 때 허공에 둥둥 떠서 어색한 공중 상태가 되는 것을 물리적으로 보정하기 위해 라인 트레이싱(Line Tracing)을 통해 바닥 높이를 탐색한다.

구현 분석

FVector UFTItemPoolSubsystem::CalculateDropLocation(AActor* InstigatorActor) const
{
    if (!InstigatorActor) return FVector::ZeroVector;

    FVector StartLoc = InstigatorActor->GetActorLocation();
    FVector ForwardDir = InstigatorActor->GetActorForwardVector();

    // 플레이어 전방 1.2m, 좌우 랜덤 오프셋 부여 (아이템 뭉침 방지)
    FVector DropOffset = (ForwardDir * 120.0f) + (InstigatorActor->GetActorRightVector() * FMath::FRandRange(-30.0f, 30.0f));
    FVector TargetLoc = StartLoc + DropOffset;

    // 바닥 검출 레이저 발사
    FHitResult HitResult;
    FVector TraceStart = TargetLoc + FVector(0.0f, 0.0f, 50.0f);	// 시작: 전방 위치의 약간 위쪽 (+50cm)
    FVector TraceEnd = TargetLoc - FVector(0.0f, 0.0f, 500.0f);		// 끝: 아래 방향 5m 지점 (-500cm)

    FCollisionQueryParams QueryParams;
    QueryParams.AddIgnoredActor(InstigatorActor);	// 자기 자신(플레이어) 충돌 무시

    UWorld* World = GetWorld();
    // 3. 레이저가 가시성 채널(ECC_Visibility)의 충돌체(지반, 테이블 등)와 만나는지 판별
    if (World && World->LineTraceSingleByChannel(HitResult, TraceStart, TraceEnd, ECC_Visibility, QueryParams))
    {
        // 충돌했다면 충돌 바닥 지점에서 아이템 반경을 감안해 살짝 띄운 Z값 반환
        return HitResult.Location + FVector(0.0f, 0.0f, 15.0f);
    }

    // 충돌 실패 시 원래 공중 위치 반환
    return TargetLoc;
}

 


4. 오브젝트 풀링 (Object Pooling)

아이템을 버릴 때마다 매번 SpawnActor를 하고 주울 때마다 Destroy를 호출하게 되면, 엔진은 액터 소멸 및 가비지 수거 연산으로 인해 프레임 드랍을 발생시킨다. 이를 방지하고자 액터를 메모리에 보관하여 재사용한다.

1) 풀에서 획득 및 활성화

AFTItemActor* UFTItemPoolSubsystem::AcquireItemActor(FName ItemId, const FVector& Location, const FRotator& Rotation)
{
    // (생략: 에셋 로드 및 유효성 검사)
    AFTItemActor* TargetActor = nullptr;

    // 1. 풀에 재사용 가능한 유효한 액터가 있는지 확인
    while (InactivePool.Num() > 0)
    {
        TObjectPtr<AFTItemActor> PooledActor = InactivePool.Pop();
        if (IsValid(PooledActor))
        {
            TargetActor = PooledActor;
            break;
        }
    }

    if (TargetActor)
    {
        // 2. 풀에서 꺼낸 액터 활성화(물리/콜리전/가시성 원복)
        TargetActor->SetActorLocationAndRotation(Location, Rotation);
        TargetActor->ItemData = ItemDataAsset;
        TargetActor->UpdateAppearance();	// 새 아이템 외형으로 갱신

        TargetActor->SetActorHiddenInGame(false);
        TargetActor->SetActorTickEnabled(true);

        if (UStaticMeshComponent* MeshComp = TargetActor->FindComponentByClass<UStaticMeshComponent>())
        {
            MeshComp->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
            MeshComp->SetCollisionProfileName(TEXT("PhysicsBody"));
            MeshComp->SetSimulatePhysics(true);
            MeshComp->WakeRigidBody(); // 잠든 물리 엔진 수면 모드 깨우기
        }

        UE_LOG(LogFTItem, Log, TEXT("아이템 풀 재사용 성공: '%s' 획득 완료"), *ItemId.ToString());
    }
    else
    {
        // 3. 풀이 비어있으면 새로 생성
        FActorSpawnParameters SpawnParams;
        SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;

        TargetActor = World->SpawnActor<AFTItemActor>(AFTItemActor::StaticClass(), Location, Rotation, SpawnParams);
        if (TargetActor)
        {
            TargetActor->ItemData = ItemDataAsset;
            TargetActor->UpdateAppearance();

            UE_LOG(LogFTItem, Log, TEXT("아이템 풀 신규 스폰: '%s' 생성 완료"), *ItemId.ToString());
        }
    }

    return TargetActor;
}

 


2) 풀에 반환 및 비활성화

void UFTItemPoolSubsystem::ReleaseItemActor(AFTItemActor* ItemActor)
{
    if (!ItemActor) return;

    // 최대 보관 개수를 초과하면 진짜 파괴
    if (InactivePool.Num() >= MaxPoolSize)
    {
        UE_LOG(LogFTItem, Log, TEXT("아이템 풀 포화 상태 (현재 크기: %d): 액터 '%s'를 실제 Destroy 처리합니다."), InactivePool.Num(), *ItemActor->GetName());
        ItemActor->Destroy();
        return;
    }

    // 액터 비활성화 처리
    ItemActor->SetActorHiddenInGame(true);
    ItemActor->SetActorTickEnabled(false);

    // 물리 시뮬레이션 및 콜리전 끄기
    if (UStaticMeshComponent* MeshComp = ItemActor->FindComponentByClass<UStaticMeshComponent>())
    {
        MeshComp->SetSimulatePhysics(false);
        MeshComp->SetCollisionEnabled(ECollisionEnabled::NoCollision);
        MeshComp->SetPhysicsLinearVelocity(FVector::ZeroVector);
        MeshComp->SetPhysicsAngularVelocityInDegrees(FVector::ZeroVector);
        // 스태틱 메시 할당을 해제하여 사용하지 않는 그래픽 자원 즉시 언로드 유도
        MeshComp->SetStaticMesh(nullptr); 
    }

    // 데이터 참조 해제
    ItemActor->ItemData = nullptr;

    // 안전을 위해 멀리 떨어진 공간으로 위치 이동
    ItemActor->SetActorLocation(FVector(0.0f, 0.0f, -99999.0f));

    // 풀에 반환 적재
    InactivePool.Add(ItemActor);
    UE_LOG(LogFTItem, Log, TEXT("아이템 풀 반환 완료: '%s' (현재 풀 크기: %d/%d)"), *ItemActor->GetName(), InactivePool.Num(), MaxPoolSize);
}

 


5. [트러블슈팅] 초기화 및 소멸 수명 주기 오류 해결

트러블슈팅 1: 에디터 켬/끔 시점 및 레벨 로드 시 Router nullptr 어설션 크래시

· 문제 상황: 에셋이나 맵을 편집하다가 꺼지거나 PIE(플레이인에디터)를 실행/종료할 때 갑자기 엔진이 Assert 실패로 죽는 현상

· 원인: UWorldSubsystem::Initialize 함수 안에서 GameplayMessageSubsystem::Get(this)를 호출했기 때문이었다. 이 초기화 시점은 언리얼의 게임 월드/에디터 월드 생성 초기 단계이므로, 메시지를 전달해 주는 시스템인 `Router`가 메모리에 탑재되기도 전이다.

· 해결책: 등록 시점을 서브시스템과 라우터가 모두 완벽히 활성화된 단계인 [OnWorldBeginPlay]로 연기하고, InWorld.IsGameWorld() 가드를 세워 에디터 뷰포트 월드나 프리뷰 씬에서의 리스너 불필요 생성을 차단했다.

트러블슈팅 2: 월드 종료(Teardown) 단계 크래시

· 문제 상황: 게임을 끌 때 Deinitialize() 함수에서 UnregisterListener를 수행하다가 또 Router 관련 널 크래시가 유발

· 원인: 월드가 파괴되는 시점(Deinitialize)에는 이미 GameplayMessageSubsystem 내부의 핵심 라우터가 소멸한 후였기 때문이었다. 서브시스템의 파괴 순서상 UWorldSubsystem이 한참 늦게 파괴되어 스테일(Stale) 상태의 서브시스템을 참조해 에러가 났다.

· 해결책: 어차피 메시지 라우터 자체가 소멸되고 월드가 가비지 컬렉팅되어 통째로 해제되는 시점이기 때문에 굳이 강제로 리스너 해제를 부를 필요가 없으므로 Deinitialize() 내부에서 크래시를 유발하는 UnregisterListener 정적 함수 호출을 제거하여 근본적으로 해결했다. (어차피 리스너 인스턴스 정보도 메모리에서 함께 휩쓸려 사라지기 때문에 메모리 누수가 없다.)