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

사전 다운로드 가이드

앱인토스 Unity 게임에서 사전 다운로드 기능을 구현하여 게임 플레이 중 끊김 없는 경험을 제공하는 방법을 다뤄요.


1. 사전 다운로드 시스템 개요

사전 다운로드 전략

📥 사전 다운로드 아키텍처
├── 필수 콘텐츠 (게임 시작 전 다운로드)
│   ├── 핵심 게임 로직
│   ├── 기본 UI 리소스
│   ├── 첫 레벨 에셋
│   └── 필수 오디오
├── 우선순위 콘텐츠 (백그라운드 다운로드)
│   ├── 다음 레벨 에셋
│   ├── 캐릭터 스킨
│   ├── 추가 음악
│   └── 이펙트 에셋
├── 선택적 콘텐츠 (필요시 다운로드)
│   ├── 고해상도 텍스처
│   ├── 보너스 콘텐츠
│   ├── 계절 이벤트 에셋
│   └── 언어팩
└── 예측적 콘텐츠 (사용자 패턴 기반)
    ├── 자주 플레이하는 레벨
    ├── 선호 캐릭터 관련
    ├── 개인화 콘텐츠
    └── 추천 에셋

사전 다운로드 매니저

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

public class AppsInTossPreloadManager : MonoBehaviour
{
    public static AppsInTossPreloadManager Instance { get; private set; }
    
    [System.Serializable]
    public class PreloadItem
    {
        [Header("기본 정보")]
        public string itemId;
        public string itemName;
        public string description;
        public float sizeMB;
        public string version = "1.0.0";
        
        [Header("다운로드 설정")]
        public PreloadPriority priority;
        public PreloadTrigger downloadTrigger;
        public string[] dependencies;
        public string remoteUrl;
        public string localPath;
        
        [Header("조건 설정")]
        public bool requiresWiFi = false;
        public bool downloadOnlyWhenIdle = false;
        public int minimumBatteryLevel = 20;
        public long maxStorageUsageMB = 500;
        
        [Header("게임 로직")]
        public string[] requiredForScenes;
        public string[] requiredForFeatures;
        public bool isUserGenerated = false;
        public System.DateTime expiryDate;
    }
    
    public enum PreloadPriority
    {
        Critical = 0,    // 게임 진행에 필수
        High = 1,        // 사용자 경험 향상에 중요
        Medium = 2,      // 부가 기능
        Low = 3,         // 선택적 콘텐츠
        OnDemand = 4     // 요청시에만 다운로드
    }
    
    public enum PreloadTrigger
    {
        Immediate,       // 즉시 다운로드
        OnGameStart,     // 게임 시작시
        OnSceneLoad,     // 특정 씬 로드시
        OnFeatureAccess, // 기능 접근시
        OnUserRequest,   // 사용자 요청시
        OnWiFiAvailable, // WiFi 연결시
        OnIdle,          // 유휴 시간에
        OnBatteryOK      // 배터리 충분시
    }
    
    [Header("사전 다운로드 설정")]
    public PreloadItem[] preloadItems;
    
    [Header("네트워크 설정")]
    public int maxConcurrentDownloads = 2;
    public float downloadTimeoutSeconds = 30f;
    public bool enableBackgroundDownload = true;
    public bool enablePredictiveDownload = true;
    
    [Header("저장소 관리")]
    public long maxTotalCacheSizeMB = 1000;
    public bool enableAutomaticCleanup = true;
    public int keepRecentDays = 30;
    
    // 내부 상태
    private Dictionary<string, PreloadItem> itemMap = new Dictionary<string, PreloadItem>();
    private HashSet<string> downloadedItems = new HashSet<string>();
    private HashSet<string> downloadingItems = new HashSet<string>();
    private Queue<PreloadItem> downloadQueue = new Queue<PreloadItem>();
    private Dictionary<string, float> downloadProgress = new Dictionary<string, float>();
    private int activeDownloads = 0;
    private bool isInitialized = false;
    
    // 사용자 패턴 분석
    private Dictionary<string, int> usageFrequency = new Dictionary<string, int>();
    private Dictionary<string, System.DateTime> lastUsedTime = new Dictionary<string, System.DateTime>();
    
