앱인토스 개발자센터 로고
Skip to content

Addressable 가이드

개요

Addressable Asset System은 Unity에서 권장하는 최신 리소스 관리 방식이에요.
AppsInToss 미니앱에서는 효율적인 로딩과 메모리 관리를 위해 꼭 사용해야 해요.


Addressable 기본 설정

1. 프로젝트 설정

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

[CreateAssetMenu(fileName = "AddressableConfig", menuName = "AppsInToss/Addressable Config")]
public class AddressableConfig : ScriptableObject
{
    [Header("기본 설정")]
    public string remoteLoadPath = "https://cdn.appsintos.com/assets";
    public string buildPath = "ServerData/WebGL";
    public bool enableCaching = true;
    public long maxCacheSize = 1024 * 1024 * 100; // 100MB
    
    [Header("로딩 설정")]
    public int maxConcurrentLoads = 5;
    public float timeoutDuration = 30f;
    public bool enableRetry = true;
    public int maxRetryCount = 3;
    
    [Header("그룹 설정")]
    public AddressableGroupConfig[] groupConfigs;
    
    [System.Serializable]
    public class AddressableGroupConfig
    {
        public string groupName;
        public bool isRemote;
        public bool enableCompression;
        public BundledAssetGroupSchema.BundleCompressionMode compressionType;
        public int priority;
    }
}

2. 초기화 관리자

c#
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using System.Collections;
using System.Threading.Tasks;

public class AddressableManager : MonoBehaviour
{
    [Header("설정")]
    public AddressableConfig config;
    public bool initializeOnStart = true;
    
    private static AddressableManager instance;
    private bool isInitialized = false;
    private Dictionary<string, AsyncOperationHandle> loadedAssets = new Dictionary<string, AsyncOperationHandle>();
    private Queue<LoadRequest> loadQueue = new Queue<LoadRequest>();
    private int currentLoadOperations = 0;
    
    public static AddressableManager Instance
    {
        get
        {
            if (instance == null)
            {
                instance = FindObjectOfType<AddressableManager>();
                if (instance == null)
                {
                    GameObject go = new GameObject("AddressableManager");
                    instance = go.AddComponent<AddressableManager>();
                    DontDestroyOnLoad(go);
                }
            }
            return instance;
        }
    }
    
    [System.Serializable]
    private class LoadRequest
    {
        public string address;
        public System.Type type;
        public System.Action<AsyncOperationHandle> onComplete;
        public int priority;
        public float requestTime;
    }
    
    private void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject);
            
            if (initializeOnStart)
            {
                StartCoroutine(InitializeAsync());
            }
        }
        else if (instance != this)
        {
            Destroy(gameObject);
        }
    }
    
    private IEnumerator InitializeAsync()
    {
        DebugLogger.LogInfo("Addressable 시스템 초기화 시작", "Addressable");
        
        // Addressable 초기화
        var initHandle = Addressables.InitializeAsync();
        yield return initHandle;
        
        if (initHandle.Status == AsyncOperationStatus.Succeeded)
        {
            DebugLogger.LogInfo("Addressable 초기화 성공", "Addressable");
            
            // 카탈로그 업데이트 확인
            yield return StartCoroutine(CheckForCatalogUpdates());
            
            // 캐시 설정
            SetupCaching();
            
            isInitialized = true;
            
            // 로드 큐 처리 시작
            StartCoroutine(ProcessLoadQueue());
            
            // AppsInToss에 초기화 완료 알림
            NotifyAppsInTossInitialization(true);
        }
        else
        {
            DebugLogger.LogError($"Addressable 초기화 실패: {initHandle.OperationException}", "Addressable");
            NotifyAppsInTossInitialization(false);
        }
    }
    
    private IEnumerator CheckForCatalogUpdates()
    {
        DebugLogger.LogInfo("카탈로그 업데이트 확인 중...", "Addressable");
        
        var checkHandle = Addressables.CheckForCatalogUpdates(false);
        yield return checkHandle;
        
        if (checkHandle.Status == AsyncOperationStatus.Succeeded)
        {
            if (checkHandle.Result.Count > 0)
            {
                DebugLogger.LogInfo($"{checkHandle.Result.Count}개의 카탈로그 업데이트 발견", "Addressable");
                
                var updateHandle = Addressables.UpdateCatalogs(checkHandle.Result, false);
                yield return updateHandle;
                
                if (updateHandle.Status == AsyncOperationStatus.Succeeded)
                {
                    DebugLogger.LogInfo("카탈로그 업데이트 완료", "Addressable");
                }
            }
            else
            {
                DebugLogger.LogInfo("카탈로그 업데이트 없음", "Addressable");
            }
        }
        
        Addressables.Release(checkHandle);
    }
    
    private void SetupCaching()
    {
        if (config.enableCaching)
        {
            // 캐시 크기 설정
            Caching.maximumAvailableDiskSpace = config.maxCacheSize;
            DebugLogger.LogInfo($"캐시 최대 크기 설정: {config.maxCacheSize / 1024 / 1024}MB", "Addressable");
        }
    }
    
    private void NotifyAppsInTossInitialization(bool success)
    {
        string initData = JsonUtility.ToJson(new {
            success = success,
            timestamp = System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
            cacheEnabled = config.enableCaching,
            maxCacheSize = config.maxCacheSize
        });
        
        Application.ExternalCall("OnAddressableInitialized", initData);
    }
}

