상세 컨텐츠

본문 제목

타이머 실행중에 레벨이 바뀌면 에디터가 종료되는 오류

Unreal C++

by hyunjunstar 2026. 4. 21. 22:39

본문

언리얼 C++에서 타이머를 이용해서 일정 시간이 지나면 파티클이 제거되게끔 구현을 하였다.

아래는 구현한 코드

// BaseItem.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ItemInterface.h"
#include "BaseItem.generated.h"

class USphereComponent;

UCLASS()
class SPARTA_API ABaseItem : public AActor, public IItemInterface
{
    GENERATED_BODY()

public:
    ABaseItem();

protected:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
    FName ItemType;
    // 루트 컴포넌트 (씬)
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|Component")
    USceneComponent* Scene;
    // 충돌 컴포넌트 (플레이어 진입 범위 감지용)
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|Component")
    USphereComponent* Collision;
    // 아이템 시각 표현용 스태틱 메시
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|Component")
    UStaticMeshComponent* StaticMesh;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effects")
    UParticleSystem* PickupParticle;
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effects")
    USoundBase* PickupSound;

    virtual void OnItemOverlap(
        UPrimitiveComponent* OverlappedComp,
        AActor* OtherActor,
        UPrimitiveComponent* OtherComp,
        int32 OtherBodyIndex,
        bool bFromSweep,
        const FHitResult& SweepResult) override;

    virtual void OnItemEndOverlap(
        UPrimitiveComponent* OverlappedComp,
        AActor* OtherActor,
        UPrimitiveComponent* OtherComp,
        int32 OtherBodyIndex) override;

    virtual void ActivateItem(AActor* Activator) override;
    virtual FName GetItemType() const override;

    void DestroyItem();
};
// BaseItem.cpp

#include "BaseItem.h"
#include "Components/SphereComponent.h"
#include "Kismet/GameplayStatics.h"
#include "Particles/ParticleSystemComponent.h"

ABaseItem::ABaseItem()
{
    PrimaryActorTick.bCanEverTick = false;

    // 루트 컴포넌트 생성 및 설정
    Scene = CreateDefaultSubobject<USceneComponent>(TEXT("Scene"));
    SetRootComponent(Scene);

    // 충돌 컴포넌트 생성 및 설정
    Collision = CreateDefaultSubobject<USphereComponent>(TEXT("Collision"));
    // 겹침만 감지하는 프로파일 설정
    Collision->SetCollisionProfileName(TEXT("OverlapAllDynamic"));
    // 루트 컴포넌트로 설정
    Collision->SetupAttachment(Scene);

    // 스태틱 메시 컴포넌트 생성 및 설정
    StaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMesh"));
    StaticMesh->SetupAttachment(Collision);
    // 메시가 불필요하게 충돌을 막지 않도록 하기 위해, 별도로 NoCollision 등으로 설정할 수 있음.

    // Overlap 이벤트 바인딩
    Collision->OnComponentBeginOverlap.AddDynamic(this, &ABaseItem::OnItemOverlap);
    Collision->OnComponentEndOverlap.AddDynamic(this, &ABaseItem::OnItemEndOverlap);
}

void ABaseItem::OnItemOverlap(
    UPrimitiveComponent* OverlappedComp,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex,
    bool bFromSweep,
    const FHitResult& SweepResult)
{
    // OtherActor가 플레이어인지 확인 ("Player" 태그 활용)
    if (OtherActor && OtherActor->ActorHasTag("Player"))
    {
        GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT("Overlap!!!")));
        // 아이템 사용 (획득) 로직 호출
        ActivateItem(OtherActor);
    }
}

void ABaseItem::OnItemEndOverlap(
    UPrimitiveComponent* OverlappedComp,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex)
{
}

