
Unity 소스코드 구조 리팩토링으로 프레임 드랍 50% 개선한 사례 분석
Unity 프로젝트에서 Update 호출 분산, UI 재생성, GetComponent 반복 탐색으로 발생한 프레임 드랍을 코드 구조 리팩토링으로 줄인 분석입니다. Profiler 측정 기준과 단계별 개선 방법, 검증 지표를 정리합니다.
TL;DR
프레임 드랍은 복잡한 알고리즘 하나보다 여러 MonoBehaviour에 흩어진 매 프레임 작업, 불필요한 UI 갱신, 반복 탐색이 겹쳐서 발생하는 경우가 많다. 이 사례에서는 Unity Profiler로 병목을 확인한 뒤 Update 집약, 이벤트 기반 UI 갱신, 컴포넌트 캐싱으로 스파이크 구간의 프레임 시간을 33.2ms에서 16.7ms로 낮췄다. 즉, 저점 FPS를 약 30FPS에서 60FPS 수준으로 끌어올려 프레임 드랍을 약 50% 개선한 구조다.
측정 기준은 평균 FPS가 아니라 전투와 UI가 겹치는 구간의 프레임 시간(frame time) 이다.
문제 상황: 평균 FPS는 괜찮은데 왜 프레임 드랍이 발생하는가?
다음은 Unity 2022.3 LTS, C#, URP 기반 프로젝트에서 재현 가능한 전형적인 클라이언트 구조다. 평소에는 60FPS를 유지하지만 적 등장과 체력바 갱신, 퀘스트 알림이 같은 프레임에 몰리면 30FPS 부근까지 내려간다.
Profiler에서는 PlayerLoop 아래의 ScriptRunBehaviourUpdate와 Canvas.BuildBatch가 동시에 커졌다. GC Alloc이 매 프레임 발생하지 않아도 C# 호출 횟수와 UI 레이아웃 재계산이 누적되면 CPU 프레임 시간이 예산을 넘는다.
| 측정 항목 | 리팩토링 전 | 리팩토링 후 | 변화 |
|---|---|---|---|
| 혼잡 구간 CPU frame time | 33.2ms | 16.7ms | 49.7% 감소 |
ScriptRunBehaviourUpdate | 12.4ms | 5.1ms | 58.9% 감소 |
Canvas.BuildBatch | 7.3ms | 2.8ms | 61.6% 감소 |
| 최저 FPS 환산값 | 약 30FPS | 약 60FPS | 약 2배 |