에셋 로딩 시스템

1. 스마트 로딩 관리

c#
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using System.Collections;
using System.Collections.Generic;
using System.Linq;

public class SmartAssetLoader : MonoBehaviour
{
    [Header("로딩 설정")]
    public int maxConcurrentLoads = 3;
    public float loadTimeoutSeconds = 30f;
    public bool enablePredictiveLoading = true;
    
    private Dictionary<string, AsyncOperationHandle> activeHandles = new Dictionary<string, AsyncOperationHandle>();
    private Dictionary<string, object> cachedAssets = new Dictionary<string, object>();
    private Queue<LoadOperation> loadQueue = new Queue<LoadOperation>();
    private HashSet<string> predictiveLoadRequests = new HashSet<string>();
    
    private class LoadOperation
    {
        public string address;
        public System.Type assetType;
        public System.Action<object> onSuccess;
        public System.Action<string> onFailure;
        public int priority;
        public float requestTime;
        public int retryCount;
    }
    
    private void Start()
    {
        StartCoroutine(ProcessLoadQueue());
        
        if (enablePredictiveLoading)
        {
            StartCoroutine(PredictiveLoadingRoutine());
        }
    }
    
    public void LoadAssetAsync<T>(string address, System.Action<T> onComplete, System.Action<string> onError = null, int priority = 0)
    {
        // 이미 캐시된 에셋 확인
        if (cachedAssets.TryGetValue(address, out object cachedAsset) && cachedAsset is T)
        {
            onComplete?.Invoke((T)cachedAsset);
            return;
        }
        
        // 로딩 큐에 추가
        LoadOperation operation = new LoadOperation
        {
            address = address,
            assetType = typeof(T),
            onSuccess = (asset) => onComplete?.Invoke((T)asset),
            onFailure = onError,
            priority = priority,
            requestTime = Time.time,
            retryCount = 0
        };
        
        // 우선순위에 따라 정렬된 위치에 삽입
        var tempList = loadQueue.ToList();
        tempList.Add(operation);
        tempList.Sort((a, b) => b.priority.CompareTo(a.priority));
        
        loadQueue.Clear();
        foreach (var op in tempList)
        {
            loadQueue.Enqueue(op);
        }
        
        DebugLogger.LogDebug($"에셋 로딩 요청: {address} (우선순위: {priority})", "AssetLoader");
    }
    
    private IEnumerator ProcessLoadQueue()
    {
        while (true)
        {
            while (loadQueue.Count > 0 && activeHandles.Count < maxConcurrentLoads)
            {
                LoadOperation operation = loadQueue.Dequeue();
                StartCoroutine(LoadAssetCoroutine(operation));
            }
            
            yield return new WaitForSeconds(0.1f);
        }
    }
    
    private IEnumerator LoadAssetCoroutine(LoadOperation operation)
    {
        string address = operation.address;
        
        // 중복 로딩 방지
        if (activeHandles.ContainsKey(address))
        {
            yield return new WaitUntil(() => !activeHandles.ContainsKey(address));
            
            if (cachedAssets.TryGetValue(address, out object asset))
            {
                operation.onSuccess?.Invoke(asset);
                yield break;
            }
        }
        
        DebugLogger.LogInfo($"에셋 로딩 시작: {address}", "AssetLoader");
        
        var handle = Addressables.LoadAssetAsync(address, operation.assetType);
        activeHandles[address] = handle;
        
        float startTime = Time.time;
        bool timedOut = false;
        
        // 타임아웃 처리
        while (!handle.IsDone)
        {
            if (Time.time - startTime > loadTimeoutSeconds)
            {
                timedOut = true;
                break;
            }
            yield return null;
        }
        
        activeHandles.Remove(address);
        
        if (timedOut)
        {
            DebugLogger.LogError($"에셋 로딩 타임아웃: {address}", "AssetLoader");
            Addressables.Release(handle);
            
            // 재시도
            if (operation.retryCount < 3)
            {
                operation.retryCount++;
                DebugLogger.LogInfo($"에셋 로딩 재시도 ({operation.retryCount}/3): {address}", "AssetLoader");
                yield return new WaitForSeconds(1f);
                StartCoroutine(LoadAssetCoroutine(operation));
            }
            else
            {
                operation.onFailure?.Invoke($"로딩 타임아웃: {address}");
            }
        }
        else if (handle.Status == AsyncOperationStatus.Succeeded)
        {
            DebugLogger.LogInfo($"에셋 로딩 완료: {address}", "AssetLoader");
            
            object loadedAsset = handle.Result;
            cachedAssets[address] = loadedAsset;
            operation.onSuccess?.Invoke(loadedAsset);
            
            // 성공적으로 로드된 에셋은 예측 로딩 목록에서 제거
            predictiveLoadRequests.Remove(address);
        }
        else
        {
            DebugLogger.LogError($"에셋 로딩 실패: {address} - {handle.OperationException}", "AssetLoader");
            Addressables.Release(handle);
            
            // 재시도
            if (operation.retryCount < 3)
            {
                operation.retryCount++;
                yield return new WaitForSeconds(2f);
                StartCoroutine(LoadAssetCoroutine(operation));
            }
            else
            {
                operation.onFailure?.Invoke(handle.OperationException?.Message ?? "로딩 실패");
            }
        }
    }
    
