언리얼 엔진 5

[UE5] 비동기 프로그래밍

언린이 2026. 3. 1. 16:11
반응형

1. 비동기 프로그래밍이 필요한 이유

게임은 매 프레임(보통 16.6ms, 60FPS 기준) 내에 모든 로직을 처리해야 합니다. 그런데 대규모 데이터 연산, 네트워크 요청, 파일 I/O, 경로 탐색(Pathfinding) 같은 작업은 한 프레임 안에 처리하기에 너무 무겁습니다. 이러한 작업을 게임 스레드에서 동기적으로 실행하면 해당 프레임의 처리 시간이 급증하여 프레임 드롭(hitching)이 발생하고, 플레이어는 게임이 "멈추는" 것처럼 느끼게 됩니다.

비동기 프로그래밍은 이 문제를 해결합니다. 무거운 작업을 별도의 스레드나 백그라운드 태스크로 위임하여 게임 스레드가 블로킹되지 않도록 하고, 작업이 완료되면 결과를 게임 스레드로 돌려받는 구조입니다.

언리얼 엔진은 비동기 프로그래밍을 위해 두 가지 큰 축의 시스템을 제공합니다. 첫 번째 축은 멀티스레드 태스크 시스템으로, AsyncTask() 함수, FAutoDeleteAsyncTask, FAsyncTask 등을 통해 실제 백그라운드 스레드에서 코드를 실행합니다. 두 번째 축은 비동기 액션 시스템으로, UBlueprintAsyncActionBaseUCancellableAsyncAction을 통해 게임 스레드에서 여러 프레임에 걸쳐 비동기 작업을 관리하면서 블루프린트와도 연동됩니다.

이 글에서는 두 축의 핵심 클래스를 모두 다루고, 각각 어떤 상황에서 사용해야 하는지 실제 코드 예제와 함께 설명합니다.

2. 비동기 시스템의 핵심 구조

2-1. 멀티스레드 태스크 — 실제 스레드에서 실행

멀티스레드 태스크는 게임 스레드와 별도의 워커 스레드에서 코드를 병렬로 실행합니다. CPU 집약적인 연산(대량의 수학 계산, 프로시저럴 생성, AI 경로 탐색 등)을 게임 스레드에서 분리하는 데 적합합니다.

언리얼 엔진은 두 가지 멀티스레드 실행 환경을 제공합니다.

Task Graph 시스템은 엔진 내부의 많은 시스템이 공유하는 경량 태스크 스케줄러입니다. 매우 짧게 실행되고, 다른 작업을 블로킹하지 않으며, 가능한 빨리 완료되어야 하는 작은 태스크에 적합합니다. 태스크 실행 비용이 스레드를 직접 시작하는 것에 비해 매우 저렴합니다.

Thread Pool은 Task Graph와 독립적인 별도의 워커 스레드 집합입니다. 실행 시간이 길거나, 블로킹 I/O가 포함되거나, 다른 비동기 호출을 내부에서 생성하는 태스크에 적합합니다. FAutoDeleteAsyncTaskFAsyncTask가 이 Thread Pool에서 실행됩니다.

2-2. 비동기 액션 — 게임 스레드에서 여러 프레임에 걸쳐 실행

비동기 액션은 별도의 스레드를 사용하지 않고, 게임 스레드에서 여러 프레임에 걸쳐 비동기 작업을 관리합니다. HTTP 요청 완료 대기, 타이머 기반 지연, 애니메이션 완료 감지 같은 이벤트 기반 비동기 패턴에 적합하며, 블루프린트에서 Latent 노드로 사용할 수 있다는 것이 핵심입니다.

UBlueprintAsyncActionBaseUCancellableAsyncAction이 이 카테고리에 속합니다.

3. AsyncTask() 함수 — 가장 간단한 멀티스레드 실행

3-1. 기본 사용법

