1. 성능 문제가 시작되는 곳
게임을 개발하다 보면 초반에는 문제가 없던 프로젝트가 액터 수가 늘어날수록 점점 프레임이 떨어지는 경험을 하게 됩니다. 원인을 추적해보면 상당수는 동일한 패턴에서 비롯됩니다. 매 프레임 실행되는 Tick 함수에 무거운 로직이 들어가 있거나, 컴포넌트를 조회할 때마다 선형 탐색을 반복하거나, 필요 없는 컴포넌트들이 여전히 매 프레임 업데이트되고 있는 경우입니다.
언리얼 엔진 5는 액터와 컴포넌트를 효율적으로 운용하기 위한 다양한 도구를 제공합니다. 이 글에서는 그 도구들을 어떻게 올바르게 사용하는지, 그리고 어떤 실수가 성능 병목을 만드는지를 중심으로 설명합니다.
2. 액터와 컴포넌트의 구조
언리얼 엔진에서 AActor는 월드에 존재하는 모든 객체의 기반 클래스입니다. 액터 자체는 위치, 회전, 스케일을 갖지만, 실제 기능은 대부분 컴포넌트가 담당합니다. 컴포넌트는 세 가지 주요 계층으로 나뉩니다.
UActorComponent는 가장 기본적인 컴포넌트로, 트랜스폼(위치/회전)을 갖지 않습니다. 체력 관리나 인벤토리처럼 물리적 위치와 무관한 추상적인 기능에 적합합니다. USceneComponent는 UActorComponent의 자식 클래스로 트랜스폼을 갖습니다. 카메라, 스프링 암, 오디오처럼 위치가 중요하지만 렌더링이 필요 없는 컴포넌트에 사용합니다. UPrimitiveComponent는 USceneComponent의 자식으로, 메시 렌더링과 콜리전을 담당합니다. UStaticMeshComponent, USkeletalMeshComponent 등이 여기에 해당합니다.
이 계층 구조를 이해하는 것이 최적화의 출발점입니다. 렌더링이 불필요한 기능에 UPrimitiveComponent를 쓰거나, 위치 정보가 필요 없는 기능에 USceneComponent를 쓰면 엔진이 불필요한 렌더 상태와 물리 상태를 관리하는 비용이 발생합니다.
3. Tick 최적화 — 가장 효과가 큰 영역
3-1. Tick이 비싼 이유
Tick 함수는 매 프레임 호출됩니다. 60fps 기준으로 초당 60회, 1000개의 액터가 있다면 초당 6만 번의 함수 호출이 발생합니다. 블루프린트로 작성된 액터라면 그 비용이 C++보다 훨씬 높습니다.
공개된 성능 측정 자료에 따르면, 빈 레벨에서 기준선이 약 3.2ms일 때, 틱이 비활성화된 단순 블루프린트 액터 1000개를 추가하면 약 5.1ms로 증가합니다. 그리고 틱을 활성화하면 8.5ms까지 올라갑니다. 단순히 틱 활성화 여부만으로 3.4ms의 차이가 발생하는 것입니다. 대규모 프로젝트에서는 이 차이가 훨씬 크게 나타납니다.
3-2. Tick 비활성화
Tick이 필요 없는 액터라면 생성자에서 즉시 비활성화하는 것이 좋습니다. 아래는 생성자에서 Tick을 완전히 끄는 코드입니다.
AMyStaticActor::AMyStaticActor()
{
// 이 액터는 매 프레임 업데이트가 필요 없으므로 Tick을 비활성화합니다.
PrimaryActorTick.bCanEverTick = false;
}
위 코드처럼 bCanEverTick을 false로 설정하면 엔진의 Tick 관리 목록에 아예 등록되지 않으므로 가장 확실한 최적화 방법입니다. 이후 런타임에서도 Tick을 켤 수 없게 됩니다.
Tick 자체는 필요하지만 시작 시에는 비활성화하고 조건에 따라 켜야 하는 경우라면 아래처럼 처리합니다.
AMyActor::AMyActor()
{
PrimaryActorTick.bCanEverTick = true;
// 시작 시에는 Tick을 끄고, 필요할 때만 활성화합니다.
PrimaryActorTick.bStartWithTickEnabled = false;
}
void AMyActor::ActivateBehavior()
{
// 특정 조건이 충족될 때만 Tick을 활성화합니다.
SetActorTickEnabled(true);
}
void AMyActor::DeactivateBehavior()
{
// 동작이 끝나면 다시 비활성화합니다.
SetActorTickEnabled(false);
}
위 코드처럼 bStartWithTickEnabled와 SetActorTickEnabled를 조합하면, 예를 들어 플레이어 근처에 있을 때만 동작하는 AI 액터나 특정 구간에서만 작동하는 트랩 액터 등에 효율적으로 활용할 수 있습니다.
3-3. Tick 주기 늘리기
모든 로직이 매 프레임 필요하지는 않습니다. 예를 들어 주변 적의 수를 확인하거나 UI 상태를 갱신하는 로직은 0.1~0.5초 간격으로 실행해도 충분한 경우가 많습니다.
아래는 생성자에서 Tick 주기를 설정하는 코드입니다.
AMyActor::AMyActor()
{
PrimaryActorTick.bCanEverTick = true;
// Tick을 매 프레임이 아닌 0.2초마다 한 번씩 호출합니다.
PrimaryActorTick.TickInterval = 0.2f;
}
위 코드처럼 TickInterval을 설정하면 별도의 타이머 없이도 주기적인 업데이트를 구현할 수 있습니다. 단, Tick 주기를 0.05초 이하로 너무 짧게 설정하면 매 프레임보다 더 자주 호출될 수 있으니 주의하셔야 합니다.
3-4. 컴포넌트 Tick 개별 제어
컴포넌트도 각자의 Tick을 가집니다. 액터의 Tick을 비활성화해도 컴포넌트의 Tick은 별도로 관리됩니다. 컴포넌트 생성자에서 아래와 같이 개별 제어할 수 있습니다.
UMyHealthComponent::UMyHealthComponent()
{
PrimaryComponentTick.bCanEverTick = false; // 체력 컴포넌트는 Tick이 필요 없습니다.
}
위 코드처럼 PrimaryComponentTick을 사용하는 것이 컴포넌트에서의 올바른 방법입니다. 액터에서 사용하는 PrimaryActorTick과 이름이 다르므로, 혼동하지 않도록 주의하셔야 합니다.
4. 컴포넌트 참조 캐싱 — 조회 비용 제거
4-1. FindComponentByClass의 비용
FindComponentByClass<T>()는 액터가 가진 모든 컴포넌트를 순회하여 일치하는 타입을 찾습니다. 컴포넌트 수에 비례하는 O(n) 탐색입니다. 단 한 번 호출하는 것은 큰 문제가 없지만, 매 프레임 또는 다수의 액터에 대해 반복 호출하면 성능 저하가 발생합니다.
아래는 잘못된 패턴의 예시입니다.
void AMyActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// 매 프레임 컴포넌트를 탐색하는 것은 비효율적입니다.
UHealthComponent* HealthComp = FindComponentByClass<UHealthComponent>();
if (HealthComp)
{
HealthComp->RegenerateHealth(DeltaTime);
}
}
위 코드처럼 Tick 내부에서 FindComponentByClass를 반복 호출하면 매 프레임마다 탐색 비용이 발생합니다.
4-2. BeginPlay에서 캐싱
올바른 방법은 BeginPlay에서 한 번 탐색하여 포인터를 멤버 변수에 저장해두는 것입니다. 아래는 캐싱을 적용한 코드입니다.
// MyActor.h
UPROPERTY()
UHealthComponent* CachedHealthComponent;
// MyActor.cpp
void AMyActor::BeginPlay()
{
Super::BeginPlay();
// 시작 시 한 번만 탐색하여 캐싱합니다.
CachedHealthComponent = FindComponentByClass<UHealthComponent>();
}
void AMyActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// 캐싱된 포인터를 직접 사용합니다. O(1) 접근입니다.
if (CachedHealthComponent)
{
CachedHealthComponent->RegenerateHealth(DeltaTime);
}
}
위 코드에서 중요한 점은 캐싱된 포인터에 반드시 UPROPERTY() 매크로를 붙여야 한다는 것입니다. UPROPERTY() 없이 UObject를 가리키는 원시 포인터는 가비지 컬렉터가 추적하지 못하며, 가비지 컬렉션이 실행된 뒤 댕글링 포인터가 될 수 있습니다. 가비지 컬렉션에 대해서는 별도 글에서 자세히 다룹니다.
4-3. 생성자에서 CreateDefaultSubobject로 직접 생성
컴포넌트를 직접 생성하는 경우라면 생성자에서 CreateDefaultSubobject로 만들고, 멤버 변수에 즉시 저장하는 것이 가장 이상적입니다. 이 경우 BeginPlay에서 별도 탐색이 필요 없습니다.
// MyCharacter.h
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
UHealthComponent* HealthComponent;
// MyCharacter.cpp
AMyCharacter::AMyCharacter()
{
// 생성자에서 컴포넌트를 만들고 멤버 변수에 저장합니다.
HealthComponent = CreateDefaultSubobject<UHealthComponent>(TEXT("HealthComponent"));
}
위 코드처럼 생성자에서 할당된 포인터는 이후 런타임 내내 유효하므로 별도의 캐싱 과정 없이 어디서든 직접 사용할 수 있습니다.
5. 불필요한 컴포넌트 등록 방지
언리얼 엔진은 컴포넌트를 등록(Register)할 때 해당 컴포넌트를 엔진의 씬, 렌더링, 물리 시스템과 연결합니다. 이 과정에는 비용이 따릅니다.
컴포넌트가 액터의 생성 시점에 CreateDefaultSubobject로 만들어지면 자동으로 등록됩니다. 그러나 런타임 도중 동적으로 컴포넌트를 생성하고 RegisterComponent()를 직접 호출하는 경우, 이 작업이 성능에 영향을 줄 수 있습니다.
아래는 런타임 동적 등록 예시입니다.
void AMyActor::SpawnEffectComponent()
{
// 런타임에 컴포넌트를 동적으로 생성하고 등록합니다.
UParticleSystemComponent* EffectComp = NewObject<UParticleSystemComponent>(this);
EffectComp->RegisterComponent(); // 씬 및 렌더 시스템에 등록합니다.
EffectComp->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform);
}
위 코드처럼 런타임 등록은 필요한 경우에만 제한적으로 사용해야 합니다. 자주 켜고 꺼야 하는 이펙트라면 등록/해제를 반복하는 것보다 미리 등록해두고 SetVisibility(false)로 숨기는 방식이 더 효율적인 경우가 많습니다.
6. 일반 C++과의 비교
일반 C++에서도 클래스 안에 다른 클래스를 포함하거나 포인터로 참조하는 구조는 동일합니다. 컴포넌트 패턴 자체는 C++에서 흔히 쓰이는 합성(Composition) 기법과 개념적으로 같습니다.
그러나 언리얼 엔진의 컴포넌트는 엔진 내부의 리플렉션, 가비지 컬렉션, 렌더링, 물리 시스템과 긴밀하게 연결되어 있습니다. 일반 C++의 멤버 객체는 생성자에서 초기화하면 끝이지만, 언리얼의 컴포넌트는 RegisterComponent()를 통해 엔진 시스템에 별도로 등록해야 업데이트를 받습니다. 또한 컴포넌트를 가리키는 포인터에 UPROPERTY() 매크로가 없으면 가비지 컬렉터의 추적 대상에서 제외되어 런타임에서 댕글링 포인터가 발생할 수 있습니다. 이 점은 일반 C++ 포인터 관리와 가장 크게 다른 부분입니다.
7. 유니티 엔진과의 비교
유니티에서는 GameObject에 Component를 붙이는 구조가 언리얼의 AActor + UActorComponent 구조와 유사합니다. 유니티의 GetComponent<T>()는 언리얼의 FindComponentByClass<T>()에 대응합니다. 두 함수 모두 모든 컴포넌트를 순회하는 선형 탐색이며, 반복 호출을 피하고 결과를 캐싱해야 한다는 점도 동일합니다.
차이점은 Tick 제어의 세밀함에 있습니다. 유니티에서는 MonoBehaviour의 Update()를 빈 채로 두어도 호출 오버헤드가 발생하며, 비활성화하려면 enabled = false로 설정해야 합니다. 언리얼은 PrimaryActorTick.bCanEverTick = false로 완전히 등록 자체를 차단하거나, TickInterval로 주기를 세밀하게 조정할 수 있습니다. 또한 틱 그룹(TG_PrePhysics, TG_DuringPhysics, TG_PostPhysics 등)으로 실행 순서를 명시적으로 제어할 수 있어 물리 연산과의 순서 의존 문제를 명확하게 해결할 수 있습니다. 유니티의 FixedUpdate / Update / LateUpdate 구분보다 더 세분화된 제어가 가능하다는 것이 언리얼의 강점입니다.
8. 주의사항
Tick 비활성화는 생성자에서만 유효하게 초기 설정됩니다. BeginPlay 이후에 PrimaryActorTick.bCanEverTick = false를 설정해도 이미 등록된 Tick 함수에는 영향을 주지 않습니다. 반드시 생성자에서 설정하거나, 런타임 토글이 필요한 경우 SetActorTickEnabled(false)를 사용해야 합니다.
컴포넌트 포인터에 UPROPERTY() 누락은 자주 발생하는 실수입니다. 특히 BeginPlay에서 캐싱한 포인터에 UPROPERTY()를 붙이지 않으면, 가비지 컬렉션이 실행된 후 해당 포인터가 가리키는 객체가 이미 회수된 상태에서 접근해 크래시가 발생할 수 있습니다.
dumpticks 콘솔 명령어를 활용하면 현재 씬에서 Tick이 등록된 모든 액터 목록을 출력 로그에서 확인할 수 있습니다. 프로젝트 규모가 커지면 예상치 못한 액터들이 Tick 목록에 올라와 있는 경우가 많습니다. 주기적으로 확인하는 습관을 들이면 성능 병목을 조기에 발견할 수 있습니다.
런타임에 컴포넌트를 동적으로 자주 생성하고 등록하는 패턴은 성능에 부정적인 영향을 줍니다. 반복적으로 나타나야 하는 이펙트나 UI 컴포넌트라면 오브젝트 풀링(Object Pooling)을 고려하거나, 미리 생성해두고 가시성을 토글하는 방식을 검토하시기 바랍니다.
Tick 내부에서 Cast<T>()를 자주 호출하는 것도 주의가 필요합니다. 캐스팅 역시 비용이 있으며, 결과를 캐싱하지 않고 매 프레임 반복하면 누적 비용이 상당해집니다.
액터와 컴포넌트 최적화의 핵심은 "필요한 것만, 필요한 시점에만 실행되도록 설계하는 것"입니다. Tick을 끄고, 참조를 캐싱하고, 컴포넌트 계층을 목적에 맞게 선택하는 것만으로도 대규모 씬에서 눈에 띄는 성능 향상을 기대할 수 있습니다. 특히 수백 개 이상의 액터가 동시에 존재하는 장면을 설계할 때 이 글의 내용을 기준으로 삼으면 이후 발생할 수 있는 성능 문제를 상당 부분 예방할 수 있습니다.
'언리얼 엔진 5' 카테고리의 다른 글
| [UE5] 언리얼 오브젝트 생성과 소멸 (0) | 2026.02.26 |
|---|---|
| [UE5] 컨테이너(TArray, TMap, TSet) (1) | 2026.02.24 |
| [UE5] 가비지 컬렉션 (0) | 2026.02.23 |
| [UE5] 리플렉션 시스템 (1) | 2026.02.22 |