언리얼 엔진 5

[UE5] 멀티스레딩

언린이 2026. 3. 1. 20:25
반응형

1. 게임에서 멀티스레딩이 필요한 이유

게임은 매 프레임마다 렌더링, 물리 시뮬레이션, AI 연산, 네트워크 처리 등 수많은 작업을 수행해야 합니다. 이 모든 작업을 게임 스레드 하나에서 순차적으로 처리하면, 하나의 무거운 연산이 전체 프레임 시간을 잡아먹어 프레임 드롭이 발생합니다. 예를 들어 오픈 월드 게임에서 수천 개의 액터에 대한 경로 탐색을 한 프레임 안에 처리하려 하면, 게임이 버벅거리는 현상을 피할 수 없습니다.

멀티스레딩은 이러한 무거운 작업을 별도의 스레드에 분산시켜 게임 스레드의 부담을 줄이는 기법입니다. 현대 CPU는 대부분 멀티코어를 탑재하고 있기 때문에, 여러 스레드를 동시에 실행하면 전체 처리 속도를 크게 향상시킬 수 있습니다. 언리얼 엔진 5는 이를 위해 다양한 수준의 멀티스레딩 도구를 제공합니다.

이 글에서는 언리얼 엔진이 제공하는 세 가지 핵심 멀티스레딩 도구인 FRunnable, AsyncTask, ParallelFor를 다룹니다. 각각은 스레드를 직접 관리하는 저수준 방식부터 간편하게 병렬 처리를 수행하는 고수준 방식까지의 스펙트럼을 형성하며, 상황에 따라 적절한 도구를 선택하는 것이 중요합니다.

2. 언리얼 엔진의 멀티스레딩 구조

언리얼 엔진의 멀티스레딩 시스템은 크게 세 가지 계층으로 나눌 수 있습니다.

첫 번째는 FRunnable / FRunnableThread 기반의 직접 스레드 관리입니다. 개발자가 스레드의 생성, 실행, 종료까지 전 과정을 직접 제어합니다. 장시간 실행되는 백그라운드 작업(네트워크 리스닝, 파일 I/O 등)에 적합합니다.

두 번째는 AsyncTask / Task Graph 기반의 태스크 디스패칭입니다. Task Graph는 언리얼 엔진이 내부적으로 운영하는 태스크 스케줄링 시스템으로, 태스크 간 의존성을 정의하면 엔진이 최적의 순서와 스레드에 자동 배분합니다. AsyncTask()는 이 Task Graph에 작업을 간편하게 제출하는 함수입니다. 짧은 비동기 작업이나 게임 스레드로의 콜백 전환에 적합합니다.

세 번째는 ParallelFor 기반의 데이터 병렬 처리입니다. 동일한 연산을 대량의 데이터에 병렬로 적용합니다. 대규모 배열 순회나 일괄 계산에 적합합니다.

이 세 가지 도구는 서로 배타적이지 않으며, 하나의 시스템 안에서 함께 사용할 수 있습니다. 예를 들어 FRunnable로 생성한 백그라운드 스레드 내부에서 ParallelFor를 사용하여 데이터를 병렬 처리하고, 결과를 AsyncTask를 통해 게임 스레드로 전달하는 패턴이 가능합니다.

3. FRunnable — 스레드를 직접 관리하기

3-1. FRunnable의 구조

FRunnable은 언리얼 엔진에서 스레드를 직접 생성하고 관리하기 위한 인터페이스입니다. 개발자는 FRunnable을 상속하여 스레드에서 실행할 로직을 정의하고, FRunnableThread를 통해 실제 OS 스레드를 생성합니다.

FRunnable은 네 가지 가상 함수를 제공하며, 각 함수는 스레드 수명 주기의 특정 시점에 엔진이 자동으로 호출합니다.

함수 호출 시점 호출 스레드 역할
Init() 스레드 시작 직후 새 스레드 초기화 작업. false를 반환하면 스레드 중단
Run() Init() 성공 후 새 스레드 스레드의 메인 로직 실행
Stop() Kill() 호출 시 호출한 스레드(게임 스레드 등) Run()에게 종료 신호 전달
Exit() Run() 종료 후 새 스레드 리소스 정리