AsyncTask() 함수는 언리얼의 Task Graph 시스템을 통해 코드를 비동기로 실행하는 가장 간단한 방법입니다. 별도의 클래스 정의 없이 람다 하나로 백그라운드 실행이 가능합니다.

다음은 AsyncTask()의 기본 사용 예제입니다.

#include "Async/Async.h"

void AMyActor::PerformHeavyCalculation()
{
    // 백그라운드 스레드에서 무거운 연산을 실행합니다
    AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [this]()
    {
        // 이 코드는 백그라운드 스레드에서 실행됩니다
        TArray<FVector> CalculatedPositions = GenerateProceduralPositions(10000);

        // 연산 완료 후 게임 스레드로 복귀하여 결과를 적용합니다
        AsyncTask(ENamedThreads::GameThread, [this, CalculatedPositions = MoveTemp(CalculatedPositions)]()
        {
            // 이 코드는 게임 스레드에서 실행됩니다
            ApplyPositions(CalculatedPositions);
        });
    });
}

위의 코드에서 핵심 패턴은 "백그라운드 → 게임 스레드 복귀"의 중첩 호출입니다. 첫 번째 AsyncTask가 백그라운드에서 무거운 연산을 수행하고, 내부의 두 번째 AsyncTaskENamedThreads::GameThread를 지정하여 결과를 게임 스레드에서 안전하게 적용합니다. 게임 스레드에서만 접근해야 하는 UObject 프로퍼티나 컴포넌트 조작은 반드시 이 복귀 패턴을 사용해야 합니다.

3-2. Async() 템플릿 함수와 TFuture

Async() 템플릿 함수는 AsyncTask()보다 유연한 방식으로, TFuture 객체를 통해 결과를 받을 수 있고 Thread Pool에서도 실행할 수 있습니다.

다음은 Async() 함수와 TFuture를 활용한 예제입니다.

#include "Async/Async.h"

void AMyActor::LoadDataAsync()
{
    // Thread Pool에서 실행하고 TFuture로 결과를 받습니다
    TFuture<TArray<FString>> Future = Async(EAsyncExecution::ThreadPool, []()
    {
        // Thread Pool에서 파일을 읽는 작업을 수행합니다
        TArray<FString> LoadedLines;
        FFileHelper::LoadFileToStringArray(LoadedLines, TEXT("/Game/Data/LevelData.txt"));
        return LoadedLines;
    });

    // 다른 작업을 수행할 수 있습니다...

    // 결과가 필요한 시점에 Get()으로 가져옵니다 (블로킹)
    TArray<FString> Result = Future.Get();
}

위 코드처럼 Async()EAsyncExecution::ThreadPool 또는 EAsyncExecution::TaskGraph를 선택하여 실행 환경을 지정할 수 있습니다. TFuture::Get()은 결과가 준비될 때까지 호출 스레드를 블로킹하므로, 게임 스레드에서 호출하면 프레임 드롭이 발생할 수 있습니다. Then() 콜백을 활용하면 블로킹 없이 결과를 받을 수 있습니다.

4. FAutoDeleteAsyncTask — 자동 관리 Thread Pool 태스크

4-1. 개념과 구조

FAutoDeleteAsyncTask는 Thread Pool에서 실행되는 태스크를 정의하는 클래스입니다. AsyncTask() 함수가 람다 기반의 일회성 실행이라면, FAutoDeleteAsyncTask는 재사용 가능한 태스크 클래스를 정의하고 생성자로 파라미터를 전달하는 구조화된 방식입니다. 이름처럼 태스크 완료 후 자동으로 메모리가 해제됩니다.

태스크 클래스를 정의하려면 FNonAbandonableTask를 상속하고, DoWork() 함수에 실행할 로직을 구현합니다.

다음은 프로시저럴 지형 데이터를 생성하는 태스크 클래스 예제입니다.

// ProceduralTerrainTask.h
#include "Async/AsyncWork.h"