    void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
            InitializePreloadSystem();
        }
        else
        {
            Destroy(gameObject);
        }
    }
    
    void InitializePreloadSystem()
    {
        // 아이템 맵 생성
        foreach (var item in preloadItems)
        {
            itemMap[item.itemId] = item;
        }
        
        // 로컬 캐시 상태 확인
        LoadCacheStatus();
        
        // 사용 패턴 데이터 로드
        LoadUsagePatterns();
        
        // 시스템 상태 모니터링 시작
        StartCoroutine(MonitorSystemConditions());
        
        // 초기 다운로드 큐 설정
        SetupInitialDownloadQueue();
        
        isInitialized = true;
        Debug.Log("AppsInToss 사전 다운로드 시스템 초기화 완료");
    }
    
    void LoadCacheStatus()
    {
        string cacheStatusPath = Path.Combine(Application.persistentDataPath, "preload_cache_status.json");
        
        if (File.Exists(cacheStatusPath))
        {
            try
            {
                string json = File.ReadAllText(cacheStatusPath);
                var cacheStatus = JsonUtility.FromJson<CacheStatus>(json);
                
                downloadedItems = new HashSet<string>(cacheStatus.downloadedItems);
                
                Debug.Log($"캐시 상태 로드 완료: {downloadedItems.Count}개 아이템");
            }
            catch (System.Exception e)
            {
                Debug.LogError($"캐시 상태 로드 실패: {e.Message}");
            }
        }
    }
    
    void SaveCacheStatus()
    {
        string cacheStatusPath = Path.Combine(Application.persistentDataPath, "preload_cache_status.json");
        
        var cacheStatus = new CacheStatus
        {
            downloadedItems = downloadedItems.ToArray(),
            lastUpdateTime = System.DateTime.UtcNow.ToString("o")
        };
        
        try
        {
            string json = JsonUtility.ToJson(cacheStatus, true);
            File.WriteAllText(cacheStatusPath, json);
        }
        catch (System.Exception e)
        {
            Debug.LogError($"캐시 상태 저장 실패: {e.Message}");
        }
    }
    
    void LoadUsagePatterns()
    {
        string patternsPath = Path.Combine(Application.persistentDataPath, "usage_patterns.json");
        
        if (File.Exists(patternsPath))
        {
            try
            {
                string json = File.ReadAllText(patternsPath);
                var patterns = JsonUtility.FromJson<UsagePatterns>(json);
                
                foreach (var item in patterns.items)
                {
                    usageFrequency[item.itemId] = item.frequency;
                    if (System.DateTime.TryParse(item.lastUsed, out System.DateTime lastUsed))
                    {
                        lastUsedTime[item.itemId] = lastUsed;
                    }
                }
                
                Debug.Log($"사용 패턴 로드 완료: {usageFrequency.Count}개 항목");
            }
            catch (System.Exception e)
            {
                Debug.LogError($"사용 패턴 로드 실패: {e.Message}");
            }
        }
    }
    
    void SaveUsagePatterns()
    {
        string patternsPath = Path.Combine(Application.persistentDataPath, "usage_patterns.json");
        
        var patterns = new UsagePatterns();
        patterns.items = new List<UsagePatternItem>();
        
        foreach (var kvp in usageFrequency)
        {
            var item = new UsagePatternItem
            {
                itemId = kvp.Key,
                frequency = kvp.Value,
                lastUsed = lastUsedTime.ContainsKey(kvp.Key) ? 
                          lastUsedTime[kvp.Key].ToString("o") : 
                          System.DateTime.UtcNow.ToString("o")
            };
            patterns.items.Add(item);
        }
        
        try
        {
            string json = JsonUtility.ToJson(patterns, true);
            File.WriteAllText(patternsPath, json);
        }
        catch (System.Exception e)
        {
            Debug.LogError($"사용 패턴 저장 실패: {e.Message}");
        }
    }
    
    IEnumerator MonitorSystemConditions()
    {
        while (true)
        {
            // 네트워크 상태 확인
            bool isWiFiAvailable = Application.internetReachability == NetworkReachability.ReachableViaLocalAreaNetwork;
            
            // 배터리 상태 확인 (모바일 플랫폼에서만)
            float batteryLevel = SystemInfo.batteryLevel * 100f;
            
            // 저장 공간 확인
            long availableStorage = GetAvailableStorageSpace();
            
            // 시스템 조건에 따른 다운로드 제어
            UpdateDownloadBehavior(isWiFiAvailable, batteryLevel, availableStorage);
            
            // 다운로드 큐 처리
            ProcessDownloadQueue();
            
            yield return new WaitForSeconds(5f);
        }
    }
    
    void UpdateDownloadBehavior(bool isWiFiAvailable, float batteryLevel, long availableStorage)
    {
        // WiFi 전용 아이템들 처리
        if (isWiFiAvailable)
        {
            var wifiOnlyItems = preloadItems.Where(item => 
                item.requiresWiFi && 
                !downloadedItems.Contains(item.itemId) && 
                !downloadingItems.Contains(item.itemId)
            ).ToArray();
            
            foreach (var item in wifiOnlyItems)
            {
                if (ShouldDownloadItem(item, batteryLevel, availableStorage))
                {
                    QueueItemForDownload(item);
                }
            }
        }
        
        // 배터리 조건 확인
        if (batteryLevel < 20f)
        {
            // 배터리가 부족하면 중요하지 않은 다운로드 일시 정지
            PauseNonCriticalDownloads();
        }
        
        // 저장 공간 부족시 정리
        if (availableStorage < 100 * 1024 * 1024) // 100MB 미만
        {
            StartCoroutine(CleanupOldCache());
        }
    }
    
    bool ShouldDownloadItem(PreloadItem item, float batteryLevel, long availableStorage)
    {
        // 배터리 레벨 체크
        if (batteryLevel < item.minimumBatteryLevel)
        {
            return false;
        }
        
        // 저장 공간 체크
        if (availableStorage < item.sizeMB * 1024 * 1024)
        {
            return false;
        }
        
        // 트리거 조건 체크
        if (item.downloadTrigger == PreloadTrigger.OnWiFiAvailable && 
            Application.internetReachability != NetworkReachability.ReachableViaLocalAreaNetwork)
        {
            return false;
        }
        
        // 유휴 시간 체크
        if (item.downloadOnlyWhenIdle && !IsPlayerIdle())
        {
            return false;
        }
        
        return true;
    }
    
    void SetupInitialDownloadQueue()
    {
        // 우선순위별로 정렬
        var sortedItems = preloadItems
            .Where(item => item.downloadTrigger == PreloadTrigger.Immediate || 
                          item.downloadTrigger == PreloadTrigger.OnGameStart)
            .OrderBy(item => item.priority)
            .ThenByDescending(item => GetItemPredictionScore(item));
        
        foreach (var item in sortedItems)
        {
            if (!downloadedItems.Contains(item.itemId))
            {
                QueueItemForDownload(item);
            }
        }
        
        Debug.Log($"초기 다운로드 큐 설정 완료: {downloadQueue.Count}개 아이템");
    }
    
    float GetItemPredictionScore(PreloadItem item)
    {
        float score = 0f;
        
        // 사용 빈도 점수
        if (usageFrequency.ContainsKey(item.itemId))
        {
            score += usageFrequency[item.itemId] * 10f;
        }
        
        // 최근 사용 점수
        if (lastUsedTime.ContainsKey(item.itemId))
        {
            var daysSinceLastUse = (System.DateTime.UtcNow - lastUsedTime[item.itemId]).TotalDays;
            score += Mathf.Max(0, 30f - (float)daysSinceLastUse);
        }
        
        // 크기 점수 (작을수록 높은 점수)
        score += Mathf.Max(0, 50f - item.sizeMB);
        
        return score;
    }
    
    void ProcessDownloadQueue()
    {
        while (downloadQueue.Count > 0 && activeDownloads < maxConcurrentDownloads)
        {
            var nextItem = downloadQueue.Dequeue();
            
            if (!downloadedItems.Contains(nextItem.itemId) && 
                !downloadingItems.Contains(nextItem.itemId))
            {
                StartCoroutine(DownloadItem(nextItem));
            }
        }
    }
    
    IEnumerator DownloadItem(PreloadItem item)
    {
        downloadingItems.Add(item.itemId);
        activeDownloads++;
        downloadProgress[item.itemId] = 0f;
        
        Debug.Log($"다운로드 시작: {item.itemName} ({item.sizeMB:F1}MB)");
        float startTime = Time.realtimeSinceStartup;
        
        // 의존성 체크 및 다운로드
        yield return StartCoroutine(EnsureDependencies(item));
        
        // 실제 파일 다운로드
        string downloadUrl = item.remoteUrl;
        string localPath = GetLocalPath(item);
        
        using (var www = new WWW(downloadUrl))
        {
            float timeoutTime = Time.realtimeSinceStartup + downloadTimeoutSeconds;
            
            while (!www.isDone && Time.realtimeSinceStartup < timeoutTime)
            {
                downloadProgress[item.itemId] = www.progress;
                
                // 진행률 이벤트 발송
                SendDownloadProgressEvent(item.itemId, www.progress);
                
                yield return null;
            }
            
            if (www.isDone && string.IsNullOrEmpty(www.error))
            {
                // 파일 저장
                Directory.CreateDirectory(Path.GetDirectoryName(localPath));
                File.WriteAllBytes(localPath, www.bytes);
                
                // 성공 처리
                OnDownloadSuccess(item, Time.realtimeSinceStartup - startTime);
            }
            else
            {
                // 실패 처리
                OnDownloadFailed(item, www.error);
            }
        }
        
        downloadingItems.Remove(item.itemId);
        activeDownloads--;
        downloadProgress.Remove(item.itemId);
    }
    
    IEnumerator EnsureDependencies(PreloadItem item)
    {
        foreach (var dependencyId in item.dependencies)
        {
            if (!downloadedItems.Contains(dependencyId))
            {
                if (itemMap.ContainsKey(dependencyId))
                {
                    var dependency = itemMap[dependencyId];
                    yield return StartCoroutine(DownloadItem(dependency));
                }
            }
        }
    }
    
    void OnDownloadSuccess(PreloadItem item, float downloadTime)
    {
        downloadedItems.Add(item.itemId);
        SaveCacheStatus();
        
        Debug.Log($"다운로드 완료: {item.itemName} ({downloadTime:F2}초)");
        
        // 성공 분석 데이터 전송
        SendDownloadAnalytics(item, true, downloadTime, null);
        
        // 완료 이벤트 발송
        AppsInToss.SendEvent("preload_item_downloaded", new Dictionary<string, object>
        {
            {"item_id", item.itemId},
            {"item_name", item.itemName},
            {"size_mb", item.sizeMB},
            {"download_time", downloadTime}
        });
    }
    
    void OnDownloadFailed(PreloadItem item, string error)
    {
        Debug.LogError($"다운로드 실패: {item.itemName} - {error}");
        
        // 실패 분석 데이터 전송
        SendDownloadAnalytics(item, false, 0f, error);
        
        // 재시도 로직 (낮은 우선순위 아이템만)
        if (item.priority >= PreloadPriority.Medium)
        {
            StartCoroutine(RetryDownloadAfterDelay(item, 10f));
        }
    }
    
    IEnumerator RetryDownloadAfterDelay(PreloadItem item, float delay)
    {
        yield return new WaitForSeconds(delay);
        
        if (!downloadedItems.Contains(item.itemId))
        {
            QueueItemForDownload(item);
            Debug.Log($"다운로드 재시도 예약: {item.itemName}");
        }
    }
    
    void SendDownloadProgressEvent(string itemId, float progress)
    {
        AppsInToss.SendEvent("preload_progress", new Dictionary<string, object>
        {
            {"item_id", itemId},
            {"progress", progress}
        });
    }
    
    void SendDownloadAnalytics(PreloadItem item, bool success, float downloadTime, string error)
    {
        var analyticsData = new Dictionary<string, object>
        {
            {"item_id", item.itemId},
            {"item_name", item.itemName},
            {"size_mb", item.sizeMB},
            {"priority", item.priority.ToString()},
            {"success", success},
            {"download_time", downloadTime},
            {"error_message", error ?? ""},
            {"network_type", Application.internetReachability.ToString()},
            {"device_model", SystemInfo.deviceModel},
            {"timestamp", System.DateTime.UtcNow.ToString("o")}
        };
        
        AppsInToss.SendAnalytics("preload_download", analyticsData);
    }
    
    string GetLocalPath(PreloadItem item)
    {
        if (!string.IsNullOrEmpty(item.localPath))
        {
            return Path.Combine(Application.persistentDataPath, item.localPath);
        }
        
        return Path.Combine(Application.persistentDataPath, "PreloadCache", item.itemId);
    }
    
    long GetAvailableStorageSpace()
    {
        // 플랫폼별 저장 공간 확인 로직
        // 여기서는 간단한 추정값 반환
        return 1024 * 1024 * 1024; // 1GB
    }
    
    bool IsPlayerIdle()
    {
        // 플레이어 유휴 상태 확인 로직
        return Time.realtimeSinceStartup - Time.time > 30f;
    }
    
    void PauseNonCriticalDownloads()
    {
        // 현재 진행중인 중요하지 않은 다운로드들을 일시 정지
        Debug.Log("배터리 부족으로 인한 비중요 다운로드 일시 정지");
    }
    
    IEnumerator CleanupOldCache()
    {
        Debug.Log("저장 공간 부족으로 인한 캐시 정리 시작");
        
        var cacheDir = Path.Combine(Application.persistentDataPath, "PreloadCache");
        if (Directory.Exists(cacheDir))
        {
            var files = Directory.GetFiles(cacheDir, "*", SearchOption.AllDirectories);
            var fileInfos = files.Select(f => new FileInfo(f)).ToArray();
            
            // 오래된 파일부터 정렬
            System.Array.Sort(fileInfos, (a, b) => a.LastAccessTime.CompareTo(b.LastAccessTime));
            
            long freedSpace = 0;
            long targetFreeSpace = 200 * 1024 * 1024; // 200MB
            
            foreach (var fileInfo in fileInfos)
            {
                if (freedSpace >= targetFreeSpace) break;
                
                try
                {
                    freedSpace += fileInfo.Length;
                    File.Delete(fileInfo.FullName);
                    
                    // 해당 아이템을 다운로드 목록에서 제거
                    string itemId = Path.GetFileNameWithoutExtension(fileInfo.Name);
                    downloadedItems.Remove(itemId);
                    
                    Debug.Log($"캐시 파일 삭제: {fileInfo.Name}");
                }
                catch (System.Exception e)
                {
                    Debug.LogError($"캐시 파일 삭제 실패: {e.Message}");
                }
                
                yield return null;
            }
            
            SaveCacheStatus();
            Debug.Log($"캐시 정리 완료: {freedSpace / (1024 * 1024)}MB 확보");
        }
    }
    
    // 공개 API
    public void QueueItemForDownload(PreloadItem item)
    {
        if (!downloadedItems.Contains(item.itemId) && 
            !downloadingItems.Contains(item.itemId))
        {
            downloadQueue.Enqueue(item);
            Debug.Log($"다운로드 큐에 추가: {item.itemName}");
        }
    }
    
    public void QueueItemForDownload(string itemId)
    {
        if (itemMap.ContainsKey(itemId))
        {
            QueueItemForDownload(itemMap[itemId]);
        }
    }
    
    public bool IsItemDownloaded(string itemId)
    {
        return downloadedItems.Contains(itemId);
    }
    
    public float GetDownloadProgress(string itemId)
    {
        return downloadProgress.ContainsKey(itemId) ? downloadProgress[itemId] : 0f;
    }
    
    public bool IsItemDownloading(string itemId)
    {
        return downloadingItems.Contains(itemId);
    }
    
    public void RecordItemUsage(string itemId)
    {
        if (usageFrequency.ContainsKey(itemId))
        {
            usageFrequency[itemId]++;
        }
        else
        {
            usageFrequency[itemId] = 1;
        }
        
        lastUsedTime[itemId] = System.DateTime.UtcNow;
        
        // 주기적으로 패턴 저장
        if (usageFrequency[itemId] % 10 == 0)
        {
            SaveUsagePatterns();
        }
    }
    
    public long GetTotalCacheSize()
    {
        var cacheDir = Path.Combine(Application.persistentDataPath, "PreloadCache");
        if (!Directory.Exists(cacheDir))
        {
            return 0;
        }
        
        long totalSize = 0;
        var files = Directory.GetFiles(cacheDir, "*", SearchOption.AllDirectories);
        
        foreach (var file in files)
        {
            totalSize += new FileInfo(file).Length;
        }
        
        return totalSize;
    }
    
    public PreloadItem[] GetQueuedItems()
    {
        return downloadQueue.ToArray();
    }
    
    public string[] GetDownloadedItemIds()
    {
        return downloadedItems.ToArray();
    }
    
    void OnApplicationPause(bool pauseStatus)
    {
        if (pauseStatus)
        {
            // 앱 일시정지시 패턴 데이터 저장
            SaveUsagePatterns();
        }
    }
    
    void OnDestroy()
    {
        SaveCacheStatus();
        SaveUsagePatterns();
    }
}