    private IEnumerator PredictiveLoadingRoutine()
    {
        while (true)
        {
            yield return new WaitForSeconds(5f);
            
            // 예측 로딩 로직 (게임 상태에 따라)
            string currentScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name;
            PredictAssetsForScene(currentScene);
        }
    }
    
    private void PredictAssetsForScene(string sceneName)
    {
        // 씬별 예측 로딩 로직
        List<string> predictedAssets = new List<string>();
        
        switch (sceneName)
        {
            case "MainMenu":
                predictedAssets.AddRange(new[] { "UI/GameModeSelect", "Audio/MenuMusic" });
                break;
            case "GamePlay":
                predictedAssets.AddRange(new[] { "Effects/ExplosionEffect", "Audio/GameplayMusic" });
                break;
        }
        
        foreach (string asset in predictedAssets)
        {
            if (!cachedAssets.ContainsKey(asset) && !predictiveLoadRequests.Contains(asset))
            {
                predictiveLoadRequests.Add(asset);
                LoadAssetAsync<Object>(asset, 
                    (loadedAsset) => DebugLogger.LogDebug($"예측 로딩 완료: {asset}", "PredictiveLoader"),
                    (error) => DebugLogger.LogWarning($"예측 로딩 실패: {asset} - {error}", "PredictiveLoader"),
                    -1 // 낮은 우선순위
                );
            }
        }
    }
    
    public void UnloadAsset(string address)
    {
        if (cachedAssets.Remove(address, out object asset))
        {
            if (activeHandles.TryGetValue(address, out AsyncOperationHandle handle))
            {
                Addressables.Release(handle);
                activeHandles.Remove(address);
            }
            
            DebugLogger.LogInfo($"에셋 언로드: {address}", "AssetLoader");
        }
    }
    
    public void ClearCache()
    {
        foreach (var handle in activeHandles.Values)
        {
            Addressables.Release(handle);
        }
        
        activeHandles.Clear();
        cachedAssets.Clear();
        predictiveLoadRequests.Clear();
        
        DebugLogger.LogInfo("에셋 캐시 클리어 완료", "AssetLoader");
    }
}

2. 씬별 에셋 관리

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

[CreateAssetMenu(fileName = "SceneAssetConfig", menuName = "AppsInToss/Scene Asset Config")]
public class SceneAssetConfig : ScriptableObject
{
    [System.Serializable]
    public class SceneAssetGroup
    {
        public string sceneName;
        public AssetReferenceT<Object>[] preloadAssets;
        public AssetReferenceT<Object>[] lazyLoadAssets;
        public string[] addressesToPreload;
        public string[] addressesToLazyLoad;
    }
    
    [Header("씬별 에셋 설정")]
    public SceneAssetGroup[] sceneAssets;
}

public class SceneAssetManager : MonoBehaviour
{
    [Header("설정")]
    public SceneAssetConfig config;
    
    private Dictionary<string, List<AsyncOperationHandle>> sceneHandles = new Dictionary<string, List<AsyncOperationHandle>>();
    private Dictionary<string, List<object>> sceneAssets = new Dictionary<string, List<object>>();
    private string currentScene = "";
    
    private void Start()
    {
        UnityEngine.SceneManagement.SceneManager.sceneLoaded += OnSceneLoaded;
        UnityEngine.SceneManagement.SceneManager.sceneUnloaded += OnSceneUnloaded;
    }
    
    private void OnDestroy()
    {
        UnityEngine.SceneManagement.SceneManager.sceneLoaded -= OnSceneLoaded;
        UnityEngine.SceneManagement.SceneManager.sceneUnloaded -= OnSceneUnloaded;
    }
    
    private void OnSceneLoaded(UnityEngine.SceneManagement.Scene scene, UnityEngine.SceneManagement.LoadSceneMode mode)
    {
        currentScene = scene.name;
        StartCoroutine(LoadSceneAssets(scene.name));
    }
    
    private void OnSceneUnloaded(UnityEngine.SceneManagement.Scene scene)
    {
        UnloadSceneAssets(scene.name);
    }
    