엔진 내부에서 스레드가 시작되면 다음과 같은 순서로 코드가 실행됩니다.

// 엔진이 새 스레드에서 실행하는 내부 흐름
if (Runnable->Init())
{
    Runnable->Run();
    Runnable->Exit();
}

위의 코드에서 볼 수 있듯이, Init()이 true를 반환해야만 Run()이 실행됩니다. Run()이 종료되면 자동으로 Exit()이 호출되어 정리 작업을 수행합니다. Stop()은 이 흐름과 별개로, 외부에서 Kill()을 호출했을 때 호출되는 함수입니다.

3-2. FRunnable 구현 예제

네트워크 데이터를 백그라운드에서 지속적으로 수신하는 스레드를 구현하는 예제입니다. 먼저 헤더 파일에서 FRunnable을 상속받는 클래스를 선언합니다.

// FNetworkReceiver.h
#pragma once
#include "CoreMinimal.h"
#include "HAL/Runnable.h"

class FNetworkReceiver : public FRunnable
{
public:
    FNetworkReceiver(int32 InPort);
    virtual ~FNetworkReceiver();

    // FRunnable 인터페이스
    virtual bool Init() override;
    virtual uint32 Run() override;
    virtual void Stop() override;
    virtual void Exit() override;

    // 수신된 데이터를 게임 스레드에서 가져갈 수 있도록 제공
    bool DequeueMessage(FString& OutMessage);

private:
    FRunnableThread* Thread = nullptr;
    FThreadSafeBool bShouldStop = false;  // 스레드 종료 플래그

    int32 Port;
    TQueue<FString> MessageQueue;  // 스레드 안전한 큐
};

위의 코드에서 FThreadSafeBool은 원자적(atomic) 접근이 가능한 불리언 타입으로, 여러 스레드에서 동시에 읽고 써도 안전합니다. TQueue는 언리얼 엔진이 제공하는 락프리(lock-free) 큐로, 하나의 스레드가 쓰고 다른 스레드가 읽는 단일 생산자-단일 소비자(SPSC) 패턴에서 별도의 동기화 없이 사용할 수 있습니다. 멀티스레딩에서 스레드 간 데이터 전달에 매우 자주 사용되는 컨테이너입니다.

다음은 구현 파일입니다.

// FNetworkReceiver.cpp
#include "FNetworkReceiver.h"

FNetworkReceiver::FNetworkReceiver(int32 InPort)
    : Port(InPort)
{
    // 스레드 생성 — 생성 즉시 Init() → Run() 실행 시작
    Thread = FRunnableThread::Create(
        this,                      // FRunnable 인스턴스
        TEXT("NetworkReceiver"),   // 스레드 이름 (디버깅용)
        0,                         // 스택 크기 (0 = 기본값)
        TPri_Normal                // 스레드 우선순위
    );
}

FNetworkReceiver::~FNetworkReceiver()
{
    if (Thread)
    {
        // Kill(true) — Stop() 호출 후 Run()이 끝날 때까지 대기
        Thread->Kill(true);
        delete Thread;
        Thread = nullptr;
    }
}

bool FNetworkReceiver::Init()
{
    UE_LOG(LogTemp, Log, TEXT("NetworkReceiver: 스레드 초기화 시작 (포트: %d)"), Port);
    // 소켓 초기화 등의 준비 작업 수행
    // 실패 시 false를 반환하면 Run()이 실행되지 않음
    return true;
}

uint32 FNetworkReceiver::Run()
{
    // bShouldStop이 true가 될 때까지 반복
    while (!bShouldStop)
    {
        // 네트워크 데이터 수신 시뮬레이션
        FString ReceivedData = FString::Printf(TEXT("Data from port %d"), Port);
        MessageQueue.Enqueue(ReceivedData);

        // CPU 과부하 방지를 위한 슬립
        FPlatformProcess::Sleep(0.016f);  // 약 60Hz
    }

    return 0;  // 정상 종료
}

void FNetworkReceiver::Stop()
{
    // 게임 스레드에서 호출됨 — Run()의 루프를 종료시킴
    bShouldStop = true;
}