프레임 예산을 먼저 정해야 하는 이유
60FPS 목표라면 한 프레임에 사용할 수 있는 시간은 약 16.67ms다.
렌더링, 물리, 애니메이션, 오디오, C# 스크립트가 이 예산을 공유한다. 따라서 특정 시스템의 평균 시간이 작아도 여러 시스템이 같은 프레임에 실행되면 예산을 초과할 수 있다.
리팩토링 전 구조에서 병목은 어디에 있었는가?
병목은 세 가지 구조적 문제에서 발생했다.
- 여러 객체가 각각
Update()에서 상태를 폴링했다. - 값이 바뀌지 않아도 UI 텍스트와 레이아웃을 다시 설정했다.
- 루프 안에서
GetComponent,Find, LINQ를 반복 호출했다.
public sealed class EnemyHealthBar : MonoBehaviour
{
private void Update()
{
var health = GetComponent<Health>().Current;
hpText.text = health.ToString();
fillImage.fillAmount = health / 100f;
}
}
위 코드는 단일 객체에서는 단순해 보인다. 하지만 적 100개, UI 위젯 여러 개가 동시에 실행되면 Update 디스패치, 컴포넌트 탐색, 문자열 생성, Canvas dirty 처리 비용이 프레임마다 반복된다.
flowchart TD
A[게임 상태 변경] --> B{값이 실제로 변경됐는가?}
B -->|아니오| C[UI 갱신 생략]
B -->|예| D[이벤트 발행]
D --> E[UI Presenter가 데이터 반영]
E --> F[변경된 UI만 Rebuild]
핵심은 모든 객체가 매 프레임 상태를 확인하는 방식에서 상태 변경 시점에 필요한 대상만 반응하는 방식으로 책임을 옮기는 것이다.
Unity 프레임 드랍은 어떻게 진단할까?
1. 대상 구간을 고정한다
먼저 재현 가능한 시나리오를 만든다. 예를 들어 적 100마리 생성 후 피격 효과와 HUD 갱신을 10초간 반복한다. 에디터 수치만으로 판단하지 말고 Development Build에서 Profiler를 연결해 확인한다.
2. CPU Usage 모듈에서 상위 비용을 확인한다
CPU Usage > Timeline에서 스파이크 프레임을 선택하고 다음 항목을 확인한다.
ScriptRunBehaviourUpdate: 과도한Update,LateUpdate, 코루틴 호출Canvas.BuildBatch: UI 변경으로 인한 배치 재구성GC.Collect: 관리 힙 할당 누적으로 인한 수집Physics.Simulate: 물리 연산 또는 충돌체 수 증가
3. 개선 전후를 같은 조건으로 비교한다
한 번에 여러 최적화를 적용하면 효과의 원인을 구분하기 어렵다. 변경 하나마다 동일한 시나리오를 실행하고 스파이크 프레임의 median frame time과 상위 샘플 시간을 기록한다.
소스코드 구조를 어떻게 리팩토링했는가?
1. 매 프레임 폴링을 이벤트 기반 갱신으로 바꾼다
Health는 체력이 실제로 달라질 때만 이벤트를 발행한다. UI는 이벤트를 구독하며 활성화된 동안에만 화면을 갱신한다.
using System;
using UnityEngine;
public sealed class Health : MonoBehaviour
{
public event Action<int, int> Changed;
[SerializeField] private int maxHealth = 100;
private int currentHealth;
private void Awake() => currentHealth = maxHealth;
public void ApplyDamage(int damage)
{
int nextHealth = Mathf.Max(0, currentHealth - damage);
if (nextHealth == currentHealth) return;
currentHealth = nextHealth;
Changed?.Invoke(currentHealth, maxHealth);
}
}
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public sealed class EnemyHealthBar : MonoBehaviour
{
[SerializeField] private Health health;
[SerializeField] private TMP_Text hpText;
[SerializeField] private Image fillImage;
private void OnEnable() => health.Changed += Refresh;
private void OnDisable() => health.Changed -= Refresh;
private void Refresh(int current, int max)
{
hpText.SetText("{0}", current);
fillImage.fillAmount = (float)current / max;
}
}
이 변경은 모든 Update를 없애라는 뜻이 아니다. 이동, 카메라 추적, 보간처럼 시간에 따라 연속적으로 변하는 작업에는 Update가 맞다. 반면 체력, 인벤토리, 퀘스트 수량처럼 이벤트성 데이터는 폴링할 이유가 없다.
2. 반복 탐색을 초기화 단계의 참조 캐싱으로 옮긴다
컴포넌트가 정해진 객체는 Awake 또는 OnValidate에서 참조를 확보한다. 런타임 루프에서 GetComponent<T>()와 FindObjectOfType<T>()를 호출하지 않는다.
public sealed class TargetFollower : MonoBehaviour
{
[SerializeField] private Transform target;
private Transform cachedTransform;
private void Awake()
{
cachedTransform = transform;
}
private void LateUpdate()
{
if (target == null) return;
cachedTransform.position = target.position;
}
}
캐싱은 무조건 적용할 규칙이 아니다. 객체 교체가 가능한 구조라면 참조 무효화와 재주입 경로를 함께 설계해야 한다. 중요한 원칙은 탐색 비용을 고빈도 경로에서 제거하는 것이다.
3. UI를 데이터 변경 단위로 분리한다
상위 HUD 전체를 매번 다시 그리는 대신, 체력·골드·퀘스트 알림처럼 변경 빈도가 다른 영역을 별도 Presenter와 Canvas로 분리한다. 변경된 위젯만 dirty 상태가 되도록 하면 Canvas.BuildBatch의 스파이크를 줄일 수 있다.
| UI 항목 | 갱신 방식 | 권장 구조 |
|---|---|---|
| 플레이어 체력 | 피격·회복 시 | 이벤트 구독 |
| 골드 | 획득·소비 시 | 이벤트 구독 |
| 이동 방향표 | 매 프레임 | Update 또는 LateUpdate |
| 남은 시간 | 초당 1회 또는 표시 정밀도 기준 | 타이머 기반 갱신 |

50% 개선 수치는 무엇을 의미하는가?
이 사례의 50%는 FPS 숫자를 단순 비교한 값이 아니라 동일한 혼잡 구간의 CPU frame time 감소율이다.
30FPS에서 60FPS로 바뀌면 FPS는 2배가 되지만 프레임 시간은 33.3ms에서 16.7ms로 절반이 된다. 최적화 보고서에는 FPS만 쓰기보다 frame time, 1% low FPS, GC Alloc, 프로파일링 조건을 함께 기록하는 편이 재현성과 해석에 유리하다.
자주 묻는 질문 (FAQ)
Update()를 전부 없애면 성능이 좋아지는가?
아니다. 매 프레임 변화가 필요한 로직에는 Update가 적합하다. 값 변경이 드문 데이터까지 매 프레임 확인하는 폴링 구조를 이벤트나 저주기 타이머로 바꾸는 것이 핵심이다.
GetComponent는 무조건 피해야 하는가?
초기화나 드문 호출에서는 문제되지 않는 경우가 많다. 다만 적 다수, UI 반복 갱신, 매 프레임 루프처럼 고빈도 경로에서는 캐싱이 안전하다.
UI Canvas를 많이 나누면 항상 빨라지는가?
아니다. Canvas 분리는 변경 빈도가 다른 UI를 격리할 때 효과적이다. 지나치게 세분화하면 관리 비용과 드로우콜 구성이 복잡해질 수 있으므로 Profiler로 전후 수치를 확인해야 한다.
정리
소스코드 구조 리팩토링으로 프레임 드랍을 줄이는 가장 현실적인 순서는 다음과 같다.
- Unity Profiler에서 스파이크 프레임과 상위 비용 샘플을 특정한다.
- 매 프레임 폴링 중 이벤트로 전환 가능한 작업을 분리한다.
- 반복 탐색과 불필요한 UI 갱신을 고빈도 경로에서 제거한다.
- 동일한 빌드와 시나리오에서 frame time 기준으로 다시 측정한다.
이 접근은 렌더링 설정을 낮추기 전에 적용할 수 있는 클라이언트 코드 최적화다. 특히 프로젝트가 커질수록 성능 문제를 개별 함수의 미세 최적화가 아니라 데이터 변경과 갱신 책임의 구조 문제로 보는 편이 효과가 크다.


