Addressables 메모리 테스트

이준영·2025년 12월 2일

오늘은 진행중인 프로젝트에 어드레서블을 추가해서 프리펩을 관리해보려고 했는데,
모의 면접에서 마지막 프로젝트의 어드레서블 사용이 부적절 하다고 해서 이전 코드와 비교하며 테스트하려고 한다.

이전 코드

using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;


//<summary>
//어드레서블 이용한 리소스 로더
//</summary>
public class ResourceLoader : MonoBehaviour
{
    /// <summary>
    /// 어드레서블 주소 기반 로더
    /// </summary>
    /// <typeparam name="T">제네릭 타입 파라미터</typeparam>
    /// <param name="key">주소값 넣을 파라미터, 하드코딩 지양하고 StringNameSpace 사용권장</param>
    /// <returns>T타입 에셋을 반환</returns>
    public static async Task<T> LoadAssetAddress<T>(string key) where T : Object
    {
        AsyncOperationHandle<T> handle = Addressables.LoadAssetAsync<T>(key);
        await handle.Task;

        if (handle.Status == AsyncOperationStatus.Succeeded)
        {
            return handle.Result; // 로딩 성공시 반환
        }
        else
        {
            Debug.LogError($"주소 로딩 실패 : \"{key}\"");
            return null;
        }
    }

    /// <summary>
    /// 어드레서블 라벨 기반 로더
    /// </summary>
    /// <typeparam name="T">제네릭 타입 파라미터</typeparam>
    /// <param name="label">해당 라벨이 붙은걸 호출 </param>
    /// <returns>T타입 라벨들을 일괄 반환</returns>
    public static async Task<List<T>> LoadAssetsLabel<T>(string label) where T : Object
    {
        AsyncOperationHandle<IList<T>> handle = Addressables.LoadAssetsAsync<T> // 라벨 붙은 에셋을 로딩
        (
            label,
            null
        );

        await handle.Task; // 끝나길 기다리는 곳

        if (handle.Status == AsyncOperationStatus.Succeeded)
        {
            return new List<T>(handle.Result); // 로딩 성공시 반환
        }
        else
        {
            Debug.LogError($"라벨 로딩 실패 : \'{label}\'"); // 로딩 실패시 오류메시지
            return null;
        }
    }

    /// <summary>
    /// 어드레서블은 수동으로 로드 해제해야 메모리 누수가 없음
    /// </summary>
    /// <param name="obj"> 다쓴건 obj로 파라미터 넣어서 호출</param>
    public static void Release(object obj)
    {
        Addressables.Release(obj);  // 사용한 어드레서블 메모리 해제
    }


}
  • 팀원이 작성했던 어드레서블 코드이다.
  • 주소 방식과 라벨 방식 둘 다 이용하는데 특징으로는 Release를 수동으로 하여 관리를 해줘야 한다는 것이다.

장점

  1. 호출의 간결성 : 모든 로드 함수가 static으로 구현되어 있어, 인스턴스 생성 없이 사용 가능.
  2. 비동기 처리의 단순화 : 타입의 결과(handle.Result)를 즉시 반환하여 코드를 읽기 쉽게 만듦.
  3. 재활용 가능한 Release 함수

단점

  1. 메모리 누수 위험 (가장 큰 단점)
    • 핸들 정보 손실
    • Release의 모호성
  2. 에셋 재활용 및 관리의 비효율성
    • 재활용 불가
    • 불필요한 await 오버헤드
  3. 불필요한 MonoBehaviour 상속
    • 비효율적인 구조

Test

Test Code1
  • DevStage에서 사용하는 Complete 메서드의 일부이다.
Test1
  • 호출을 반복할 때 마다 Handle 수가 늘어나는 것을 확인할 수가 있다.
  • 이런 값이 쌓이게 되면 메모리 누수가 된다.
Test Code2
  • 생성할 오브젝트를 어드레서블로 가져온 후,
  • 오브젝트 폴링을 이용하여 생성.
  • 생성 된 이후에 오브젝트를 Release() 하는 방법으로 테스트 해보았다.
Test2
  • 오브젝트의 모습이 제대로 작동하지 않고, Profiler에서도 탐색이 되지 않았다.
Test Code3
  • 이번엔 확인 버튼을 눌렀을 때, 해제되게 하였다.
Test3
  • 해당 오류는 어드레서블로 호출한 오브젝트가 아니기 때문에 발생하였다.
Test Code4
  • 이번에는 생성한 곳에서 해제를 해보았다.
Test4
  • 이번에는 메모리 관리는 잘 되지만, 오브젝트 폴링과 충돌하여 이전에 생성된 오브젝트들은 깨지는 모습을 보였다.

결론

이전에 사용한 어드레서블 코드는 메모리 적으로 부담이 되거나 메모리에서 해제할 때, 문제가 생기는 상태였다.

개선 방법

그래서 어떤 방법이 좋을까 고민하다가 Gemini에게 열심히 질문을 던져 보았다.

질문하기

