Android에서 Unity 게임 메모리 최적화

최신 휴대기기의 실제 RAM 용량이 계속 증가하고 있지만, 고해상도 애셋과 복잡한 렌더링 파이프라인으로 인해 게임 메모리 소비가 이 증가세를 앞지르고 있습니다.

과도한 메모리 사용으로 인한 주요 문제

  • 로우 메모리 킬러 (LMK) 종료: OS가 시스템 전반의 메모리 부족을 해결하기 위해 백그라운드 또는 포그라운드 앱을 강제로 닫습니다.
  • 프레임 삭제 및 끊김 현상 (버벅거림): 관리되는 힙 또는 OS 수준 메모리 스왑에서 가비지 컬렉션 (GC)이 자주 급증하면 처리 병목 현상이 발생합니다.
  • 열 조절 및 배터리 소모: 지속적인 메모리 할당, 할당 해제, 메모리 페이지 커밋으로 인해 상당한 CPU 오버헤드가 발생하여 열이 증가하고 배터리가 더 빨리 소모됩니다.

Android 메모리 관리 업데이트

  • Android 17: MemoryLimiter 도입: Android 17에서는 기기별 임계값에 대해 애플리케이션 메모리 소비를 적극적으로 모니터링하는 MemoryLimiter를 도입합니다. 메모리 한도를 초과하는 앱은 시스템 수준에서 즉시 종료됩니다. 이 메커니즘은 기존 LMK보다 더 엄격하므로 최대 메모리 사용량을 관리하는 것이 그 어느 때보다 중요 합니다.

Unity에서 메모리 사용량 줄이기

Unity에서 엔진이 높은 최대 부하를 수용하기 위해 내부 메모리 풀 (네이티브 블록 할당자관리되는 힙)을 확장하면 OS에 즉시 반환하는 대신 이러한 메모리 페이지를 유지합니다. 따라서 대용량 애셋이 언로드된 후에도 기준 상주 메모리 가 계속 증가하여 애플리케이션이 Android OS 프로세스 종료 (LMK 또는 MemoryLimiter 와 같은)에 매우 취약해집니다.

OS 수준 적용을 방지하려면 메모리 최적화를 세 가지 주요 원칙을 통해 접근해야 합니다.

  • 카테고리 A: 최대 메모리 사용량 줄이기
  • 카테고리 B: 불필요한 애셋 및 시스템 공간 삭제
  • 카테고리 C: 불필요한 GC 할당 삭제

카테고리 A: 최대 메모리 사용량 줄이기

Unity의 할당자는 해제된 메모리 페이지를 OS에 즉시 반환하는 대신 재사용을 위해 유지하므로 기준 메모리는 현재 사용량보다는 도달한 최고점을 반영하는 경향이 있습니다. 따라서 사후 정리에 의존하는 것보다 급증을 방지하는 것이 더 효과적입니다.

1. 지나치게 큰 AssetBundle 피하기

Unity의 번들 로드 메커니즘으로 인해 거대한 AssetBundle은 심각한 메모리 증가 및 OOM 비정상 종료로 이어집니다. 효율적인 리소스 관리를 위해 번들을 모듈식으로 유지하세요.

주요 문제

  • 높은 메모리 오버헤드: 단일 소형 애셋을 요청하면 Unity가 헤더, 메타데이터, 스트리밍 버퍼를 포함한 전체 번들 파일을 RAM에 로드해야 합니다.
  • 언로드 트랩: 번들 내의 모든 애셋이 활발하게 사용 중인 경우 전체 번들을 언로드할 수 없으며, 사용되지 않는 데이터가 RAM에 트랩됩니다.

권장사항

  • 번들을 모듈식으로 유지: 장면 또는 수명 주기에 따라 애셋을 논리적으로 그룹화합니다.
  • Unity 6.6+ 팁: 콘텐츠 디렉터리를 사용하여 의도하지 않은 번들 간 종속성을 방지합니다.

2. ScriptableObjects에서 애셋 참조 최적화

ScriptableObject의 직접 UnityEngine.Object 직렬화된 필드는 직접 하드 참조를 만들어 참조된 모든 애셋이 ScriptableObject 자체가 로드되거나 인스턴스화되는 즉시 RAM에 로드되도록 합니다.