class FProceduralTerrainTask : public FNonAbandonableTask
{
    // FAutoDeleteAsyncTask가 내부에 접근할 수 있도록 friend 선언합니다
    friend class FAutoDeleteAsyncTask<FProceduralTerrainTask>;

public:
    FProceduralTerrainTask(int32 InGridSize, float InScale, TWeakObjectPtr<AActor> InOwner)
        : GridSize(InGridSize)
        , Scale(InScale)
        , Owner(InOwner)
    {}

protected:
    int32 GridSize;
    float Scale;
    TWeakObjectPtr<AActor> Owner;

    void DoWork()
    {
        // 백그라운드 스레드에서 지형 높이맵을 생성합니다
        TArray<float> HeightMap;
        HeightMap.SetNum(GridSize * GridSize);

        for (int32 Y = 0; Y < GridSize; ++Y)
        {
            for (int32 X = 0; X < GridSize; ++X)
            {
                float NoiseValue = FMath::PerlinNoise2D(FVector2D(X * Scale, Y * Scale));
                HeightMap[Y * GridSize + X] = NoiseValue;
            }
        }

        // 결과를 게임 스레드에서 적용합니다
        AsyncTask(ENamedThreads::GameThread, [this, HeightMap = MoveTemp(HeightMap)]()
        {
            if (Owner.IsValid())
            {
                // 게임 스레드에서 안전하게 결과를 적용합니다
            }
        });
    }

    // 프로파일링을 위한 통계 ID입니다
    FORCEINLINE TStatId GetStatId() const
    {
        RETURN_QUICK_DECLARE_CYCLE_STAT(FProceduralTerrainTask, STATGROUP_ThreadPoolAsyncTasks);
    }
};

위의 코드에서 주목해야 할 점이 세 가지 있습니다. 첫째, friend class FAutoDeleteAsyncTask<FProceduralTerrainTask> 선언이 필요합니다. 이를 통해 FAutoDeleteAsyncTaskDoWork()에 접근할 수 있습니다. 둘째, RETURN_QUICK_DECLARE_CYCLE_STAT 매크로는 프로파일링 도구에서 이 태스크를 식별하기 위해 필요합니다. 셋째, 액터에 접근할 때는 TWeakObjectPtr를 사용하여 GC에 의해 소멸된 오브젝트에 접근하는 것을 방지합니다.

4-2. 태스크 실행

정의한 태스크를 실행하는 방법은 매우 간단합니다.

void ATerrainGenerator::GenerateTerrainAsync()
{
    // new로 생성한 뒤 StartBackgroundTask()를 호출합니다
    (new FAutoDeleteAsyncTask<FProceduralTerrainTask>(256, 0.01f, this))->StartBackgroundTask();

    // 태스크가 Thread Pool에 전달되어 큐에 들어갑니다
    // 워커 스레드가 사용 가능해지면 DoWork()가 실행됩니다
    // 완료 후 자동으로 메모리가 해제됩니다
}

위 코드처럼 new로 생성하고 StartBackgroundTask()를 호출하면 됩니다. FAutoDeleteAsyncTaskDoWork() 완료 후 자동으로 delete되므로 별도의 정리 코드가 필요 없습니다.

5. FAsyncTask — 수동 관리 Thread Pool 태스크

5-1. FAutoDeleteAsyncTask와의 차이

FAsyncTaskFAutoDeleteAsyncTask와 동일한 FNonAbandonableTask 기반이지만, 자동 삭제되지 않습니다. 대신 태스크의 취소(Cancel()), 완료 대기(EnsureCompletion()), 완료 여부 확인(IsDone())이 가능합니다.

항목 FAutoDeleteAsyncTask FAsyncTask
메모리 관리 완료 시 자동 삭제 개발자가 수동 삭제
취소 불가 Cancel() 가능
완료 대기 불가 EnsureCompletion() 가능
적합한 상황 일회성 Fire-and-forget 수명 제어가 필요한 태스크

5-2. 생성, 실행, 정리

다음은 FAsyncTask의 전체 수명 관리 예제입니다.