// 데이터 클래스들
[System.Serializable]
public class CacheStatus
{
    public string[] downloadedItems;
    public string lastUpdateTime;
}

[System.Serializable]
public class UsagePatterns
{
    public List<UsagePatternItem> items;
}

[System.Serializable]
public class UsagePatternItem
{
    public string itemId;
    public int frequency;
    public string lastUsed;
}

2. 사용자 인터페이스

다운로드 관리 UI

c#
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections.Generic;
using System.Linq;

public class PreloadManagerUI : MonoBehaviour
{
    [Header("UI 컴포넌트")]
    public GameObject downloadItemPrefab;
    public Transform downloadItemContainer;
    public Button pauseAllButton;
    public Button resumeAllButton;
    public Button clearCacheButton;
    public TextMeshProUGUI totalSizeText;
    public TextMeshProUGUI availableSpaceText;
    public Slider totalProgressSlider;
    
    [Header("설정 UI")]
    public Toggle wifiOnlyToggle;
    public Toggle backgroundDownloadToggle;
    public Slider maxCacheSizeSlider;
    public TextMeshProUGUI maxCacheSizeLabel;
    
    private Dictionary<string, GameObject> downloadItemUIs = new Dictionary<string, GameObject>();
    private bool isPaused = false;
    
    void Start()
    {
        SetupUI();
        RegisterEventListeners();
        UpdateUI();
    }
    