void ABaseItem::ActivateItem(AActor* Activator)
{
    UParticleSystemComponent* Particle = nullptr;

    if (PickupParticle)
    {
        Particle = UGameplayStatics::SpawnEmitterAtLocation(
            GetWorld(),
            PickupParticle,
            GetActorLocation(),
            GetActorRotation(),
            true
        );
    }

    if (PickupSound)
    {
        UGameplayStatics::PlaySoundAtLocation(
            GetWorld(),
            PickupSound,
            GetActorLocation()
        );
    }

    if (Particle)
    {
        FTimerHandle DestroyParticleTimerHandle;

        GetWorld()->GetTimerManager().SetTimer(
            DestroyParticleTimerHandle,
            [Particle]()
            {
                Particle->DestroyComponent();
            },
            2.0f,
            false
        );
    }
}

FName ABaseItem::GetItemType() const
{
    return ItemType;
}

void ABaseItem::DestroyItem()
{
    Destroy();
}
// MineItem.h

#pragma once

#include "CoreMinimal.h"
#include "BaseItem.h"
#include "MineItem.generated.h"

UCLASS()
class SPARTA_API AMineItem : public ABaseItem
{
    GENERATED_BODY()

public:
    AMineItem();

protected:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|Component")
    USphereComponent* ExplosionCollision;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effects")
    UParticleSystem* ExplosionParticle;
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effects")
    USoundBase* ExplosionSound;

    // 폭발까지 걸리는 시간 (5초)
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Mine")
    float ExplosionDelay;
    // 폭발 범위
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Mine")
    float ExplosionRadius;
    // 폭발 데미지
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Mine")
    int ExplosionDamage;

    bool bHasExploded;

    // 지뢰 발동 여부
    FTimerHandle ExplosionTimerHandle;

    virtual void ActivateItem(AActor* Activator) override;

    void Explode();
};
// MineItem.cpp


#include "MineItem.h"
#include "Components/SphereComponent.h"
#include "Kismet/GameplayStatics.h"
#include "Particles/ParticleSystemComponent.h"

AMineItem::AMineItem()
{
    ExplosionDelay = 5.0f;
    ExplosionRadius = 300.0f;
    ExplosionDamage = 30.0f;
    ItemType = "Mine";
    bHasExploded = false;

    ExplosionCollision = CreateDefaultSubobject<USphereComponent>(TEXT("ExplosionCollision"));
    ExplosionCollision->InitSphereRadius(ExplosionRadius);
    ExplosionCollision->SetCollisionProfileName(TEXT("OverlapAllDynamic"));
    ExplosionCollision->SetupAttachment(Scene);
}

void AMineItem::ActivateItem(AActor* Activator)
{
    if (bHasExploded) return;

    Super::ActivateItem(Activator);

    GetWorld()->GetTimerManager().SetTimer(
        ExplosionTimerHandle,
        this,
        &AMineItem::Explode,
        ExplosionDelay,
        false
    );

    bHasExploded = true;
}

void AMineItem::Explode()
{
    UParticleSystemComponent* Particle = nullptr;


    if (ExplosionParticle)
    {
        Particle = UGameplayStatics::SpawnEmitterAtLocation(
            GetWorld(),
            ExplosionParticle,
            GetActorLocation(),
            GetActorRotation(),
            false
        );
    }

    if (ExplosionSound)
    {
        UGameplayStatics::PlaySoundAtLocation(
            GetWorld(),
            ExplosionSound,
            GetActorLocation()
        );
    }

    TArray<AActor*> OverlappingActors;
    ExplosionCollision->GetOverlappingActors(OverlappingActors);

    for (AActor* Actor : OverlappingActors)
    {
        if (Actor && Actor->ActorHasTag("Player"))
        {
            UGameplayStatics::ApplyDamage(
                Actor,
                ExplosionDamage,
                nullptr,
                this,
                UDamageType::StaticClass()
            );
        }
    }

    // 지뢰 제거
    DestroyItem();

    /*if (Particle)
    {
        FTimerHandle DestroyParticleTimerHandle;

        GetWorld()->GetTimerManager().SetTimer(
            DestroyParticleTimerHandle,
            [Particle]()
            {
                Particle->DestroyComponent();
            },
            2.0f,
            false
        );
    }*/
}

 

구현이 다 끝나고 게임 실행을 해서 테스트를 해보니,