// 멤버 변수로 태스크 포인터를 저장합니다
FAsyncTask<FProceduralTerrainTask>* TerrainTask = nullptr;

void ATerrainGenerator::StartGeneration()
{
    // 기존 태스크가 있으면 정리합니다
    CleanupTask();

    // 새 태스크를 생성하고 실행합니다
    TerrainTask = new FAsyncTask<FProceduralTerrainTask>(256, 0.01f, this);
    TerrainTask->StartBackgroundTask();
}

void ATerrainGenerator::CleanupTask()
{
    if (TerrainTask)
    {
        // 취소를 시도합니다 — 아직 시작되지 않은 태스크만 취소 가능합니다
        if (!TerrainTask->Cancel())
        {
            // 이미 실행 중이라면 완료될 때까지 대기합니다
            TerrainTask->EnsureCompletion();
        }

        delete TerrainTask;
        TerrainTask = nullptr;
    }
}

void ATerrainGenerator::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    Super::EndPlay(EndPlayReason);

    // 액터 소멸 시 반드시 태스크를 정리합니다
    CleanupTask();
}

위의 코드에서 Cancel()은 아직 Thread Pool 큐에서 대기 중인 태스크만 취소할 수 있습니다. 이미 DoWork()가 실행 중인 태스크는 취소할 수 없으므로, EnsureCompletion()을 호출하여 완료될 때까지 대기해야 합니다. EndPlay에서 반드시 태스크를 정리해야 액터 소멸 후 댕글링 포인터 문제를 방지할 수 있습니다.

6. UBlueprintAsyncActionBase — 블루프린트 비동기 노드

6-1. 개념과 역할

UBlueprintAsyncActionBase는 C++에서 정의한 비동기 작업을 블루프린트에서 Latent 노드(여러 출력 실행 핀이 있는 노드)로 사용할 수 있게 해주는 기반 클래스입니다. HTTP 요청, 데이터 로딩, 서버 응답 대기 같은 이벤트 기반 비동기 패턴에 적합합니다.

이 클래스의 핵심 구성 요소는 세 가지입니다. 첫째, DECLARE_DYNAMIC_MULTICAST_DELEGATE로 선언한 출력 델리게이트가 블루프린트 노드의 출력 핀이 됩니다. 둘째, static 팩토리 함수가 블루프린트 노드의 입력 핀과 호출 인터페이스를 정의합니다. 셋째, Activate() 함수 오버라이드에 실제 비동기 로직을 구현합니다.

6-2. 구현 예제 — HTTP 요청 비동기 노드

다음은 HTTP 요청을 비동기로 수행하는 블루프린트 노드를 구현하는 전체 예제입니다.

// AsyncHttpRequest.h
#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintAsyncActionBase.h"
#include "Interfaces/IHttpRequest.h"
#include "Interfaces/IHttpResponse.h"
#include "AsyncHttpRequest.generated.h"

// 출력 핀에 해당하는 델리게이트를 선언합니다
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHttpRequestComplete, const FString&, ResponseBody);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHttpRequestFailed, const FString&, ErrorMessage);

UCLASS()
class UAsyncHttpRequest : public UBlueprintAsyncActionBase
{
    GENERATED_BODY()

public:
    // 블루프린트 노드의 출력 핀 — BlueprintAssignable이 필수입니다
    UPROPERTY(BlueprintAssignable)
    FOnHttpRequestComplete OnSuccess;

    UPROPERTY(BlueprintAssignable)
    FOnHttpRequestFailed OnFailed;

    // 팩토리 함수 — 블루프린트 노드의 입력 핀을 정의합니다
    UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true",
        WorldContext = "WorldContextObject", DisplayName = "Async HTTP Request"))
    static UAsyncHttpRequest* SendHttpRequest(
        UObject* WorldContextObject,
        const FString& URL);

    // 비동기 작업의 시작점입니다
    virtual void Activate() override;

