게임 개발

언리얼 C++ 액터 틱, 이렇게 쓰면 망합니다: 비동기/부하 분산으로 성능 잡는 실전 튜닝

강코의 코딩 일기 2026. 7. 12. 18:16
반응형

언리얼 엔진 C++ 액터의 틱 함수는 강력하지만, 잘못 사용하면 게임 성능을 치명적으로 저하시킵니다. 비동기 작업과 부하 분산 기법으로 틱 호출 비용을 최적화하여 쾌적한 게임 환경을 만드는 실전 팁을 공유합니다.

안녕하세요, 게임 개발 현장에서 언리얼 엔진 C++와 씨름하고 있는 주니어 개발자 여러분. 혹시 여러분의 게임에서도 특정 순간 프레임 드롭이 발생하거나, 복잡한 로직이 실행될 때마다 게임이 버벅이는 경험을 하고 계신가요? 저도 처음에는 아무 생각 없이 Tick 함수에 모든 로직을 때려 박았다가, 어느 날 갑자기 찾아온 성능 병목 현상에 당황했던 기억이 생생합니다. 특히 수많은 액터가 동시에 복잡한 계산을 수행해야 할 때, 이 Tick 함수가 게임 성능의 주범이 되는 경우가 많습니다.

이 글에서는 언리얼 엔진 C++ 액터의 틱(Tick) 함수가 왜 성능 문제를 일으키는지, 그리고 이를 어떻게 비동기 작업부하 분산 기법으로 최적화할 수 있는지, 제가 직접 경험하고 적용해 본 실전 노하우를 공유하려고 합니다. 기본기는 탄탄하지만 실전 경험이 부족한 주니어 개발자분들께 큰 도움이 되리라 확신합니다.

언리얼 엔진 C++ 액터의 틱(Tick) 함수 호출 비용 최적화: 비동기 작업 및 부하 분산 튜닝 - seo, sem, marketing, optimization, web, internet, search engine, website, web traffic, strategy, content, advertising, online, www, analysis, service, seo, seo, seo, seo, seo

Image by Firmbee on Pixabay

언리얼 액터의 틱(Tick) 함수: 양날의 검

언리얼 엔진에서 액터(Actor)는 게임 월드에 존재하는 모든 오브젝트의 기본 단위입니다. 그리고 액터가 매 프레임마다 업데이트되어야 하는 로직을 처리하는 핵심적인 함수가 바로 Tick 함수입니다. AActor::Tick(float DeltaTime)은 게임의 메인 스레드(Main Thread)에서 매 프레임 호출되며, DeltaTime 인자를 통해 이전 프레임으로부터 경과된 시간을 알 수 있습니다. 이동, 애니메이션 업데이트, AI 로직, 충돌 감지 등 실시간으로 변화해야 하는 거의 모든 로직이 이 틱 함수 안에서 처리됩니다.

처음에는 틱 함수가 모든 것을 처리해주는 만능 함수처럼 느껴질 수 있습니다. 저 역시 그랬습니다. "그냥 여기에 다 넣으면 알아서 돌아가겠지!"라고 생각했죠. 하지만 이 편리함 뒤에는 엄청난 함정이 숨어있습니다. 게임 내 액터의 수가 늘어나고, 각 액터의 틱 함수 안에서 수행하는 연산량이 많아질수록, 메인 스레드는 감당할 수 없을 만큼의 부하를 받게 됩니다. 결국 이는 프레임 드롭(Frame Drop)으로 이어지고, 사용자 경험을 심각하게 저해하는 원인이 됩니다.

왜 틱 함수가 성능 병목의 주범이 되는가?

문제의 핵심은 동기적(Synchronous) 실행 방식에 있습니다. 언리얼 엔진은 기본적으로 단일 메인 스레드에서 대부분의 게임 로직을 순차적으로 실행합니다. 모든 액터의 틱 함수 호출 또한 이 메인 스레드에서 이루어집니다. 만약 1000개의 액터가 있고, 각 액터의 틱 함수가 0.1ms를 소모한다면, 단순히 틱 함수 처리만으로도 1000 * 0.1ms = 100ms가 소요됩니다. 100ms는 10 FPS에 해당하며, 이는 즉각적인 게임 멈춤 현상으로 이어집니다. 여기에 렌더링, 물리, 네트워크 등 다른 작업까지 합쳐지면 상황은 더욱 악화됩니다.

