TIL: Unity Addressable Asset System 정복하기

2025. 4. 30. 21:15[Unity] Game Programing

TIL: Unity Addressable Asset System

1. 어드레서블이란 무엇인가? 왜 필요한가?

Unity의 어드레서블 에셋 시스템(Addressable Asset System)은 프로젝트의 에셋(프리팹, 텍스처, 오디오 클립, 씬 등)을 관리하고 로드하는 방식을 혁신하는 강력한 프레임워크입니다. 기존의 Resources 폴더나 에셋 번들(AssetBundle) 방식의 단점을 보완하고, 더 유연하고 효율적인 에셋 관리를 가능하게 합니다.

기존 방식의 문제점:

  • Resources 폴더:
    • 빌드 시 모든 에셋이 포함되어 앱 크기가 불필요하게 커집니다.
    • 에셋 이름 변경이나 이동 시 참조가 깨지기 쉽습니다.
    • 메모리 관리가 수동적이고 복잡합니다.
    • 빌드 후에 에셋을 업데이트할 수 없습니다.
  • 에셋 번들:
    • 구현 및 관리가 복잡하고 오류 발생 가능성이 높습니다.
    • 의존성 관리가 어렵습니다. (중복 에셋 문제)
    • 빌드 파이프라인 설정이 까다롭습니다.

어드레서블 시스템은 이러한 문제점들을 해결하기 위해 등장했습니다. 에셋에 '주소(Address)'를 부여하여, 빌드 시점이 아닌 런타임에 필요에 따라 에셋을 로드하고 관리할 수 있게 합니다. 이는 마치 웹 페이지가 URL 주소를 통해 리소스를 로드하는 것과 유사한 개념입니다.

2. 어드레서블 시스템의 핵심 장점

  • 에셋과 코드의 분리 (Decoupling): 에셋을 직접 참조하는 대신 '주소'를 통해 간접적으로 참조하므로, 코드 변경 없이 에셋을 수정하거나 교체하기 용이합니다.
  • 빌드 크기 최적화: 필요한 에셋만 선택적으로 빌드에 포함하거나, 원격 서버에서 다운로드하도록 설정하여 초기 앱 설치 크기를 줄일 수 있습니다.
  • 빠른 개발 반복 (Faster Iteration): 에디터에서 'Play Mode Script'를 사용하면 실제 빌드 과정 없이 원격 에셋 로딩을 시뮬레이션할 수 있어 개발 속도가 향상됩니다.
  • 동적 콘텐츠 업데이트 (Dynamic Content Updates): 앱 재빌드 및 재배포 없이 원격 서버의 에셋 번들을 업데이트하여 게임 콘텐츠(이벤트, DLC 등)를 동적으로 추가하거나 수정할 수 있습니다.
  • 효율적인 메모리 관리: 자동 참조 카운팅(Reference Counting)을 통해 더 이상 사용되지 않는 에셋 번들과 에셋을 자동으로 언로드하여 메모리 누수를 방지하고 관리를 용이하게 합니다.
  • 의존성 관리 자동화: 에셋 번들 빌드 시 에셋 간의 의존성을 자동으로 분석하고 처리하여 중복 에셋 문제를 최소화합니다.
  • 강력한 프로파일링 도구: 'Addressables Event Viewer'와 'Analyze Tool'을 통해 에셋 로딩 과정, 메모리 사용량 등을 상세하게 분석하고 최적화할 수 있습니다.