    void SetupUI()
    {
        // 버튼 리스너 등록
        pauseAllButton.onClick.AddListener(PauseAllDownloads);
        resumeAllButton.onClick.AddListener(ResumeAllDownloads);
        clearCacheButton.onClick.AddListener(ClearCache);
        
        // 토글 리스너 등록
        wifiOnlyToggle.onValueChanged.AddListener(OnWiFiOnlyChanged);
        backgroundDownloadToggle.onValueChanged.AddListener(OnBackgroundDownloadChanged);
        
        // 슬라이더 리스너 등록
        maxCacheSizeSlider.onValueChanged.AddListener(OnMaxCacheSizeChanged);
        
        // 초기 설정 로드
        LoadUserPreferences();
    }
    
    void RegisterEventListeners()
    {
        AppsInToss.OnEvent += HandlePreloadEvent;
    }
    
    void HandlePreloadEvent(string eventName, Dictionary<string, object> data)
    {
        switch (eventName)
        {
            case "preload_item_downloaded":
                string itemId = data["item_id"] as string;
                UpdateDownloadItemUI(itemId, 1f, "완료");
                break;
                
            case "preload_progress":
                itemId = data["item_id"] as string;
                float progress = (float)data["progress"];
                UpdateDownloadItemUI(itemId, progress, $"{progress * 100f:F0}%");
                break;
        }
        
        UpdateUI();
    }
    