특히 다음과 같은 작업들이 틱 함수 내에서 빈번하게 발생할 때 성능 저하가 심화됩니다:

  • 복잡한 경로 탐색(Pathfinding) 알고리즘
  • 다수의 주변 액터를 대상으로 하는 범위 탐지(Overlap/Line Trace)
  • 복잡한 AI 의사 결정 로직
  • 대규모 데이터를 처리하는 수학적 연산
  • 파일 입출력 또는 네트워크 요청과 같은 블로킹(Blocking) 작업

동기적 틱 처리의 한계와 비동기 작업의 필요성

앞서 설명했듯이, 메인 스레드에서 모든 무거운 작업을 처리하는 것은 명백한 한계가 있습니다. 사용자의 입력 처리, 렌더링 파이프라인 진행 등 게임의 원활한 흐름을 위해 메인 스레드는 항상 빠르게 반응해야 합니다. 하지만 복잡한 연산이 틱 함수 안에서 동기적으로 실행되면, 메인 스레드는 해당 연산이 끝날 때까지 다른 작업을 수행할 수 없게 됩니다. 마치 고속도로의 모든 차들이 한 차선으로만 달리려다 정체되는 상황과 유사합니다.

이러한 한계를 극복하기 위해 비동기(Asynchronous) 작업의 도입이 필수적입니다. 비동기 작업은 메인 스레드에서 직접 처리하기에 시간이 오래 걸리는 작업들을 백그라운드 스레드(Background Thread)로 분리하여 실행하는 기법입니다. 이렇게 하면 메인 스레드는 무거운 작업이 백그라운드에서 진행되는 동안에도 다른 중요한 작업을 계속 처리할 수 있게 됩니다. 게임 개발에서는 주로 복잡한 AI 계산, 데이터 로딩, 물리 연산의 일부 등을 비동기 작업으로 처리합니다.

언리얼 엔진에서 비동기 작업을 활용하는 방법

언리얼 엔진은 개발자가 비동기 작업을 쉽게 다룰 수 있도록 다양한 기능을 제공합니다. 제가 실무에서 유용하게 사용했던 두 가지 방법을 소개합니다.

FAsyncTask와 FAsyncWork를 이용한 기본 비동기 작업

가장 기본적인 비동기 작업 처리 방법은 언리얼 엔진의 FAsyncWorkFAsyncTask 클래스를 활용하는 것입니다. 이들은 특정 작업을 별도의 스레드에서 실행하고, 완료 시 콜백을 통해 결과를 처리할 수 있도록 도와줍니다.

예를 들어, 액터 주변의 적들을 탐색하는 복잡한 로직이 있다고 가정해봅시다. 이 로직이 매 틱마다 실행되면 성능에 큰 영향을 줄 수 있습니다. 이를 비동기로 처리하는 방법은 다음과 같습니다.


// MyAsyncTask.h
class FMyComplexSearchTask : public FNonAbandonableTask
{
public:
    FMyComplexSearchTask(TArray<AActor*>& InActorsToSearch)
        : ActorsToSearch(InActorsToSearch)
        , FoundActors(nullptr)
    {
    }

    void DoWork()
    {
        // 여기에 복잡한 탐색 로직 구현 (예: 수백 개의 액터에 대한 거리 계산, 라인 트레이스 등)
        // 이 작업은 메인 스레드가 아닌 별도의 스레드에서 실행됩니다.
        UE_LOG(LogTemp, Warning, TEXT("Performing complex search on background thread..."));
        
        // 결과 저장 (예시)
        TArray<AActor*> LocalFoundActors;
        for (AActor* Actor : ActorsToSearch)
        {
            if (FMath::RandBool()) // 임의의 조건
            {
                LocalFoundActors.Add(Actor);
            }
        }

        // 결과를 메인 스레드로 전달하기 위해 멤버 변수에 저장
        // 단, 스레드 안전성 고려 필요. 여기서는 예시를 위해 단순화.
        if (FoundActors)
        {
            *FoundActors = LocalFoundActors;
        }

        UE_LOG(LogTemp, Warning, TEXT("Complex search finished."));
    }

    FORCEINLINE TStatId Get='TStatId'
    {
        RETURN_QUICK_STATS_ROOT(TEXT("FMyComplexSearchTask"));
    }

    // 결과 데이터를 받을 포인터 (메인 스레드에서 설정)
    TArray<AActor*>* FoundActors;

private:
    TArray<AActor*> ActorsToSearch;
};

// MyActor.h (예시)
class AMyActor : public AActor
{
    GENERATED_BODY()

public:
    AMyActor();
    virtual void Tick(float DeltaTime) override;

    void StartComplexSearch();
    void OnComplexSearchFinished();

private:
    TUniquePtr<FAsyncTask<FMyComplexSearchTask>> CurrentSearchTask;
    TArray<AActor*> NearbyActors; // 검색 대상 액터들
    TArray<AActor*> FoundActorsResult; // 검색 결과