void FNetworkReceiver::Exit()
{
    UE_LOG(LogTemp, Log, TEXT("NetworkReceiver: 스레드 정리 완료"));
    // 소켓 해제 등의 정리 작업 수행
}

bool FNetworkReceiver::DequeueMessage(FString& OutMessage)
{
    return MessageQueue.Dequeue(OutMessage);
}

위의 코드에서 핵심은 bShouldStop 플래그의 역할입니다. Stop()은 게임 스레드에서 호출되어 플래그를 true로 설정하고, Run()은 새 스레드에서 이 플래그를 확인하여 루프를 탈출합니다. 이처럼 Stop()과 Run()은 서로 다른 스레드에서 동시에 실행되므로, 공유 변수는 반드시 스레드 안전한 타입을 사용해야 합니다.

3-3. FRunnable 사용하기

액터에서 FRunnable 스레드를 생성하고 관리하는 예제입니다.

// ANetworkActor.cpp
void ANetworkActor::BeginPlay()
{
    Super::BeginPlay();

    // 스레드 생성 (생성 즉시 실행 시작)
    NetworkReceiver = new FNetworkReceiver(7777);
}

void ANetworkActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    // 게임 스레드에서 수신된 메시지 꺼내기
    FString Message;
    while (NetworkReceiver && NetworkReceiver->DequeueMessage(Message))
    {
        UE_LOG(LogTemp, Log, TEXT("수신: %s"), *Message);
    }
}

void ANetworkActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    // 스레드 정리 — 소멸자에서 Kill() 호출
    if (NetworkReceiver)
    {
        delete NetworkReceiver;
        NetworkReceiver = nullptr;
    }

    Super::EndPlay(EndPlayReason);
}

위 코드처럼 FRunnable 객체의 생성과 소멸은 액터의 수명 주기에 맞춰 관리합니다. BeginPlay에서 생성하고 EndPlay에서 반드시 삭제하여 메모리 누수와 댕글링 스레드를 방지합니다.

4. AsyncTask — 간편한 비동기 작업 디스패칭

4-1. AsyncTask의 개념

AsyncTask()는 언리얼 엔진의 Task Graph 시스템을 활용하여 특정 스레드에 작업을 간편하게 전달하는 함수입니다. FRunnable처럼 스레드를 직접 생성하고 관리할 필요 없이, 람다 함수 하나만으로 백그라운드 작업을 실행할 수 있습니다.

AsyncTask()의 핵심은 어떤 스레드에서 실행할지를 지정할 수 있다는 점입니다. 첫 번째 매개변수로 ENamedThreads::Type을 전달하면, 해당 작업이 지정된 스레드에서 실행됩니다.

주요 스레드 타입은 다음과 같습니다.

스레드 타입 설명
ENamedThreads::GameThread 게임 스레드 (UObject 접근 가능)
ENamedThreads::AnyBackgroundThreadNormalTask 백그라운드 스레드 풀의 임의 스레드
ENamedThreads::AnyHiPriThreadNormalTask 높은 우선순위 스레드

4-2. AsyncTask 기본 사용법

백그라운드 스레드에서 무거운 계산을 수행한 뒤 결과를 게임 스레드로 전달하는 패턴입니다.

void AMyActor::StartHeavyComputation()
{
    // 백그라운드 스레드에서 무거운 연산 실행
    AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [this]()
    {
        // 이 람다는 백그라운드 스레드에서 실행됨
        float Result = 0.f;
        for (int32 i = 0; i < 1000000; ++i)
        {
            Result += FMath::Sin(static_cast<float>(i));
        }

        // 연산 완료 후 결과를 게임 스레드로 전달
        AsyncTask(ENamedThreads::GameThread, [this, Result]()
        {
            // 이 람다는 게임 스레드에서 실행됨 — UObject 접근 안전
            UE_LOG(LogTemp, Log, TEXT("계산 결과: %f"), Result);
            OnComputationCompleted(Result);
        });
    });
}

위의 코드에서 AsyncTask를 중첩하여 사용하는 패턴에 주목해야 합니다. 바깥쪽 AsyncTask는 백그라운드 스레드에서 연산을 수행하고, 안쪽 AsyncTask는 게임 스레드에서 결과를 처리합니다. 이 패턴은 언리얼 멀티스레딩에서 매우 자주 사용되는 백그라운드 → 게임 스레드 콜백 패턴입니다.