// BEFORE: Loading SceneRequiredAssets forces _worldAsset and _spawnSettings into RAM immediately
public class SceneRequiredAssets : ScriptableObject
{
    public string sceneName;
    public Object _worldAsset;
    public Object _spawnSettings;
}

// AFTER: Use AssetReference to enable asynchronous, on-demand loading using Addressables
public class SceneRequiredAssets : ScriptableObject
{
    public string sceneName;
    public AssetReference _worldAsset;
    public AssetReference _spawnSettings;
}

3. 오디오 클립 로드 유형 구성

모든 오디오 클립을 압축 해제된 상태로 메모리에 직접 로드하면 크고 영구적인 메모리 급증이 발생합니다. 오디오 로드 설정은 사용 프로필에 따라 구성해야 합니다.

오디오 카테고리 로드 유형 이유
BGM (배경음악) 스트리밍 메모리 급증을 없애기 위해 작은 버퍼에서 디스크의 오디오를 스트리밍합니다.
긴 SFX 메모리 내 압축 RAM 오버헤드를 낮게 유지하고 재생 중에 오디오를 즉시 압축 해제합니다.
짧고 빈번한 SFX 로드 시 압축 해제 재생 중에 런타임 CPU 오버헤드를 방지하기 위해 로드 시 오디오를 RAM으로 압축 해제합니다.

4. 객체 풀링 및 출시 전략 구현

장면 전환 전반에 걸쳐 객체 풀 내부에 남아 있는 출시되지 않은 인스턴스는 예약된 메모리를 무기한 유지하여 기준 메모리 사용량을 불필요하게 늘립니다.

  • 작업: 장면 전환 또는 활동이 적은 기간에 사용되지 않는 풀링된 객체를 주기적으로 삭제하거나 트리밍하여 Unity가 예약된 공간을 다른 할당에 반환하거나 재사용할 수 있도록 합니다.

카테고리 B: 불필요한 메모리 사용량 삭제

중복 그래픽 저작물과 렌더링 대상 버퍼를 삭제하면 기준 메모리 사용량이 직접 낮아집니다.

1. 렌더 텍스처 및 카메라 깊이 최적화

  • 깊이/스텐실 버퍼 삭제: 색상 데이터만 필요한 렌더 텍스처의 경우 깊이 스텐실 형식을 없음 으로 설정합니다.
  • UI 카메라 깊이 텍스처 사용 중지: 깊이 데이터가 필요하지 않은 UI 카메라의 경우 URP 카메라 설정에서 깊이 텍스처 생성을 사용 중지하여 CopyDepth 패스 및 관련 GPU 텍스처 메모리를 삭제합니다.

2. 텍스처 및 메시 최적화

카테고리 최적화 가이드라인
텍스처 압축 항상 타겟 플랫폼 압축 형식을 적용합니다 (예: Android의 경우 ASTC).
읽기/쓰기 사용 설정됨 필요한 경우가 아니면 사용 중지 상태로 유지합니다. 이 기능을 사용 설정하면 CPU 및 GPU RAM 전반에서 텍스처 메모리가 중복됩니다.
밉맵 UI 텍스처 또는 일정한 카메라 거리에 고정된 객체의 경우 밉맵을 사용 중지하여 텍스처 메모리를 약 33% 절약합니다.
메시 복잡성 불필요한 다각형 수와 꼭짓점 스트림을 줄여 GPU 및 네이티브 메모리 사용량을 줄입니다.

3. 셰이더 배리언트 삭제 및 메모리 최적화

Uber-shader (예: URP Lit Shader)는 #multi_compileshader_feature 키워드를 사용하여 수많은 기능을 캡슐화합니다. 최적화하지 않으면 조합 폭발로 인해 수만 개의 고유한 셰이더 배리언트가 생성되어 빌드 크기가 늘어나고 네이티브 메모리 소비가 많아지며 게임플레이 중에 GPU 드라이버 컴파일이 중단됩니다.

