π English Abstract
This article provides a deep technical analysis of Garbage Collection (GC) spikes and frame stuttering during asynchronous asset loading with Unity Addressables in large-scale 3D scenes. It covers the root causes within Unity's Managed Heap and Main Thread operation, profiling methodologies using Unity Profiler, and practical zero-allocation optimization techniques. Developers will learn actionable solutions including LZ4 compression tuning, custom UniTask wrapper implementation, and pre-allocated object pooling strategies for seamless performance.
μ λν°(Unity 3D) μμ§μμ λκ·λͺ¨ μ¬(Large-scale Scene)μ΄λ μ€ν μλ νκ²½μ κ°λ°ν λ, μμ
κ΄λ¦¬ λ° λ©λͺ¨λ¦¬ μ΅μ νλ₯Ό μν΄ μ΄λλ μ€λΈ μμ
μμ€ν
(Addressable Asset System)μ λμ
νλ κ²μ νμ¬ μ
κ³ νμ€μΌλ‘ μ리 μ‘μμ΅λλ€. κ·Έλ¬λ λ§μ κ°λ°μ§μ΄ λΉλκΈ° λ‘λ© APIμΈ Addressables.LoadAssetAsync λλ Addressables.InstantiateAsyncλ₯Ό μ±ννμμλ λΆκ΅¬νκ³ , μ€μκ° κ²μνλ μ΄ λμ€ μ¬κ°ν νλ μ μ€ν°ν°λ§(Frame Stuttering)κ³Ό GC μ€νμ΄ν¬(Garbage Collector Spikes) νμμ κ²ͺκ³€ ν©λλ€.
λΆλͺ 'λΉλκΈ°(Asynchronous)'λ‘ λ‘λ©μ μ²λ¦¬νλλ‘ μ½λλ₯Ό μμ±νμμλ λΆκ΅¬νκ³ μ λ©μΈ μ€λ λ(Main Thread)κ° λ©μΆκ³ νλ©΄μ΄ νλ νμμ΄ λ°μν κΉμ? λ³Έ ν¬μ€ν μμλ λΉλκΈ° μμ λ‘λ© λμ κ³Όμ μμ μΌμ΄λλ μ λν° λ΄λΆ λ©μ»€λμ¦μ μ¬λ μκ² νν€μΉκ³ , νλ‘νμΌλ§ μ§λ¨λ²λΆν° νμ μμ μ¦μ μ μ© κ°λ₯ν μ€μ μ΅μ ν κΈ°λ²κΉμ§ μλ²½ν λ€λ£Ήλλ€.
1. Addressables λΉλκΈ° λ‘λ© μ€ GC μ€νμ΄ν¬κ° λ°μνλ λ΄λΆ λ©μ»€λμ¦
"λΉλκΈ° λ‘λ©μ μ¬μ©νλ©΄ λ©μΈ μ€λ λ μ°μ°κ³Ό 무κ΄νκ² λ°±κ·ΈλΌμ΄λ μ€λ λ(Background Thread)μμ λͺ¨λ μ²λ¦¬κ° μλ£λ κ²μ΄λ€"λΌλ μκ°μ λνμ μΈ μ€ν΄μ λλ€. μ λν°μ C# κ΄λ¦¬ν ν(Managed Heap) μμκ³Ό λ λλ§ νμ΄νλΌμΈμ ν΅μ¬ μμ μ μ¬μ ν λ©μΈ μ€λ λμμ μ§λ ¬ν(Serialization)λμ΄ μ€νλκΈ° λλ¬Έμ λλ€.
κ°. λΉλκΈ° μμ μ€ λ©μΈ μ€λ λ λΈλ‘νΉ(Main Thread Blocking) μμΈ
μ΄λλ μ€λΈμ΄ μμ λ¬Άμ(AssetBundle)μ λ‘λν λ λ°±κ·ΈλΌμ΄λ μ€λ λμμλ λμ€ν¬ I/Oμ μμ λ°μ΄ν°μ μμΆ ν΄μ (Decompression) μμ μ΄ μΌμ΄λ©λλ€. νμ§λ§ μμΆ ν΄μ λ λ°μ΄λ리 λ°μ΄ν°λ₯Ό λ°νμΌλ‘ μ€μ μ λν° μμ§ λ΄λΆμ C++ μμ κ°μ²΄(Mesh, Texture, Material λ±)λ₯Ό μμ±νκ³ λ©μΈ μ€λ λμ λ λλ§ νμ΄νλΌμΈμ λ±λ‘νλ μμ (Integration Phase)μ λ°λμ λ©μΈ μ€λ λμμ μ΄λ£¨μ΄μ§λλ€. μ΄ μμ μ΄ λ¨μΌ νλ μμ κ³Όλνκ² λͺ°λ¦¬λ©΄ λ©μΈ μ€λ λ μ μ μκ°μ΄ κΈΈμ΄μ Έ νλ μ λλμ΄ λ°μν©λλ€.
λ. κ΄λ¦¬ν λ©λͺ¨λ¦¬ ν λΉ(Managed Memory Allocation)κ³Ό GC ν
λΉλκΈ° λ‘λ©μ νΈμΆνλ κ³Όμ μμ λ°μνλ Garbage(κ°λΉμ§)λ ν¬κ² μΈ κ°μ§ μμΈμμ κΈ°μΈν©λλ€:
- C# Task λ° AsyncOperationHandle λ°μ±(Boxing):
AsyncOperationHandleμ μ λ€λ¦ ꡬ쑰체 ννλ‘ λ€λ£¨κ±°λTask/CoroutineμΌλ‘ λ³ν(Awaiting)νλ κ³Όμ μμ ν ν λΉμ΄ λ°μν©λλ€. - λ¬Έμμ΄ κΈ°λ° ν€(String-based Address Keys) κ²μ: μμ
μ£Όμκ°μ
stringμΌλ‘ μ λ¬ν λ λ°μνλ λ°νμ λ¬Έμμ΄ ν λΉ λ° λμ λ리(Dictionary) ν΄μ νμ μ€λ²ν€λμ λλ€. - λ΄λΆ μ½λ°±(Callback) λ§€κ°λ³μ μΊ‘μ²: λΉλκΈ° μμ μλ£ μ μ€νν λλ€μ(Lambda Expression)μ΄λ μ΄λ²€νΈ νΈλ€λ¬κ° μΈλΆ λ³μλ₯Ό μΊ‘μ²ν λ μ΅λͺ ν΄λμ€ κ°μ²΄κ° νμ μμ±λ©λλ€.
μ΄λ¬ν λ―ΈμΈν λ©λͺ¨λ¦¬ ν λΉλ€μ΄ λκ·λͺ¨ μ¬ μ νμ΄λ λλμ μμ (μ: νλ¦¬νΉ 50κ° λμ λ‘λ©) μ²λ¦¬ μ μκ°μ μΌλ‘ μ λ©κ°λ°μ΄νΈ(MB) λ¨μλ‘ λμ λλ©°, κ²°κ΅ μ λν°μ μ μ§μ κ°λΉμ§ 컬λ ν°(Incremental GC)λ§μ μ μ΄ν μ μλ κ±°λν GC μ€νμ΄ν¬λ₯Ό μ΄λ°νκ² λ©λλ€.
2. Profilerλ₯Ό νμ©ν λ³λͺ© μ§μ μ§λ¨ λ° μΈ‘μ
λ¬Έμ ν΄κ²°μ μν΄μλ μ νν μΈ‘μ (Measurement)μ΄ μ νλμ΄μΌ ν©λλ€. μ λν° νλ‘νμΌλ¬(Unity Profiler)μ μμ νλ‘νμΌλ§ λꡬλ₯Ό μ¬μ©νμ¬ μ€ν°ν°λ§μ μ£Όλ²μ μΆμ νλ λ°©λ²μ λλ€.
κ°. Unity Profiler ν΅μ¬ μνλ¬ Marker νμΈ
νλ‘νμΌλ¬μ CPU Usage λͺ¨λμμ Hierarchy λ·°λ₯Ό GC Alloc κΈ°μ€μΌλ‘ μ λ ¬ν λ€ λ€μ λ§μ»€(Marker)λ€μ μΆμ ν΄μΌ ν©λλ€:
AssetBundleRequest.GetResult/AwakeFromLoad: μμ μ§λ ¬ν λ°μ΄ν°λ₯Ό ν΄μ νκ³ λ©μΈ μ€λ λμ κ°μ²΄λ₯Ό μμ±ν λ λ°μνλ λ³λͺ©μ λλ€. μ΄ μμΉκ° λλ€λ©΄ μμ λ¨μ λΆκ΄(Granularity)μ΄ μλͺ»λμκ±°λ λ¨μΌ νλ μμ λ무 λ§μ μμ μ λ‘λνκ³ μμμ λ»ν©λλ€.GC.Collect: λ©λͺ¨λ¦¬ ν λΉ νκ³λ₯Ό μ΄κ³Όνμ¬ κ°λΉμ§ 컬λ ν°κ° λκΈ°μ μΌλ‘ μ€νλμμμ μ리λ μ νΈμ λλ€.UpdateAllProviders: Addressables 리μμ€ κ΄λ¦¬ μμ€ν λ΄λΆμμ 릴리μ€(Release) μν λ° μ’ μμ±(Dependency)μ κ°±μ ν λ λ°μνλ ν ν λΉ μ§μ μ λλ€.
λ. Profile Analyzerλ₯Ό μ΄μ©ν νλ μ κ° κ²©μ°¨ λΉκ΅
λ¨μΌ νλ μ λΆμλ§μΌλ‘ λΆμ‘±ν κ²½μ°, μ λν° ν¨ν€μ§ λ§€λμ μ Profile Analyzerλ₯Ό νμ©ν΄ λ‘λ© μ /ν 300νλ μ κ°μ Mean Time(νκ· μκ°)κ³Ό Max Time(μ΅λ μκ°)μ λΉκ΅ν©λλ€. λ‘λ© μ€ νλ μ νμμ΄ 16.6ms(60fps κΈ°μ€)λ₯Ό λμ΄μλ ꡬκ°μ GC Alloc λ°μ νμμ ν λ©λͺ¨λ¦¬ κΈμ¦ μ§μ μ μκ°μ μΌλ‘ κ²μ¦ν μ μμ΅λλ€.
3. GC μ€νμ΄ν¬ λ° μ€ν°ν°λ§ ν΄κ²°μ μν 4λ¨κ³ μ΅μ ν μ루μ
λ¨κ³ 1: AssetBundle μμΆ μ΅μ λ° λ‘λ© λ²νΌ μ‘°μ (LZ4 vs LZMA)
μ΄λλ μ€λΈ λΉλ μ€μ (Addressable Asset Group Settings)μμ κΈ°λ³Έ μμΆ λ°©μμ μ΅μ νν΄μΌ ν©λλ€. κΈ°λ³Έκ°μΈ LZMA μμΆμ μμΆλ₯ μ΄ λμ§λ§ λ°νμμ μ 체 λ¬Άμμ λ©μΈ μ€λ λ λ©λͺ¨λ¦¬μ νμ΄μΌ νλ―λ‘ νλ μ λλμ μ£ΌμμΈμ΄ λ©λλ€.
- LZ4 μμΆ μ μ©: μ²ν¬ κΈ°λ°(Chunk-based) μμΆ λ°©μμΈ LZ4λ₯Ό μ μ©νλ©΄ μμ μ νμν λΆλΆλ§ μ€μκ°μΌλ‘ μμΆ ν΄μ νλ―λ‘ λ‘λ© μ€νμ΄ν¬λ₯Ό νκΈ°μ μΌλ‘ μ€μΌ μ μμ΅λλ€.
- AssetBundle λ‘λ© λ²νΌ(Loading Buffer) μ΅μ ν:
QualitySettings.asyncUploadBufferSizeλ₯Ό κΈ°λ³Έκ°(4MB λλ 16MB)μμ 64MB μ΄μμΌλ‘ ν¬κ² λλ €μ€λλ€. μ΄ λ²νΌλ ν μ€μ²μ λ©μ λ°μ΄ν°λ₯Ό CPU λ©λͺ¨λ¦¬λ₯Ό κ±°μΉμ§ μκ³ λμ€ν¬μμ GPU VRAMμΌλ‘ μ§μ μ΄κ΄(Async Upload Pipeline)νλ ν΅λ‘ μν μ μννμ¬ λ©μΈ μ€λ λ λ³λͺ©μ μ κ±°ν©λλ€.
λ¨κ³ 2: GC-Free C# Async Architecture (UniTask λμ )
μ λν° κΈ°λ³Έ Taskλ Coroutine(YieldInstruction)μ λΉλκΈ° νΈμΆ μλ§λ€ ν κ°μ²΄λ₯Ό ν λΉν©λλ€. μ±λ₯ μ΅μ νλ₯Ό μν΄ UniTask λΌμ΄λΈλ¬λ¦¬λ₯Ό λμ
νκ³ AsyncOperationHandleμ μ§μ μ μΈ Task λ³νμ μ§μν΄μΌ ν©λλ€.
λ¨κ³ 3: νλ μ λ²μ§ν (Frame Budgeting) κΈ°λ° λΉλκΈ° ν ꡬν
λκ·λͺ¨ μ¬μ νλ¦¬νΉ 100κ°λ₯Ό λμμ Addressables.InstantiateAsyncλ‘ νΈμΆνλ©΄ λΉλκΈ° μμ
μ΄λΌ νλλΌλ λμΌ νλ μμ λ©μΈ μ€λ λ μΈμ€ν΄μ€ν μμ
μ΄ μ λ¦¬κ² λ©λλ€. μ΄λ₯Ό λ°©μ§νκΈ° μν΄ νλ μλΉ μκ° μμ°(μ: νλ μλΉ μ΅λ 3ms)μ μ€μ νκ³ μμ
μ λΆμ°μν€λ μμ
ν(Task Queue)λ₯Ό μ μ©ν΄μΌ ν©λλ€.
λ¨κ³ 4: Pre-Warmingκ³Ό κ°μ²΄ νλ§(Object Pooling)μ κ³ λν κ²°ν©
μ΄λλ μ€λΈμ ν΅ν΄ μμ
μ λ‘λ(LoadAssetAsync)ν ν InstantiateAsyncλ₯Ό μ§μ λ§€λ² νΈμΆνλ λμ , μ¬ μ§μ
μ Pre-load λ¨κ³μμ μμ
μ μλ³Έ νΈλ€λ§ λ©λͺ¨λ¦¬μ λμλκ³ (Pre-warming), λ°νμ μμ±μ μ λν°μ UnityEngine.Pool κΈ°λ° μ€λΈμ νΈ νκ³Ό μ°λνλ ν¨ν΄μΌλ‘ μ νν΄μΌ ν©λλ€.
4. μ€λ¬΄μ© κ³ μ±λ₯ λΉλκΈ° μμ λ‘λ μ½λ μμ
μλ μμ μ½λλ C#μ ν ν λΉμ κ·Ήλ¨μ μΌλ‘ μ€μ΄λ©΄μ(Zero-Allocation μ§ν₯), UniTaskμ νλ μ λ²μ§ν λ°©μμ μ‘°ν©νμ¬ μ΄λλ μ€λΈ μμ μ μμ νκ² λΉλκΈ° λ‘λ©νλ μ€λ¬΄ν λνΌ(Wrapper) ꡬν체μ λλ€.
using System.Collections.Generic; using Cysharp.Threading.Tasks; using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; public class ZeroAllocAddressableLoader : MonoBehaviour { // μμ νΈλ€ μΊμ±μ ν΅ν μ¬λ‘λ© μ GC λ° I/O λ°μ λ°©μ§ private readonly Dictionary<string, AsyncOperationHandle> _handleCache = new Dictionary<string, AsyncOperationHandle>(128); /// <summary> /// GC Allocμ μ΅μνν μμ ν λΉλκΈ° μμ λ‘λ© /// </summary> public async UniTask<T> LoadAssetZeroAlloc<T>(string addressKey) where T : UnityEngine.Object { // 1. μ΄λ―Έ μΊμλ νΈλ€μ΄ μ‘΄μ¬νλμ§ νμΈ (λ©λͺ¨λ¦¬ λλΉ λ° μ€λ³΅ λ‘λ λ°©μ§) if (_handleCache.TryGetValue(addressKey, out var cachedHandle)) { if (cachedHandle.IsValid()) { return (T)cachedHandle.Result; } _handleCache.Remove(addressKey); } // 2. λΉλκΈ° λ‘λ μμ (AssetReference λμ λ΄λΆ λ¬Έμμ΄ ν€ μ§μ μ΄μ©) AsyncOperationHandle<T> handle = Addressables.LoadAssetAsync<T>(addressKey); // 3. UniTaskλ₯Ό νμ©νμ¬ ν ν λΉ(Task κ°μ²΄ μμ±) μμ΄ await μ²λ¦¬ await handle.ToUniTask(PlayerLoopTiming.Update); if (handle.Status == AsyncOperationStatus.Succeeded) { _handleCache[addressKey] = handle; return handle.Result; } Debug.LogError($"[AddressableLoader] μμ λ‘λ μ€ν¨: {addressKey}"); if (handle.IsValid()) { Addressables.Release(handle); } return null; } /// <summary> /// νλ μ μ€ν°ν°λ§ λ°©μ§λ₯Ό μν ν κΈ°λ° λΆμ° μΈμ€ν΄μ€ν μμ /// </summary> public async UniTask BatchInstantiateWithBudget(List<string> keys, float maxFrameTimeMs = 3.0f) { float frameBudgetSec = maxFrameTimeMs / 1000.0f; for (int i = 0; i < keys.Count; i++) { float startTime = Time.realtimeSinceStartup; // μμ λ‘λ GameObject prefab = await LoadAssetZeroAlloc<GameObject>(keys[i]); if (prefab != null) { Instantiate(prefab); } // νμ¬ νλ μ μμ μκ°μ΄ μ€μ λ μμ°(Frame Budget)μ μ΄κ³Όνλ©΄ λ€μ νλ μμΌλ‘ λκΈ° μ΄κ΄ if (Time.realtimeSinceStartup - startTime > frameBudgetSec) { await UniTask.Yield(PlayerLoopTiming.Update); } } } private void OnDestroy() { // νΈλ€ ν΄μ λ©λͺ¨λ¦¬ λμ λ°©μ§ foreach (var handle in _handleCache.Values) { if (handle.IsValid()) { Addressables.Release(handle); } } _handleCache.Clear(); } }5. λκ·λͺ¨ μ¬λ¦¬μ€ λ§΅ μ μ© μ μ£μ§ μΌμ΄μ€(Edge Cases) λμ
κ°. μ€λ³΅ μμ λ²λ€(Duplicate Bundles)κ³Ό 무λΆλ³ν λ©λͺ¨λ¦¬ ν½μ°½
μλ‘ λ€λ₯Έ μ΄λλ μ€λΈ κ·Έλ£Ήμ λμΌν λ¨Έν°λ¦¬μΌ(Material)μ΄λ ν μ€μ² μμ μ΄ μ°Έμ‘°λμ΄ μλ κ²½μ°, μ λν°λ μμ λ²λ€μ λΉλν λ ν΄λΉ μμ μ κ° λ²λ€μ μ€λ³΅μΌλ‘ ν¬ν¨μμΌ λ²λ¦½λλ€. μ΄λ‘ μΈν΄ λ©λͺ¨λ¦¬κ° μ΄μ€μΌλ‘ λλΉλκ³ λ°νμ λ‘λ© μκ°μ΄ λμ΄λκ² λ©λλ€.
ν΄κ²°μ± : Addressables Windowμ Analyze Toolμ λ°λμ μ£ΌκΈ°μ μΌλ‘ μ€ννμ¬ "Check Duplicate Bundle Dependencies" κ·μΉμ κ²μ¬ν΄μΌ ν©λλ€. 곡ν΅μΌλ‘ μ°Έμ‘°λλ μμ λ€μ λ³λμ
Shared_Assetsκ·Έλ£ΉμΌλ‘ μΆμΆνμ¬ λ¨μΌ λ²λ€λ‘ λ¬Άμ΄λλ‘ μ’ μμ±μ 격리ν΄μΌ ν©λλ€.λ. μ μ¬μ λͺ¨λ°μΌ κΈ°κΈ°μμμ Incremental GC νκ³
μ λν°μ μ μ§μ κ°λΉμ§ 컬λ ν°(Incremental GC)λ GC μ€ν μκ°μ μ¬λ¬ νλ μμΌλ‘ λΆμ°μμΌ μ£Όμ§λ§, μκ°μ μΈ ν ν λΉ ν¬κΈ°κ° ν νλ μμ Incremental GC νμ© λ²μλ₯Ό λμ΄μλ©΄ κ°μ λκΈ°(Synchronous) GCλ‘ μ νλ©λλ€. νΉν μ μ¬μ λͺ¨λ°μΌ κΈ°κΈ°(Android RAM 4GB μ΄ν λ±)μμλ μ΄ μ€νμ΄ν¬κ° 50ms~100ms μ΄μμ λ©μΆ€ νμμΌλ‘ μ§κ²°λ©λλ€.
λ°λΌμ λκ·λͺ¨ μ¬ μ΄λ μ
System.GC.Collect()λ°Resources.UnloadUnusedAssets()λ₯Ό λ‘λ© νλ©΄(Loading Screen)κ³Ό κ°μ μμ ν μ°μΆ ꡬκ°μμ λͺ μμ μΌλ‘ μ μ νΈμΆνμ¬, κ²μνλ μ΄ λμ€ μλ GCκ° ν°μ§μ§ μλλ‘ μ¬μ ν λ©λͺ¨λ¦¬λ₯Ό ν보νλ μ λ΅μ΄ νμν©λλ€.
6. κ²°λ‘ λ° μμ½
Addressables μμ€ν μ νμ©ν λΉλκΈ° μμ λ‘λ©μ λκ·λͺ¨ μ λν° νλ‘μ νΈμ νμμμμ΄μ§λ§, λ¨μν
async/awaitν€μλλ₯Ό μ¬μ©νλ κ²λ§μΌλ‘λ νλ μ μ€ν°ν°λ§κ³Ό GC μ€νμ΄ν¬λ‘λΆν° μμ λ‘μΈ μ μμ΅λλ€.
- μμ μμΆ λ° μ μ‘ λ²νΌ μ΅μ ν: LZ4 μμΆ λ°©μμ μ¬μ©νκ³
asyncUploadBufferSizeλ₯Ό μ΅μ 64MB μ΄μμΌλ‘ ν보νμ¬ GPU μ΄κ΄ λ³λͺ©μ μ€μ λλ€.- C# λ©λͺ¨λ¦¬ ν λΉ μ΅μν: UniTaskμ struct κΈ°λ° νΈλ€μ νμ©νκ³ λλ€μ λ§€κ°λ³μ μΊ‘μ² λ° λ¬Έμμ΄ ν€ ν λΉμ λ°©μ§ν©λλ€.
- νλ μ μμ°(Frame Budgeting) λΆμ°:
Instantiateμ°μ°μ΄ λ¨μΌ νλ μμ κ³Όλνκ² μ λ¦¬μ§ μλλ‘ ν(Queue) μμ€ν μΌλ‘ μ΄κ΄ν©λλ€.- μ€λ³΅ μ’ μμ± μ§μ λΆμ: Analyze Toolμ νμ©ν΄ λ¬Άμ κ° μ€λ³΅ 리μμ€λ₯Ό μ κ±°νκ³ μ¬μ λ‘λ©(Pre-warming) κΈ°λ²μ ꡬ쑰νν©λλ€.
μ΄λ¬ν μ΅μ ν μν€ν μ²λ₯Ό νλ‘μ νΈ μ΄κΈ°μ μ λ¦½ν΄ λλ€λ©΄, λκ·λͺ¨ μ¬λ¦¬μ€ 3D νκ²½μμλ 60 FPSλ₯Ό μμ μ μΌλ‘ μ μ§νλ κ³ μ±λ₯ λͺ¨λ°μΌ λ° PC κ²μμ μ±κ³΅μ μΌλ‘ ꡬμΆν μ μμ κ²μ λλ€.