4-3. Async() 함수와 TFuture

AsyncTask()보다 더 유연한 방법으로 Async() 템플릿 함수가 있습니다. Async()는 작업의 결과를 TFuture로 반환하여, 나중에 결과를 조회하거나 완료를 대기할 수 있습니다.

void AMyActor::StartAsyncWork()
{
    // Async()는 TFuture를 반환
    TFuture<int32> Future = Async(EAsyncExecution::ThreadPool, []() -> int32
    {
        // 스레드 풀에서 실행되는 작업
        int32 Sum = 0;
        for (int32 i = 0; i < 100000; ++i)
        {
            Sum += i;
        }
        return Sum;
    });

    // 결과가 준비될 때 실행할 콜백 등록
    Future.Then([this](TFuture<int32> CompletedFuture)
    {
        int32 Result = CompletedFuture.Get();
        UE_LOG(LogTemp, Log, TEXT("Async 결과: %d"), Result);
    });
}

위 코드처럼 Async()는 EAsyncExecution 열거형으로 실행 방식을 지정합니다.

실행 방식 설명
EAsyncExecution::ThreadPool 엔진의 글로벌 스레드 풀에서 실행
EAsyncExecution::Thread 새로운 전용 스레드를 생성하여 실행
EAsyncExecution::TaskGraph Task Graph 시스템에 작업 제출

TFuture는 일반 C++의 std::future와 유사하게 비동기 작업의 결과를 나타내는 객체입니다. Get()을 호출하면 결과가 준비될 때까지 현재 스레드를 블로킹하고, Then()을 사용하면 논블로킹으로 완료 콜백을 등록할 수 있습니다.

5. ParallelFor — 데이터 병렬 처리

5-1. ParallelFor의 개념

ParallelFor는 일반적인 for 루프를 여러 스레드에 분산하여 병렬로 실행하는 함수입니다. 동일한 연산을 대량의 데이터에 적용해야 할 때, 단순히 ParallelFor로 감싸는 것만으로 멀티코어의 이점을 활용할 수 있습니다.

일반 for 루프와 ParallelFor의 가장 큰 차이는 실행 순서입니다. 일반 for 루프는 인덱스 0부터 순차적으로 실행되지만, ParallelFor는 여러 스레드가 각자 다른 인덱스를 동시에 처리하므로 실행 순서가 보장되지 않습니다.

5-2. ParallelFor 기본 사용법

수천 개의 데미지 계산을 병렬로 처리하는 예제입니다.

void ADamageCalculator::CalculateDamageForAllTargets(
    const TArray<FDamageContext>& DamageContexts,
    TArray<float>& OutResults)
{
    // 결과 배열을 미리 할당 — ParallelFor 내부에서 크기 변경 금지
    OutResults.SetNum(DamageContexts.Num());

    // 각 인덱스별로 독립적인 계산을 병렬 수행
    ParallelFor(DamageContexts.Num(), [&DamageContexts, &OutResults](int32 Index)
    {
        const FDamageContext& Context = DamageContexts[Index];

        // 각 요소에 대한 독립적인 연산
        float BaseDamage = Context.AttackPower - Context.Defense;
        float ElementalModifier = CalculateElementalModifier(
            Context.AttackElement, Context.DefenseElement);
        float CriticalMultiplier = Context.bIsCritical ? 2.0f : 1.0f;

        // 인덱스 기반 접근이므로 다른 스레드와 충돌하지 않음
        OutResults[Index] = FMath::Max(0.f, BaseDamage * ElementalModifier * CriticalMultiplier);
    });
}

위의 코드에서 핵심은 각 반복이 완전히 독립적이라는 점입니다. 각 스레드는 자신의 인덱스에 해당하는 데이터만 읽고, 결과도 자신의 인덱스에만 기록합니다. 이처럼 각 반복 간에 데이터 의존성이 없어야 ParallelFor를 안전하게 사용할 수 있습니다.

5-3. ParallelFor의 최소 배치 크기

ParallelFor는 세 번째 매개변수로 최소 배치 크기를 지정하여 스레드 분배를 제어할 수 있습니다.