레벨이 바뀔때 갑자기 에디터가 종료되고 비주얼스튜디오에서 아래 코드로 이동되면서 디버깅이 실행이 되었다.

	// faster than GetValueOnAnyThread()
	T GetValueOnRenderThread() const
	{
		UE::AccessDetection::ReportAccess(UE::AccessDetection::EType::CVar);
#if !defined(__clang__) // @todo Mac: figure out how to make this compile
		// compiled out in shipping for performance (we can change in development later), if this get triggered you need to call GetValueOnGameThread() or GetValueOnAnyThread(), the last one is a bit slower
		cvarCheckCode(ensure(IsInParallelRenderingThread()));	// ensure to not block content creators, #if to optimize in shipping
#endif
		return ShadowedValue[1];
	}
cvarCheckCode(ensure(IsInParallelRenderingThread()));	// ensure to not block content creators, #if to optimize in shipping

case FTimerDelegateVariant::IndexOfType<FTimerFunction>():
	{
		if (const FTimerFunction& TimerFunction = VariantDelegate.Get<FTimerFunction>())
		{
			QUICK_SCOPE_CYCLE_COUNTER(STAT_FTimerUnifiedDelegate_Execute);
			TimerFunction();
		}
		break;
	}
default:
	break;
}

 

계속 테스트 해보면서 원인을 찾아보니 파티클을 제거하는 타이머가 실행중일때 다음 레벨로 바뀌면서 오류가 뜨는거였다.

레벨1 > 레벨2로 바뀌면서 레벨1에 있던 기존 액터들이 전부 제거되고 레벨2에서 새로운 액터들이 생성 되는 구조인데, 

레벨2로 바뀌면서 제거된 후 레벨1에서 타이머 작동중인 액터가 있었을때 타이머가 그 액터로 접근을 하면서 오류가 실행 된 것이다. 

문제라고 생각한 코드

// BaseItem.cpp

void ABaseItem::ActivateItem(AActor* Activator)
{
    UParticleSystemComponent* Particle = nullptr;

    if (Particle)
    {
        FTimerHandle DestroyParticleTimerHandle;

        GetWorld()->GetTimerManager().SetTimer(
            DestroyParticleTimerHandle,
            [Particle]()
            {
                Particle->DestroyComponent();
            },
            2.0f,
            false
        );
    }
}
// MineItem.cpp

void AMineItem::Explode()
{
    UParticleSystemComponent* Particle = nullptr;

    if (Particle)
    {
        FTimerHandle DestroyParticleTimerHandle;

        GetWorld()->GetTimerManager().SetTimer(
            DestroyParticleTimerHandle,
            [Particle]()
            {
                Particle->DestroyComponent();
            },
            2.0f,
            false
        );
    }
}

이 두 코드의 타이머가 제거가 안된 상태에서,

레벨이 넘어가며 삭제된 파티클 삭제 타이머에 접근하여 오류가 난 것이라고 생각이 들어서

아래처럼 함수와 변수를 추가해주었고 코드를 수정 하였다

// BaseItem.h

UCLASS()
class SPARTA_API ABaseItem : public AActor, public IItemInterface
{
    GENERATED_BODY()

public:
    ABaseItem();
	
    // ActivateItem 함수 안에서 지역변수로 사용하던 타이머핸들을
    // 멤버 변수로 분리하여 다른 함수에서도 사용 가능하게 정의
    FTimerHandle DestroyParticleTimerHandle;
    
    // 파티클 삭제를 위한 함수
    // 람다 내부에서 직접 처리하지 않고 함수로 분리하여 사용
    void DestroyParticle(UParticleSystemComponent* Particle);
    
    // DestroyParticleTimerHandle를 삭제하는 함수 
    // 레벨 변경시 타이머로 인한 오류 발생 방지
    void ClearTimer();
};
// BaseItem.cpp