private:
    FString RequestURL;

    void OnResponseReceived(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful);
};
// AsyncHttpRequest.cpp
#include "AsyncHttpRequest.h"
#include "HttpModule.h"

UAsyncHttpRequest* UAsyncHttpRequest::SendHttpRequest(
    UObject* WorldContextObject, const FString& URL)
{
    // 팩토리 함수에서 인스턴스를 생성하고 파라미터를 저장합니다
    UAsyncHttpRequest* Action = NewObject<UAsyncHttpRequest>();
    Action->RequestURL = URL;

    // 블루프린트 스코프를 벗어나도 GC되지 않도록 등록합니다
    Action->RegisterWithGameInstance(WorldContextObject);

    return Action;
}

void UAsyncHttpRequest::Activate()
{
    // HTTP 요청을 생성하고 전송합니다
    TSharedRef<IHttpRequest, ESPMode::ThreadSafe> HttpRequest = FHttpModule::Get().CreateRequest();
    HttpRequest->SetURL(RequestURL);
    HttpRequest->SetVerb(TEXT("GET"));

    // 응답 콜백을 바인딩합니다
    HttpRequest->OnProcessRequestComplete().BindUObject(
        this, &UAsyncHttpRequest::OnResponseReceived);

    HttpRequest->ProcessRequest();
}

void UAsyncHttpRequest::OnResponseReceived(
    FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful)
{
    if (bWasSuccessful && Response.IsValid())
    {
        // 성공 핀으로 결과를 전달합니다
        OnSuccess.Broadcast(Response->GetContentAsString());
    }
    else
    {
        // 실패 핀으로 에러를 전달합니다
        OnFailed.Broadcast(TEXT("HTTP 요청에 실패했습니다."));
    }

    // 작업이 완료되면 GC 대상으로 전환합니다
    SetReadyToDestroy();
}

위의 코드에서 핵심적인 부분이 네 가지 있습니다. 첫째, meta = (BlueprintInternalUseOnly = "true")BlueprintCallable에 의한 일반 노드 생성을 비활성화하고, 비동기 전용 노드만 생성되도록 합니다. 이 메타 태그가 없으면 동일한 함수에 대해 두 개의 노드가 생성됩니다. 둘째, RegisterWithGameInstance()를 호출하면 비동기 작업 도중 가비지 컬렉션에 의해 액션 객체가 회수되는 것을 방지합니다. 셋째, 작업이 완료된 후 반드시 SetReadyToDestroy()를 호출하여 GC가 액션 객체를 회수할 수 있도록 해야 합니다. 넷째, UPROPERTY(BlueprintAssignable)로 선언된 다이나믹 멀티캐스트 델리게이트가 블루프린트 노드의 출력 실행 핀이 됩니다.

7. UCancellableAsyncAction — 취소 가능한 비동기 액션

7-1. UBlueprintAsyncActionBase와의 차이

UCancellableAsyncActionUBlueprintAsyncActionBase를 상속한 클래스로, 취소(Cancel) 기능이 기본 내장되어 있습니다. UCLASS 지정자에 meta=(ExposedAsyncProxy=AsyncAction)이 설정되어 있어, 블루프린트 노드에 비동기 액션 객체 자체가 출력 핀으로 노출됩니다. 이 핀을 통해 블루프린트에서 Cancel() 함수를 호출할 수 있습니다.

항목 UBlueprintAsyncActionBase UCancellableAsyncAction
취소 기능 직접 구현 필요 Cancel() 기본 제공
블루프린트 취소 핀 없음 자동 노출
보일러플레이트 보통 더 적음
권장도 기본적 비동기 노드 UE5에서 더 권장

7-2. 구현 예제 — 지연 실행 비동기 액션

다음은 UCancellableAsyncAction을 활용한 지연 실행 비동기 액션의 예제입니다.

// AsyncDelayAction.h
#pragma once

#include "CoreMinimal.h"
#include "Engine/CancellableAsyncAction.h"
#include "AsyncDelayAction.generated.h"

DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnDelayComplete);

UCLASS()
class UAsyncDelayAction : public UCancellableAsyncAction
{
    GENERATED_BODY()

public:
    UPROPERTY(BlueprintAssignable)
    FOnDelayComplete OnComplete;

    UPROPERTY(BlueprintAssignable)
    FOnDelayComplete OnCancelled;

    // 팩토리 함수
    UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true",
        WorldContext = "WorldContextObject", DisplayName = "Async Delay"))
    static UAsyncDelayAction* AsyncDelay(UObject* WorldContextObject, float DelaySeconds);

    virtual void Activate() override;
    virtual void Cancel() override;

private:
    float Delay = 0.f;
    FTimerHandle TimerHandle;
    TWeakObjectPtr<UWorld> WorldPtr;

    void OnTimerComplete();
};
// AsyncDelayAction.cpp
#include "AsyncDelayAction.h"
#include "Engine/World.h"
#include "TimerManager.h"

UAsyncDelayAction* UAsyncDelayAction::AsyncDelay(
    UObject* WorldContextObject, float DelaySeconds)
{
    UAsyncDelayAction* Action = NewObject<UAsyncDelayAction>();
    Action->Delay = DelaySeconds;
    Action->WorldPtr = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull);
    Action->RegisterWithGameInstance(WorldContextObject);
    return Action;
}

void UAsyncDelayAction::Activate()
{
    if (UWorld* World = WorldPtr.Get())
    {
        // 타이머를 설정하여 지정된 시간 후에 콜백을 호출합니다
        World->GetTimerManager().SetTimer(
            TimerHandle, this, &UAsyncDelayAction::OnTimerComplete, Delay, false);
    }
    else
    {
        // 월드가 유효하지 않으면 즉시 취소 핀을 실행합니다
        OnCancelled.Broadcast();
        SetReadyToDestroy();
    }
}

void UAsyncDelayAction::Cancel()
{
    // 타이머를 해제하고 취소 핀을 실행합니다
    if (UWorld* World = WorldPtr.Get())
    {
        World->GetTimerManager().ClearTimer(TimerHandle);
    }

    OnCancelled.Broadcast();
    SetReadyToDestroy();
}

void UAsyncDelayAction::OnTimerComplete()
{
    OnComplete.Broadcast();
    SetReadyToDestroy();
}

위의 코드에서 Cancel() 오버라이드가 핵심입니다. UCancellableAsyncAction은 블루프린트에서 액션 객체를 통해 Cancel()을 호출할 수 있으므로, 진행 중인 비동기 작업(여기서는 타이머)을 정리하고 취소 핀을 실행하는 로직을 구현해야 합니다.

8. 상황별 선택 기준

어떤 비동기 시스템을 사용해야 할지 판단할 때, 다음 질문을 순서대로 따라가면 됩니다.

"CPU 집약적인 연산을 별도 스레드에서 실행해야 하는가?"AsyncTask() 함수 또는 FAutoDeleteAsyncTask/FAsyncTask를 사용합니다. 간단한 일회성 작업에는 AsyncTask() 함수, 구조화된 재사용 가능 태스크에는 FAutoDeleteAsyncTask, 취소나 완료 대기가 필요하면 FAsyncTask를 선택합니다.

"블루프린트에서 사용할 수 있는 비동기 노드가 필요한가?"UBlueprintAsyncActionBase 또는 UCancellableAsyncAction을 사용합니다. 취소 기능이 필요하면 UCancellableAsyncAction이 더 적합합니다.

"이벤트 완료를 기다리면서 게임 스레드를 블로킹하면 안 되는가?" — HTTP 응답, 서버 리플라이, 애니메이션 완료 같은 외부 이벤트를 기다리는 경우 비동기 액션(UBlueprintAsyncActionBase 계열)을 사용합니다. 이들은 게임 스레드에서 돌아가며 멀티스레드 안전성 문제가 없습니다.