    bool bIsSearchRunning = false;
};

// MyActor.cpp (예시)
AMyActor::AMyActor()
{
    PrimaryActorTick.bCanEverTick = true;
}

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

    if (CurrentSearchTask.IsValid() && CurrentSearchTask->IsDone() && bIsSearchRunning)
    {
        OnComplexSearchFinished();
        bIsSearchRunning = false;
    }

    // 다른 틱 로직...
}

void AMyActor::StartComplexSearch()
{
    if (bIsSearchRunning) return; // 이미 실행 중이면 스킵

    // 검색 대상 액터들을 먼저 수집 (이것 자체는 메인 스레드에서 실행)
    UGameplayStatics::GetAllActorsOfClass(GetWorld(), AActor::StaticClass(), NearbyActors); // 예시

    CurrentSearchTask = MakeUnique<FAsyncTask<FMyComplexSearchTask>>(NearbyActors);
    CurrentSearchTask->GetTask().FoundActors = &FoundActorsResult; // 결과 포인터 연결
    CurrentSearchTask->StartBackgroundTask(); // 백그라운드에서 작업 시작
    bIsSearchRunning = true;

    UE_LOG(LogTemp, Warning, TEXT("Complex search started."));
}

void AMyActor::OnComplexSearchFinished()
{
    // 비동기 작업 완료 후, 메인 스레드에서 결과 처리
    // FoundActorsResult 배열에 결과가 담겨있을 것입니다.
    for (AActor* FoundActor : FoundActorsResult)
    {
        UE_LOG(LogTemp, Warning, TEXT("Found actor: %s"), *FoundActor->GetName());
    }
    
    // 작업 완료 후 태스크 정리
    CurrentSearchTask.Reset();
    FoundActorsResult.Empty();
}

이 패턴을 사용하면 무거운 계산을 메인 스레드에서 분리하여 프레임 레이트를 안정적으로 유지할 수 있습니다. FNonAbandonableTask는 작업이 취소되지 않음을 보장하며, FAsyncTask는 이 태스크를 스레드 풀에 제출하여 실행합니다.

언리얼 태스크 그래프 시스템 들여다보기

좀 더 복잡하거나 의존성 있는 비동기 작업 체인을 구축해야 할 때는 언리얼 엔진의 태스크 그래프(Task Graph) 시스템이 강력한 대안이 됩니다. 태스크 그래프는 개별 태스크들을 그래프 형태로 연결하여 병렬 처리의 효율을 극대화하고, 작업 간의 의존성을 명확히 관리할 수 있게 해줍니다.

기본적으로 FTaskGraphInterface::Get().QueueTask()를 사용하여 태스크를 큐에 넣을 수 있으며, ENamedThreads를 통해 특정 스레드(예: 렌더 스레드, 게임 스레드)에서 실행되도록 지정하거나, 일반 백그라운드 스레드에서 실행되도록 할 수 있습니다. 복잡한 AI 행동 트리, 대규모 환경 데이터 스트리밍 등 여러 단계의 비동기 작업이 필요한 경우에 특히 유용합니다.

직접 태스크 그래프를 다루는 것은 FAsyncTask보다 복잡할 수 있지만, 성능에 민감한 대규모 프로젝트에서는 필수적인 기술입니다. 자세한 내용은 언리얼 엔진 공식 문서를 참고하시는 것을 권장합니다.

언리얼 엔진 C++ 액터의 틱(Tick) 함수 호출 비용 최적화: 비동기 작업 및 부하 분산 튜닝 - search engine optimization, seo, digital marketing, laptop, online job, seo, seo, seo, seo, seo, digital marketing

Image by DiggityMarketing on Pixabay

액터 부하 분산 전략: 틱 호출 비용 최소화

비동기 작업이 무거운 연산을 스레드 풀로 넘기는 방법이라면, 부하 분산(Load Balancing)은 애초에 틱 함수가 수행해야 할 작업량을 줄이거나, 틱 호출 자체를 효율적으로 제어하는 기법입니다. 제가 실제로 적용해보고 효과를 봤던 몇 가지 전략을 소개합니다.

Tick Interval과 SetActorTickEnabled 활용

모든 액터가 매 프레임마다 틱을 호출할 필요는 없습니다. 특히 배경 오브젝트나 멀리 떨어진 AI 캐릭터처럼 자주 업데이트되지 않아도 되는 액터는 틱 주기를 조절하여 성능을 크게 개선할 수 있습니다.

  • SetActorTickInterval(float TickInterval): 틱이 호출되는 최소 간격을 설정합니다. 예를 들어 0.1f로 설정하면 100ms마다 한 번씩 틱이 호출되어, 10프레임에 한 번만 틱이 실행됩니다.
  • SetActorTickEnabled(bool bEnabled): 액터의 틱 활성화 여부를 제어합니다. 화면에 보이지 않거나, 로직상 더 이상 업데이트가 필요 없는 액터는 아예 틱을 비활성화하는 것이 가장 좋습니다.