Gemini
  • 꽤 많은 질문을 했는데, 결론은 마지막 질문이다. (생략된 질문도 많음)
  • 어드레서블 기능 자체는 Static 클래스로 구현하되, 실제 역할을 하는 Manager클래스에서 각자 Handle을 생성하여 관리하는 방법을 생각했다.
  • 즉 프리펩 담당 매니저, BGM 담당 매니저 등으로 관리하는 방법이다.

코드 작성

using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

//<summary>
//어드레서블 이용한 리소스 로더
//</summary>
public static class ResourceLoader
{
    // 정적 함수로 Addressables 로드 요청을 감쌉니다.
    public static AsyncOperationHandle<T> LoadAssetAsync<T>(string address) where T : Object
    {
        return Addressables.LoadAssetAsync<T>(address);
    }
    
    // 정적 함수로 Addressables 해제 요청을 감쌉니다.
    public static void Release<T>(AsyncOperationHandle<T> handle) where T : Object
    {
        if (handle.IsValid())
        {
            Addressables.Release(handle);
        }
    }
}
  • 이전에 비해 비교적 간단하다. UI프리펩을 담당하는 매니저는 코드가 많다.
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.ResourceManagement.AsyncOperations;

public enum UIPrefabs
{
    Alert,
    Confirm,
    StageButton,
    StageContainer,
}

/// <summary>
/// UI 프리팹을 로드하고, 인스턴스화된 객체와 Addressables 핸들을 매핑하여 관리합니다.
/// </summary>
public class UIPrefabManager : MonoBehaviour
{
    public static UIPrefabManager Instance { get; private set; }
    
    private Dictionary<GameObject, AsyncOperationHandle<GameObject>> activePrefabHandles = 
        new Dictionary<GameObject, AsyncOperationHandle<GameObject>>();

    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }


    public async Task<GameObject> ShowUI(UIPrefabs address)
    {
        AsyncOperationHandle<GameObject> loadHandle = ResourceLoader.LoadAssetAsync<GameObject>(address.ToString());
        
        await loadHandle.Task;

        if (loadHandle.Status == AsyncOperationStatus.Succeeded)
        {
            GameObject prefabAsset = loadHandle.Result;
            Transform uiParent = FindObjectOfType<Canvas>().transform;
            GameObject instance = ObjectPool.Get(prefabAsset, uiParent);
            
            activePrefabHandles.Add(instance, loadHandle);
            
            return instance;
        }
        else
        {
            ResourceLoader.Release(loadHandle);
            
            return null;
        }
    }
    
    public void CloseUI(GameObject instance)
    {
        if (activePrefabHandles.TryGetValue(instance, out AsyncOperationHandle<GameObject> handle))
        {
            ObjectPool.Release(instance);
            
            ResourceLoader.Release(handle); 
            
            activePrefabHandles.Remove(instance);
        }
        else
        {
            Destroy(instance); 
        }
    }
}
  • 특징을 나열하면
  1. 싱글톤 패턴 사용
  2. Dictionary로 Instance와 Handle을 관리

장점

1. 메모리 누수 완벽 방지 : 인스턴스 객체와 로드 핸들을 1:1로 매핑하여 추적
2. 중앙 집중식 제어 : 모든 UI 프리팹의 로드 및 해제 라이프사이클이 UIPrefabManager 싱글톤 클래스에 의해 관리
3. 비동기/동기 호출 분리 : 호출자가 로드 완료를 기다린 후 인스턴스에 즉시 접근하여 설정할 수 있음.
4. 안전한 로드 실패 처리 : 로드 실패 시에도 불필요한 핸들이 남아있지 않도록 else 블록에서 처리.

단점

  1. 동기 코드 복잡성 증가 : 프리팹 로드 시 await 키워드가 강제되므로, 호출하는 쪽 코드도 모두 async/await 사용 필수.
  2. Dictionary 메모리 오버헤드 : 매우 많은 인스턴스를 동시에 관리할 경우 Dictionary\text{Dictionary} 조회 및 관리에 약간의 오버헤드가 발생할 수 있음.

Test

TestCode
  • 사용하는 곳 코드가 간결 해진것도 장점이다.
  • 이 외에도 프리펩을 불러서 사용했던 곳은 전부 교체했다.
Test
  • 확실하게 메모리가 관리되는 모습이 보인다.

비교

간단 요약

특징원본 코드개선된 방식
메모리 누수 위험매우 높음매우 낮음
핸들 관리핸들 정보 손실 -> Release에 필요한 정보 없음.매니저 내부에 해들을 저장 및 추적하여 1:1 대응 관리.
해제 방식Addressables.Release(object obj) 사용 -> 해제 실패 및 오류 발생.Addressables.Release(handle) 사용 -> 정확한 핸들로 해제 보장.
재활용 효율성비효율적: 이미 로드된 에셋도 매번 재로드(오버헤드 발생).매우 효율적: 핸들을 캐싱하여 이미 로드된 에셋은 재로드 없이 즉시 사용 가능.
코드 스타일Static 함수로 간결하나, 메모리 관리 코드가 분산됨.Singleton 인스턴스 접근이 필요하나, 메모리 관리 코드가 중앙 집중식으로 모여 안전함.
profile
게임 개발자가 되기 위해서 공부하는 중입니다.

0개의 댓글