9. 일반 C++과의 비교

일반 C++에서는 std::thread, std::async, std::future, std::promise를 사용하여 비동기 프로그래밍을 구현합니다. 기본 개념은 동일합니다. 무거운 작업을 별도 스레드에서 실행하고, 결과를 메인 스레드로 전달하는 것입니다.

그러나 언리얼의 비동기 시스템은 게임 엔진에 특화된 중요한 차이가 있습니다.

첫째, 언리얼은 "게임 스레드"라는 명확한 메인 스레드 개념이 있으며, AsyncTask(ENamedThreads::GameThread, ...) 한 줄로 게임 스레드 복귀가 가능합니다. 일반 C++에서는 별도의 메시지 큐나 동기화 메커니즘을 직접 구축해야 합니다.

// 일반 C++ — 메인 스레드 복귀가 복잡합니다
std::future<int> Result = std::async(std::launch::async, []()
{
    return HeavyComputation();
});
// Result.get()으로 블로킹 대기하거나, 별도 큐 시스템 필요

// 언리얼 — 게임 스레드 복귀가 한 줄입니다
AsyncTask(ENamedThreads::AnyThread, [this]()
{
    int32 ComputedValue = HeavyComputation();
    AsyncTask(ENamedThreads::GameThread, [this, ComputedValue]()
    {
        ApplyResult(ComputedValue);
    });
});

위 코드처럼 언리얼의 AsyncTaskENamedThreads 열거형으로 실행할 스레드를 명시적으로 지정할 수 있어, 게임 스레드와 백그라운드 스레드 간의 전환이 매우 직관적입니다.

둘째, 일반 C++의 std::thread는 스레드를 직접 생성하므로 오버헤드가 크지만, 언리얼의 Task Graph는 이미 존재하는 워커 스레드 풀에서 태스크를 할당하여 스레드 생성 비용이 거의 없습니다.

셋째, UBlueprintAsyncActionBase 같은 비동기 액션 패턴은 표준 C++에 대응하는 기능이 없습니다. 이는 블루프린트 비주얼 스크립팅과 통합하기 위한 언리얼 고유의 시스템입니다.

10. 유니티 엔진과의 비교

유니티에서는 비동기 프로그래밍을 위해 C#의 async/await 키워드, Task 클래스, 그리고 유니티 고유의 코루틴(Coroutine) 시스템을 사용합니다. 코루틴은 IEnumerator를 반환하는 함수에서 yield return으로 실행을 일시 중단하고 다음 프레임에 재개하는 패턴으로, 언리얼의 비동기 액션과 유사한 역할을 합니다.

언리얼의 비동기 시스템이 갖는 고유한 강점은 두 가지입니다.

첫째, UBlueprintAsyncActionBase는 C++에서 정의한 비동기 로직이 블루프린트에서 여러 출력 핀을 가진 Latent 노드로 자동 변환됩니다. 성공/실패/취소 같은 분기를 시각적으로 명확하게 표현할 수 있어, 프로그래머가 아닌 디자이너도 복잡한 비동기 흐름을 이해하고 사용할 수 있습니다. 유니티의 코루틴은 블루프린트 같은 비주얼 스크립팅과의 통합이 제한적입니다.

둘째, Thread Pool 태스크(FAutoDeleteAsyncTask, FAsyncTask)는 GetStatId()를 통해 프로파일링 시스템과 자동으로 통합됩니다. 언리얼의 Stat 시스템에서 각 태스크의 실행 시간과 빈도를 바로 확인할 수 있어, 성능 최적화 과정에서 병목 지점을 빠르게 식별할 수 있습니다.

11. 주의사항

11-1. 백그라운드 스레드에서 UObject에 직접 접근하지 않습니다

UObject의 프로퍼티 변경, 컴포넌트 조작, 월드 쿼리 등은 게임 스레드에서만 안전합니다. 백그라운드 스레드에서 이러한 작업을 수행하면 레이스 컨디션(race condition)이나 크래시가 발생할 수 있습니다. 반드시 AsyncTask(ENamedThreads::GameThread, ...) 패턴으로 게임 스레드에서 실행해야 합니다.