3. 주요 개념 이해하기

  • 주소 (Address): 각 에셋을 식별하는 고유한 문자열 키입니다. 파일 경로, GUID, 또는 사용자 정의 문자열 등 다양한 형태로 지정할 수 있습니다. 이 주소를 통해 런타임에 에셋을 로드합니다.
  • 레이블 (Label): 하나 이상의 주소에 할당할 수 있는 태그입니다. 특정 카테고리(예: "UI", "Characters", "Level1")의 에셋들을 그룹화하여 한 번에 로드하는 데 유용합니다.
  • 어드레서블 그룹 (Addressable Group): 에셋 번들을 빌드하는 단위입니다. 어떤 에셋을 어떤 번들에 포함시키고, 어떻게 빌드하고 로드할지 (Local/Remote, 압축 방식 등) 설정합니다.
  • 에셋 레퍼런스 (AssetReference): 어드레서블 에셋에 대한 직렬화 가능한 참조입니다. Inspector 창에서 직접 어드레서블 에셋을 할당하고, 코드에서는 이 참조를 통해 에셋을 로드할 수 있습니다. AssetReferenceGameObject, AssetReferenceTexture2D 등 특정 타입에 대한 참조도 제공됩니다.
  • 카탈로그 (Catalog): 어드레서블 에셋의 주소, 레이블, 번들 정보 등을 담고 있는 메타데이터 파일입니다. 런타임에 이 카탈로그를 로드하여 어떤 에셋을 어디서 어떻게 가져올지 결정합니다. (catalog.json, catalog.hash)
  • 프로필 (Profile): 빌드 및 로드 경로와 관련된 변수들을 관리하는 설정 세트입니다. 로컬 빌드, 개발 서버, 운영 서버 등 다양한 환경에 맞는 경로 설정을 쉽게 전환할 수 있습니다.

4. 어드레서블 시스템 설정 및 시작하기

  1. 패키지 설치: Unity 에디터의 Package Manager (Window > Package Manager)에서 'Addressables' 패키지를 검색하여 설치합니다.
  2. 어드레서블 초기화: 설치 후 Window > Asset Management > Addressables > Groups 창을 엽니다. 'Create Addressables Settings' 버튼을 클릭하여 필요한 설정 파일과 기본 그룹을 생성합니다.
  3. 에셋을 어드레서블로 만들기:
    • Project 창에서 원하는 에셋(프리팹, 텍스처 등)을 선택합니다.
    • Inspector 창 하단의 'Addressable' 체크박스를 활성화합니다.
    • 고유한 주소가 자동으로 생성됩니다 (기본적으로 에셋 경로). 필요시 수정할 수 있습니다.
    • 에셋은 기본 그룹('Default Local Group')에 추가됩니다.
  4. 그룹 설정 조정: Groups 창에서 그룹을 선택하고 Inspector 창에서 빌드 경로(Build Path), 로드 경로(Load Path), 번들링 모드(Bundled Asset Group Schema), 압축 방식(Content Packing & Loading) 등을 설정합니다. 로컬 빌드용 그룹과 원격 다운로드용 그룹을 분리하여 관리하는 것이 일반적입니다.
  5. 프로필 설정: Window > Asset Management > Addressables > Profiles 창에서 빌드 환경별 변수(주로 경로)를 정의하고 관리합니다. 예를 들어, 로컬 테스트 시에는 LocalBuildPath, 원격 서버 배포 시에는 RemoteLoadPath 등을 설정합니다.
  6. 빌드 수행: Groups 창 상단의 'Build' 드롭다운 메뉴에서 'New Build > Default Build Script'를 선택하여 어드레서블 에셋 번들과 카탈로그를 빌드합니다. 빌드된 파일은 설정된 빌드 경로(보통 ServerData 폴더 아래)에 생성됩니다.

5. 코드에서 어드레서블 에셋 로드하기

어드레서블 에셋은 비동기 방식으로 로드됩니다. Unity의 AsyncOperationHandle을 사용하여 로딩 상태를 확인하고 완료 시점에 에셋에 접근합니다.

5.1. 주소를 이용한 로드 (LoadAssetAsync)

가장 일반적인 방법으로, 에셋의 주소를 사용하여 단일 에셋을 로드합니다.

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

public class LoadPrefabByAddress : MonoBehaviour
{
    public string prefabAddress = "Assets/Prefabs/MyCube.prefab"; // 또는 사용자 지정 주소 "MyCubePrefab"
    private AsyncOperationHandle<GameObject> loadHandle;