// 최소 100개 단위로 스레드에 분배
ParallelFor(LargeArray.Num(), [&LargeArray](int32 Index)
{
    ProcessItem(LargeArray[Index]);
},
/* bForceSingleThread = */ false,  // 병렬 실행 강제 여부
/* MinBatchSize = */ 100);          // 최소 배치 크기 (EParallelForFlags 버전도 존재)

위 코드에서 최소 배치 크기를 100으로 설정하면, 배열의 크기가 100 미만일 때는 단일 스레드에서 실행됩니다. 이는 소량의 데이터에 대해 스레드를 나누는 오버헤드가 연산 자체보다 클 수 있기 때문입니다. 일반적으로 단일 반복의 연산이 가벼울수록 배치 크기를 크게 설정하는 것이 좋습니다.

5-4. ParallelFor에서의 동기화

ParallelFor 내부에서 공유 자원에 접근해야 하는 경우, 반드시 동기화가 필요합니다.

void AEnemyManager::CountEnemiesByType(
    const TArray<AEnemy*>& Enemies,
    TMap<FName, int32>& OutCounts)
{
    FCriticalSection CountLock;  // 뮤텍스 역할

    ParallelFor(Enemies.Num(), [&Enemies, &OutCounts, &CountLock](int32 Index)
    {
        FName EnemyType = Enemies[Index]->GetEnemyType();

        // 공유 자원(OutCounts)에 접근할 때는 반드시 락 사용
        FScopeLock Lock(&CountLock);
        OutCounts.FindOrAdd(EnemyType)++;
    });
}

위의 코드에서 FScopeLock은 생성 시 자동으로 락을 획득하고 스코프를 벗어나면 자동으로 해제하는 RAII 패턴의 락입니다. 그러나 이처럼 매 반복마다 락을 거는 패턴은 병렬 처리의 이점을 크게 감소시킵니다. 가능하다면 각 스레드가 독립적인 결과를 계산한 뒤, 마지막에 한 번만 병합하는 패턴이 훨씬 효율적입니다.

6. 스레드 동기화 기법

멀티스레딩에서 가장 어렵고 중요한 부분은 여러 스레드 간의 데이터 공유와 동기화입니다. 언리얼 엔진은 다양한 동기화 프리미티브를 제공합니다.

6-1. FCriticalSection과 FScopeLock

FCriticalSection은 일반 C++의 std::mutex에 해당하는 언리얼 엔진의 뮤텍스입니다. 한 번에 하나의 스레드만 보호된 코드 영역에 진입할 수 있도록 보장합니다.

class FThreadSafeInventory
{
public:
    void AddItem(const FName& ItemName, int32 Count)
    {
        // FScopeLock — 스코프를 벗어나면 자동으로 언락
        FScopeLock Lock(&InventoryLock);
        Inventory.FindOrAdd(ItemName) += Count;
    }

    int32 GetItemCount(const FName& ItemName) const
    {
        FScopeLock Lock(&InventoryLock);
        const int32* Found = Inventory.Find(ItemName);
        return Found ? *Found : 0;
    }

private:
    mutable FCriticalSection InventoryLock;
    TMap<FName, int32> Inventory;
};

위 코드처럼 FScopeLock을 사용하면 Lock/Unlock을 수동으로 호출할 필요 없이, 스코프 기반으로 안전하게 락을 관리할 수 있습니다. 이는 일반 C++에서 std::lock_guard를 사용하는 것과 동일한 패턴입니다.

6-2. FThreadSafeBool과 TAtomic

간단한 플래그나 카운터의 경우, 뮤텍스보다 가벼운 원자적(atomic) 타입을 사용할 수 있습니다.

// FThreadSafeBool — 스레드 안전한 불리언
FThreadSafeBool bIsProcessing = false;

// TAtomic — 스레드 안전한 정수 카운터
TAtomic<int32> ProcessedCount(0);

// 백그라운드 스레드에서
bIsProcessing = true;
ProcessedCount++;  // 원자적 증가

// 게임 스레드에서 확인
if (bIsProcessing)
{
    int32 Count = ProcessedCount.Load();
    UE_LOG(LogTemp, Log, TEXT("처리된 항목: %d"), Count);
}