    void UpdateUI()
    {
        if (AppsInTossPreloadManager.Instance == null) return;
        
        // 전체 캐시 크기 표시
        long totalCacheSize = AppsInTossPreloadManager.Instance.GetTotalCacheSize();
        totalSizeText.text = $"사용 중: {FormatFileSize(totalCacheSize)}";
        
        // 사용 가능한 공간 표시 (추정값)
        availableSpaceText.text = "사용 가능: ~1.2GB";
        
        // 전체 진행률 계산
        UpdateTotalProgress();
        
        // 개별 다운로드 아이템 UI 업데이트
        UpdateDownloadItemsUI();
    }
    
    void UpdateTotalProgress()
    {
        var queuedItems = AppsInTossPreloadManager.Instance.GetQueuedItems();
        var downloadedItems = AppsInTossPreloadManager.Instance.GetDownloadedItemIds();
        
        if (queuedItems.Length == 0)
        {
            totalProgressSlider.value = 1f;
            return;
        }
        
        float totalItems = queuedItems.Length + downloadedItems.Length;
        float completedItems = downloadedItems.Length;
        
        // 현재 다운로드 중인 아이템들의 부분 진행률 추가
        foreach (var item in queuedItems)
        {
            if (AppsInTossPreloadManager.Instance.IsItemDownloading(item.itemId))
            {
                float itemProgress = AppsInTossPreloadManager.Instance.GetDownloadProgress(item.itemId);
                completedItems += itemProgress;
            }
        }
        
        totalProgressSlider.value = totalItems > 0 ? completedItems / totalItems : 1f;
    }
    