// AMyActor.cpp 예시
AMyActor::AMyActor()
{
    PrimaryActorTick.bCanEverTick = true; // 기본적으로 틱 활성화
    PrimaryActorTick.TickGroup = TG_PostPhysics; // 틱 그룹 설정 (아래에서 설명)

    // 이 액터는 0.5초(500ms)에 한 번만 틱이 호출되도록 설정
    PrimaryActorTick.TickInterval = 0.5f; 
}

void AMyActor::BeginPlay()
{
    Super::BeginPlay();

    // 특정 조건에서 틱을 완전히 비활성화
    if (bIsStaticDecoration)
    {
        SetActorTickEnabled(false);
    }
}

void AMyActor::ReceiveDamage(float DamageAmount)
{
    // 데미지를 입으면 잠시 틱을 활성화하여 특정 반응을 처리
    SetActorTickEnabled(true);
    // 일정 시간 후 다시 비활성화하는 타이머 설정 등
}

이 두 함수를 적절히 활용하면 불필요한 틱 호출을 줄여 메인 스레드의 부하를 효과적으로 분산할 수 있습니다. 특히 TickInterval은 수많은 군중 AI나 환경 효과 액터에 적용할 때 빛을 발합니다.

Tick Group을 이용한 실행 순서 제어

언리얼 엔진은 액터의 틱 함수가 실행되는 순서를 제어하기 위한 틱 그룹(Tick Group)이라는 개념을 제공합니다. ETickingGroup 열거형에는 다음과 같은 값들이 있습니다:

  • TG_PrePhysics: 물리 시뮬레이션 이전에 실행
  • TG_StartPhysics: 물리 시뮬레이션 시작 시 실행
  • TG_DuringPhysics: 물리 시뮬레이션 도중 실행
  • TG_EndPhysics: 물리 시뮬레이션 완료 후 실행
  • TG_PostPhysics: 모든 물리 시뮬레이션 이후, 렌더링 이전에 실행 (가장 일반적)
  • TG_PostUpdateWork: 모든 업데이트 작업이 끝난 후 실행

기본적으로 대부분의 액터는 TG_PostPhysics 그룹에서 틱이 실행됩니다. 특정 액터가 다른 액터의 업데이트 결과에 의존하거나, 특정 시점에 정확히 실행되어야 하는 경우 틱 그룹을 조절하여 실행 순서를 보장할 수 있습니다. 예를 들어, 플레이어의 위치를 기반으로 AI의 시야를 업데이트하는 로직은 플레이어 액터의 물리 업데이트가 완료된 후(TG_PostPhysics)에 실행되는 것이 합리적입니다.

이를 통해 틱 로직 간의 불필요한 동기화를 줄이고, 데이터 일관성을 유지하며 성능을 최적화할 수 있습니다. 잘못된 틱 그룹 설정은 오히려 디버깅하기 어려운 버그를 유발할 수 있으므로 신중하게 적용해야 합니다.

언리얼 엔진 C++ 액터의 틱(Tick) 함수 호출 비용 최적화: 비동기 작업 및 부하 분산 튜닝 - digital marketing, seo, google, emarketing, sem, serp, business, informatics, computers, web pages, diagrams, internet, analyst, search engine optimization, modern, tablet, phone, smartphone, advertising, corporation, competition, analysis, adwords, stylus, google adwords, it, samsung, digital marketing, digital marketing, digital marketing, seo, seo, seo, seo, seo, google, business, business, business, business, advertising, analysis

Image by AS_Photography on Pixabay

실제 프로젝트 적용 사례와 튜닝 팁

제가 참여했던 프로젝트에서 수많은 NPC들이 존재했고, 각 NPC는 주변 환경을 탐색하고, 플레이어와의 거리에 따라 다른 행동 패턴을 보여야 했습니다. 초기에는 모든 NPC가 매 틱마다 복잡한 탐색 로직과 AI 의사결정을 수행하여 프레임 드롭이 심각했습니다.