    private IEnumerator LoadSceneAssets(string sceneName)
    {
        DebugLogger.LogInfo($"씬 에셋 로딩 시작: {sceneName}", "SceneAssetManager");
        
        var sceneConfig = GetSceneConfig(sceneName);
        if (sceneConfig == null)
        {
            DebugLogger.LogWarning($"씬 설정을 찾을 수 없음: {sceneName}", "SceneAssetManager");
            yield break;
        }
        
        List<AsyncOperationHandle> handles = new List<AsyncOperationHandle>();
        List<object> assets = new List<object>();
        
        // 프리로드 에셋들 로딩
        yield return StartCoroutine(LoadAssetGroup(sceneConfig.preloadAssets, sceneConfig.addressesToPreload, handles, assets, "preload"));
        
        sceneHandles[sceneName] = handles;
        sceneAssets[sceneName] = assets;
        
        DebugLogger.LogInfo($"씬 프리로드 완료: {sceneName} ({handles.Count}개 에셋)", "SceneAssetManager");
        
        // 지연 로딩 에셋들은 백그라운드에서 로딩
        StartCoroutine(LoadLazyAssets(sceneName, sceneConfig));
    }
    
    private IEnumerator LoadLazyAssets(string sceneName, SceneAssetConfig.SceneAssetGroup sceneConfig)
    {
        yield return new WaitForSeconds(1f); // 잠시 대기 후 시작
        
        if (currentScene != sceneName) yield break; // 씬이 바뀌었으면 중단
        
        if (!sceneHandles.TryGetValue(sceneName, out List<AsyncOperationHandle> handles))
        {
            handles = new List<AsyncOperationHandle>();
            sceneHandles[sceneName] = handles;
        }
        
        if (!sceneAssets.TryGetValue(sceneName, out List<object> assets))
        {
            assets = new List<object>();
            sceneAssets[sceneName] = assets;
        }
        
        yield return StartCoroutine(LoadAssetGroup(sceneConfig.lazyLoadAssets, sceneConfig.addressesToLazyLoad, handles, assets, "lazy"));
        
        DebugLogger.LogInfo($"씬 지연 로딩 완료: {sceneName}", "SceneAssetManager");
    }
    
    private IEnumerator LoadAssetGroup(AssetReferenceT<Object>[] assetRefs, string[] addresses, 
        List<AsyncOperationHandle> handles, List<object> assets, string groupType)
    {
        // AssetReference 로딩
        if (assetRefs != null)
        {
            foreach (var assetRef in assetRefs)
            {
                if (assetRef == null || !assetRef.RuntimeKeyIsValid()) continue;
                
                var handle = assetRef.LoadAssetAsync();
                yield return handle;
                
                if (handle.Status == AsyncOperationStatus.Succeeded)
                {
                    handles.Add(handle);
                    assets.Add(handle.Result);
                    DebugLogger.LogDebug($"에셋 로딩 성공 ({groupType}): {assetRef.AssetGUID}", "SceneAssetManager");
                }
                else
                {
                    DebugLogger.LogError($"에셋 로딩 실패 ({groupType}): {assetRef.AssetGUID}", "SceneAssetManager");
                    Addressables.Release(handle);
                }
            }
        }
        
        // 주소 기반 로딩
        if (addresses != null)
        {
            foreach (string address in addresses)
            {
                if (string.IsNullOrEmpty(address)) continue;
                
                var handle = Addressables.LoadAssetAsync<Object>(address);
                yield return handle;
                
                if (handle.Status == AsyncOperationStatus.Succeeded)
                {
                    handles.Add(handle);
                    assets.Add(handle.Result);
                    DebugLogger.LogDebug($"에셋 로딩 성공 ({groupType}): {address}", "SceneAssetManager");
                }
                else
                {
                    DebugLogger.LogError($"에셋 로딩 실패 ({groupType}): {address}", "SceneAssetManager");
                    Addressables.Release(handle);
                }
            }
        }
    }
    
    private void UnloadSceneAssets(string sceneName)
    {
        if (sceneHandles.TryGetValue(sceneName, out List<AsyncOperationHandle> handles))
        {
            foreach (var handle in handles)
            {
                if (handle.IsValid())
                {
                    Addressables.Release(handle);
                }
            }
            sceneHandles.Remove(sceneName);
        }
        
        sceneAssets.Remove(sceneName);
        
        DebugLogger.LogInfo($"씬 에셋 언로드 완료: {sceneName}", "SceneAssetManager");
    }
    
    private SceneAssetConfig.SceneAssetGroup GetSceneConfig(string sceneName)
    {
        if (config == null || config.sceneAssets == null) return null;
        
        foreach (var sceneAsset in config.sceneAssets)
        {
            if (sceneAsset.sceneName == sceneName)
                return sceneAsset;
        }
        
        return null;
    }
    
    public T GetSceneAsset<T>(string sceneName, string assetName) where T : Object
    {
        if (sceneAssets.TryGetValue(sceneName, out List<object> assets))
        {
            foreach (object asset in assets)
            {
                if (asset is T typedAsset && typedAsset.name == assetName)
                {
                    return typedAsset;
                }
            }
        }
        
        return null;
    }
}

앱인토스 플랫폼 브릿지

1. CDN 에셋 관리

tsx

interface CDNAssetInfo {
    address: string;
    url: string;
    size: number;
    hash: string;
    version: string;
    compressionType: string;
}

interface LoadProgress {
    address: string;
    bytesLoaded: number;
    totalBytes: number;
    progress: number;
    speed: number;
}