A. 셰이더 배리언트 메모리 오버헤드의 메커니즘

  • 조합 폭발: 가능한 총 배리언트는 추가된 각 키워드 그룹에 따라 기하급수적으로 증가합니다.
  • 청크 할당 아키텍처: Unity는 컴파일된 바이너리 배리언트 청크 (기본값: 4MB)라는 압축된 메모리 블록으로 그룹화합니다.
  • 네이티브 메모리 증가: 런타임 코드가 청크 내에서 단일 배리언트를 요청하면 전체 4MB 청크가 RAM으로 압축 해제 됩니다. 최적화되지 않은 경우 이러한 청크에 패키징된 수천 개의 사용되지 않는 배리언트가 네이티브 메모리를 영구적으로 차지합니다.

B. Unity 기본 제공 다단계 삭제 파이프라인: Unity는 빌드 시 타겟 플랫폼 그래픽 설정 및 사용되지 않는 엔진 기능 (예: 안개, 라이트맵, XR 설정)을 기반으로 불필요한 배리언트를 자동으로 삭제합니다. 또한 shader_feature 배리언트는 키워드가 프로젝트의 어떤 머티리얼에서도 활발하게 사용되지 않는 경우 자동으로 필터링되는 반면, #multi_compile 배리언트는 사용 여부와 관계없이 강제로 포함됩니다.

C. 맞춤 자동 삭제 아키텍처 (IPreprocessShaders) 정적 분석은 런타임 C# 스크립트 (Material.EnableKeyword)를 사용하여 동적으로 수정된 키워드를 감지할 수 없으므로 표준 삭제는 종종 충분하지 않습니다. 실제로 사용된 배리언트만 포함되도록 하려면 QA 테스트 모음 중에 Player.log (편집기 설정에서 셰이더 컴파일 로깅 사용 설정) 또는 프로파일러 추적(Shader.CreateGPUProgram 마커)을 사용하여 배리언트를 수집할 수 있습니다. 그런 다음 편집기 스크립트에서 IPreprocessShaders.OnProcessShader를 구현하여 런타임 중에 실행되지 않은 배리언트를 필터링하고 빌드에 필요한 배리언트만 유지합니다.

카테고리 C: 불필요한 GC 할당 삭제

관리되는 힙의 가비지 컬렉션 (GC) 할당은 메모리 조각화, 축소되지 않는 힙 확장, GC 일시중지 중 심각한 프레임 삭제로 이어집니다.

1. 람다 클로저 할당 방지

람다 표현식이 외부 로컬 변수를 캡처하면 C# 이 힙에 암시적 표시 클래스를 생성합니다. Update 내에서 이를 실행하면 모든 프레임에 클로저 인스턴스가 할당됩니다.

// Bad: Capturing local variable 'targetId' allocates a new closure object on the Heap every frame
void Update()
{
    int targetId = 100;
    Monster target = monsterList.Find(m => m.Id == targetId);
}

// Good 1: Replace with a standard 'for' loop (Recommended: 0 B allocation)
void Update()
{
    int targetId = 100;
    Monster target = null;
    for (int i = 0; i < monsterList.Count; i++)
    {
        if (monsterList[i].Id == targetId)
        {
            target = monsterList[i];
            break;
        }
    }
}

// Good 2: Use a static lambda (C# 9.0+) if no outer variables are captured
Monster target = monsterList.Find(static m => m.Id == 100);

2. stackalloc 및 Span 사용

스택 메모리를 사용하여 수명이 짧은 임시 배열의 힙 할당을 방지합니다.

// Before: Allocates an array on the Heap every call (GC Target)
Vector2[] pos = new Vector2[4];

// After: Utilizes Stack memory using System.Span (0 B Heap Allocation)
System.Span<Vector2> pos = stackalloc Vector2[4];

3. 컬렉션 반복 최적화

LINQ 확장 프로그램 또는 ReadOnlyCollection 접근자로 인해 발생하는 박싱 및 열거자 힙 할당을 방지합니다.

// Before: LINQ Count() causes internal GetEnumerator() heap allocations
bool hasData = component != null && component.parameters.Count(parameter => parameter.overrideState) > 0;

// After: Replaced with indexer and direct loop iteration
bool hasData = HasDataOptimized(component);

private bool HasDataOptimized(TestComponent component)
{
    if (component == null) return false;

    var count = component.parameters.Count;
    for (var i = 0; i < count; ++i)
    {
        if (component.parameters[i].overrideState)
            return true;
    }
    return false;
}