    void UpdateDownloadItemsUI()
    {
        var queuedItems = AppsInTossPreloadManager.Instance.GetQueuedItems();
        var downloadedItems = AppsInTossPreloadManager.Instance.GetDownloadedItemIds();
        
        // 큐에 있는 아이템들 UI 생성/업데이트
        foreach (var item in queuedItems)
        {
            if (!downloadItemUIs.ContainsKey(item.itemId))
            {
                CreateDownloadItemUI(item);
            }
            
            bool isDownloading = AppsInTossPreloadManager.Instance.IsItemDownloading(item.itemId);
            float progress = isDownloading ? 
                AppsInTossPreloadManager.Instance.GetDownloadProgress(item.itemId) : 0f;
            
            string status = isDownloading ? "다운로드 중" : "대기 중";
            UpdateDownloadItemUI(item.itemId, progress, status);
        }
        
        // 완료된 아이템들 처리
        foreach (var itemId in downloadedItems)
        {
            if (downloadItemUIs.ContainsKey(itemId))
            {
                UpdateDownloadItemUI(itemId, 1f, "완료");
            }
        }
    }
    
    void CreateDownloadItemUI(AppsInTossPreloadManager.PreloadItem item)
    {
        var itemUI = Instantiate(downloadItemPrefab, downloadItemContainer);
        downloadItemUIs[item.itemId] = itemUI;
        
        // UI 컴포넌트 설정
        var nameText = itemUI.transform.Find("NameText").GetComponent<TextMeshProUGUI>();
        var sizeText = itemUI.transform.Find("SizeText").GetComponent<TextMeshProUGUI>();
        var progressSlider = itemUI.transform.Find("ProgressSlider").GetComponent<Slider>();
        var statusText = itemUI.transform.Find("StatusText").GetComponent<TextMeshProUGUI>();
        var cancelButton = itemUI.transform.Find("CancelButton").GetComponent<Button>();
        
        nameText.text = item.itemName;
        sizeText.text = FormatFileSize((long)(item.sizeMB * 1024 * 1024));
        progressSlider.value = 0f;
        statusText.text = "대기 중";
        
        // 취소 버튼 이벤트
        cancelButton.onClick.AddListener(() => CancelDownload(item.itemId));
        
        // 우선순위에 따른 색상 설정
        var priorityIcon = itemUI.transform.Find("PriorityIcon").GetComponent<Image>();
        priorityIcon.color = GetPriorityColor(item.priority);
    }
    