위의 코드에서 TAtomic은 일반 C++의 std::atomic에 대응하는 타입입니다. 원자적(atomic) 연산이란 다른 스레드의 개입 없이 하나의 명령으로 완료되는 연산을 의미하며, 뮤텍스보다 훨씬 가볍지만 단일 변수의 읽기/쓰기만 보호할 수 있다는 제한이 있습니다. 여러 변수를 동시에 일관성 있게 수정해야 한다면 FCriticalSection을 사용해야 합니다.

6-3. FEvent — 스레드 간 신호 전달

FEvent는 한 스레드가 다른 스레드에 이벤트 발생을 알리는 데 사용하는 동기화 객체입니다.

class FWorkerThread : public FRunnable
{
public:
    FWorkerThread()
    {
        // 수동 리셋 이벤트 생성
        WorkReadyEvent = FPlatformProcess::GetSynchEventFromPool(false);
        Thread = FRunnableThread::Create(this, TEXT("WorkerThread"));
    }

    virtual ~FWorkerThread()
    {
        bShouldStop = true;
        WorkReadyEvent->Trigger();  // 대기 중인 스레드를 깨움
        Thread->Kill(true);
        delete Thread;
        FPlatformProcess::ReturnSynchEventToPool(WorkReadyEvent);
    }

    virtual uint32 Run() override
    {
        while (!bShouldStop)
        {
            // 작업이 들어올 때까지 대기 (CPU를 점유하지 않음)
            WorkReadyEvent->Wait();

            if (!bShouldStop)
            {
                ProcessPendingWork();
            }
        }
        return 0;
    }

    // 게임 스레드에서 호출 — 작업을 추가하고 워커를 깨움
    void EnqueueWork(TFunction<void()> Work)
    {
        {
            FScopeLock Lock(&QueueLock);
            WorkQueue.Enqueue(MoveTemp(Work));
        }
        WorkReadyEvent->Trigger();  // 대기 중인 Run()을 깨움
    }

private:
    void ProcessPendingWork()
    {
        TFunction<void()> Work;
        while (WorkQueue.Dequeue(Work))
        {
            Work();
        }
    }

    FRunnableThread* Thread = nullptr;
    FThreadSafeBool bShouldStop = false;
    FEvent* WorkReadyEvent = nullptr;
    FCriticalSection QueueLock;
    TQueue<TFunction<void()>> WorkQueue;
};

위의 코드에서 FEvent의 Wait()는 Trigger()가 호출될 때까지 현재 스레드를 슬립 상태로 만듭니다. 이는 while 루프에서 FPlatformProcess::Sleep()으로 폴링하는 것보다 훨씬 효율적입니다. Sleep 방식은 매번 깨어나서 조건을 확인하지만, FEvent는 실제로 작업이 들어왔을 때만 스레드를 깨우기 때문입니다.

7. 세 가지 도구의 비교와 선택 기준

기준 FRunnable AsyncTask ParallelFor
추상화 수준 저수준 (직접 관리) 중간 (태스크 제출) 고수준 (루프 병렬화)
스레드 생성 전용 OS 스레드 생성 기존 스레드 풀 사용 기존 스레드 풀 사용
수명 장기간 (앱 수명 내내 가능) 단발성 작업 단발성 작업
적합한 작업 네트워크 리스닝, 파일 감시 비동기 계산, 스레드 전환 대량 데이터 일괄 처리
복잡도 높음 낮음 중간 (동기화 주의)
결과 반환 직접 구현 (큐, 콜백 등) 람다 캡처 또는 TFuture 인덱스 기반 결과 배열

선택 기준을 정리하면 다음과 같습니다. 장시간 실행되는 독립적인 백그라운드 작업이 필요하다면 FRunnable을 사용합니다. 짧은 비동기 작업이나 게임 스레드로의 결과 전달이 필요하다면 AsyncTask를 사용합니다. 동일한 연산을 대량의 데이터에 적용해야 한다면 ParallelFor를 사용합니다.

8. 일반 C++과의 비교

언리얼 엔진의 멀티스레딩 도구는 일반 C++ 표준 라이브러리의 스레딩 기능과 많은 공통점을 가지고 있습니다.

