Addressables로 리소스 로딩을 옮기고 나면 한동안은 평화롭다. 빌드 용량도 줄고, 패치도 쉬워지고, Resources 폴더의 통제 불능 상태에서 벗어난 기쁨에 젖는다. 그러다 플레이 30분쯤 지나면 메모리 그래프가 슬금슬금 우상향하기 시작한다. 씬을 옮길 때마다 베이스라인이 조금씩 높아지고, 결국 모바일에서는 OS가 앱을 죽인다.
원인을 추적해보면 거의 항상 같은 결론에 도달한다. 로드는 했는데 Release를 안 했다. Addressables는 Resources.Load처럼 "엔진이 알아서 관리해주는" 시스템이 아니다. 핸들 하나하나가 참조 카운트를 들고 있고, 그 카운트를 0으로 내려주는 책임은 전적으로 우리에게 있다. Resources 시절의 습관을 그대로 들고 오면 100% 샌다.
이 글에서는 Addressables 메모리 누수가 발생하는 전형적인 패턴 세 가지와, 핸들 수명을 안전하게 관리하는 실전 코드를 정리한다. 모바일 출시를 앞두고 메모리 프로파일러에서 식은땀 흘리는 사람이 가장 빨리 볼 부분만 추렸다.
1. 핸들을 버리는 순간 누수가 시작된다
가장 흔한 실수는 로드 결과만 받고 핸들을 버리는 것이다.
// 누수 코드: 핸들이 사라져서 영원히 Release 불가
async void Spawn(string key)
{
var prefab = await Addressables.LoadAssetAsync<GameObject>(key).Task;
Instantiate(prefab);
// 핸들 참조를 안 들고 있다 → 나중에 Release할 방법이 없다
}
LoadAssetAsync는 AsyncOperationHandle<T>를 반환하는데, 위 코드는 .Task로 결과만 빼먹고 핸들 자체를 즉시 잃어버린다. 참조 카운트가 1로 올라간 채 영원히 남고, 이 에셋은 게임이 끝날 때까지 메모리에 박혀 있다.
핸들은 반드시 어딘가에 보관해야 한다. 보관해야 Release할 수 있다.
public sealed class EnemySpawner : MonoBehaviour
{
AsyncOperationHandle<GameObject> _handle;
async void Awake()
{
_handle = Addressables.LoadAssetAsync<GameObject>("Enemy_Goblin");
var prefab = await _handle.Task;
Instantiate(prefab);
}
void OnDestroy()
{
if (_handle.IsValid())
Addressables.Release(_handle);
}
}
핵심은 OnDestroy에서의 IsValid() 체크다. 로드가 끝나기 전에 오브젝트가 파괴되면 핸들이 무효 상태일 수 있는데, 이때 Release를 호출하면 예외가 난다. IsValid 가드는 선택이 아니라 필수다.
2. AssetReference는 자기 핸들을 스스로 들고 있다
인스펙터에서 에셋을 끌어다 넣는 AssetReference를 쓸 때 함정이 하나 더 있다. AssetReference는 내부적으로 자기가 로드한 핸들을 OperationHandle 프로퍼티에 들고 있다. 그래서 같은 AssetReference로 두 번 로드하면 두 번째 호출이 예외를 던지거나, 잘못 Release하면 다른 곳에서 쓰던 핸들까지 같이 죽는다.
규칙은 단순하다. AssetReference는 LoadAssetAsync로 로드했으면 ReleaseAsset으로 푼다. Addressables.Release에 핸들을 직접 넘기지 말고, AssetReference 자신의 메서드를 쓴다.
[SerializeField] AssetReferenceGameObject _bossRef;
public async UniTask LoadBoss(CancellationToken ct)
{
// 이미 로드 중이면 기존 핸들 재사용
if (!_bossRef.OperationHandle.IsValid())
await _bossRef.LoadAssetAsync().ToUniTask(cancellationToken: ct);
Instantiate(_bossRef.Asset as GameObject);
}
void OnDisable()
{
if (_bossRef.OperationHandle.IsValid())
_bossRef.ReleaseAsset();
}
OperationHandle.IsValid()로 중복 로드를 막는 패턴은 보스나 UI 패널처럼 "있으면 재사용, 없으면 로드"하는 리소스에 특히 잘 맞는다.
3. Instantiate한 인스턴스는 자동 카운트가 붙는다
Addressables.InstantiateAsync는 미묘하게 다르다. 이건 단순히 에셋을 로드하는 게 아니라, 생성된 GameObject 인스턴스 하나하나에 참조 카운트를 건다. 그래서 풀고 싶을 때는 핸들이 아니라 인스턴스 자체를 ReleaseInstance에 넘겨야 한다.
public sealed class BulletPool
{
readonly Stack<GameObject> _pool = new();
public async UniTask<GameObject> Rent(string key)
{
if (_pool.TryPop(out var pooled))
{
pooled.SetActive(true);
return pooled;
}
return await Addressables.InstantiateAsync(key).ToUniTask();
}
public void Return(GameObject bullet) => _pool.Push(bullet); // 재사용용 보관
public void Clear()
{
// 진짜 파괴 시점에만 ReleaseInstance로 카운트까지 정리
while (_pool.TryPop(out var bullet))
Addressables.ReleaseInstance(bullet);
}
}
여기서 흔히 저지르는 실수는 풀링하면서 Destroy(bullet)를 호출하는 것이다. 일반 Destroy로 인스턴스를 없애면 Addressables가 건 참조 카운트는 그대로 남아서, 정작 원본 에셋은 메모리에서 안 내려간다. InstantiateAsync로 만든 건 반드시 ReleaseInstance로 정리한다.
4. 빠른 점검: 진짜로 풀렸는지 확인하기
코드를 고쳤으면 실제로 카운트가 0으로 떨어지는지 눈으로 확인하는 게 마음 편하다. Addressables는 런타임에 참조 카운트를 조회할 방법을 제공한다.
#if UNITY_EDITOR || DEVELOPMENT_BUILD
void LogRefCount(AsyncOperationHandle handle)
{
var rc = handle.IsValid()
? UnityEngine.AddressableAssets.Addressables
.ResourceManager.Acquire(handle)
: default;
Debug.Log($"[Addr] valid={handle.IsValid()}");
}
#endif
더 실용적인 방법은 에디터의 Addressables Event Viewer(Window > Asset Management > Addressables > Event Viewer)를 켜고, 씬을 몇 번 왕복하면서 그래프가 0으로 복귀하는지 보는 것이다. Release를 빠뜨린 에셋은 씬을 나가도 라인이 안 내려가니 한눈에 보인다.
정리
Addressables 메모리 누수의 거의 전부는 "로드했으면 풀어라"는 한 문장으로 막을 수 있다. 잊지 말아야 할 세 가지를 다시 짚으면 이렇다.
LoadAssetAsync로 받은 핸들은 필드에 보관하고, OnDestroy에서IsValid()체크 후Addressables.Release한다.AssetReference는 자기 핸들을 들고 있으니LoadAssetAsync↔ReleaseAsset짝으로만 다룬다.InstantiateAsync로 만든 인스턴스는 Destroy가 아니라ReleaseInstance로 정리한다.
로드와 Release를 항상 같은 수명 범위(생성-파괴, 활성-비활성, 씬 진입-퇴출) 안에 짝지어 두는 습관을 들이면, 메모리 그래프는 다시 평평해진다.
'Unity·Unreal 실전' 카테고리의 다른 글
| Unity UI Toolkit 실전 — IMGUI 없이 커스텀 EditorWindow 제작하기 (0) | 2026.06.11 |
|---|---|
| Unity URP Renderer Feature로 적 피격 하이라이트 분리하기 (0) | 2026.06.08 |
| Unity PropertyDrawer 실전 — 커스텀 Attribute로 Inspector 필드를 직접 디자인하기 (0) | 2026.06.04 |
| Unity DOTS 실전: IJobEntity + Burst 로 수만 개 엔티티 이동을 메인 스레드에서 떼어내기 (0) | 2026.06.01 |
| Unity AssetPostprocessor로 임포트 설정 자동화하기 — 팀 전체의 텍스처 실수를 코드로 막는 법 (0) | 2026.05.28 |