    void UpdateDownloadItemUI(string itemId, float progress, string status)
    {
        if (!downloadItemUIs.ContainsKey(itemId)) return;
        
        var itemUI = downloadItemUIs[itemId];
        var progressSlider = itemUI.transform.Find("ProgressSlider").GetComponent<Slider>();
        var statusText = itemUI.transform.Find("StatusText").GetComponent<TextMeshProUGUI>();
        
        progressSlider.value = progress;
        statusText.text = status;
        
        // 완료된 아이템은 일정 시간 후 제거
        if (progress >= 1f)
        {
            StartCoroutine(RemoveCompletedItemUI(itemId, 3f));
        }
    }
    
    System.Collections.IEnumerator RemoveCompletedItemUI(string itemId, float delay)
    {
        yield return new WaitForSeconds(delay);
        
        if (downloadItemUIs.ContainsKey(itemId))
        {
            Destroy(downloadItemUIs[itemId]);
            downloadItemUIs.Remove(itemId);
        }
    }
    
    Color GetPriorityColor(AppsInTossPreloadManager.PreloadPriority priority)
    {
        switch (priority)
        {
            case AppsInTossPreloadManager.PreloadPriority.Critical:
                return Color.red;
            case AppsInTossPreloadManager.PreloadPriority.High:
                return Color.yellow;
            case AppsInTossPreloadManager.PreloadPriority.Medium:
                return Color.green;
            case AppsInTossPreloadManager.PreloadPriority.Low:
                return Color.gray;
            default:
                return Color.white;
        }
    }
    