FRunnable / FRunnableThread는 일반 C++의 std::thread와 유사한 역할을 합니다. 둘 다 OS 스레드를 생성하고, 스레드에서 실행할 함수를 지정하며, 종료를 대기(join)하는 기본 흐름을 따릅니다.

그러나 언리얼 엔진은 std::thread 대신 FRunnable 인터페이스 패턴을 채택했습니다. std::thread는 단순히 함수를 스레드에 전달하는 방식이지만, FRunnable은 Init → Run → Exit라는 명시적인 수명 주기를 제공합니다. 이는 초기화 실패 시 스레드를 안전하게 중단하거나, 종료 시 정리 작업을 체계적으로 수행할 수 있게 합니다.

// 일반 C++ — std::thread
std::thread WorkerThread([]() {
    // 작업 수행
});
WorkerThread.join();  // 종료 대기

// 언리얼 — FRunnable + FRunnableThread
FRunnableThread* Thread = FRunnableThread::Create(
    MyRunnable,            // Init → Run → Exit 수명 주기 보유
    TEXT("WorkerThread")
);
Thread->Kill(true);        // Stop() 호출 + 종료 대기

위의 코드에서 볼 수 있듯이, std::thread는 join()으로 단순히 종료를 대기하는 반면, 언리얼의 Kill()은 Stop()을 먼저 호출하여 Run()에 종료 신호를 보낸 뒤 대기합니다. 이 패턴 덕분에 스레드를 안전하게 중단할 수 있습니다.

동기화 프리미티브도 대응 관계가 명확합니다. FCriticalSection은 std::mutex에, FScopeLock은 std::lock_guard에, TAtomic은 std::atomic에, TFuture는 std::future에 각각 대응합니다.

또한 일반 C++17의 std::execution::par를 사용한 병렬 알고리즘과 언리얼의 ParallelFor는 유사한 목적을 가지지만, ParallelFor는 언리얼 엔진의 스레드 풀과 통합되어 엔진의 다른 시스템과의 스레드 경합을 최소화한다는 장점이 있습니다.

9. 유니티 엔진과의 비교

유니티 엔진에서도 멀티스레딩을 지원하지만, 접근 방식에 상당한 차이가 있습니다.

유니티의 전통적인 멀티스레딩 방식은 C#의 System.Threading.Thread 클래스나 Task.Run()을 사용하는 것입니다. 이는 언리얼의 FRunnable이나 AsyncTask와 유사한 역할을 합니다. 그러나 유니티의 대표적인 병렬 처리 시스템은 Job SystemBurst Compiler를 결합한 방식입니다. 유니티의 Job System은 IJob, IJobParallelFor 등의 인터페이스를 구현하여 작업을 정의하고, Job Scheduler가 워커 스레드에 분배합니다.

언리얼 엔진의 멀티스레딩이 가지는 차별적인 강점은 C++ 네이티브 수준의 제어력입니다. 유니티의 Job System은 안전성을 위해 NativeContainer만 사용 가능하고, 관리 객체(Managed Object) 접근이 제한되는 등 구조적 제약이 있습니다. 반면 언리얼은 FRunnable로 OS 스레드를 직접 관리하면서도, AsyncTask와 ParallelFor로 간편한 병렬 처리를 동시에 제공합니다. 또한 언리얼의 Task Graph 시스템은 태스크 간 의존성을 선언적으로 정의할 수 있어, 복잡한 병렬 파이프라인을 구성할 때 유연한 설계가 가능합니다.

10. 주의사항

10-1. 게임 스레드 외부에서 UObject 접근 금지

멀티스레딩에서 가장 흔하고 치명적인 실수는 백그라운드 스레드에서 UObject에 직접 접근하는 것입니다. 언리얼 엔진의 가비지 컬렉션(GC)은 게임 스레드에서 주기적으로 실행되며, 참조되지 않는 UObject를 자동으로 회수합니다. 이 GC 사이클은 게임 스레드에서만 안전하게 동작하므로, 백그라운드 스레드에서 UObject의 프로퍼티를 읽거나 함수를 호출하면 GC가 이미 해당 객체를 회수한 뒤일 수 있어 크래시나 정의되지 않은 동작이 발생할 수 있습니다.