    async void Start()
    {
        loadHandle = Addressables.LoadAssetAsync<GameObject>(prefabAddress);
        await loadHandle.Task; // 로딩 완료까지 대기 (async/await 사용)

        if (loadHandle.Status == AsyncOperationStatus.Succeeded)
        {
            GameObject prefab = loadHandle.Result;
            Instantiate(prefab, Vector3.zero, Quaternion.identity);
            Debug.Log("Prefab loaded and instantiated successfully.");
        }
        else
        {
            Debug.LogError($"Failed to load prefab at address: {prefabAddress}");
        }
    }

    void OnDestroy()
    {
        // 로드 핸들을 해제하여 참조 카운트 감소
        if (loadHandle.IsValid())
        {
            Addressables.Release(loadHandle);
        }
    }
}

5.2. 레이블을 이용한 로드 (LoadAssetsAsync)

특정 레이블이 지정된 모든 에셋을 한 번에 로드합니다.

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

public class LoadAssetsByLabel : MonoBehaviour
{
    public string assetLabel = "UI_Icons";
    private AsyncOperationHandle<IList<Texture2D>> loadHandle;

    async void Start()
    {
        loadHandle = Addressables.LoadAssetsAsync<Texture2D>(assetLabel, null); // 두 번째 인자는 콜백 (선택 사항)
        await loadHandle.Task;

        if (loadHandle.Status == AsyncOperationStatus.Succeeded)
        {
            IList<Texture2D> icons = loadHandle.Result;
            Debug.Log($"Loaded {icons.Count} icons with label '{assetLabel}'.");
            // 로드된 아이콘 사용...
        }
        else
        {
            Debug.LogError($"Failed to load assets with label: {assetLabel}");
        }
    }

    void OnDestroy()
    {
        if (loadHandle.IsValid())
        {
            Addressables.Release(loadHandle); // 로드된 모든 에셋의 핸들 해제
        }
    }
}

5.3. 에셋 레퍼런스를 이용한 로드

Inspector에서 할당한 AssetReference를 통해 에셋을 로드합니다.

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

public class LoadFromAssetReference : MonoBehaviour
{
    public AssetReferenceGameObject playerPrefabRef; // Inspector에서 할당
    private AsyncOperationHandle<GameObject> loadHandle;

    async void Start()
    {
        if (playerPrefabRef == null || !playerPrefabRef.RuntimeKeyIsValid())
        {
            Debug.LogError("AssetReference is not set or invalid.");
            return;
        }

        // LoadAssetAsync 또는 InstantiateAsync 사용 가능
        loadHandle = playerPrefabRef.LoadAssetAsync<GameObject>();
        await loadHandle.Task;

        if (loadHandle.Status == AsyncOperationStatus.Succeeded)
        {
            Instantiate(loadHandle.Result);
            Debug.Log("Loaded and instantiated via AssetReference.");
        }
        else
        {
            Debug.LogError($"Failed to load asset from AssetReference: {playerPrefabRef.AssetGUID}");
        }
    }

    void OnDestroy()
    {
        // AssetReference를 통해 로드한 경우, ReleaseAsset() 또는 ReleaseInstance() 사용
        if (loadHandle.IsValid())
        {
             playerPrefabRef.ReleaseAsset(); // LoadAssetAsync 사용 시
            // playerPrefabRef.ReleaseInstance(gameObjectInstance); // InstantiateAsync 사용 시
        }
    }
}

5.4. 인스턴스화 (InstantiateAsync)

에셋 로드와 동시에 인스턴스화를 수행합니다. 내부적으로 LoadAssetAsyncInstantiate를 호출하는 것과 유사하지만, 참조 카운팅 관리가 약간 다릅니다.

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