class CDNAssetManager {
    private static instance: CDNAssetManager;
    private baseUrl: string = '';
    private assetManifest: Map<string, CDNAssetInfo> = new Map();
    private downloadQueue: CDNAssetInfo[] = [];
    private maxConcurrentDownloads = 3;
    private currentDownloads = 0;
    private downloadProgressCallbacks: Map<string, (progress: LoadProgress) => void> = new Map();
    
    public static getInstance(): CDNAssetManager {
        if (!CDNAssetManager.instance) {
            CDNAssetManager.instance = new CDNAssetManager();
        }
        return CDNAssetManager.instance;
    }
    
    public async initialize(baseUrl: string): Promise<void> {
        this.baseUrl = baseUrl;
        
        try {
            // 매니페스트 다운로드
            const manifestUrl = `${baseUrl}/catalog.json`;
            const response = await fetch(manifestUrl);
            const manifest = await response.json();
            
            this.parseManifest(manifest);
            
            // Unity에 매니페스트 정보 전송
            const unityInstance = (window as any).unityInstance;
            if (unityInstance) {
                unityInstance.SendMessage('AddressableManager', 'OnManifestLoaded', JSON.stringify({
                    assetCount: this.assetManifest.size,
                    totalSize: this.getTotalSize()
                }));
            }
            
            console.log(`CDN 매니페스트 로드 완료: ${this.assetManifest.size}개 에셋`);
            
        } catch (error) {
            console.error('CDN 매니페스트 로드 실패:', error);
            throw error;
        }
    }
    
    private parseManifest(manifest: any): void {
        if (manifest.assets) {
            for (const asset of manifest.assets) {
                const assetInfo: CDNAssetInfo = {
                    address: asset.address,
                    url: `${this.baseUrl}/${asset.path}`,
                    size: asset.size || 0,
                    hash: asset.hash || '',
                    version: asset.version || '1.0',
                    compressionType: asset.compression || 'none'
                };
                
                this.assetManifest.set(asset.address, assetInfo);
            }
        }
    }
    
    private getTotalSize(): number {
        let totalSize = 0;
        for (const assetInfo of this.assetManifest.values()) {
            totalSize += assetInfo.size;
        }
        return totalSize;
    }
    
    public getAssetInfo(address: string): CDNAssetInfo | null {
        return this.assetManifest.get(address) || null;
    }
    
    public setDownloadProgressCallback(address: string, callback: (progress: LoadProgress) => void): void {
        this.downloadProgressCallbacks.set(address, callback);
    }
    
    public async downloadAsset(address: string): Promise<ArrayBuffer> {
        const assetInfo = this.assetManifest.get(address);
        if (!assetInfo) {
            throw new Error(`에셋을 찾을 수 없음: ${address}`);
        }
        
        return new Promise((resolve, reject) => {
            const xhr = new XMLHttpRequest();
            xhr.open('GET', assetInfo.url, true);
            xhr.responseType = 'arraybuffer';
            
            const startTime = performance.now();
            let lastProgressTime = startTime;
            let lastLoadedBytes = 0;
            
            xhr.onprogress = (event) => {
                if (event.lengthComputable) {
                    const currentTime = performance.now();
                    const timeDiff = (currentTime - lastProgressTime) / 1000;
                    const bytesDiff = event.loaded - lastLoadedBytes;
                    const speed = timeDiff > 0 ? bytesDiff / timeDiff : 0;
                    
                    const progress: LoadProgress = {
                        address: address,
                        bytesLoaded: event.loaded,
                        totalBytes: event.total,
                        progress: event.loaded / event.total,
                        speed: speed
                    };
                    
                    const callback = this.downloadProgressCallbacks.get(address);
                    if (callback) {
                        callback(progress);
                    }
                    
                    // Unity에 진행 상황 전송
                    const unityInstance = (window as any).unityInstance;
                    if (unityInstance) {
                        unityInstance.SendMessage('AddressableManager', 'OnDownloadProgress', JSON.stringify(progress));
                    }
                    
                    lastProgressTime = currentTime;
                    lastLoadedBytes = event.loaded;
                }
            };
            
            xhr.onload = () => {
                if (xhr.status === 200) {
                    const totalTime = (performance.now() - startTime) / 1000;
                    console.log(`에셋 다운로드 완료: ${address} (${(assetInfo.size / 1024 / 1024).toFixed(2)}MB, ${totalTime.toFixed(2)}초)`);
                    resolve(xhr.response);
                } else {
                    reject(new Error(`다운로드 실패: ${xhr.status}`));
                }
            };
            
            xhr.onerror = () => {
                reject(new Error('네트워크 오류'));
            };
            
            xhr.send();
        });
    }
    
}

// Unity에서 호출할 함수들
(window as any).InitializeCDNAssets = async (baseUrl: string) => {
    try {
        await CDNAssetManager.getInstance().initialize(baseUrl);
        return true;
    } catch (error) {
        console.error('CDN 초기화 실패:', error);
        return false;
    }
};

(window as any).GetAssetInfo = (address: string) => {
    const assetInfo = CDNAssetManager.getInstance().getAssetInfo(address);
    return assetInfo ? JSON.stringify(assetInfo) : null;
};