// 잘못된 사용 — 백그라운드 스레드에서 UObject 접근
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [this]()
{
    // 위험! GC가 이 액터를 이미 회수했을 수 있음
    FVector Location = GetActorLocation();
});

// 올바른 사용 — 필요한 데이터를 미리 복사
FVector CachedLocation = GetActorLocation();
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [CachedLocation]()
{
    // 안전 — 값 복사본을 사용
    FVector ProcessedLocation = CachedLocation * 2.0f;
});

위 코드처럼 백그라운드 스레드에서 필요한 데이터는 반드시 게임 스레드에서 미리 복사한 뒤, 복사본만 사용해야 합니다. 결과를 UObject에 반영해야 할 때는 AsyncTask로 게임 스레드에 돌아와서 처리합니다.

10-2. ParallelFor에서의 공유 자원 충돌

ParallelFor의 람다는 여러 스레드에서 동시에 호출됩니다. 공유 컨테이너에 동시에 원소를 추가하면 크래시가 발생합니다.

// 잘못된 사용 — 공유 배열에 동시 추가
TArray<FVector> Results;
ParallelFor(Positions.Num(), [&Positions, &Results](int32 Index)
{
    // 위험! TArray::Add는 스레드 안전하지 않음
    Results.Add(Positions[Index] * 2.0f);
});

// 올바른 사용 — 인덱스 기반 접근
TArray<FVector> Results;
Results.SetNum(Positions.Num());  // 미리 크기 할당
ParallelFor(Positions.Num(), [&Positions, &Results](int32 Index)
{
    // 안전 — 각 스레드가 고유 인덱스에만 쓰기
    Results[Index] = Positions[Index] * 2.0f;
});

위의 코드에서 핵심은 결과 배열의 크기를 미리 할당하고, 각 스레드가 자신의 인덱스에만 접근하는 것입니다. 이 패턴을 따르면 락 없이도 안전한 병렬 처리가 가능합니다.

10-3. 데드락 방지

여러 개의 락을 사용할 때 획득 순서가 일정하지 않으면 데드락이 발생할 수 있습니다. 반드시 모든 코드에서 동일한 순서로 락을 획득해야 합니다. 또한 락을 보유한 상태에서 다른 스레드의 완료를 대기하는 것도 데드락의 원인이 됩니다.

10-4. 소량 데이터에 대한 ParallelFor 오버헤드

ParallelFor는 내부적으로 스레드 풀에 작업을 분배하는 과정에서 오버헤드가 발생합니다. 배열 크기가 작거나 단일 반복의 연산이 매우 가벼운 경우, 일반 for 루프보다 오히려 느릴 수 있습니다. 실제로 사용해보면 수백 개 이하의 가벼운 연산에서는 일반 for 루프가 더 빠른 경우가 많습니다. 프로파일링을 통해 성능 이점을 확인한 후에 적용하는 것이 중요합니다.

10-5. 스레드 수명 관리

FRunnable로 생성한 스레드는 반드시 명시적으로 정리해야 합니다. 액터가 파괴될 때 스레드를 Kill하지 않으면 댕글링 스레드가 남아 크래시를 유발할 수 있습니다. EndPlay 또는 BeginDestroy에서 반드시 스레드를 종료하고 삭제하는 코드를 작성해야 합니다.

 

 

언리얼 엔진의 멀티스레딩은 FRunnable로 스레드를 직접 관리하는 저수준 방식부터, AsyncTask로 간편하게 비동기 작업을 실행하는 중간 수준, ParallelFor로 데이터를 일괄 병렬 처리하는 고수준 방식까지 세 가지 계층의 도구를 제공합니다. 대부분의 게임 개발 상황에서는 AsyncTask와 ParallelFor만으로 충분하며, 네트워크나 파일 감시처럼 장기간 실행되는 전용 스레드가 필요한 경우에만 FRunnable을 고려하면 됩니다. 어떤 도구를 선택하든, 게임 스레드 외부에서의 UObject 접근 금지와 공유 자원의 동기화라는 두 가지 원칙을 반드시 지켜야 합니다.

반응형

'언리얼 엔진 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