public class InstantiateAddressable : MonoBehaviour
{
    public AssetReferenceGameObject enemyPrefabRef;
    private AsyncOperationHandle<GameObject> instantiateHandle;
    private GameObject spawnedEnemy;

    async void Start()
    {
        if (!enemyPrefabRef.RuntimeKeyIsValid()) return;

        instantiateHandle = enemyPrefabRef.InstantiateAsync(Vector3.zero, Quaternion.identity);
        spawnedEnemy = await instantiateHandle.Task; // 인스턴스화된 게임 오브젝트 반환

        if (instantiateHandle.Status == AsyncOperationStatus.Succeeded && spawnedEnemy != null)
        {
            Debug.Log("Enemy instantiated successfully.");
        }
        else
        {
            Debug.LogError("Failed to instantiate enemy.");
        }
    }

    void OnDestroy()
    {
        // InstantiateAsync로 생성된 인스턴스는 ReleaseInstance로 해제해야 함
        if (instantiateHandle.IsValid())
        {
            Addressables.ReleaseInstance(instantiateHandle);
            // 또는 enemyPrefabRef.ReleaseInstance(spawnedEnemy);
        }
    }
}

5.5. 씬 로드 (LoadSceneAsync)

어드레서블로 지정된 씬을 비동기적으로 로드합니다.

using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.ResourceManagement.ResourceProviders; // SceneInstance 필요
using UnityEngine.SceneManagement; // LoadSceneMode

public class LoadAddressableScene : MonoBehaviour
{
    public AssetReference sceneRef; // 어드레서블 씬 에셋 레퍼런스
    private AsyncOperationHandle<SceneInstance> sceneLoadHandle;

    public async void LoadScene()
    {
        if (!sceneRef.RuntimeKeyIsValid()) return;

        // LoadSceneMode.Single: 기존 씬 언로드 후 로드
        // LoadSceneMode.Additive: 기존 씬에 추가로 로드
        sceneLoadHandle = Addressables.LoadSceneAsync(sceneRef, LoadSceneMode.Additive);
        await sceneLoadHandle.Task;

        if (sceneLoadHandle.Status == AsyncOperationStatus.Succeeded)
        {
            Debug.Log($"Scene '{sceneLoadHandle.Result.Scene.name}' loaded successfully.");
            // SceneInstance sceneInstance = sceneLoadHandle.Result;
            // SceneManager.SetActiveScene(sceneInstance.Scene); // 필요시 활성 씬 설정
        }
        else
        {
            Debug.LogError($"Failed to load scene: {sceneRef.AssetGUID}");
        }
    }

    public async void UnloadScene()
    {
        if (sceneLoadHandle.IsValid())
        {
            var unloadHandle = Addressables.UnloadSceneAsync(sceneLoadHandle);
            await unloadHandle.Task;

            if (unloadHandle.Status == AsyncOperationStatus.Succeeded)
            {
                 Debug.Log("Scene unloaded successfully.");
            }
            else
            {
                Debug.LogError("Failed to unload scene.");
            }
            // sceneLoadHandle은 Unload 후 자동으로 Invalid 상태가 됨
        }
    }

     // 스크립트 파괴 시 씬 언로드 (Additive 로드 시 중요)
    void OnDestroy()
    {
        // 아직 로드 핸들이 유효하다면 (씬이 로드되어 있다면) 언로드 시도
        if (sceneLoadHandle.IsValid() && sceneLoadHandle.Status == AsyncOperationStatus.Succeeded)
        {
             // 비동기 언로드를 기다리지 않고 요청만 보냄
             Addressables.UnloadSceneAsync(sceneLoadHandle);
        }
    }
}

5.6. 에셋 해제 (Release)

더 이상 사용하지 않는 에셋은 반드시 해제하여 메모리에서 언로드해야 합니다. LoadAssetAsync 또는 LoadAssetsAsync로 얻은 핸들은 Addressables.Release(handle)을 사용하고, InstantiateAsync로 얻은 핸들(또는 인스턴스)은 Addressables.ReleaseInstance(handle) 또는 Addressables.ReleaseInstance(gameObject)를 사용합니다. AssetReference를 통해 로드/인스턴스화 한 경우 assetRef.ReleaseAsset() 또는 assetRef.ReleaseInstance()를 사용합니다.