    string FormatFileSize(long bytes)
    {
        string[] sizes = { "B", "KB", "MB", "GB" };
        double len = bytes;
        int order = 0;
        
        while (len >= 1024 && order < sizes.Length - 1)
        {
            order++;
            len = len / 1024;
        }
        
        return $"{len:0.##} {sizes[order]}";
    }
    
    void PauseAllDownloads()
    {
        isPaused = true;
        // 다운로드 일시정지 로직 구현
        Debug.Log("모든 다운로드 일시정지");
    }
    
    void ResumeAllDownloads()
    {
        isPaused = false;
        // 다운로드 재개 로직 구현
        Debug.Log("모든 다운로드 재개");
    }
    
    void ClearCache()
    {
        // 캐시 정리 확인 다이얼로그 표시
        ShowClearCacheDialog();
    }
    
    void ShowClearCacheDialog()
    {
        // 간단한 확인 다이얼로그
        bool confirmed = true; // 실제로는 다이얼로그 결과
        
        if (confirmed)
        {
            // 캐시 정리 실행
            Debug.Log("캐시 정리 시작");
        }
    }
    
    void CancelDownload(string itemId)
    {
        // 다운로드 취소 로직
        if (downloadItemUIs.ContainsKey(itemId))
        {
            Destroy(downloadItemUIs[itemId]);
            downloadItemUIs.Remove(itemId);
        }
        
        Debug.Log($"다운로드 취소: {itemId}");
    }
    
    void OnWiFiOnlyChanged(bool value)
    {
        // WiFi 전용 설정 변경
        SaveUserPreference("wifi_only", value);
    }
    
    void OnBackgroundDownloadChanged(bool value)
    {
        // 백그라운드 다운로드 설정 변경
        SaveUserPreference("background_download", value);
    }
    
    void OnMaxCacheSizeChanged(float value)
    {
        // 최대 캐시 크기 설정 변경
        long maxSizeMB = (long)(value * 1000);
        maxCacheSizeLabel.text = $"최대 캐시 크기: {maxSizeMB}MB";
        SaveUserPreference("max_cache_size_mb", maxSizeMB);
    }
    
    void LoadUserPreferences()
    {
        wifiOnlyToggle.isOn = GetUserPreference("wifi_only", false);
        backgroundDownloadToggle.isOn = GetUserPreference("background_download", true);
        
        long maxCacheSizeMB = GetUserPreference("max_cache_size_mb", 500L);
        maxCacheSizeSlider.value = maxCacheSizeMB / 1000f;
        maxCacheSizeLabel.text = $"최대 캐시 크기: {maxCacheSizeMB}MB";
    }
    
    void SaveUserPreference(string key, object value)
    {
        // 사용자 설정 저장
        PlayerPrefs.SetString($"preload_{key}", value.ToString());
        PlayerPrefs.Save();
    }
    
    T GetUserPreference<T>(string key, T defaultValue)
    {
        // 사용자 설정 로드
        string prefKey = $"preload_{key}";
        if (PlayerPrefs.HasKey(prefKey))
        {
            string value = PlayerPrefs.GetString(prefKey);
            try
            {
                return (T)System.Convert.ChangeType(value, typeof(T));
            }
            catch
            {
                return defaultValue;
            }
        }
        return defaultValue;
    }
    
    void Update()
    {
        // UI 주기적 업데이트
        if (Time.frameCount % 60 == 0) // 1초마다
        {
            UpdateUI();
        }
    }
    
    void OnDestroy()
    {
        AppsInToss.OnEvent -= HandlePreloadEvent;
    }
}

사전 다운로드를 통해 게임 플레이 중 끊김을 최소화하되, 사용자의 데이터 요금과 배터리, 저장 공간을 고려한 스마트한 다운로드 전략을 구현하세요.