-
Unity 스크립트 최적화게임 클라이언트 개발/Unity 2023. 11. 7. 01:07
#️⃣ 들어가며
유니티에서 사용하는 C#은 프로그래머가 직접 메모리를 해제하는 C++과 달리 메모리 해제를 Garbage Collector(통칭 GC)에 의해 수행된다. GC의 메모리 해제 로직(Mark and Sweep)은 일정 주기마다 이루어지는데 이때 해제해야 하는 메모리가 많다면 성능에 영향을 주며, 이를 Spark 현상이라고 한다. 특히, 실시간 반응이 중요한 게임에서 스파크 현상이 많다면 게임이 뚝뚝 끊기는 현상이 발생하여 게임 플레이에 불쾌한 경험을 제공한다. 이번 글에선 이를 방지하기 위한 GC 최적화에 대한 내용을 정리하며, 이외의 최적화 방법도 소개한다.
Unity Profiler 중 Garbage Collector
1️⃣ 객체 생성을 최소화한 GC 최적화
✅ GetComponent 대신 TryGetComponent 사용하기
GetComponent는 할당 성공 여부에 상관없이 GC Allocation이 발생한다. 할당이 실패하더라도 Unity에서는 "Fake Null" 오브젝트를 할당하기 때문이다. TryGetComponent는 GC Allocation으로부터 자유로우며 할당 성공 여부를 반환받아 예외 처리 로직 등에서 사용할 수 있다.
private GameObject playerObj; // Player 컴포넌트가 존재하지 않더라도 GC Allocation 발생 // Player player = playerObj.GetComponent<Player>(); // Player 컴포넌트가 없다면 GC Allocation이 발생하지 않음 // 또한 해당 컴포넌트가 없을 경우의 예외 처리 로직을 작성하기 용이함 if (playerObj.TryGetComponent(out Player player) == true) { player.Init(); } else { player = playerObj.AddComponent<Player>(); player.Init(); }
✅ Object.name, GameObject.tag 사용을 지양하기
두 프로퍼티의 내부 구현을 살펴보면, 동일하게 GetStringAndDispose 함수를 통해 문자열을 반환한다. 해당 함수는 new 키워드를 사용하여 문자열을 힙에 할당하므로 가비지를 생성한다. GameObject.tag 프로퍼티의 경우 CompareTag 함수를 사용하여 비교하면 가비지 생성을 방지할 수 있으나, Object.name 프로퍼티는 다른 대안이 없으므로 주의해서 사용해야 한다.
public unsafe static string GetStringAndDispose(ManagedSpanWrapper managedSpan) { if (managedSpan.length == 0) { return (managedSpan.begin == null) ? null : string.Empty; } string result = new string((char*)managedSpan.begin, 0, managedSpan.length); BindingsAllocator.Free(managedSpan.begin); return result; }
✅ 코루틴 역시 객체이다
코루틴을 사용할 때 StartCoroutine 함수를 활용한다. 이 때, Coroutine 타입의 객체를 생성하고 반환하므로, 역시나 가비지를 생성하게 된다. 따라서, 매 프레임 실행되는 코루틴의 경우 코루틴이 아닌 Update 문을 활용하여 로직을 작성해야 가비지 생성을 방지할 수 있다.
또한, WaitForSeconds 등의 Yield Instruction 객체를 매번 new 키워드로 생성한다면 이 역시 GC의 대상이 되므로, 미리 캐싱해서 재사용하는 것이 바람직하다.
// Bad case private IEnumerator SomeCoroutine() { while(true) { // ... yield return new WaitForSeconds(0.01f); } } // Good case private IEnumerator SomeCoroutine() { var wfs = new WaitForSeconds(0.01f); while(true) { // ... yield return wfs; } }
이를 보다 효율적으로 관리하기 위해, 자주 사용되는 지연 시간을 정적 캐싱하여 제공하는 전용 유틸리티 클래스를 정의해 두면 실수 없이 일관된 사용이 가능하다.
public class YieldUtility { #region Variables public static readonly WaitForFixedUpdate WaitForFixedUpdate = new WaitForFixedUpdate(); private static readonly Dictionary<float, WaitForSeconds> waitForSeconds = new(); #endregion Variables #region Method public static WaitForSeconds WaitForSeconds(float sec) { if (waitForSeconds.ContainKey(sec)) return waitForSeconds[sec]; WaitForSeconds wfs = new(sec); waitForSeconds.Add(sec, wfs); return wfs; } #endregion Method }
✅ 필요하지 않은 경우, 리턴하지 않기
함수를 호출하고 그 리턴값을 사용하지 않는 경우, 함수의 반환 타입을 void로 지정하는 것이 바람직하다. 리턴 연산 자체도 성능 비용을 수반하며, 특히 클래스 인스턴스나 컬렉션처럼 참조형 데이터를 반환하는 경우, 해당 객체가 GC 힙에 할당되어 불필요한 메모리 관리 대상이 될 수 있다. 또한, void 메서드는 JIT 컴파일러 수준에서 더 효율적인 최적화가 가능하며, 불필요한 레지스터 이동이나 참조 유지 등이 생략될 수 있다.
// Bad case public List<int> SomeMethod() { values.Clear(); return values; } // Good case public void SomeMethod() { values.Clear() }
✅ 동적 객체 생성 최소화 및 재사용
new 키워드로 생성된 객체는 힙에 할당되며, 이후 더 이상 참조되지 않을 때 GC에 의해 자동 수거된다. 따라서, 객체를 매번 생성하기보다는 한 번 생성한 객체를 재사용하는 것이 권장된다. 이를 가장 효과적으로 적용할 수 있는 대표적인 사례가 바로 게임 오브젝트 생성/삭제이다.
Unity 프로파일러를 통해 확인하면, Instantiate와 Destroy는 상당한 비용이 소모되는 연산이며, 매 프레임 단위로 반복될 경우 성능 저하가 두드러진다. 특히 총알과 같이 오브젝트의 생성 및 파괴가 빈번하게 발생할 경우, 오브젝트 풀링을 통해 활성화/비활성화하여 재사용하는 방식이 효율적이다.
// Bad case (매번 생성 및 파괴) GameObject bullet = Instantiate(bulletPrefab); Destroy(bullet, 2f); // Good case (오브젝트 풀링 기법 적용) GameObject bullet = bulletPool.Pop(); bullet.SetActive(true); // ... bulletPool.Push(bullet);
✅ 구조체 사용하기
클래스는 참조를 위해 8 ~ 24 바이트의 추가적인 메모리를 필요로 하며, 구조체와 달리 참조 타입의 객체로 스택이 아닌 힙에 저장된다. 따라서 더 이상 참조되지 않는 경우 GC의 수거 대상이 된다. 그렇다고 모든 클래스를 구조체로 대체하기엔 참조 타입의 장점 또한 무시할 수 없다. 어떤 방식을 사용할 지 잘 모르겠다면 다음 가이드라인을 따라 결정하도록 하자.
- 16 bytes 이하의 데이터 클래스는 구조체로 선언
- 생성 / 해제가 자주 일어날 경우 구조체로 선언
- 생성 / 해제보다 전달(매개변수, 리턴)이 자주 일어날 경우 클래스로 선언하거나 매개변수 한정자 in을 사용
✅ 컬렉션 재사용하기
List, Dictionary와 같은 자료구조를 사용할 때 new 키워드를 사용하기 때문에 이 역시 GC의 대상이 된다. 반복해서 사용하는 자료구조의 경우 캐싱하여 재사용하는 것이 좋다.
List<Transform> transformList = new List<Transform>(); private void SomeMethod() // 여러 번 호출되는 메소드 { //List<Transform> transformList = new List<Transform>(); transformList.Clear(); transformList.Add(...); // ... }
✅ List 사용 시 주의할 점
C#의 가변 배열인 List<T>는 내부적으로 배열로 구현되어 있다. 그리고 이 배열은 C++의 Vector와 유사하게 원소를 추가할 때 배열이 가득찬다면 두 배 크기의 새로운 배열을 생성하여 원소들을 복제한다. 그래서 리스트를 생성할 때 미리 필요한 크기를 알고 있다면 생성자를 통해서 크기를 미리 지정하는 것이 좋다.
List<int> elements = new List<int>(200);
✅ StringBuilder 사용하기
String Concatenation(연결) 또는 String Formatting이 자주 발생하는 경우 StringBuilder를 사용하는 것이 좋다. 상수 문자열은 가비지를 생성하지 않지만, 런타임에 문자열끼리 연결하거나 스트링 포멧팅을 통해 문자열 연산을 수행하는 경우 가비지가 생성되기 때문이다.
string a = "a"; string b = "b"; string c = "c"; string c = a + b; string formatted = string.Format("{0} + {1} = {2}", 123, 456, 789);
반면, StringBuilder는 내부적으로 char 배열을 사용하고 문자를 직접 삽입 및 삭제하기 때문에 string처럼 불변 객체를 생성하지 않는다. 다만 StringBuilder 역시 객체이므로 한 번만 생성하여 Clear()로 내부를 초기화하면서 재사용해야 한다.
StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append(123) .Append(" + ") .Append(456); string sbString = stringBuilder.ToString(); stringBuilder.Clear();
✅ LINQ 사용 시 주의할 점
LINQ의 대부분 연산자는 중간 버퍼(일종의 배열)을 생성하며 이는 모두 GC의 대상이다. LINQ를 사용해야 할 지 고민이 된다면 다음 가이드라인을 적용해보자.
- 빠르게 구현해야만 하는 상황(프로토 타입 개발 등) -> LINQ 사용
- 성능에 민감하지 않은 코드(에디터 전용 코드 또는 작은 규모의 코드) -> LINQ 사용
- LINQ의 편의성 또는 가독성이 반드시 필요 -> LINQ 사용
- 매 프레임 호출되거나, 성능에 민감한 부분 -> LINQ 사용하지 않음
2️⃣ Hierarchy 구조 탐색 지양
✅ GetComponent, Find 사용 줄이기
GetComponent, Find, FindObjectOfType 등의 함수는 Hierarchy 내에 오브젝트를 탐색하기 때문에 비싼 비용이 들어간다. Update와 같이 자주 호출되는 함수에서 사용할 경우 게임이 느려지는 모습을 관찰할 수 있기에 객체 참조가 필요하다면 해당 객체를 필드에 캐싱 후 사용해야 한다.
// Bad case // private void Update() // { // GetComponent<Rigidbody>().AddForce(Vector3.right); // } // Good case private Rigidbody rb; private void Awake() { rb = GetComponent<Rigidbody>(); } private void Update() { rb.AddForce(Vector3.right); }
✅ Transform 변경은 한 번에
position, rotation, scale을 한 함수 내에서 여러 번 변경할 경우, 그 때마다 Transform이 변경되며 모든 자식 오브젝트에서도 연산이 수행된다. 따라서, 벡터로 미리 모든 변환을 담아두고, 최종 계산 이후 Transform을 단 한 번만 변경하는 것이 좋다. position과 rotation을 모두 변경하는 경우 SetPositionAndRotation을 사용하면 된다.
✅ 불필요하게 부모 자식 구조 늘리지 않기
Hierarchy가 너무 복잡할 경우 빈 오브젝트를 사용하여 정리하는 경우가 많다. 하지만, 자식 오브젝트의 Transform은 부모 오브젝트의 Transform에 종속적이기 때문에 부모 오브젝트의 Transform에 '변경'이 발생한 경우 모든 자식 오브젝트의 Transform도 변경되어 성능에 악영향을 끼칠 수 있다. 따라서 부모-자식 관계는 필요한 만큼만 최소한으로 구성해야 한다.
3️⃣ 함수 호출 비용 최적화
✅ 비어있는 유니티 이벤트 함수 방치하지 않기
유니티 이벤트 함수(Ex. Awake, Start, ...)는 스크립트 내 작성되있는 것만으로도 호출되므로 비어있는 경우 지워야 한다.
protected virtual 등으로 지정하는 경우에도 비워놓을 가능성이 있다면 지양하는 것이 좋다.
✅ 함수 호출 줄이기
함수는 호출하는 것 자체만으로도 성능에 영향을 준다. 함수의 결과값이 범위 내에서 항상 같은 경우 지역 변수나 필드에 함수 호출
로 값을 얻어 재사용해야 한다.
void Update() { bool isTransformFullyActive = IsFullyActive(transform); // 블록 내에서 isTransformFullyActive 재사용 } bool IsFullyActive(Transform tr) => transform.gameObject.activeSelf && transform.gameObject.activeInHierarchy;
✅ 참조 캐싱하기
프로퍼티는 필드가 아닌 필드처럼 호출할 수 있는 함수이다. 내부적으로는 Setter, Getter로 구성되있으므로 호출 시 함수 호출만큼의 오버헤드가 발생한다. 자주 호출하는 프로퍼티, 참조들은 최대한 해당 타입 그대로 필드에 캐싱해서 사용해야 한다.
private Transform charTransform; private void Awake() { charTransform = transform; } private void Update() { // _ += transform.position.x; _ += charTransform.position.x; _.SetParent(charTransform.parent); }
✅ 빌드 이후 Debug.Log() 사용하지 않기
Debug의 함수들은 에디터에서 디버깅을 위해 사용한다. 하지만 빌드 이후에도 호출되어 성능에 영향을 준다. Debug 클래스를 에디터 전용으로 래핑하여 사용하면 이런 경우를 방지할 수 있다.
public static class Debug { [Conditional("UNITY_EDITOR")] public static void Log(object message) => UnityEngine.Debug.Log(message); }
4️⃣ Scriptable Object를 활용한 공용 데이터 관리
✅ ScriptableObject 활용하기
게임 내 항상 공통으로 참조하는 변수를 사용할 경우, 각 객체의 필드로 사용하면 동일한 데이터가 객체 수 만큼의 메모리를 차지한다. 이를 ScriptableObject로 공유하면 데이터는 객체 수와 상관없이 하나만 존재하여 메모리를 절약할 수 있다. (경량 패턴, Flyweight Pattern)
public class GameUnit : MonoBehaviour { /* 개별 데이터 필드 */ private float hp; private float mp; // 공유 데이터 필드(Scriptable Object) private GameUnitData data; private void Awake() { /* 게임 시작 시 초기 HP, MP 값을 최대치로 설정 */ hp = data.maxHp; mp = data.maxMp; } } public class GameUnitData : ScriptableObject { public float maxHp = 100; public float maxMp = 50; }
5️⃣ 연산 성능 최적화
✅ 비싼 수학 계산 피하기
나눗셈보다 곱셈이 훨씬 빠른 연산이라는 점은 널리 알려진 사실이므로 곱셈으로 대체할 수 있다면 곱셈을 이용하자.
// result = 1.0f / 2.0f; result = 1.0f * 0.5f;
다만 나눗셈이 반드시 필요한 경우도 있을 것이다. 잘 모르겠다면, 다음 가이드라인을 따라 나눗셈을 사용하자.
- 만약 b값이 자주 변할 경우, 나눗셈 사용
- b값이 상수일 경우, c = 1 / b 상수 또는 변수를 선언하여 a * c를 항상 사용
- b값이 일정한 주기로 바뀌는 경우, 바뀌는 순간마다 c = 1 / b로 캐싱하고 a * c 사용
수학 계산을 위한 라이브러리로 System.Math와 UnityEngine.Mathf가 존재한다. 다만 UnityEngine.Mathf는 System.Math를 랩핑한 구조체이기 때문에 속도적인 측면에선 System.Math가 빠르고, 직접 구현하는 것이 더 빠르다. 하지만 성능 차이는 크게 나지 않으므로 자주 사용되는 연산이나 성능에 민감하다면 한 번쯤 고려해보자.
using System; namespace UnityEngine; // // 요약: // A collection of common math functions. public struct Mathf { // // 요약: // Returns the absolute value of f. // // 매개 변수: // f: public static float Abs(float f) { return Math.Abs(f); } }
✅ 벡터 연산 시 주의 사항
벡터와 스칼라의 연산은 스칼라부터 모두 계산한 후 벡터와 계산하는 것이 성능 상 매우 유리하다.
// 벡터가 앞에 있을 경우 Vector3 t_vector = vec * a * b; // 실제로 일어나는 연산 Vector3 temp = new Vector3(vec.x * a, vec.y * a, vec.z * a); Vector3 t_vector = new Vector3(temp.x * b, temp.y * b, temp.z * b) // 순서를 바꿀 경우 Vector3 t_vector = a * b * vec; // 실제로 일어나는 연산 float temp = a * b; Vector3 t_vector = new Vector3(vec.x * temp, vec.y * temp, vec.z * temp);
'게임 클라이언트 개발 > Unity' 카테고리의 다른 글
Animation Missing! 해결법 (0) 2024.04.12 플레이어 시야 FOV 구현 (0) 2023.12.13 길찾기 알고리즘과 최적화 (0) 2023.11.27 UGUI의 성능 최적화 (0) 2023.11.08