참조 카운팅에 의해 해당 에셋을 참조하는 곳이 모두 해제 요청을 해야 실제 메모리에서 언로드됩니다.

6. 원격 콘텐츠 업데이트

어드레서블의 강력한 기능 중 하나는 앱 재빌드 없이 콘텐츠를 업데이트하는 기능입니다.

  1. 원격 그룹 설정: Groups 창에서 업데이트하려는 에셋이 포함된 그룹을 선택하고, Build & Load Paths를 'Remote'로 설정합니다. Inspector의 'Group Settings'에서 'Build Remote Catalog' 옵션을 활성화합니다.
  2. 콘텐츠 수정 및 빌드: 원격 그룹의 에셋을 수정하거나 추가/삭제합니다. 이후 'Build > Update a Previous Build'를 선택하고, 이전 빌드 시 생성된 addressables_content_state.bin 파일을 지정합니다. 그러면 변경된 내용만 포함하는 새로운 에셋 번들과 업데이트된 카탈로그가 생성됩니다.
  3. 서버에 업로드: 새로 생성된 에셋 번들 파일들과 catalog_[hash].json, catalog_[hash].hash 파일을 프로필에 설정된 원격 로드 경로(RemoteLoadPath)의 웹 서버에 업로드합니다.
  4. 런타임 업데이트 확인 및 적용: 앱 실행 시, 먼저 최신 카탈로그가 있는지 확인하고, 있다면 다운로드하여 적용합니다.
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using System.Collections.Generic;

public class ContentUpdater : MonoBehaviour
{
    async void Start()
    {
        // 초기화 확인 (선택 사항)
        await Addressables.InitializeAsync().Task;

        // 업데이트 확인
        AsyncOperationHandle<List<string>> checkHandle = Addressables.CheckForCatalogUpdates(false); // autoReleaseHandle=false 권장
        await checkHandle.Task;

        if (checkHandle.Status == AsyncOperationStatus.Succeeded)
        {
            List<string> catalogsToUpdate = checkHandle.Result;
            if (catalogsToUpdate != null && catalogsToUpdate.Count > 0)
            {
                Debug.Log($"Found {catalogsToUpdate.Count} catalog updates.");

                // 업데이트 진행
                AsyncOperationHandle<List<UnityEngine.ResourceManagement.ResourceLocations.IResourceLocator>> updateHandle = Addressables.UpdateCatalogs(catalogsToUpdate, false);
                await updateHandle.Task;

                if (updateHandle.Status == AsyncOperationStatus.Succeeded)
                {
                    Debug.Log("Catalogs updated successfully.");
                    // 업데이트된 카탈로그를 기반으로 에셋 로드 가능
                }
                else
                {
                    Debug.LogError("Failed to update catalogs.");
                }
                Addressables.Release(updateHandle); // 핸들 해제
            }
            else
            {
                Debug.Log("No catalog updates found.");
            }
        }
        else
        {
            Debug.LogError("Failed to check for catalog updates.");
        }
        Addressables.Release(checkHandle); // 핸들 해제
    }
}

업데이트 확인 및 적용 시점은 게임의 로직에 맞게 결정해야 합니다 (예: 타이틀 화면, 특정 메뉴 진입 시 등).