11-2. 액터에 접근할 때는 TWeakObjectPtr를 사용합니다

비동기 태스크가 실행되는 동안 참조하던 액터가 Destroy()되거나 GC에 의해 회수될 수 있습니다. TWeakObjectPtr를 사용하면 오브젝트가 유효한지 안전하게 확인한 후 접근할 수 있습니다. 일반 포인터(AActor*)를 백그라운드 스레드에 전달하면 댕글링 포인터 위험이 있습니다.

11-3. AsyncTask()는 취소할 수 없습니다

AsyncTask() 함수로 전달한 람다는 한 번 실행되면 취소할 방법이 없습니다. 취소 가능성이 필요한 태스크에는 FAsyncTask를 사용하거나, 공유 플래그(std::atomic<bool>)를 통해 DoWork() 내부에서 주기적으로 취소 여부를 확인하는 패턴을 사용해야 합니다.

11-4. FAsyncTask는 반드시 정리해야 합니다

FAsyncTask를 사용한 후 delete하지 않으면 메모리 누수가 발생합니다. 특히 태스크가 실행 중일 때 그냥 delete하면 크래시가 발생하므로, 반드시 Cancel()을 시도하고 실패하면 EnsureCompletion()으로 완료를 대기한 후 삭제해야 합니다. 소유 액터의 EndPlay에서 정리하는 것이 가장 안전한 패턴입니다.

11-5. RegisterWithGameInstance와 SetReadyToDestroy는 쌍으로 사용합니다

UBlueprintAsyncActionBase에서 RegisterWithGameInstance()를 호출하면 GC가 액션 객체를 회수하지 않습니다. 비동기 작업이 완료된 후 SetReadyToDestroy()를 호출하지 않으면 액션 객체가 메모리에 영구적으로 남아 메모리 누수가 발생합니다. 성공, 실패, 취소 등 모든 종료 경로에서 SetReadyToDestroy()가 호출되는지 반드시 확인해야 합니다.

11-6. Task Graph에서 블로킹 작업을 실행하지 않습니다

Task Graph 시스템의 워커 스레드는 엔진의 많은 시스템이 공유합니다. Task Graph 태스크에서 파일 I/O나 네트워크 대기 같은 블로킹 작업을 수행하면, 다른 엔진 태스크의 실행이 지연되어 전체 엔진 성능이 저하될 수 있습니다. 블로킹이 필요한 작업은 반드시 Thread Pool(EAsyncExecution::ThreadPool)에서 실행해야 합니다.

 

 

언리얼의 비동기 프로그래밍은 CPU 집약적 연산을 위한 멀티스레드 태스크(AsyncTask, FAutoDeleteAsyncTask, FAsyncTask)와, 블루프린트 연동이 가능한 이벤트 기반 비동기 액션(UBlueprintAsyncActionBase, UCancellableAsyncAction)의 두 축으로 구성됩니다. 멀티스레드 태스크는 연산을 별도 스레드로 분리하여 프레임 드롭을 방지하고, 비동기 액션은 HTTP 요청이나 타이머 같은 외부 이벤트를 게임 스레드에서 안전하게 관리합니다. 프로젝트의 요구사항에 맞는 비동기 패턴을 선택하면 게임의 반응성과 확장성을 동시에 확보할 수 있습니다.

반응형

'언리얼 엔진 5' 카테고리의 다른 글

[UE5] 멀티스레딩  (0) 2026.03.01
[UE5] 비동기 애셋 로딩  (0) 2026.02.28
[UE5] 델리게이트  (0) 2026.02.28
[UE5] 언리얼 오브젝트 생성과 소멸  (0) 2026.02.26
[UE5] 컨테이너(TArray, TMap, TSet)  (1) 2026.02.24