2. 오프라인 캐싱 시스템

c#
using UnityEngine;
using UnityEngine.AddressableAssets;
using System.Collections.Generic;
using System.Collections;
using System.IO;

public class OfflineCacheManager : MonoBehaviour
{
    [Header("캐시 설정")]
    public long maxCacheSize = 200 * 1024 * 1024; // 200MB
    public float cacheCleanupInterval = 300f; // 5분
    public bool enableOfflineMode = true;
    
    [Header("우선순위 설정")]
    public string[] highPriorityAssets;
    public string[] lowPriorityAssets;
    
    private Dictionary<string, CacheEntry> cacheEntries = new Dictionary<string, CacheEntry>();
    private long currentCacheSize = 0;
    private string cacheDirectory;
    
    [System.Serializable]
    public class CacheEntry
    {
        public string address;
        public string filePath;
        public long fileSize;
        public System.DateTime lastAccessed;
        public System.DateTime cachedTime;
        public int priority;
        public string hash;
    }
    
    private void Start()
    {
        InitializeCache();
        
        if (enableOfflineMode)
        {
            InvokeRepeating(nameof(CleanupCache), cacheCleanupInterval, cacheCleanupInterval);
        }
    }
    
    private void InitializeCache()
    {
        cacheDirectory = Path.Combine(Application.persistentDataPath, "AddressableCache");
        
        if (!Directory.Exists(cacheDirectory))
        {
            Directory.CreateDirectory(cacheDirectory);
        }
        
        LoadCacheIndex();
        CalculateCacheSize();
        
        DebugLogger.LogInfo($"오프라인 캐시 초기화 완료: {currentCacheSize / 1024 / 1024}MB / {maxCacheSize / 1024 / 1024}MB", "OfflineCache");
    }
    
    private void LoadCacheIndex()
    {
        string indexPath = Path.Combine(cacheDirectory, "cache_index.json");
        
        if (File.Exists(indexPath))
        {
            try
            {
                string json = File.ReadAllText(indexPath);
                var cacheData = JsonUtility.FromJson<CacheIndexData>(json);
                
                foreach (var entry in cacheData.entries)
                {
                    if (File.Exists(entry.filePath))
                    {
                        cacheEntries[entry.address] = entry;
                    }
                }
                
                DebugLogger.LogInfo($"캐시 인덱스 로드됨: {cacheEntries.Count}개 항목", "OfflineCache");
            }
            catch (System.Exception e)
            {
                DebugLogger.LogError($"캐시 인덱스 로드 실패: {e.Message}", "OfflineCache");
            }
        }
    }
    
    private void SaveCacheIndex()
    {
        string indexPath = Path.Combine(cacheDirectory, "cache_index.json");
        
        try
        {
            var cacheData = new CacheIndexData { entries = new List<CacheEntry>(cacheEntries.Values) };
            string json = JsonUtility.ToJson(cacheData, true);
            File.WriteAllText(indexPath, json);
        }
        catch (System.Exception e)
        {
            DebugLogger.LogError($"캐시 인덱스 저장 실패: {e.Message}", "OfflineCache");
        }
    }
    
    [System.Serializable]
    private class CacheIndexData
    {
        public List<CacheEntry> entries = new List<CacheEntry>();
    }
    
    private void CalculateCacheSize()
    {
        currentCacheSize = 0;
        
        foreach (var entry in cacheEntries.Values)
        {
            if (File.Exists(entry.filePath))
            {
                entry.fileSize = new FileInfo(entry.filePath).Length;
                currentCacheSize += entry.fileSize;
            }
        }
    }
    
    public bool IsAssetCached(string address)
    {
        if (!cacheEntries.TryGetValue(address, out CacheEntry entry))
            return false;
        
        return File.Exists(entry.filePath);
    }
    
    public void CacheAsset(string address, byte[] data, string hash = "")
    {
        if (currentCacheSize + data.Length > maxCacheSize)
        {
            FreeUpSpace(data.Length);
        }
        
        string fileName = GetCacheFileName(address);
        string filePath = Path.Combine(cacheDirectory, fileName);
        
        try
        {
            File.WriteAllBytes(filePath, data);
            
            var entry = new CacheEntry
            {
                address = address,
                filePath = filePath,
                fileSize = data.Length,
                lastAccessed = System.DateTime.Now,
                cachedTime = System.DateTime.Now,
                priority = GetAssetPriority(address),
                hash = hash
            };
            
            // 기존 항목 제거 (있는 경우)
            if (cacheEntries.TryGetValue(address, out CacheEntry oldEntry))
            {
                currentCacheSize -= oldEntry.fileSize;
                if (File.Exists(oldEntry.filePath))
                {
                    File.Delete(oldEntry.filePath);
                }
            }
            
            cacheEntries[address] = entry;
            currentCacheSize += data.Length;
            
            SaveCacheIndex();
            
            DebugLogger.LogDebug($"에셋 캐시됨: {address} ({data.Length / 1024}KB)", "OfflineCache");
        }
        catch (System.Exception e)
        {
            DebugLogger.LogError($"에셋 캐시 실패: {address} - {e.Message}", "OfflineCache");
        }
    }
    
