모바일 게임 메모리 최적화: 유니티(Unity) 에셋번들(AssetBundle) & 어드레서블(Addressables) 언로드(Unload) 누수 해결법
모바일 게임 개발에서 가장 치명적인 기술적 결함 중 하나는 장시간 플레이 시 앱이 경고 없이 강제 종료되는 OOM(Out of Memory) 크래시입니다. 모바일 운영체제(Android LMK, iOS Jetsam)는 백그라운드 프로세스와 활성 앱의 물리 RAM 사용량을 실시간으로 감시하며, 지정된 임계치를 초과하는 순간 즉각 프로세스를 강제 종료합니다. 유니티에서 대용량 리소스를 효율적으로 관리하기 위해 에셋번들(AssetBundle)과 어드레서블 에셋 시스템(Addressable Assets System)을 도입하지만, 정작 로드된 에셋의 라이프사이클 관리와 언로드(Unload / Release) 메커니즘을 정확히 이해하지 못하면 사용하지 않는 텍스처, 사운드, 애니메이션이 메모리에 영구 고착되는 심각한 누수가 발생합니다. 본 포스팅에서는 에셋번들과 어드레서블의 참조 카운팅(Reference Counting) 작동 원리부터 실무에서 빈번히 발생하는 4대 누수 안티패턴, 그리고 안전한 해제 아키텍처와 프로파일링 추적법까지 상세히 다룹니다.
1. 에셋번들과 어드레서블의 메모리 적재 및 참조 카운팅 원리
전통적인 Resources.Load 방식은 프로젝트 빌드에 모든 에셋이 포함되고 경로 문자열로 로드되어 런타임 메모리 관리가 극히 비효율적이었습니다. 이를 개선한 에셋번들과 어드레서블은 에셋을 독립된 아카이브로 묶고 동적으로 메모리에 올립니다.
1.1 어드레서블의 참조 카운팅(Reference Counting) 메커니즘
- 참조 증가 (+1):
Addressables.LoadAssetAsync<T>또는Addressables.InstantiateAsync를 호출할 때마다 해당 에셋과 이를 포함하는 에셋번들의 참조 카운트가 1씩 증가합니다. - 참조 감소 (-1):
Addressables.Release(handle)또는Addressables.ReleaseInstance(instance)를 호출하면 참조 카운트가 1 감소합니다. - 실제 메모리 해제 조건: 해당 번들 내 모든 에셋의 참조 카운트가 정확히 0에 도달해야만 에셋번들이 메모리(RAM/VRAM)에서 실제로 언로드(Unload)됩니다. 단 하나의 에셋이라도 카운트 1을 유지하고 있다면 번들 전체가 메모리에 잔류합니다.
1.2 AssetBundle.Unload(true) vs Unload(false)의 치명적 차이
| 메서드 | 동작 특성 | 발생 가능한 문제 및 실무 적용 |
|---|---|---|
Unload(true) |
번들 헤더뿐만 아니라 번들에서 로드되어 현재 씬에서 사용 중인 인스턴스/에셋까지 강제 파괴 | 씬에 배치된 오브젝트의 텍스처/머티리얼이 마젠타(분홍색 누락)로 깨짐. 씬 완전 전환 시에만 사용 |
Unload(false) |
번들 압축 해제 헤더 메모리만 날리고, 로드된 에셋 인스턴스는 씬에 유지 | 에셋과 번들의 연결 고리가 끊겨 추후 동일 에셋 재로드 시 메모리 중복 적재(Duplicate Asset) 누수 발생 |
2. 실무에서 가장 흔한 4대 메모리 누수 안티패턴
2.1 [안티패턴 1] Destroy(gameObject) 호출과 릴리즈(Release) 누락
Addressables.InstantiateAsync로 생성한 게임오브젝트를 GameObject.Destroy(go)로만 삭제하면, 씬에서는 사라지지만 어드레서블 내부 참조 카운트는 여전히 1로 남아있습니다. 반드시 Addressables.ReleaseInstance(go)를 호출해야 인스턴스 파괴와 참조 카운트 감소가 동시에 정상 처리됩니다.
2.2 [안티패턴 2] 정적(Static) 이벤트 및 싱글톤에 의한 GC 고착
로드된 UI 팝업이나 캐릭터 프리팹 내부 스크립트가 GameManager.OnScoreChanged += UpdateUI;와 같이 전역 정적 이벤트에 리스너를 등록한 후, 오브젝트 파괴 시 이벤트 구독을 해제(-=)하지 않으면 C# 가비지 컬렉터(GC)가 해당 인스턴스를 살아있는 객체로 판단하여 네이티브 C++ 메모리까지 릴리즈되지 못하고 고착됩니다.
2.3 [안티패턴 3] 종속성 번들(Dependency Bundles)의 은폐된 누수
캐릭터 프리팹 A가 사용하는 텍스처가 공유 텍스처 아틀라스 번들 B에 속해 있을 때, 프리팹 A를 로드하면 번들 B도 백그라운드에서 자동 로드됩니다. 만약 프리팹 A를 릴리즈했더라도 다른 곳에서 번들 B에 포함된 사소한 텍스처 하나를 여전히 참조하고 있다면, 번들 B 전체(수십 MB)가 메모리에 그대로 남게 됩니다.
2.4 [안티패턴 4] `Resources.UnloadUnusedAssets()`의 맹목적 남용
많은 개발자가 씬 전환 시 Resources.UnloadUnusedAssets()를 만병통치약처럼 호출합니다. 하지만 이 함수는 전체 씬의 모든 오브젝트 그래프를 전수 조사하는 극도로 무거운 동기식(Sync) 연산으로, 인게임 플레이 도중 호출할 경우 수백 밀리초의 심각한 프레임 스파이크(Stuttering)를 유발합니다.
3. 안전한 어드레서블 리소스 관리 실무 C# 코드 패턴
에셋 로드 시 반환되는 AsyncOperationHandle을 안전하게 보관하고 수명 주기에 맞춰 자동 릴리즈하는 실무형 래퍼(Wrapper) 구조입니다.
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class SafeResourceManager : MonoBehaviour
{
// 로드된 핸들을 추적하기 위한 딕셔너리
private Dictionary<string, AsyncOperationHandle> _loadedHandles = new Dictionary<string, AsyncOperationHandle>();
// 1. 에셋 비동기 안전 로드
public void LoadAssetSafe<T>(string key, System.Action<T> onLoaded) where T : Object
{
if (_loadedHandles.TryGetValue(key, out var existingHandle))
{
if (existingHandle.IsDone)
{
onLoaded?.Invoke(existingHandle.Result as T);
return;
}
}
AsyncOperationHandle<T> handle = Addressables.LoadAssetAsync<T>(key);
_loadedHandles[key] = handle;
handle.Completed += h =>
{
if (h.Status == AsyncOperationStatus.Succeeded)
{
onLoaded?.Invoke(h.Result);
}
else
{
Debug.LogError($"[Addressables] 로드 실패: {key}");
Addressables.Release(h);
_loadedHandles.Remove(key);
}
};
}
// 2. 특정 에셋 릴리즈 (참조 카운트 차감)
public void ReleaseAsset(string key)
{
if (_loadedHandles.TryGetValue(key, out var handle))
{
Addressables.Release(handle);
_loadedHandles.Remove(key);
Debug.Log($"[Addressables] 에셋 정상 릴리즈: {key}");
}
}
// 3. 씬 종료 시 일괄 안전 릴리즈
private void OnDestroy()
{
foreach (var kvp in _loadedHandles)
{
if (kvp.Value.IsValid())
{
Addressables.Release(kvp.Value);
}
}
_loadedHandles.Clear();
}
}
4. 유니티 프로파일러를 활용한 실무 누수 검증 기법
- Addressables Event Viewer 활용:
Window > Asset Management > Addressables > Event Viewer를 열고 플레이 모드를 실행하면, 각 에셋번들의 실시간 참조 카운트 그래프가 표시됩니다. 씬을 닫았을 때 막대 그래프가 0으로 떨어지지 않고 초록색으로 남아있는 번들이 바로 누수의 주범입니다. - Memory Profiler 스냅샷 Diff 비교: 씬 진입 전 스냅샷 A를 캡처하고, 씬 플레이 후 로비로 복귀한 시점에서 스냅샷 B를 캡처하여 두 스냅샷을 Compare(비교)합니다. 로비로 돌아왔음에도 해제되지 않은 Texture2D나 Mesh 인스턴스가 남아있는지 추적합니다.
5. 결론 및 모바일 메모리 최적화 실무 체크리스트
에셋번들과 어드레서블의 최적화는 단순히 번들을 잘 쪼개는 것에서 끝나지 않으며, 로드한 주체가 정확한 시점에 책임을 지고 릴리즈하는 생명주기 관리 파이프라인 구축이 필수적입니다. 프로젝트 배포 전 아래 5가지 항목을 점검하시기 바랍니다.
- 인스턴스 해제 함수 검수:
Addressables.InstantiateAsync로 생성한 오브젝트를Destroy()가 아닌Addressables.ReleaseInstance()로 파괴하고 있는가? - 정적 이벤트 구독 해제: 씬 언로드 또는 오브젝트 파괴 시 등록된 C# 델리게이트와 유니티 이벤트가 누락 없이 해제(
-=)되는가? - 이벤트 뷰어 참조 카운트 점검: 씬 퇴장 후 Addressables Event Viewer에서 모든 동적 번들의 참조 카운트가 0으로 정상 반환되는가?
- 에셋번들 의존성 분리: 너무 많은 공용 에셋이 한 번들에 몰려 다른 불필요한 번들까지 메모리에 잡고 늘어지지 않도록 패킹 규칙을 점검했는가?
- 실기기 LMK 프로파일링: 저사양 타깃 기기(3GB RAM 이하 안드로이드, 구형 아이폰)에서 30분 이상 연속 플레이 시 물리 메모리 점유율이 우상향하지 않고 안정적으로 유지되는가?
댓글 쓰기