4. 추가 GC 할당 방지 규칙

  • Camera.allCameras는 호출할 때마다 힙에 새 Camera[] 배열을 생성하므로 사용을 피하세요. 대신 카메라 배열을 캐시하고 Camera.GetAllCameras(_allCameras)에 전달합니다.
  • 에서 foreachIReadOnlyList<T> 피하기: 인터페이스를 반복하면 구조체 열거자 박싱이 발생하여 GC 할당이 생성됩니다. 대신 표준 for 루프를 사용하세요.
  • 코루틴 객체 캐시: WaitForSeconds을 반복적으로 인스턴스화하는 대신 yield return new WaitForSeconds(time); 인스턴스를 캐시합니다.
  • 사전의 맞춤 구조체 키: 맞춤 구조체를 Dictionary 키로 사용하면 기본 Equals가 호출되어 객체 박싱이 트리거됩니다. IEqualityComparer<T>를 구현하고 사전 생성자에 전달합니다.
public struct TypeKey
{
    public int v1;
    public int v2;

    public class TypeKeyComparer : IEqualityComparer<TypeKey>
    {
        public bool Equals(TypeKey x, TypeKey y) => x.v1 == y.v1 && x.v2 == y.v2;
        public int GetHashCode(TypeKey obj) => obj.v1.GetHashCode() ^ obj.v2.GetHashCode();
    }
}

// Pass custom comparer during Dictionary initialization to prevent boxing
public readonly Dictionary<TypeKey, int> _typeKeyDictionary = new(new TypeKey.TypeKeyComparer());

5. 일반 및 리플렉션 사용 최소화

일반 메서드는 뛰어난 코드 재사용 및 유지보수 기능을 제공하지만, 이를 과도하게 사용하면 Unity IL2CPP(Intermediate Language to C++) 백엔드 컨텍스트에서 프로젝트에 부정적인 영향을 미칠 수 있습니다.

  • IL2CPP 코드 증가: 모든 고유한 일반 유형 조합에 대해 IL2CPP 는 특수 버전의 코드를 생성합니다. 복잡한 일반 유형을 과도하게 사용하면 생성된 C++ 코드의 '조합 폭발'로 이어져 애플리케이션의 바이너리 크기와 네이티브 메모리 공간이 크게 증가할 수 있습니다.
  • 리플렉션 오버헤드: System.Reflection API와 같은 리플렉션을 사용하는 메서드는 본질적으로 느리고 런타임 중에 힙 할당을 일으키는 경우가 많습니다.
  • 권장사항: 일반 유형을 신중하게 사용하세요. 광범위하고 무분별한 애플리케이션보다는 아키텍처 명확성을 위해 일반 유형을 우선시하세요. 성능이 중요한 경우 구체적인 유형 또는 인터페이스 기반 다형성을 선호하세요. 리플렉션의 경우 업데이트 루프에서 쿼리하는 대신 초기화 중에 MethodInfo 또는 FieldInfo와 같은 결과를 캐시합니다.

6. 누수된 관리되는 셸 방지

MonoBehaviour, Texture, GameObject와 같은 모든 UnityEngine.Object에는 네이티브 C++ 엔진과 통신하는 C# '관리되는 셸' 래퍼가 있습니다.

  • 문제: 관리되는 셸이 정적 참조, 영구 이벤트 구독 또는 정리되지 않은 클로저에 의해 메모리에 보관되는 경우 GC에서 메모리를 회수할 수 없습니다. 네이티브 객체가 소멸되더라도 관리되는 래퍼는 유지되어 관리되는 힙을 늘리는 '고스트' 메모리 누수로 이어집니다.
  • 해결 방법: 항상 강력한 정리 패턴을 구현합니다. 객체를 소멸하거나 장면을 전환할 때는 -= 연산자를 사용하여 이벤트 구독을 명시적으로 취소하고 UnityEngine.Object 유형에 대한 정적 참조를 무효화합니다. 이 정리를 통해 네이티브 엔진이 핸들을 해제한 후 GC가 래퍼를 성공적으로 수집할 수 있습니다.