1. 프로파일링이 필요한 이유
게임을 개발하다 보면 프레임 드롭이 발생하는 순간을 반드시 마주하게 됩니다. 이때 가장 먼저 떠오르는 질문은 "도대체 어디서 느려지는 건가?"입니다. 문제를 해결하려면 병목 지점이 CPU의 게임 스레드인지, 렌더 스레드인지, 아니면 GPU인지를 먼저 파악해야 합니다. 감으로 최적화를 시도하면 엉뚱한 곳에 시간을 낭비하기 쉽습니다.
언리얼 엔진은 이런 상황을 위해 stat 콘솔 명령어 시리즈를 제공합니다. 에디터나 빌드 실행 중에 콘솔 창(~ 키)을 열고 명령어 한 줄만 입력하면, 각 스레드가 한 프레임을 처리하는 데 얼마나 걸리는지를 밀리초(ms) 단위로 즉시 확인할 수 있습니다. 별도의 외부 도구 없이 실시간으로 병목을 진단할 수 있다는 점에서, stat 명령어는 프로파일링의 첫 번째 도구로 가장 많이 사용됩니다.
2. 언리얼 엔진의 스레드 구조
stat 명령어의 출력값을 정확히 읽으려면, 먼저 언리얼 엔진이 한 프레임을 어떤 스레드들로 나누어 처리하는지 이해해야 합니다.
언리얼 엔진은 크게 세 가지 핵심 스레드로 프레임을 처리합니다.
- Game Thread (게임 스레드): 게임플레이 로직을 담당합니다.
Tick함수, AI, 물리 시뮬레이션, 블루프린트 실행, 애니메이션 업데이트 등이 이 스레드에서 처리됩니다. - Render Thread (렌더 스레드, Draw): CPU 측에서 렌더링 명령을 준비합니다. 씬의 가시성 판별(Culling), 드로우 콜 생성, 머티리얼 파라미터 전달 등을 처리하며, 이 명령들을 GPU에 전달하는 역할을 합니다.
- GPU: 렌더 스레드가 제출한 명령을 실제로 실행하여 화면에 그립니다. 셰이더 연산, 포스트 프로세싱, 텍스처 샘플링 등이 GPU에서 수행됩니다.
이 세 스레드는 파이프라인 형태로 동작합니다. 한 프레임의 실제 소요 시간(Frame Time)은 이 세 스레드 중 가장 오래 걸리는 스레드의 시간에 의해 결정됩니다. 즉, 아무리 게임 스레드가 빠르더라도 GPU가 느리면 전체 프레임 시간은 GPU 시간에 맞춰집니다. 이것이 바로 "병목(Bottleneck)"의 핵심 원리입니다.
3. stat unit — 병목 스레드를 한눈에 파악하기
프로파일링의 첫 단계는 항상 stat unit입니다. 콘솔 창에 다음과 같이 입력합니다.
stat unit
위 명령어를 입력하면 화면에 다음과 같은 항목이 표시됩니다.
| 항목 | 의미 |
|---|---|
| Frame | 한 프레임의 전체 소요 시간 (ms). stat fps에서 보여주는 FPS의 역수에 해당합니다. |
| Game | 게임 스레드가 소비한 시간 (ms). 게임플레이 로직, Tick, AI 등의 처리 시간입니다. |
| Draw | 렌더 스레드(CPU 측 렌더링)가 소비한 시간 (ms). 드로우 콜 생성, 가시성 판별 등의 시간입니다. |
| GPU | GPU가 실제 렌더링에 소비한 시간 (ms). 셰이더, 포스트 프로세싱 등의 처리 시간입니다. |
| RHIT | RHI(Rendering Hardware Interface) 스레드 시간입니다. GPU 명령 변환에 소비되는 시간입니다. |
| DynRes | 동적 해상도(Dynamic Resolution) 관련 정보입니다. |
위의 항목들 중에서 병목을 판별하는 핵심은 Frame, Game, Draw, GPU 네 가지입니다. 판별 기준은 다음과 같습니다.
Frame ≈ Game인 경우: 게임 스레드가 병목입니다. 게임플레이 로직이 너무 무거운 상황으로, Tick 처리, AI 연산, 블루프린트 노드 수 등을 점검해야 합니다.
Frame ≈ Draw인 경우: 렌더 스레드가 병목입니다. 드로우 콜이 과도하게 많거나, 씬에 렌더링할 오브젝트가 너무 많은 상황입니다. 메시 병합이나 LOD 설정을 검토해야 합니다.
Frame ≈ GPU인 경우: GPU가 병목입니다. 셰이더 복잡도, 오버드로우, 고해상도 텍스처, 포스트 프로세싱 비용 등을 점검해야 합니다.
실제로 사용해보면, 30FPS를 목표로 하는 프로젝트에서는 각 스레드가 33.3ms 이하를 유지해야 하고, 60FPS를 목표로 한다면 16.6ms 이하를 유지해야 한다는 점이 중요합니다. 이 기준을 초과하는 스레드가 바로 병목입니다.
4. stat unitgraph — 시간에 따른 병목 변화를 그래프로 관찰하기
stat unit이 현재 순간의 스냅샷이라면, stat unitgraph는 시간 흐름에 따른 변화를 그래프로 보여줍니다. 콘솔에 다음과 같이 입력합니다.
stat unitgraph
위 명령어를 실행하면 화면에 실시간 그래프가 나타납니다. 가로축은 프레임 번호, 세로축은 프레임 시간(ms)이며, Game, Draw, GPU 각각의 시간이 서로 다른 색상의 선으로 표시됩니다. 30FPS 기준선(약 33.3ms)이 수평선으로 표시되며, 이 선을 넘는 프레임은 깜빡이며 강조됩니다.
위 그래프가 특히 유용한 상황은 레벨을 돌아다니면서 "어디서 프레임이 떨어지는지" 찾을 때입니다. 특정 구역에 진입했을 때 그래프가 급등하면, 해당 구역에 병목 원인이 있다고 판단할 수 있습니다. 또한 어떤 색상의 선이 급등하는지에 따라 게임 스레드 문제인지, 렌더 스레드 문제인지, GPU 문제인지를 시각적으로 빠르게 파악할 수 있습니다.
5. 게임 스레드 병목 심층 진단
stat unit에서 Game 값이 높다면, 게임 스레드 내부의 어떤 작업이 시간을 많이 소비하는지 더 깊이 들어가야 합니다. 이때 사용하는 명령어들이 있습니다.
5-1. stat game
게임 스레드의 주요 카테고리별 소요 시간을 보여줍니다. 콘솔에 다음과 같이 입력합니다.
stat game
위 명령어를 실행하면 한 프레임에서 게임플레이 관련 작업들이 각각 얼마나 걸리는지 카테고리별로 표시됩니다. 월드 틱 시간, 오브젝트 틱 시간 등이 분류되어 나타나므로, 어떤 종류의 작업이 게임 스레드를 지배하고 있는지 빠르게 파악할 수 있습니다.
5-2. stat tickables
Tick 함수가 활성화된 액터와 컴포넌트들의 개별 Tick 소요 시간을 보여줍니다.
stat tickables
위 명령어는 매 프레임 Tick이 호출되는 오브젝트들의 목록과 각각의 소요 시간을 표시합니다. Tick이 불필요하게 활성화된 액터가 많다면 이 명령어로 쉽게 발견할 수 있습니다. 실제로 사용해보면, Tick을 끄고 타이머나 이벤트 기반으로 전환하는 것만으로도 게임 스레드 시간이 크게 줄어드는 경우가 많습니다.
5-3. stat character
캐릭터 무브먼트 관련 처리 시간을 보여줍니다.
stat character
위 명령어는 CharacterMovementComponent의 이동 연산, 충돌 검사 등에 소요되는 시간을 보여줍니다. 캐릭터가 많은 멀티플레이어 게임에서 Game 시간이 높을 때 유용합니다.
5-4. stat component
컴포넌트 단위의 틱 처리 시간을 보여줍니다.
stat component
위 명령어를 통해 어떤 컴포넌트 타입이 가장 많은 시간을 소비하는지 확인할 수 있습니다. SkeletalMeshComponent의 애니메이션 업데이트나 PhysicsComponent의 시뮬레이션이 의외로 많은 시간을 차지하는 경우를 발견할 수 있습니다.
6. 렌더 스레드 병목 심층 진단
stat unit에서 Draw 값이 높다면, 렌더 스레드 내부를 살펴봐야 합니다.
6-1. stat scenerendering
렌더 스레드의 주요 렌더링 패스별 소요 시간을 보여줍니다.
stat scenerendering
위 명령어를 실행하면 그림자 렌더링, 라이팅 계산, 가시성 판별 등 각 렌더링 단계의 소요 시간이 표시됩니다. "RenderViewFamily"나 "InitViews" 같은 항목이 높게 나타난다면, 씬에 렌더링할 오브젝트가 너무 많거나 가시성 판별에 시간이 오래 걸리는 상황입니다.
6-2. stat initviews
가시성 판별(Visibility Culling)에 특화된 정보를 보여줍니다.
stat initviews
위 명령어는 렌더 스레드 성능에서 가장 중요한 지표 중 하나인 "Visible Static Mesh Elements" 수를 보여줍니다. 이 값이 과도하게 높다면 오컬루전 컬링 설정을 점검하거나, 원거리 오브젝트의 LOD를 조정해야 합니다. 렌더 스레드에서 CPU 바운드가 발생하는 가장 흔한 원인은 드로우 콜 수가 너무 많은 것이며, 이는 stat initviews로 빠르게 확인할 수 있습니다.
7. GPU 병목 심층 진단
stat unit에서 GPU 값이 높다면, GPU 내부의 어떤 렌더링 패스가 비용을 많이 차지하는지 확인해야 합니다.
7-1. stat gpu
GPU의 렌더링 패스별 소요 시간을 보여줍니다.
stat gpu
위 명령어를 실행하면 프레임 렌더링 시간이 개별 패스(베이스 패스, 라이팅, 그림자, 포스트 프로세싱 등)로 분리되어 표시됩니다. 이는 GPU Visualizer(Ctrl+Shift+,)의 간소화된 텍스트 버전이라고 생각하면 됩니다.
위 명령어에서 특정 패스의 시간이 높다면 다음과 같이 대응할 수 있습니다.
| 높은 패스 | 의심되는 원인 | 대응 방안 |
|---|---|---|
| BasePass | 머티리얼 복잡도, 오브젝트 수 | 머티리얼 인스트럭션 수 줄이기, 메시 병합 |
| Shadows | 그림자 캐스터 수, 캐스케이드 수 | 그림자 거리 줄이기, 캐스케이드 수 조정 |
| Lights | 동적 라이트 수 | 라이트 수 줄이기, 라이트 반경 조정 |
| PostProcessing | 포스트 프로세싱 이펙트 | 불필요한 이펙트 비활성화 |
| Translucency | 반투명 오브젝트 오버드로우 | 반투명 사용 최소화 |
8. stat startfile / stopfile — 프로파일링 데이터를 파일로 캡처하기
실시간으로 화면에 표시되는 stat 정보만으로는 세밀한 분석이 어려울 수 있습니다. 이때 stat startfile로 프로파일링 데이터를 파일로 캡처하고, 세션 프론트엔드(Session Frontend)에서 상세 분석할 수 있습니다.
다음은 캡처를 시작하고 종료하는 코드입니다.
// 콘솔 명령어로 프로파일링 캡처 시작
// 게임 실행 중 콘솔(~ 키)에서 직접 입력하거나, C++ 코드에서 호출할 수 있습니다.
GEngine->Exec(GetWorld(), TEXT("stat startfile"));
// 약 10초 이상 캡처한 뒤 종료합니다.
GEngine->Exec(GetWorld(), TEXT("stat stopfile"));
위의 코드에서는 GEngine->Exec을 통해 C++ 코드 내에서 프로파일링 캡처를 제어하고 있습니다. 물론 콘솔 창에서 stat startfile과 stat stopfile을 직접 입력해도 동일하게 동작합니다.
캡처가 종료되면 <프로젝트 경로>/Saved/Profiling/UnrealStats/ 디렉터리에 .uestats 파일이 생성됩니다. 이 파일을 분석하려면 에디터 메뉴에서 Tools > Session Frontend를 열고, Profiler 탭에서 Load 버튼을 클릭하여 해당 파일을 로드합니다.
세션 프론트엔드에서는 GameThread 항목을 펼쳐서 각 함수의 Inc Time(포함 시간)과 Calls(호출 횟수)를 확인할 수 있습니다. Inc Time이 수 밀리초 이상인 항목을 찾으면, 해당 함수가 병목의 원인일 가능성이 높습니다.
주의하셔야 할 점은, stat startfile을 실행한 뒤 stat stopfile을 호출하지 않으면 PIE를 종료해도 백그라운드에서 계속 캡처가 진행된다는 것입니다. 이 경우 디스크 용량이 급격히 소모될 수 있으므로, 캡처 후에는 반드시 stat stopfile로 종료해야 합니다.
9. stat slow — 느린 항목만 빠르게 필터링하기
모든 stat 항목을 일일이 확인하기 번거로울 때, stat slow 명령어를 사용하면 기준 시간을 초과하는 항목만 필터링하여 표시할 수 있습니다.
stat slow -ms=1.0 -depth=4
위 명령어는 게임 스레드와 렌더 스레드에서 1.0ms를 초과하는 항목을 최대 4단계 깊이까지 표시합니다. 프레임 드롭이 발생하는 순간에 이 명령어를 활성화해두면, 병목을 유발하는 구체적인 함수나 시스템을 빠르게 찾아낼 수 있습니다.
10. C++ 코드에서 커스텀 프로파일링 마커 추가하기
언리얼 엔진의 stat 시스템은 C++ 코드에서 직접 프로파일링 마커를 추가할 수 있도록 매크로를 제공합니다. 일반 C++에서 성능을 측정하려면 std::chrono로 시작 시간과 종료 시간을 직접 기록하고 차이를 계산해야 합니다. 언리얼 엔진은 이 과정을 SCOPE_CYCLE_COUNTER 매크로 하나로 간소화하며, 측정 결과를 stat 시스템에 자동으로 통합하여 다른 엔진 내부 지표와 함께 확인할 수 있도록 합니다.
먼저 stat 그룹과 카운터를 선언합니다.
// MyGameStats.h
// 커스텀 stat 그룹을 선언합니다.
DECLARE_STATS_GROUP(TEXT("MyGame"), STATGROUP_MyGame, STATCAT_Advanced);
// 해당 그룹에 속하는 개별 사이클 카운터를 선언합니다.
DECLARE_CYCLE_STAT(TEXT("Enemy AI Update"), STAT_EnemyAIUpdate, STATGROUP_MyGame);
DECLARE_CYCLE_STAT(TEXT("Pathfinding Calculate"), STAT_PathfindingCalculate, STATGROUP_MyGame);
위의 코드에서 DECLARE_STATS_GROUP은 stat 명령어에서 사용할 그룹 이름을 정의하고, DECLARE_CYCLE_STAT은 해당 그룹에 속하는 개별 측정 항목을 정의합니다.
다음으로 측정하고 싶은 코드 블록에 SCOPE_CYCLE_COUNTER를 배치합니다.
// MyEnemyAI.cpp
#include "MyGameStats.h"
void AMyEnemyAI::UpdateAI(float DeltaTime)
{
// 이 스코프 안의 실행 시간이 STAT_EnemyAIUpdate에 기록됩니다.
SCOPE_CYCLE_COUNTER(STAT_EnemyAIUpdate);
// AI 상태 머신 업데이트
UpdateStateMachine(DeltaTime);
{
// 중첩 스코프로 경로 탐색만 따로 측정합니다.
SCOPE_CYCLE_COUNTER(STAT_PathfindingCalculate);
CalculatePathToTarget();
}
ExecuteCurrentAction(DeltaTime);
}
위 코드처럼 SCOPE_CYCLE_COUNTER는 해당 스코프({})에 진입할 때 타이머를 시작하고, 스코프를 벗어날 때 자동으로 종료합니다. 중첩 스코프를 활용하면 함수 전체 시간과 내부 특정 구간의 시간을 동시에 측정할 수 있습니다.
이렇게 등록한 커스텀 stat은 콘솔에서 다음과 같이 확인할 수 있습니다.
stat MyGame
위 명령어를 입력하면 STATGROUP_MyGame에 등록된 모든 카운터가 화면에 표시되며, 각 함수가 프레임당 몇 밀리초를 소비하는지 실시간으로 확인할 수 있습니다.
11. 일반 C++과의 비교
일반 C++에서 코드 실행 시간을 측정하려면 보통 std::chrono::high_resolution_clock을 사용하여 시작과 종료 시점을 수동으로 기록합니다.
// 일반 C++ 방식: 수동으로 시간을 측정합니다.
auto StartTime = std::chrono::high_resolution_clock::now();
DoSomeWork();
auto EndTime = std::chrono::high_resolution_clock::now();
auto Duration = std::chrono::duration_cast<std::chrono::microseconds>(EndTime - StartTime);
UE_LOG(LogTemp, Log, TEXT("Duration: %lld us"), Duration.count());
위 코드처럼 일반 C++에서는 측정 시작과 종료를 직접 관리해야 하며, 결과를 모아서 표시하는 것도 개발자의 몫입니다. 언리얼 엔진의 SCOPE_CYCLE_COUNTER는 이와 동일한 원리(스코프 기반 RAII 패턴)를 사용하지만, 다음과 같은 차이점이 있습니다.
공통점: 둘 다 스코프 기반으로 동작하며, 코드 블록의 실행 시간을 측정한다는 근본적인 목적은 같습니다. SCOPE_CYCLE_COUNTER도 내부적으로 생성자에서 시작 시간을 기록하고 소멸자에서 종료 시간을 기록하는 RAII 패턴을 사용합니다.
차이점: 언리얼 엔진의 stat 시스템은 측정 결과를 엔진의 통계 수집 파이프라인에 자동으로 통합합니다. 따라서 stat 콘솔 명령어로 실시간 확인이 가능하고, stat startfile로 캡처한 뒤 세션 프론트엔드에서 다른 엔진 내부 지표와 함께 시각적으로 분석할 수 있습니다. 일반 C++의 std::chrono 방식으로는 이러한 통합이 불가능하며, 별도의 로깅 및 시각화 시스템을 직접 구축해야 합니다.
12. 유니티 엔진과의 비교
유니티 엔진에서도 스레드별 병목을 진단하는 프로파일링 도구를 제공합니다. 유니티의 Profiler 윈도우는 메인 스레드, 렌더 스레드, 워커 스레드의 시간을 타임라인 형태로 보여줍니다. 기능적으로는 언리얼의 stat unit과 유사한 역할을 합니다.
그러나 두 엔진의 접근 방식에는 중요한 차이가 있습니다.
유니티: 프로파일링을 위해 별도의 Profiler 윈도우를 열어야 하며, 커스텀 마커를 추가할 때는 C# 코드에서 Profiler.BeginSample("이름")과 Profiler.EndSample()을 쌍으로 호출해야 합니다. 시작과 종료를 수동으로 관리해야 하므로 EndSample() 호출을 빠뜨리는 실수가 발생할 수 있습니다. 또한 GPU 프로파일링은 유니티 자체 도구로는 제한적이며, RenderDoc 같은 외부 도구에 의존하는 경우가 많습니다.
언리얼 엔진: 콘솔 명령어 한 줄(stat unit)로 즉시 스레드별 시간을 확인할 수 있어 진입 장벽이 낮습니다. 커스텀 마커는 SCOPE_CYCLE_COUNTER 매크로 한 줄이면 되고, 스코프 기반이므로 종료를 빠뜨릴 위험이 없습니다. GPU 프로파일링도 stat gpu 명령어로 엔진 내부에서 직접 렌더링 패스별 시간을 확인할 수 있습니다.
언리얼 엔진의 강점은 stat 명령어 체계가 계층적으로 설계되어 있다는 점입니다. stat unit으로 전체 병목을 파악한 뒤, stat game, stat scenerendering, stat gpu 등으로 단계적으로 깊이 들어가는 워크플로우가 자연스럽게 이어집니다. 또한 stat startfile로 캡처한 데이터를 세션 프론트엔드에서 함수 단위까지 드릴다운할 수 있어, 별도의 외부 도구 없이도 엔진 내에서 상당히 깊은 수준의 분석이 가능합니다.
13. 주의사항
stat fps만으로 프로파일링하지 마십시오. stat fps는 FPS 수치만 표시할 뿐, CPU와 GPU 중 어디가 병목인지 알려주지 않습니다. 반드시 stat unit을 사용하여 스레드별 시간을 확인해야 합니다.
FPS가 아닌 밀리초(ms) 단위로 분석해야 합니다. FPS는 비선형적입니다. 60FPS에서 30FPS로 떨어지는 것은 16.6ms의 차이이지만, 30FPS에서 15FPS로 떨어지는 것은 33.3ms의 차이입니다. 밀리초로 분석해야 성능 변화를 정확히 측정할 수 있습니다.
"CPU Stall" 항목에 속지 마십시오. 세션 프론트엔드에서 GameThread를 분석할 때 "CPU Stall"이라는 항목이 높게 나타날 수 있습니다. 이 항목은 해당 스레드가 다른 스레드를 기다리면서 유휴 상태였던 시간을 나타냅니다. 즉, 실제 작업 시간이 아니라 대기 시간이므로, 이 값이 높다는 것은 오히려 해당 스레드가 병목이 아니라는 의미입니다.
에디터가 아닌 실제 빌드에서 프로파일링해야 합니다. 에디터 환경에서는 에디터 자체의 오버헤드(에디터 UI, 디버깅 기능 등)가 측정값에 포함됩니다. 정확한 프로파일링은 Development 빌드나 Test 빌드에서 수행해야 합니다. 특히 모바일 프로젝트의 경우 타겟 디바이스에서 직접 측정하는 것이 필수적입니다.
stat startfile 후 반드시 stat stopfile을 실행하십시오. 캡처를 종료하지 않으면 .uestats 파일이 계속 커지면서 디스크 공간을 빠르게 소모합니다. PIE를 종료해도 캡처는 백그라운드에서 계속 진행됩니다.
stat 명령어로 충분하지 않을 때는 Unreal Insights를 사용하십시오. stat 명령어는 빠른 실시간 진단에 최적화되어 있지만, 함수 단위의 정밀한 분석이나 메모리 프로파일링, 네트워크 프로파일링이 필요하다면 Unreal Insights를 사용하는 것이 더 적합합니다. stat 명령어를 1차 진단 도구로, Unreal Insights를 2차 정밀 분석 도구로 활용하는 워크플로우를 권장합니다.
14. 프로파일링 워크플로우 요약
프로파일링을 처음 접하는 분들을 위해, stat 명령어를 활용한 전체 워크플로우를 정리하면 다음과 같습니다.
| 단계 | 명령어 | 목적 |
|---|---|---|
| 1단계 | stat unit |
전체 병목 스레드 파악 (Game / Draw / GPU) |
| 2단계 | stat unitgraph |
시간 흐름에 따른 병목 변화 관찰 |
| 3단계-A | stat game, stat tickables |
게임 스레드가 병목일 때 심층 진단 |
| 3단계-B | stat scenerendering, stat initviews |
렌더 스레드가 병목일 때 심층 진단 |
| 3단계-C | stat gpu |
GPU가 병목일 때 심층 진단 |
| 4단계 | stat startfile / stat stopfile |
데이터 캡처 후 세션 프론트엔드에서 정밀 분석 |
| 5단계 | stat slow -ms=1.0 |
특정 기준 이상의 느린 항목만 필터링 |
stat unit으로 시작하여 병목 스레드를 파악하고, 해당 스레드의 세부 stat 명령어로 원인을 좁혀나가는 것이 기본 워크플로우입니다. 이 흐름을 숙지해두면 프레임 드롭이 발생했을 때 체계적으로 원인을 추적할 수 있으며, 감에 의존하는 최적화에서 벗어나 데이터 기반의 효율적인 최적화를 수행할 수 있습니다.
'언리얼 엔진 5' 카테고리의 다른 글
| [UE5] Game Thread 최적화 (0) | 2026.03.04 |
|---|---|
| [UE5] 프로파일링 - Unreal Insights (0) | 2026.03.03 |
| [UE5] 게임플레이 프레임워크 구조 이해하기 (0) | 2026.03.02 |
| [UE5] 멀티스레딩 (0) | 2026.03.01 |
| [UE5] 비동기 프로그래밍 (0) | 2026.03.01 |