프로파일링 (1) - Unity Profiler와 Frame Debugger - soo:bak
작성일 :
병목을 찾는 도구
모바일 최적화 시리즈에서는 렌더링, 메모리, 물리 등 각 서브시스템의 최적화 원리를 살펴보았습니다. 드로우콜을 줄이는 배칭, GC 스파이크를 막는 오브젝트 풀링, 물리 비용을 낮추는 콜라이더 단순화 — 각 기법의 원리는 명확합니다.
하지만 실전에서는 “어떤 기법을 적용할지”보다 “무엇이 병목인지”를 먼저 파악해야 합니다. 프레임 드롭이 발생할 때, 원인이 같은 픽셀을 여러 번 그리는 오버드로우인지, GC(Garbage Collection) 가 실행되며 프레임 시간이 급등하는 스파이크인지, 물리 연산이 프레임을 소모하는 것인지 구분하지 못하면 최적화 작업은 추측에 기반한 시행착오가 됩니다. GPU 바운드인 게임에서 스크립트를 최적화해도 프레임 시간은 줄어들지 않고, CPU 바운드인 게임에서 셰이더를 단순화해도 마찬가지입니다.
그 진단의 출발점이 프로파일러(Profiler)입니다. 프로파일러는 프레임마다 CPU, GPU, 메모리가 어디에 얼마나 쓰이고 있는지를 수치로 보여주는 도구입니다. 추측 대신 수치에 기반하여, 병목의 위치를 좁히고, 최적화 후 실제로 개선되었는지를 확인할 수 있게 합니다.
이 글에서는 Unity에 내장된 Unity Profiler와 Frame Debugger의 구조를 다룹니다. 각 모듈(CPU, GPU, Memory)의 데이터를 읽는 방법부터, Frame Debugger로 드로우콜(CPU가 GPU에 보내는 개별 렌더링 명령)을 시각적으로 분석하는 방법, 그리고 프로파일러 수치에서 반복적으로 나타나는 성능 패턴을 인식하는 방법까지 순서대로 살펴봅니다.
Unity Profiler 개요
Unity Profiler는 Window → Analysis → Profiler 메뉴에서 열 수 있습니다. 에디터에서 Play 모드로 진입하면, 프로파일러가 실시간으로 프레임별 성능 데이터를 수집하여 그래프와 표로 보여줍니다.
프로파일러 창은 크게 세 영역으로 구성됩니다. 왼쪽 모듈 목록에서 CPU, GPU, Memory 등 분석 대상을 선택하면, 오른쪽 상단의 타임라인 그래프가 해당 모듈의 데이터로 전환됩니다. 타임라인 그래프의 가로축은 프레임 번호, 세로축은 해당 프레임의 소요 시간(밀리초)입니다. 특정 프레임을 클릭하면 하단의 상세 뷰에 그 프레임의 세부 데이터가 표시됩니다.
프로파일러는 에디터에서도 동작하지만, 에디터 자체의 오버헤드가 측정 데이터에 포함됩니다. 인스펙터 갱신, 씬 뷰 렌더링, 에디터 UI 처리 등 게임과 무관한 작업이 게임 로직의 성능 데이터와 섞여서 나타나기 때문입니다. 정확한 성능 측정을 위해서는 실제 모바일 기기에서 프로파일링하는 것이 필수입니다. 모바일 기기를 USB나 Wi-Fi로 연결하여 원격 프로파일링하는 구체적인 방법은 Part 2에서 다룹니다.
CPU 모듈
프로파일러의 기본 구조를 살펴보았으니, 이제 각 모듈의 데이터를 읽는 방법을 모듈별로 살펴봅니다. 게임 로직, 물리 연산, 렌더링 준비 등 대부분의 프레임 작업이 CPU에서 시작되므로, CPU 모듈은 프로파일러에서 가장 먼저 확인하는 모듈입니다. CPU 모듈은 각 프레임에서 어떤 함수가 실행되었고, 각 함수에 얼마나 걸렸는지를 보여줍니다.
Hierarchy 뷰
Hierarchy 뷰는 함수 호출을 트리 형태로 표시합니다. 트리의 최상위에는 Unity 엔진의 메인 루프인 PlayerLoop(빌드 실행 시)이나 EditorLoop(에디터 실행 시)이 루트 항목으로 나타나고, 그 안에서 호출된 함수들이 계층적으로 펼쳐집니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CPU 모듈 — Hierarchy 뷰 예시
함수 이름 Total (ms) Self (ms) GC Alloc
──────────────────────────────────────────────────────────────
▼ PlayerLoop 16.7 0.0 0 B
▼ Update.ScriptRunBehaviour 8.3 0.0 0 B
▼ EnemyManager.Update() 5.1 0.8 0 B
▼ Enemy.UpdateAI() 3.2 1.4 0 B
PathFinding.Calculate() 1.8 1.8 256 B
Enemy.UpdateAnimation() 1.1 1.1 0 B
PlayerController.Update() 2.5 2.5 128 B
UIManager.Update() 0.7 0.7 64 B
▼ PreLateUpdate 3.1 0.0 0 B
...
▼ PostLateUpdate 5.3 0.0 0 B
▼ Rendering 4.8 0.1 0 B
Camera.Render() 4.7 0.3 0 B
...
Hierarchy 뷰에는 두 가지 시간 값이 표시됩니다. Total은 해당 함수와 그 하위에서 호출된 모든 함수의 시간을 합산한 값이고, Self는 해당 함수 자체에서만 소비된 시간입니다. 예를 들어 EnemyManager.Update()의 Total이 5.1ms이고 Self가 0.8ms라면, 나머지 4.3ms는 그 안에서 호출된 Enemy.UpdateAI()나 Enemy.UpdateAnimation() 등 하위 함수에서 소비된 것입니다.
이 두 값의 차이를 이용하면 병목 지점을 좁힐 수 있습니다. Total이 높더라도 Self가 낮다면, 그 함수 자체가 아니라 하위 함수에서 시간이 소비되고 있다는 뜻입니다. 따라서 트리를 더 펼쳐서, Self가 실제로 높은 함수를 찾는 것이 병목 추적의 핵심입니다.
GC Alloc 컬럼
Hierarchy 뷰의 GC Alloc 컬럼은 각 함수가 해당 프레임에서 관리 힙(Managed Heap)에 할당한 바이트 수를 보여줍니다. 관리 힙은 C#의 new 키워드로 생성된 객체, 문자열, 배열 등이 저장되는 메모리 영역입니다. 이 영역의 할당이 누적되어 여유 공간이 부족해지면 가비지 컬렉션(GC)이 실행됩니다. GC가 실행되는 동안에는 모든 관리 스레드가 정지하므로, 프레임 시간이 급격히 늘어나는 스파이크가 발생합니다. 관리 힙과 GC의 동작 원리는 메모리 관리 (1) - 가비지 컬렉션의 원리에서 다룹니다.
GC Alloc이 0이 아닌 함수를 찾아서, 그 할당이 매 프레임 발생하는 것인지 확인합니다. 매 프레임 반복되는 힙 할당은 GC 스파이크의 직접적인 원인입니다. 대표적인 대응 방법으로는, 할당된 객체를 미리 만들어 재사용하는 오브젝트 풀링과, 연산 결과를 저장해두고 반복 할당을 피하는 캐싱이 있습니다.
Timeline 뷰
Hierarchy 뷰가 함수별 비용을 수치로 보여준다면, Timeline 뷰는 시간축 위에서 함수들이 언제, 어떤 스레드에서 실행되었는지를 시각적으로 보여줍니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
CPU 모듈 — Timeline 뷰 예시
메인 스레드 ├─────── A ───────┤├───── B ─────┤
렌더 스레드 ├────── C ────────┤
잡 스레드 1 ├── D ──┤ ├─ E ─┤
잡 스레드 2 ├── F ──┤ ├── G ──────┤
A 스크립트 실행 (Update, 물리 등)
B 렌더링 준비 (컬링, 정렬)
C GPU 명령 제출 (드로우콜 실행)
D 물리 잡 E 잡 F 애니 잡 G 컬링 잡
→ 막대의 길이 = 실행 시간, 스레드별 작업이 병렬로 진행됨
Timeline 뷰에서는 메인 스레드, 렌더 스레드, 잡 스레드(Job Thread)가 가로 행으로 나뉘어 표시됩니다.
잡 스레드는 메인 스레드에서 분리할 수 있는 작업(물리 시뮬레이션, 애니메이션 계산, 컬링 등)을 병렬로 처리하는 워커 스레드입니다.
각 스레드에서 실행되는 작업이 수평 막대로 그려지며, 막대의 길이가 실행 시간에 비례합니다.
이 뷰에서 특히 중요한 것은 스레드 간 병목입니다. 스레드들은 서로 독립적으로 실행되지만, 한 스레드가 다른 스레드의 결과를 기다려야 하는 구간이 생기며, 그 대기 시간이 곧 병목입니다.
Unity에서 렌더 스레드는 메인 스레드가 만든 렌더링 명령을 받아 GPU에 제출하고, GPU가 처리를 마칠 때까지 기다리는 역할을 합니다. 이 구조 때문에 대기 패턴만 보면 병목 지점을 구분할 수 있습니다. 메인 스레드에 Gfx.WaitForPresentOnGfxThread(렌더 스레드의 GPU 명령 처리 완료를 기다리는 마커)가 오래 나타나면, GPU가 아직 이전 프레임의 렌더링을 끝내지 못한 것이므로 GPU 바운드(GPU-bound)일 가능성이 높습니다. 반대로, 렌더 스레드에 Gfx.WaitForCommands(메인 스레드로부터 새 렌더링 명령이 올 때까지 대기하는 마커)가 길게 나타나면, 메인 스레드의 게임 로직이 느린 것이므로 CPU 바운드(CPU-bound)입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
CPU 바운드 vs GPU 바운드 — Timeline에서의 패턴
├───┤ 작업 ░░░ 유휴 (대기)
CPU 바운드:
메인 스레드 ├────────────────────────────────┤
렌더 스레드 ├─────┤░░░░░░░░░░░░░░░░░░░░░░░░░░
→ 렌더 스레드가 유휴: 메인 스레드의 명령을 기다리는 중
GPU 바운드:
메인 스레드 ├─────┤░░░░░░░░░░░░░░░░░░░░░░░░░░
렌더 스레드 ├────────────────────────────────┤
→ 메인 스레드가 유휴: GPU 완료를 기다리는 중
게임 루프 (1)에서 살펴본 것처럼, Unity의 프레임 처리는 스크립트 실행 → 물리 시뮬레이션 → 렌더링 순서로 진행됩니다. Timeline 뷰에서 이 순서가 실제로 어떤 시간 비율로 나뉘는지를 확인하면, 프레임 시간의 대부분을 차지하는 단계가 드러나고, 그 단계를 먼저 최적화 대상으로 삼을 수 있습니다.
GPU 모듈
CPU 모듈이 CPU 측 비용을 함수 단위로 보여준다면, GPU 모듈은 GPU에서 각 렌더링 단계가 얼마나 걸렸는지를 보여줍니다. GPU 시간(ms 단위)이 렌더링 단계별로 분류되어 표시됩니다.
1
2
3
4
5
6
7
8
GPU 모듈에서 확인할 수 있는 주요 항목
Opaque 4.1 ms 불투명 오브젝트
Transparent 2.3 ms 반투명 오브젝트
Shadows 1.2 ms 그림자 렌더링
Post-processing 0.6 ms 후처리 효과
────────────────────────
Total 8.2 ms
GPU 시간이 프레임 예산(목표 프레임 레이트를 유지하기 위해 한 프레임에 허용되는 최대 시간)을 초과하면, 분류별로 어떤 단계가 병목인지 추적합니다.
- Opaque가 높으면 — 불투명 오브젝트의 셰이더 복잡도나 오브젝트 수가 원인일 수 있습니다.
- Transparent가 높으면 — 반투명 오브젝트의 오버드로우(같은 픽셀 위에 반투명 오브젝트가 여러 겹 그려져 GPU가 동일 픽셀을 반복 처리하는 현상)를 의심합니다.
- Shadows가 높으면 — 그림자를 드리우는 광원 수나 Shadow Resolution을 줄이는 것이 효과적입니다.
1
2
3
4
5
6
7
8
9
GPU 시간 해석 기준 (모바일)
항목 양호 주의 위험
─────────────────────────────────────────
GPU 시간 < 8ms 8~16ms > 16ms
※ 60fps 목표 시 프레임 예산 = 16.67ms
※ 30fps 목표 시 프레임 예산 = 33.33ms
※ 기기 성능에 따라 달라질 수 있음
다만, GPU 모듈은 모든 플랫폼에서 동작하지는 않습니다. 특히 모바일 기기에서는 GPU 타이밍 데이터를 지원하지 않는 경우가 많으며, 이때는 GPU 모듈에 데이터가 표시되지 않습니다. 이 경우 GPU 시간은 벤더별 전용 프로파일링 도구(Part 2에서 다룹니다)로 확인해야 합니다.
또한, GPU 모듈은 GPU 시간의 분포만 보여줍니다. 드로우콜 수나 삼각형 수 같은 수량 지표는 제공하지 않으므로, 병목의 구체적인 원인(셰이더 복잡도, 오버드로우, 과도한 삼각형 수 등)을 특정하려면 Rendering 모듈의 수량 지표와 Frame Debugger의 드로우콜 분석을 함께 확인합니다.
Memory 모듈
CPU 모듈과 GPU 모듈이 프레임 시간을 분석하는 도구라면, Memory 모듈은 게임이 사용하는 메모리를 카테고리별로 분류하여 보여주는 도구입니다. 메모리 관리 (2) - 네이티브 메모리와 에셋에서 살펴본 것처럼, Unity 게임의 메모리는 C# 관리 힙과 C++ 네이티브 메모리로 나뉘며, 네이티브 메모리에 에셋(텍스처, 메쉬, 오디오 등)이 로드됩니다. Memory 모듈은 이 구조를 그대로 반영하여, Simple 뷰와 Detailed 뷰 두 가지로 메모리 사용 현황을 보여줍니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Memory 모듈의 Simple 뷰
Total Used Memory: 287 MB
Total Reserved: 342 MB
├── Unity (네이티브) 198 MB
│ Textures 124 MB (156개)
│ Meshes 18 MB (89개)
│ Audio 12 MB (43개)
│ Shaders 8 MB (27개)
│ AnimationClips 6 MB (64개)
│ Materials 3 MB (112개)
│ Other 27 MB
│
├── Mono (관리 힙) 34 MB
│ Used 22 MB
│ Reserved 34 MB
│
├── GfxDriver 41 MB
│
└── Other 14 MB
Simple 뷰에서는 전체 메모리 사용량과 카테고리별 비중을 빠르게 파악할 수 있습니다. 위 예시에서 텍스처가 124MB로 전체 사용량(287MB) 대비 43%를 차지하는데, 모바일 게임에서 텍스처가 메모리의 절반 가까이를 차지하는 것은 일반적입니다.
Detailed 뷰와 스냅샷
Simple 뷰에서 메모리 비중이 높은 카테고리를 확인한 뒤, Detailed 뷰로 전환하면 개별 에셋 수준의 정보를 볼 수 있습니다. “Take Sample” 버튼을 눌러 특정 시점의 메모리 스냅샷을 찍으면, 현재 메모리에 로드된 모든 에셋의 이름, 크기, 참조 횟수가 목록으로 나타납니다.
스냅샷을 씬 전환 전후에 각각 찍으면, 이전 씬의 에셋이 제대로 해제되었는지 확인할 수 있습니다. 씬을 전환했는데 이전 씬의 텍스처가 여전히 목록에 남아 있다면, 해제되지 않은 에셋(메모리 누수)이 존재한다는 뜻입니다.
Memory Profiler 패키지
Unity Profiler의 Memory 모듈은 카테고리별 수치를 보여주지만, 에셋 간의 참조 관계나 메모리 변화 추이를 시각적으로 파악하기는 어렵습니다. Memory Profiler 패키지(Package Manager에서 설치)는 이 한계를 보완하는 도구로, 메모리 스냅샷을 시각적으로 분석할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Memory Profiler의 주요 기능
1. 트리맵 뷰 (Tree Map)
에셋을 유형별/크기별로 사각형 크기에 비례하여 표시
→ 메모리를 많이 차지하는 에셋이 큰 사각형으로 표시됨
→ 시각적으로 "어디에 메모리가 쓰이는지" 즉시 파악 가능
2. 스냅샷 비교 (Diff)
두 시점의 스냅샷을 비교하여 차이를 보여줌
→ 씬 전환 전후, 특정 이벤트 전후의 메모리 변화 추적
→ 새로 할당된 에셋, 해제된 에셋, 크기가 변한 에셋 구분
3. 참조 추적
특정 에셋이 어떤 오브젝트로부터 참조되고 있는지 추적
→ 해제되지 않는 에셋의 참조 경로를 찾아 누수 원인 파악
트리맵 뷰에서 텍스처 영역이 전체의 대부분을 차지한다면, 텍스처 압축과 해상도 최적화가 가장 효과적인 메모리 절감 방법입니다.
같은 트리맵에서 메쉬 영역이 예상보다 크면, 메쉬 임포트 설정에서 Read/Write Enabled 옵션이 불필요하게 켜져 있는지 확인합니다. 이 옵션은 런타임에 스크립트로 메쉬 데이터를 읽거나 수정해야 할 때 사용하는 설정입니다. 켜져 있으면 GPU 메모리에 올라간 메쉬 데이터의 복사본을 CPU 메모리에도 유지하므로, 메쉬 하나당 메모리 사용량이 2배가 됩니다. 런타임에 메쉬를 변형할 필요가 없다면 이 옵션을 끄는 것만으로 메쉬 메모리를 절반으로 줄일 수 있습니다.
다음으로, 스냅샷 비교 기능은 메모리 누수를 추적할 때 유용합니다. 게임플레이 시작 직후와 10분 후의 스냅샷을 비교하여, 시간이 지남에 따라 계속 증가하는 에셋이 있으면 누수를 의심할 수 있습니다.
마지막으로, 참조 추적 기능은 스냅샷 비교에서 발견된 누수 에셋의 원인을 찾을 때 사용합니다. 해제되지 않는 에셋을 선택하면, 해당 에셋을 참조하고 있는 오브젝트의 경로가 트리 형태로 표시됩니다. 예를 들어, 이전 씬의 텍스처가 해제되지 않는다면 참조 추적을 통해 특정 매니저 스크립트가 해당 텍스처의 머티리얼을 여전히 들고 있다는 사실을 확인할 수 있고, 그 참조를 끊으면 에셋이 정상적으로 해제됩니다.
Rendering 모듈과 Physics 모듈
지금까지 CPU, GPU, Memory 모듈로 프레임 시간과 메모리를 분석했습니다. 이 세 모듈이 “어디서 느린가, 메모리가 왜 큰가”에 답하는 도구라면, Rendering 모듈과 Physics 모듈은 렌더링과 물리 각각의 내부 수치를 세분화하여 보여주는 도구입니다.
Rendering 모듈
Rendering 모듈은 CPU 측에서 본 렌더링 통계를 보여줍니다. SetPass 콜 수, 드로우콜 수, 삼각형 수, 정점 수 등을 확인할 수 있습니다. GPU 모듈이 GPU 시간(ms)을 보여준다면, Rendering 모듈은 렌더링에 관련된 수량 정보를 보여줍니다.
1
2
3
4
5
6
7
8
Rendering 모듈 통계 예시
SetPass Calls: 42
Draw Calls: 156
Triangles: 245,000
Vertices: 382,000
Shadow Casters: 12
Visible Skinned Meshes: 8
드로우콜 수는 CPU가 GPU에 보낸 렌더링 명령의 횟수입니다. 렌더 파이프라인 (2) - 드로우콜과 배칭에서 살펴본 것처럼, 드로우콜의 비용은 명령 자체가 아니라 드로우콜 사이에 발생하는 GPU 상태 변경(셰이더, 텍스처, 블렌딩 모드 등의 교체)에서 비롯됩니다.
SetPass 콜 수는 이 상태 변경이 실제로 발생한 횟수입니다. 상태 변경 없이 연속된 드로우콜은 배칭으로 묶을 수 있으므로, SetPass 수가 낮을수록 배칭이 잘 동작하고 있다는 뜻입니다. 예를 들어 드로우콜이 300번이라도 SetPass가 10번이면, 대부분의 오브젝트가 같은 머티리얼을 공유하여 상태 변경 없이 연속 처리되고 있는 상태입니다.
삼각형 수와 정점 수는 해당 프레임에서 GPU가 처리한 지오메트리의 총량입니다. 모바일에서는 프레임당 삼각형 수를 10만~30만 이내로 유지하는 것이 일반적인 기준으로 알려져 있지만, 실제 허용 범위는 타겟 기기의 GPU 성능에 따라 크게 달라집니다. 저사양 기기에서는 10만 이하에서도 부하가 발생할 수 있고, 최신 플래그십 기기에서는 그 이상도 처리할 수 있으므로, 반드시 타겟 기기에서 실측하여 기준을 정해야 합니다.
Rendering 모듈과 별개로, Game View 우측 상단의 Stats 창에서도 배칭 효과를 바로 확인할 수 있습니다. Stats 창은 프레임별 렌더링 통계를 요약하여 보여주는 패널로, Rendering 모듈과 유사한 수치(드로우콜, 삼각형 수 등)를 에디터 플레이 중 실시간으로 표시합니다. 여기에 표시되는 “Saved by batching” 값이 배칭으로 절약된 드로우콜 수입니다. 이 값이 낮으면 배칭이 제대로 동작하지 않는 상태이므로, 렌더 파이프라인 (2) - 드로우콜과 배칭에서 다룬 배칭 조건(같은 머티리얼 등)을 점검합니다.
Physics 모듈
Physics 모듈은 물리 엔진의 프레임별 비용을 보여줍니다. 활성 리지드바디(Rigidbody) 수, 충돌 접점(Contact Point) 수, 물리 시뮬레이션에 소요된 시간 등을 확인할 수 있습니다.
물리 시뮬레이션 시간은 CPU 모듈의 FixedUpdate 영역에서도 확인할 수 있지만, Physics 모듈은 물리 엔진 내부의 세부 항목을 분리하여 보여줍니다.
물리 시뮬레이션은 Broad Phase → Narrow Phase → Solver 순서로 진행됩니다. 활성 리지드바디 수가 많으면 Broad Phase(충돌 가능성이 있는 오브젝트 쌍을 빠르게 걸러내는 단계)의 비용이 올라갑니다. 접점 수가 많으면 Narrow Phase(후보 쌍의 정밀 충돌을 검출하는 단계)와 Solver(충돌 반응과 힘을 계산하는 단계)에 부하가 집중됩니다.
따라서 물리 비용이 높은 프레임에서는 Physics 모듈의 활성 리지드바디 수와 접점 수를 먼저 확인하고, 수치가 높은 항목을 줄이는 것이 직접적인 해결책입니다. 예를 들어, 멀리 있는 오브젝트의 리지드바디를 비활성화하거나, 메시 콜라이더 대신 단순한 콜라이더를 사용하여 접점 수를 줄일 수 있습니다.
Frame Debugger
Profiler가 프레임의 소요 시간을 보여주는 도구라면, Frame Debugger는 프레임의 렌더링 과정을 보여주는 도구입니다. Unity에 내장된 렌더링 디버깅 도구로, Window → Analysis → Frame Debugger에서 열 수 있습니다.
드로우콜 재생
Frame Debugger를 활성화하면 화면이 정지하고, 해당 프레임을 구성하는 드로우콜 목록이 왼쪽 패널에 표시됩니다. 드로우콜을 하나씩 선택하면, 그 드로우콜 시점까지의 화면 렌더링 결과를 단계별로 확인할 수 있습니다.
드로우콜을 하나씩 넘기면, 화면이 실제로 구성되는 순서가 그대로 드러납니다. 불투명 오브젝트가 먼저 그려지고 반투명 오브젝트가 나중에 그려지는 과정, 그림자 패스가 실행되는 시점, 후처리 효과가 적용되는 단계까지 직접 확인할 수 있습니다.
배칭 깨짐 원인 확인
Frame Debugger는 배칭이 깨지는 원인도 직접 보여줍니다. 같은 머티리얼을 사용하는 오브젝트가 배칭되지 않고 별도의 드로우콜로 그려질 때, Frame Debugger는 그 이유를 표시합니다.
렌더 파이프라인 (2) - 드로우콜과 배칭에서 배칭의 원리와 조건을 개념적으로 다루었습니다. Frame Debugger는 그 개념을 실제 프로젝트에서 검증하는 도구입니다. 배칭이 깨지는 오브젝트를 찾으면, 머티리얼을 통합하거나 텍스처 아틀라스(여러 텍스처를 하나의 큰 텍스처에 합친 것)를 사용하거나 메쉬를 합치는 등의 조치를 취할 수 있습니다.
오버드로우 시각화
오버드로우(Overdraw) 시각화는 Scene View의 기능입니다. Scene View 좌측 상단의 Draw Mode 드롭다운에서 “Overdraw”를 선택하면, 같은 픽셀이 여러 번 그려질수록 색이 밝아지는 뷰가 표시됩니다. Frame Debugger에서 드로우콜별 렌더링 결과를 확인하면서, Overdraw 뷰로 겹쳐 그려지는 영역을 함께 파악하면 오버드로우의 원인을 효과적으로 추적할 수 있습니다.
GPU 아키텍처 (2) - 모바일 GPU와 TBDR에서 살펴본 것처럼, 오버드로우는 모바일 GPU에서 프래그먼트 셰이더 예산을 직접 소모합니다. 특히 반투명 오브젝트와 UI는 하드웨어의 히든 서페이스 제거(HSR) 최적화를 받지 못하므로, 오버드로우의 주요 원인이 됩니다. Scene View의 Overdraw 뷰에서 화면 전체가 밝게 표시되는 영역이 넓다면, 불필요한 UI 오브젝트를 비활성화하거나, 파티클의 중첩 영역을 줄이는 등의 조치가 필요합니다.
프로파일러 데이터 읽기 — 실전 흐름
CPU, GPU, Memory 모듈과 Frame Debugger는 각각 독립된 정보를 보여 줍니다. 실제 병목을 찾으려면 이 도구들을 하나의 진단 흐름으로 조합해야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
프로파일링 진단 흐름
1단계 프레임 시간 확인
CPU 모듈에서 프레임 시간 확인
→ 16.67ms(60fps) 또는 33.33ms(30fps) 초과 여부
→ 초과하지 않으면 성능 문제 없음
→ 초과한다면 2단계로
2단계 CPU 바운드 vs GPU 바운드 판별
Timeline 뷰에서 유휴 스레드 확인
→ 렌더 스레드가 유휴 → CPU 바운드 (3A)
→ 메인 스레드가 유휴 → GPU 바운드 (3B)
3A CPU 바운드 분석
Hierarchy 뷰에서 Self 시간이 높은 함수 찾기
GC Alloc 확인
3B GPU 바운드 분석
GPU + Rendering 모듈에서 GPU 시간, SetPass, 삼각형 확인
Frame Debugger에서 배칭, 오버드로우 확인
먼저 프레임 시간이 목표치를 초과하는 프레임을 찾습니다. 모든 프레임을 분석할 필요는 없고, 스파이크가 발생하는 프레임을 클릭합니다. 스파이크 프레임이 프레임 드롭(끊김)의 직접적인 원인이기 때문입니다.
스파이크 프레임을 찾았다면, CPU와 GPU 중 어디가 병목인지 판별합니다. 이 방향을 잘못 잡으면 최적화 효과가 없습니다. GPU 바운드인데 스크립트를 최적화하거나, CPU 바운드인데 셰이더를 단순화해도 프레임 시간은 줄어들지 않습니다. Timeline 뷰에서 렌더 스레드의 실행 시간이 길면 GPU 바운드입니다. 렌더 스레드가 오래 걸린다는 것은 GPU에 제출한 명령의 처리 완료를 기다리고 있다는 뜻입니다. 반대로 메인 스레드가 오래 걸리면, 게임 로직이나 물리 연산 등 CPU 작업 자체가 병목이므로 CPU 바운드입니다.
바운드 유형이 결정되면 구체적인 원인을 좁혀 갑니다. CPU 바운드라면 Hierarchy 뷰에서 Self 시간이 높은 함수를 찾고, GC Alloc 열에서 해당 프레임의 관리 힙 할당량도 함께 확인합니다. GPU 바운드라면 GPU 모듈과 Rendering 모듈에서 GPU 시간, SetPass Calls, 삼각형 수를 확인한 뒤, Frame Debugger에서 배칭 상태와 오버드로우를 점검합니다.
패턴 인식 — 이전 시리즈 개념과의 연결
진단 흐름으로 병목의 위치를 좁혔다면, 이제 프로파일러 수치에서 반복적으로 나타나는 패턴을 읽어낼 차례입니다. 아래에서 다루는 다섯 가지 패턴은 각각 시리즈의 특정 글에서 다룬 원리와 직접 대응되므로, 패턴을 인식하는 것만으로 원인과 해결 방향을 바로 특정할 수 있습니다.
GC 스파이크
CPU 모듈에서 특정 프레임의 시간이 갑자기 급증하고, 그 프레임에서 GC.Collect가 높은 Self 시간을 차지하는 패턴입니다.
매 프레임 관리 힙에 할당이 누적되면, 힙 여유 공간이 부족해진 시점에 GC가 실행되어 프레임 시간이 급등합니다. 메모리 관리 (1) - 가비지 컬렉션의 원리에서 다룬 메커니즘입니다. CPU 모듈 Hierarchy 뷰의 GC Alloc 컬럼에서 매 프레임 할당이 발생하는 함수를 찾아, 오브젝트 풀링이나 캐싱으로 힙 할당을 제거하는 것이 대응 방법입니다.
높은 SetPass
Rendering 모듈에서 SetPass 수가 드로우콜 수에 가까운 패턴입니다. 배칭이 정상적으로 동작하면 여러 드로우콜이 같은 렌더 상태를 공유하므로 SetPass 수는 드로우콜 수보다 훨씬 낮아야 하는데, 두 수치가 비슷하다면 배칭이 거의 동작하지 않고 있다는 신호입니다.
1
2
3
4
높은 SetPass 패턴
드로우콜: 280 SetPass: 245 ← 거의 매 드로우콜마다 상태 변경
오브젝트마다 서로 다른 머티리얼을 사용하거나, 텍스처 아틀라스를 사용하지 않아 배칭이 동작하지 못하는 경우에 이 패턴이 나타납니다. 렌더 파이프라인 (2) - 드로우콜과 배칭에서 다룬 배칭 조건을 점검하여, 머티리얼 통합, 텍스처 아틀라스, SRP Batcher(셰이더 속성을 GPU 메모리에 유지하여 렌더 상태 변경 횟수를 줄이는 배칭 시스템) 활용으로 SetPass 수를 줄입니다. Frame Debugger에서 배칭 깨짐 원인을 직접 확인할 수 있습니다.
오버드로우 과다
GPU 시간이 높고, Scene View의 오버드로우 뷰에서 화면 대부분이 밝게 표시되는 패턴입니다.
1
2
3
4
5
오버드로우 과다 패턴
GPU 시간: 14ms (목표 대비 초과)
Scene View Overdraw: 화면 중앙 영역이 4~5회 이상 겹쳐 렌더링됨
GPU 시간이 높으면서 오버드로우 뷰에서 화면 대부분이 밝게 표시되면, 같은 픽셀을 여러 번 렌더링하여 GPU의 픽셀 처리 능력(필레이트, Fill Rate)을 소진하고 있다는 뜻입니다. 반투명 UI가 화면 전체를 덮거나, 파티클이 겹치는 영역이 넓은 경우가 대표적입니다.
GPU 아키텍처 (2) - 모바일 GPU와 TBDR에서 다룬 것처럼, 모바일 GPU는 히든 서페이스 제거 최적화(Apple GPU의 HSR, ARM Mali의 FPK, Qualcomm Adreno의 LRZ)를 제공하여 불투명 오브젝트에 대해 가려진 픽셀의 프래그먼트 셰이더 실행을 건너뜁니다. 반투명 오브젝트는 뒤의 픽셀과 블렌딩해야 하므로 이 최적화를 받지 못하고, 겹치는 만큼 프래그먼트 셰이더가 반복 실행됩니다. 비활성 UI 요소를 끄거나 파티클 영역을 축소하여 필레이트 부하를 줄여야 합니다.
물리 연산 과다
CPU 모듈에서 Physics.Simulate 또는 FixedUpdate 영역의 Self 시간이 높은 패턴입니다.
1
2
3
4
물리 연산 과다 패턴
Physics.Simulate: 6.2ms
활성 리지드바디 수가 과다하거나 복잡한 메쉬 콜라이더를 사용하면, 위 예시처럼 물리 시뮬레이션 하나가 프레임 시간의 상당 부분을 차지하는 상황이 발생합니다. 물리 최적화 (1) - 물리 엔진의 실행 구조에서 다룬 것처럼, MeshCollider는 삼각형 수에 비례하는 비용이 발생하므로 BoxCollider나 SphereCollider 같은 Primitive 콜라이더로 교체하는 것이 직접적인 해결책입니다. Physics 모듈에서 활성 리지드바디 수와 접점 수를 확인합니다.
물리 최적화 (2) - 물리 최적화 전략에서 다룬 Layer Collision Matrix를 설정하여 불필요한 충돌 검사 쌍을 제거하는 것도 효과적입니다.
렌더링 시간 과다
CPU 모듈에서 Camera.Render 영역의 시간이 높은 패턴입니다. 이름에 “Render”가 들어 있어서 GPU 문제로 오해하기 쉽지만, Camera.Render는 GPU가 실제로 그리기 전에 CPU가 수행하는 준비 작업입니다. 어떤 오브젝트가 카메라에 보이는지 판별하는 컬링, 렌더링 순서 정렬, 드로우콜 제출 등이 여기에 해당하며, 이 영역이 높다면 CPU 측 렌더링 준비가 병목입니다.
1
2
3
4
5
6
7
렌더링 CPU 비용 과다 패턴
Camera.Render: 8.5ms
├── CullScriptable: 3.2ms ← 컬링 비용
├── Render.OpaqueGeometry: 2.8ms
└── Render.TransparentGeometry: 2.5ms
씬에 오브젝트가 지나치게 많으면 컬링 자체의 비용이 높아지고, 컬링을 통과한 오브젝트가 과다하면 정렬과 드로우콜 제출에 CPU 시간이 집중됩니다. 두 경우 모두 CPU가 처리해야 할 오브젝트 수를 줄이는 것이 직접적인 대응입니다.
렌더 파이프라인 (1) - 카메라에서 화면까지에서 다룬 오클루전 컬링으로 가려진 오브젝트를 조기에 제외하면 드로우콜 제출 수가 줄고, LOD(Level of Detail)로 먼 오브젝트의 메쉬를 단순화하면 배칭 효율이 올라가 드로우콜이 감소합니다. 카메라에서 먼 오브젝트를 적극적으로 비활성화하면 컬링 대상 자체가 줄어 컬링 비용도 함께 낮아집니다.
위 다섯 가지 패턴으로 병목의 종류를 특정했다면, 다음 단계는 해당 함수 내부의 어떤 코드가 비용을 유발하는지 좁히는 것입니다.
Deep Profiling
앞서 프로파일러로 병목 함수를 찾는 과정을 살펴보았지만, 그 함수 내부의 어떤 코드가 비용을 유발하는지까지는 기본 프로파일링으로 파악하기 어렵습니다. 기본 프로파일링에서는 Unity 엔진 내부 함수와 MonoBehaviour 콜백(Update, FixedUpdate 등)의 시간만 측정되기 때문입니다. 사용자가 작성한 일반 C# 함수의 내부 호출까지 추적하려면 Deep Profiling 모드를 활성화해야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
기본 프로파일링 vs Deep Profiling
기본 프로파일링:
▼ EnemyManager.Update() 5.1 ms
(내부 호출은 보이지 않음)
Deep Profiling:
▼ EnemyManager.Update() 5.1 ms
▼ ProcessAllEnemies() 3.8 ms
▼ FindNearestTarget() 2.1 ms
CalculateDistance() 0.9 ms
CompareDistances() 1.2 ms
ApplyBehavior() 1.7 ms
UpdateUI() 1.3 ms
Deep Profiling은 모든 함수 진입과 종료에 계측(instrumentation) 코드를 삽입하므로, 게임 전체의 실행 속도가 크게 느려집니다. 프레임 시간이 정상 상태의 5~10배까지 늘어날 수 있으므로, 이 상태에서 측정된 절대적인 시간 수치 자체는 신뢰할 수 없습니다.
Deep Profiling의 용도는 상대적인 비용 비교입니다. 어떤 함수가 전체의 몇 퍼센트를 차지하는지, 어떤 내부 호출이 가장 비싼지를 비율로 파악하는 데 사용합니다.
실기기에서 이 속도 저하가 부담된다면 대안이 있습니다. Unity가 제공하는 C# API인 Profiler.BeginSample / EndSample 마커를 특정 코드 블록의 시작과 끝에 삽입하면, Deep Profiling 없이도 해당 블록의 비용만 프로파일러에 표시됩니다. 전체 게임의 속도 저하 없이 관심 영역만 측정할 수 있으므로, 실기기 프로파일링에서는 이 방법이 더 실용적입니다.
1
2
3
4
5
6
7
8
9
void ProcessEnemies() {
Profiler.BeginSample("ProcessEnemies.FindTarget");
FindNearestTarget();
Profiler.EndSample();
Profiler.BeginSample("ProcessEnemies.ApplyAI");
ApplyBehavior();
Profiler.EndSample();
}
위 코드를 적용하면 프로파일러 Hierarchy에 “ProcessEnemies.FindTarget”과 “ProcessEnemies.ApplyAI”가 별도 항목으로 표시됩니다.
프로파일링 시 주의 사항
도구 사용법을 숙지하더라도, 측정 환경이 수치를 왜곡하면 잘못된 결론에 도달할 수 있습니다.
에디터 오버헤드
에디터에서 프로파일링하면, 에디터 자체의 연산이 데이터에 포함됩니다. Inspector 갱신, Scene 뷰 렌더링, 에디터 스크립트의 OnValidate 호출 등이 CPU 시간에 섞이기 때문입니다. 에디터에서 16ms를 초과하더라도, 실기기에서는 10ms 이내일 수 있습니다. 반대로 에디터에서 문제없어 보이더라도 저사양 모바일 기기에서는 프레임 드롭이 발생할 수 있습니다.
스크립트 디버깅 오버헤드
실기기 프로파일링에는 Development Build를 켜야 합니다. 프로파일러가 빌드에 연결되려면 이 설정이 필수입니다.
단, Build Settings에서 함께 표시되는 Script Debugging 옵션은 꺼야 합니다. Script Debugging이 켜져 있으면, Unity의 스크립팅 백엔드인 IL2CPP(C# 코드를 C++로 변환한 뒤 네이티브 컴파일러로 최적화하는 과정)가 브레이크포인트 지원을 위해 최적화를 제한하고 디버깅용 검사 코드를 추가하므로, 스크립트 실행 시간이 실제보다 부풀려집니다.
VSync와 프레임 레이트 제한
프로파일러에서 프레임 시간이 정확히 16.67ms나 33.33ms에 고정되어 나타나면, 실제 병목이 아니라 VSync(화면 주사율에 프레임을 동기화하는 설정)나 Application.targetFrameRate에 의한 대기 시간일 수 있습니다. VSync가 켜진 상태에서는 GPU가 일찍 작업을 마쳐도 다음 디스플레이 갱신 주기까지 대기하므로, 프로파일러에 실제 렌더링 비용보다 긴 프레임 시간이 표시됩니다. 정확한 GPU 병목 분석을 위해서는 프로파일링 시 VSync를 끄거나(QualitySettings.vSyncCount = 0), Timeline 뷰에서 대기 마커(WaitForTargetFPS, Gfx.WaitForPresentOnGfxThread)를 구분하여 실제 작업 시간만 확인해야 합니다.
프레임 평균 vs 스파이크
평균 프레임 시간이 16ms 이내이더라도, 간헐적으로 50ms를 넘는 스파이크가 발생하면 사용자는 끊김을 체감합니다. 프로파일러 그래프에서는 평균이 아닌 최악의 프레임을 찾아 분석합니다. GC 스파이크, 에셋 로딩, 물리 충돌 폭주 등이 간헐적 스파이크의 대표적인 원인입니다.
마무리
- CPU 모듈의 Hierarchy 뷰에서 Self 시간이 높은 함수를 찾아 병목을 좁힙니다
- Timeline 뷰에서 스레드 대기 패턴으로 CPU 바운드와 GPU 바운드를 구분합니다
- GPU 모듈에서 렌더링 단계별 GPU 시간을, Rendering 모듈에서 드로우콜·SetPass·삼각형 수를 확인합니다
- Memory 모듈과 Memory Profiler 패키지로 에셋별 메모리 사용량과 누수를 추적합니다
- Frame Debugger에서 드로우콜 순서와 배칭 깨짐 원인을 시각적으로 확인합니다
- GC 스파이크, 높은 SetPass, 오버드로우, 물리 비용, 렌더링 CPU 비용 — 다섯 가지 패턴으로 병목의 종류를 특정합니다
- Deep Profiling으로 함수 내부의 호출별 비용을 확인하고, Profiler.BeginSample/EndSample로 관심 영역만 측정합니다
- 실기기 프로파일링 시 Development Build는 켜되 Script Debugging은 끄고, 평균이 아닌 최악의 프레임을 기준으로 분석합니다
프로파일러는 최적화의 출발점입니다. 수치가 있어야 병목의 위치를 구분할 수 있고, 수정 후 실제로 개선되었는지도 확인할 수 있습니다.
Unity Profiler와 Frame Debugger는 내장 범용 도구로서 대부분의 병목 진단에 충분하지만, 모바일 GPU의 타일 효율, 메모리 대역폭, 셰이더 사이클 수 같은 하드웨어 수준의 상세 정보는 제공하지 않습니다. GPU 아키텍처 (2) - 모바일 GPU와 TBDR에서 다룬 타일 기반 렌더링의 효율을 실제로 측정하려면, GPU 벤더가 제공하는 전용 프로파일링 도구가 필요합니다. Part 2에서 Android와 iOS의 벤더별 프로파일링 도구, 원격 프로파일링 워크플로우, 모바일 성능 테스트 방법론을 다룹니다.
관련 글
시리즈
- 프로파일링 (1) - Unity Profiler와 Frame Debugger (현재 글)
- 프로파일링 (2) - 모바일 프로파일링
전체 시리즈
- 게임 루프의 원리 (1) - 프레임의 구조
- 게임 루프의 원리 (2) - CPU-bound와 GPU-bound
- 렌더링 기초 (1) - 메쉬의 구조
- 렌더링 기초 (2) - 텍스처와 압축
- 렌더링 기초 (3) - 머티리얼과 셰이더 기초
- GPU 아키텍처 (1) - GPU 병렬 처리와 렌더링 파이프라인
- GPU 아키텍처 (2) - 모바일 GPU와 TBDR
- Unity 렌더 파이프라인 (1) - Built-in과 URP의 구조
- Unity 렌더 파이프라인 (2) - 드로우콜과 배칭
- Unity 렌더 파이프라인 (3) - 컬링과 오클루전
- 스크립트 최적화 (1) - C# 실행과 메모리 할당
- 스크립트 최적화 (2) - Unity API와 실행 비용
- 메모리 관리 (1) - 가비지 컬렉션의 원리
- 메모리 관리 (2) - 네이티브 메모리와 에셋
- 메모리 관리 (3) - Addressables와 에셋 전략
- UI 최적화 (1) - 캔버스와 리빌드 시스템
- UI 최적화 (2) - UI 최적화 전략
- 조명과 그림자 (1) - 실시간 조명과 베이크
- 조명과 그림자 (2) - 그림자와 후처리
- 셰이더 최적화 (1) - 셰이더 성능의 원리
- 셰이더 최적화 (2) - 셰이더 배리언트와 모바일 기법
- 물리 최적화 (1) - 물리 엔진의 실행 구조
- 물리 최적화 (2) - 물리 최적화 전략
- 파티클과 애니메이션 (1) - 파티클 시스템 최적화
- 파티클과 애니메이션 (2) - 애니메이션 최적화
- 프로파일링 (1) - Unity Profiler와 Frame Debugger (현재 글)
- 프로파일링 (2) - 모바일 프로파일링
- 모바일 전략 (1) - 발열과 배터리
- 모바일 전략 (2) - 빌드와 품질 전략