// 파티클이 생성된 경우
if (Particle)
{   // 일정시간(2초) 후 DestroyParticle(Particle); 함수 실행
    GetWorld()->GetTimerManager().SetTimer(
        DestroyParticleTimerHandle,
        [Particle, this]()
	{   // 기존 Particle->DestroyComponent(); 대신
    	    // 파티클과 타이머 삭제 함수를 만들어서 사용
    	    DestroyParticle(Particle);
        },
        2.0f,
        false
    );
}

// 파티클과 파티클 삭제 타이머를 제거하는 함수
void ABaseItem::DestroyParticle(UParticleSystemComponent* Particle)
{
    // 파티클 삭제 타이머 함수 호출(타이머 삭제)
    ClearTimer();
    // 생성된 파티클 컴포넌트 삭제
    Particle->DestroyComponent();
}

// 파티클 삭제 타이머를 제거하는 함수
void ABaseItem::ClearTimer()
{   // DestroyParticleTimerHandle에 등록된 타이머를 제거
    // 레벨 전환시 살아있는 타이머 때문에 생기는 오류 방지
    GetWorld()->GetTimerManager().ClearTimer(DestroyParticleTimerHandle);
}
// MineItem.cpp

if (Particle)
{
    GetWorld()->GetTimerManager().SetTimer(
        DestroyParticleTimerHandle,
        [Particle, this]()
        {
            ABaseItem::DestroyParticle(Particle);
        },
        2.0f,
        false
    );
}

위와 같이 코드를 수정하고 변수와 함수를 새로 추가해주었고, 

 

레벨이 변경되는 시점에 코드를 추가해주었따.

아래는 수정 전 코드

// GameState.cpp

void ASpartaGameState::EndLevel()
{
    GetWorldTimerManager().ClearTimer(LevelTimerHandle);

    if (UGameInstance* GameInstance = GetGameInstance())
    {
        USpartaGameInstance* SpartaGameInstance = Cast<USpartaGameInstance>(GameInstance);
        if (SpartaGameInstance)
        {
            AddScore(Score);
            CurrentLevelIndex++;
            SpartaGameInstance->CurrentLevelIndex = CurrentLevelIndex;

            if (CurrentLevelIndex >= MaxLevels)
            {
                OnGameOver();
                return;
            }

            if (LevelMapNames.IsValidIndex(CurrentLevelIndex))
            {
                UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
            }
            else
            {
                OnGameOver();
            }
        }
    }
}

아래는 수정한 코드

// GameState.cpp

void ASpartaGameState::EndLevel()
{
    GetWorldTimerManager().ClearTimer(LevelTimerHandle);
    {   // 현재 월드 내에 존재하는 모든 액터를 찾기 위한 배열 정의
        TArray<AActor*> Actors;
        // BaseItem 클래스를 상속받은 모든 액터를 찾아서 Actors 배열에 저장
        UGameplayStatics::GetAllActorsOfClass(GetWorld(), ABaseItem::StaticClass(), Actors);

        // 찾은 모든 액터를 순회
        for (AActor* Item : Actors)
        {   // AActor를 ABaseItem으로 캐스팅
            if (ABaseItem* BaseItem = Cast<ABaseItem>(Item))
            {   // 액터들에 설정되어 있는 파티클 삭제 타이머를 제거
                BaseItem->ClearTimer();
            }
        }
    }

    if (UGameInstance* GameInstance = GetGameInstance())
    {
        USpartaGameInstance* SpartaGameInstance = Cast<USpartaGameInstance>(GameInstance);
        if (SpartaGameInstance)
        {
            AddScore(Score);
            CurrentLevelIndex++;
            SpartaGameInstance->CurrentLevelIndex = CurrentLevelIndex;

            if (CurrentLevelIndex >= MaxLevels)
            {
                OnGameOver();
                return;
            }

            if (LevelMapNames.IsValidIndex(CurrentLevelIndex))
            {
                UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
            }
            else
            {
                OnGameOver();
            }
        }
    }
}

 

이번 문제를 발견하고 해결하면서 타이머는 액터가 삭제되더라도 자동으로 정리되지 않으며, 

레벨 전환시 타이머를 직접 관리를 해줘야 한다는 것을 알게 되었다.

관련글 더보기