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 시스템을 효과적으로 활용하여 성능과 사용자 경험을 크게 향상시킬 수 있어요.