이 문제를 해결하기 위해 다음과 같은 조합을 사용했습니다:

  1. 원거리 NPC 틱 인터벌 조정: 플레이어로부터 일정 거리 이상 떨어진 NPC들은 SetActorTickInterval(0.25f) 또는 SetActorTickInterval(0.5f)를 적용하여 틱 호출 빈도를 줄였습니다.
  2. 복잡한 AI 의사결정 비동기화: 경로 탐색, 목표물 탐지 등 시간이 오래 걸리는 AI 의사결정 로직은 FAsyncTask를 사용하여 백그라운드 스레드에서 비동기적으로 처리하도록 변경했습니다. 결과는 비동기 작업 완료 시점에 메인 스레드로 다시 가져와 적용했습니다.
  3. 시야/청각 업데이트 부하 분산: 모든 NPC가 동시에 시야/청각을 업데이트하는 대신, 매 틱마다 소수의 NPC만 랜덤하게 선택하여 업데이트하거나, TickInterval을 활용하여 순차적으로 업데이트하도록 로직을 변경했습니다.
  4. 가시성 기반 틱 비활성화: 카메라 시야에 들어오지 않는 NPC나 오브젝트는 틱을 완전히 비활성화(SetActorTickEnabled(false))했습니다.

이러한 튜닝 결과, 프레임 드롭 현상은 크게 개선되었고, 수많은 NPC가 등장하는 상황에서도 안정적인 프레임 레이트를 유지할 수 있었습니다. 특히 UE4/UE5 프로파일러(Profiler)를 적극적으로 활용하여 어느 액터의 틱 함수가 가장 많은 시간을 소모하는지 정확히 파악하는 것이 중요했습니다. CPU 스파이크(CPU Spike)가 발생하는 지점을 찾아내고, 해당 부분의 로직을 위에서 소개한 기법들로 개선하는 과정을 반복했습니다.

최적화 기법 설명 주요 효과 적합한 상황
비동기 작업 (FAsyncTask, Task Graph) 시간이 오래 걸리는 연산을 백그라운드 스레드로 분리 메인 스레드 블로킹 방지, 프레임 레이트 안정화 복잡한 AI, 경로 탐색, 데이터 로딩, 대규모 연산
Tick Interval 조정 틱 호출 빈도를 낮춰 부하 감소 전체적인 틱 처리 시간 감소, CPU 부하 분산 원거리 액터, 배경 오브젝트, 중요도 낮은 NPC
Tick Enabled 제어 필요 없는 액터의 틱을 완전히 비활성화 가장 확실한 틱 비용 절감 시야 밖 액터, 정적인 오브젝트, 특정 상태의 액터
Tick Group 활용 틱 실행 순서를 제어하여 의존성 관리 데이터 일관성 유지, 불필요한 동기화 감소 특정 순서 보장이 필요한 로직, 물리 관련 액터

마치며: 효율적인 틱 관리가 곧 게임 성능입니다

언리얼 엔진의 틱(Tick) 함수는 강력한 도구이지만, 그만큼 신중하게 다루어야 합니다. 제가 직접 경험해본 결과, 무분별한 틱 사용은 게임의 성능을 저해하는 가장 흔하고도 치명적인 실수 중 하나였습니다. 하지만 비동기 작업부하 분산이라는 두 가지 핵심 전략을 통해 틱 호출 비용을 효과적으로 최적화할 수 있음을 깨달았습니다.

이 글에서 다룬 내용은 언리얼 엔진 틱 최적화의 빙산의 일각일 수 있습니다. 하지만 주니어 개발자 여러분이 게임 개발 과정에서 겪을 수 있는 틱 관련 성능 문제에 대한 실마리를 제공하고, 더 나아가 메인 스레드의 중요성과 멀티스레딩(Multithreading)의 필요성을 이해하는 데 도움이 되기를 바랍니다. 항상 프로파일러를 가까이하고, 작은 최적화라도 꾸준히 적용하는 습관을 들이는 것이 중요합니다.

여러분은 어떤 틱 최적화 경험을 가지고 계신가요? 댓글로 여러분의 노하우나 궁금증을 공유해주세요. 함께 성장하는 개발 커뮤니티를 만들어나가요!

📌 함께 읽으면 좋은 글

  • [개발 책 리뷰] 고루틴과 채널, 당신이 아는 것은 절반의 진실입니다: Go 동시성 패턴의 오해와 실습
  • [게임 개발] 멀티플레이어 게임 개발, TCP와 UDP 선택 기준과 숨겨진 트레이드오프 깊이 파고들기
  • [게임 개발] 예비 게임 개발자를 위한 3대 게임 엔진 비교: 면접부터 실무까지 핵심 질문 5가지

이 글이 도움이 되셨다면 공감(♥)댓글로 응원해 주세요!
궁금한 점이나 다루었으면 하는 주제가 있다면 댓글로 남겨주세요.

반응형