1. Game Thread 최적화가 중요한 이유
언리얼 엔진은 크게 Game Thread, Render Thread, GPU 세 가지 축으로 매 프레임의 작업을 처리합니다. 이 중 Game Thread는 게임플레이 로직, 틱(Tick) 함수 실행, 물리 시뮬레이션 트리거, AI 판단, 애니메이션 블루프린트 평가 등 "게임의 두뇌" 역할을 담당합니다.
문제는 Game Thread가 병목이 되면 나머지 두 축이 아무리 빨라도 프레임 타임이 개선되지 않는다는 점입니다. stat unit 콘솔 명령으로 확인했을 때 Frame 시간이 Game 시간과 거의 일치한다면, 현재 프로젝트는 Game Thread에 의해 성능이 제한되고 있는 것입니다.
Game Thread 최적화는 단순히 "느린 코드를 고치는 것"이 아니라, 매 프레임 어떤 작업이 실행되어야 하고 어떤 작업은 실행하지 않아도 되는지를 설계 수준에서 재정비하는 과정입니다. 이 글에서는 틱 관리, 오브젝트 생성/파괴 최적화, 충돌 설정 최적화, 그리고 병렬 처리 활용까지 Game Thread 병목을 해결하는 핵심 기법들을 다룹니다.
2. Game Thread 병목 진단 방법
최적화를 시작하기 전에 반드시 프로파일링으로 병목 지점을 정확히 파악해야 합니다. 감으로 최적화하는 것은 오히려 코드 복잡도만 높이는 결과를 가져올 수 있습니다.
2-1. stat unit으로 병목 축 파악하기
가장 기본적인 진단 명령은 stat unit입니다. 다음 코드는 콘솔에서 실행하는 명령입니다.
stat unit
위 명령을 실행하면 화면에 Frame, Game, Draw, GPU 네 가지 시간이 표시됩니다. Frame 시간이 Game 시간에 가깝다면 Game Thread 병목, Draw 시간에 가깝다면 Render Thread 병목, GPU 시간에 가깝다면 GPU 병목입니다.
2-2. stat startfile로 상세 프로파일 캡처하기
Game Thread 내부의 어떤 함수가 비용을 많이 소모하는지 확인하려면 프로파일 캡처가 필요합니다.
stat startfile
// 10초 이상 플레이
stat stopfile
위 명령으로 캡처한 .uestats 파일을 세션 프론트엔드 프로파일러에서 열면, FTickFunctionTask 아래에 매 프레임 틱하는 모든 액터와 컴포넌트가 나열됩니다. 이 목록이 Game Thread 최적화의 출발점입니다.
2-3. dumpticks로 틱 등록 현황 확인하기
현재 월드에 틱이 등록된 모든 액터를 한눈에 보려면 dumpticks 콘솔 명령을 사용합니다.
dumpticks
위 명령은 출력 로그에 각 액터의 식별자, 틱 활성 상태, 틱 그룹, 틱 의존성 정보를 출력합니다. 목록 맨 아래에 활성/비활성 틱 총 개수가 표시되므로, 불필요하게 틱이 켜져 있는 액터를 빠르게 식별할 수 있습니다.
3. 틱(Tick) 최적화
Game Thread에서 가장 큰 비중을 차지하는 것이 틱 함수의 실행입니다. 매 프레임 수백, 수천 개의 액터가 Tick()을 실행하면 Game Thread는 순식간에 과부하 상태에 빠집니다. 틱 최적화는 Game Thread 최적화의 핵심입니다.
3-1. 불필요한 틱 비활성화
언리얼 엔진에서 액터를 생성하면 기본적으로 틱이 활성화되어 있습니다. 블루프린트로 생성한 액터도 마찬가지입니다. 따라서 틱이 필요 없는 액터는 반드시 명시적으로 비활성화해야 합니다.
다음은 C++ 생성자에서 틱을 완전히 비활성화하는 코드입니다.
AMyStaticActor::AMyStaticActor()
{
// 이 액터는 절대 틱하지 않음
PrimaryActorTick.bCanEverTick = false;
PrimaryActorTick.bStartWithTickEnabled = false;
}
위 코드에서 bCanEverTick을 false로 설정하면 엔진이 해당 액터의 틱 함수를 아예 등록하지 않습니다. bStartWithTickEnabled만 false로 설정하는 것과는 다른데, 후자는 틱 함수 자체는 등록되어 있되 비활성 상태로 시작하는 것이므로 약간의 오버헤드가 여전히 존재합니다.
컴포넌트도 동일하게 적용할 수 있습니다.
UMyCustomComponent::UMyCustomComponent()
{
// 컴포넌트 틱 비활성화
PrimaryComponentTick.bCanEverTick = false;
}
위 코드처럼 컴포넌트 단위에서도 불필요한 틱을 제거하면, 액터 하나에 여러 컴포넌트가 붙어 있을 때 누적 비용을 크게 줄일 수 있습니다.
3-2. 틱 주기(Tick Interval) 조절
매 프레임 실행할 필요는 없지만 주기적으로 로직을 실행해야 하는 경우, 틱 인터벌을 늘려서 호출 빈도를 줄일 수 있습니다.
AMyPeriodicActor::AMyPeriodicActor()
{
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = true;
// 0.5초마다 한 번 틱 (매 프레임 대신)
PrimaryActorTick.TickInterval = 0.5f;
}
위 코드에서 TickInterval을 0.5f로 설정하면, 60FPS 기준으로 매 프레임 틱하던 것을 약 30프레임에 한 번꼴로 줄이는 효과가 있습니다. AI 감지 로직, 환경 오브젝트 상태 갱신 등 정밀한 프레임 단위 처리가 필요 없는 로직에 효과적입니다.
3-3. 이벤트 기반 로직으로 전환
틱 최적화의 가장 근본적인 해결책은 "매 프레임 확인"하는 폴링(Polling) 방식을 "변화가 발생했을 때만 반응"하는 이벤트 기반 방식으로 전환하는 것입니다.
다음은 UI 업데이트를 틱에서 처리하는 비효율적인 예시입니다.
// 나쁜 예: 매 프레임 체력 UI를 갱신
void AMyHUD::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
int32 CurrentHealth = PlayerCharacter->GetHealth();
HealthWidget->SetHealthValue(CurrentHealth);
}
위 코드는 체력이 변하지 않는 대부분의 프레임에서도 불필요하게 UI를 갱신합니다.
다음은 이벤트 기반으로 개선한 코드입니다.
// 좋은 예: 체력이 변할 때만 UI 갱신
// 캐릭터 클래스에서 델리게이트 선언
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChanged, int32, NewHealth);
UPROPERTY(BlueprintAssignable)
FOnHealthChanged OnHealthChanged;
void AMyCharacter::SetHealth(int32 NewHealth)
{
if (Health != NewHealth)
{
Health = NewHealth;
// 체력이 실제로 변할 때만 브로드캐스트
OnHealthChanged.Broadcast(Health);
}
}
// HUD에서 델리게이트에 바인딩
void AMyHUD::BeginPlay()
{
Super::BeginPlay();
PlayerCharacter->OnHealthChanged.AddDynamic(this, &AMyHUD::HandleHealthChanged);
}
void AMyHUD::HandleHealthChanged(int32 NewHealth)
{
HealthWidget->SetHealthValue(NewHealth);
}
위 코드처럼 델리게이트를 사용하면 체력이 변경되는 순간에만 UI가 갱신되므로, 매 프레임 불필요한 작업을 완전히 제거할 수 있습니다.
3-4. 타이머 활용
일정 주기로 반복해야 하지만 매 프레임까지는 필요 없는 로직에는 FTimerManager를 사용하는 것이 틱보다 효율적입니다.
void AMyAIActor::BeginPlay()
{
Super::BeginPlay();
// 2초마다 주변 적 감지 로직 실행
GetWorldTimerManager().SetTimer(
DetectionTimerHandle,
this,
&AMyAIActor::PerformDetection,
2.0f, // 2초 간격
true // 반복 실행
);
}
void AMyAIActor::PerformDetection()
{
// 주변 적 탐지 로직 (매 프레임이 아닌 2초마다 실행)
TArray<AActor*> NearbyEnemies;
UKismetSystemLibrary::SphereOverlapActors(
GetWorld(),
GetActorLocation(),
DetectionRadius,
ObjectTypes,
AEnemy::StaticClass(),
TArray<AActor*>(),
NearbyEnemies
);
ProcessDetectedEnemies(NearbyEnemies);
}
위 코드에서 타이머는 엔진의 타이머 매니저가 관리하므로, 개별 액터가 매 프레임 시간을 누적해서 체크하는 것보다 오버헤드가 적습니다.
3-5. 틱 그룹(Tick Group)과 틱 의존성
언리얼 엔진은 틱을 틱 그룹 단위로 실행합니다. 기본 틱 그룹은 TG_PrePhysics, TG_DuringPhysics, TG_PostPhysics, TG_PostUpdateWork 네 가지입니다. 각 틱 그룹이 끝나야 다음 그룹이 시작됩니다.
AMyPhysicsActor::AMyPhysicsActor()
{
PrimaryActorTick.bCanEverTick = true;
// 물리 시뮬레이션 이후에 틱 실행
PrimaryActorTick.TickGroup = TG_PostPhysics;
}
위 코드처럼 물리 결과에 의존하는 로직은 TG_PostPhysics에 배치하면 불필요한 재계산을 피할 수 있습니다.
또한 특정 액터가 다른 액터의 틱이 끝난 뒤에 실행되어야 하는 경우, 틱 의존성을 설정할 수 있습니다.
void AFollowerActor::BeginPlay()
{
Super::BeginPlay();
if (LeaderActor)
{
// LeaderActor의 틱이 끝난 뒤에 이 액터가 틱
AddTickPrerequisiteActor(LeaderActor);
}
}
위 코드에서 AddTickPrerequisiteActor를 사용하면 LeaderActor의 위치가 갱신된 뒤에 FollowerActor가 그 위치를 기반으로 로직을 실행하므로, 한 프레임 지연(one-frame lag) 문제를 방지할 수 있습니다.
3-6. 배치 틱(Batched Ticks) — UE 5.5 이상
언리얼 엔진 5.5부터 도입된 배치 틱(Batched Ticks) 기능은 동일한 클래스의 액터/컴포넌트 틱을 그룹으로 묶어 실행합니다. 이 기능은 CPU 캐시 활용도를 높여서 Game Thread 성능을 크게 개선합니다.
프로젝트 설정이나 DefaultEngine.ini에서 배치 틱을 활성화할 수 있으며, ForEachNestedTick 같은 옵션을 통해 수동 배치 틱도 지원합니다. 같은 클래스의 인스턴스가 수백 개 이상 존재하는 프로젝트에서 특히 효과적입니다.
4. 오브젝트 생성과 파괴 최적화
4-1. SpawnActor의 비용
SpawnActor는 단순히 메모리를 할당하는 것이 아니라, 오브젝트 생성 → 프로퍼티 초기화 → 컴포넌트 등록 → BeginPlay 호출 → 틱 등록 등 여러 단계를 거칩니다. 대량의 액터를 짧은 시간에 생성하면 Game Thread에 큰 스파이크가 발생합니다.
마찬가지로 DestroyActor 역시 컴포넌트 해제, 틱 등록 해제, 레퍼런스 정리 등의 비용이 발생하며, 파괴된 UObject들이 누적되면 가비지 컬렉션이 트리거되어 추가적인 Game Thread 히칭(hitching)을 유발합니다.
4-2. 오브젝트 풀링(Object Pooling)
오브젝트 풀링은 자주 생성/파괴되는 액터를 미리 만들어두고 재사용하는 패턴입니다. 총알, 이펙트, AI 유닛 등 빈번하게 스폰되는 오브젝트에 적용하면 Game Thread 부하를 크게 줄일 수 있습니다.
다음은 UGameInstanceSubsystem을 활용한 오브젝트 풀 구현 예시입니다.
UCLASS()
class UActorPoolSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
// 풀에서 액터를 꺼내거나, 풀이 비어있으면 새로 생성
UFUNCTION(BlueprintCallable, Category = "ObjectPool")
AActor* AcquireActor(TSubclassOf<AActor> ActorClass, const FTransform& SpawnTransform);
// 사용이 끝난 액터를 풀에 반환
UFUNCTION(BlueprintCallable, Category = "ObjectPool")
void ReleaseActor(AActor* Actor);
private:
// 클래스별로 비활성 액터를 보관
TMap<UClass*, TArray<AActor*>> PoolMap;
};
AActor* UActorPoolSubsystem::AcquireActor(TSubclassOf<AActor> ActorClass, const FTransform& SpawnTransform)
{
TArray<AActor*>& Pool = PoolMap.FindOrAdd(ActorClass);
AActor* PooledActor = nullptr;
if (Pool.Num() > 0)
{
// 풀에서 비활성 액터를 꺼냄
PooledActor = Pool.Pop();
PooledActor->SetActorTransform(SpawnTransform);
PooledActor->SetActorHiddenInGame(false);
PooledActor->SetActorEnableCollision(true);
PooledActor->SetActorTickEnabled(true);
}
else
{
// 풀이 비어있으면 새로 스폰
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
PooledActor = GetWorld()->SpawnActor<AActor>(ActorClass, SpawnTransform, SpawnParams);
}
return PooledActor;
}
void UActorPoolSubsystem::ReleaseActor(AActor* Actor)
{
if (!Actor) return;
// 액터를 비활성화하고 풀에 반환
Actor->SetActorHiddenInGame(true);
Actor->SetActorEnableCollision(false);
Actor->SetActorTickEnabled(false);
TArray<AActor*>& Pool = PoolMap.FindOrAdd(Actor->GetClass());
Pool.Add(Actor);
}
위 코드에서 AcquireActor는 풀에 사용 가능한 액터가 있으면 재활용하고, 없으면 새로 생성합니다. ReleaseActor는 액터를 파괴하지 않고 비활성화한 뒤 풀에 돌려놓습니다. UGameInstanceSubsystem을 사용했기 때문에 레벨 전환에도 풀이 유지되는 장점이 있습니다.
4-3. 가비지 컬렉션 부하 줄이기
UObject를 빈번하게 생성·파괴하면 가비지 컬렉션(GC)이 자주 트리거되어 Game Thread에 히칭이 발생합니다. 다음 방법으로 GC 부하를 줄일 수 있습니다.
순환 참조를 방지하려면 TWeakObjectPtr을 활용합니다.
UPROPERTY()
TWeakObjectPtr<AActor> CachedTarget; // 약한 참조로 순환 참조 방지
void AMyActor::UpdateTarget(AActor* NewTarget)
{
CachedTarget = NewTarget;
}
void AMyActor::UseTarget()
{
// 유효성 검사 후 사용
if (CachedTarget.IsValid())
{
CachedTarget->DoSomething();
}
}
위 코드처럼 TWeakObjectPtr을 사용하면 대상 액터가 파괴되어도 GC가 이를 올바르게 수거할 수 있으며, 댕글링 포인터 문제도 방지할 수 있습니다.
또한 GC 빈도 자체를 조절하려면 gc.TimeBetweenPurgingPendingKillObjects CVar를 수정할 수 있습니다. 기본값은 60초이며, 오브젝트 풀링을 적극 활용한다면 이 값을 늘려도 안전합니다.
5. 충돌(Collision) 설정 최적화
충돌 처리는 Game Thread에서 물리 엔진이 수행하는 작업 중 가장 비용이 높은 부분 중 하나입니다. 특히 씬에 존재하는 모든 스태틱 메시는 기본적으로 충돌이 활성화되어 있으므로, 플레이어가 절대 닿을 수 없는 먼 거리의 장식용 메시까지 충돌 검사 대상에 포함됩니다.
5-1. 불필요한 충돌 비활성화
플레이어가 접근할 수 없는 배경 오브젝트, 순수 시각 효과용 메시 등은 충돌을 NoCollision으로 설정해야 합니다.
UStaticMeshComponent* DecoMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("DecoMesh"));
// 장식용 메시는 충돌 불필요
DecoMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
위 코드처럼 충돌을 완전히 비활성화하면, 물리 엔진이 해당 오브젝트를 충돌 검사 대상에서 제외하므로 Game Thread 부하가 줄어듭니다.
5-2. 충돌 복잡도 줄이기
충돌 메시의 복잡도도 성능에 직접적인 영향을 미칩니다. 복잡한 메시에 UseComplexAsSimple을 사용하면 수천 개의 폴리곤으로 충돌 검사를 수행하게 됩니다.
// 나쁜 예: 복잡한 메시를 그대로 충돌에 사용
MeshComp->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
MeshComp->BodyInstance.SetCollisionProfileName(TEXT("BlockAll"));
// 고폴리곤 메시의 UseComplexAsSimple → 매우 비쌈
대신 간단한 형태의 심플 콜리전(Simple Collision) 을 사용해야 합니다. 박스, 구, 캡슐 같은 기본 도형이나 자동 생성된 컨벡스 헐(Convex Hull)을 사용하면 충돌 검사 비용을 크게 줄일 수 있습니다.
5-3. 충돌 채널과 오브젝트 타입 최적화
충돌 채널을 세밀하게 설정하면 불필요한 충돌 쌍(collision pair) 검사를 건너뛸 수 있습니다.
// 적 전용 프로젝타일: 플레이어와 플레이어 프로젝타일만 감지
ProjectileCollision->SetCollisionObjectType(ECC_GameTraceChannel1); // 커스텀 채널
ProjectileCollision->SetCollisionResponseToAllChannels(ECR_Ignore); // 기본적으로 모두 무시
ProjectileCollision->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap); // Pawn만 오버랩
ProjectileCollision->SetCollisionResponseToChannel(ECC_WorldStatic, ECR_Block); // 벽에는 블록
위 코드에서 기본 응답을 Ignore로 설정한 뒤 필요한 채널만 선택적으로 활성화하면, 물리 엔진이 검사해야 하는 충돌 쌍의 수가 크게 줄어듭니다.
5-4. 라인 트레이스와 오버랩 검사 최적화
라인 트레이스(LineTrace)와 오버랩 검사(OverlapMulti)도 Game Thread에서 수행되는 비용이 높은 연산입니다. 매 프레임 다수의 라인 트레이스를 실행하면 Game Thread에 부담이 됩니다.
// 나쁜 예: 매 프레임 여러 번의 복잡한 라인 트레이스
void AMyActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
for (int32 i = 0; i < 10; ++i)
{
GetWorld()->LineTraceSingleByChannel(...); // 매 프레임 10번의 라인 트레이스
}
}
다음과 같이 개선할 수 있습니다.
// 좋은 예: 타이머로 빈도를 줄이고, 채널 필터링 활용
void AMyActor::PerformPeriodicTrace()
{
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(this);
QueryParams.bTraceComplex = false; // 심플 콜리전만 사용
// 필요한 채널만 대상으로 트레이스
GetWorld()->LineTraceSingleByChannel(
HitResult,
StartLocation,
EndLocation,
ECC_GameTraceChannel1, // 커스텀 채널로 범위 제한
QueryParams
);
}
위 코드에서 bTraceComplex를 false로 설정하면 심플 콜리전만 대상으로 트레이스하여 비용을 줄이고, 커스텀 채널을 사용하면 불필요한 오브젝트와의 검사를 건너뜁니다. 이를 타이머와 결합하면 추가적인 최적화가 가능합니다.
6. 기타 Game Thread 최적화 기법
6-1. 스켈레탈 메시와 애니메이션 블루프린트 최적화
SkinnedMeshComp Tick은 Game Thread에서 상당한 비용을 차지합니다. 화면에 보이지 않는 스켈레탈 메시는 애니메이션 업데이트를 건너뛰도록 설정할 수 있습니다.
SkeletalMeshComponent->VisibilityBasedAnimTickOption = EVisibilityBasedAnimTickOption::OnlyTickPoseWhenRendered;
위 설정은 카메라에 보이는 경우에만 포즈를 갱신하므로, 화면 밖의 캐릭터가 불필요하게 애니메이션을 계산하는 것을 방지합니다.
6-2. 슬레이트(Slate) 위젯 최적화
TickWidgets 항목이 프로파일에서 높게 나오는 경우, 동시에 표시되는 위젯 수가 너무 많거나 위젯 어트리뷰트의 델리게이트가 복잡한 것이 원인입니다. 특히 Visibility 같은 어트리뷰트는 프레임당 여러 번 호출될 수 있으므로, 바인딩 함수는 가능한 한 가볍게 유지해야 합니다.
6-3. 블루프린트에서 C++로 이전
블루프린트는 프로토타이핑에 적합하지만, 블루프린트 VM의 해석 실행 비용은 네이티브 C++보다 약 10배 이상 느릴 수 있습니다. 매 프레임 실행되는 틱 로직이나 빈번하게 호출되는 함수는 C++로 이전하면 Game Thread 성능이 크게 개선됩니다.
UFUNCTION(BlueprintCallable)로 C++ 함수를 노출하면 블루프린트에서도 호출할 수 있으므로, 핵심 로직은 C++로 구현하고 블루프린트는 조합(composition) 용도로 사용하는 하이브리드 접근이 이상적입니다.
6-4. 비동기 처리와 Tasks 시스템
Game Thread에서 모든 작업을 처리할 필요는 없습니다. 언리얼 엔진의 Tasks 시스템을 활용하면 무거운 연산을 별도 스레드로 분산할 수 있습니다.
#include "Tasks/Task.h"
void AMyActor::PerformHeavyComputation()
{
UE::Tasks::FTask ComputeTask = UE::Tasks::Launch(
UE_SOURCE_LOCATION,
[this]()
{
// 무거운 연산을 백그라운드 스레드에서 수행
TArray<FVector> Results;
for (int32 i = 0; i < LargeDataSet.Num(); ++i)
{
Results.Add(ProcessData(LargeDataSet[i]));
}
// 결과를 스레드 세이프한 방식으로 저장
FScopeLock Lock(&ResultMutex);
ComputedResults = MoveTemp(Results);
}
);
}
위 코드에서 UE::Tasks::Launch를 사용하면 람다 내부의 로직이 백그라운드 스레드에서 실행됩니다. 단, Game Thread에서 접근하는 데이터에 대해서는 반드시 동기화 처리(FScopeLock, TAtomic 등)를 해야 합니다.
7. 일반 C++과의 비교
Game Thread 최적화에서 사용되는 여러 개념은 일반 C++ 프로그래밍과 공통되는 부분이 있습니다.
오브젝트 풀링은 일반 C++에서도 std::vector에 미리 할당된 오브젝트를 보관하고 재사용하는 형태로 구현할 수 있습니다. 언리얼 엔진에서는 여기에 SetActorHiddenInGame, SetActorEnableCollision, SetActorTickEnabled 같은 엔진 고유의 활성/비활성 관리가 추가됩니다. 또한 UGameInstanceSubsystem을 통해 레벨 전환에도 풀이 유지되는 라이프사이클 관리가 자동으로 제공됩니다.
스레드 병렬 처리의 경우, 일반 C++에서는 std::thread, std::async, std::future 등을 사용합니다. 언리얼 엔진의 UE::Tasks 시스템은 이러한 표준 라이브러리 대신 엔진의 스레드 풀을 활용하며, 엔진의 프레임 사이클과 통합되어 있어 Game Thread와의 동기화가 더 자연스럽습니다.
이벤트 기반 프로그래밍은 일반 C++에서 옵저버 패턴이나 std::function 콜백으로 구현합니다. 언리얼 엔진은 DECLARE_DYNAMIC_MULTICAST_DELEGATE와 같은 매크로로 리플렉션 시스템과 통합된 델리게이트를 제공하여, 블루프린트에서도 바인딩할 수 있는 타입 세이프한 이벤트 시스템을 지원합니다.
8. 유니티 엔진과의 비교
유니티에서도 Game Thread(Main Thread) 최적화는 핵심 과제이며, 접근 방식에 유사점과 차이점이 있습니다.
틱 관리에서 유니티의 Update()는 언리얼의 Tick()에 해당합니다. 유니티에서는 MonoBehaviour를 비활성화(enabled = false)하거나 게임오브젝트를 비활성화(SetActive(false))하여 Update 호출을 막습니다. 언리얼은 bCanEverTick이라는 좀 더 세밀한 제어 옵션을 제공하며, 틱 그룹과 틱 의존성으로 실행 순서를 명시적으로 관리할 수 있습니다. 유니티의 FixedUpdate와 LateUpdate가 실행 시점만 분리하는 반면, 언리얼의 틱 그룹은 물리 시뮬레이션 전후로 더 정밀하게 배치할 수 있습니다.
오브젝트 풀링은 양 엔진 모두에서 널리 사용되는 패턴입니다. 유니티 2021부터 UnityEngine.Pool 네임스페이스에 빌트인 ObjectPool<T>이 추가되었으나, 언리얼은 공식 빌트인 풀링 시스템을 제공하지 않아 직접 구현하거나 서브시스템으로 만들어야 합니다. 그러나 UGameInstanceSubsystem을 활용하면 엔진 라이프사이클에 자연스럽게 통합되는 풀링 시스템을 만들 수 있다는 장점이 있습니다.
충돌 최적화에서 유니티는 레이어(Layer) 기반 충돌 매트릭스를 사용하고, 언리얼은 콜리전 채널과 오브젝트 타입 기반의 응답 시스템을 사용합니다. 언리얼의 방식이 채널별로 Block, Overlap, Ignore를 개별 설정할 수 있어 더 세밀한 제어가 가능합니다.
병렬 처리에서 유니티는 C# Job System과 Burst Compiler를 조합하여 멀티스레드 처리를 합니다. 언리얼의 UE::Tasks 시스템은 C++ 네이티브 코드로 동작하므로 JIT 컴파일 없이도 높은 성능을 발휘하며, 엔진 내부의 스레드 풀과 직접 통합되어 있어 오버헤드가 적습니다.
9. 주의사항
9-1. 프로파일링 없이 최적화하지 않기
최적화의 첫 번째 원칙은 측정 먼저, 최적화 나중입니다. stat unit, stat startfile, Unreal Insights 등으로 실제 병목을 확인하지 않고 추측만으로 코드를 수정하면, 성능 개선 없이 코드 복잡도만 높아지는 결과를 가져올 수 있습니다.
9-2. 오브젝트 풀 크기 관리
오브젝트 풀은 메모리와 성능 사이의 트레이드오프입니다. 풀에 너무 많은 액터를 보관하면 메모리 사용량이 불필요하게 증가합니다. 프로파일링을 통해 동시에 활성화되는 최대 수를 파악하고, 그에 적절한 마진을 더한 크기로 풀을 관리해야 합니다.
9-3. SetActorTickEnabled 호출 시점 주의
SetActorTickEnabled(false)는 BeginPlay 내에서 호출하면 정상 동작하지만, 스폰 직후 외부에서 즉시 호출하면 기대대로 작동하지 않을 수 있습니다. 이는 액터의 틱 등록이 완전히 완료되기 전에 비활성화를 시도하기 때문입니다. 안전하게 처리하려면 BeginPlay에서 비활성화하거나, 생성자에서 bStartWithTickEnabled = false를 설정하는 것이 좋습니다.
9-4. 멀티스레드 접근 시 동기화 필수
UE::Tasks로 백그라운드 스레드에서 작업을 수행할 때, Game Thread에서 접근하는 데이터에 대한 동기화 없이 읽기/쓰기를 하면 레이스 컨디션(Race Condition)이 발생합니다. 반드시 FScopeLock, FRWLock, TAtomic 등의 동기화 메커니즘을 사용해야 합니다. 또한 UObject나 AActor의 프로퍼티를 백그라운드 스레드에서 직접 수정하는 것은 안전하지 않으므로 주의하셔야 합니다.
9-5. 충돌 비활성화 시 물리 시뮬레이션 확인
충돌을 NoCollision으로 설정하면 물리 시뮬레이션도 함께 비활성화됩니다. 만약 중력이나 물리 반응은 필요하되 다른 오브젝트와의 충돌만 무시하고 싶다면, SetCollisionResponseToAllChannels(ECR_Ignore)를 사용하되 SetCollisionEnabled(ECollisionEnabled::PhysicsOnly) 또는 QueryAndPhysics를 유지해야 합니다.
9-6. 디버그/개발 빌드에서의 프로파일링 주의
프로파일링은 반드시 Development 빌드 또는 Test 빌드에서 수행해야 합니다. Debug 빌드에서는 최적화가 꺼져 있어 실제 성능과 큰 차이가 나며, 에디터에서의 PIE(Play In Editor) 결과도 스탠드얼론 빌드와 다를 수 있습니다.
이 글에서는 Game Thread 병목을 진단하고 해결하는 핵심 기법들을 살펴보았습니다. 틱 비활성화와 이벤트 기반 전환, 오브젝트 풀링, 충돌 설정 최적화, 비동기 처리 활용은 모두 "Game Thread가 매 프레임 해야 할 일의 총량을 줄이는 것"이라는 하나의 원칙으로 귀결됩니다. 프로파일링으로 병목을 정확히 파악한 뒤, 가장 비용이 큰 지점부터 이 기법들을 적용하면 체감 가능한 성능 개선을 얻을 수 있습니다.
'언리얼 엔진 5' 카테고리의 다른 글
| [UE5] GPU 최적화 (0) | 2026.03.08 |
|---|---|
| [UE5] Render Thread 최적화 (0) | 2026.03.05 |
| [UE5] 프로파일링 - Unreal Insights (0) | 2026.03.03 |
| [UE5] 프로파일링 - stat 명령어로 스레드 병목 진단하기 (1) | 2026.03.02 |
| [UE5] 게임플레이 프레임워크 구조 이해하기 (0) | 2026.03.02 |