최신 휴대기기의 실제 RAM 용량이 계속 증가하고 있지만, 고해상도 애셋과 복잡한 렌더링 파이프라인으로 인해 게임 메모리 소비가 이러한 증가세를 앞지르고 있습니다.
과도한 메모리 사용으로 인한 주요 문제
- 메모리 부족 종료 (LMK): OS가 시스템 전반의 메모리 부족을 해결하기 위해 백그라운드 또는 포그라운드 앱을 강제로 닫습니다.
- 프레임 삭제 및 끊김 (jank): 관리형 힙에서 자주 발생하는 가비지 컬렉션 (GC) 스파이크 또는 OS 수준 메모리 스왑으로 인해 처리 병목 현상이 발생합니다.
- 열 조절 및 배터리 소모: 지속적인 메모리 할당, 할당 해제, 메모리 페이지 커밋으로 인해 상당한 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으로 압축 해제합니다. |
로드 유형 외에도 다음 오디오 구성을 적용하여 메모리 오버헤드를 더욱 줄이세요.
- 비방향성 오디오에 Force To Mono 사용 설정: UI 음향 효과, 음성 해설, 3D 공간화된 음향 효과와 같은 많은 오디오 애셋에는 스테레오 채널 분리가 필요하지 않습니다. 이러한 애셋의
AudioClip가져오기 설정에서 Force To Mono를 사용 설정하여 메모리 사용량과 버퍼 사이즈를 절반으로 줄입니다 (50% 감소). - 기기 등급별로 활성 오디오 채널 조절: 각 활성 오디오 음성은
네이티브 DSP 믹서 및 압축 해제 버퍼를 할당합니다. 메모리가 부족한 기기에서는
Unity에서
AudioSettings.Reset을 사용하여 최대 실제 음성을 낮추거나 (예: 32에서 16으로) 서드 파티 오디오 엔진에서 소프트웨어 재생 채널을 제한하는 등 동시에 활성 채널의 최대 수를 줄입니다.
4. 객체 풀링 및 출시 전략 구현
장면 전환 전반에 걸쳐 객체 풀 내부에 남아 있는 출시되지 않은 인스턴스는 예약된 메모리를 무기한 유지하여 기준 메모리 사용량을 불필요하게 증가시킵니다.
- 작업: 장면 전환 또는 활동이 적은 기간에 사용하지 않는 풀링된 객체를 주기적으로 삭제하거나 트리밍하여 Unity가 예약된 공간을 다른 할당에 반환하거나 재사용할 수 있도록 합니다.
카테고리 B: 불필요한 메모리 사용량 삭제
중복 그래픽 저작물과 렌더링 대상 버퍼를 삭제하면 기준 메모리 사용량이 직접적으로 줄어듭니다.
1. 렌더 텍스처 및 카메라 깊이 최적화
- 깊이/스텐실 버퍼 삭제: 색상 데이터만 필요한 렌더 텍스처의 경우 깊이 스텐실 형식을 없음 으로 설정합니다.
- UI 카메라 깊이 텍스처 사용 중지: 깊이 데이터가
필요하지 않은 UI 카메라의 경우 URP 카메라 설정에서 깊이 텍스처 생성을 사용 중지하여
CopyDepth패스 및 연결된 GPU 텍스처 메모리를 삭제합니다.
2. 텍스처 및 메시 최적화
| 카테고리 | 최적화 가이드라인 |
|---|---|
| 텍스처 압축 | 항상 대상 플랫폼 압축 형식 (예: Android의 경우 ASTC)을 적용합니다. |
| 읽기/쓰기 사용 설정됨 | 필요한 경우가 아니면 사용 중지 상태로 유지합니다. 이 기능을 사용 설정하면 CPU 및 GPU RAM 전반에서 텍스처 메모리가 중복됩니다. |
| 밉맵 | UI 텍스처 또는 일정한 카메라 거리에 고정된 객체의 경우 밉맵을 사용 중지하여 텍스처 메모리를 약 33% 절약합니다. |
| 메시 복잡성 | 불필요한 다각형 수와 꼭짓점 스트림을 줄여 GPU 및 네이티브 메모리 사용량을 줄입니다. |
3. 셰이더 배리언트 스트리핑 및 메모리 최적화
Uber 셰이더 (예: URP Lit 셰이더)는 #multi_compile 및 shader_feature 키워드를 사용하여 여러 기능을 캡슐화합니다. 최적화하지 않으면
조합 폭발로 인해 수만 개의 고유한
셰이더 배리언트가 생성되어 빌드 크기가 증가하고 네이티브 메모리
소비가 많아지며 게임플레이 중에 GPU 드라이버 컴파일이 중단됩니다.
A. 셰이더 배리언트 메모리 오버헤드의 메커니즘
- 조합 폭발: 가능한 총 배리언트는 추가된 각 키워드 그룹에 따라 기하급수적으로 증가합니다.
- 청크 할당 아키텍처: Unity는 컴파일된 바이너리 배리언트 청크 (기본값: 4MB)라는 압축된 메모리 블록으로 그룹화합니다.
- 네이티브 메모리 증가: 런타임 코드가 청크 내에서 단일 배리언트를 요청하면 전체 4MB 청크가 RAM으로 압축 해제 됩니다. 최적화되지 않은 경우 이러한 청크에 패키징된 수천 개의 사용하지 않는 배리언트가 네이티브 메모리를 영구적으로 차지합니다.
B. Unity 기본 제공 다단계 스트리핑 파이프라인: Unity는 빌드 시 대상 플랫폼 그래픽
설정 및 사용하지 않는 엔진 기능 (예: 안개, 라이트맵, XR
설정)을 기반으로 불필요한 배리언트를 자동으로 스트리핑합니다. 또한 shader_feature 배리언트는 키워드가 프로젝트의 어떤 머티리얼에서도 활발하게 사용되지 않는 경우 자동으로 필터링되는 반면, #multi_compile 배리언트는 사용 여부와 관계없이 강제로 포함됩니다. 하지만 렌더링 또는 후처리 기능 (예: 추가 조명, 실시간 그림자, 사용하지 않는 UberPost 효과)이 URP 또는 파이프라인 설정에서 사용 설정된 상태로 유지되면 Unity는 콘텐츠에서 실제로 이러한 기능을 사용하는지 확인할 수 없으며 모든 해당 #multi_compile 배리언트를 생성합니다. 사용하지 않는 파이프라인 설정을 감사하고 사용 중지하여 실제 렌더링 요구사항과 일치하도록 하고 불필요한 배리언트 생성을 방지합니다.
C. 맞춤 자동 스트리핑 아키텍처
(IPreprocessShaders) 정적 분석은 런타임 C# 스크립트
(Material.EnableKeyword)를 사용하여 동적으로 수정된 키워드를
감지할 수 없으므로 표준 스트리핑은 종종 충분하지 않습니다. 실제로 사용된 배리언트만 포함되도록 하려면 QA 테스트 모음 중에 Player.log (편집기 설정에서 셰이더 컴파일 로깅 사용 설정) 또는 프로파일러 트레이스(Shader.CreateGPUProgram 마커)를 사용하여 배리언트를 수집할 수 있습니다. 그런 다음 런타임 중에 실행되지 않은 배리언트를 필터링하여 빌드에 필요한 배리언트만 유지하도록 편집기 스크립트에서
IPreprocessShaders.OnProcessShader를 구현합니다.
D. 중복 셰이더 통합: Lit 및 Simple
Lit와 같은 Uber 셰이더에는 여러 #multi_compile 지시어가 포함되어 있으며 여러 배리언트를 생성할 때 상당한
메모리를 소비합니다.
- 동시 사용 프로파일링: 메모리 사용량을 프로파일링하여 두 셰이더가 동시에 상당한 메모리를 소비하는지 확인하고 프로젝트에 실제로 둘 다 필요한지 확인합니다.
- 단일 셰이더 표준화: 물리 기반 렌더링 (PBR) 이 필요하지 않은 경우 중복 Lit 셰이더 사용을 없애고 Simple Lit 로 표준화하도록 애셋을 구성합니다 (예: 내보낸 FBX 모델에 가져오는 동안 의도하지 않게 기본 Lit 머티리얼이 적용되었는지 확인).
카테고리 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)에 전달합니다.foreach에서IReadOnlyList<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.ReflectionAPI와 같은 리플렉션을 사용하는 메서드는 본질적으로 느리고 런타임 중에 힙 할당을 일으키는 경우가 많습니다. - 권장사항: 일반 유형을 신중하게 사용합니다. 광범위하고 무분별한 애플리케이션보다는
아키텍처 명확성을 위해 일반 유형을 우선시합니다. 성능이 중요한 경우 구체적인 유형 또는 인터페이스 기반 다형성을 선호합니다. 리플렉션의 경우 업데이트 루프에서 쿼리하는 대신 초기화 중에
MethodInfo또는FieldInfo와 같은 결과를 캐시합니다.
6. 누출된 관리형 셸 방지
MonoBehaviour, Texture, GameObject와 같은 모든 UnityEngine.Object에는 네이티브 C++ 엔진과 통신하는 C# '관리형 셸' 래퍼가 있습니다.
- 문제: 관리형 셸이 정적 참조, 지속적인 이벤트 구독 또는 정리되지 않은 클로저에 의해 메모리에 보관되는 경우 GC는 메모리를 회수할 수 없습니다. 네이티브 객체가 소멸되더라도 관리형 래퍼는 유지되어 관리형 힙을 증가시키는 '고스트' 메모리 누수로 이어집니다.
- 해결 방법: 항상 강력한 정리 패턴을 구현합니다. 객체를 소멸하거나 장면을 전환할 때는
-=연산자를 사용하여 이벤트 구독을 명시적으로 취소하고UnityEngine.Object유형에 대한 정적 참조를 무효화합니다. 이 정리로 인해 네이티브 엔진이 핸들을 출시한 후 GC가 래퍼를 성공적으로 수집할 수 있습니다.
7. Addressables에 바이너리 카탈로그 사용
Unity 2022 이하에서는 Addressables가 바이너리 카탈로그 (Unity 2023 이상에서 기본값)가 아닌 JSON 카탈로그를 기본값으로 사용합니다. 프로젝트에서 JSON 카탈로그를 사용하는 경우 시작 시 대규모 카탈로그를 로드하면 전체 파일이 단일 대규모 C# 문자열로 읽히고 JSON 파싱 및 Base64 디코딩 중에 수천 개의 관리형 문자열과 임시 byte[] 배열이 할당됩니다. 이로 인해 관리형 힙을 영구적으로 확장하는 대규모 GC 할당 스파이크가 발생하므로 바이너리 카탈로그로 전환하여 컴팩트 데이터를 직접 스트리밍합니다.