    public byte[] GetCachedAsset(string address)
    {
        if (!cacheEntries.TryGetValue(address, out CacheEntry entry))
            return null;
        
        if (!File.Exists(entry.filePath))
            return null;
        
        try
        {
            entry.lastAccessed = System.DateTime.Now;
            byte[] data = File.ReadAllBytes(entry.filePath);
            
            DebugLogger.LogDebug($"캐시된 에셋 로드: {address}", "OfflineCache");
            return data;
        }
        catch (System.Exception e)
        {
            DebugLogger.LogError($"캐시된 에셋 로드 실패: {address} - {e.Message}", "OfflineCache");
            return null;
        }
    }
    
    private void FreeUpSpace(long requiredSpace)
    {
        long spaceToFree = requiredSpace + (maxCacheSize * 0.1f); // 10% 여유 공간
        var entriesToRemove = new List<string>();
        
        // 우선순위와 마지막 접근 시간 기준으로 정렬
        var sortedEntries = new List<CacheEntry>(cacheEntries.Values);
        sortedEntries.Sort((a, b) => {
            if (a.priority != b.priority)
                return a.priority.CompareTo(b.priority); // 낮은 우선순위 먼저
            return a.lastAccessed.CompareTo(b.lastAccessed); // 오래된 것 먼저
        });
        
        long freedSpace = 0;
        foreach (var entry in sortedEntries)
        {
            if (freedSpace >= spaceToFree)
                break;
            
            entriesToRemove.Add(entry.address);
            freedSpace += entry.fileSize;
        }
        
        // 선택된 항목들 삭제
        foreach (string address in entriesToRemove)
        {
            RemoveCachedAsset(address);
        }
        
        DebugLogger.LogInfo($"캐시 정리 완료: {freedSpace / 1024 / 1024}MB 확보", "OfflineCache");
    }
    
    private void RemoveCachedAsset(string address)
    {
        if (cacheEntries.TryGetValue(address, out CacheEntry entry))
        {
            if (File.Exists(entry.filePath))
            {
                try
                {
                    File.Delete(entry.filePath);
                    currentCacheSize -= entry.fileSize;
                }
                catch (System.Exception e)
                {
                    DebugLogger.LogError($"캐시 파일 삭제 실패: {entry.filePath} - {e.Message}", "OfflineCache");
                }
            }
            
            cacheEntries.Remove(address);
        }
    }
    
    private int GetAssetPriority(string address)
    {
        if (System.Array.IndexOf(highPriorityAssets, address) >= 0)
            return 10; // 높은 우선순위
        if (System.Array.IndexOf(lowPriorityAssets, address) >= 0)
            return 1; // 낮은 우선순위
        return 5; // 보통 우선순위
    }
    
    private string GetCacheFileName(string address)
    {
        // 주소를 파일명으로 변환 (특수문자 제거)
        string fileName = address.Replace("/", "_").Replace("\\", "_").Replace(":", "_");
        return $"{fileName}.cache";
    }
    
    private void CleanupCache()
    {
        var expiredEntries = new List<string>();
        var expireTime = System.TimeSpan.FromDays(7); // 7일 후 만료
        
        foreach (var kvp in cacheEntries)
        {
            if (System.DateTime.Now - kvp.Value.cachedTime > expireTime)
            {
                expiredEntries.Add(kvp.Key);
            }
        }
        
        foreach (string address in expiredEntries)
        {
            RemoveCachedAsset(address);
        }
        
        if (expiredEntries.Count > 0)
        {
            SaveCacheIndex();
            DebugLogger.LogInfo($"만료된 캐시 정리: {expiredEntries.Count}개 항목", "OfflineCache");
        }
    }
    
    public void ClearAllCache()
    {
        foreach (var entry in cacheEntries.Values)
        {
            if (File.Exists(entry.filePath))
            {
                File.Delete(entry.filePath);
            }
        }
        
        cacheEntries.Clear();
        currentCacheSize = 0;
        SaveCacheIndex();
        
        DebugLogger.LogInfo("모든 캐시 삭제 완료", "OfflineCache");
    }
    
    public string GetCacheStatus()
    {
        return JsonUtility.ToJson(new {
            cachedAssets = cacheEntries.Count,
            totalSize = currentCacheSize,
            maxSize = maxCacheSize,
            usagePercentage = (float)currentCacheSize / maxCacheSize * 100
        });
    }
}

메모리 최적화 및 성능

1. 메모리 효율적인 로딩

c#
using UnityEngine;
using UnityEngine.AddressableAssets;
using System.Collections.Generic;

public class MemoryEfficientLoader : MonoBehaviour
{
    [Header("메모리 관리")]
    public long memoryBudget = 50 * 1024 * 1024; // 50MB
    public float memoryCheckInterval = 5f;
    public bool enableAdaptiveUnloading = true;
    
    private Dictionary<string, LoadedAssetInfo> loadedAssets = new Dictionary<string, LoadedAssetInfo>();
    private Queue<string> unloadQueue = new Queue<string>();
    