7. 고급 기능 및 팁

  • Play Mode Scripts: 에디터 플레이 모드에서 실제 빌드 없이 에셋 번들 로딩을 시뮬레이션하는 방식입니다.
    • Use Asset Database (Fastest): 에셋 번들을 빌드하지 않고 에디터의 에셋 데이터베이스에서 직접 에셋을 로드합니다. 가장 빠르지만, 실제 빌드 환경과 차이가 있을 수 있습니다.
    • Simulate Groups (Advanced): 로컬 및 원격 그룹 설정을 시뮬레이션합니다. 의존성 및 메모리 사용량을 실제 빌드와 유사하게 테스트할 수 있습니다. 로컬 호스팅 설정이 필요할 수 있습니다.
    • Use Existing Build (Requires Built Groups): 미리 빌드된 에셋 번들을 사용하여 테스트합니다. 실제 빌드 환경과 가장 유사합니다.
    Window > Asset Management > Addressables > Settings에서 'Play Mode Script'를 선택할 수 있습니다.
  • Addressables Analyze Tool: Window > Asset Management > Addressables > Analyze 창에서 접근합니다. 에셋 번들의 중복 포함 여부, 번들 레이아웃 시각화 등 빌드 분석 및 최적화에 유용한 규칙들을 제공합니다. 'Check Duplicate Bundle Dependencies' 규칙은 특히 유용합니다.
  • Addressables Event Viewer: Window > Asset Management > Addressables > Event Viewer 창에서 런타임 에셋 로딩/언로딩 이벤트, 참조 카운트 변화, 에셋 번들 캐시 상태 등을 실시간으로 모니터링할 수 있습니다. 성능 문제 및 메모리 누수 디버깅에 필수적입니다.
  • 동기식 주소 지정 가능 항목 (Synchronous Addressables): (주의해서 사용) Addressables.LoadAssetAsync 대신 Addressables.LoadAsset<T>("address") 와 같은 동기식 API를 사용할 수 있지만, 메인 스레드를 차단하여 성능 저하를 유발할 수 있으므로 꼭 필요한 경우가 아니면 사용하지 않는 것이 좋습니다.
  • 커스텀 호스팅 서비스: Unity 로컬 호스팅 외에 자체 CDN이나 클라우드 스토리지(AWS S3, Google Cloud Storage 등)를 사용하여 원격 에셋 번들을 호스팅할 수 있습니다. 프로필 설정에서 해당 서버 URL을 RemoteLoadPath로 지정해야 합니다.
  • 메모리 관리 주의사항:
    • InstantiateAsync로 생성된 인스턴스는 반드시 ReleaseInstance로 해제해야 합니다. Destroy()만 호출하면 에셋 번들이 메모리에 남아있을 수 있습니다.
    • LoadAssetAsync로 로드된 에셋은 해당 핸들을 Release해야 합니다.
    • 씬이 언로드될 때 해당 씬에서 로드된 어드레서블 에셋이 자동으로 해제되지 않을 수 있습니다. 씬 전환 시 명시적으로 관련 핸들을 해제하는 로직이 필요할 수 있습니다. Event Viewer를 통해 참조 카운트를 확인하는 것이 중요합니다.
  • 그룹 구성 전략:
    • 자주 함께 사용되는 에셋은 같은 그룹으로 묶습니다. (예: 특정 레벨의 모든 에셋)
    • 자주 업데이트되는 에셋은 별도의 원격 그룹으로 분리합니다.
    • 코어 에셋(항상 필요한 에셋)은 로컬 그룹에 포함시킵니다.
    • 에셋 크기와 로딩 단위를 고려하여 그룹 크기를 적절히 조절합니다. 너무 큰 그룹은 초기 로딩 시간을 늘릴 수 있습니다.

8. 결론

어드레서블 에셋 시스템은 현대 Unity 개발에서 필수적인 요소로 자리 잡고 있습니다. 초기 학습 곡선이 다소 존재하지만, 제공하는 장점(빌드 크기 최적화, 동적 업데이트, 효율적인 메모리 관리, 빠른 개발 속도 등)은 프로젝트의 규모와 복잡성이 증가할수록 더욱 빛을 발합니다. 에셋 관리 방식을 근본적으로 개선하여 더 유연하고 확장 가능한 프로젝트를 구축하는 데 크게 기여할 것입니다.