아래의 각 섹션에서는 애플리케이션 퍼포먼스에 영향을 미칠 수 있는 상황을 간략하게 설명하고, 발생 가능한 문제를 해결할 대안 및 해결 방법에 대한 가이드라인을 제공합니다.
사전 준비
언리얼 엔진의 퍼포먼스 프로파일링에 익숙하지 않다면, 아래 섹션을 읽기 전에 퍼포먼스 프로파일링 및 환경설정 소개를 읽고 이 주제에 대한 기반 지식을 갖추는 것이 좋습니다.
관리되는 오브젝트, 가비지 수집 및 처치량 급증
언리얼 엔진에서 UObject 및 거기서 파생된 모든 클래스(예: 액터 및 데이터 에셋)는 언리얼 엔진의 가비지 컬렉터가 관리합니다. 가비지 컬렉터는 월드에서 삭제된 UObject를 주기적으로 클린업하고, 해당 오브젝트의 기존 레퍼런스를 정리합니다.
이에 비해 표준 C++ 오브젝트는 관리되지 않습니다. 즉, 오브젝트의 사본을 삭제하거나 null 값이 되면 해당 오브젝트에 대한 레퍼런스를 수동으로 클린업해야 합니다. 클린업 로직에 작은 틈이라도 생기면 메모리 누수(오브젝트가 클린업되지 않은 경우)와 유효하지 않은 레퍼런스(오브젝트가 삭제되었지만 레퍼런스는 남은 경우)가 발생할 수 있으므로 신중하게 처리하지 않으면 위험이 따릅니다.
관리되는 오브젝트를 지원하면 추가로 메모리 사용량이 약간 증가합니다. UObject에는 메모리를 추가적으로 차지하는 FName
및 Outer
레퍼런스와 같은 추가적인 메타데이터가 있습니다. 가비지 컬렉터는 오브젝트를 자동으로 클린업하기 위해 자주 실행되어야 하므로 백엔드 시스템은 오브젝트가 참조되는 모든 위치를 모니터링할 수 있어야 합니다. 가비지 컬렉터가 실행되는 프레임에서는 처리량이 자주 급증하며, 특히 애플리케이션이 최근 대량의 오브젝트를 소멸시킨 경우 더욱 그렇습니다.
프로젝트 세팅(Project Settings) > 엔진(Engine) > 가비지 컬렉션(Garbage Collection)에서 가비지 컬렉션을 환경설정할 수 있습니다. 여기에는 가비지 컬렉션 간격, 지정 시간에 클린업할 최대 오브젝트 수, 그리고 가비지 컬렉션 처리 방식의 기타 세팅이 포함되어 있습니다. 프로젝트 초기부터 미세조정할 일은 거의 없겠지만, 언리얼 엔진의 가비지 컬렉터 작동 방식을 프로젝트의 고유한 필요성에 맞춰 조정할 옵션을 제공합니다.
자동 가비지 컬렉션을 사용하는 것이 좋습니다. 필요한 경우 블루프린트의 Collect Garbage 노드를 사용하거나 C++의 UObjectGlobals::CollectGarbage
함수를 사용하여 가비지 컬렉터를 수동으로 호출할 수도 있습니다.
가비지 컬렉션을 수동으로 호출하면 처리량이 급증할 수 있지만, 백그라운드에서 누적된 가비지 때문에 나중에 자동 실행 시 더 심각한 스파이크가 발생하는 일을 미연에 방지할 수 있습니다.
수동 가비지 컬렉션은 다음과 같은 상황에 적절합니다.
프로그램이 로딩 화면과 같이 UX 관점에서 프레임 스파이크를 더 허용할 수 있는 상태인 경우에 적절합니다. 이 경우 더 두드러지거나 용납하기 어려운 상황에서 스파이크가 발생할 확률을 줄입니다.
이는 메모리가 많이 할당되는 작업을 수행하기 전, 테스트 도중 가비지 컬렉션을 수행하지 않으면 메모리 부족 크래시나 페이지 스왑 멈춤 현상이 발생할 수 있다는 사실을 작업 직전에 발견한 경우에 적절합니다.
오브젝트 생성 및 소멸과 오브젝트 풀링 비교
오브젝트를 생성하려면 컴퓨터에서 오브젝트의 사본을 저장할 새 메모리 블록을 할당한 다음, 그 메모리 블록을 필요한 모든 서브 오브젝트와 함께 초기화해야 합니다. 오브젝트를 소멸시킬 때는 해당 정보를 삭제하고 할당을 해제하고 애플리케이션 코드의 다른 위치에 있을 수 있는 해당 오브젝트에 대한 레퍼런스를 모두 지워야 합니다.
이 두 가지 작업은 특히 초기화 과정에서 다른 시스템과의 조율이 필요한 경우 비용이 상당히 많이 들 수 있습니다. 대부분의 경우 언리얼 엔진은 이러한 작업을 효율적으로 처리하므로 PC와 콘솔의 대다수 상황에서 안전하게 사용할 수 있지만, CPU 처리 능력에 여유가 별로 없는 프로젝트에서는 오브젝트 풀링을 대신 사용하는 것이 좋습니다. 오브젝트 풀링은 필요한 오브젝트의 모든 사본을 미리 생성하여 메모리에 할당한 다음, 필요할 때까지 비활성화하거나 숨겨두는 방식을 사용합니다.
오브젝트의 레벨이 높을수록 생성 및 소멸 비용이 더 많이 듭니다. 풀링은 다른 UObject보다는 컴포넌트에, 컴포넌트보다는 액터에 더 유용할 가능성이 큽니다. 왜냐하면 액터 생성에 드는 비용이 있기 때문인데, 월드의 액터 목록에 액터를 삽입하고, 액터의 컴포넌트를 생성하고, 렌더링 및 피직스 등의 추가 인프라에 액터와 컴포넌트를 등록하는 작업도 수반되기 때문입니다. 생성 및 소멸 시 추가적인 클래스와 상호작용하지 않는 C++ 구조체의 경우, 풀링을 시도하는 것은 시스템 얼로케이터가 원시 메모리를 재활용하는 것보다 실제로 효율성이 떨어질 수 있습니다.
투사체를 발사하는 무기를 예로 들어보겠습니다. 무기가 발사될 때 투사체를 스폰한 후 투사체가 다른 오브젝트와 충돌하면 스스로 파괴되는 것이 매우 일반적입니다.
오브젝트 풀링을 사용하면 투사체를 발사할 때마다 새 투사체를 스폰하는 것이 아니라 무기가 지정된 시간에 활성화할 수 있는 최대 투사체 수를 미리 스폰한 다음 이를 숨기고 비활성화합니다. 이 비활성화된 투사체 그룹이 오브젝트 풀입니다. 무기에서 투사체가 발사되면 투사체를 풀에서 꺼내 무기 끝으로 이동한 다음, 숨김을 해제하고 활성화하여 적절한 방식으로 초기화합니다. 그런 다음, 투사체가 타깃에 충돌하면 투사체를 숨기고 비활성화한 다음 나중에 다시 사용할 수 있도록 풀에 반환합니다.
오브젝트 풀의 장점은 오브젝트를 생성하거나 소멸할 필요가 없어 초기화 및 클린업에 소요되는 처리 시간을 크게 절약할 수 있다는 점입니다. 단, 풀의 오브젝트가 비활성 상태일 때에도 비어 있는 메모리를 점유한다는 단점이 있습니다. 그러나 어차피 많은 상황에서 풀에 필요한 최대 오브젝트 수를 위한 공간을 남겨두어야 합니다. 또한, 이러한 오브젝트의 메모리는 더 안정적으로 유지됩니다. 작은 청크가 아닌 큰 청크로 할당해서 클린업하므로 메모리 단편화 가능성이 줄어들기 때문입니다.
온 틱 로직과 콜백, 타이머 및 예약 로직 비교
틱 가능 UObject 및 액터의 틱(Tick) 이벤트는 프레임마다 반복되는 로직을 생성하는 방법을 제공합니다. 이는 실시간 이동을 처리하는 데 유용합니다. 그러나 지속적이지 않고 간헐적인 루틴에 틱을 사용하면 CPU 사용량이 과도해져 낭비될 수 있습니다.
특히 다음 예시처럼 프레임마다 변수가 변경되었는지 확인하는 로직을 사용하면 적당하지 않은 경우가 많습니다. 한 클래스가 틱을 사용하여 다른 클래스의 변수가 언제 변경되는지 반복적으로 확인합니다.
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
public:
//Set this reference in the details panel.
UPROPERTY(BlueprintReadOnly, EditAnywhere)
AChildActor* ChildActor;
protected:
UCLASS()
class AChildActor : public AActor
{
GENERATED_BODY()
public:
int32 getMyInt(){
return MyInt;
}
private:
int32 myInt = 0;
틱으로 값을 모니터링하는 것이 아니라 커스텀 세터 함수를 생성하여 변수를 변경하는 작업을 래핑하고, 해당 값을 변경할 때만 원하는 로직을 수행할 다른 함수나 이벤트를 호출할 수 있습니다.
다음 예시에는 지난 예시의 클래스가 포함되지만 이번에는 변수가 변경된 경우에만 콜백을 사용하여 액션을 수행합니다.
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
public:
void OnChildIntChanged(int32 NewValue)
{
if (newValue > 3)
{
UCLASS()
class AChildActor : public AActor
{
GENERATED_BODY()
public:
//Set this reference in the details panel.
UPROPERTY(BlueprintReadOnly, EditAnywhere)
AMyActor* ParentActor;
이렇게 하면 프레임마다 값을 쿼리하지 않고 변수가 변경될 때만 로직이 실행됩니다.
그러나 이벤트 주도형 접근 방식은 조건이 바뀌는 빈도에 따라 최적으로 작동하지 않을 수 있습니다. 이벤트가 프레임당 여러 번 실행되거나, 함수가 여러 이벤트에 연결되어 있고 그러한 이벤트가 같은 프레임에서 모두 변경될 수 있는 경우, 틱을 사용하거나 '명령 패턴'을 사용하는 것이 더 효과적일 수 있습니다. 이렇게 하면 렌더링되기 전에 덮어써지기만 하는 결과를 계산하지 않아도 됩니다.
설정된 시간 후에 발생하도록 이벤트를 예약하려는 경우, 타이머(Timer)를 시작하면 이벤트가 완료될 때까지 경과된 시간을 일시적으로 트래킹한 다음 자동으로 클린업합니다. 아니면 블루프린트 이벤트 그래프에서 Delay 노드를 사용할 수도 있습니다.
자주 반복되는 로직이 필요하지만 프레임마다 발생할 필요는 없는 경우, 특정 간격의 프레임이나 시간(초) 간격으로 발생하도록 설정하는 것이 좋습니다. 개별 오브젝트와 액터 컴포넌트에서 틱 간격을 특정 시간(초)으로 설정하여 이 작업을 수행할 수 있습니다. 아니면 Tick 함수에서 로직의 서브셋 간격을 만들 수도 있습니다. 이 작업을 수행하려면 여전히 변수를 누적하고 리셋해야 하지만, 프레임마다 로직을 실행하는 것보다는 여전히 저렴합니다.
비동기식 로직과 동기식 로직 비교
동기식(Synchronous) 로직은 처음부터 끝까지 순차적으로 액션을 완료하는 것을 말합니다. 블루프린트나 C++로 작성하는 대부분의 로직은 기본적으로 동기식입니다. 예를 들어, 블루프린트에서 이벤트를 생성하지만 어떠한 Delay 노드, 타이머, 게임플레이 작업도 추가하지 않으면 해당 블루프린트 이벤트에서 비롯된 모든 로직이 같은 프레임에서 한 번에 실행됩니다. 그 프레임은 해당 로직의 실행이 완료될 때까지 처리를 완료할 수 없습니다. 특히 메모리에서 로드하거나 언로드해야 하는 대규모 데이터 세트나 대형 오브젝트에 광범위한 작업을 실행하는 경우, 처리량이 상당히 급증할 수 있습니다.
비동기(Asynchronous) 로직은 실제로 동시에(별도의 CPU 코어에서) 또는 논리적으로 동시에(기술적으로 낮은 수준에서 동기적으로 실행되는 작은 청크로 인터레이스되어서) 액션을 병렬로 완료하는 것을 말합니다. 비동기 작업은 완료될 때까지 실행되지만, 메인 프로그램은 작업이 따라잡도록 기다리지 않고 계속 실행됩니다. 일반적으로 비동기 작업은 콜백을 사용하여 완료되었을 때 신호를 보냅니다.
월드 파티션 시스템과 다양한 온디맨드 콘텐츠 전송 시스템 등 언리얼 엔진의 일부 프레임워크는 이미 비동기식입니다. 자체 프로젝트에서는 단일 작업이나 프레임에 너무 많은 부담을 주지 않도록 작업을 일정 기간으로 분산하는 비동기 로직을 구현하는 것을 고려해 보세요.
예를 들어, 웨이브 기반 디펜스 게임에서 다수의 적, 이를테면 30명 이상의 적을 로드하고 인스턴스화해야 할 수 있습니다. 런타임에 새 액터를 생성하는 데 이미 많은 비용이 들기 때문에 같은 프레임에서 모든 액터를 처리하기란 매우 번거롭습니다. 대신 지정된 한계에 도달하거나 지정된 스폰 위치를 모두 소진할 때까지 프레임당 최대 5명의 적만 스폰하도록 비동기 작업을 생성할 수 있습니다. 이렇게 하면 6프레임에 걸쳐 30명의 적이 모두 스폰되며, 이 시점에서 대량 스폰 작업이 완료되었음을 알릴 수 있습니다. 이렇게 하면 다수의 적을 스폰하는 워크로드가 크게 줄어들며 1/10초 또는 1/5초 동안 스폰이 이루어지기 때문에 대부분의 플레이어는 일정 기간에 걸쳐 스폰되는 것을 알아차리지 못합니다.
언리얼 엔진의 병렬 처리
병렬 처리는 액션을 같은 컴퓨터지만 서로 다른 스레드나 CPU 코어에서 처리하는 비동기 처리의 한 유형입니다. 다음과 같은 언리얼 엔진 병렬 처리 예시가 있습니다.
소프트 포인터 리졸브.
백그라운드에서 레벨과 에셋을 로드합니다.
온라인 콘텐츠 전송 시스템에서 에셋을 비동기로 로드합니다.
스레드(Thread)는 CPU 또는 GPU에서 인스트럭션을 처리하는 전용 경로입니다. 대부분의 CPU에는 여러 개의 코어가 있는데, 코어는 개별 프로세서이며, 각 코어의 스레드는 여러 개일 수 있습니다. 병렬 처리의 활용은 특히 복잡한 태스크와 대량의 데이터를 처리하는 프로그램에서 CPU 프로세스의 병목 현상을 방지하는 데 핵심 역할을 합니다.
주목할 만한 처리 스레드
언리얼 엔진에는 다음과 같은 전용 스레드가 있습니다.
스레드 이름 | Description |
---|---|
게임(Game) | C++ 및 블루프린트에서 UObject 및 액터 로직과 UI 로직을 처리합니다. 대부분의 프로그래밍은 이 스레드에서 이루어집니다. |
렌더링 | 씬 구조체를 그리기 명령으로 변환합니다. |
RHI | GPU에 그리기 명령을 전송합니다. |
태스크 풀(Task Pools) | 재사용할 수 있는 스레드에서 다양한 태스크를 처리합니다. |
오디오 | 사운드 및 음악 처리. |
로딩(Loading) | 데이터 로딩 및 언로딩을 처리합니다. |
이러한 스레드는 언리얼 인사이트의 타이밍 인사이트(Timing Insights) 창에서 확인할 수 있습니다.
게임 스레드는 대량의 로직을 처리하므로 코드를 신중하게 프로파일링하고 최적화하는 것이 중요합니다.
나만의 스레드 로직 생성하기
UE에는 자신만의 병렬 처리 로직을 추가해주는 여러 가지 리소스가 있습니다.
태스크 시스템(Tasks System)은 로직을 별도의 스레드에서 병렬로 실행할 수 있는 태스크로 분할하는 강력하면서도 비교적 가벼운 프레임워크를 제공합니다.
FRunnable은 임의의 스레드에서 함수를 실행할 수 있는 가장 직접적인 로우 레벨 인터페이스를 제공합니다. 자기가 무슨 일을 하는지 알고 있고 스레드 풀 대신 전용 스레드를 사용해야 하는 정당한 이유가 있는 경우가 아니라면 이 방법은 피해야 합니다.
자체 스레드 로직을 생성할 때는 주의해야 합니다. 작업이 예상치 못한 순서로 진행될 경우 오류가 발생하는 경쟁 상황이 될 수 있기 때문입니다.
또한, 스레드 렌더링 페이지에서 렌더링 관련 스레드 로직에 대한 정보를 확인할 수 있습니다.
셰이더 컴파일, 프레임 레이트 멈춤 현상 및 PSO 캐싱
언리얼 엔진은 머티리얼 인스트럭션을 셰이더로 컴파일하여 GPU에서 실행할 수 있도록 준비합니다. 컴파일이 완료되면 머티리얼의 전반적인 퍼포먼스가 크게 향상되지만, 셰이더를 컴파일하는 과정에서 처리량이 크게 급증하여 프레임 레이트 멈춤 현상이 잠시 두드러지게 발생할 수 있습니다.
UE는 이를 해결하기 위해 PSO 캐싱을 구현합니다. 애플리케이션을 플레이하고 테스트하여 수동으로 PSO를 수집할 수도 있고, PSO 프리캐싱을 사용하여 자동으로 수집할 수도 있습니다. 두 경우 모두 애플리케이션이 실행 중일 때 그래픽 카드가 렌더링할 가능성이 있는 모든 상태를 기록한 다음, 해당 데이터를 캐시하고 번들로 묶어 후속 빌드에서 사용한다는 개념입니다. 이렇게 하면 런타임에 수행해야 하는 셰이더 컴파일의 양이 크게 줄어듭니다. 그 이유는 대부분의 셰이더를 미리 로드할 수 있으므로 새로운 영역과 머티리얼을 로드할 때 사용자가 멈춤 현상을 덜 겪기 때문입니다.