    [System.Serializable]
    public class LoadedAssetInfo
    {
        public object asset;
        public AsyncOperationHandle handle;
        public long memorySize;
        public System.DateTime loadTime;
        public System.DateTime lastUsed;
        public int useCount;
        public bool isPermanent;
    }
    
    private void Start()
    {
        if (enableAdaptiveUnloading)
        {
            InvokeRepeating(nameof(CheckMemoryUsage), memoryCheckInterval, memoryCheckInterval);
        }
    }
    
    private void CheckMemoryUsage()
    {
        long totalMemory = System.GC.GetTotalMemory(false);
        long assetMemory = CalculateAssetMemoryUsage();
        
        if (totalMemory > memoryBudget || assetMemory > memoryBudget * 0.8f)
        {
            UnloadUnusedAssets();
        }
    }
    
    private long CalculateAssetMemoryUsage()
    {
        long total = 0;
        foreach (var info in loadedAssets.Values)
        {
            total += info.memorySize;
        }
        return total;
    }
    
    private void UnloadUnusedAssets()
    {
        var assetsToUnload = new List<string>();
        var now = System.DateTime.Now;
        
        // 사용되지 않은 에셋들을 찾아서 언로드 큐에 추가
        foreach (var kvp in loadedAssets)
        {
            var info = kvp.Value;
            
            if (info.isPermanent) continue;
            
            // 마지막 사용 후 5분 이상 지난 에셋
            if ((now - info.lastUsed).TotalMinutes > 5 && info.useCount == 0)
            {
                assetsToUnload.Add(kvp.Key);
            }
        }
        
        // 사용 빈도와 메모리 크기를 고려하여 정렬
        assetsToUnload.Sort((a, b) => {
            var infoA = loadedAssets[a];
            var infoB = loadedAssets[b];
            
            // 사용 빈도가 낮고 메모리를 많이 차지하는 것 우선
            float scoreA = infoA.memorySize / (float)(infoA.useCount + 1);
            float scoreB = infoB.memorySize / (float)(infoB.useCount + 1);
            
            return scoreB.CompareTo(scoreA);
        });
        
        // 메모리 사용량이 임계값 이하가 될 때까지 언로드
        long currentMemory = CalculateAssetMemoryUsage();
        long targetMemory = memoryBudget / 2; // 절반까지 줄이기
        
        foreach (string address in assetsToUnload)
        {
            if (currentMemory <= targetMemory) break;
            
            var info = loadedAssets[address];
            currentMemory -= info.memorySize;
            
            UnloadAsset(address);
        }
        
        if (assetsToUnload.Count > 0)
        {
            DebugLogger.LogInfo($"메모리 정리: {assetsToUnload.Count}개 에셋 언로드", "MemoryManager");
        }
    }
    
    private void UnloadAsset(string address)
    {
        if (loadedAssets.TryGetValue(address, out LoadedAssetInfo info))
        {
            if (info.handle.IsValid())
            {
                Addressables.Release(info.handle);
            }
            
            loadedAssets.Remove(address);
            DebugLogger.LogDebug($"에셋 언로드: {address} ({info.memorySize / 1024}KB 해제)", "MemoryManager");
        }
    }
    
    public void MarkAssetAsUsed(string address)
    {
        if (loadedAssets.TryGetValue(address, out LoadedAssetInfo info))
        {
            info.lastUsed = System.DateTime.Now;
            info.useCount++;
        }
    }
    
    public void MarkAssetAsPermanent(string address, bool permanent = true)
    {
        if (loadedAssets.TryGetValue(address, out LoadedAssetInfo info))
        {
            info.isPermanent = permanent;
            DebugLogger.LogInfo($"에셋 {address}을(를) {(permanent ? "영구" : "임시")}로 설정", "MemoryManager");
        }
    }
    
    private long EstimateAssetMemorySize(object asset)
    {
        // 에셋 타입별 메모리 사용량 추정
        if (asset is Texture2D texture)
        {
            return UnityEngine.Profiling.Profiler.GetRuntimeMemorySizeLong(texture);
        }
        else if (asset is AudioClip audioClip)
        {
            return UnityEngine.Profiling.Profiler.GetRuntimeMemorySizeLong(audioClip);
        }
        else if (asset is Mesh mesh)
        {
            return UnityEngine.Profiling.Profiler.GetRuntimeMemorySizeLong(mesh);
        }
        else
        {
            // 기본 추정값
            return 1024; // 1KB
        }
    }
}

베스트 프랙티스

  • 점진적 로딩: 필요한 에셋만 적시에 로딩
  • 스마트 캐싱: 사용 패턴을 고려한 캐시 관리
  • 메모리 모니터링: 지속적인 메모리 사용량 모니터링
  • 오프라인 대응: 네트워크 상태에 따른 적절한 대체 방안
  • AppsInToss 통합: 플랫폼 기능을 활용한 최적화

이 가이드를 통해 Unity WebGL에서 Addressable 시스템을 효과적으로 활용하여 성능과 사용자 경험을 크게 향상시킬 수 있어요.