에디터에서는 매끄럽게 돌아가던 장면이, 실기기에 올리는 순간 버벅입니다. 반대로 개발 빌드에서 무겁던 처리가, 제품 빌드에서는 아무 일도 없었다는 듯 흘러가기도 합니다. 같은 게임인데, 어느 환경에서 재느냐에 따라 숫자가 어긋나는 것은 왜일까요?
측정 환경의 3단계
성능 측정 환경은 크게 3단계로 나눌 수 있습니다. 에디터에서의 프로파일링, Development 빌드에서의 프로파일링, 그리고 Release / Shipping에 준하는 최적화를 켠 빌드에서의 측정입니다. 앞 단계일수록 손쉽게 숫자가 나오지만, 그 숫자는 플레이어의 손에서 돌아가는 제품 빌드의 숫자에서 멀어집니다.
오른쪽으로 갈수록 측정 노이즈가 줄고, 플레이어가 체감하는 숫자에 가까워집니다.
에디터에서의 측정에는 에디터 고유의 비용이 실립니다. 핫 리로드나 에셋 감시 같은 에디터 전용 시스템이 뒤에서 계속 돌아가고, Unity라면 IL2CPP가 아니라 Mono로 돕니다. 메모리도 디버그용 할당자를 거치기 때문에 레이아웃과 정렬, 단편화 경향, GC가 실행되는 타이밍이 제품 빌드와 일치하지 않습니다.
Development 빌드로 바꾸면 에디터 고유의 비용은 사라지지만, 디버그용 오버헤드는 남습니다. 어설션 체크나 장황한 로그 출력, 프로파일러 연결 같은 디버그 기능이 켜진 채이고, 컴파일러 최적화도 프로덕션과 같지 않습니다. 인라인 전개가 억제되어 있거나, LTO(링크 타임 최적화)가 걸리지 않기도 합니다. Development 구성 그대로 만든 패키지에 대량의 로그 출력과 디버그 기능이 남은 채, 그 숫자를 제품의 성능으로 읽어 버리는 경우도 드물지 않을 것입니다.
즉, 앞의 두 단계에서 관측하는 “무거움”의 상당 부분은 측정 환경 자체가 만들어 낸 노이즈입니다.
플레이어의 기기에는 존재하지 않는 비용을 보면서, 실기에는 없는 병목과 싸우게 됩니다.
측정용 빌드 만드는 법
3단계째에 해당하는 것이, 프로덕션과 동일한 최적화 조건에서 동작하는 “측정용 빌드”입니다. 기본 방침은, Release / Shipping에 준하는 최적화를 켠 상태에서 측정에 필요한 최소한의 로그와 훅만 남기는 것입니다. 치트 기능이나 과도한 진단은 넣지 않습니다. 어디까지나 “제품과 같은 속도로 동작하되, 숫자만큼은 뽑을 수 있는” 빌드를 목표로 합니다.
Unreal Engine: Test 구성을 사용한다
Unreal Engine에는 DebugGame / Development / Test / Shipping이라는 빌드 구성이 있습니다. 여기서 노리는 것은 Test 구성입니다.
Development는 일상적인 이터레이션용으로, 최적화가 어느 정도 걸리기는 하지만 디버그 기능과 체크가 많이 남아 있습니다. 반대로 Shipping은 제품 출시용으로, 콘솔 명령어나 stat 계열 도구까지 걷어내서 측정 훅을 끼워 넣기 어려운 구성입니다.
Test는 그 중간으로 설계되어 있습니다. Shipping과 동등한 최적화를 유지하면서 stat unit 같은 진단 명령어와 최소한의 도구를 쓸 수 있는 상태를 남깁니다. 성능 측정에 필요한 속도 특성과 관측 가능성을 양립할 수 있어서, 측정용 빌드의 토대로 가장 무난한 선택지입니다.
다만 Test는 Development나 Shipping처럼 에디터의 패키징 메뉴에서 원클릭으로 고를 수 있는 것은 아닙니다. 확실한 경로는 Automation Tool(BuildCookRun)이나 Project Launcher의 커스텀 프로파일에서 지정하는 것입니다.
// MyGame.Build.cs (project module)
public class MyGame : ModuleRules
{
public MyGame(ReadOnlyTargetRules Target) : base(Target)
{
PublicDependencyModuleNames.AddRange(
new string[] { "Core", "CoreUObject", "Engine" });
// Measurement-only compile switch, scoped to this module.
// Module-level PrivateDefinitions leave the shared (installed-
// engine) build environment untouched, unlike target-level
// GlobalDefinitions, which would require a unique build
// environment and abort UBT on Launcher engines.
if (Target.Configuration == UnrealTargetConfiguration.Test)
{
PrivateDefinitions.Add("MEASUREMENT_BUILD=1");
}
}
}
RunUAT BuildCookRun -project="C:/Path/MyGame.uproject" -platform=Win64 -clientconfig=Test -cook -stage -pak -package -build
참고로 Test에서도 UE_LOG는 기본적으로 컴파일 아웃됩니다(활성화 스위치인 bUseLoggingInShipping은 Test와 Shipping 양쪽에 모두 적용됩니다). 다만 이 설정은 Launcher 판(installed) 엔진에서는 unique build environment를 요구해 UBT가 중단되므로, 측정 훅의 출력은 UE_LOG에 의존하지 않는 형태로 만들어 두는 것이 안전합니다.
또한 Launcher 판 엔진에는 Test용 엔진 바이너리가 기본으로 포함되지 않습니다(installed build의 GameConfigurations 기본값은 Shipping, Development, DebugGame입니다). Test로 패키징할 수 없는 경우에는 소스 빌드 엔진이나, Test를 포함해 빌드한 커스텀 installed build를 사용합니다.
이 구성은 UE5(및 UE4)에서 공통으로 쓸 수 있습니다.
Unity: 측정용 빌드 프로파일 (Unity 6)
Unity 6에서는 Build Profiles(빌드 프로파일)라는 구조가 도입되었습니다. 플랫폼별 빌드 설정을 이름 붙인 프로파일로 저장하고 전환할 수 있는 기능입니다. 측정용으로 전용 프로파일을 하나 만들고, 다음 방침으로 구성합니다.
- Development Build 체크는 끕니다(프로파일러 자동 연결과 스크립트 디버깅의 오버헤드를 싣지 않습니다)
- 스크립팅 백엔드는 IL2CPP를 선택해, 제품과 동일한 네이티브 코드 경로로 측정합니다
- Scripting Define Symbols로 측정용 최소 로그만 활성화하고, 그 외의 장황한 로그는 끕니다
구체적인 순서는 이렇습니다. File > Build Profiles에서 측정용 프로파일을 새로 만들고, Development Build 체크를 해제합니다. Player Settings에서 Scripting Backend를 IL2CPP, C++ Compiler Configuration을 Release로 설정합니다. 측정용 MEASUREMENT 정의는 프로파일의 Build Data > Scripting Defines에 추가합니다(프로파일의 정의는 프로젝트와 플랫폼의 정의에 더해집니다). 측정 코드 호출은 이 정의가 켜진 빌드에서만 컴파일 결과에 남습니다([Conditional]이 제거하는 것은 호출 지점이고, 메서드 본체는 어셈블리에 남습니다).
using System.Diagnostics;
public static class Measure
{
// Call sites are stripped at compile time unless the MEASUREMENT
// define is active for the current build profile. The method body
// itself stays in the assembly.
[Conditional("MEASUREMENT")]
public static void Mark(string label)
{
UnityEngine.Debug.Log($"[measure] {label} @ {UnityEngine.Time.frameCount}");
}
}
측정 기본값은 Release를 권합니다. 스튜디오가 Master 구성으로 출시한다면 측정도 Master에서 합니다. [Conditional]을 이용한 전환 자체는 Unity 6 이전에서도, 다음에 설명하는 BuildPipeline 경로로 똑같이 쓸 수 있습니다.
Unity 6 이전 버전에는 Build Profiles가 없습니다. 그럴 때는 동등한 릴리스 구성을 만드는 빌드 스크립트(BuildPipeline에서 Development 플래그를 세우지 않고, IL2CPP와 최소 로그 심벌을 지정)를 준비해 대체합니다.
Godot: 릴리스 내보내기와 커스텀 기능 태그
Godot에서는 내보내기 프리셋이 이 역할을 맡습니다. “Export With Debug”를 끄고(즉 릴리스용 내보내기 템플릿으로) 내보내면, 원격 디버거나 프로파일러 연결 같은 디버그 장치가 빠진 최적화된 바이너리가 됩니다.
측정용으로는 프리셋을 하나 복제하고 커스텀 기능 태그(예: measurement)를 붙여 둡니다. 이 태그는 내보내기 프리셋의 설정 섹션에 적습니다.
# export_presets.cfg (measurement preset section)
custom_features="measurement"
코드 쪽은 이 태그로 분기하므로, 측정용 최소 로그만 이 빌드에서 활성화할 수 있습니다.
// Enable minimal measurement logging only in the measurement build.
if (OS.HasFeature("measurement"))
{
GD.Print($"[measure] frame={Engine.GetFramesDrawn()} fps={Engine.GetFramesPerSecond()}");
}
커스텀 기능 태그는 내보낸 빌드 안에서만 해석됩니다. 에디터에서 실행하는 동안에는 OS.HasFeature("measurement")가 false를 반환하므로, 내보내기 전까지는 이 분기가 죽은 코드처럼 보입니다.
바이너리를 더 작고 빠르게 만들고 싶다면 production=yes로 빌드한 커스텀 내보내기 템플릿으로 교체할 수도 있습니다. 커스텀 템플릿 빌드는 scons platform=windows target=template_release production=yes module_mono_enabled=yes이며, production=yes는 use_static_cpp=yes debug_symbols=no lto=auto의 별칭입니다(Godot 4.x). C# 프로젝트에서 쓰는 템플릿에는 module_mono_enabled=yes가 필수입니다. C# 대응 템플릿은 이 한 줄만으로는 완성되지 않으며, mono glue 생성과 GodotSharp 매니지드 어셈블리 빌드도 필요합니다(절차는 공식 문서를 참조).
측정용 빌드에서 측정할 지표: 프레임 타임, 메모리, 로드 시간, 스토리지 IO
측정용 빌드가 준비되면, 실제 플레이 세션을 통해 숫자를 모읍니다. 봐야 할 지표는 다음과 같습니다.
그리고 가장 중요한 것은, 이 숫자들에 반드시 상황 컨텍스트를 함께 붙이는 것입니다. 구체적으로는 그때의 플레이어 좌표, 카메라 방향, 화면에 표시 중인 맵 / 씬, 빌드 ID, 기기·플랫폼입니다.
컨텍스트가 없는 “프레임 타임이 튀었다”는 기록은 나중에 추적할 방법이 없습니다. 반대로 “build 1042의 desert_ruins, 좌표 (X, Y), 카메라가 북쪽을 향할 때 P95가 튄다”는 정도의 입도로 남아 있으면, 그 이상은 재현 가능한 조사 대상이 됩니다.
컨텍스트야말로 그저 숫자에 불과하던 것을 「조사에 착수할 수 있는 이상」으로 바꾸는 열쇠입니다.
헤드룸은 예산입니다
측정의 목적은 느린 부분을 고치는 것만이 아닙니다. 타깃 하드웨어에서 CPU·GPU·메모리·IO에 아직 여유(헤드룸)가 남아 있다면, 그 여유는 그대로 “쓸 수 있는 예산”입니다.
예를 들어 렌더 스레드에 여력이 있다면, VFX를 더 리치하게 만들거나, 동시 표시 오브젝트를 늘리거나, 스트리밍 거리를 늘리는 방향으로 그 예산을 과감히 배분할 수 있습니다. 최적화는 깎아 내기 위한 작업인 동시에, “얼마나 야심 찬 표현을 쌓을 수 있는가”를 알려 주는 작업이기도 합니다.
측정용 빌드로 정확한 헤드룸을 파악하고 있으면, 이런 판단을 감이 아니라 숫자로 내릴 수 있습니다. 여유가 있는지, 이미 빠듯한지. 그것을 알아야 비로소 게임플레이의 밀도를 의도적으로 설계할 수 있습니다.
이상에서 원인으로
측정용 빌드의 역할은 문제가 “어디서·언제” 발생하는지를 특정하는 것입니다. 원인 자체는 여기서 알 수 없습니다.
예를 들어 “특정 좌표에서 프레임 스파이크가 난다”, “어떤 로드 이후 메모리가 한 단계 올라가서 돌아오지 않는다” 같은 이상이 빌드에서 올라왔다고 합시다. 다음 단계는 바로 그 상황을 Development 빌드에서 정확히 재현하고, 상세 프로파일러로 파고드는 것입니다.
- Unity라면 Unity Profiler와 Memory Profiler
- Unreal Engine이라면 Unreal Insights
- Godot이라면 내장 프로파일러와 모니터(스크립트 계측은 GDScript만 지원하므로, C# 코드를 파고들 때는 .NET 프로파일러를 함께 사용합니다)
여기서 “왜”가 보이기 시작합니다. 어떤 시스템이 그 프레임을 잡아먹고 있는지, 어떤 에셋이 메모리를 붙잡은 채 놓지 않는지. 역할 분담은 단순합니다.
이 2단 구성이 추측에 의존하지 않는 최적화 루프가 됩니다.
Framedash로 측정 루프를 자동화한다
여기까지의 “모은다 → 집계한다 → 이상을 찾는다”는 흐름은, 수작업으로 돌리면 운영 비용이 커집니다. Framedash는 이 루프의 자동화를 담당하는 도구입니다.
FPS·프레임 타임·GPU 시간·메모리 사용량을 게임 맵 위에 셀 단위로 오버레이하는 성능 히트맵을 제공합니다. 기기나 빌드 프로파일로 필터링할 수 있어서, 어떤 기기 구성의 어디가 무거운지 한눈에 파악할 수 있습니다.
각 텔레메트리 이벤트에 플레이어 좌표와 카메라 방향(요·피치)을 붙일 수 있습니다. 카메라 방향은 SDK가 자동으로 수집하므로(옵트아웃 가능), 추가 구현 없이 이 글에서 이야기한 “상황 컨텍스트를 곁들인 측정”을 그대로 실천할 수 있습니다. 임의의 커스텀 메트릭이나 속성을 함께 붙이는 것도 가능합니다. 각 엔진의 SDK 통합 순서는 Unity, UE5, Godot (C#) 문서에 정리되어 있습니다.
또한 build_id별로 프레임 타임·GPU 시간·메모리를 비교하는 빌드 회귀 분석을 갖추고 있으며, 임계값을 넘은 회귀는 Slack·이메일·웹훅으로 알릴 수 있습니다. 릴리스 전에 “이 빌드에서 P95가 나빠졌다”는 것을 알아챌 수 있는 구조입니다.