--- url: >- https://developers-apps-in-toss.toss.im/unity/optimization/start/addressables.md --- # 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 loadedAssets = new Dictionary(); private Queue loadQueue = new Queue(); private int currentLoadOperations = 0; public static AddressableManager Instance { get { if (instance == null) { instance = FindObjectOfType(); if (instance == null) { GameObject go = new GameObject("AddressableManager"); instance = go.AddComponent(); DontDestroyOnLoad(go); } } return instance; } } [System.Serializable] private class LoadRequest { public string address; public System.Type type; public System.Action 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 activeHandles = new Dictionary(); private Dictionary cachedAssets = new Dictionary(); private Queue loadQueue = new Queue(); private HashSet predictiveLoadRequests = new HashSet(); private class LoadOperation { public string address; public System.Type assetType; public System.Action onSuccess; public System.Action onFailure; public int priority; public float requestTime; public int retryCount; } private void Start() { StartCoroutine(ProcessLoadQueue()); if (enablePredictiveLoading) { StartCoroutine(PredictiveLoadingRoutine()); } } public void LoadAssetAsync(string address, System.Action onComplete, System.Action 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 predictedAssets = new List(); 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(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[] preloadAssets; public AssetReferenceT[] lazyLoadAssets; public string[] addressesToPreload; public string[] addressesToLazyLoad; } [Header("씬별 에셋 설정")] public SceneAssetGroup[] sceneAssets; } public class SceneAssetManager : MonoBehaviour { [Header("설정")] public SceneAssetConfig config; private Dictionary> sceneHandles = new Dictionary>(); private Dictionary> sceneAssets = new Dictionary>(); 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 handles = new List(); List assets = new List(); // 프리로드 에셋들 로딩 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 handles)) { handles = new List(); sceneHandles[sceneName] = handles; } if (!sceneAssets.TryGetValue(sceneName, out List assets)) { assets = new List(); sceneAssets[sceneName] = assets; } yield return StartCoroutine(LoadAssetGroup(sceneConfig.lazyLoadAssets, sceneConfig.addressesToLazyLoad, handles, assets, "lazy")); DebugLogger.LogInfo($"씬 지연 로딩 완료: {sceneName}", "SceneAssetManager"); } private IEnumerator LoadAssetGroup(AssetReferenceT[] assetRefs, string[] addresses, List handles, List 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(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 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(string sceneName, string assetName) where T : Object { if (sceneAssets.TryGetValue(sceneName, out List 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 = new Map(); private downloadQueue: CDNAssetInfo[] = []; private maxConcurrentDownloads = 3; private currentDownloads = 0; private downloadProgressCallbacks: Map void> = new Map(); public static getInstance(): CDNAssetManager { if (!CDNAssetManager.instance) { CDNAssetManager.instance = new CDNAssetManager(); } return CDNAssetManager.instance; } public async initialize(baseUrl: string): Promise { 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 { 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 cacheEntries = new Dictionary(); 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(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(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 entries = new List(); } 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(); // 우선순위와 마지막 접근 시간 기준으로 정렬 var sortedEntries = new List(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(); 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 loadedAssets = new Dictionary(); private Queue unloadQueue = new Queue(); [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(); 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 시스템을 효과적으로 활용하여 성능과 사용자 경험을 크게 향상시킬 수 있어요. --- --- url: >- https://developers-apps-in-toss.toss.im/unity/optimization/runtime/android-profiling.md --- # Android 프로파일링 앱인토스 Unity 게임을 Android 환경에서 효과적으로 프로파일링하여 성능 최적화 포인트를 찾는 방법을 제공합니다. *** ## 1. Android 프로파일링 도구 ### Unity Profiler with Android ```c# public class AndroidProfilerManager : MonoBehaviour { [Header("Android 프로파일링 설정")] public bool enableDeepProfiling = true; public bool enableGPUProfiling = true; public ProfilerArea[] activeAreas = { ProfilerArea.CPU, ProfilerArea.GPU, ProfilerArea.Rendering, ProfilerArea.Memory, ProfilerArea.Audio, ProfilerArea.NetworkOperations }; void Start() { #if UNITY_ANDROID && !UNITY_EDITOR SetupAndroidProfiling(); #endif } void SetupAndroidProfiling() { // Android 전용 프로파일링 설정 Profiler.enableBinaryLog = true; Profiler.logFile = Application.persistentDataPath + "/profiler.raw"; if (enableDeepProfiling) { Profiler.deepProfiling = true; } StartCoroutine(MonitorAndroidPerformance()); } IEnumerator MonitorAndroidPerformance() { while (true) { // Android 특화 성능 데이터 수집 var perfData = CollectAndroidPerformanceData(); // 앱인토스 분석 시스템에 전송 AppsInToss.SendPerformanceData(perfData); yield return new WaitForSeconds(1f); } } AndroidPerformanceData CollectAndroidPerformanceData() { return new AndroidPerformanceData { cpuUsage = Profiler.GetTotalAllocatedMemory(false), gpuUsage = SystemInfo.graphicsMemorySize, thermalState = GetAndroidThermalState(), batteryLevel = SystemInfo.batteryLevel, networkType = Application.internetReachability.ToString() }; } string GetAndroidThermalState() { // Android 열 상태 확인 return AppsInToss.GetAndroidThermalState(); } } [System.Serializable] public class AndroidPerformanceData { public long cpuUsage; public int gpuUsage; public string thermalState; public float batteryLevel; public string networkType; } ``` *** ## 2. 메모리 프로파일링 ### Android 메모리 분석 ```c# public class AndroidMemoryProfiler : MonoBehaviour { [Header("메모리 프로파일링")] public bool enableMemoryProfiling = true; public float profilingInterval = 5f; private Dictionary memorySnapshots = new Dictionary(); void Start() { if (enableMemoryProfiling) { InvokeRepeating(nameof(TakeMemorySnapshot), profilingInterval, profilingInterval); } } void TakeMemorySnapshot() { var snapshot = new AndroidMemorySnapshot { timestamp = System.DateTime.Now, totalAllocated = Profiler.GetTotalAllocatedMemory(false), totalReserved = Profiler.GetTotalReservedMemory(false), totalUnused = Profiler.GetTotalUnusedReservedMemory(false), monoUsed = Profiler.GetMonoUsedSize(), monoHeap = Profiler.GetMonoHeapSize(), tempAllocator = Profiler.GetTempAllocatorSize(), gfxDriver = Profiler.GetAllocatedMemoryForGraphicsDriver(), nativeMemory = GetNativeMemoryUsage() }; AnalyzeMemorySnapshot(snapshot); LogMemorySnapshot(snapshot); } long GetNativeMemoryUsage() { // Android 네이티브 메모리 사용량 확인 return Profiler.GetTotalAllocatedMemory(false) - Profiler.GetMonoUsedSize(); } void AnalyzeMemorySnapshot(AndroidMemorySnapshot snapshot) { // 메모리 누수 감지 if (memorySnapshots.ContainsKey("previous")) { long previousTotal = memorySnapshots["previous"]; long currentTotal = snapshot.totalAllocated; long growth = currentTotal - previousTotal; if (growth > 10 * 1024 * 1024) // 10MB 이상 증가 { Debug.LogWarning($"Android 메모리 급증 감지: +{growth / (1024*1024)}MB"); AppsInToss.ReportMemoryLeak(growth); } } memorySnapshots["previous"] = snapshot.totalAllocated; } } [System.Serializable] public class AndroidMemorySnapshot { public System.DateTime timestamp; public long totalAllocated; public long totalReserved; public long totalUnused; public long monoUsed; public long monoHeap; public long tempAllocator; public long gfxDriver; public long nativeMemory; } ``` Android 환경에서의 정확한 프로파일링을 통해 앱인토스 게임의 성능 병목점을 식별하고 최적화하세요. --- --- url: 'https://developers-apps-in-toss.toss.im/development/client/android.md' description: >- React Native 앱인토스 개발을 위한 Android 환경 설정 가이드입니다. Android Studio, SDK Command-line Tools 설치 및 환경 변수 설정 방법을 확인하세요. --- # Android 환경설정 React Native 개발을 위해 **Android 개발 환경 설정 방법**을 안내해요. ## 1. Android Studio 설치 React Native를 Android 환경에서 실행하려면 **Android SDK**와 [`adb`(Android Debug Bridge)](https://developer.android.com/tools/adb?hl=ko#howadbworks)가 필요해요.\ 먼저, 아래 링크를 통해 Android Studio를 설치하세요. * [Android Studio 설치 링크](https://developer.android.com/studio?hl=ko) ## 2. Android SDK Command-line Tools 설치 Android SDK Command-line Tools를 설치하려면 다음 단계를 따라 진행하세요. 1. Android Studio를 열고 상단 메뉴에서 **\[Android Studio] > \[Settings]** 를 클릭하세요. 2. 왼쪽 메뉴에서 **\[Languages & Frameworks] > \[Android SDK]** 를 선택하세요. 3. **\[SDK Tools]** 탭에서 "Android SDK Command-line Tools"를 체크하고 "OK" 버튼을 눌러 설치하세요. ![이미지](/assets/setup-android-adb.COp4Yig6.png) ## 3. 환경 변수 설정 `adb`를 사용하려면 환경 변수를 설정해야 해요. ### macOS 사용 중인 셸의 초기화 스크립트(`.zshrc` 또는 `.bashrc`)에 다음 내용을 추가하세요. ```bash export ANDROID_HOME=~/Library/Android/sdk export PATH=$PATH:$ANDROID_HOME/tools:$ANDROID_HOME/tools/bin:$ANDROID_HOME/platform-tools ``` ### Windows #### 1. 실행 프롬프트 열기 `Windows` + `R` 키를 눌러 실행 창을 열고 `SystemPropertiesAdvanced`를 입력한 뒤 **Enter** 키를 눌러주세요. ![실행 프롬프트](/assets/setup-android-windows-1.DOFYC1J8.jpg) #### 2. 환경 변수 메뉴로 진입하기 \[시스템 속성] 창에서 \[고급] 탭을 선택하고, 하단의 \[환경 변수] 버튼을 눌러주세요. ![환경 변수](/assets/setup-android-windows-2.BQ_BCp0c.png) #### 3. 사용자 변수에서 `Path` 편집 사용자 변수 섹션에서 `Path` 변수를 선택한 뒤 \[편집] 버튼을 눌러주세요. :::info 만약 `Path` 변수가 없다면, \[새로 만들기] 버튼을 눌러 새 변수를 생성하고 이름을 `Path`로 설정하면 돼요. ::: ![Path 환경 변수](/assets/setup-android-windows-3.DxEAc_wY.png) #### 4. Android SDK 경로 추가하기 편집 창에서 \[새로 만들기] 버튼을 눌러 다음 경로를 추가하세요. `C:\Users\{사용자명}\AppData\Local\Android\sdk\platform-tools` 여기서 `{사용자명}`은 현재 Windows 사용자 계정 이름으로 바꿔 입력해야 해요. ![환경 변수 추가](/assets/setup-android-windows-4.n8Ve7E9m.png) *** ### 설정 확인 환경 변수가 정상적으로 등록되었는지 아래 명령어로 확인하세요. ```shell adb version # Android Debug Bridge version 1.0.41 # Version 33.0.2-8557947 # Installed as /Users/heecheol.kim/Library/Android/sdk/platform-tools/adb ``` 정상적으로 버전이 출력되면 `adb` 설정이 완료되었어요.\ 자세한 내용은 [Android 공식 문서 - 환경 변수](https://developer.android.com/tools/variables?hl=ko#android_home)를 참고하세요. ## 4. 기기 연결 PC와 Android 기기를 연결하는 방법을 안내해요. 이 문서를 따라 [adb(Android Debug Bridge)](https://developer.android.com/tools/adb?hl=ko#howadbworks)를 사용해서 기기와 통신할 수 있어요. ### 개발자 옵션 활성화 ::: tip 기기 제조사에 따라 개발자 옵션을 활성화하는 방법이 다를 수 있어요. 사용 중인 기기의 제조사별 가이드는 인터넷 검색으로 확인하세요. ::: **갤럭시 기기 기준** 1. \[설정] 앱 열기 2. \[휴대전화 정보] > \[소프트웨어 정보] 메뉴로 이동 3. \[빌드 번호] 항목을 빠르게 여러번 탭하기 자세한 방법은 [삼성 지원 홈페이지](https://www.samsung.com/uk/support/mobile-devices/how-do-i-turn-on-the-developer-options-menu-on-my-samsung-galaxy-device)에서 확인할 수 있어요. ### USB 디버깅 활성화 개발자 옵션이 활성화되었다면, 이제 USB 디버깅 옵션을 활성화해 주세요. 1. \[설정] 앱을 열어주세요. 2. \[개발자 옵션] 메뉴로 이동해주세요. 3. \[USB 디버깅] 항목을 활성화 해주세요. ### PC와 기기 연결하기 USB 케이블로 PC와 기기를 연결한 뒤, 다음 명령어를 실행해 기기가 정상적으로 연결되었는지 확인해 주세요. ```shell adb devices # * daemon not running; starting now at tcp:5037 # * daemon started successfully # List of devices attached # R3CTA0BMCPK device ``` 제대로 연결되었다면 **"List of devices attached"** 아래에 기기의 디바이스 아이디가 표시돼요. 다음 예시에서는 `R3CTA0BMCPK`가 디바이스 아이디예요. ::: details 디바이스 아이디가 표시되지 않는다면? 다음 사항들을 확인해주세요. * **USB 디버깅 활성화하기**: Android 기기에서 \[설정] > \[개발자 옵션] > \[USB 디버깅]이 활성화 되어있는지 확인해요. * **ADB 서버 재시작하기**: `adb kill-server` 명령어를 실행한 후 다시 `adb devices` 명령어로 연결 상태를 확인해요. ::: ## 5. 에뮬레이터 설정 Android 에뮬레이터는 실제 기기처럼 동작하며, React Native 화면을 테스트할 수 있는 유용한 도구예요. > ⚠️ 디버깅과 QA는 가능한 **실제 기기**에서 진행하는 것을 권장합니다. ### 1. Android Studio에서 에뮬레이터 추가 설정 화면 열기 Android Studio를 실행한 후 오른쪽 메뉴에서 \[Virtual Device Manager] > \[+ 버튼]을 순서대로 클릭해 주세요. ![안드로이드 스튜디오로 에뮬레이터 설정 화면 열기](/assets/setup-android-6.DIpNjcDJ.png) ### 2. 에뮬레이터 추가하기 테스트 환경은 실제 사용자 환경과 비슷하게 설정하는 게 좋아요. 아래는 "갤럭시 S23"과 비슷한 에뮬레이터를 만드는 방법이에요. :::tip 갤럭시 S23 사양 * 디스플레이: 6.1인치 * 운영체제: API 33부터 지원 (예시에서는 API 35 사용) ::: ![안드로이드 스튜디오로 에뮬레이터 설정](/assets/setup-android-7.DHo2POTo.gif) 위 영상처럼 \[Pixel 8a] > \[VanilaIceCream (API 35)] > \[AVD Name 설정] 순으로 진행하면 에뮬레이터 설정을 완료할 수 있어요. ## 6. 에뮬레이터 실행 추가한 에뮬레이터 목록을 확인할 수 있고, 원하는 에뮬레이터를 바로 실행할 수 있어요. ### 1. Android Studio에서 에뮬레이터 목록 확인하기 Android Studio를 실행한 후 오른쪽 메뉴에서 \[Virtual Device Manager]를 클릭해 주세요. ### 2. 에뮬레이터 실행하기 실행하고자 하는 에뮬레이터를 확인한 뒤, 재생 버튼을 눌러 실행할 수 있어요. ![에뮬레이터 실행하기](/assets/setup-android-exec-emulator.BbVjs52o.png) ## 7. 앱인토스 샌드박스 앱 설치 실제 기기와 에뮬레이터에서 같은 APK 파일을 사용해요. * [앱인토스 샌드박스 앱 다운로드](/development/test/sandbox) ### 1. Android Studio에서 설치 Android Stuido를 활용해서 실기기에 앱인토스 샌드박스 앱을 설치할 수 있어요. 1. Android Studio에서 오른쪽 메뉴의 \[Device Manager]에 연결된 기기가 표시되는지 확인해요. ![안드로이드 실행가능한 기기 목록 보여주기](/assets/setup-android-2.DXcNnBuo.png) 2. 연결된 기기의 오른쪽에 있는 \[Start Mirroring] 버튼을 클릭하면 기기 화면을 Android Studio에 표시할 수 있어요. ![안드로이드 실행가능한 기기 목록 보여주기](/assets/setup-android-3.5PFEsGIn.gif) 3. 다운로드한 앱인토스 샌드박스 앱 APK 파일을 끌고 와서 기기에 설치할 수 있어요. ![안드로이드 스튜디오로 앱 설치](/assets/setup-android-4.WhqxGp5g.gif) ### 2. `adb` 명령어로 설치 1. 터미널에서 APK 파일이 있는 폴더로 이동해요. 2. 아래 명령어로 APK 파일을 설치해요. ```shell adb install -r -t {파일이름} ``` 예를 들어, 파일 이름이 `apssintoss-debug.apk`라면 아래 명령어를 사용해요. ```shell adb install -r -t apssintoss-debug.apk ``` ![adb로 앱인토스 샌드박스 앱 다운로드](/assets/setup-android-5.DrtXEOR5.gif) --- --- url: 'https://developers-apps-in-toss.toss.im/development/integration-process.md' description: >- 앱인토스 API 사용을 위한 mTLS 기반 서버 간 통신 설정 가이드입니다. 인증서 발급, 통신 구조, 언어별 API 요청 예제를 확인하세요. --- # API 사용하기 앱인토스 API를 사용하려면 **mTLS 기반의 서버 간(Server-to-Server) 통신 설정이 반드시 필요해요.**\ mTLS 인증서는 파트너사 서버와 앱인토스 서버 간 통신을 **암호화**하고 **쌍방 신원을 상호 검증**하는 데 사용됩니다. ::: tip 아래 기능은 반드시 mTLS 인증서를 통한 통신이 필요해요 * [토스 로그인](/login/intro.md) * [토스 페이](/tosspay/intro.md) * [인앱 결제](/iap/intro.md) * [기능성 푸시, 알림](/push/intro.md) * [프로모션(토스 포인트)](/promotion/intro.md) ::: ## 통신 구조 앱인토스 API는 파트너사 서버에서 앱인토스 서버로 요청을 전송하고,\ 앱인토스 서버가 토스 서버에 연동 요청을 전달하는 구조로 동작해요. ![](/assets/appintoss_process_2.DkmHrB4Z.png) ![](../resources/prepare/appintoss_process.png) ## mTLS 인증서 발급 방법 서버용 mTLS 인증서는 **콘솔에서 직접 발급**할 수 있어요. ### 1. 앱 선택하기 앱인토스 콘솔에 접속해 인증서를 발급받을 앱을 선택하세요.\ 왼쪽 메뉴에서 **mTLS 인증서** 탭을 클릭한 뒤, **+ 발급받기** 버튼을 눌러 발급을 진행해요. ![](/assets/mtls.C_guSa2X.png) ### 2. 인증서 다운로드 및 보관 mTLS 인증서가 발급되면 **인증서 파일과 키 파일**을 다운로드할 수 있어요. ::: tip 보관 시 주의하세요 * 인증서와 키 파일은 유출되지 않도록 **안전한 위치에 보관**하세요. * 인증서가 **만료되기 전에 반드시 재발급**해 주세요. ::: ![](/assets/mtls-2._GxAfDcf.png) 콘솔에서 발급된 인증서는 아래와 같이 확인할 수 있어요. ![](/assets/mtls-3.CkETJCHm.png) 인증서는 일반적으로 하나만 사용하지만, **무중단 교체**를 위해 **두 개 이상 등록해 둘 수도 있어요.**\ 콘솔에서는 이를 위해 **다중 인증서 관리 기능을** 제공해요. ## API 요청 시 인증서 설정 앱인토스 서버에 요청하려면, 발급받은 **인증서/키 파일**을 서버 애플리케이션에 등록해야 해요. 아래는 주요 언어별 mTLS 요청 예제예요.\ 환경에 맞게 경로, 알고리즘, TLS 버전 등을 조정하세요. ::: details Kotlin 예제 ```kotlin import java.security.KeyStore import java.security.cert.X509Certificate import java.security.KeyFactory import java.security.spec.PKCS8EncodedKeySpec import java.io.FileReader import java.io.ByteArrayInputStream import java.util.Base64 import javax.net.ssl.* class TLSClient { fun createSSLContext(certPath: String, keyPath: String): SSLContext { val cert = loadCertificate(certPath) val key = loadPrivateKey(keyPath) val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) keyStore.load(null, null) keyStore.setCertificateEntry("client-cert", cert) keyStore.setKeyEntry("client-key", key, "".toCharArray(), arrayOf(cert)) val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) kmf.init(keyStore, "".toCharArray()) return SSLContext.getInstance("TLS").apply { init(kmf.keyManagers, null, null) } } private fun loadCertificate(path: String): X509Certificate { val content = FileReader(path).readText() .replace("-----BEGIN CERTIFICATE-----", "") .replace("-----END CERTIFICATE-----", "") .replace("\\s".toRegex(), "") val bytes = Base64.getDecoder().decode(content) return CertificateFactory.getInstance("X.509") .generateCertificate(ByteArrayInputStream(bytes)) as X509Certificate } private fun loadPrivateKey(path: String): java.security.PrivateKey { val content = FileReader(path).readText() .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replace("\\s".toRegex(), "") val bytes = Base64.getDecoder().decode(content) val spec = PKCS8EncodedKeySpec(bytes) return KeyFactory.getInstance("RSA").generatePrivate(spec) } fun makeRequest(url: String, context: SSLContext): String { val connection = (URL(url).openConnection() as HttpsURLConnection).apply { sslSocketFactory = context.socketFactory requestMethod = "GET" connectTimeout = 5000 readTimeout = 5000 } return connection.inputStream.bufferedReader().use { it.readText() }.also { connection.disconnect() } } } fun main() { val client = TLSClient() val context = client.createSSLContext("/path/to/client-cert.pem", "/path/to/client-key.pem") val response = client.makeRequest("https://apps-in-toss-api.toss.im/endpoint", context) println(response) } ``` ::: ::: details Python 예제 ```python import requests class TLSClient: def __init__(self, cert_path, key_path): self.cert_path = cert_path self.key_path = key_path def make_request(self, url): response = requests.get( url, cert=(self.cert_path, self.key_path), headers={'Content-Type': 'application/json'} ) return response.text if __name__ == '__main__': client = TLSClient( cert_path='/path/to/client-cert.pem', key_path='/path/to/client-key.pem' ) result = client.make_request('https://apps-in-toss-api.toss.im/endpoint') print(result) ``` ::: ::: details JavaScript(Node.js) 예제 ```js const https = require('https'); const fs = require('fs'); const options = { cert: fs.readFileSync('/path/to/client-cert.pem'), key: fs.readFileSync('/path/to/client-key.pem'), rejectUnauthorized: true, }; const req = https.request( 'https://apps-in-toss-api.toss.im/endpoint', { method: 'GET', ...options }, (res) => { let data = ''; res.on('data', (chunk) => (data += chunk)); res.on('end', () => { console.log('Response:', data); }); } ); req.on('error', (e) => console.error(e)); req.end(); ``` ::: ::: details C# 예제 ```c# using System; using System.Net.Http; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; class Program { static async Task Main(string[] args) { var handler = new HttpClientHandler(); handler.ClientCertificates.Add( new X509Certificate2("/path/to/client-cert.pem") ); using var client = new HttpClient(handler); var response = await client.GetAsync("https://apps-in-toss-api.toss.im/endpoint"); string body = await response.Content.ReadAsStringAsync(); Console.WriteLine(body); } } ``` ::: ::: details C++ 예제(libcurl 사용) ```cpp #include #include #include size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) { userp->append((char*)contents, size * nmemb); return size * nmemb; } int main() { CURL* curl = curl_easy_init(); if (curl) { std::string response; curl_easy_setopt(curl, CURLOPT_URL, "https://apps-in-toss-api.toss.im/endpoint"); curl_easy_setopt(curl, CURLOPT_SSLCERT, "/path/to/client-cert.pem"); curl_easy_setopt(curl, CURLOPT_SSLKEY, "/path/to/client-key.pem"); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); CURLcode res = curl_easy_perform(curl); if (res == CURLE_OK) { std::cout << "Response: " << response << std::endl; } else { std::cerr << "Error: " << curl_easy_strerror(res) << std::endl; } curl_easy_cleanup(curl); } return 0; } ``` ::: ## 자주 묻는 질문 \](https://tossmini-docs.toss.im/tds-mobile/components/Asset/check-first/) ![common-asset](/assets/Thumbnail-Asset.DOBg_PK6.png) --- --- url: >- https://developers-apps-in-toss.toss.im/unity/optimization/start/assetbundle.md --- # AssetBundle 가이드 앱인토스 Unity 게임에서 AssetBundle을 효율적으로 사용하여 앱 크기를 최소화하고 동적 콘텐츠 로딩을 구현하는 방법을 제공해요. ## 1. 앱인토스 AssetBundle 전략 ### AssetBundle 구성 전략 ``` 📦 앱인토스 AssetBundle 구조 ├── Core Bundle (필수 번들) - 10MB 이내 │ ├── 게임 엔진 핵심 에셋 │ ├── 앱인토스 SDK 리소스 │ ├── 기본 UI 시스템 │ └── 토스 브랜딩 에셋 ├── Feature Bundles (기능별 번들) - 각 5MB 이내 │ ├── 게임플레이 번들 │ ├── 사운드 번들 │ ├── 이펙트 번들 │ └── 토스페이 연동 번들 ├── Level Bundles (레벨별 번들) - 각 3MB 이내 │ ├── 스테이지 1-10 │ ├── 스테이지 11-20 │ └── 보스 레벨 └── Seasonal Bundles (시즌 컨텐츠) - 각 2MB 이내 ├── 이벤트 에셋 ├── 한정 스킨 └── 특별 아이템 ``` ### AssetBundle 관리자 구현 ```c# using UnityEngine; using System.Collections; using System.Collections.Generic; using System.IO; public class AppsInTossAssetBundleManager : MonoBehaviour { public static AppsInTossAssetBundleManager Instance { get; private set; } [System.Serializable] public class BundleConfig { public string bundleName; public BundlePriority priority; public string cdnUrl; public long bundleSize; public string version; public bool persistent = true; public List dependencies = new List(); } public enum BundlePriority { Critical = 0, // 앱 시작 전 필수 로딩 High = 1, // 게임 시작 시 로딩 Medium = 2, // 백그라운드 로딩 Low = 3, // 필요시 로딩 Toss = 4 // 앱인토스 특화 번들 } [Header("번들 설정")] public BundleConfig[] bundleConfigs; [Header("앱인토스 CDN 설정")] public string tossCdnBaseUrl = "https://cdn.appintoss.com/bundles/"; public bool enableTossCaching = true; public long maxCacheSizeMB = 100; // 내부 관리 private Dictionary loadedBundles = new Dictionary(); private Dictionary bundleConfigMap = new Dictionary(); private HashSet downloadingBundles = new HashSet(); void Awake() { if (Instance == null) { Instance = this; DontDestroyOnLoad(gameObject); InitializeBundleManager(); } else { Destroy(gameObject); } } void InitializeBundleManager() { // 번들 설정 맵 생성 foreach (var config in bundleConfigs) { bundleConfigMap[config.bundleName] = config; } // 로컬 캐시 정리 StartCoroutine(CleanupOldBundles()); // Critical 우선순위 번들 자동 로딩 StartCoroutine(LoadCriticalBundles()); } IEnumerator LoadCriticalBundles() { var criticalBundles = System.Array.FindAll(bundleConfigs, b => b.priority == BundlePriority.Critical || b.priority == BundlePriority.Toss); foreach (var bundle in criticalBundles) { yield return StartCoroutine(LoadBundleCoroutine(bundle.bundleName)); } Debug.Log("Critical AssetBundle 로딩 완료"); } public void LoadBundleAsync(string bundleName, System.Action onComplete = null) { StartCoroutine(LoadBundleAsyncCoroutine(bundleName, onComplete)); } IEnumerator LoadBundleAsyncCoroutine(string bundleName, System.Action onComplete) { yield return StartCoroutine(LoadBundleCoroutine(bundleName)); onComplete?.Invoke(IsBundleLoaded(bundleName)); } IEnumerator LoadBundleCoroutine(string bundleName) { if (IsBundleLoaded(bundleName)) { yield break; } if (downloadingBundles.Contains(bundleName)) { yield return new WaitUntil(() => !downloadingBundles.Contains(bundleName)); yield break; } downloadingBundles.Add(bundleName); var config = bundleConfigMap[bundleName]; // 의존성 번들 먼저 로딩 foreach (var dependency in config.dependencies) { if (!IsBundleLoaded(dependency)) { yield return StartCoroutine(LoadBundleCoroutine(dependency)); } } // 번들 다운로드 또는 로컬 로딩 string bundlePath = GetBundlePath(bundleName); AssetBundle bundle = null; if (File.Exists(bundlePath)) { // 로컬에서 로딩 var bundleLoadRequest = AssetBundle.LoadFromFileAsync(bundlePath); yield return bundleLoadRequest; bundle = bundleLoadRequest.assetBundle; } else { // CDN에서 다운로드 yield return StartCoroutine(DownloadBundle(config)); if (File.Exists(bundlePath)) { var bundleLoadRequest = AssetBundle.LoadFromFileAsync(bundlePath); yield return bundleLoadRequest; bundle = bundleLoadRequest.assetBundle; } } if (bundle != null) { loadedBundles[bundleName] = bundle; Debug.Log($"AssetBundle 로딩 성공: {bundleName}"); // 앱인토스 분석에 번들 로딩 성공 전송 SendBundleAnalytics(bundleName, true, 0); } else { Debug.LogError($"AssetBundle 로딩 실패: {bundleName}"); SendBundleAnalytics(bundleName, false, 0); } downloadingBundles.Remove(bundleName); } IEnumerator DownloadBundle(BundleConfig config) { string downloadUrl = tossCdnBaseUrl + config.bundleName; string savePath = GetBundlePath(config.bundleName); Debug.Log($"AssetBundle 다운로드 시작: {config.bundleName}"); float startTime = Time.realtimeSinceStartup; using (var www = new WWW(downloadUrl)) { yield return www; if (string.IsNullOrEmpty(www.error)) { // 파일 저장 Directory.CreateDirectory(Path.GetDirectoryName(savePath)); File.WriteAllBytes(savePath, www.bytes); float downloadTime = Time.realtimeSinceStartup - startTime; Debug.Log($"AssetBundle 다운로드 완료: {config.bundleName} ({downloadTime:F2}초)"); SendBundleAnalytics(config.bundleName, true, downloadTime); } else { Debug.LogError($"AssetBundle 다운로드 실패: {config.bundleName} - {www.error}"); SendBundleAnalytics(config.bundleName, false, 0); } } } string GetBundlePath(string bundleName) { return Path.Combine(Application.persistentDataPath, "Bundles", bundleName); } // 에셋 로딩 API public void LoadAssetFromBundle(string bundleName, string assetName, System.Action onComplete, System.Action onError = null) where T : UnityEngine.Object { StartCoroutine(LoadAssetFromBundleCoroutine(bundleName, assetName, onComplete, onError)); } IEnumerator LoadAssetFromBundleCoroutine(string bundleName, string assetName, System.Action onComplete, System.Action onError) where T : UnityEngine.Object { // 번들이 로딩되어 있지 않으면 먼저 로딩 if (!IsBundleLoaded(bundleName)) { yield return StartCoroutine(LoadBundleCoroutine(bundleName)); } if (!IsBundleLoaded(bundleName)) { onError?.Invoke($"번들 로딩 실패: {bundleName}"); yield break; } var bundle = loadedBundles[bundleName]; var assetRequest = bundle.LoadAssetAsync(assetName); yield return assetRequest; if (assetRequest.asset != null) { onComplete?.Invoke(assetRequest.asset as T); } else { onError?.Invoke($"에셋을 찾을 수 없습니다: {assetName}"); } } // 토스 특화 번들 로딩 public void LoadTossBundles(System.Action onComplete = null) { StartCoroutine(LoadTossBundlesCoroutine(onComplete)); } IEnumerator LoadTossBundlesCoroutine(System.Action onComplete) { var tossBundles = System.Array.FindAll(bundleConfigs, b => b.priority == BundlePriority.Toss); foreach (var bundle in tossBundles) { yield return StartCoroutine(LoadBundleCoroutine(bundle.bundleName)); } Debug.Log("토스 특화 번들 로딩 완료"); onComplete?.Invoke(); } // 번들 언로딩 public void UnloadBundle(string bundleName, bool unloadAllLoadedObjects = false) { if (loadedBundles.ContainsKey(bundleName)) { loadedBundles[bundleName].Unload(unloadAllLoadedObjects); loadedBundles.Remove(bundleName); Debug.Log($"AssetBundle 언로드: {bundleName}"); } } // 캐시 관리 IEnumerator CleanupOldBundles() { string bundleDir = Path.Combine(Application.persistentDataPath, "Bundles"); if (!Directory.Exists(bundleDir)) { yield break; } var files = Directory.GetFiles(bundleDir); long totalSize = 0; foreach (var file in files) { totalSize += new FileInfo(file).Length; } long maxCacheSize = maxCacheSizeMB * 1024 * 1024; if (totalSize > maxCacheSize) { Debug.Log($"번들 캐시 정리 시작: {totalSize / (1024 * 1024)}MB > {maxCacheSizeMB}MB"); // 최근 접근 시간 기준으로 정렬 var fileInfos = new List(); foreach (var file in files) { fileInfos.Add(new FileInfo(file)); } fileInfos.Sort((a, b) => a.LastAccessTime.CompareTo(b.LastAccessTime)); // 오래된 파일부터 삭제 foreach (var fileInfo in fileInfos) { if (totalSize <= maxCacheSize) break; totalSize -= fileInfo.Length; File.Delete(fileInfo.FullName); Debug.Log($"오래된 번들 삭제: {fileInfo.Name}"); } } } void SendBundleAnalytics(string bundleName, bool success, float downloadTime) { var analyticsData = new Dictionary { {"bundle_name", bundleName}, {"success", success}, {"download_time", downloadTime}, {"timestamp", System.DateTime.UtcNow.ToString("o")} }; AppsInToss.SendAnalytics("assetbundle_loading", analyticsData); } // 공개 API public bool IsBundleLoaded(string bundleName) { return loadedBundles.ContainsKey(bundleName); } public string[] GetLoadedBundleNames() { var names = new string[loadedBundles.Count]; loadedBundles.Keys.CopyTo(names, 0); return names; } public long GetTotalCacheSize() { string bundleDir = Path.Combine(Application.persistentDataPath, "Bundles"); if (!Directory.Exists(bundleDir)) { return 0; } long totalSize = 0; var files = Directory.GetFiles(bundleDir); foreach (var file in files) { totalSize += new FileInfo(file).Length; } return totalSize; } } ``` *** ## 2. 번들 빌드 최적화 ### 자동화된 번들 빌드 시스템 ```c# #if UNITY_EDITOR using UnityEditor; using UnityEngine; using System.IO; using System.Collections.Generic; public class AppsInTossBundleBuilder { [MenuItem("AppsInToss/Build All Bundles")] public static void BuildAllBundles() { BuildBundlesForTarget(BuildTarget.Android); BuildBundlesForTarget(BuildTarget.iOS); Debug.Log("앱인토스 번들 빌드 완료"); } static void BuildBundlesForTarget(BuildTarget target) { string outputPath = Path.Combine("AssetBundles", target.ToString()); if (!Directory.Exists(outputPath)) { Directory.CreateDirectory(outputPath); } // 앱인토스 최적화 설정 var buildOptions = BuildAssetBundleOptions.ChunkBasedCompression | BuildAssetBundleOptions.StrictMode | BuildAssetBundleOptions.DeterministicAssetBundle; // 번들 빌드 실행 var manifest = BuildPipeline.BuildAssetBundles( outputPath, buildOptions, target ); if (manifest != null) { OptimizeBundlesForAppsInToss(outputPath, manifest); GenerateBundleConfig(outputPath, manifest); } } static void OptimizeBundlesForAppsInToss(string outputPath, AssetBundleManifest manifest) { var allBundles = manifest.GetAllAssetBundles(); foreach (var bundleName in allBundles) { string bundlePath = Path.Combine(outputPath, bundleName); var fileInfo = new FileInfo(bundlePath); // 번들 크기 체크 (앱인토스 권장 크기) long sizeMB = fileInfo.Length / (1024 * 1024); if (sizeMB > 10) // 10MB 초과 { Debug.LogWarning($"번들 크기가 권장 크기를 초과합니다: {bundleName} ({sizeMB}MB)"); } // 압축률 정보 출력 Debug.Log($"번들 정보: {bundleName} - {sizeMB}MB"); } } static void GenerateBundleConfig(string outputPath, AssetBundleManifest manifest) { var config = new AppsInTossBundleConfig(); var allBundles = manifest.GetAllAssetBundles(); foreach (var bundleName in allBundles) { string bundlePath = Path.Combine(outputPath, bundleName); var fileInfo = new FileInfo(bundlePath); var bundleInfo = new AppsInTossBundleConfig.BundleInfo { name = bundleName, size = fileInfo.Length, hash = manifest.GetAssetBundleHash(bundleName).ToString(), dependencies = manifest.GetAllDependencies(bundleName) }; config.bundles.Add(bundleInfo); } // JSON으로 설정 파일 저장 string configPath = Path.Combine(outputPath, "bundle_config.json"); string json = JsonUtility.ToJson(config, true); File.WriteAllText(configPath, json); Debug.Log($"번들 설정 파일 생성: {configPath}"); } } [System.Serializable] public class AppsInTossBundleConfig { [System.Serializable] public class BundleInfo { public string name; public long size; public string hash; public string[] dependencies; } public List bundles = new List(); public string version = "1.0.0"; public string buildTime; public AppsInTossBundleConfig() { buildTime = System.DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"); } } #endif ``` AssetBundle을 사용해 앱 크기를 줄이고, 필요한 콘텐츠만 동적으로 업데이트하세요. 앱인토스 환경에서는 번들 크기와 다운로드 속도에 특히 주의해야 해요. --- --- url: >- https://developers-apps-in-toss.toss.im/unity/optimization/start/autostreaming.md --- # AutoStreaming 가이드 ## 1. AutoStreaming 개요 ### AutoStreaming이란? ``` 🚀 AutoStreaming 개념 ├── 즉시 시작 (Instant Play) - 0-5초 내 게임 시작 ├── 백그라운드 로딩 - 플레이 중 추가 콘텐츠 다운로드 ├── 우선순위 기반 로딩 - 중요한 에셋 우선 로딩 └── 적응형 품질 - 네트워크 상태에 따른 품질 조절 ``` ### AutoStreaming 아키텍처 ```c# using UnityEngine; using System.Collections; using System.Collections.Generic; using System.IO; public class AppsInTossAutoStreaming : MonoBehaviour { public static AppsInTossAutoStreaming Instance { get; private set; } [System.Serializable] public class StreamingConfig { public string assetName; public StreamingPriority priority; public float sizeMB; public string[] dependencies; public bool essential = false; // 게임 진행에 필수 여부 public int loadOrder = 0; public float timeoutSeconds = 30f; } public enum StreamingPriority { Immediate = 0, // 즉시 로딩 (게임 시작 전 필수) High = 1, // 게임 시작 후 바로 로딩 Medium = 2, // 사용자가 해당 영역에 접근하기 전 로딩 Low = 3, // 필요할 때만 로딩 Preload = 4 // 여유가 있을 때 미리 로딩 } [Header("스트리밍 설정")] public StreamingConfig[] streamingAssets; public float initialLoadThresholdMB = 2f; // 즉시 시작을 위한 초기 로딩 임계값 public bool enableAdaptiveQuality = true; public bool enablePreloadInIdle = true; [Header("네트워크 설정")] public string baseUrl = "https://cdn.appintoss.com/streaming/"; public int maxConcurrentDownloads = 3; public float networkTimeoutSeconds = 15f; // 내부 상태 private Dictionary configMap = new Dictionary(); private HashSet loadedAssets = new HashSet(); private HashSet downloadingAssets = new HashSet(); private Queue downloadQueue = new Queue(); private bool isInitialLoadComplete = false; private int activeDownloads = 0; void Awake() { if (Instance == null) { Instance = this; DontDestroyOnLoad(gameObject); InitializeAutoStreaming(); } else { Destroy(gameObject); } } void InitializeAutoStreaming() { // 설정 맵 생성 foreach (var config in streamingAssets) { configMap[config.assetName] = config; } // 네트워크 상태 모니터링 시작 StartCoroutine(MonitorNetworkStatus()); // 즉시 로딩 가능한 최소한의 에셋 로딩 StartCoroutine(LoadImmediateAssets()); } IEnumerator LoadImmediateAssets() { Debug.Log("AutoStreaming 즉시 로딩 시작"); float startTime = Time.realtimeSinceStartup; // Immediate 우선순위 에셋들을 크기 기준으로 정렬 var immediateAssets = System.Array.FindAll(streamingAssets, a => a.priority == StreamingPriority.Immediate); System.Array.Sort(immediateAssets, (a, b) => a.sizeMB.CompareTo(b.sizeMB)); float loadedSizeMB = 0f; foreach (var asset in immediateAssets) { if (loadedSizeMB >= initialLoadThresholdMB && !asset.essential) { // 임계값 도달, 필수 에셋이 아니면 나중에 로딩 QueueAssetForDownload(asset); continue; } yield return StartCoroutine(LoadAssetCoroutine(asset)); loadedSizeMB += asset.sizeMB; } float loadTime = Time.realtimeSinceStartup - startTime; Debug.Log($"즉시 로딩 완료: {loadTime:F2}초, {loadedSizeMB:F1}MB 로딩됨"); isInitialLoadComplete = true; // 게임 시작 가능 신호 전송 SendGameReadySignal(loadTime, loadedSizeMB); // 백그라운드에서 나머지 에셋 로딩 시작 StartCoroutine(BackgroundStreamingProcess()); } IEnumerator LoadAssetCoroutine(StreamingConfig config) { if (loadedAssets.Contains(config.assetName)) { yield break; } downloadingAssets.Add(config.assetName); // 의존성 에셋 먼저 로딩 foreach (var dependency in config.dependencies) { if (!loadedAssets.Contains(dependency)) { var dependencyConfig = configMap[dependency]; yield return StartCoroutine(LoadAssetCoroutine(dependencyConfig)); } } // 에셋 다운로드 yield return StartCoroutine(DownloadAsset(config)); downloadingAssets.Remove(config.assetName); loadedAssets.Add(config.assetName); } IEnumerator DownloadAsset(StreamingConfig config) { string assetUrl = baseUrl + config.assetName; string cachePath = GetAssetCachePath(config.assetName); Debug.Log($"에셋 스트리밍 시작: {config.assetName} ({config.sizeMB:F1}MB)"); float startTime = Time.realtimeSinceStartup; // 로컬 캐시 확인 if (File.Exists(cachePath)) { yield return StartCoroutine(LoadFromCache(config, cachePath)); yield break; } // 네트워크 다운로드 using (var www = new WWW(assetUrl)) { float timeoutTime = Time.realtimeSinceStartup + config.timeoutSeconds; while (!www.isDone && Time.realtimeSinceStartup < timeoutTime) { // 진행률 업데이트 UpdateDownloadProgress(config.assetName, www.progress); yield return null; } if (www.isDone && string.IsNullOrEmpty(www.error)) { // 캐시에 저장 Directory.CreateDirectory(Path.GetDirectoryName(cachePath)); File.WriteAllBytes(cachePath, www.bytes); float downloadTime = Time.realtimeSinceStartup - startTime; Debug.Log($"스트리밍 완료: {config.assetName} ({downloadTime:F2}초)"); SendStreamingAnalytics(config.assetName, true, downloadTime, config.sizeMB); } else { Debug.LogError($"스트리밍 실패: {config.assetName} - {www.error}"); SendStreamingAnalytics(config.assetName, false, 0, config.sizeMB); } } } IEnumerator LoadFromCache(StreamingConfig config, string cachePath) { Debug.Log($"캐시에서 로딩: {config.assetName}"); // 캐시 파일 검증 var fileInfo = new FileInfo(cachePath); float expectedSizeMB = config.sizeMB; float actualSizeMB = fileInfo.Length / (1024f * 1024f); if (Mathf.Abs(actualSizeMB - expectedSizeMB) > 0.1f) { Debug.LogWarning($"캐시 파일 크기 불일치: {config.assetName} (예상: {expectedSizeMB:F1}MB, 실제: {actualSizeMB:F1}MB)"); File.Delete(cachePath); // 다시 다운로드 yield return StartCoroutine(DownloadAsset(config)); yield break; } yield return null; // 1프레임 대기 Debug.Log($"캐시 로딩 완료: {config.assetName}"); } IEnumerator BackgroundStreamingProcess() { while (true) { // 큐에서 다음 에셋 처리 if (downloadQueue.Count > 0 && activeDownloads < maxConcurrentDownloads) { var nextAsset = downloadQueue.Dequeue(); StartCoroutine(BackgroundDownload(nextAsset)); } // Idle 상태에서 preload 에셋 로딩 if (enablePreloadInIdle && IsPlayerIdle() && activeDownloads == 0) { var preloadAssets = System.Array.FindAll(streamingAssets, a => a.priority == StreamingPriority.Preload && !loadedAssets.Contains(a.assetName)); if (preloadAssets.Length > 0) { var nextPreload = preloadAssets[0]; QueueAssetForDownload(nextPreload); } } yield return new WaitForSeconds(1f); } } IEnumerator BackgroundDownload(StreamingConfig config) { activeDownloads++; yield return StartCoroutine(LoadAssetCoroutine(config)); activeDownloads--; } void QueueAssetForDownload(StreamingConfig config) { if (!downloadingAssets.Contains(config.assetName) && !loadedAssets.Contains(config.assetName)) { downloadQueue.Enqueue(config); } } IEnumerator MonitorNetworkStatus() { while (true) { // 네트워크 상태 확인 var networkReachability = Application.internetReachability; if (enableAdaptiveQuality) { AdaptQualityBasedOnNetwork(networkReachability); } yield return new WaitForSeconds(5f); } } void AdaptQualityBasedOnNetwork(NetworkReachability reachability) { switch (reachability) { case NetworkReachability.ReachableViaCarrierDataNetwork: // 모바일 데이터: 압축률 높이고 다운로드 제한 maxConcurrentDownloads = 1; networkTimeoutSeconds = 30f; break; case NetworkReachability.ReachableViaLocalAreaNetwork: // WiFi: 최적 성능 maxConcurrentDownloads = 3; networkTimeoutSeconds = 15f; break; case NetworkReachability.NotReachable: // 오프라인: 캐시된 에셋만 사용 maxConcurrentDownloads = 0; break; } } string GetAssetCachePath(string assetName) { return Path.Combine(Application.persistentDataPath, "StreamingCache", assetName); } bool IsPlayerIdle() { // 플레이어가 유휴 상태인지 확인하는 로직 // 예: 일정 시간 동안 입력이 없었는지, 메뉴 화면에 있는지 등 return Time.realtimeSinceStartup - Time.time > 10f; } void UpdateDownloadProgress(string assetName, float progress) { // UI에 다운로드 진행률 업데이트 var progressData = new Dictionary { {"asset_name", assetName}, {"progress", progress} }; // 이벤트 시스템으로 UI에 알림 AppsInToss.SendEvent("streaming_progress", progressData); } void SendGameReadySignal(float loadTime, float loadedSizeMB) { var readyData = new Dictionary { {"load_time", loadTime}, {"loaded_size_mb", loadedSizeMB}, {"timestamp", System.DateTime.UtcNow.ToString("o")} }; AppsInToss.SendEvent("game_ready", readyData); Debug.Log($"게임 시작 준비 완료! 로딩 시간: {loadTime:F2}초"); } void SendStreamingAnalytics(string assetName, bool success, float downloadTime, float sizeMB) { var analyticsData = new Dictionary { {"asset_name", assetName}, {"success", success}, {"download_time", downloadTime}, {"size_mb", sizeMB}, {"network_type", Application.internetReachability.ToString()}, {"timestamp", System.DateTime.UtcNow.ToString("o")} }; AppsInToss.SendAnalytics("streaming_performance", analyticsData); } // 공개 API public bool IsAssetLoaded(string assetName) { return loadedAssets.Contains(assetName); } public bool IsInitialLoadComplete() { return isInitialLoadComplete; } public void RequestAssetLoad(string assetName, StreamingPriority priority = StreamingPriority.High) { if (configMap.ContainsKey(assetName) && !IsAssetLoaded(assetName)) { var config = configMap[assetName]; config.priority = priority; // 우선순위 업데이트 QueueAssetForDownload(config); } } public float GetLoadingProgress() { if (streamingAssets.Length == 0) return 1f; float totalAssets = streamingAssets.Length; float loadedCount = loadedAssets.Count; return loadedCount / totalAssets; } public string[] GetLoadedAssets() { var result = new string[loadedAssets.Count]; loadedAssets.CopyTo(result); return result; } public long GetCacheSize() { string cacheDir = Path.Combine(Application.persistentDataPath, "StreamingCache"); 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 void ClearCache() { string cacheDir = Path.Combine(Application.persistentDataPath, "StreamingCache"); if (Directory.Exists(cacheDir)) { Directory.Delete(cacheDir, true); Debug.Log("스트리밍 캐시 정리 완료"); } loadedAssets.Clear(); } } ``` *** ## 2. 최적화 전략 ### 에셋 우선순위 설정 ```c# [System.Serializable] public class AssetPriorityManager { [Header("게임 시작 필수 에셋")] public string[] immediateAssets = { "core_ui", // 핵심 UI 시스템 "player_controller", // 플레이어 조작 "first_level_mesh", // 첫 번째 레벨 메시 "essential_sounds" // 핵심 사운드 }; [Header("조기 로딩 권장 에셋")] public string[] highPriorityAssets = { "level_backgrounds", // 레벨 배경 "enemy_sprites", // 적 스프라이트 "effect_particles", // 이펙트 파티클 "ui_animations" // UI 애니메이션 }; [Header("지연 로딩 가능 에셋")] public string[] lowPriorityAssets = { "cutscene_videos", // 컷신 비디오 "boss_models", // 보스 모델 "extra_music", // 추가 음악 "optional_effects" // 선택적 이펙트 }; public StreamingPriority GetAssetPriority(string assetName) { if (System.Array.Exists(immediateAssets, a => a == assetName)) return StreamingPriority.Immediate; else if (System.Array.Exists(highPriorityAssets, a => a == assetName)) return StreamingPriority.High; else if (System.Array.Exists(lowPriorityAssets, a => a == assetName)) return StreamingPriority.Low; else return StreamingPriority.Medium; } } ``` ### 스트리밍 UI 시스템 ```c# using UnityEngine; using UnityEngine.UI; public class StreamingProgressUI : MonoBehaviour { [Header("UI 컴포넌트")] public Slider progressBar; public Text progressText; public Text statusText; public Button playButton; public GameObject loadingPanel; [Header("설정")] public float minimumDisplayTime = 2f; // 최소 로딩 화면 표시 시간 public bool hideProgressAfterReady = true; private float displayStartTime; private bool isGameReady = false; void Start() { displayStartTime = Time.realtimeSinceStartup; // 이벤트 리스너 등록 AppsInToss.OnEvent += HandleAppsInTossEvent; // 초기 UI 설정 playButton.interactable = false; loadingPanel.SetActive(true); UpdateProgressDisplay(); } void Update() { UpdateProgressDisplay(); // 최소 표시 시간 후 게임 시작 가능 if (isGameReady && Time.realtimeSinceStartup - displayStartTime >= minimumDisplayTime) { if (hideProgressAfterReady) { loadingPanel.SetActive(false); } playButton.interactable = true; } } void UpdateProgressDisplay() { if (AppsInTossAutoStreaming.Instance == null) return; float progress = AppsInTossAutoStreaming.Instance.GetLoadingProgress(); bool initialComplete = AppsInTossAutoStreaming.Instance.IsInitialLoadComplete(); // 진행률 업데이트 progressBar.value = progress; progressText.text = $"{progress * 100f:F0}%"; // 상태 텍스트 업데이트 if (initialComplete) { statusText.text = "게임 시작 준비 완료!"; statusText.color = Color.green; } else { statusText.text = "에셋 로딩 중..."; statusText.color = Color.white; } } void HandleAppsInTossEvent(string eventName, Dictionary data) { switch (eventName) { case "game_ready": isGameReady = true; break; case "streaming_progress": string assetName = data["asset_name"] as string; float assetProgress = (float)data["progress"]; // 개별 에셋 진행률 표시 statusText.text = $"로딩 중: {assetName} ({assetProgress * 100f:F0}%)"; break; } } public void OnPlayButtonClicked() { if (isGameReady) { // 게임 씬 로드 UnityEngine.SceneManagement.SceneManager.LoadScene("GameScene"); } } void OnDestroy() { AppsInToss.OnEvent -= HandleAppsInTossEvent; } } ``` *** ## 3. 성능 모니터링 ### 스트리밍 성능 추적 ```c# public class StreamingPerformanceMonitor : MonoBehaviour { [System.Serializable] public class PerformanceMetrics { public float initialLoadTime; public float totalDownloadSize; public int failedDownloads; public float averageDownloadSpeed; public string networkType; public Dictionary assetLoadTimes; public PerformanceMetrics() { assetLoadTimes = new Dictionary(); } } public PerformanceMetrics metrics = new PerformanceMetrics(); void Start() { AppsInToss.OnAnalytics += HandleAnalyticsEvent; } void HandleAnalyticsEvent(string eventName, Dictionary data) { if (eventName == "streaming_performance") { string assetName = data["asset_name"] as string; bool success = (bool)data["success"]; float downloadTime = (float)data["download_time"]; float sizeMB = (float)data["size_mb"]; if (success) { metrics.assetLoadTimes[assetName] = downloadTime; metrics.totalDownloadSize += sizeMB; if (downloadTime > 0) { float speedMBps = sizeMB / downloadTime; UpdateAverageSpeed(speedMBps); } } else { metrics.failedDownloads++; } // 성능 보고서 생성 if (metrics.assetLoadTimes.Count % 10 == 0) { GeneratePerformanceReport(); } } } void UpdateAverageSpeed(float newSpeed) { if (metrics.averageDownloadSpeed == 0) { metrics.averageDownloadSpeed = newSpeed; } else { metrics.averageDownloadSpeed = (metrics.averageDownloadSpeed + newSpeed) / 2f; } } void GeneratePerformanceReport() { var report = new Dictionary { {"total_assets_loaded", metrics.assetLoadTimes.Count}, {"total_download_size_mb", metrics.totalDownloadSize}, {"failed_downloads", metrics.failedDownloads}, {"average_download_speed_mbps", metrics.averageDownloadSpeed}, {"network_type", Application.internetReachability.ToString()}, {"device_model", SystemInfo.deviceModel}, {"timestamp", System.DateTime.UtcNow.ToString("o")} }; AppsInToss.SendAnalytics("streaming_report", report); Debug.Log($"스트리밍 성능 보고서 전송: {metrics.assetLoadTimes.Count}개 에셋 로딩 완료"); } void OnDestroy() { AppsInToss.OnAnalytics -= HandleAnalyticsEvent; } } ``` *** ## 4. 에디터 도구 ### AutoStreaming 설정 도구 ```c# #if UNITY_EDITOR using UnityEngine; using UnityEditor; using System.IO; using System.Collections.Generic; public class AutoStreamingConfigWindow : EditorWindow { private AppsInTossAutoStreaming streamingComponent; private Vector2 scrollPosition; private bool showAnalytics = true; [MenuItem("AppsInToss/AutoStreaming 설정")] public static void ShowWindow() { GetWindow("AutoStreaming 설정"); } void OnGUI() { GUILayout.Label("AutoStreaming 설정", EditorStyles.boldLabel); // 컴포넌트 참조 streamingComponent = EditorGUILayout.ObjectField( "AutoStreaming 컴포넌트", streamingComponent, typeof(AppsInTossAutoStreaming), true ) as AppsInTossAutoStreaming; if (streamingComponent == null) { EditorGUILayout.HelpBox("AutoStreaming 컴포넌트를 선택해주세요.", MessageType.Warning); return; } EditorGUILayout.Space(); // 에셋 분석 결과 표시 if (GUILayout.Button("프로젝트 에셋 분석")) { AnalyzeProjectAssets(); } EditorGUILayout.Space(); // 스트리밍 설정 표시 scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); SerializedObject so = new SerializedObject(streamingComponent); SerializedProperty streamingAssets = so.FindProperty("streamingAssets"); EditorGUILayout.PropertyField(streamingAssets, true); so.ApplyModifiedProperties(); EditorGUILayout.EndScrollView(); EditorGUILayout.Space(); // 유틸리티 버튼들 EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("설정 검증")) { ValidateStreamingConfig(); } if (GUILayout.Button("최적화 제안")) { SuggestOptimizations(); } if (GUILayout.Button("테스트 빌드")) { BuildStreamingTest(); } EditorGUILayout.EndHorizontal(); } void AnalyzeProjectAssets() { var assetGuids = AssetDatabase.FindAssets("t:Object"); var assetSizes = new Dictionary(); foreach (var guid in assetGuids) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); var fileInfo = new FileInfo(assetPath); if (fileInfo.Exists) { float sizeMB = fileInfo.Length / (1024f * 1024f); assetSizes[Path.GetFileName(assetPath)] = sizeMB; } } // 크기별 정렬 var sortedAssets = new List>(assetSizes); sortedAssets.Sort((a, b) => b.Value.CompareTo(a.Value)); Debug.Log("=== 프로젝트 에셋 크기 분석 ==="); for (int i = 0; i < Mathf.Min(10, sortedAssets.Count); i++) { var asset = sortedAssets[i]; Debug.Log($"{i + 1}. {asset.Key}: {asset.Value:F2}MB"); } float totalSizeMB = 0f; foreach (var size in assetSizes.Values) { totalSizeMB += size; } Debug.Log($"총 에셋 크기: {totalSizeMB:F2}MB"); Debug.Log($"권장 즉시 로딩 크기: {streamingComponent.initialLoadThresholdMB}MB"); } void ValidateStreamingConfig() { var issues = new List(); // 설정 검증 if (streamingComponent.streamingAssets.Length == 0) { issues.Add("스트리밍 에셋이 설정되지 않았습니다."); } // 즉시 로딩 에셋 크기 체크 float immediateSizeMB = 0f; foreach (var asset in streamingComponent.streamingAssets) { if (asset.priority == AppsInTossAutoStreaming.StreamingPriority.Immediate) { immediateSizeMB += asset.sizeMB; } } if (immediateSizeMB > streamingComponent.initialLoadThresholdMB) { issues.Add($"즉시 로딩 에셋 크기가 임계값을 초과합니다: {immediateSizeMB:F1}MB > {streamingComponent.initialLoadThresholdMB}MB"); } // 결과 표시 if (issues.Count == 0) { EditorUtility.DisplayDialog("검증 완료", "AutoStreaming 설정이 올바릅니다.", "확인"); } else { string message = "다음 문제들을 해결해주세요:\n\n" + string.Join("\n", issues); EditorUtility.DisplayDialog("검증 실패", message, "확인"); } } void SuggestOptimizations() { var suggestions = new List(); // 큰 에셋 우선순위 확인 foreach (var asset in streamingComponent.streamingAssets) { if (asset.sizeMB > 5f && asset.priority == AppsInTossAutoStreaming.StreamingPriority.Immediate) { suggestions.Add($"큰 에셋 '{asset.assetName}' ({asset.sizeMB:F1}MB)의 우선순위를 낮추는 것을 고려해보세요."); } } // 의존성 체크 var dependencyCount = new Dictionary(); foreach (var asset in streamingComponent.streamingAssets) { foreach (var dep in asset.dependencies) { dependencyCount[dep] = dependencyCount.ContainsKey(dep) ? dependencyCount[dep] + 1 : 1; } } foreach (var kvp in dependencyCount) { if (kvp.Value > 3) { suggestions.Add($"에셋 '{kvp.Key}'이 {kvp.Value}개의 다른 에셋에서 참조됩니다. 공통 번들로 분리를 고려해보세요."); } } // 결과 표시 if (suggestions.Count == 0) { EditorUtility.DisplayDialog("최적화 분석", "현재 설정이 최적화되어 있습니다.", "확인"); } else { string message = "다음 최적화를 고려해보세요:\n\n" + string.Join("\n\n", suggestions); EditorUtility.DisplayDialog("최적화 제안", message, "확인"); } } void BuildStreamingTest() { if (EditorUtility.DisplayDialog("테스트 빌드", "AutoStreaming 테스트 빌드를 시작하시겠습니까?", "시작", "취소")) { // 테스트용 빌드 설정 BuildPlayerOptions buildOptions = new BuildPlayerOptions(); buildOptions.scenes = new[] { EditorBuildSettings.scenes[0].path }; buildOptions.locationPathName = "Builds/StreamingTest/StreamingTest"; buildOptions.target = BuildTarget.WebGL; buildOptions.options = BuildOptions.Development; BuildPipeline.BuildPlayer(buildOptions); Debug.Log("AutoStreaming 테스트 빌드 완료"); } } } #endif ``` AutoStreaming을 통해 사용자가 즉시 게임을 시작할 수 있도록 하되, 백그라운드에서 추가 콘텐츠를 스마트하게 로딩하여 끊김 없는 게임 경험을 제공하세요. --- --- url: 'https://developers-apps-in-toss.toss.im/design/components/badge.md' description: 토스 디자인 시스템(TDS)의 Badge 컴포넌트 가이드입니다. 항목의 상태를 강조하는 배지 컴포넌트 사용법을 확인하세요. --- # {{ $frontmatter.title }} `Badge` 컴포넌트는 항목의 상태를 빠르게 인식할 수 있도록 강조하는 데 사용돼요. [자세히 알아보기 >](https://tossmini-docs.toss.im/tds-mobile/components/badge/) ![Badge](/assets/Thumbnail-Badge.Bwxw5iGR.png) --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/UI/BlurView.md --- # BlurView `BlurView` 컴포넌트는 **iOS에서만 지원되는 블러(Blur) 효과**를 제공해요.\ 배경을 흐리게 처리하거나, [Vibrancy 효과](https://developer.apple.com/documentation/uikit/uivibrancyeffect?language=objc)를 적용해 콘텐츠를 더 생동감 있게 표현할 수 있어요.\ Android에서는 블러 효과가 적용되지 않고 기본 [`View`](https://reactnative.dev/docs/0.72/view)가 렌더링돼요. ## 시그니처 ```typescript function BlurView({ blurType, blurAmount, reducedTransparencyFallbackColor, vibrancyEffect, ...viewProps }: BlurViewProps): import("react/jsx-runtime").JSX.Element; ``` ### 파라미터 ### 반환 값 ::: tip 유의할 점 `BlurView`는 iOS 5.126.0 이상에서만 블러 효과를 지원해요.\ Android에서는 기본 `View`가 렌더링되며, 블러 효과가 적용되지 않아요. ::: ## 예제 ### BlurView로 텍스트 블러 처리하기 아래 예시는 텍스트 위에 `BlurView`를 겹쳐 배경을 흐리게 처리하는 방법을 보여줘요. ```tsx import { View, Text, StyleSheet } from 'react-native'; import { BlurView } from '@granite-js/react-native'; function BlurViewExample() { return ( Blurred Text Non Blurred Text ); } const styles = StyleSheet.create({ container: { justifyContent: 'center', alignItems: 'center', width: '100%', height: 300, }, absolute: { position: 'absolute', top: 0, left: 0, bottom: 0, right: 0, }, }); ``` ## 참고 * [iOS Vibrancy Effect Documentation](https://developer.apple.com/documentation/uikit/uivibrancyeffect) * [Zeddios Blog 설명](https://zeddios.tistory.com/1140) --- --- url: 'https://developers-apps-in-toss.toss.im/design/components/border.md' description: 토스 디자인 시스템(TDS)의 Border 가이드입니다. 테두리 스타일 및 사용법을 확인하세요. --- # {{ $frontmatter.title }} `Border` 컴포넌트는 요소 주위에 선을 그려서 요소 간의 구분을 명확히 하고 싶을 때 사용해요. UI 요소 간의 명확한 구분과 계층 구조를 표현할 수 있어요. 주로 리스트나 섹션을 구분하는 데 사용돼요. [자세히 알아보기 >](https://tossmini-docs.toss.im/tds-mobile/components/border/) ![Border](/assets/Thumbnail-Border.BVe6_eEm.png) --- --- url: 'https://developers-apps-in-toss.toss.im/design/components/bottomcta.md' description: 토스 디자인 시스템(TDS)의 BottomCTA 컴포넌트 가이드입니다. 화면 하단 고정 CTA 버튼 사용법을 확인하세요. --- # {{ $frontmatter.title }} `BottomCTA` 컴포넌트는 사용자 인터페이스(UI)에 표시되는 호출 버튼(Call-to-Action)이에요.\ 주로 사용자가 특정 작업을 완료할 수 있도록 도와줄 때 사용하죠. 보통 페이지 하단에 항상 고정되어 있어서, 긴 스크롤이나 키보드 입력 시에도 손쉽게 접근할 수 있어요. [자세히 알아보기 >](https://tossmini-docs.toss.im/tds-mobile/components/BottomCTA/check-first/) ![Bottom CTA](/assets/Thumbnail-BottomCTA.gzD9Z0yf.png) --- --- url: 'https://developers-apps-in-toss.toss.im/design/components/button.md' description: 토스 디자인 시스템(TDS)의 Button 컴포넌트 가이드입니다. 사용자 액션을 트리거하는 버튼 컴포넌트 사용법을 확인하세요. --- # {{ $frontmatter.title }} `Button` 컴포넌트는 사용자가 어떤 액션을 트리거하거나 이벤트를 실행할 때 사용해요. 버튼은 기본적인 UI 요소로, 폼 제출, 다이얼로그 열기, 작업 취소, 삭제와 같은 다양한 액션을 처리하는 데 사용해요. [자세히 알아보기 >](https://tossmini-docs.toss.im/tds-mobile/components/button/) ![button](/assets/Thumbnail-Button_1.CiJvgV68.png) --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/UI/ColorPreference.md --- # ColorPreference 현재 기기의 색상 모드를 나타내는 타입이에요. 라이트모드, 다크모드를 나타내는 문자열이에요 ## 시그니처 ```typescript type ColorPreference = 'light' | 'dark'; ``` ### 타입 정의 #### `ColorPreference` *`'light' | 'dark'`* --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/코어/ColorPreference.md description: Bedrock 프레임워크 레퍼런스 문서입니다. --- # ColorPreference 현재 기기의 색상 모드를 나타내는 타입이에요. 라이트모드, 다크모드를 나타내는 문자열이에요 ## 시그니처 ```typescript type ColorPreference = 'light' | 'dark'; ``` ### 타입 정의 #### `ColorPreference` *`'light' | 'dark'`* --- --- url: >- https://developers-apps-in-toss.toss.im/unity/optimization/runtime/emscripten-glx.md --- # EmscriptenGLX 렌더링 모드 가이드 앱인토스 Unity 게임에서 EmscriptenGLX를 활용한 고성능 렌더링 최적화 방법을 다뤄요. *** ## 1. EmscriptenGLX 개요 ### WebGL 렌더링 최적화 EmscriptenGLX는 Unity WebGL 빌드에서 더 효율적인 그래픽 렌더링을 제공하는 고급 렌더링 모드입니다. ``` 🎮 EmscriptenGLX 렌더링 파이프라인 ├── GPU 직접 접근 최적화 ├── 배치 렌더링 향상 ├── 셰이더 컴파일 최적화 ├── 텍스처 스트리밍 개선 └── 메모리 관리 효율화 ``` ### EmscriptenGLX 매니저 ```c# using UnityEngine; using UnityEngine.Rendering; public class EmscriptenGLXManager : MonoBehaviour { public static EmscriptenGLXManager Instance { get; private set; } [Header("GLX 렌더링 설정")] public bool enableGLXOptimizations = true; public RenderQuality renderQuality = RenderQuality.Balanced; public bool enableBatchedRendering = true; public bool enableShaderOptimization = true; [Header("성능 설정")] public int targetFrameRate = 60; public bool adaptiveQuality = true; public float performanceThreshold = 0.9f; public enum RenderQuality { Performance, // 성능 우선 Balanced, // 균형 Quality // 품질 우선 } void Awake() { if (Instance == null) { Instance = this; DontDestroyOnLoad(gameObject); InitializeGLX(); } else { Destroy(gameObject); } } void InitializeGLX() { #if UNITY_WEBGL && !UNITY_EDITOR // WebGL 전용 GLX 초기화 EnableGLXOptimizations(); ConfigureRenderingSettings(); StartCoroutine(MonitorPerformance()); #endif Debug.Log("EmscriptenGLX 초기화 완료"); } void EnableGLXOptimizations() { if (!enableGLXOptimizations) return; // GLX 최적화 활성화 GL.Clear(true, true, Color.black); // 배치 렌더링 설정 if (enableBatchedRendering) { ConfigureBatchedRendering(); } // 셰이더 최적화 if (enableShaderOptimization) { OptimizeShaders(); } } void ConfigureBatchedRendering() { // Unity WebGL 배치 렌더링 설정 QualitySettings.maxQueuedFrames = 2; // 렌더링 배치 크기 최적화 Application.targetFrameRate = targetFrameRate; } void OptimizeShaders() { // 셰이더 컴파일 최적화 Shader.WarmupAllShaders(); // 사용되지 않는 셰이더 제거 CleanupUnusedShaders(); } void CleanupUnusedShaders() { var allShaders = Resources.FindObjectsOfTypeAll(); foreach (var shader in allShaders) { if (!IsShaderInUse(shader)) { // 사용되지 않는 셰이더는 메모리에서 해제 Resources.UnloadAsset(shader); } } } bool IsShaderInUse(Shader shader) { var materials = Resources.FindObjectsOfTypeAll(); foreach (var material in materials) { if (material.shader == shader) { return true; } } return false; } } ``` EmscriptenGLX를 통해 WebGL 환경에서 더 나은 그래픽 성능을 얻을 수 있어요.\ 특히 복잡한 3D 렌더링이나 많은 오브젝트를 다룰 때 성능 향상이 두드러져요. --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/사진/FetchAlbumPhotosPermissionError.md --- # FetchAlbumPhotosPermissionError 사진첩 권한이 거부되었을 때 발생하는 에러예요. 에러가 발생했을 때 `error instanceof FetchAlbumPhotosPermissionError`를 통해 확인할 수 있어요. ## 시그니처 ```typescript class FetchAlbumPhotosPermissionError extends PermissionError { constructor(); } ``` --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/연락처/FetchContactsPermissionError.md --- # FetchContactsPermissionError 연락처 권한이 거부되었을 때 발생하는 에러예요. 에러가 발생했을 때 `error instanceof FetchContactsPermissionError`를 통해 확인할 수 있어요. ## 시그니처 ```typescript class FetchContactsPermissionError extends PermissionError { constructor(); } ``` --- --- url: 'https://developers-apps-in-toss.toss.im/firebase/intro.md' --- # Firebase 연동하기 앱인토스(미니앱) Webview 환경에서 Firebase를 연동하는 방법을 안내해요.\ 이 문서는 **Vite(React + TypeScript)** 기반 프로젝트를 기준으로 작성되었어요. *** ## 개요 Firebase는 인증, 데이터베이스, 파일 저장 등 다양한 기능을 제공하는 서비스예요.\ 앱인토스 WebView 환경에서도 동일하게 사용할 수 있지만, **보안 설정과 환경 변수 관리**가 중요해요. *** ## 1. 준비하기 * Firebase 콘솔 계정 ([console.firebase.google.com](https://console.firebase.google.com)) * Vite(React + TypeScript)로 만든 프로젝트 * Node.js, npm (또는 yarn, pnpm) ## 2. Firebase 프로젝트 만들기 1. Firebase 콘솔에서 **프로젝트 생성**을 눌러 새 프로젝트를 만들어요. 2. 프로젝트 설정 → **앱 추가** → **웹(\)** 을 선택해요. 3. 앱 닉네임을 입력하고 등록하면, 아래처럼 구성 정보(firebaseConfig)가 표시돼요. ```js const firebaseConfig = { apiKey: '...', authDomain: '...', databaseURL: '...', projectId: '...', storageBucket: '...', messagingSenderId: '...', appId: '...', measurementId: '...' } ``` ## 3. 환경 변수 설정하기 Firebase 구성 정보는 보안을 위해 Vite 환경 변수로 관리하는 걸 권장해요. 프로젝트 루트에 `.env` 파일을 만들고 아래처럼 작성하세요. ```bash VITE_FIREBASE_API_KEY=your_api_key VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com VITE_FIREBASE_PROJECT_ID=your-project-id VITE_FIREBASE_STORAGE_BUCKET=your-project.appspot.com VITE_FIREBASE_MESSAGING_SENDER_ID=your_sender_id VITE_FIREBASE_APP_ID=your_app_id ``` 코드에서는 `import.meta.env.VITE_FIREBASE_API_KEY`처럼 불러와요. ## 4. Firebase 설치 및 초기화 최신 Firebase 모듈식 SDK(v12+) 기준으로 작성했어요. ```bash npm install firebase # 또는 yarn add firebase ``` `src/firebase/init.ts` ```ts import { initializeApp, getApps } from 'firebase/app' import { getAuth } from 'firebase/auth' import { getFirestore } from 'firebase/firestore' import { getStorage } from 'firebase/storage' const firebaseConfig = { apiKey: import.meta.env.VITE_FIREBASE_API_KEY, authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, appId: import.meta.env.VITE_FIREBASE_APP_ID, } const app = getApps().length ? getApps()[0] : initializeApp(firebaseConfig) export const auth = getAuth(app) export const db = getFirestore(app) export const storage = getStorage(app) ``` > **참고:** > > * `databaseURL`은 **Realtime Database**를 사용할 때만 필요해요. > Firestore를 사용한다면 생략해도 괜찮아요. > * `measurementId`는 **Firebase Analytics**(Google Analytics)를 쓸 때 필요해요. ## 5. Firestore 사용 예제 Firestore를 초기화했다면, React 컴포넌트 안에서 데이터를 읽거나 쓸 수 있어요.\ 아래는 `App.tsx`에서 단일 문서를 읽고 저장하는 가장 간단한 예시예요. ```tsx import { useState, useEffect } from 'react' import { db } from './firebase/init' import { doc, getDoc, setDoc } from 'firebase/firestore' function App() { const [name, setName] = useState('') const [savedName, setSavedName] = useState('') // Firestore에서 데이터 읽기 useEffect(() => { const fetchData = async () => { const ref = doc(db, 'users', 'exampleUser') const snap = await getDoc(ref) if (snap.exists()) { setSavedName(snap.data().name) } } fetchData() }, []) // Firestore에 데이터 쓰기 const handleSave = async () => { const ref = doc(db, 'users', 'exampleUser') await setDoc(ref, { name }) setSavedName(name) setName('') } return (

Firestore 간단 예제

setName(e.target.value)} placeholder="이름 입력" />

저장된 이름: {savedName || '(없음)'}

) } export default App ``` ### 동작 방식 * 데이터 읽기 (`getDoc`) * Firestore의 users/exampleUser 문서를 한 번만 불러와요. * 문서가 존재하면 snap.data()의 값을 화면에 표시해요. * 데이터 쓰기 (`setDoc`) * 입력한 이름을 Firestore에 덮어써 저장해요. * 문서가 없으면 자동으로 새로 생성돼요. ![firestore](/assets/firestore-1.DBSmKjYU.png) > Firestore는 단일 문서 외에도 여러 기능을 지원해요. > > * 실시간 구독 : `onSnapshot(doc(...))`을 사용하면 문서가 변경될 때마다 UI가 자동으로 갱신돼요. > * 컬렉션 다루기 : `collection()`, `addDoc()`을 사용하면 여러 문서를 추가하고 불러올 수 있어요. > * 파일 저장 : `getStorage()`로 `Storage`를 연결해 이미지나 파일을 업로드할 수 있어요. > * 인증 연동 : `getAuth()`와 함께 사용하면 사용자별 데이터 저장이 가능해요. ## 6. 보안 체크리스트 * 민감한 정보 환경 변수로 관리하기 * Firebase API Key, 서비스 계정 키 등은 코드에 직접 작성하지 않고 `.env`로 관리하세요. * 환경 파일을 Git 등에 업로드하지 않기 * `.env` 파일은 `.gitignore`에 반드시 추가하세요. * 키가 노출되면 Firebase 콘솔에서 즉시 재발급하고, 관련 프로젝트 권한을 점검하세요. * Firebase 보안 규칙 설정하기 * Firestore / Storage는 기본적으로 모든 사용자에게 공개되어 있어요. * 배포 전에 반드시 인증된 사용자만 접근하도록 규칙을 수정하세요. * 출처(Origin) 제한 확인하기 * Firebase 콘솔의 Authentication / Hosting / API Key 설정에서 허용 도메인을 제한해두세요. * 미니앱(WebView) 도메인만 허용하면 무단 접근을 예방할 수 있어요. :::tip 허용 대상 도메인 https://.apps.tossmini.com : 실제 서비스 환경 https://.private-apps.tossmini.com : 콘솔 QR 테스트 환경 ::: --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/style-utils/Flex.md --- # Flex `Flex`는 자식 요소를 **Flexbox 레이아웃** 기반으로 배치하는 레이아웃 컴포넌트예요.\ 가로와 세로 방향 배치, 정렬, 중앙 정렬을 간편하게 구성할 수 있어요.\ 또한, `Flex.Center`, `Flex.CenterVertical`, `Flex.CenterHorizontal` 컴포넌트를 제공해요. ### 시그니처 ```typescript Flex: FlexType ``` ### 파라미터 ### 예제 : 가로, 세로 방향으로 요소를 배치하기 ```tsx import { Flex } from '@granite-js/react-native'; import { Text } from 'react-native'; function FlexExample() { return ( <> 세로로 배치해요 1 2 3 가로로 배치해요 1 2 3 ); } ``` ## 가로와 세로 모두 중앙 정렬하기 (`Flex.Center`) `Flex.Center`는 자식 요소를 **가로와 세로 모두 중앙**에 배치하는 컴포넌트예요.\ 내부적으로 `justifyContent: 'center'`, `alignItems: 'center'` 를 설정해요. ### 예제 ```tsx import { Flex } from '@granite-js/react-native'; import { Text } from 'react-native'; function FlexCenterExample() { return ( 정 중앙에 배치해요 ); } ``` ## 가로 방향으로 중앙 정렬하기 (`Flex.CenterHorizontal`) `Flex.CenterHorizontal`은 자식 요소를 가로 방향 중앙에 배치하는 컴포넌트예요.\ `alignItems: 'center'`가 기본으로 적용돼요. ### 예제 ```tsx import { Flex } from '@granite-js/react-native'; import { Text } from 'react-native'; function FlexCenterHorizontalExample() { return ( 가로 중앙에 배치해요 ); } ``` ## 세로 방향으로 중앙 정렬하기 (`Flex.CenterVertical`) `Flex.CenterVertical`은 자식 요소를 세로 방향 중앙에 배치하는 컴포넌트예요.\ `justifyContent: 'center'`가 기본으로 적용돼요. ### 예제 ```tsx import { Flex } from '@granite-js/react-native'; import { Text } from 'react-native'; function FlexCenterVerticalExample() { return ( 세로 중앙에 배치해요 ); } ``` ## 참고사항 * `Flex` 계열 컴포넌트는 `React Native` 의 `Flexbox` 속성을 간결하게 감싸 제공해요. * Flex.Center 계열 컴포넌트를 사용하면 반복적인 중앙 정렬 코드를 단순화할 수 있어요. * `direction`, `align`, `justify` 속성으로 세밀한 레이아웃 조정도 가능해요. --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/클립보드/GetClipboardTextPermissionError.md --- # GetClipboardTextPermissionError 클립보드 읽기 권한이 거부되었을 때 발생하는 에러예요. 에러가 발생했을 때 `error instanceof GetClipboardTextPermissionError`를 통해 확인할 수 있어요. ## 시그니처 ```typescript class GetClipboardTextPermissionError extends PermissionError { constructor(); } ``` --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/위치 정보/GetCurrentLocationPermissionError.md --- # GetCurrentLocationPermissionError 위치 권한이 거부되었을 때 발생하는 에러예요. 에러가 발생했을 때 `error instanceof GetCurrentLocationPermissionError`를 통해 확인할 수 있어요. ## 시그니처 ```typescript class GetCurrentLocationPermissionError extends PermissionError { constructor(); } ``` --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/코어/Bedrock.md --- # Granite `Granite`는 React Native 기반 프레임워크이며, 앱인토스 SDK의 서비스 런타임을 구성하는 핵심 모듈입니다. :::info 앱인토스의 React Native 미니앱은 **Granite** 기반에서만 동작합니다. ::: ## 시그니처 ```typescript AppsInToss: { registerApp(AppContainer: ComponentType>, { appName, context, router }: BedrockProps): (initialProps: InitialProps) => JSX.Element; readonly appName: string; } ``` ### 프로퍼티 `AppsInToss.registerApp` 함수가 제공하는 기능은 다음과 같아요. * 라우팅: 파일 경로에 맞게 URL이 자동으로 매핑돼요. Next.js의 파일 기반 라우팅과 비슷한 방식으로 동작해요. 예를 들어 `/my-service/pages/index.ts` 파일은 `intoss://my-service` 주소로 접근할 수 있고, `/my-service/pages/home.ts 파일은 intoss://my-service/home` 주소로 접근할 수 있어요. * 쿼리 파라미터: URL 스킴으로 전달 받은 쿼리 파라미터를 쉽게 사용할 수 있어요. 예를 들어, `referrer` 파라미터를 받아서 로그를 남길 수 있어요. * 뒤로 가기 제어: 뒤로 가기 이벤트를 제어할 수 있어요. 예를 들어, 사용자가 화면에서 뒤로 가기를 누르면 다이얼로그를 띄우거나 화면을 닫을 수 있어요. * 화면 가시성(Visibility): 화면이 사용자에게 보이는지, 가려져 있는지 알 수 있어요. 예를 들어, 사용자가 홈 화면으로 나갔을 때 이 값을 활용해 특정 동작을 처리할 수 있어요. ## 예제 ### `AppsInToss` 컴포넌트로 만드는 예제 ```tsx import { AppsInToss } from '@apps-in-toss/framework'; import { PropsWithChildren } from 'react'; import { InitialProps } from '@granite-js/react-native'; import { context } from '../require.context'; function AppContainer({ children }: PropsWithChildren) { return <>{children}; } export default AppsInToss.registerApp(AppContainer, { context }); ``` --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/코어/InitialProps.md --- # Granite 프로퍼티 ## `InitialProps` React Native 앱에서 사용자가 특정 화면에 진입할 때, 네이티브 플랫폼(Android/iOS)이 앱으로 전달하는 초기 데이터 타입을 제공해요. 초기 데이터는 화면 초기화에 사용되는 중요한 정보를 포함하고 있고, 네이티브 플랫폼별로 필요한 데이터 타입이 달라요. Android에서 제공하는 데이터 타입은 `AndroidInitialProps`이고, iOS에서 제공하는 데이터 타입은 `IOSInitialProps` 이에요. ## 시그니처 ```typescript type InitialProps = AndroidInitialProps | IOSInitialProps; ``` ### 프로퍼티 ## 예제 ### `InitialProps`를 사용하는 예제 ::: code-group ```tsx [_app.tsx] import { AppsInToss } from '@apps-in-toss/framework'; import { PropsWithChildren } from 'react'; import { InitialProps } from '@granite-js/react-native'; import { context } from '../require.context'; function AppContainer({ children, ...initialProps }: PropsWithChildren) { console.log({ initialProps }); return <>{children}; } export default AppsInToss.registerApp(AppContainer, { context }); ::: ``` --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/네트워크/http.md --- # HTTP 통신하기 Bedrock에서 네트워크 통신을 하는 방법을 소개해요. ## Fetch API 사용하기 Bedrock에서는 React Native처럼 [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch)를 사용해서 네트워크 통신을 할 수 있어요. Fetch API는 비동기 네트워크 요청을 간단히 구현할 수 있는 표준 웹 API에요. 다음은 "할 일 목록"을 가져오는 API를 사용해 "할 일"이 완료됐을 때 취소선을 표시하는 예제에요. ::: code-group ```tsx [pages/index.tsx] import { createRoute } from '@granite-js/react-native'; import { useCallback, useState } from "react"; import { Button, ScrollView } from "react-native"; import { Todo, TodoItem } from "./Todo"; export const Route = createRoute("/", { component: Index, }); function Index() { const [todos, setTodos] = useState([]); // [!code highlight:10] const handlePress = useCallback(async () => { /** * JSONPlaceholder API에서 할 일 데이터를 가져와요. * @link https://jsonplaceholder.typicode.com/ */ const result = await fetch("https://jsonplaceholder.typicode.com/todos"); const json = await result.json(); // 응답 데이터를 JSON으로 변환해요. setTodos(json); // 가져온 데이터를 상태로 저장해요. }, []); return ( <> ); }; ``` ### 상단 여백 적용 예시 상단 고정 헤더가 상태바에 겹치지 않도록 여백을 주는 예시예요. ```tsx import { getSafeAreaInsets } from "@apps-in-toss/web-framework"; const insets = getSafeAreaInsets(); const Header = () => { return (

제목

); }; ``` ## `useSafeAreaInsets` 모바일 브라우저에서 상태바나 홈 인디케이터 같은 시스템 UI에 의해 콘텐츠가 가려지는 문제를 방지할 수 있도록, 화면의 안전 영역(Safe Area) 여백 값을 픽셀 단위로 계산해줘요.\ `useSafeAreaInsets` 는 ReactNative 로 개발할 때 사용할 수 있어요. ```tsx import { useSafeAreaInsets } from '@granite-js/native/react-native-safe-area-context'; const { top: safeAreaTop, right: safeAreaRight } = useSafeAreaInsets(); // 네비바 상단 여백: safeAreaTop // 네비바 우측 여백: safeAreaRight + 10 ``` --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/UI/ScrollViewInertialBackground.md --- # ScrollViewInertialBackground iOS `ScrollView` 콘텐츠의 위, 아래 공간에 배경색을 추가해서, 스크롤 했을 때 자연스러운 시각 효과를 제공해요. iOS에서는 스크롤이 끝에 도달했을 때 살짝 튕기는 듯한 [Bounce 효과](https://medium.com/@wcandillon/ios-bounce-list-effect-with-react-native-5102e3a83999)가 발생해요. 이때 콘텐츠 위, 아래 공간에 배경색을 설정하면 더 일관된 유저 경험을 제공할 수 있어요. ## 시그니처 ```typescript function ScrollViewInertialBackground({ topColor, bottomColor, spacer: _spacer, }: ScrollViewInertialBackgroundProps): import("react/jsx-runtime").JSX.Element; ``` ### 파라미터 ## 예제 ### 스크롤 뷰 위, 아래에 배경색을 추가하기 스크롤 뷰 위에 빨간색, 아래에 파란색 배경색을 추가해요. 스크롤을 벗어난 영역에 배경색이 적용돼요. ```tsx import { ScrollView, View, Text } from 'react-native'; import { ScrollViewInertialBackground } from '@granite-js/react-native'; const dummies = Array.from({ length: 20 }, (_, i) => i); function InertialBackgroundExample() { return ( {dummies.map((i) => ( 스크롤을 해보세요. ))} ); } ``` --- --- url: 'https://developers-apps-in-toss.toss.im/learn-more/sentry-monitoring.md' description: Sentry를 이용한 앱인토스 미니앱 모니터링 가이드입니다. 에러 추적 및 성능 모니터링 방법을 확인하세요. --- # Sentry 설정하기 앱에 **Sentry**를 연동하면 JavaScript에서 발생한 오류를 자동으로 감지하고 모니터링할 수 있어요.\ 이를 통해 앱의 안정성을 높이고, 사용자에게 더 나은 경험을 제공할 수 있어요. ## 1. Sentry 초기 설정 [Sentry 공식 가이드](https://docs.sentry.io/platforms/react-native)를 참고하여 앱에서 Sentry를 초기화해주세요. 앱인토스 환경에서는 네이티브 오류 추적 기능을 사용할 수 없으므로 `enableNative` 옵션을 `false`로 설정해야 해요. ::: tip 네이티브 오류 추적은 지원되지 않아요 앱인토스 환경에서는 JavaScript 오류만 추적할 수 있어요. ::: ```ts import * as Sentry from '@sentry/react-native'; Sentry.init({ // ... enableNative: false, }); ``` ## 2. Sentry 플러그인 설치 프로젝트 루트 디렉터리에서 사용 중인 패키지 관리자에 맞는 명령어를 실행해 Sentry 플러그인을 설치하세요. ::: code-group ```sh [npm] npm install @granite-js/plugin-sentry ``` ```sh [pnpm] pnpm install @granite-js/plugin-sentry ``` ```sh [yarn] yarn add @granite-js/plugin-sentry ``` ::: ## 3. 플러그인 구성 설치한 `@granite-js/plugin-sentry`를 `granite.config.ts` 파일의 `plugins` 항목에 추가하세요. 앱인토스 환경에서는 **`useClient` 옵션을 반드시 `false`로 설정**해야 해요. ::: tip 왜 `useClient` 옵션을 꺼야 하나요? `useClient`를 `false`로 설정하면 앱 빌드 시 Sentry에 소스맵이 자동으로 업로드되지 않아요. 앱인토스 환경에서는 빌드 후 **수동으로 소스맵을 업로드**해야 하므로, 이 옵션을 꺼야 해요. ::: ```ts [granite.config.ts] import { defineConfig } from '@granite-js/react-native/config'; import { sentry } from '@granite-js/plugin-sentry'; // [!code highlight] import { appsInToss } from '@apps-in-toss/framework/plugins'; export default defineConfig({ // ..., plugins: [ sentry({ useClient: false }), // [!code highlight] appsInToss({ // ... }), ], }); ``` ## 4. 앱 출시하기 앱을 출시하는 방법은 [미니앱 출시](/development/deploy.md) 문서를 참고하세요. ## 5. Sentry에 소스맵 업로드 출시된 미니앱의 오류를 정확히 추적하려면\ 빌드 후 생성된 **소스맵을 Sentry에 업로드**해야 해요. 아래 명령어를 실행하면 소스맵이 업로드돼요. ::: tip 입력값 안내 * ``: 앱인토스 콘솔에서 발급받은 API 키예요. * ``: Sentry에 등록된 서비스 이름이에요. * ``: 앱을 배포할 때 사용한 배포 ID예요. ::: ::: code-group ```sh [npm] npx ait sentry upload-sourcemap \ --api-key \ --app-name \ --deployment-id ``` ```sh [pnpm] pnpm ait sentry upload-sourcemap \ --api-key \ --app-name \ --deployment-id ``` ```sh [yarn] yarn ait sentry upload-sourcemap \ --api-key \ --app-name \ --deployment-id ``` ::: 명령어 실행 후 Sentry의 조직(Org), 프로젝트(Project), 인증 토큰 입력이 요청됩니다.\ 모든 정보를 입력하면 해당 서비스의 소스맵이 Sentry에 업로드돼요. --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/클립보드/SetClipboardTextPermissionError.md --- # SetClipboardTextPermissionError 클립보드 쓰기 권한이 거부되었을 때 발생하는 에러예요. 에러가 발생했을 때 `error instanceof SetClipboardTextPermissionError`를 통해 확인할 수 있어요. ## 시그니처 ```typescript class SetClipboardTextPermissionError extends PermissionError { constructor(); } ``` --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/style-utils/Spacing.md --- # Spacing `Spacing`은 빈 공간을 차지해 **가로 또는 세로 방향으로 여백을 추가**하는 컴포넌트예요.\ 화면 요소 사이의 간격을 일정하게 맞추거나, 시각적인 구분을 줄 때 사용해요. ## 시그니처 ```typescript Spacing: import("react").NamedExoticComponent ``` ## 파라미터 ## 예제 #### 세로 방향으로 여백 추가하기 ```tsx import { View, Text } from 'react-native'; import { Spacing } from '@granite-js/react-native'; function VerticalSpacingExample() { return ( Top Bottom — 세로 여백만큼 아래에 위치해요 ); } ``` #### 가로 방향으로 여백 추가하기 ```tsx import { View, Text } from 'react-native'; import { Spacing } from '@granite-js/react-native'; function HorizontalSpacingExample() { return ( Left Right — 가로 여백만큼 옆에 위치해요 ); } ``` ## 참고사항 * `Spacing`은 `margin`이나 `padding` 대신 명시적으로 간격을 줄 때 유용해요. * `direction` 속성을 통해 한쪽 방향의 여백만 적용할 수 있어요. * `size` 값은 숫자(px 단위)로 지정하며, 반응형 간격이 필요할 경우 `StyleSheet`와 함께 사용할 수 있어요. --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/style-utils/Stack.md --- # Stack `Stack`은 자식 요소를 **가로 또는 세로 방향으로 일정 간격을 두고 배치**하는 레이아웃 컴포넌트예요.\ `direction` 속성으로 방향을 설정하고, `gutter` 속성으로 요소 간 간격을 조절할 수 있어요.\ 가로 배치는 `Stack.Horizontal`, 세로 배치는 `Stack.Vertical`로 간편하게 사용할 수 있어요. ### 시그니처 ```typescript Stack: StackType ``` ### 파라미터 ### 예제 : 가로·세로 방향으로 배치하기 ```tsx import { Text } from 'react-native'; import { Stack } from '@granite-js/react-native'; function StackExample() { return ( <> 16간격을 두고 가로 방향으로 배치해요 1 2 3 16간격을 두고 세로 방향으로 배치해요 1 2 3 ); } ``` ## 가로로 배치하기 (`Stack.Horizontal`) `Stack.Horizontal`은 자식 요소를 **가로 방향**으로 쌓아 배치해요.\ `gutter` 속성으로 간격을 설정해 일정한 가로 레이아웃을 유지할 수 있어요. ### 예제 ```tsx import { Stack } from '@granite-js/react-native'; import { View, Text } from 'react-native'; function StackHorizontalExample() { return ( 16간격을 두고 가로 방향으로 배치해요 1 2 3 ); } ``` ## 세로로 배치하기 (`Stack.Vertical`) `Stack.Vertical`은 자식 요소를 **세로 방향**으로 쌓아 배치해요.\ `gutter` 속성으로 요소 간의 간격을 쉽게 제어할 수 있어요. ### 예제 ```tsx import { Stack } from '@granite-js/react-native'; import { View, Text } from 'react-native'; function StackVerticalExample() { return ( 16간격을 두고 세로 방향으로 배치해요 1 2 3 ); } ``` ## 참고사항 * Stack은 `Flexbox` 기반으로 구성되어 있어요. * `gutter`를 통해 일정한 간격을 유지할 수 있고, React 컴포넌트를 전달해 커스텀 간격도 만들 수 있어요. * `Stack.Horizontal`과 `Stack.Vertical`을 사용하면 가로·세로 레이아웃을 더 간결하게 구성할 수 있어요. --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/위치 정보/StartUpdateLocationPermissionError.md --- # StartUpdateLocationPermissionError 위치 업데이트 권한이 거부되었을 때 발생하는 에러예요. 에러가 발생했을 때 `error instanceof StartUpdateLocationPermissionError`를 통해 확인할 수 있어요. ## 시그니처 ```typescript StartUpdateLocationPermissionError: typeof GetCurrentLocationPermissionError ``` --- --- url: 'https://developers-apps-in-toss.toss.im/design/components/tab.md' description: 토스 디자인 시스템(TDS)의 Tab 컴포넌트 가이드입니다. 탭 네비게이션 구성 및 사용법을 확인하세요. --- # {{ $frontmatter.title }} `Tab` 컴포넌트는 여러 콘텐츠를 한 화면에서 효율적으로 전환할 수 있도록 도와줘요. 각 탭은 콘텐츠 목록을 보여주고, 사용자가 선택한 탭에 따라 해당 콘텐츠를 전환해요. `Tab` 컴포넌트를 사용하면 여러 콘텐츠를 한 번에 볼 수 있고, 전환도 간편하게 할 수 있어요. [자세히 알아보기 >](https://tossmini-docs.toss.im/tds-mobile/components/tab/) ![tab](/assets/Thumbnail-Tab.D8dHQEie.png) --- --- url: 'https://developers-apps-in-toss.toss.im/design/components/tabbar.md' description: 토스 디자인 시스템(TDS)의 Tabbar 컴포넌트 가이드입니다. 하단 탭바 구성 및 사용법을 확인하세요. --- # {{ $frontmatter.title }} 하단 탭바는 서비스의 주요 기능에 빠르게 접근할 수 있도록 돕는 내비게이션 요소입니다. 필수 컴포넌트는 아니지만, 탭바가 필요하다면 TDS 미사용 시에도 반드시 토스에서 제공하는 플로팅 형태의 탭바를 사용해야 합니다. ![tabbar](/assets/Thumbnail-Tabbar.B3GwW4mW.png) --- --- url: 'https://developers-apps-in-toss.toss.im/bedrock/reference/framework/UI/Text.md' description: Bedrock 프레임워크 레퍼런스 문서입니다. --- # Text `Text` 컴포넌트를 이용해서 글자를 화면에 표시할 수 있어요.\ 예를 들어, 아래 코드는 안녕하세요라는 글자를 화면에 표시하는 예제에요. ```tsx import { Text } from "react-native"; export default function TextPage() { return 안녕하세요; } ``` ::: tip 글자는 `Text` 컴포넌트로 감싸지 않으면 에러가 발생해요. 에러 메시지는 다음과 같아요. 글자를 화면에 표시하려면 반드시 `Text` 컴포넌트를 사용해야 해요. ``` Error: Text strings must be rendered within a component. ``` ::: --- --- url: 'https://developers-apps-in-toss.toss.im/design/components/text.md' description: 토스 디자인 시스템(TDS)의 Text 컴포넌트 가이드입니다. 텍스트 스타일 및 타이포그래피 사용법을 확인하세요. --- # {{ $frontmatter.title }} 컴포넌트에 기본 포함된 텍스트를 제외한 모든 문구는 이 컴포넌트를 통해 입력합니다. 토스 전용 폰트인 ‘토스 프로덕트 산스’가 기본으로 적용되어 있습니다. ![text](/assets/Thumbnail-Text.CUOGp53J.png) --- --- url: 'https://developers-apps-in-toss.toss.im/design/components/top.md' description: 토스 디자인 시스템(TDS)의 Top 컴포넌트 가이드입니다. 상단 영역 구성 및 사용법을 확인하세요. --- # {{ $frontmatter.title }} `Top` 컴포넌트는 다양한 레이아웃을 지원하는 페이지 상단 컴포넌트로, 여러 요소(텍스트, 버튼, 이미지 등)를 쉽게 배치할 수 있어요. 주로 페이지의 최상단에 사용되어 헤더나 타이틀 영역을 구성하는 데 활용돼요. [자세히 알아보기 >](https://tossmini-docs.toss.im/tds-mobile/components/top/) ![top](/assets/Thumbnail-Top.DW3dRbnk.png) --- --- url: >- https://developers-apps-in-toss.toss.im/unity/optimization/runtime/unity-profiler.md --- # Unity Profiler 앱인토스 Unity 게임 개발에서 Unity Profiler를 효과적으로 활용하여 성능 이슈를 진단하고 해결하는 완전한 가이드입니다. *** ## 1. Profiler 설정 및 연결 ### 앱인토스 환경 Profiler 설정 ```c# public class AppsInTossProfilerSetup : MonoBehaviour { [Header("Profiler 연결 설정")] public bool enableAutoConnect = true; public string remoteIP = "192.168.1.100"; public int profilerPort = 54998; [Header("프로파일링 영역")] public bool enableCPUProfiling = true; public bool enableMemoryProfiling = true; public bool enableGPUProfiling = true; public bool enableAudioProfiling = true; public bool enableNetworkProfiling = true; void Start() { #if DEVELOPMENT_BUILD || UNITY_EDITOR SetupProfilerConnection(); ConfigureProfilerAreas(); #endif } void SetupProfilerConnection() { if (enableAutoConnect) { // 자동 연결 시도 ProfilerDriver.profileEditor = false; ProfilerDriver.connectedProfiler = 0; Debug.Log($"Unity Profiler 자동 연결 시작: {remoteIP}:{profilerPort}"); } // 앱인토스 환경 정보 로깅 LogAppsInTossEnvironmentInfo(); } void ConfigureProfilerAreas() { List activeAreas = new List(); if (enableCPUProfiling) activeAreas.Add(ProfilerArea.CPU); if (enableMemoryProfiling) activeAreas.Add(ProfilerArea.Memory); if (enableGPUProfiling) activeAreas.Add(ProfilerArea.GPU); if (enableAudioProfiling) activeAreas.Add(ProfilerArea.Audio); if (enableNetworkProfiling) activeAreas.Add(ProfilerArea.NetworkOperations); Debug.Log($"활성화된 Profiler 영역: {string.Join(", ", activeAreas)}"); } void LogAppsInTossEnvironmentInfo() { Debug.Log("=== 앱인토스 환경 정보 ==="); Debug.Log($"플랫폼: {Application.platform}"); Debug.Log($"기기 모델: {SystemInfo.deviceModel}"); Debug.Log($"메모리: {SystemInfo.systemMemorySize}MB"); Debug.Log($"GPU: {SystemInfo.graphicsDeviceName}"); Debug.Log($"GPU 메모리: {SystemInfo.graphicsMemorySize}MB"); Debug.Log($"프로세서: {SystemInfo.processorType}"); Debug.Log($"코어 수: {SystemInfo.processorCount}"); Debug.Log($"배터리 레벨: {SystemInfo.batteryLevel * 100}%"); Debug.Log("========================"); } } ``` *** ## 2. 커스텀 Profiler 마커 ### 앱인토스 특화 성능 마커 ```c# using Unity.Profiling; public static class AppsInTossProfilerMarkers { // 앱인토스 특화 성능 마커들 public static readonly ProfilerMarker TossLoginMarker = new ProfilerMarker("AppsInToss.Login"); public static readonly ProfilerMarker TossPaymentMarker = new ProfilerMarker("AppsInToss.Payment"); public static readonly ProfilerMarker TossAnalyticsMarker = new ProfilerMarker("AppsInToss.Analytics"); public static readonly ProfilerMarker AssetLoadingMarker = new ProfilerMarker("AppsInToss.AssetLoading"); public static readonly ProfilerMarker SceneTransitionMarker = new ProfilerMarker("AppsInToss.SceneTransition"); public static readonly ProfilerMarker UIUpdateMarker = new ProfilerMarker("AppsInToss.UIUpdate"); public static readonly ProfilerMarker NetworkRequestMarker = new ProfilerMarker("AppsInToss.NetworkRequest"); public static readonly ProfilerMarker GameLogicMarker = new ProfilerMarker("AppsInToss.GameLogic"); // 메모리 관련 마커 public static readonly ProfilerMarker MemoryCleanupMarker = new ProfilerMarker("AppsInToss.MemoryCleanup"); public static readonly ProfilerMarker GarbageCollectionMarker = new ProfilerMarker("AppsInToss.GC"); // 렌더링 관련 마커 public static readonly ProfilerMarker BatchingMarker = new ProfilerMarker("AppsInToss.Batching"); public static readonly ProfilerMarker LODUpdateMarker = new ProfilerMarker("AppsInToss.LODUpdate"); public static readonly ProfilerMarker CullingMarker = new ProfilerMarker("AppsInToss.Culling"); } // 사용 예제 public class AppsInTossGameManager : MonoBehaviour { void Update() { using (AppsInTossProfilerMarkers.GameLogicMarker.Auto()) { UpdateGameLogic(); } using (AppsInTossProfilerMarkers.UIUpdateMarker.Auto()) { UpdateUI(); } } void UpdateGameLogic() { // 게임 로직 처리 } void UpdateUI() { // UI 업데이트 로직 } public void ProcessTossLogin() { using (AppsInTossProfilerMarkers.TossLoginMarker.Auto()) { // 토스 로그인 처리 AppsInToss.Login((result) => { Debug.Log($"토스 로그인 결과: {result}"); }); } } public void ProcessPayment(float amount) { using (AppsInTossProfilerMarkers.TossPaymentMarker.Auto()) { // 토스페이 결제 처리 AppsInToss.ProcessPayment(amount, (success) => { Debug.Log($"결제 결과: {success}"); }); } } } ``` *** ## 3. 자동화된 성능 분석 ### Profiler 데이터 자동 분석 ```c# #if UNITY_EDITOR using UnityEngine; using UnityEditor; using UnityEditorInternal; using System.Collections.Generic; using System.Linq; public class AppsInTossProfilerAnalyzer : EditorWindow { [System.Serializable] public class PerformanceIssue { public string category; public string description; public float severity; // 0-1 public string suggestion; public int frameNumber; } private List detectedIssues = new List(); private Vector2 scrollPosition; private bool isAnalyzing = false; [MenuItem("AppsInToss/Profiler Analyzer")] public static void ShowWindow() { GetWindow("Profiler Analyzer"); } void OnGUI() { GUILayout.Label("앱인토스 Profiler 자동 분석", EditorStyles.boldLabel); GUILayout.Space(10); if (GUILayout.Button(isAnalyzing ? "분석 중..." : "성능 분석 시작", GUILayout.Height(30))) { if (!isAnalyzing) { StartPerformanceAnalysis(); } } GUILayout.Space(10); // 이슈 목록 표시 DrawIssueList(); GUILayout.Space(10); // 최적화 제안 DrawOptimizationSuggestions(); } void StartPerformanceAnalysis() { isAnalyzing = true; detectedIssues.Clear(); // Profiler 데이터 분석 AnalyzeProfilerData(); isAnalyzing = false; Repaint(); } void AnalyzeProfilerData() { int frameCount = ProfilerDriver.lastFrameIndex - ProfilerDriver.firstFrameIndex; for (int i = ProfilerDriver.firstFrameIndex; i <= ProfilerDriver.lastFrameIndex; i++) { AnalyzeFrame(i); } Debug.Log($"성능 분석 완료: {frameCount}프레임, {detectedIssues.Count}개 이슈 발견"); } void AnalyzeFrame(int frameIndex) { // CPU 사용량 분석 float cpuTime = ProfilerDriver.GetFormattedStatisticsValue(frameIndex, ProfilerArea.CPU, "Total"); if (cpuTime > 33.33f) // 30 FPS 기준 { detectedIssues.Add(new PerformanceIssue { category = "CPU", description = $"높은 CPU 사용량: {cpuTime:F2}ms", severity = Mathf.Clamp01(cpuTime / 100f), suggestion = "CPU 집약적인 작업을 최적화하거나 프레임에 분산하세요", frameNumber = frameIndex }); } // 메모리 사용량 분석 long memoryUsage = ProfilerDriver.GetStatisticsValue(frameIndex, ProfilerArea.Memory, "Total Reserved Memory"); if (memoryUsage > 200 * 1024 * 1024) // 200MB 초과 { detectedIssues.Add(new PerformanceIssue { category = "Memory", description = $"높은 메모리 사용량: {memoryUsage / (1024*1024)}MB", severity = Mathf.Clamp01((float)memoryUsage / (300f * 1024 * 1024)), suggestion = "메모리 사용량을 줄이거나 가비지 컬렉션을 최적화하세요", frameNumber = frameIndex }); } // 렌더링 분석 float renderingTime = ProfilerDriver.GetFormattedStatisticsValue(frameIndex, ProfilerArea.Rendering, "Camera.Render"); if (renderingTime > 16.67f) // 60 FPS 기준 { detectedIssues.Add(new PerformanceIssue { category = "Rendering", description = $"높은 렌더링 시간: {renderingTime:F2}ms", severity = Mathf.Clamp01(renderingTime / 50f), suggestion = "드로우콜을 줄이거나 LOD 시스템을 최적화하세요", frameNumber = frameIndex }); } // 가비지 컬렉션 분석 float gcTime = ProfilerDriver.GetFormattedStatisticsValue(frameIndex, ProfilerArea.Memory, "GC.Collect"); if (gcTime > 1f) { detectedIssues.Add(new PerformanceIssue { category = "GC", description = $"가비지 컬렉션 지연: {gcTime:F2}ms", severity = Mathf.Clamp01(gcTime / 10f), suggestion = "메모리 할당을 줄이고 오브젝트 풀링을 사용하세요", frameNumber = frameIndex }); } } void DrawIssueList() { GUILayout.Label($"발견된 성능 이슈 ({detectedIssues.Count}개)", EditorStyles.boldLabel); if (detectedIssues.Count == 0) { GUILayout.Label("성능 이슈가 발견되지 않았습니다."); return; } scrollPosition = GUILayout.BeginScrollView(scrollPosition, GUILayout.Height(200)); var groupedIssues = detectedIssues.GroupBy(i => i.category); foreach (var group in groupedIssues) { GUILayout.Label(group.Key, EditorStyles.boldLabel); foreach (var issue in group.Take(5)) // 카테고리당 최대 5개만 표시 { Color originalColor = GUI.color; GUI.color = GetSeverityColor(issue.severity); GUILayout.BeginVertical("box"); GUILayout.Label($"프레임 {issue.frameNumber}: {issue.description}"); GUILayout.Label($"제안: {issue.suggestion}", EditorStyles.wordWrappedMiniLabel); GUILayout.EndVertical(); GUI.color = originalColor; } GUILayout.Space(5); } GUILayout.EndScrollView(); } Color GetSeverityColor(float severity) { if (severity > 0.8f) return Color.red; else if (severity > 0.5f) return Color.yellow; else return Color.green; } void DrawOptimizationSuggestions() { GUILayout.Label("최적화 제안", EditorStyles.boldLabel); var categoryGroups = detectedIssues.GroupBy(i => i.category); foreach (var group in categoryGroups) { string category = group.Key; int issueCount = group.Count(); float avgSeverity = group.Average(i => i.severity); GUILayout.BeginVertical("box"); GUILayout.Label($"{category} 최적화 ({issueCount}개 이슈, 심각도: {avgSeverity:F2})"); switch (category) { case "CPU": GUILayout.Label("• Update 함수 최적화", EditorStyles.miniLabel); GUILayout.Label("• 코루틴 사용 고려", EditorStyles.miniLabel); GUILayout.Label("• 물리 연산 최적화", EditorStyles.miniLabel); break; case "Memory": GUILayout.Label("• 오브젝트 풀링 적용", EditorStyles.miniLabel); GUILayout.Label("• 메모리 누수 체크", EditorStyles.miniLabel); GUILayout.Label("• 에셋 언로딩", EditorStyles.miniLabel); break; case "Rendering": GUILayout.Label("• 배칭 최적화", EditorStyles.miniLabel); GUILayout.Label("• LOD 시스템 적용", EditorStyles.miniLabel); GUILayout.Label("• 컬링 최적화", EditorStyles.miniLabel); break; case "GC": GUILayout.Label("• string 할당 최소화", EditorStyles.miniLabel); GUILayout.Label("• 배열 재사용", EditorStyles.miniLabel); GUILayout.Label("• StringBuilder 사용", EditorStyles.miniLabel); break; } GUILayout.EndVertical(); } } } #endif ``` Unity Profiler를 활용한 체계적인 성능 분석을 통해 앱인토스 게임의 성능 병목점을 정확히 진단하고 최적화하세요. --- --- url: 'https://developers-apps-in-toss.toss.im/unity/debug/debug-exception.md' --- # Unity WebGL 디버깅 및 예외 처리 가이드 Unity WebGL에서의 디버깅과 예외 처리는 앱인토스 플랫폼에서 안정적인 게임 서비스를 위해 필수예요.\ 이 가이드는 효과적인 디버깅 방법과 예외 처리 전략을 제공해요. *** ## 디버깅 시스템 ### 1. 통합 로깅 시스템 ```c# using UnityEngine; using System.Collections.Generic; using System.Text; using System; public enum LogLevel { Debug, Info, Warning, Error, Critical } public class DebugLogger : MonoBehaviour { [Header("로깅 설정")] public bool enableConsoleOutput = true; public bool enableFileOutput = false; public bool enableWebOutput = true; public LogLevel minLogLevel = LogLevel.Debug; [Header("웹 출력 설정")] public int maxWebLogs = 100; public bool sendToAppsInToss = true; private static DebugLogger instance; private Queue logBuffer = new Queue(); private StringBuilder logStringBuilder = new StringBuilder(); [System.Serializable] public class LogEntry { public LogLevel level; public string message; public string stackTrace; public DateTime timestamp; public string tag; public LogEntry(LogLevel level, string message, string stackTrace = "", string tag = "") { this.level = level; this.message = message; this.stackTrace = stackTrace; this.timestamp = DateTime.Now; this.tag = tag; } public string ToJson() { return JsonUtility.ToJson(this); } } public static DebugLogger Instance { get { if (instance == null) { GameObject loggerGO = new GameObject("DebugLogger"); instance = loggerGO.AddComponent(); DontDestroyOnLoad(loggerGO); } return instance; } } private void Awake() { if (instance == null) { instance = this; DontDestroyOnLoad(gameObject); InitializeLogger(); } else if (instance != this) { Destroy(gameObject); } } private void InitializeLogger() { // Unity의 기본 로그 핸들러 오버라이드 Application.logMessageReceived += HandleUnityLog; LogInfo("DebugLogger 초기화 완료", "System"); } private void HandleUnityLog(string logString, string stackTrace, LogType type) { LogLevel level = ConvertLogType(type); if (level >= minLogLevel) { LogEntry entry = new LogEntry(level, logString, stackTrace, "Unity"); AddLogEntry(entry); } } private LogLevel ConvertLogType(LogType unityLogType) { switch (unityLogType) { case LogType.Log: return LogLevel.Info; case LogType.Warning: return LogLevel.Warning; case LogType.Error: return LogLevel.Error; case LogType.Exception: return LogLevel.Critical; case LogType.Assert: return LogLevel.Error; default: return LogLevel.Info; } } private void AddLogEntry(LogEntry entry) { logBuffer.Enqueue(entry); // 버퍼 크기 제한 while (logBuffer.Count > maxWebLogs) { logBuffer.Dequeue(); } // 출력 처리 if (enableConsoleOutput) { Debug.Log($"[{entry.level}][{entry.tag}] {entry.message}"); } if (enableWebOutput) { SendLogToWeb(entry); } if (sendToAppsInToss) { SendLogToAppsInToss(entry); } } private void SendLogToWeb(LogEntry entry) { string logData = entry.ToJson(); Application.ExternalCall("ReceiveUnityLog", logData); } private void SendLogToAppsInToss(LogEntry entry) { if (entry.level >= LogLevel.Error) { Application.ExternalCall("SendErrorToAppsInToss", entry.ToJson()); } } // 공개 로깅 메서드들 public static void LogDebug(string message, string tag = "") { Instance.AddLogEntry(new LogEntry(LogLevel.Debug, message, "", tag)); } public static void LogInfo(string message, string tag = "") { Instance.AddLogEntry(new LogEntry(LogLevel.Info, message, "", tag)); } public static void LogWarning(string message, string tag = "") { Instance.AddLogEntry(new LogEntry(LogLevel.Warning, message, "", tag)); } public static void LogError(string message, string tag = "", Exception exception = null) { string stackTrace = exception?.StackTrace ?? Environment.StackTrace; Instance.AddLogEntry(new LogEntry(LogLevel.Error, message, stackTrace, tag)); } public static void LogCritical(string message, string tag = "", Exception exception = null) { string stackTrace = exception?.StackTrace ?? Environment.StackTrace; Instance.AddLogEntry(new LogEntry(LogLevel.Critical, message, stackTrace, tag)); } // 로그 내보내기 public string ExportLogs() { logStringBuilder.Clear(); foreach (LogEntry entry in logBuffer) { logStringBuilder.AppendLine($"[{entry.timestamp:yyyy-MM-dd HH:mm:ss}] [{entry.level}] [{entry.tag}] {entry.message}"); if (!string.IsNullOrEmpty(entry.stackTrace)) { logStringBuilder.AppendLine($"Stack Trace: {entry.stackTrace}"); } logStringBuilder.AppendLine(); } return logStringBuilder.ToString(); } } ``` ### 2. 시각적 디버그 도구 ```c# using UnityEngine; using System.Collections.Generic; public class VisualDebugger : MonoBehaviour { [Header("디스플레이 설정")] public bool showDebugGUI = false; public KeyCode toggleKey = KeyCode.F12; public Color debugColor = Color.green; [Header("성능 모니터링")] public bool showFPS = true; public bool showMemory = true; public bool showDrawCalls = true; private static Dictionary debugVariables = new Dictionary(); private static List debugCommands = new List(); private Vector2 scrollPosition; private string commandInput = ""; public class DebugCommand { public string name; public string description; public System.Action action; public DebugCommand(string name, string description, System.Action action) { this.name = name; this.description = description; this.action = action; } } private void Start() { RegisterDefaultCommands(); } private void Update() { if (Input.GetKeyDown(toggleKey)) { showDebugGUI = !showDebugGUI; } } private void RegisterDefaultCommands() { // 기본 디버그 명령어들 RegisterCommand("fps", "FPS 표시 토글", (args) => { showFPS = !showFPS; DebugLogger.LogInfo($"FPS 표시: {showFPS}", "Debug"); }); RegisterCommand("memory", "메모리 정보 표시", (args) => { long memory = System.GC.GetTotalMemory(false); DebugLogger.LogInfo($"현재 메모리 사용량: {memory / 1024 / 1024} MB", "Debug"); }); RegisterCommand("quality", "품질 설정 변경", (args) => { if (args.Length > 0 && int.TryParse(args[0], out int level)) { QualitySettings.SetQualityLevel(level); DebugLogger.LogInfo($"품질 레벨을 {level}로 변경", "Debug"); } }); RegisterCommand("timescale", "타임 스케일 변경", (args) => { if (args.Length > 0 && float.TryParse(args[0], out float scale)) { Time.timeScale = scale; DebugLogger.LogInfo($"타임 스케일을 {scale}로 변경", "Debug"); } }); } private void OnGUI() { if (!showDebugGUI) return; GUILayout.BeginArea(new Rect(10, 10, Screen.width - 20, Screen.height - 20)); GUILayout.BeginVertical("box"); GUILayout.Label("Unity WebGL 디버그 콘솔", GUI.skin.box); // 성능 정보 if (showFPS || showMemory || showDrawCalls) { GUILayout.BeginHorizontal(); if (showFPS) { float fps = 1.0f / Time.smoothDeltaTime; GUILayout.Label($"FPS: {fps:F1}"); } if (showMemory) { long memory = System.GC.GetTotalMemory(false); GUILayout.Label($"Memory: {memory / 1024 / 1024} MB"); } if (showDrawCalls) { GUILayout.Label($"Quality: {QualitySettings.GetQualityLevel()}"); } GUILayout.EndHorizontal(); } GUILayout.Space(10); // 디버그 변수들 if (debugVariables.Count > 0) { GUILayout.Label("디버그 변수:", GUI.skin.box); scrollPosition = GUILayout.BeginScrollView(scrollPosition, GUILayout.Height(150)); foreach (var kvp in debugVariables) { GUILayout.Label($"{kvp.Key}: {kvp.Value}"); } GUILayout.EndScrollView(); } // 명령어 입력 GUILayout.Label("명령어 입력:"); GUILayout.BeginHorizontal(); commandInput = GUILayout.TextField(commandInput); if (GUILayout.Button("실행") || (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Return)) { ExecuteCommand(commandInput); commandInput = ""; } GUILayout.EndHorizontal(); // 사용 가능한 명령어 목록 GUILayout.Label("사용 가능한 명령어:"); foreach (var command in debugCommands) { GUILayout.Label($"• {command.name}: {command.description}"); } GUILayout.EndVertical(); GUILayout.EndArea(); } // 디버그 변수 등록/업데이트 public static void SetDebugVariable(string name, object value) { debugVariables[name] = value; } public static void RemoveDebugVariable(string name) { debugVariables.Remove(name); } // 디버그 명령어 등록 public static void RegisterCommand(string name, string description, System.Action action) { debugCommands.Add(new DebugCommand(name, description, action)); } private void ExecuteCommand(string input) { if (string.IsNullOrEmpty(input)) return; string[] parts = input.Split(' '); string commandName = parts[0].ToLower(); string[] args = parts.Length > 1 ? new string[parts.Length - 1] : new string[0]; if (args.Length > 0) { System.Array.Copy(parts, 1, args, 0, args.Length); } foreach (var command in debugCommands) { if (command.name.Equals(commandName, System.StringComparison.OrdinalIgnoreCase)) { command.action?.Invoke(args); return; } } DebugLogger.LogWarning($"알 수 없는 명령어: {commandName}", "Debug"); } } ``` *** ## 예외 처리 시스템 ### 1. 중앙 집중식 예외 처리 ```c# using UnityEngine; using System; using System.Collections.Generic; public class ExceptionHandler : MonoBehaviour { [Header("예외 처리 설정")] public bool enableGlobalHandling = true; public bool enableRecoveryAttempts = true; public int maxRecoveryAttempts = 3; private static ExceptionHandler instance; private Dictionary> recoveryStrategies = new Dictionary>(); private Dictionary exceptionCounts = new Dictionary(); public static ExceptionHandler Instance { get { if (instance == null) { GameObject handlerGO = new GameObject("ExceptionHandler"); instance = handlerGO.AddComponent(); DontDestroyOnLoad(handlerGO); } return instance; } } private void Awake() { if (instance == null) { instance = this; DontDestroyOnLoad(gameObject); SetupExceptionHandling(); } else if (instance != this) { Destroy(gameObject); } } private void SetupExceptionHandling() { if (enableGlobalHandling) { Application.logMessageReceived += HandleLogMessage; } RegisterRecoveryStrategies(); } private void RegisterRecoveryStrategies() { // OutOfMemoryException 복구 전략 recoveryStrategies[typeof(OutOfMemoryException)] = RecoverFromOutOfMemory; // NullReferenceException 복구 전략 recoveryStrategies[typeof(NullReferenceException)] = RecoverFromNullReference; // ArgumentException 복구 전략 recoveryStrategies[typeof(ArgumentException)] = RecoverFromInvalidArgument; } private void HandleLogMessage(string logString, string stackTrace, LogType type) { if (type == LogType.Exception) { HandleException(logString, stackTrace); } } public bool HandleException(Exception exception) { return HandleException(exception.Message, exception.StackTrace, exception.GetType()); } public bool HandleException(string message, string stackTrace, Type exceptionType = null) { // 예외 카운트 증가 if (exceptionType != null) { exceptionCounts[exceptionType] = exceptionCounts.GetValueOrDefault(exceptionType, 0) + 1; } // 로깅 DebugLogger.LogError($"예외 발생: {message}", "Exception"); DebugLogger.LogError($"스택 트레이스: {stackTrace}", "Exception"); // 복구 시도 bool recovered = false; if (enableRecoveryAttempts && exceptionType != null) { recovered = AttemptRecovery(exceptionType); } // AppsInToss에 리포트 ReportExceptionToAppsInToss(message, stackTrace, exceptionType, recovered); return recovered; } private bool AttemptRecovery(Type exceptionType) { if (recoveryStrategies.TryGetValue(exceptionType, out Func strategy)) { try { return strategy(null); // 간소화된 호출 } catch (Exception recoveryException) { DebugLogger.LogError($"복구 시도 중 예외 발생: {recoveryException.Message}", "Exception"); return false; } } return false; } private bool RecoverFromOutOfMemory(Exception exception) { DebugLogger.LogInfo("메모리 부족 예외 복구 시도", "Recovery"); // 리소스 해제 Resources.UnloadUnusedAssets(); System.GC.Collect(); System.GC.WaitForPendingFinalizers(); System.GC.Collect(); // 품질 설정 낮추기 QualitySettings.SetQualityLevel(0); QualitySettings.masterTextureLimit = 2; DebugLogger.LogInfo("메모리 부족 예외 복구 완료", "Recovery"); return true; } private bool RecoverFromNullReference(Exception exception) { DebugLogger.LogInfo("NullReference 예외 복구 시도", "Recovery"); // 필수 컴포넌트들 재초기화 시도 try { // 게임 매니저 재초기화 var gameManager = FindObjectOfType(); if (gameManager != null) { gameManager.SendMessage("Reinitialize", SendMessageOptions.DontRequireReceiver); } return true; } catch { return false; } } private bool RecoverFromInvalidArgument(Exception exception) { DebugLogger.LogInfo("잘못된 인수 예외 복구 시도", "Recovery"); // 기본값으로 재설정 // 구체적인 복구 로직은 게임에 따라 다름 return true; } private void ReportExceptionToAppsInToss(string message, string stackTrace, Type exceptionType, bool recovered) { string exceptionData = JsonUtility.ToJson(new { message = message, stackTrace = stackTrace, exceptionType = exceptionType?.Name ?? "Unknown", recovered = recovered, timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), exceptionCount = exceptionType != null ? exceptionCounts.GetValueOrDefault(exceptionType, 0) : 0 }); Application.ExternalCall("SendExceptionToAppsInToss", exceptionData); } // 예외 통계 public void GetExceptionStatistics() { DebugLogger.LogInfo("=== 예외 통계 ===", "Statistics"); foreach (var kvp in exceptionCounts) { DebugLogger.LogInfo($"{kvp.Key.Name}: {kvp.Value}회", "Statistics"); } } } ``` ### 2. 안전한 코루틴 래퍼 ```c# using UnityEngine; using System.Collections; using System; public static class SafeCoroutines { public static Coroutine StartSafe(MonoBehaviour behaviour, IEnumerator routine, string routineName = "") { return behaviour.StartCoroutine(SafeWrapper(routine, routineName)); } private static IEnumerator SafeWrapper(IEnumerator routine, string routineName) { bool hasError = false; while (true) { object current = null; bool moveNext = false; try { moveNext = routine.MoveNext(); current = routine.Current; } catch (Exception e) { hasError = true; string name = string.IsNullOrEmpty(routineName) ? "Unknown" : routineName; DebugLogger.LogError($"코루틴 '{name}'에서 예외 발생: {e.Message}", "Coroutine", e); // 예외 처리 ExceptionHandler.Instance.HandleException(e); // 코루틴 종료 yield break; } if (!moveNext) break; yield return current; } if (!hasError) { string name = string.IsNullOrEmpty(routineName) ? "Unknown" : routineName; DebugLogger.LogDebug($"코루틴 '{name}' 정상 완료", "Coroutine"); } } } // 사용 예제 public class SafeCoroutineExample : MonoBehaviour { private void Start() { SafeCoroutines.StartSafe(this, RiskyOperation(), "RiskyOperation"); } private IEnumerator RiskyOperation() { for (int i = 0; i < 10; i++) { // 위험할 수 있는 작업 yield return new WaitForSeconds(1f); if (i == 5) { // 의도적으로 예외 발생 시켜보기 throw new InvalidOperationException("테스트 예외"); } } } } ``` *** ## 성능 이슈 진단 ### 1. 자동 성능 진단 도구 ```c# using UnityEngine; using System.Collections.Generic; using System.Linq; public class PerformanceDiagnostics : MonoBehaviour { [Header("진단 설정")] public bool enableAutoDiagnosis = true; public float diagnosisInterval = 5f; public float fpsThreshold = 30f; public float memoryThreshold = 100f; // MB private List diagnosticHistory = new List(); [System.Serializable] public class DiagnosticResult { public float timestamp; public float fps; public float memoryUsage; public int activeObjects; public int activeRenderers; public string[] issues; public string[] recommendations; } private void Start() { if (enableAutoDiagnosis) { InvokeRepeating(nameof(RunDiagnosis), diagnosisInterval, diagnosisInterval); } } private void RunDiagnosis() { DiagnosticResult result = new DiagnosticResult { timestamp = Time.time, fps = 1.0f / Time.smoothDeltaTime, memoryUsage = System.GC.GetTotalMemory(false) / 1024f / 1024f, activeObjects = FindObjectsOfType().Length, activeRenderers = FindObjectsOfType().Where(r => r.enabled).Count() }; List issues = new List(); List recommendations = new List(); // FPS 문제 진단 if (result.fps < fpsThreshold) { issues.Add($"낮은 FPS: {result.fps:F1}"); // 원인 분석 if (result.activeRenderers > 100) { recommendations.Add("렌더러 수 감소 (현재: " + result.activeRenderers + ")"); } if (QualitySettings.GetQualityLevel() > 2) { recommendations.Add("품질 설정 낮추기"); } } // 메모리 문제 진단 if (result.memoryUsage > memoryThreshold) { issues.Add($"높은 메모리 사용량: {result.memoryUsage:F1}MB"); recommendations.Add("Resources.UnloadUnusedAssets() 호출"); recommendations.Add("GC.Collect() 실행"); } // 오브젝트 수 진단 if (result.activeObjects > 1000) { issues.Add($"과도한 활성 오브젝트: {result.activeObjects}"); recommendations.Add("오브젝트 풀링 사용"); recommendations.Add("비활성화 가능한 오브젝트 정리"); } result.issues = issues.ToArray(); result.recommendations = recommendations.ToArray(); diagnosticHistory.Add(result); // 히스토리 크기 제한 if (diagnosticHistory.Count > 20) { diagnosticHistory.RemoveAt(0); } // 문제가 있는 경우 로그 if (issues.Count > 0) { DebugLogger.LogWarning($"성능 문제 감지: {string.Join(", ", issues)}", "Performance"); DebugLogger.LogInfo($"권장사항: {string.Join(", ", recommendations)}", "Performance"); } // AppsInToss에 진단 결과 전송 SendDiagnosticResultToAppsInToss(result); } private void SendDiagnosticResultToAppsInToss(DiagnosticResult result) { string diagnosticData = JsonUtility.ToJson(result); Application.ExternalCall("SendDiagnosticDataToAppsInToss", diagnosticData); } public DiagnosticResult GetLatestDiagnostic() { return diagnosticHistory.LastOrDefault(); } public DiagnosticResult[] GetDiagnosticHistory() { return diagnosticHistory.ToArray(); } } ``` *** ## 문제 해결 가이드 ### 일반적인 WebGL 문제들 1. 메모리 관련 문제 * Out of Memory 에러 * 가비지 컬렉션으로 인한 끊김 * 메모리 누수 2. 성능 문제 * 낮은 프레임률 * 긴 로딩 시간 * 버벅거림 3. 호환성 문제 * 브라우저별 차이 * 모바일 기기 대응 * 오래된 디바이스 지원 ### 해결 전략 ```c# public class TroubleshootingHelper : MonoBehaviour { public static void DiagnoseCommonIssues() { DebugLogger.LogInfo("=== 일반적인 문제 진단 ===", "Troubleshooting"); // 1. 메모리 체크 long memory = System.GC.GetTotalMemory(false); if (memory > 100 * 1024 * 1024) // 100MB { DebugLogger.LogWarning("높은 메모리 사용량 감지", "Troubleshooting"); DebugLogger.LogInfo("권장사항: Resources.UnloadUnusedAssets() 호출", "Troubleshooting"); } // 2. 품질 설정 체크 int qualityLevel = QualitySettings.GetQualityLevel(); if (qualityLevel > 2) { DebugLogger.LogInfo($"현재 품질 레벨: {qualityLevel} (높음)", "Troubleshooting"); DebugLogger.LogInfo("권장사항: 모바일 환경에서는 품질 낮추기", "Troubleshooting"); } // 3. 렌더러 수 체크 int rendererCount = FindObjectsOfType().Length; if (rendererCount > 100) { DebugLogger.LogWarning($"많은 렌더러 수: {rendererCount}", "Troubleshooting"); DebugLogger.LogInfo("권장사항: 배칭 최적화 또는 LOD 사용", "Troubleshooting"); } // 4. 텍스처 설정 체크 Texture2D[] textures = Resources.FindObjectsOfTypeAll(); int uncompressedTextures = textures.Count(t => t.format == TextureFormat.RGBA32); if (uncompressedTextures > 10) { DebugLogger.LogWarning($"압축되지 않은 텍스처: {uncompressedTextures}개", "Troubleshooting"); DebugLogger.LogInfo("권장사항: 텍스처 압축 적용", "Troubleshooting"); } } } ``` *** ## 베스트 프랙티스 1. **프로액티브 로깅** - 문제가 발생하기 전에 충분한 로그 수집 2. **예외 복구 전략** - 예외 발생 시 게임이 계속 실행될 수 있도록 복구 로직 구현 3. **성능 모니터링** - 지속적인 성능 모니터링으로 문제 조기 발견 4. **AppsInToss 연동** - 플랫폼 기능을 활용한 효과적인 디버깅 5. **사용자 피드백** - 사용자가 겪는 문제를 쉽게 리포트할 수 있는 시스템 구축 이 가이드를 통해 Unity WebGL 게임의 안정성과 디버깅 효율성을 크게 향상시킬 수 있어요. --- --- url: >- https://developers-apps-in-toss.toss.im/unity/optimization/runtime/performance.md --- # Unity WebGL 런타임 성능 최적화 가이드 Unity WebGL 환경에서 런타임 성능을 최적화하는 건, 앱인토스 미니앱에서 부드러운 게임 경험을 만드는 핵심이에요.\ 이 가이드에서는 프레임률 향상, CPU 사용량 감소, GPU 최적화 방법을 함께 다뤄요. *** ## CPU 최적화 ### 1. 프레임률 관리 시스템 ```c# using UnityEngine; using System.Collections; public class PerformanceManager : MonoBehaviour { [Header("성능 설정")] public int targetFrameRate = 60; public bool adaptiveFrameRate = true; public float performanceCheckInterval = 2f; [Header("성능 임계값")] public float lowPerformanceThreshold = 40f; public float highPerformanceThreshold = 55f; private float averageFrameRate; private int qualityLevel; private bool isLowPerformanceMode = false; private void Start() { qualityLevel = QualitySettings.GetQualityLevel(); Application.targetFrameRate = targetFrameRate; if (adaptiveFrameRate) { StartCoroutine(AdaptivePerformanceRoutine()); } } private IEnumerator AdaptivePerformanceRoutine() { while (true) { yield return new WaitForSeconds(performanceCheckInterval); averageFrameRate = 1.0f / Time.smoothDeltaTime; AdjustPerformanceSettings(); // AppsInToss 플랫폼에 성능 정보 전송 SendPerformanceDataToAppsInToss(); } } private void AdjustPerformanceSettings() { if (averageFrameRate < lowPerformanceThreshold && !isLowPerformanceMode) { EnableLowPerformanceMode(); } else if (averageFrameRate > highPerformanceThreshold && isLowPerformanceMode) { DisableLowPerformanceMode(); } } private void EnableLowPerformanceMode() { isLowPerformanceMode = true; // 품질 설정 낮추기 QualitySettings.SetQualityLevel(Mathf.Max(0, qualityLevel - 1)); // 그림자 비활성화 QualitySettings.shadows = ShadowQuality.Disable; // 파티클 수 감소 QualitySettings.particleRaycastBudget = 16; // 물리 업데이트 빈도 감소 Time.fixedDeltaTime = 1f / 30f; Debug.Log("저성능 모드 활성화됨"); } private void DisableLowPerformanceMode() { isLowPerformanceMode = false; // 원래 품질 설정 복원 QualitySettings.SetQualityLevel(qualityLevel); QualitySettings.shadows = ShadowQuality.All; QualitySettings.particleRaycastBudget = 256; Time.fixedDeltaTime = 1f / 50f; Debug.Log("일반 성능 모드 복원됨"); } private void SendPerformanceDataToAppsInToss() { string performanceData = JsonUtility.ToJson(new { frameRate = averageFrameRate, isLowPerformanceMode = isLowPerformanceMode, qualityLevel = QualitySettings.GetQualityLevel() }); Application.ExternalCall("SendPerformanceDataToAppsInToss", performanceData); } } ``` ### 2. 효율적인 업데이트 관리 ```c# using UnityEngine; using System.Collections.Generic; public class UpdateManager : MonoBehaviour { private static UpdateManager instance; public static UpdateManager Instance { get { if (instance == null) { instance = FindObjectOfType(); if (instance == null) { GameObject go = new GameObject("UpdateManager"); instance = go.AddComponent(); DontDestroyOnLoad(go); } } return instance; } } private List updatables = new List(); private List fixedUpdatables = new List(); private List lateUpdatables = new List(); // 성능 최적화를 위한 업데이트 빈도 제어 private int frameCount = 0; private void Update() { frameCount++; // 매 프레임 업데이트 for (int i = updatables.Count - 1; i >= 0; i--) { if (updatables[i] != null) updatables[i].OnUpdate(); } // 2프레임마다 업데이트 (30Hz) if (frameCount % 2 == 0) { for (int i = updatables.Count - 1; i >= 0; i--) { if (updatables[i] is ISlowUpdatable slowUpdatable) slowUpdatable.OnSlowUpdate(); } } } private void FixedUpdate() { for (int i = fixedUpdatables.Count - 1; i >= 0; i--) { if (fixedUpdatables[i] != null) fixedUpdatables[i].OnFixedUpdate(); } } private void LateUpdate() { for (int i = lateUpdatables.Count - 1; i >= 0; i--) { if (lateUpdatables[i] != null) lateUpdatables[i].OnLateUpdate(); } } public void RegisterUpdatable(IUpdatable updatable) { if (!updatables.Contains(updatable)) updatables.Add(updatable); } public void UnregisterUpdatable(IUpdatable updatable) { updatables.Remove(updatable); } } // 인터페이스들 public interface IUpdatable { void OnUpdate(); } public interface ISlowUpdatable { void OnSlowUpdate(); } public interface IFixedUpdatable { void OnFixedUpdate(); } public interface ILateUpdatable { void OnLateUpdate(); } // 사용 예제 public class OptimizedBehavior : MonoBehaviour, IUpdatable, ISlowUpdatable { private Vector3 targetPosition; private bool needsPositionUpdate = true; private void OnEnable() { UpdateManager.Instance.RegisterUpdatable(this); } private void OnDisable() { UpdateManager.Instance.UnregisterUpdatable(this); } public void OnUpdate() { // 매 프레임 필요한 업데이트 if (needsPositionUpdate) { transform.position = Vector3.Lerp(transform.position, targetPosition, Time.deltaTime * 5f); if (Vector3.Distance(transform.position, targetPosition) < 0.01f) { needsPositionUpdate = false; } } } public void OnSlowUpdate() { // 2프레임마다 수행되는 업데이트 (AI, 경로 찾기 등) UpdateAI(); } private void UpdateAI() { // 비용이 큰 AI 로직 } } ``` *** ## GPU 최적화 ### 1. 배칭 최적화 ```c# using UnityEngine; using System.Collections.Generic; public class BatchingOptimizer : MonoBehaviour { [Header("배칭 설정")] public bool enableStaticBatching = true; public bool enableDynamicBatching = true; public bool enableInstancing = true; [Header("인스턴싱")] public GameObject instancedPrefab; public Material instancedMaterial; public int maxInstances = 1000; private Matrix4x4[] instanceMatrices; private MaterialPropertyBlock propertyBlock; private Mesh instanceMesh; private List instancePositions = new List(); private void Start() { SetupBatching(); SetupInstancing(); } private void SetupBatching() { // 정적 배칭 활성화 if (enableStaticBatching) { GameObject[] staticObjects = GameObject.FindGameObjectsWithTag("StaticBatchable"); StaticBatchingUtility.Combine(staticObjects, this.gameObject); } } private void SetupInstancing() { if (!enableInstancing || instancedPrefab == null) return; instanceMatrices = new Matrix4x4[maxInstances]; propertyBlock = new MaterialPropertyBlock(); MeshRenderer renderer = instancedPrefab.GetComponent(); if (renderer != null) { instanceMesh = instancedPrefab.GetComponent().sharedMesh; } } private void Update() { if (enableInstancing && instanceMesh != null) { RenderInstances(); } } private void RenderInstances() { int instanceCount = Mathf.Min(instancePositions.Count, maxInstances); for (int i = 0; i < instanceCount; i++) { instanceMatrices[i] = Matrix4x4.TRS(instancePositions[i], Quaternion.identity, Vector3.one); } Graphics.DrawMeshInstanced( instanceMesh, 0, instancedMaterial, instanceMatrices, instanceCount, propertyBlock ); } public void AddInstance(Vector3 position) { if (instancePositions.Count < maxInstances) { instancePositions.Add(position); } } public void RemoveInstance(Vector3 position) { instancePositions.Remove(position); } public void ClearInstances() { instancePositions.Clear(); } } ``` ### 2. 텍스처 아틀라스 관리 ```c# using UnityEngine; using System.Collections.Generic; [CreateAssetMenu(fileName = "TextureAtlasConfig", menuName = "Performance/Texture Atlas Config")] public class TextureAtlasConfig : ScriptableObject { [System.Serializable] public class AtlasEntry { public string name; public Texture2D texture; public Rect uvRect; } [Header("아틀라스 설정")] public Texture2D atlasTexture; public List entries = new List(); public int atlasSize = 2048; private Dictionary uvLookup; public void Initialize() { uvLookup = new Dictionary(); foreach (var entry in entries) { uvLookup[entry.name] = entry.uvRect; } } public Rect GetUVRect(string textureName) { if (uvLookup == null) Initialize(); return uvLookup.TryGetValue(textureName, out Rect rect) ? rect : new Rect(0, 0, 1, 1); } } public class TextureAtlasManager : MonoBehaviour { [Header("아틀라스 설정")] public TextureAtlasConfig atlasConfig; public Material atlasMaterial; private void Start() { if (atlasConfig != null) { atlasConfig.Initialize(); ApplyAtlasToMaterials(); } } private void ApplyAtlasToMaterials() { // 씬의 모든 렌더러에 아틀라스 적용 MeshRenderer[] renderers = FindObjectsOfType(); foreach (var renderer in renderers) { if (renderer.material.name.Contains("Atlasable")) { renderer.material = atlasMaterial; // UV 좌표 조정 string textureName = renderer.name; // 또는 다른 식별 방법 Rect uvRect = atlasConfig.GetUVRect(textureName); renderer.material.SetVector("_MainTex_ST", new Vector4(uvRect.width, uvRect.height, uvRect.x, uvRect.y)); } } } } ``` *** ## AppsInToss 플랫폼 연동 ### 1. 성능 모니터링 시스템 ```tsx interface PerformanceMetrics { frameRate: number; drawCalls: number; triangles: number; vertices: number; memoryUsage: number; loadTime: number; } class PerformanceMonitor { private static instance: PerformanceMonitor; private metrics: PerformanceMetrics = { frameRate: 0, drawCalls: 0, triangles: 0, vertices: 0, memoryUsage: 0, loadTime: 0 }; private frameCount = 0; private lastTime = 0; public static getInstance(): PerformanceMonitor { if (!PerformanceMonitor.instance) { PerformanceMonitor.instance = new PerformanceMonitor(); } return PerformanceMonitor.instance; } public startMonitoring(): void { this.lastTime = performance.now(); this.monitoringLoop(); } private monitoringLoop(): void { const currentTime = performance.now(); this.frameCount++; // FPS 계산 (1초마다) if (currentTime - this.lastTime >= 1000) { this.metrics.frameRate = this.frameCount; this.frameCount = 0; this.lastTime = currentTime; this.collectMetrics(); } requestAnimationFrame(() => this.monitoringLoop()); } private collectMetrics(): void { // Unity에서 성능 메트릭 수집 const unityInstance = (window as any).unityInstance; if (unityInstance) { // Unity에서 렌더링 통계 요청 unityInstance.SendMessage('PerformanceManager', 'CollectRenderingStats', ''); } // 메모리 사용량 수집 const memInfo = (performance as any).memory; if (memInfo) { this.metrics.memoryUsage = memInfo.usedJSHeapSize; } } public updateMetrics(data: Partial): void { Object.assign(this.metrics, data); } public getMetrics(): PerformanceMetrics { return { ...this.metrics }; } // 성능 경고 시스템 public checkPerformanceWarnings(): void { const warnings = []; if (this.metrics.frameRate < 30) { warnings.push('낮은 프레임률 감지'); } if (this.metrics.drawCalls > 200) { warnings.push('과도한 드로우 콜'); } if (this.metrics.memoryUsage > 100 * 1024 * 1024) { // 100MB warnings.push('높은 메모리 사용량'); } } } // Unity에서 호출할 함수들 (window as any).UpdatePerformanceMetrics = (data: string) => { const metrics = JSON.parse(data); PerformanceMonitor.getInstance().updateMetrics(metrics); }; (window as any).OnUnityLoaded = () => { PerformanceMonitor.getInstance().startMonitoring(); }; ``` ### 2. 동적 품질 조정 ```c# using UnityEngine; using System.Runtime.InteropServices; public class DynamicQualityManager : MonoBehaviour { [Header("품질 설정")] public QualityProfile[] qualityProfiles; [System.Serializable] public class QualityProfile { public string name; public int textureQuality; public ShadowQuality shadowQuality; public int particleBudget; public float renderScale; public bool enablePostProcessing; } [DllImport("__Internal")] private static extern void SendQualityChangeToAppsInToss(string qualityData); private int currentQualityIndex = 1; // 보통 품질로 시작 private float lastFrameTime; private int lowFrameCount = 0; private void Start() { if (qualityProfiles.Length > 0) { ApplyQualityProfile(currentQualityIndex); } } private void Update() { MonitorPerformance(); } private void MonitorPerformance() { float currentFrameTime = Time.unscaledDeltaTime; // 프레임 시간이 33ms(30fps) 이상인 경우 if (currentFrameTime > 1f / 30f) { lowFrameCount++; } else { lowFrameCount = Mathf.Max(0, lowFrameCount - 1); } // 연속으로 저성능이 감지되면 품질 낮추기 if (lowFrameCount > 10 && currentQualityIndex > 0) { ChangeQuality(currentQualityIndex - 1); lowFrameCount = 0; } // 성능이 개선되면 품질 올리기 else if (lowFrameCount == 0 && currentFrameTime < 1f / 50f && currentQualityIndex < qualityProfiles.Length - 1) { ChangeQuality(currentQualityIndex + 1); } lastFrameTime = currentFrameTime; } public void ChangeQuality(int qualityIndex) { if (qualityIndex < 0 || qualityIndex >= qualityProfiles.Length) return; currentQualityIndex = qualityIndex; ApplyQualityProfile(qualityIndex); // AppsInToss 플랫폼에 품질 변경 알림 NotifyQualityChange(); } private void ApplyQualityProfile(int index) { QualityProfile profile = qualityProfiles[index]; // 텍스처 품질 QualitySettings.masterTextureLimit = profile.textureQuality; // 그림자 품질 QualitySettings.shadows = profile.shadowQuality; // 파티클 예산 QualitySettings.particleRaycastBudget = profile.particleBudget; // 렌더 스케일 (가능한 경우) if (profile.renderScale != 1f) { Camera.main.pixelRect = new Rect(0, 0, Screen.width * profile.renderScale, Screen.height * profile.renderScale); } // 포스트 프로세싱 var postProcessVolume = FindObjectOfType(); if (postProcessVolume != null) { postProcessVolume.enabled = profile.enablePostProcessing; } Debug.Log($"품질 프로필 적용됨: {profile.name}"); } private void NotifyQualityChange() { QualityProfile currentProfile = qualityProfiles[currentQualityIndex]; string qualityData = JsonUtility.ToJson(new { qualityLevel = currentQualityIndex, qualityName = currentProfile.name, timestamp = Time.time }); SendQualityChangeToAppsInToss(qualityData); } // 외부에서 품질 강제 변경 (AppsInToss에서 호출) public void ForceQualityChange(string qualityData) { var data = JsonUtility.FromJson(qualityData); ChangeQuality(data.qualityLevel); } [System.Serializable] private class QualityChangeData { public int qualityLevel; } } ``` *** ## 렌더링 최적화 ### 1. LOD (Level of Detail) 시스템 ```c# using UnityEngine; public class DynamicLODManager : MonoBehaviour { [Header("LOD 설정")] public float[] lodDistances = { 50f, 100f, 200f }; public float cullDistance = 300f; public Transform playerTransform; [Header("성능 기반 LOD")] public bool enablePerformanceLOD = true; public float performanceThreshold = 45f; // FPS private LODGroup[] allLODGroups; private Camera mainCamera; private float performanceLODBias = 1f; private void Start() { mainCamera = Camera.main; allLODGroups = FindObjectsOfType(); if (enablePerformanceLOD) { InvokeRepeating(nameof(UpdatePerformanceLOD), 1f, 2f); } } private void Update() { UpdateLODs(); } private void UpdateLODs() { if (playerTransform == null) return; foreach (LODGroup lodGroup in allLODGroups) { if (lodGroup == null) continue; float distance = Vector3.Distance(playerTransform.position, lodGroup.transform.position); // 컬링 거리 확인 if (distance > cullDistance) { lodGroup.gameObject.SetActive(false); continue; } else if (!lodGroup.gameObject.activeInHierarchy) { lodGroup.gameObject.SetActive(true); } // 성능 기반 LOD 바이어스 적용 float adjustedDistance = distance * performanceLODBias; // LOD 레벨 결정 int lodLevel = GetLODLevel(adjustedDistance); lodGroup.ForceLOD(lodLevel); } } private int GetLODLevel(float distance) { for (int i = 0; i < lodDistances.Length; i++) { if (distance < lodDistances[i]) return i; } return lodDistances.Length; // 가장 낮은 품질 또는 컬링 } private void UpdatePerformanceLOD() { float currentFPS = 1.0f / Time.smoothDeltaTime; if (currentFPS < performanceThreshold) { // 성능이 낮으면 LOD 바이어스를 줄여서 더 낮은 품질 사용 performanceLODBias = Mathf.Max(0.5f, performanceLODBias - 0.1f); } else if (currentFPS > performanceThreshold + 10f) { // 성능이 좋으면 LOD 바이어스를 높여서 더 높은 품질 사용 performanceLODBias = Mathf.Min(1f, performanceLODBias + 0.1f); } QualitySettings.lodBias = performanceLODBias; } } ``` ### 2. 오클루전 컬링 ```c# using UnityEngine; using System.Collections.Generic; public class CustomOcclusionCulling : MonoBehaviour { [Header("오클루전 설정")] public LayerMask occluderLayers = -1; public float raycastDistance = 100f; public int raysPerObject = 9; // 3x3 그리드 [Header("성능 설정")] public int objectsPerFrame = 10; public float updateInterval = 0.1f; private List allRenderers = new List(); private Queue renderersToCheck = new Queue(); private Camera mainCamera; private void Start() { mainCamera = Camera.main; CollectRenderers(); InvokeRepeating(nameof(ProcessOcclusion), 0f, updateInterval); } private void CollectRenderers() { Renderer[] renderers = FindObjectsOfType(); foreach (Renderer renderer in renderers) { // 스킨드 메시나 파티클은 제외 if (renderer is MeshRenderer || renderer is SpriteRenderer) { allRenderers.Add(renderer); renderersToCheck.Enqueue(renderer); } } } private void ProcessOcclusion() { int processedCount = 0; while (renderersToCheck.Count > 0 && processedCount < objectsPerFrame) { Renderer renderer = renderersToCheck.Dequeue(); if (renderer != null && renderer.gameObject.activeInHierarchy) { bool isVisible = CheckVisibility(renderer); renderer.enabled = isVisible; // 다음 프레임에 다시 검사하도록 큐에 추가 renderersToCheck.Enqueue(renderer); } processedCount++; } } private bool CheckVisibility(Renderer renderer) { Bounds bounds = renderer.bounds; Vector3 cameraPosition = mainCamera.transform.position; // 카메라 프러스텀 내부에 있는지 확인 if (!GeometryUtility.TestPlanesAABB(GeometryUtility.CalculateFrustumPlanes(mainCamera), bounds)) { return false; } // 레이캐스트를 통한 오클루전 테스트 Vector3 boundsCenter = bounds.center; Vector3 boundsSize = bounds.size; int visibleRays = 0; // 3x3 그리드로 레이 발사 for (int x = -1; x <= 1; x++) { for (int y = -1; y <= 1; y++) { Vector3 testPoint = boundsCenter + new Vector3( x * boundsSize.x * 0.25f, y * boundsSize.y * 0.25f, 0 ); Vector3 direction = (testPoint - cameraPosition).normalized; float distance = Vector3.Distance(cameraPosition, testPoint); if (!Physics.Raycast(cameraPosition, direction, distance, occluderLayers)) { visibleRays++; if (visibleRays > raysPerObject / 3) // 1/3 이상 보이면 렌더링 { return true; } } } } return visibleRays > 0; } } ``` *** ## 물리 최적화 ### 1. 물리 업데이트 관리 ```c# using UnityEngine; using System.Collections.Generic; public class PhysicsOptimizer : MonoBehaviour { [Header("물리 설정")] public float physicsTimeStep = 0.02f; // 50Hz public int maxSubSteps = 8; public float sleepThreshold = 0.005f; [Header("성능 기반 조정")] public bool enableAdaptivePhysics = true; public float targetFrameRate = 60f; private List managedRigidbodies = new List(); private float originalFixedDeltaTime; private void Start() { originalFixedDeltaTime = Time.fixedDeltaTime; Time.fixedDeltaTime = physicsTimeStep; Physics.sleepThreshold = sleepThreshold; CollectRigidbodies(); if (enableAdaptivePhysics) { InvokeRepeating(nameof(AdaptPhysicsSettings), 1f, 2f); } } private void CollectRigidbodies() { Rigidbody[] rigidbodies = FindObjectsOfType(); foreach (Rigidbody rb in rigidbodies) { managedRigidbodies.Add(rb); OptimizeRigidbody(rb); } } private void OptimizeRigidbody(Rigidbody rb) { // 잠자기 설정 최적화 rb.sleepThreshold = sleepThreshold; // 불필요한 물리 연산 비활성화 if (rb.GetComponent() == null) { rb.detectCollisions = false; } // 정적 오브젝트는 키네마틱으로 설정 if (rb.GetComponent() != null && rb.GetComponent().isTrigger) { rb.isKinematic = true; } } private void AdaptPhysicsSettings() { float currentFrameRate = 1.0f / Time.smoothDeltaTime; if (currentFrameRate < targetFrameRate - 10f) { // 성능이 낮으면 물리 업데이트 빈도 감소 Time.fixedDeltaTime = Mathf.Min(0.033f, Time.fixedDeltaTime + 0.002f); // 일부 리지드바디를 잠자기 상태로 전환 SleepDistantRigidbodies(); } else if (currentFrameRate > targetFrameRate + 5f) { // 성능이 좋으면 물리 업데이트 빈도 증가 Time.fixedDeltaTime = Mathf.Max(physicsTimeStep, Time.fixedDeltaTime - 0.002f); } } private void SleepDistantRigidbodies() { Vector3 playerPosition = Camera.main.transform.position; float maxActiveDistance = 50f; foreach (Rigidbody rb in managedRigidbodies) { if (rb == null) continue; float distance = Vector3.Distance(rb.transform.position, playerPosition); if (distance > maxActiveDistance && !rb.IsSleeping()) { rb.Sleep(); } else if (distance <= maxActiveDistance && rb.IsSleeping()) { rb.WakeUp(); } } } } ``` *** ## 프로파일링 및 디버깅 ### 1. 성능 분석 도구 ```c# using UnityEngine; using System.Text; using System.Collections.Generic; public class PerformanceProfiler : MonoBehaviour { [Header("프로파일링 설정")] public bool enableProfiling = true; public KeyCode toggleKey = KeyCode.F1; public float updateInterval = 1f; private Dictionary performanceData = new Dictionary(); private StringBuilder displayText = new StringBuilder(); private bool showGUI = false; private void Update() { if (Input.GetKeyDown(toggleKey)) { showGUI = !showGUI; } if (enableProfiling) { CollectPerformanceData(); } } private void CollectPerformanceData() { // FPS performanceData["FPS"] = 1.0f / Time.smoothDeltaTime; // 메모리 사용량 performanceData["Memory (MB)"] = System.GC.GetTotalMemory(false) / 1024f / 1024f; // 렌더링 통계 performanceData["SetPass Calls"] = UnityEngine.Rendering.FrameDebugger.enabled ? UnityEngine.Rendering.FrameDebugger.GetFrameEventCount() : 0; // 활성 오브젝트 수 performanceData["Active GameObjects"] = FindObjectsOfType().Length; // 활성 렌더러 수 performanceData["Active Renderers"] = FindObjectsOfType().Length; } private void OnGUI() { if (!showGUI) return; displayText.Clear(); displayText.AppendLine("=== 성능 분석 ==="); foreach (var kvp in performanceData) { displayText.AppendFormat("{0}: {1:F2}\n", kvp.Key, kvp.Value); } GUI.Box(new Rect(10, 10, 300, 200), displayText.ToString()); } public void LogPerformanceData() { StringBuilder log = new StringBuilder(); log.AppendLine("Performance Report:"); foreach (var kvp in performanceData) { log.AppendFormat("{0}: {1:F2}\n", kvp.Key, kvp.Value); } Debug.Log(log.ToString()); // AppsInToss 플랫폼에 전송 Application.ExternalCall("SendPerformanceReport", log.ToString()); } } ``` *** ## 베스트 프랙티스 * 적응형 성능 관리 - 실시간으로 성능을 모니터링하고 품질을 조정 * 효율적인 업데이트 - Update 함수 대신 이벤트 기반 시스템 사용 * 배칭 최적화 - 드로우 콜 수 최소화 * 메모리 관리 - 불필요한 할당과 가비지 생성 방지 * AppsInToss 플랫폼 활용 - 네이티브 성능 모니터링 기능 활용 --- --- url: 'https://developers-apps-in-toss.toss.im/unity/optimization/runtime/memory.md' --- # Unity WebGL 메모리 최적화 가이드 Unity WebGL에서 메모리 최적화는 성능과 안정성을 지키는 핵심이에요.\ 앱인토스 미니앱에서는 제한된 메모리 안에서 게임을 효율적으로 실행하는 게 특히 중요해요. *** ## 메모리 관리 기본 원칙 ### 1. 힙 메모리 관리 Unity WebGL은 고정 크기 힙을 사용합니다. ```c# using UnityEngine; using System.Collections; public class MemoryManager : MonoBehaviour { [Header("메모리 모니터링")] public bool enableMemoryMonitoring = true; private long lastGCMemory = 0; private void Start() { if (enableMemoryMonitoring) { StartCoroutine(MonitorMemory()); } } private IEnumerator MonitorMemory() { while (true) { long currentMemory = System.GC.GetTotalMemory(false); if (currentMemory > lastGCMemory * 1.2f) { System.GC.Collect(); Resources.UnloadUnusedAssets(); lastGCMemory = System.GC.GetTotalMemory(true); } yield return new WaitForSeconds(5f); } } public void ForceCleanup() { Resources.UnloadUnusedAssets(); System.GC.Collect(); System.GC.WaitForPendingFinalizers(); System.GC.Collect(); } } ``` ### 2. 텍스처 메모리 최적화 ```c# using UnityEngine; [System.Serializable] public class TextureSettings { [Header("품질 설정")] public int maxTextureSize = 512; public TextureFormat preferredFormat = TextureFormat.ASTC_4x4; public bool generateMipmaps = false; [Header("압축 설정")] public bool useTextureStreaming = true; public int memoryBudget = 64; // MB } public class TextureOptimizer : MonoBehaviour { public TextureSettings settings; private void Start() { OptimizeTextures(); SetupTextureStreaming(); } private void OptimizeTextures() { QualitySettings.masterTextureLimit = 1; // 절반 해상도 QualitySettings.globalTextureMipmapLimit = 1; } private void SetupTextureStreaming() { if (settings.useTextureStreaming) { QualitySettings.streamingMipmapsActive = true; QualitySettings.streamingMipmapsMemoryBudget = settings.memoryBudget; } } } ``` *** ## AppsInToss 플랫폼 통합 ### 1. 메모리 상태 모니터링 ```tsx interface MemoryInfo { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number; } class MemoryMonitor { private static instance: MemoryMonitor; private memoryThreshold: number = 0.8; public static getInstance(): MemoryMonitor { if (!MemoryMonitor.instance) { MemoryMonitor.instance = new MemoryMonitor(); } return MemoryMonitor.instance; } public startMonitoring(): void { setInterval(() => { this.checkMemoryUsage(); }, 3000); } private checkMemoryUsage(): void { const memInfo = this.getMemoryInfo(); const usage = memInfo.usedJSHeapSize / memInfo.jsHeapSizeLimit; if (usage > this.memoryThreshold) { this.requestMemoryCleanup(); } } private getMemoryInfo(): MemoryInfo { const performance = (window as any).performance; return performance.memory || { usedJSHeapSize: 0, totalJSHeapSize: 0, jsHeapSizeLimit: 0 }; } private requestMemoryCleanup(): void { // Unity에 메모리 정리 요청 const unityInstance = (window as any).unityInstance; if (unityInstance) { unityInstance.SendMessage('MemoryManager', 'ForceCleanup', ''); } } } ``` ### 2. Unity와 JavaScript 간 메모리 공유 최적화 ```c# using UnityEngine; using System.Runtime.InteropServices; using System.Text; public class MemoryBridge : MonoBehaviour { [DllImport("__Internal")] private static extern void SendMemoryData(string data); [DllImport("__Internal")] private static extern string GetMemoryInfo(); private StringBuilder stringBuilder = new StringBuilder(1024); public void SendLargeData(byte[] data) { // 큰 데이터를 청크로 분할하여 전송 int chunkSize = 1024; for (int i = 0; i < data.Length; i += chunkSize) { int currentChunkSize = Mathf.Min(chunkSize, data.Length - i); byte[] chunk = new byte[currentChunkSize]; System.Array.Copy(data, i, chunk, 0, currentChunkSize); string base64Chunk = System.Convert.ToBase64String(chunk); SendMemoryData($"{{\"chunk\": \"{base64Chunk}\", \"index\": {i / chunkSize}}}"); } } public void OptimizeStringOperations(string[] strings) { stringBuilder.Clear(); foreach (string str in strings) { stringBuilder.Append(str); stringBuilder.Append(","); } if (stringBuilder.Length > 0) { stringBuilder.Length--; // 마지막 콤마 제거 } SendMemoryData(stringBuilder.ToString()); } } ``` *** ## 오브젝트 풀링 ### 1. 범용 오브젝트 풀 ```c# using UnityEngine; using System.Collections.Generic; public class ObjectPool : MonoBehaviour where T : MonoBehaviour { [Header("풀 설정")] public T prefab; public int initialSize = 10; public int maxSize = 50; public bool allowGrowth = true; private Queue pool = new Queue(); private HashSet activeObjects = new HashSet(); private void Start() { InitializePool(); } private void InitializePool() { for (int i = 0; i < initialSize; i++) { T obj = CreateNewObject(); obj.gameObject.SetActive(false); pool.Enqueue(obj); } } private T CreateNewObject() { T obj = Instantiate(prefab, transform); return obj; } public T GetObject() { T obj; if (pool.Count > 0) { obj = pool.Dequeue(); } else if (allowGrowth && activeObjects.Count < maxSize) { obj = CreateNewObject(); } else { return null; // 풀이 가득 참 } obj.gameObject.SetActive(true); activeObjects.Add(obj); return obj; } public void ReturnObject(T obj) { if (activeObjects.Contains(obj)) { obj.gameObject.SetActive(false); activeObjects.Remove(obj); pool.Enqueue(obj); } } public void ClearPool() { while (pool.Count > 0) { T obj = pool.Dequeue(); if (obj != null) { DestroyImmediate(obj.gameObject); } } foreach (T obj in activeObjects) { if (obj != null) { DestroyImmediate(obj.gameObject); } } activeObjects.Clear(); } } // 사용 예제 public class BulletPoolManager : MonoBehaviour { public ObjectPool bulletPool; public void FireBullet(Vector3 position, Vector3 direction) { Bullet bullet = bulletPool.GetObject(); if (bullet != null) { bullet.Initialize(position, direction, () => { bulletPool.ReturnObject(bullet); }); } } } ``` ### 2. 파티클 시스템 풀링 ```c# using UnityEngine; using System.Collections; public class ParticlePool : MonoBehaviour { [Header("파티클 설정")] public ParticleSystem particlePrefab; public int poolSize = 20; private ParticleSystem[] particles; private int currentIndex = 0; private void Start() { InitializeParticlePool(); } private void InitializeParticlePool() { particles = new ParticleSystem[poolSize]; for (int i = 0; i < poolSize; i++) { particles[i] = Instantiate(particlePrefab, transform); particles[i].gameObject.SetActive(false); } } public ParticleSystem GetParticle() { ParticleSystem particle = particles[currentIndex]; currentIndex = (currentIndex + 1) % poolSize; if (particle.isPlaying) { particle.Stop(); particle.Clear(); } particle.gameObject.SetActive(true); return particle; } public void PlayEffect(Vector3 position, ParticleSystem.MainModule mainSettings = default) { ParticleSystem particle = GetParticle(); particle.transform.position = position; if (mainSettings.startColor.color != Color.clear) { var main = particle.main; main.startColor = mainSettings.startColor; } particle.Play(); StartCoroutine(ReturnToPool(particle)); } private IEnumerator ReturnToPool(ParticleSystem particle) { yield return new WaitUntil(() => !particle.isPlaying); particle.gameObject.SetActive(false); } } ``` *** ## 스마트 가비지 컬렉션 ### 1. 적응형 GC 관리 ```c# using UnityEngine; using System.Collections; public class SmartGarbageCollector : MonoBehaviour { [Header("GC 설정")] public float gcInterval = 10f; public float memoryThreshold = 0.75f; public bool adaptiveGC = true; private float lastGCTime; private long baselineMemory; private int framesSinceLastGC; private void Start() { baselineMemory = System.GC.GetTotalMemory(true); lastGCTime = Time.time; if (adaptiveGC) { StartCoroutine(AdaptiveGCRoutine()); } else { StartCoroutine(RegularGCRoutine()); } } private IEnumerator AdaptiveGCRoutine() { while (true) { framesSinceLastGC++; if (ShouldRunGC()) { RunGarbageCollection(); } yield return new WaitForEndOfFrame(); } } private IEnumerator RegularGCRoutine() { while (true) { yield return new WaitForSeconds(gcInterval); RunGarbageCollection(); } } private bool ShouldRunGC() { // 시간 기반 조건 if (Time.time - lastGCTime > gcInterval) return true; // 메모리 사용량 기반 조건 long currentMemory = System.GC.GetTotalMemory(false); float memoryGrowth = (float)currentMemory / baselineMemory; if (memoryGrowth > 1f + memoryThreshold) return true; // 프레임 드롭 기반 조건 if (Time.deltaTime > 1f / 30f && framesSinceLastGC > 300) return true; return false; } private void RunGarbageCollection() { long memoryBefore = System.GC.GetTotalMemory(false); Resources.UnloadUnusedAssets(); System.GC.Collect(); System.GC.WaitForPendingFinalizers(); System.GC.Collect(); long memoryAfter = System.GC.GetTotalMemory(true); Debug.Log($"GC 실행: {(memoryBefore - memoryAfter) / 1024 / 1024}MB 해제됨"); lastGCTime = Time.time; framesSinceLastGC = 0; if (baselineMemory == 0 || memoryAfter < baselineMemory) { baselineMemory = memoryAfter; } } } ``` *** ## 메모리 프로파일링 도구 ### 1. 실시간 메모리 분석기 ```c# using UnityEngine; using System.Collections.Generic; using System.Text; public class MemoryProfiler : MonoBehaviour { [Header("프로파일링 설정")] public bool enableProfiling = true; public float updateInterval = 1f; public int maxSamples = 60; private Queue samples = new Queue(); private StringBuilder logBuilder = new StringBuilder(); [System.Serializable] public struct MemorySample { public float timestamp; public long totalMemory; public long textureMemory; public long meshMemory; public long audioMemory; } private void Start() { if (enableProfiling) { InvokeRepeating(nameof(TakeSample), 0f, updateInterval); } } private void TakeSample() { MemorySample sample = new MemorySample { timestamp = Time.time, totalMemory = System.GC.GetTotalMemory(false), textureMemory = GetTextureMemoryUsage(), meshMemory = GetMeshMemoryUsage(), audioMemory = GetAudioMemoryUsage() }; samples.Enqueue(sample); if (samples.Count > maxSamples) { samples.Dequeue(); } // AppsInToss 플랫폼에 메모리 정보 전송 SendMemoryDataToAppsInToss(sample); } private long GetTextureMemoryUsage() { long totalMemory = 0; Texture[] textures = Resources.FindObjectsOfTypeAll(); foreach (Texture texture in textures) { if (texture is Texture2D tex2D) { totalMemory += UnityEngine.Profiling.Profiler.GetRuntimeMemorySizeLong(tex2D); } } return totalMemory; } private long GetMeshMemoryUsage() { long totalMemory = 0; Mesh[] meshes = Resources.FindObjectsOfTypeAll(); foreach (Mesh mesh in meshes) { totalMemory += UnityEngine.Profiling.Profiler.GetRuntimeMemorySizeLong(mesh); } return totalMemory; } private long GetAudioMemoryUsage() { long totalMemory = 0; AudioClip[] clips = Resources.FindObjectsOfTypeAll(); foreach (AudioClip clip in clips) { totalMemory += UnityEngine.Profiling.Profiler.GetRuntimeMemorySizeLong(clip); } return totalMemory; } private void SendMemoryDataToAppsInToss(MemorySample sample) { logBuilder.Clear(); logBuilder.Append("{"); logBuilder.AppendFormat("\"timestamp\":{0},", sample.timestamp); logBuilder.AppendFormat("\"totalMemory\":{0},", sample.totalMemory); logBuilder.AppendFormat("\"textureMemory\":{0},", sample.textureMemory); logBuilder.AppendFormat("\"meshMemory\":{0},", sample.meshMemory); logBuilder.AppendFormat("\"audioMemory\":{0}", sample.audioMemory); logBuilder.Append("}"); Application.ExternalCall("SendMemoryDataToAppsInToss", logBuilder.ToString()); } public string GetMemoryReport() { if (samples.Count == 0) return "메모리 샘플이 없습니다."; logBuilder.Clear(); logBuilder.AppendLine("=== 메모리 사용량 보고서 ==="); MemorySample latest = samples.ToArray()[samples.Count - 1]; logBuilder.AppendFormat("총 메모리: {0:F2} MB\n", latest.totalMemory / 1024f / 1024f); logBuilder.AppendFormat("텍스처 메모리: {0:F2} MB\n", latest.textureMemory / 1024f / 1024f); logBuilder.AppendFormat("메시 메모리: {0:F2} MB\n", latest.meshMemory / 1024f / 1024f); logBuilder.AppendFormat("오디오 메모리: {0:F2} MB\n", latest.audioMemory / 1024f / 1024f); return logBuilder.ToString(); } } ``` *** ## 트러블 슈팅 ### 일반적인 메모리 문제들 1. 메모리 누수 * 이벤트 핸들러 해제 누락 * 코루틴 정리 누락 * 순환 참조 2. 과도한 메모리 할당 * string concatenation * 불필요한 new 연산 * 큰 배열 생성 3. 텍스처 메모리 문제 * 압축되지 않은 텍스처 * 불필요한 밉맵 * 잘못된 텍스처 포맷 ### 해결 방법 ```c# // 메모리 누수 방지 public class ProperCleanup : MonoBehaviour { private System.Action scoreChanged; private void OnEnable() { GameManager.OnScoreChanged += HandleScoreChanged; } private void OnDisable() { GameManager.OnScoreChanged -= HandleScoreChanged; // 중요! } private void HandleScoreChanged(int newScore) { // 처리 로직 } } // 효율적인 문자열 처리 public class StringOptimization : MonoBehaviour { private StringBuilder stringBuilder = new StringBuilder(256); public string CreateFormattedString(int value1, float value2) { stringBuilder.Clear(); stringBuilder.AppendFormat("값1: {0}, 값2: {1:F2}", value1, value2); return stringBuilder.ToString(); } } ``` *** ## 베스트 프랙티스 1. 정기적인 메모리 모니터링 2. 적절한 오브젝트 풀링 사용 3. 불필요한 할당 최소화 4. 리소스 해제 자동화 5. AppsInToss 플랫폼 특성 고려 이 가이드를 통해 Unity WebGL 게임의 메모리 효율성을 크게 향상시킬 수 있어요. --- --- url: 'https://developers-apps-in-toss.toss.im/api/refreshOauth2Token.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/generateOauth2Token.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/removeByUserKey.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/removeByAccessToken.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/getExecutionResult.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/executePromotion.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/getKey.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/refundPayment.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/makePayment.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/getPaymentStatus.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/executePayment.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/getIapOrderStatus.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/sendTestMessage.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/sendMessage.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/api/loginMe.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/bedrock/overview.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/design/overview.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/development/overview.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/intro/_caution-sensitive.md' description: >- 앱인토스의 민감 콘텐츠 주의사항입니다. 앱인토스는 성인 사용자의 다양한 콘텐츠 이용을 지원해요. 불법·유해 콘텐츠를 예방하고 토스의 신뢰성과 브랜드 가치를 지키기 위해 민감 콘텐츠 주의사항을 확인하세요. --- ## 민감 콘텐츠 주의사항 {#sensitive} 앱인토스는 성인 사용자의 다양한 콘텐츠 이용을 지원하면서도, 불법·유해 콘텐츠를 예방하고 토스의 신뢰성과 브랜드 가치를 지키기 위해 민감 콘텐츠 주의사항을 안내드려요. ### 민감 콘텐츠가 무엇인가요? 민감 콘텐츠는 사용자에게 불쾌감·불안감·불편함을 줄 수 있는 표현이나 장면이 포함된 콘텐츠를 말해요.\ 법적으로 불법은 아니더라도, 사회적 통념상 논란이 될 수 있는 소재나 연출이 들어간 경우를 포함해요. 민감 콘텐츠의 카테고리는 크게 선정성, 폭력성, 불법·범죄 조장으로 나뉘어요. **(1) 선정성** * 신체 특정 부위 노출, 성행위 묘사, 자극적 카메라 워킹, 음란 행위 암시 등은 제한돼요. * 성행위를 직접적으로 연출하거나, 성적 대상화를 주요 포인트로 하는 콘텐츠는 등록할 수 없어요. * 음란 유행어나, 불쾌한 성적 표현도 사용하지 말아주세요. **(2) 폭력성** * 단순 액션이나 코믹한 격투 장면은 괜찮지만, 잔혹한 유혈·학대·고문 장면은 제한돼요. * 폭력을 미화하거나 성적 ·혐오 맥락과 결합된 경우는 즉시 삭제돼요. **(3) 불법·범죄 조장** * 마약, 불법 도박, 불법 촬영, 성매매, 미성년자 등장 등은 즉시 삭제 및 신고돼요. * 불법 행위를 미화하거나 조장하는 콘텐츠는 경고 없이 제한될 수 있어요. ::: tip 확인해 주세요 [민감 콘텐츠 등급](/intro/caution.md#민감-콘텐츠-등급-기준표)에서 경미 또는 주의 단계에 해당하더라도, 내부 심의 결과 수위가 높다고 판단되면 앱 출시가 제한될 수 있어요. 민감 콘텐츠로 분류되어도, 아래와 같은 내용은 포함할 수 없어요. * 과한 신체 노출, 성행위 묘사, 성적 대상화, 성상품화 * 폭력·학대·혐오·차별·불법 조장 장면 포함 * 허위 정보, 극단적 발언, 사회적 논란을 유발할 수 있는 장면 ::: ### 민감 콘텐츠에 해당하는지 어떻게 알 수 있나요? 앱 출시 검토 요청과 함께, 검수가 진행되면서 민감 콘텐츠 여부가 결정돼요.\ 민감 콘텐츠로 분류되면 워크스페이스의 멤버에게 메일로 안내드려요. ### 민감 콘텐츠로 분류되면 어떻게 되나요? 미니앱 서비스가 민감 콘텐츠로 분류되면, 미니앱에 진입하기 직전에 안내해요.\ 동의한 사용자는 민감 콘텐츠로 분류된 모든 미니앱을 안내 UI 없이 접속할 수 있어요. ![](/assets/caution_sensitive_alert.BJymrOGy.png) ### 민감 콘텐츠 등급 기준표 --- --- url: 'https://developers-apps-in-toss.toss.im/intro/_caution-social.md' description: >- 앱인토스의 데이팅, 만남, 소셜 서비스 운영 주의사항입니다. 법적 규제 준수, 청소년 보호, 개인정보 보호, 안전 관리 및 모니터링 요구사항을 확인하세요. --- ## 데이팅, 만남 서비스 등 주의사항 {#social} 앱인토스 미니앱에서 데이팅, 만남, 소셜 등의 서비스를 제공하기 위해서는 사용자에게 **안전하고 건전한 만남 경험**을 제공해야 해요. 데이팅, 소개팅, 만남 서비스는 **이용자 간의 교류나 인연 형성을 목적으로 연결해주는 서비스이며,** 프로필 기반 매칭, 채팅, 추천 알고리즘 등을 통해 이용자들이 서로를 알아갈 수 있게 **온라인 상에서 사람과 사람을 연결해 새로운 관계를 만드는 소셜 매칭 플랫폼**이에요. 데이팅·소개팅·만남을 목적으로 하는 서비스를 운영하는 파트너사는 아래 사항을 반드시 준수해 주세요. ::: tip 확인해 주세요. 데이팅, 만남, 소개팅 등의 서비스는 앱인토스 콘솔에서 앱 정보 등록 시, ‘소셜’ 카테고리로 등록이 필요해요. * 소셜 카테고리 선택 시 안내 되는 체크리스트를 꼼꼼히 확인해 주세요. ::: ### 1. 법적·규제 준수 #### 1) 청소년 보호 * 만 19세 미만 이용자는 가입 및 이용할 수 없어요. * 서비스 진입 전에 **본인인증(성인 인증) 절차**를 반드시 거쳐야 해요. * 청소년 접근이 확인되면 서비스는 즉시 퇴출될 수 있어요. #### 2) 개인정보 및 위치정보 보호 * 서비스 목적과 직접 관련된 **최소한의 정보만 수집**해야 해요. * **성적 지향, 위치 등 민감정보**는 이용자 **명시적 동의** 후에만 수집할 수 있어요. * 수집한 정보는 **암호화해서 안전하게 저장**하고, **탈퇴 시 즉시 삭제**해야 해요. #### 3) 불법 행위 방지 * 조건만남, 성매매, 보이스피싱, 사기, 스토킹 등 **불법 행위와 연루되지 않도록 관리**해야 해요. * 신고나 모니터링 체계를 갖추고, 불법 행위가 발생하면 즉시 신고하고 차단할 수 있어야 해요. * 이용약관에는 **‘불법 행위 방조 금지 및 사용자 책임’ 조항**을 포함해야 해요. #### 4) 결제 및 소비자 보호 * 유료 결제 서비스는 **환불 및 자동결제 해지 정책**을 명확히 안내해야 해요. * **전자상거래법 등 관련 법률을 준수**하고, 이용자 민원이 발생하면 신속히 대응해야 해요. *** ### 2. 서비스 출시 기준 서비스 출시를 할 경우 최소한의 정보를 확인하고 있어요.\ 앱인토스 콘솔에서 앱 정보 등록 시, 안내되는 체크리스트를 하나씩 꼭 확인한 후 등록해 주세요. ::: details 체크리스트 보기 □ 법인 등록이 완료됐어요. □ 허위 프로필·도용 사진을 차단하기 위한 AI·운영·수동 검증 프로세스를 갖추고 있어요. □ 앱 내 불법 광고(조건 만남, 성매매 등)를 탐지·차단하는 시스템이 있어요. □ 사용자 신고 기능(원클릭 신고, 차단)을 제공하고 있어요. □ 신고 접수 후 24시간 이내 대응 프로세스를 운영하고 있어요. □ 반복 위반자를 영구 차단하는 정책이 있어요. □ 대화 내용을 저장할 수 있는 시스템이 있어요. □ 민감정보(성적 지향, 위치 등)는 최소한으로 수집하고 있어요. □ 청소년 보호법, 개인정보보호법 등 관련 법령을 준수하고 있어요. □ 불법 행위 발생 시 수사기관에 협조할 수 있는 체계를 갖추고 있어요. □ 유료 결제 환불 및 분쟁 해결 절차가 있어요. □ 위반 사항이 발생하면 토스로 신속하게 공유할 수 있어요. ::: *** ### 3. 운영 요구사항 #### 1) 연령 제한 * 서비스는 **만 19세 이상만 이용**할 수 있어요. * 청소년 접근이 확인되면 즉시 차단되며, 서비스가 퇴출될 수 있어요. #### 2) 신뢰성 확보 * **실명 기반 본인인증**을 반드시 적용해요. * 허위·도용 프로필을 막기 위한 검증 절차를 운영해요. * 검증 절차에는 연령, 직업, 지역 등의 기본 정보는 입력값을 기준으로 수집하고, 직업·소득 등 특정 정보는 증빙 자료로 선택 검증할 수 있어요. #### 3) 안전 관리 및 모니터링 * **인력 모니터링 및 AI 모니터링 시스템**을 함께 운영해요. * 조건만남 등 불법 키워드는 자동으로 필터링해요. * 신고가 접수되면 **24시간 이내에 처리**해요. * 관련 대화 내용을 저장하고 검증할 수 있는 시스템을 권장해요. #### 4) 개인정보 보호 * 개인정보는 최소한으로 수집하고, 암호화해서 저장해요. * 이용자가 탈퇴하면 정보를 즉시 삭제해요. * 민감정보는 별도 동의 후에만 사용할 수 있어요. #### 5) 법적 대응 체계 * 불법 행위가 발생하면 **즉시 수사기관에 협조**해요. * 피해자 보호를 위해 긴급 차단 프로세스를 운영해요. *** ### 4. 계약 및 제재 정책 #### **1) 필수 약관 조항** * 불법 행위는 전적으로 사용자의 책임이에요. * 토스는 신고 및 조치 범위 내에서만 책임을 져요. #### **2) 운영 위반 시 조치** * 위반이 누적되면 단계별로 제재되며, 아래의 사유의 경우 서비스 운영이 중단될 수 있어요. * 청소년 접근 허용 * 성매매 광고나 불법 행위 방치 * 대규모 개인정보 유출 등 중대한 위반 발생 --- --- url: 'https://developers-apps-in-toss.toss.im/intro/_caution-web-board.md' description: >- 앱인토스의 웹보드 게임 서비스 출시 주의사항입니다. 게임물 등급분류, 사행성 방지, 청소년 보호, 과몰입 방지 등 법적 규제 준수 요건과 운영 정책을 확인하세요. --- ## 웹보드 게임 주의사항 {#web-board} 앱인토스는 건전하고 공정한 게임 환경 조성을 위해, 웹보드 게임 서비스 출시 유의사항을 안내 드려요. 웹보드 게임은 온라인에서 즐기는 보드·카드·전략형 게임(예: 포커, 고스톱, 맞고, 체스, 장기 등)을 말하며,\ 게임머니로 베팅하고 상대와 승패를 겨루는 구조를 가지지만, 실제 현금 거래는 엄격히 금지돼요. 또한, **「게임산업진흥에 관한 법률」에 따라 사행성 방지 규제(베팅 한도·시간·결제 제한 등) 및 아래 주의사항을 반드시 준수**해 주세요. 이를 충족하지 못할 경우 심사 및 모니터링 과정에서 서비스 오픈이 제한되거나 제재를 받을 수 있어요. ::: tip 꼭 확인해 주세요. * 앱인토스 콘솔에서 앱 정보 등록 시 **‘웹보드’ 여부를 꼭 체크**해 주세요. * 게임물관리위원회의 등급분류가 반드시 필요해요. * 성인 인증 기능 연동이 필요해요. * 미니앱에 등급 표기 시, 청소년 유해 매체물 및 등급분류 시 위원회에서 전달 받은 내용(선정성, 폭력성 등) 도 함께 표기가 되어야 해요. * 확률형 아이템의 경우 사용자가 확인할 수 있도록 자체적으로 확률 공개가 필요해요. * 이용 시간 경고, 결제 한도 알림 등 과몰입을 방지하기 위한 기능을 제공해야 해요. ::: ### 1. 법적 · 규제 준수 요건 웹보드 게임은 **관련 법령에 따른 규제 준수**가 필수이며, 아래 요건을 충족하지 않으면 출시가 제한되거나 서비스가 중단될 수 있어요. #### 1) 게임물관리위원회 등급분류 필수 모든 게임은 **「게임산업진흥에 관한 법률」 제21조**에 따라 **게임물관리위원회(GRAC)의 등급분류를 반드시 받아야 해요.** * 앱스토어·구글플레이의 IARC 등급은 국내 효력이 없어요. #### 2) 사행성 방지 기준 충족 게임 내 베팅, 도박, 재산상 이익을 유도하는 기능은 금지돼요. * 문화체육관광부 고시 **「게임제공업자의 준수사항」** 과 **「게임산업진흥법」제 32조**를 준수해야 해요. #### 3) 청소년 보호 의무 청소년 이용불가 게임물은 **청소년유해매체물 표시**를 적용하고, 본인인증·이용 제한 기능을 포함해야 해요. * 관련 근거 :**「청소년 보호법」제 26조**, **「게임산업진흥법」제 24조**를 준수해야 해요. #### 4) 개인정보 보호 이용자의 개인정보는 **「개인정보보호법」제 28조** 및 **「정보통신망법」제 28조**에 따른 안전성 확보조치를 이행해야 해요. *** ### 2. 서비스 품질 기준 출시된 미니앱은 안정적으로 운영되어야 하며, 보안·접근성·장애 대응 체계가 확보되어야 해요. #### 1) 서버 안정성 및 장애 대응 **「정보통신망법」제 45조**에 따라, 서비스 장애 발생 시 신속히 대응하고 장애 이력을 관리해야 해요. #### 2) 보안성 강화 및 부정행위 방지 봇, 매크로, 계정 공유, 부정 결제 등 비정상 이용 행위를 탐지하고 방지해야 합니다. * 관련근거 :**「게임산업진흥법」제 32조,「정보통신망법」제 28조**를 준수해 주세요. #### 3) 접근성 및 최적화 모든 이용자가 접근 가능한 UI/UX를 제공해야 하며, 필요 시 **「장애인차별금지법」제 21조**에 따른 접근성 기준을 준용할 수 있어요. *** ### 3. 운영 및 정책 #### 1) 과몰입 방지 기능 게임 내 **이용시간 경고, 결제 한도 알림**을 제공해야 해요. * 관련근거 :**「게임산업진흥법」제 24조의 2항**을 준수해 주세요. #### 2) 고객센터 운영 및 분쟁 해결 이용자 불만 및 분쟁 발생 시, 고객센터를 통해 처리 절차를 제공해야 해요. * 관련근거 :**「전자상거래 등에서의 소비자보호법」 제 20조**를 준수해 주세요. #### 3) 확률형 아이템 정보 공개 확률형 아이템은 구성 및 확률 정보를 명확히 공개해야 해요. * 관련근거 :**「게임산업진흥법」 제32조의 3항**을 준수해 주세요. #### 4) 성인 인증 기능 연동 (본인 확인 연동) 웹보드 게임은 만 19세 이상부터 이용이 가능하므로, 본인 인증 기능 연동을 해주세요. * [본인 인증 연동 방법 보러가기](/tossauth/contract.md) *** ### 4. 결제·정산 관련 결제 과정은 안전해야 하며, 이용자에게 명확히 고지되어야 해요. #### 1) 결제 안전성 **「전자금융거래법」제 6조**에 따라, 결제 서비스 제공 시 보안성 및 이용자 보호조치를 이행해야 해요 #### 2) 환불 및 취소 정책 청약철회, 취소, 환불 절차를 명시하고 이용자가 쉽게 확인할 수 있어야 해요. * 관련 근거 :**「전자상거래법」 제 17조 및 제 18조**를 준수해 주세요. #### 3) 매출 보고 및 정산 투명성 매출 내역, 세금계산서 발급 등 거래 내역을 투명하게 관리해야 해요. * **「부가가치세법」제 32조**를 준수해 주세요. #### 4) 결제 한도 관리 시스템 이용자별 결제 한도(일/월 기준)를 설정하고 관리 기능을 갖춰야 해요. * **「게임산업진흥법」 제 24조의 2항**을 준수해 주세요. *** ### 5. UX/UI 및 브랜드 적합성 앱인토스 미니앱으로 출시되는 웹보드 게임은 일관된 사용자 경험을 유지하고, 광고·홍보 콘텐츠는 공정하게 운영되어야 해요. #### 1) 건전성 확보 게임 내 표현과 구성은 **청소년 보호법, 게임산업진흥법**에 따라 사회적 통념을 해치지 않도록 구성해야 해요. #### 2) UI/UX 일관성 앱인토스 미니앱의 디자인 가이드 및 인터랙션 규칙을 따라야 해요. * [**UI/UX 가이드라인 보러가기**](/design/miniapp-branding-guide.html#frontmatter-title) #### 3) 광고 및 홍보 제한 서비스 본질과 무관한 과도한 광고, 외부 유도형 홍보, 오해를 불러일으킬 수 있는 문구는 금지돼요. * 관련 근거 :**「표시·광고의 공정화에 관한 법률」제 3조**를 준수해 주세요. *** ### 6. 심사·사후 모니터링 앱인토스는 웹보드 게임에 대해 **출시 검토 → 서비스 운영 → 사후 점검**의 3단계 관리 프로세스로 운영해요. #### 1) 출시 검토 게임물 등급, 서비스 품질, 과몰입 방지, 보안성 등을 검토해요. * [**게임 출시 가이드 보러가기**](/checklist/app-game.html) #### 2) 사후 모니터링 출시 이후에도 주기적으로 다음 항목을 점검해요. * 법령 위반 여부 * 과몰입 방지 시스템 작동 여부 * 결제 한도 및 확률 공개 준수 여부 위반 사항이 확인되면 **콘텐츠 수정 요청, 비노출 등의 조치**가 이뤄질 수 있어요. * 관련 근거:**「게임산업진흥법」제 24조의 2항, 제 32조 /「전자상거래법」제 20조** --- --- url: 'https://developers-apps-in-toss.toss.im/markdown-examples.md' --- hello --- --- url: 'https://developers-apps-in-toss.toss.im/marketing/overview.md' --- --- --- url: 'https://developers-apps-in-toss.toss.im/overview-card.md' description: >- 앱인토스 개발자센터 오버뷰 페이지. 게임 또는 비게임 서비스를 선택하고, React Native 또는 WebView 기반 개발 방식을 선택하여 시작할 수 있습니다. --- --- --- url: 'https://developers-apps-in-toss.toss.im/revenue/overview.md' --- --- --- url: >- https://developers-apps-in-toss.toss.im/unity/optimization/runtime/memory-profiling.md --- --- --- url: >- https://developers-apps-in-toss.toss.im/unity/optimization/runtime/custom-urp.md --- # URP 파이프라인 커스터마이징 앱인토스 Unity 게임에서 Universal Render Pipeline(URP)을 활용하여 최적화된 렌더링 파이프라인을 구축하는 방법을 다뤄요. *** ## 1. AppsInToss URP 설정 ### 커스텀 URP 에셋 설정 ```c# using UnityEngine; using UnityEngine.Rendering.Universal; [CreateAssetMenu(fileName = "AppsInTossURPAsset", menuName = "Rendering/AppsInToss URP Asset")] public class AppsInTossURPAsset : UniversalRenderPipelineAsset { [Header("AppsInToss 최적화")] public bool enableMobileOptimizations = true; public bool enableBatteryOptimizations = true; public RenderScale renderScale = RenderScale.Half; public enum RenderScale { Quarter = 4, Half = 2, Full = 1 } protected override ScriptableRenderer Create() { return new AppsInTossURPRenderer(this); } } ``` ### 커스텀 렌더러 ```c# using UnityEngine; using UnityEngine.Rendering.Universal; public class AppsInTossURPRenderer : UniversalRenderer { private AppsInTossRenderFeature tossBrandingFeature; private PerformanceOptimizerFeature performanceFeature; public AppsInTossURPRenderer(AppsInTossURPAsset data) : base(data) { // AppsInToss 특화 렌더링 기능 추가 tossBrandingFeature = new AppsInTossRenderFeature(); performanceFeature = new PerformanceOptimizerFeature(); // 렌더링 기능 등록 rendererFeatures.Add(tossBrandingFeature); rendererFeatures.Add(performanceFeature); } } ``` ### 앱인토스 브랜딩 렌더 기능 ```c# using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class AppsInTossRenderFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public bool enableTossWatermark = true; public Texture2D tossLogo; public Vector2 logoPosition = new Vector2(0.9f, 0.1f); public float logoScale = 0.1f; } public Settings settings = new Settings(); private AppsInTossRenderPass renderPass; public override void Create() { renderPass = new AppsInTossRenderPass(settings); renderPass.renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (settings.enableTossWatermark && settings.tossLogo != null) { renderer.EnqueuePass(renderPass); } } private class AppsInTossRenderPass : ScriptableRenderPass { private Settings settings; private Material blitMaterial; public AppsInTossRenderPass(Settings settings) { this.settings = settings; // 워터마크 셰이더 머티리얼 생성 blitMaterial = new Material(Shader.Find("AppsInToss/TossWatermark")); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (blitMaterial == null) return; CommandBuffer cmd = CommandBufferPool.Get("AppsInToss Branding"); // 토스 로고 렌더링 blitMaterial.SetTexture("_TossLogo", settings.tossLogo); blitMaterial.SetVector("_LogoPosition", settings.logoPosition); blitMaterial.SetFloat("_LogoScale", settings.logoScale); // 화면에 워터마크 렌더링 cmd.Blit(null, BuiltinRenderTextureType.CurrentActive, blitMaterial); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } } ``` URP를 사용하면 모바일에 맞게 최적화하면서도 앱인토스의 브랜딩 스타일을 함께 구현할 수 있어요.\ 커스텀 렌더링 기능을 활용해 성능과 시각적 품질의 균형을 맞춰보세요. --- --- url: 'https://developers-apps-in-toss.toss.im/design/ux-writing.md' description: >- 토스 보이스톤을 적용한 UX 라이팅 가이드입니다. 해요체, 능동적 말하기, 한 줄로 말하기, 버튼 작성법 등 일관된 사용자 경험을 위한 문구 작성 원칙을 확인하세요. --- # {{ $frontmatter.title }} 본 가이드라인은 토스의 보이스톤을 적용한 문구를 쓸 수 있도록 제공된 지침입니다. 아래 가이드라인을 지켜주세요. ## 1. 해요체 제품 안의 모든 문구는 ‘해요체’로 써요. 일관성 있는 사용자 경험을 만들 수 있도록 **상황, 맥락을 불문하고 모든 문구에 해요체를 적용해주세요.** ![합쇼체가 아닌, 해요체를 써주세요.](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2Fd13c8446-177d-4aef-977a-d6133215a246%2Fhaeyo1.png?table=block\&id=17d714bb-fde7-8082-87bc-d9889336576d\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1420\&userId=\&cache=v2) ## 2. 능동적 말하기 제품 안에서 최대한 **능동형 문장**을 써주세요. ### 됐어요 → 했어요 불필요하게 동사 끝을 ‘됐어요’로 쓰지 않아요. ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2Fb972cb10-930d-47d2-ae7c-906ed265bcec%2Fsudong3.png?table=block\&id=17d714bb-fde7-80ca-9387-c7db14b3c36e\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1860\&userId=\&cache=v2) ### ‘~었’ 빼기 동사에 ‘-었’을 빼면 문장을 더 짧게 만들 수 있어요. ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2Fc02c38fa-a84d-4547-bc87-c0a46bd4cd19%2Fsudong5.png?table=block\&id=17d714bb-fde7-80c7-a048-fd6ef6a455bd\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### 동사 바꿔쓰기 동사를 바꿔서 쓰면 의미를 더 명확하게 쓸 수 있어요. ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2F8352604b-7c4b-403c-a930-a0ebb22833dc%2Fsudong4.png?table=block\&id=17d714bb-fde7-8009-84ee-c0a428d28ed7\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### 수동형 → 능동형, 고쳤을 때 어색한 경우 수동형 문장을 기계적으로 능동형으로 바꿨을 때 문장이 어색해지거나, 전달하는 내용이 더 어려워질 때도 있어요. 그럴 때는 처음부터 문장을 다시 써보면 좋아요. ::: details 밸류 강조하기 * Don't 28개 금융사에서 대출이 승인됐어요. * Do 28개의 금융사에서 대출받을 수 있어요. * '대출'을 주어로 써서 사용자가 얻는 가치가 모소하게 느껴져요. 사용자를 주어로 표현하면 사용자가 얻는 가치가 더 뚜렷해져요. ::: ::: details **행동 강조하기** * Don't 비밀번호를 바꾸면 토스인증서가 삭제돼요. * Do 비밀번호를 바꾸면 토스인증서를 다시 받아야 해요. * 인증서를 삭제했을 때 사용자가 해야 하는 행동을 더 명확하게 설명할 수 있어요. ::: ### 수동형 문장을 써도 되는 경우 수동형 문장이 더 명확 하고 간결한 커뮤니케이션을 만드는 때도 있어요. 수동형으로 더 좋은 문장을 쓸 수 있는 사례를 알려드릴게요. ::: details **⚠️ 서비스 종료, 기간 만료** * Do 자산 조회 기간이 곧 만료돼요. * 주어(종료 서비스, 기간 등)를 강조할 수 있어요. * ‘종료, 만료’ 를 한국어로 풀어썼을 때 뉘앙스를 정확히 전달하지 못해요. (끝나요 vs. 만료돼요.) ::: :::details **⚠️ 사용자에게 미치는 영향을 알려줄 때** * Do 토스뱅크 대출로 갈아타면 원래 대출이 해지돼요. * **인과 관계**를 명확하게 설명해요. ‘사용자의 행동에 의해 따라오는 결과’라는 점을 알려줄 수 있어요. * 주요 동사 : 연체, 해지, 적용 등 ::: :::details **⚠️ 사용자 안심** * Do 이제부터 김토스님의 개인정보 이용 내역이 기록돼요. * '정보 수집 안내’ 등의 민감한 상황에서 사용자를 안심하게 할 수 있어요. ::: :::details **⚠️ 되어요 (X) → 돼요 (O)** * 모바일 화면의 좁은 공간을 고려, ‘되어요’는 모두 ‘돼요’로 통일해서 써주세요. ::: ## 3. 긍정적 말하기 제품 안에서 부정적 커뮤니케이션을 최대한 줄이고 긍정형 문장을 써주세요. ### 없어요 → 있어요 ‘할 수 없다’보다는 어떻게 해야 ‘할 수 있는지’ 알려주세요. ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2F8e18b606-d1c9-4ed7-a265-575d4eef012c%2Fpositive1.png?table=block\&id=17d714bb-fde7-80bd-8c49-c1e26e0e0bcf\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### 에러 메시지 사용자가 스스로 해결할 수 있는 에러라면 부정형 문장을 쓰지 않아요. 사용자가 기능을 쓸 수 있는 방법을 안내해주세요. ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2Fb873c576-648f-43c3-b6ec-60ce03bfc158%2Fpositive2.png?table=block\&id=17d714bb-fde7-809d-9c41-d230d317ce00\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### 혜택을 받을 수 없을 때 혜택을 받을 수 없을 때, 부정형 문장을 쓰면 사용자의 감정을 상하게 할 수 있어요. ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2F0c9550c0-68b1-4ec1-b97a-76af00275242%2Fpositive3.png?table=block\&id=17d714bb-fde7-80db-ba51-cc88b9667092\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### 혜택 대상 안내 비스는 쓸 수 있지만, 특정 혜택은 받을 수 없을 때 → 긍정형 문장. 사용자는 스킴하기 때문에 제품 전체를 쓸 수 없다고 이해하기 쉬워요. ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2Fd9280050-9714-4721-8fa2-2098c7c33a16%2Fpositive1.png?table=block\&id=17d714bb-fde7-80f6-89b6-dcce6c27a1fa\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### 부정형 문장을 써도 되는 경우 사용자에게 명확하게 부정적인 내용을 알려줘야 할 때는 부정형 문장을 써도 좋아요. :::details **⚠️ 서비스를 정책 상 쓸 수 없을 때** * Do 지금은 가입할 수 없어요. 청소년을 위한 서비스는 아직 준비 중이에요. * Do 공무원은 후원금을 보낼 수 없어요. 구각공무원법에 따라, 특정 정당이나 정치인을 후원하는 것은 금지돼요. * 사용자에게 상황을 명확하게 인지시킬 수 있어요. 쓸 수 없는 이유를 함께 안내해주세요. ::: :::details **⚠️ 일부 기능만 쓸 수 없을 때** * Do 한 번 바꾸면 주식캐시백은 다시 받을 수 없어요. * 사용자가 어떤 기능을 쓸 수 없는지 명확하게 인지 할 수 있어요. * 사용자 선택의 결과를 명확하게 안내할 수 있어요. ::: :::details **⚠️ 사용자 안심** * Do 상담이 끝나면 보험 전문가도 김토스님의 정보를 볼 수 없어요. * ‘정보 수집 안내’ 등의 민감한 상황에서 사용자를 안심하게 할 수 있어요. ::: ## 4. 캐주얼한 경어 제품 안에서 ‘~시겠어요?’, ‘시나요?’, ‘~께’ 같은 과도한 경어를 쓰지 않아요. 최대한 캐주얼하고 친근한 말투를 쓰는 게 좋아요. ### 동사에서 ‘~시’ 빼기 ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2Fa78e04b4-740a-4640-83d3-99b034c09c1d%2Frespect2.png?table=block\&id=17d714bb-fde7-80f5-bb39-ee7d3c04f0d5\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### ‘계시다' → ‘있다’ ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2Fc2aed8e3-4b15-4533-a7f6-c5c1574d894c%2Frespect4.png?table=block\&id=17d714bb-fde7-80bb-b122-d329866a8190\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### ‘여쭈다’ → ‘확인하다, 묻다’ ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2Fb94583f1-7785-489e-bc71-9fe3b87bfbe3%2Frespect5.png?table=block\&id=17d714bb-fde7-8026-883e-dc7f1b5d4486\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### ‘께’ → ‘에게’ ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2F7c6b5d55-3fcd-4ad7-8f5a-815308cba351%2Frespect6.png?table=block\&id=17d714bb-fde7-8079-b550-ff2d7051679d\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### 경어를 뺐을 때 어색한 경우 사용자의 정보를 받는 질문에서 기계적으로 ‘~시’를 뺐을 때 문장이 어색할 수 있어요. 파악하고 싶은 정보를 ‘주어’로 써서 문장을 새롭게 써보세요. ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2F31d31873-f5b0-41e8-957d-6b3a394ea3ed%2Frespect4.png?table=block\&id=17d714bb-fde7-80b2-887b-e367a2c6d6f4\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### 경어를 써도 되는 경우 :::details ⚠️ 사용자의 맥락을 활용해서 질문할 때 ‘시나요?’, ‘셨나요?’ 형태의 경어를 활용해서 사용자의 당황스러움을 줄일 수 있어요. :::details ⚠️ 사용자의 상황을 추정할 때 토스에 명확한 정보가 없어서 사용자에게 직접 판단하게 해야 할 때 ‘경어’로 정중하게 질문할 수 있어요. :::details ⚠️ 사용자의 선의가 필요할 때 설문조사처럼 사용자의 선의를 기대해야 할 때 경어로 정중하게 질문해요. ## 5. ‘{명사} + {명사}’ 쓰지 않기 ### 한자어 풀어쓰기 한자어 명사를 풀어서 동사 형태로 쓸 수 있어요. ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2F0513c260-ad59-4f08-a6b4-26782bbdf63f%2Fnoun2.png?table=block\&id=17d714bb-fde7-8040-a6ab-f6fdf0fd3e34\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### **한자어를 풀어쓰기 어려울 경우** ’{명사}가 {명사}해서’ 형태로만 풀어줘도 더 캐주얼하게 쓸 수 있어요. ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2F6d89dedb-d287-4657-876d-aff3972dca09%2Fnoun3.png?table=block\&id=17d714bb-fde7-805f-96d2-d464e6b1662a\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ### 기능 이름 + 동명사 토스 제품의 네이밍은 대부분 ‘~하기’ 형태로 끝나요. ’~하기’가 어색하게 반복되지 않게 기능 이름은 빼고 ‘동사’만 쓰거나, 기능 이름을 문장으로 풀어서 쓰면 좋아요. ![](https://tosspublic.notion.site/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F5ce36d31-78ba-49d0-aeaf-0919cf07d3f4%2Fdb651e92-b2c5-4988-97c8-c389fa52cc87%2Fnoun4.png?table=block\&id=17d714bb-fde7-807f-a5da-f936abe676bc\&spaceId=5ce36d31-78ba-49d0-aeaf-0919cf07d3f4\&width=1040\&userId=\&cache=v2) ## 더 알아보기 * [토스의 8가지 라이팅 원칙들](https://toss.tech/article/8-writing-principles-of-toss) * [좋은 에러 메시지를 만드는 6가지 원칙](https://toss.tech/article/21021) --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/UI/Video.md --- # Video `Video` 컴포넌트는 앱 내에서 비디오를 재생할 수 있는 컴포넌트예요.\ 앱이 백그라운드로 전환되면 자동으로 일시 정지되고,\ 다른 앱에서 음악이 재생 중일 때 오디오 포커스를 적절히 제어해\ 토스 앱이 그 음악을 중지시키지 않아요. ::: tip 참고하세요 `Video` 컴포넌트는 [`react-native-video` v6.0.0-alpha.6](https://github.com/TheWidlarzGroup/react-native-video/tree/v6.0.0-alpha.6)을 기반으로 동작해요.\ 일부 타입이나 기능은 최신 버전과 호환되지 않을 수 있어요. ::: ## 시그니처 ```typescript Video: import("react").ForwardRefExoticComponent> ``` ### 파라미터 ### 프로퍼티 ### 반환 값 ## 예제 ### 비디오 자동 재생하기 아래 예시는 비디오를 자동 재생하는 간단한 예시예요.\ `muted`를 `true`로 설정하면 무음 상태에서 재생할 수 있어요. ```tsx import { useRef } from 'react'; import { View } from 'react-native'; import { Video } from '@granite-js/react-native'; function VideoExample() { const videoRef = useRef(null); return ( ); } ``` ## 참고 * [react-native-video](https://github.com/react-native-video/react-native-video)\ 비디오 속성 및 이벤트에 대한 자세한 정보는 공식 문서를 참고해 주세요. * [react-native-video-6.0.0-alpha.6](https://github.com/TheWidlarzGroup/react-native-video/releases/tag/v6.0.0-alpha.6)\ 현재 토스앱에 설치되어있는 버전의 소스코드에요. --- --- url: 'https://developers-apps-in-toss.toss.im/bedrock/reference/framework/UI/View.md' description: Bedrock 프레임워크 레퍼런스 문서입니다. --- # View `View` 컴포넌트를 사용해서 요소를 적절한 위치에 배치할 수 있어요. 예를 들어, 자식 요소들을 가로 방향으로 배치하고 싶은 경우 `flexDirection` 값을 `row`로 설정해야 해요. 아래 코드는 '안녕하세요, Bedrock이에요.' 라는 글자를 화면에 가로로 배치하는 예제에요.\ `flexDirection` 의 기본값은 `column`이기 때문에 가로로 배치하려면 `row`로 설정해야 해요. ```tsx import { Text, View } from "react-native"; export default function TextPage() { return ( 안녕하세요 Bedrock이에요. ); } ``` ## 요소 꾸미기 `View` 컴포넌트, `Text`의 `style` 속성에 값을 전달해서 요소를 꾸밀 수 있어요.\ 예를 들어, 배경색을 지정하거나 글자 크기를 키우거나 테두리를 설정할 수 있어요. 아래 코드는 `Text` 컴포넌트에 배경색(`backgroundColor`)을 지정하고, 글자 크기(`fontSize`)를 30으로 설정해요.\ 그리고 두 개의 `Text` 컴포넌트를 자식으로 갖는 `View` 컴포넌트의 테두리(`border`)를 검정색으로 설정하는 예제에요. ```tsx import { Text, View } from "react-native"; export default function TextPage() { return ( 안녕하세요 Bedrock이에요. ); } ``` ## 같이 보기 * [요소 레이아웃 쉽게 적용하기](/bedrock/reference/framework/UI/Layout.html) --- --- url: 'https://developers-apps-in-toss.toss.im/unity/porting-tutorials/vite-unity.md' description: >- Vite와 Unity를 활용한 앱인토스 미니앱 개발 가이드입니다. Vite 기반 프로젝트에서 Unity WebGL을 통합하는 방법을 확인하세요. --- # Vite로 Unity WebGL 빌드 감싸기 이 가이드는 Unity에서 빌드한 WebGL 파일을 Vite(React 기반) 프로젝트로 감싸는 방법을 안내해요.\ 앱인토스에 배포하려면 `@apps-in-toss/web-framework`도 함께 설치해야 해요. ## 1. Vite 프로젝트 생성 아래 명령어 중 사용하는 패키지 매니저에 맞게 선택해 주세요.\ React + TypeScript 템플릿으로 프로젝트가 생성돼요. ::: code-group ```sh [npm] npm create vite@latest unity-webgl-wrapper -- --template react-ts cd unity-webgl-wrapper npm install ``` ```sh [pnpm] pnpm create vite unity-webgl-wrapper --template react-ts cd unity-webgl-wrapper pnpm install ``` ```sh [yarn] yarn create vite unity-webgl-wrapper --template react-ts cd unity-webgl-wrapper yarn install ``` ::: ## 2. 앱인토스 SDK 설치 프로젝트 루트에서 앱인토스 SDK를 설치해 주세요.\ 앱인토스 환경에 배포하기 위해서 이 SDK가 꼭 필요해요. ```bash npm install @apps-in-toss/web-framework ``` ## 3. Unity WebGL 빌드 결과물 복사 Unity에서 WebGL 빌드를 완료한 후, 출력 폴더의 구조는 보통 다음과 같아요. ```bash Build/ ├── index.html ├── Build/ └── TemplateData/ ``` `Build/` 안의 파일들을 vite 프로젝트의 `public/unity` 폴더로 복사해주세요. ```bash mkdir -p public/unity cp -r [UnityBuildPath]/Build/* public/unity/ ``` 그 다음 `index.html`, `TemplateData` 파일도 `public/unity` 폴더로 복사해주세요. 복사 후 구조 예시는 다음과 같아요. ```bash public/ └── unity/ ├── index.html ├── {YourProject}.data.br ├── {YourProject}.framework.js.br ├── {YourProject}.loader.js ├── {YourProject}.wasm.br └── TemplateData/ ``` ::: tip `public` 폴더에 들어 있는 파일들은 Vite dev 서버에서 정적 파일로 서빙돼요. 즉, `/unity/index.html`로 접근할 수 있어요. ::: ## 4. Unity 게임을 보여주는 컴포넌트 만들기 Unity WebGL 빌드 파일을 직접 로드해서 ``에 그리는 컴포넌트를 작성해요.\ iframe은 사용할 수 없고, Unity의 `createUnityInstance()`를 통해 DOM에 직접 렌더링해야 해요. ::: tip iframe은 사용할 수 없어요 Unity WebGL을 iframe으로 삽입하면 앱인토스 기능(API, SDK 등)이 정상 동작하지 않아요.\ 또한 보안 심사에서도 반려될 수 있기 때문에, **iframe 방식**은 지원하지 않습니다. React 환경에서 Unity WebGL을 동작시키려면, Unity에서 빌드된 JavaScript 런타임을 직접 불러와서 DOM에 렌더링해 주세요. ::: ```tsx // src/UnityCanvas.tsx import React, { useEffect, useRef, useState } from "react"; /** * Props * - basePath: public 폴더 내 unity 빌드가 위치한 경로 (ex: /unity) * - loaderFile: loader 스크립트 파일명 (ex: "test.loader.js") * - fileBasename: Unity 빌드 파일의 기본 이름 (ex: "test" -> test.data.br, test.wasm.br 등) */ type Props = { basePath?: string; loaderFile?: string; fileBasename?: string; onProgress?: (p: number) => void; onLoaded?: () => void; onError?: (e: Error) => void; style?: React.CSSProperties; }; export default function UnityCanvas({ basePath = "/unity", loaderFile = "test.loader.js", fileBasename = "test", onProgress, onLoaded, onError, style, }: Props) { const containerRef = useRef(null); const canvasRef = useRef(null); const unityInstanceRef = useRef(null); const [loading, setLoading] = useState(true); useEffect(() => { let mounted = true; const scriptUrl = `${basePath}/${loaderFile}`; // 동적 스크립트 추가 const script = document.createElement("script"); script.src = scriptUrl; script.async = true; script.onload = () => { if (!mounted) return; // 캔버스 준비 const container = containerRef.current!; const canvas = document.createElement("canvas"); canvasRef.current = canvas; canvas.id = "unity-canvas"; canvas.style.width = "100%"; canvas.style.height = "100%"; canvas.style.display = "block"; container.appendChild(canvas); const createOpts = { dataUrl: `${basePath}/${fileBasename}.data.br`, frameworkUrl: `${basePath}/${fileBasename}.framework.js.br`, codeUrl: `${basePath}/${fileBasename}.wasm.br`, streamingAssetsUrl: "", companyName: "YourCompany", productName: fileBasename, productVersion: "1.0", }; if (typeof (window as any).createUnityInstance !== "function") { const err = new Error("createUnityInstance is not available on window. Loader script may have failed to load."); console.error(err); onError?.(err); setLoading(false); return; } (window as any).createUnityInstance(canvas, createOpts, (progress: number) => { if (!mounted) return; onProgress?.(progress); // optional internal state }).then((inst: any) => { if (!mounted) { // 컴포넌트가 언마운트 되었으면 즉시 종료 if (inst && inst.Quit) inst.Quit(); return; } unityInstanceRef.current = inst; setLoading(false); onLoaded?.(); }).catch((e: any) => { console.error("createUnityInstance error:", e); onError?.(e instanceof Error ? e : new Error(String(e))); setLoading(false); }); }; script.onerror = (e) => { const err = new Error("Failed to load Unity loader script: " + scriptUrl); console.error(err, e); onError?.(err); setLoading(false); }; document.body.appendChild(script); return () => { mounted = false; // Unity 인스턴스 종료 const inst = unityInstanceRef.current; if (inst && typeof inst.Quit === "function") { inst.Quit().catch((err: any) => { // ignore console.warn("UnityQuit error", err); }); } // 캔버스/스크립트 정리 if (canvasRef.current && containerRef.current?.contains(canvasRef.current)) { containerRef.current.removeChild(canvasRef.current); } try { document.body.removeChild(script); } catch {} }; }, [basePath, loaderFile, fileBasename]); // SendMessage helper const sendMessage = (gameObject: string, method: string, value?: string | number | boolean) => { const inst = unityInstanceRef.current; if (inst && typeof inst.SendMessage === "function") { inst.SendMessage(gameObject, method, value); } else { console.warn("Unity instance not ready or SendMessage missing"); } }; return (
{loading && (
Loading Unity...
)}
{/* 필요 시 외부에서 sendMessage 사용 가능하게 ref 전달 로직 추가 */}
); } ``` src/App.tsx에서 해당 컴포넌트를 불러와 사용해요. ```tsx // src/App.tsx import UnityCanvas from './UnityCanvas'; function App() { return (
); } export default App; ``` ## 5. 개발 서버 실행 아래 명령어로 Vite 개발 서버를 실행해 보세요. ```bash npm run dev ``` 브라우저에서 Vite 개발 서버 주소(`http://localhost:5173` 등)로 접속하면 React 앱 안에서 Unity 게임이 정상적으로 렌더링되는지 확인할 수 있어요. ## 6. 앱인토스 배포환경 구성 개발 서버가 정상적으로 실행되는 것을 확인했다면, 다음 명령어로 프로젝트를 앱인토스 배포환경으로 구성해주세요. ```bash npx ait init ``` 명령어 실행 후, 아래와 같은 질문에 순서대로 응답해 주세요. 1. `web-framework` 를 선택하세요. 2. 앱 이름(`appName`)을 입력하세요. * 이 이름은 앱인토스 콘솔에서 앱을 만들 때 사용한 이름과 같아야 해요. * 앱 이름은 각 앱을 식별하는 **고유한 키**로 사용돼요. * appName은 `intoss://{appName}/path` 형태의 딥링크 경로나 테스트·배포 시 사용하는 앱 전용 주소 등에서도 사용돼요. * 샌드박스 앱에서 테스트할 때도 `intoss://{appName}`으로 접근해요.\ 단, 출시하기 메뉴의 QR 코드로 테스트할 때는 `intoss-private://{appName}`이 사용돼요. 3. 웹 번들러의 dev 명령어를 입력해주세요. ```bash vite ``` 4. 웹 번들러의 build 명령어를 입력해주세요. ```bash tsc -b && vite build ``` 5. 사용할 포트 번호를 입력하세요. ```bash 5173 ``` 초기화가 완료되면 granite.config.ts 파일이 생성돼요. 배포하려는 서비스에 맞게 수정해주세요. ## 7. 정적 사이트 빌드 및 배포 ```bash npm run build ``` 빌드가 완료되면 `.ait` 파일이 생성돼요. 이 파일을 콘솔에 업로드하면 미니앱을 배포할 수 있어요. --- --- url: 'https://developers-apps-in-toss.toss.im/unity/optimization/runtime/webgl2.md' --- # WebGL 2.0 렌더링 앱인토스 Unity 게임에서 WebGL 2.0의 고급 기능을 활용하여 성능과 품질을 크게 향상시키는 방법을 제공해요. *** ## 1. WebGL 2.0 vs WebGL 1.0 ### 주요 개선사항 ``` 🚀 WebGL 2.0 고급 기능 ├── 렌더링 향상 │ ├── Multiple Render Targets (MRT) │ ├── Instanced Rendering │ ├── Uniform Buffer Objects (UBO) │ └── Transform Feedback ├── 텍스처 기능 │ ├── 3D Textures │ ├── Texture Arrays │ ├── Integer Textures │ └── Compressed Texture Formats ├── 컴퓨팅 기능 │ ├── Vertex Array Objects (VAO) │ ├── Sampler Objects │ ├── Sync Objects │ └── Query Objects └── 셰이더 향상 ├── GLSL ES 3.0 ├── Fragment Shader 고급 기능 ├── Vertex Shader 확장 └── 조건부 렌더링 ``` ### WebGL 2.0 지원 감지 ```c# public class WebGL2Detector : MonoBehaviour { public static bool IsWebGL2Supported() { #if UNITY_WEBGL && !UNITY_EDITOR return Application.platform == RuntimePlatform.WebGLPlayer && SystemInfo.graphicsDeviceVersion.Contains("WebGL 2.0"); #else return false; #endif } public static WebGLCapabilities GetWebGLCapabilities() { var capabilities = new WebGLCapabilities(); #if UNITY_WEBGL && !UNITY_EDITOR // WebGL 버전 확인 capabilities.isWebGL2 = IsWebGL2Supported(); // 최대 텍스처 크기 capabilities.maxTextureSize = SystemInfo.maxTextureSize; // 렌더 타겟 지원 capabilities.supportsMultipleRenderTargets = SystemInfo.supportedRenderTargetCount > 1; // 인스턴싱 지원 capabilities.supportsInstancing = SystemInfo.supportsInstancing; // 컴퓨트 셰이더 지원 (WebGL 2.0에서는 제한적) capabilities.supportsComputeShaders = SystemInfo.supportsComputeShaders; // 압축 텍스처 포맷 capabilities.supportsASTC = SystemInfo.SupportsTextureFormat(TextureFormat.ASTC_4x4); capabilities.supportsETC2 = SystemInfo.SupportsTextureFormat(TextureFormat.ETC2_RGBA8); capabilities.supportsDXT = SystemInfo.SupportsTextureFormat(TextureFormat.DXT5); Debug.Log($"WebGL 능력: {capabilities}"); #endif return capabilities; } } [System.Serializable] public class WebGLCapabilities { public bool isWebGL2; public int maxTextureSize; public bool supportsMultipleRenderTargets; public bool supportsInstancing; public bool supportsComputeShaders; public bool supportsASTC; public bool supportsETC2; public bool supportsDXT; public override string ToString() { return $"WebGL2: {isWebGL2}, MaxTexSize: {maxTextureSize}, MRT: {supportsMultipleRenderTargets}, " + $"Instancing: {supportsInstancing}, ASTC: {supportsASTC}, ETC2: {supportsETC2}"; } } ``` *** ## 2. WebGL 2.0 렌더링 최적화 ### Multiple Render Targets (MRT) 활용 ```c# public class WebGL2MRTRenderer : MonoBehaviour { [Header("MRT 설정")] public bool enableMRT = true; public int renderTargetCount = 4; public RenderTextureFormat[] rtFormats = { RenderTextureFormat.ARGB32, // 알베도 RenderTextureFormat.ARGB32, // 노멀 RenderTextureFormat.RG16, // 깊이/거칠기 RenderTextureFormat.ARGB32 // 이미시브/AO }; [Header("앱인토스 최적화")] public bool adaptToDevicePerformance = true; public bool enableQualityScaling = true; private RenderTexture[] renderTargets; private Camera targetCamera; private Material deferredMaterial; void Start() { #if UNITY_WEBGL if (WebGL2Detector.IsWebGL2Supported() && enableMRT) { SetupMultipleRenderTargets(); } else { SetupFallbackRendering(); } #endif } void SetupMultipleRenderTargets() { targetCamera = GetComponent(); // 기기 성능에 따른 렌더 타겟 수 조정 if (adaptToDevicePerformance) { AdjustRenderTargetCount(); } // 렌더 타겟 생성 CreateRenderTargets(); // MRT 셰이더 설정 SetupMRTShader(); Debug.Log($"WebGL 2.0 MRT 렌더링 설정 완료: {renderTargetCount}개 타겟"); } void AdjustRenderTargetCount() { // 앱인토스 환경에서 성능에 따른 MRT 개수 조정 int memoryMB = SystemInfo.systemMemorySize / 1024; if (memoryMB < 2048) // 2GB 미만 { renderTargetCount = 2; // 기본 + 노멀 } else if (memoryMB < 4096) // 4GB 미만 { renderTargetCount = 3; // + 깊이/거칠기 } // 4GB 이상은 기본값 4개 유지 Debug.Log($"기기 메모리 ({memoryMB}MB)에 따른 MRT 타겟 수 조정: {renderTargetCount}"); } void CreateRenderTargets() { renderTargets = new RenderTexture[renderTargetCount]; int width = Screen.width; int height = Screen.height; // 앱인토스 환경에 따른 해상도 스케일링 if (enableQualityScaling) { float qualityScale = GetQualityScale(); width = Mathf.RoundToInt(width * qualityScale); height = Mathf.RoundToInt(height * qualityScale); } for (int i = 0; i < renderTargetCount; i++) { renderTargets[i] = new RenderTexture(width, height, 0, rtFormats[i]) { name = $"MRT_Target_{i}", enableRandomWrite = false, useMipMap = false, antiAliasing = 1 }; renderTargets[i].Create(); } // 카메라에 렌더 타겟 설정 targetCamera.SetTargetBuffers( renderTargets.Select(rt => rt.colorBuffer).ToArray(), renderTargets[0].depthBuffer ); } float GetQualityScale() { // 앱인토스 성능 분석을 통한 품질 스케일 결정 var perfMonitor = AppsInTossPerformanceMonitor.Instance; if (perfMonitor != null) { var currentPerf = perfMonitor.GetCurrentPerformance(); if (currentPerf != null) { if (currentPerf.fps < 20) return 0.7f; else if (currentPerf.fps < 30) return 0.85f; else return 1.0f; } } return 1.0f; // 기본값 } void SetupMRTShader() { // MRT용 셰이더 로드 및 설정 deferredMaterial = Resources.Load("Shaders/AppsInToss_WebGL2_MRT"); if (deferredMaterial != null) { // 셰이더 키워드 설정 deferredMaterial.EnableKeyword("WEBGL2_MRT"); deferredMaterial.SetInt("_RenderTargetCount", renderTargetCount); // 앱인토스 특화 설정 deferredMaterial.EnableKeyword("APPS_IN_TOSS_OPTIMIZED"); } } void SetupFallbackRendering() { Debug.Log("WebGL 2.0 미지원 - 전통적 렌더링 사용"); // WebGL 1.0 호환 렌더링 설정 targetCamera = GetComponent(); targetCamera.renderingPath = RenderingPath.Forward; } // 디퍼드 셰이딩을 위한 G-Buffer 합성 void OnRenderImage(RenderTexture source, RenderTexture destination) { if (renderTargets == null || deferredMaterial == null) { Graphics.Blit(source, destination); return; } // G-Buffer를 사용한 디퍼드 셰이딩 deferredMaterial.SetTexture("_GBuffer0", renderTargets[0]); // 알베도 deferredMaterial.SetTexture("_GBuffer1", renderTargets[1]); // 노멀 if (renderTargetCount > 2) { deferredMaterial.SetTexture("_GBuffer2", renderTargets[2]); // 깊이/거칠기 } if (renderTargetCount > 3) { deferredMaterial.SetTexture("_GBuffer3", renderTargets[3]); // 이미시브/AO } // 최종 렌더링 Graphics.Blit(source, destination, deferredMaterial); } void OnDestroy() { if (renderTargets != null) { foreach (var rt in renderTargets) { if (rt != null) { rt.Release(); } } } } } ``` *** ## 3. 인스턴스드 렌더링 ### GPU 인스턴싱으로 드로우콜 최적화 ```c# public class WebGL2InstancedRenderer : MonoBehaviour { [Header("인스턴싱 설정")] public Mesh instanceMesh; public Material instanceMaterial; public int maxInstanceCount = 1000; [Header("앱인토스 최적화")] public bool enableAdaptiveInstancing = true; public bool enableFrustumCulling = true; public bool enableLODInstancing = true; // 인스턴스 데이터 private Matrix4x4[] instanceMatrices; private Vector4[] instanceColors; private float[] instanceLODs; private MaterialPropertyBlock propertyBlock; // WebGL 2.0 전용 버퍼 private ComputeBuffer matrixBuffer; private ComputeBuffer colorBuffer; private ComputeBuffer argsBuffer; void Start() { #if UNITY_WEBGL if (WebGL2Detector.IsWebGL2Supported()) { SetupInstancedRendering(); } else { SetupFallbackInstancing(); } #endif } void SetupInstancedRendering() { // 앱인토스 환경에 맞는 인스턴스 수 조정 if (enableAdaptiveInstancing) { AdjustInstanceCountForDevice(); } // 데이터 배열 초기화 InitializeInstanceData(); // WebGL 2.0 버퍼 생성 CreateInstanceBuffers(); // 머티리얼 설정 SetupInstanceMaterial(); Debug.Log($"WebGL 2.0 인스턴스드 렌더링 설정: {maxInstanceCount}개 인스턴스"); } void AdjustInstanceCountForDevice() { var capabilities = WebGL2Detector.GetWebGLCapabilities(); // GPU 메모리 기반 인스턴스 수 조정 int gpuMemoryMB = SystemInfo.graphicsMemorySize; if (gpuMemoryMB < 512) // 저사양 GPU { maxInstanceCount = 250; } else if (gpuMemoryMB < 1024) // 중사양 GPU { maxInstanceCount = 500; } // 고사양은 기본값 1000 유지 Debug.Log($"GPU 메모리 ({gpuMemoryMB}MB)에 따른 인스턴스 수 조정: {maxInstanceCount}"); } void InitializeInstanceData() { instanceMatrices = new Matrix4x4[maxInstanceCount]; instanceColors = new Vector4[maxInstanceCount]; instanceLODs = new float[maxInstanceCount]; // 초기 인스턴스 위치 및 속성 설정 for (int i = 0; i < maxInstanceCount; i++) { // 랜덤 위치 생성 Vector3 position = new Vector3( UnityEngine.Random.Range(-50f, 50f), 0, UnityEngine.Random.Range(-50f, 50f) ); instanceMatrices[i] = Matrix4x4.TRS(position, Quaternion.identity, Vector3.one); instanceColors[i] = new Vector4( UnityEngine.Random.value, UnityEngine.Random.value, UnityEngine.Random.value, 1.0f ); instanceLODs[i] = CalculateLODLevel(position); } propertyBlock = new MaterialPropertyBlock(); } float CalculateLODLevel(Vector3 position) { if (!enableLODInstancing) return 1.0f; Camera cam = Camera.main; if (cam == null) return 1.0f; float distance = Vector3.Distance(cam.transform.position, position); if (distance < 20f) return 1.0f; // 고품질 else if (distance < 50f) return 0.7f; // 중품질 else return 0.4f; // 저품질 } void CreateInstanceBuffers() { // 변환 행렬 버퍼 matrixBuffer = new ComputeBuffer(maxInstanceCount, 16 * sizeof(float)); // Matrix4x4 matrixBuffer.SetData(instanceMatrices); // 색상 버퍼 colorBuffer = new ComputeBuffer(maxInstanceCount, 4 * sizeof(float)); // Vector4 colorBuffer.SetData(instanceColors); // 간접 렌더링 인수 버퍼 uint[] args = new uint[5] { 0, 0, 0, 0, 0 }; args[0] = (uint)instanceMesh.GetIndexCount(0); // 인덱스 수 args[1] = (uint)maxInstanceCount; // 인스턴스 수 args[2] = (uint)instanceMesh.GetIndexStart(0); // 시작 인덱스 args[3] = (uint)instanceMesh.GetBaseVertex(0); // 기본 버텍스 argsBuffer = new ComputeBuffer(1, args.Length * sizeof(uint), ComputeBufferType.IndirectArguments); argsBuffer.SetData(args); } void SetupInstanceMaterial() { if (instanceMaterial != null) { // WebGL 2.0 인스턴싱 키워드 활성화 instanceMaterial.EnableKeyword("WEBGL2_INSTANCING"); instanceMaterial.EnableKeyword("APPS_IN_TOSS_OPTIMIZED"); // 버퍼를 머티리얼에 바인딩 instanceMaterial.SetBuffer("_InstanceMatrices", matrixBuffer); instanceMaterial.SetBuffer("_InstanceColors", colorBuffer); // 앱인토스 특화 설정 instanceMaterial.SetFloat("_AppsInTossQuality", GetCurrentQualityLevel()); } } float GetCurrentQualityLevel() { // 현재 앱인토스 성능에 따른 품질 레벨 반환 var perfMonitor = AppsInTossPerformanceMonitor.Instance; if (perfMonitor != null) { var currentPerf = perfMonitor.GetCurrentPerformance(); if (currentPerf != null) { if (currentPerf.fps >= 45) return 1.0f; // 고품질 else if (currentPerf.fps >= 25) return 0.7f; // 중품질 else return 0.4f; // 저품질 } } return 0.7f; // 기본값 } void Update() { if (instanceMaterial == null || argsBuffer == null) return; // 프러스텀 컬링 (옵션) if (enableFrustumCulling) { UpdateVisibleInstances(); } // 인스턴스 데이터 업데이트 UpdateInstanceData(); // GPU 인스턴스드 렌더링 실행 Graphics.DrawMeshInstancedIndirect( instanceMesh, 0, instanceMaterial, new Bounds(Vector3.zero, new Vector3(100, 100, 100)), argsBuffer, 0, propertyBlock ); } void UpdateVisibleInstances() { Camera cam = Camera.main; if (cam == null) return; Plane[] frustumPlanes = GeometryUtility.CalculateFrustumPlanes(cam); int visibleCount = 0; for (int i = 0; i < maxInstanceCount; i++) { Vector3 position = instanceMatrices[i].GetColumn(3); if (GeometryUtility.TestPlanesAABB(frustumPlanes, new Bounds(position, Vector3.one))) { // 보이는 인스턴스만 렌더링 배열 앞쪽으로 이동 if (visibleCount != i) { instanceMatrices[visibleCount] = instanceMatrices[i]; instanceColors[visibleCount] = instanceColors[i]; } visibleCount++; } } // 렌더링할 인스턴스 수 업데이트 uint[] args = new uint[5]; argsBuffer.GetData(args); args[1] = (uint)visibleCount; argsBuffer.SetData(args); } void UpdateInstanceData() { // 동적 인스턴스 데이터 업데이트 (필요한 경우) bool needsUpdate = false; // LOD 레벨 업데이트 if (enableLODInstancing) { for (int i = 0; i < maxInstanceCount; i++) { Vector3 position = instanceMatrices[i].GetColumn(3); float newLOD = CalculateLODLevel(position); if (Mathf.Abs(instanceLODs[i] - newLOD) > 0.1f) { instanceLODs[i] = newLOD; needsUpdate = true; } } } // 버퍼 업데이트 (필요한 경우만) if (needsUpdate) { matrixBuffer.SetData(instanceMatrices); colorBuffer.SetData(instanceColors); } } void SetupFallbackInstancing() { Debug.Log("WebGL 2.0 미지원 - 기본 인스턴싱 사용"); // WebGL 1.0 호환 인스턴싱 (제한적) maxInstanceCount = 100; // 성능상 제한 InitializeInstanceData(); } void OnDestroy() { // 버퍼 정리 matrixBuffer?.Release(); colorBuffer?.Release(); argsBuffer?.Release(); } // 성능 통계 void OnGUI() { if (Application.isEditor) { GUILayout.BeginArea(new Rect(10, 100, 300, 100)); GUILayout.Label($"WebGL 2.0 인스턴싱 통계:"); GUILayout.Label($"인스턴스 수: {maxInstanceCount}"); GUILayout.Label($"메시: {instanceMesh?.name}"); GUILayout.Label($"GPU 메모리: {SystemInfo.graphicsMemorySize}MB"); GUILayout.EndArea(); } } } ``` *** ## 4. 고급 셰이더 기능 ### GLSL ES 3.0 활용 ```hlsl // AppsInToss_WebGL2_Advanced.shader Shader "AppsInToss/WebGL2/Advanced" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) _Metallic ("Metallic", Range(0,1)) = 0.0 _Smoothness ("Smoothness", Range(0,1)) = 0.5 [Header(Apps In Toss Optimization)] _QualityLevel ("Quality Level", Range(0,1)) = 1.0 _PerformanceMode ("Performance Mode", Float) = 0 } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } LOD 300 Pass { Name "FORWARD" Tags { "LightMode"="ForwardBase" } CGPROGRAM #pragma vertex vert #pragma fragment frag // WebGL 2.0 전용 기능 활성화 #pragma target 3.0 #pragma require webgl2 // 앱인토스 최적화 키워드 #pragma shader_feature APPS_IN_TOSS_OPTIMIZED #pragma shader_feature WEBGL2_MRT #pragma shader_feature WEBGL2_INSTANCING #pragma shader_feature MOBILE_OPTIMIZED // 인스턴싱 지원 #pragma multi_compile_instancing #include "UnityCG.cginc" #include "Lighting.cginc" #include "AutoLight.cginc" struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; SHADOW_COORDS(3) UNITY_VERTEX_OUTPUT_STEREO }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; half _Metallic; half _Smoothness; // 앱인토스 최적화 변수 half _QualityLevel; half _PerformanceMode; // WebGL 2.0 전용 Uniform Buffer Object #ifdef WEBGL2_UBO layout(std140) uniform AppsInTossSettings { float4 globalTint; float4 lightingSettings; float2 screenParams; float qualityScale; float performanceLevel; }; #endif v2f vert(appdata v) { v2f o; UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; TRANSFER_SHADOW(o); return o; } fixed4 frag(v2f i) : SV_Target { // 앱인토스 성능 모드에 따른 적응적 품질 half qualityFactor = lerp(0.5, 1.0, _QualityLevel); // 기본 텍스처 샘플링 fixed4 albedo = tex2D(_MainTex, i.uv) * _Color; #ifdef APPS_IN_TOSS_OPTIMIZED // 앱인토스 최적화: 거리 기반 품질 조정 float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos); float distance = length(_WorldSpaceCameraPos - i.worldPos); // 거리에 따른 디테일 감소 half distanceFactor = saturate(1.0 - distance / 100.0); qualityFactor *= distanceFactor; // 성능 모드가 활성화된 경우 간소화된 라이팅 if (_PerformanceMode > 0.5) { // 간단한 Lambert 라이팅 half NdotL = dot(i.worldNormal, _WorldSpaceLightPos0.xyz); half lambert = saturate(NdotL); fixed3 lighting = lambert * _LightColor0.rgb; albedo.rgb *= lighting; return albedo; } #endif // 고품질 PBR 라이팅 (품질에 따라 조정) half3 worldNormal = normalize(i.worldNormal); half3 worldView = normalize(_WorldSpaceCameraPos - i.worldPos); half3 worldLight = normalize(_WorldSpaceLightPos0.xyz); // 정반사 half3 specular = pow(saturate(dot(reflect(-worldLight, worldNormal), worldView)), lerp(8, 32, _Smoothness * qualityFactor)) * _Metallic; // 그림자 UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos); // 최종 색상 계산 fixed3 lighting = saturate(dot(worldNormal, worldLight)) * _LightColor0.rgb * atten; lighting += specular * qualityFactor; albedo.rgb *= lighting; #ifdef WEBGL2_MRT // Multiple Render Targets 출력 (WebGL 2.0 전용) // 이 부분은 MRT 패스에서만 사용됨 #endif return albedo; } ENDCG } // 그림자 패스 Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 3.0 #pragma multi_compile_shadowcaster #pragma multi_compile_instancing #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct v2f { V2F_SHADOW_CASTER; UNITY_VERTEX_OUTPUT_STEREO }; v2f vert(appdata v) { v2f o; UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) return o; } fixed4 frag(v2f i) : SV_Target { SHADOW_CASTER_FRAGMENT(i) } ENDCG } } // WebGL 1.0 호환성을 위한 폴백 FallBack "Mobile/Diffuse" } ``` WebGL 2.0의 고급 기능을 활용하면 앱인토스 미니앱의 렌더링 성능과 품질을 크게 높일 수 있어요.\ MRT, 인스턴싱, 고급 셰이더 기능을 통해 한층 더 완성도 높은 웹 게임 경험을 만들어보세요. --- --- url: 'https://developers-apps-in-toss.toss.im/tutorials/webview.md' description: 앱인토스 미니앱을 WebView로 개발 시작할 때 사용하는 튜토리얼입니다. WebView로 프로젝트를 스캐폴딩하는 방법들을 담고 있습니다. --- # WebView ::: details 새 웹 프로젝트를 시작하시나요? 이 가이드에서는 이해를 돕기 위해 **Vite(React + TypeScript)** 기준으로 설명합니다.\ 다른 빌드 환경을 사용하셔도 괜찮아요. :::code-group ```bash[npm] npm create vite@latest {project명} -- --template react-ts cd {project명} npm install npm run dev ``` ```bash[yarn] yarn create vite {project명} --template react-ts cd {project명} yarn yarn dev ``` ```bash[pnpm] pnpm create vite@latest {project명} --template react-ts cd {project명} pnpm install pnpm dev ``` 기존 웹 서비스가 이미 있으시다면, 아래 가이드에 따라 `@apps-in-toss/web-framework`를 설치해주세요. ::: 기존 웹 프로젝트에 `@apps-in-toss/web-framework`를 설치하면 앱인토스 샌드박스에서 바로 개발하고 배포할 수 있어요. ## 설치하기 기존 웹 프로젝트에 아래 명령어 중 사용하는 패키지 매니저에 맞는 명령어를 실행하세요. ::: code-group ```sh [npm] npm install @apps-in-toss/web-framework ``` ```sh [pnpm] pnpm install @apps-in-toss/web-framework ``` ```sh [yarn] yarn add @apps-in-toss/web-framework ``` ::: ## 환경 구성하기 `ait init` 명령어를 실행해 환경을 구성할 수 있어요. 1. `ait init` 명령어를 실행하세요. ::: code-group ```sh [npm] npx ait init ``` ```sh [pnpm] pnpm ait init ``` ```sh [yarn] yarn ait init ``` ::: ::: tip Cannot set properties of undefined (setting 'dev') 오류가 발생한다면? package.json scripts 필드의 dev 필드에, 원래 사용하던 번들러의 개발 모드를 띄우는 커맨드를 입력 후 다시 시도해주세요. ::: 2. `web-framework`를 선택하세요. 3. 앱 이름(`appName`)을 입력하세요. ::: tip appName 입력 시 주의하세요 * 이 이름은 앱인토스 콘솔에서 앱을 만들 때 사용한 이름과 같아야 해요. * 앱 이름은 각 앱을 식별하는 **고유한 키**로 사용돼요. * appName은 `intoss://{appName}/path` 형태의 딥링크 경로나 테스트·배포 시 사용하는 앱 전용 주소 등에서도 사용돼요. * 샌드박스 앱에서 테스트할 때도 `intoss://{appName}`으로 접근해요.\ 단, 출시하기 메뉴의 QR 코드로 테스트할 때는 `intoss-private://{appName}`이 사용돼요. ::: 4. 웹 번들러의 dev 명령어를 입력해주세요. 5. 웹 번들러의 build 명령어를 입력해주세요. 6. 웹 개발 서버에서 사용할 포트 번호를 입력하세요. ### 설정 파일 확인하기 설정을 완료하면 설정 파일인 `granite.config.ts` 파일이 생성돼요.\ 자세한 설정 방법은 [공통 설정](/bedrock/reference/framework/UI/Config.html) 문서를 확인해 주세요. ::: code-group ```ts [granite.config.ts] import { defineConfig } from '@apps-in-toss/web-framework/config'; export default defineConfig({ appName: 'ping-pong', // 앱인토스 콘솔에서 설정한 앱 이름 brand: { displayName: '%%appName%%', // 화면에 노출될 앱의 한글 이름으로 바꿔주세요. primaryColor: '#3182F6', // 화면에 노출될 앱의 기본 색상으로 바꿔주세요. icon: null, // 화면에 노출될 앱의 아이콘 이미지 주소로 바꿔주세요. }, web: { host: 'localhost', // 앱 내 웹뷰에 사용될 host port: 5173, commands: { dev: 'vite', // 개발 모드 실행 (webpack serve도 가능) build: 'vite build', // 빌드 명령어 (webpack도 가능) }, }, permissions: [], }); ``` ::: * `brand`: 앱 브랜드와 관련된 구성이에요. * `displayName`: 브릿지 뷰에 표시할 앱 이름이에요. * `icon`: 앱 아이콘 이미지 주소예요. 사용자에게 앱 브랜드를 전달해요. * `primaryColor`: Toss 디자인 시스템(TDS) 컴포넌트에서 사용할 대표 색상이에요. RGB HEX 형식(eg. `#3182F6`)으로 지정해요. * `web.commands.dev` 필드는 `granite dev` 명령어 실행 시 함께 실행할 명령어예요. 번들러의 개발 모드를 시작하는 명령어를 입력해주세요. * `web.commands.build` 필드는 `granite build` 명령어 실행 시 함께 실행할 명령어예요. 번들러의 빌드 명령어를 입력해주세요. * `webViewProps.type` 옵션에는 아래 세 가지 값 중 하나를 설정할 수 있어요. * `partner`: 파트너사 콘텐츠에 사용하는 기본 웹뷰예요. 다른 값을 설정하지 않으면 이 값이 기본으로 사용돼요. - `game`: 전체 화면을 사용하는 게임 콘텐츠처럼, 가득 찬 영역이 필요한 경우 사용해요. ::: tip 웹 빌드 시 주의사항 `granite build`를 실행하면 `web.commands.build`가 실행되고, 이 과정에서 생성된 결과물을 바탕으로 `.ait` 파일을 만들어요. `web.commands.build`의 결과물은 `granite.config.ts`의 `outdir` 경로와 같아야 해요. `outdir`의 기본값은 프로젝트 경로의 `dist` 폴더지만, 필요하면 `granite.config.ts`에서 수정할 수 있어요. 만약 빌드 결과물이 `outdir`과 다른 경로에 저장되면 배포가 정상적으로 이루어지지 않을 수 있으니 주의하세요. ::: ### WebView TDS 패키지 설치하기 **TDS (Toss Design System)** 패키지는 웹뷰 기반 미니앱이 일관된 UI/UX를 유지하도록 돕는 토스의 디자인 시스템이에요.\ `@apps-in-toss/web-framework`를 사용하려면 TDS WebView 패키지를 추가로 설치해야 해요.\ 모든 비게임 WebView 미니앱은 TDS 사용이 필수이며, 검수 승인 기준에도 포함돼요. | @apps-in-toss/web-framework 버전 | 사용할 패키지 | | -------------------------------- | -------------------------- | | < 1.0.0 | @toss-design-system/mobile | | >= 1.0.0 | @toss/tds-mobile | TDS에 대한 자세한 가이드는 [WebView TDS](https://tossmini-docs.toss.im/tds-mobile/)를 참고해주세요. ## 서버 실행하기 ### 로컬 개발 서버 실행하기 로컬 개발 서버를 실행하면 웹 개발 서버와 React Native 개발 서버가 함께 실행돼요. 웹 개발 서버는 `granite.config.ts` 파일의 `web.commands.dev` 필드에 설정한 명령어를 사용해 실행돼요. 또, HMR(Hot Module Replacement)을 지원해서 코드 변경 사항을 실시간으로 반영할 수 있어요. 다음은 개발 서버를 실행하는 명령어에요. Granite으로 스캐폴딩된 서비스는 `dev` 스크립트를 사용해서 로컬 서버를 실행할 수 있어요. 서비스의 루트 디렉터리에서 아래 명령어를 실행해 주세요. ::: code-group ```sh [npm] npm run dev ``` ```sh [pnpm] pnpm run dev ``` ```sh [yarn] yarn dev ``` 명령어를 실행하면 아래와 같은 화면이 표시돼요. ![Metro 실행 예시](/assets/local-develop-js-1.B_LK2Zlw.png) ::: tip 실행 혹은 빌드시 '\[Apps In Toss Plugin] 플러그인 옵션이 올바르지 않습니다' 에러가 발생한다면? '\[Apps In Toss Plugin] 플러그인 옵션이 올바르지 않습니다. granite.config.ts 구성을 확인해주세요.'\ 라는 메시지가 보인다면, `granite.config.ts`의 `icon` 설정을 확인해주세요.\ 아이콘을 아직 정하지 않았다면 ''(빈 문자열)로 비워둔 상태로도 테스트할 수 있어요. ```ts ... displayName: 'test-app', // 화면에 노출될 앱의 한글 이름으로 바꿔주세요. primaryColor: '#3182F6', // 화면에 노출될 앱의 기본 색상으로 바꿔주세요. icon: '',// 화면에 노출될 앱의 아이콘 이미지 주소로 바꿔주세요. ... ``` ::: ### 개발 서버를 실기기에서 접근 가능하게 설정하기 실기기에서 테스트하려면 번들러를 실행할 때 `--host` 옵션을 활성화하고, `web.host`를 실 기기에서 접근할 수 있는 네트워크 주소로 설정해야 해요. ```ts [granite.config.ts] import { defineConfig } from '@apps-in-toss/web-framework/config'; export default defineConfig({ appName: 'ping-pong', web: { host: '192.168.0.100', // 실 기기에서 접근할 수 있는 IP 주소로 변경 port: 5173, commands: { dev: 'vite --host', // --host 옵션 활성화 build: 'vite build', }, }, permissions: [], }); ``` ## 미니앱 실행하기(시뮬레이터·실기기) :::info 준비가 필요해요 미니앱은 샌드박스 앱을 통해서만 실행되기때문에 **샌드박스 앱(테스트앱)** 설치가 필수입니다.\ 개발 및 테스트를 위해 [샌드박스앱](/development/test/sandbox)을 설치해주세요. ::: ### iOS 시뮬레이터(샌드박스앱)에서 실행하기 1. **앱인토스 샌드박스 앱**을 실행해요. 2. 샌드박스 앱에서 스킴을 실행해요. 예를 들어 서비스 이름이 `kingtoss`라면, `intoss://kingtoss`를 입력하고 "스키마 열기" 버튼을 눌러주세요. 아래는 로컬 서버를 실행한 후, iOS 시뮬레이터의 샌드박스앱에서 서버에 연결하는 예시예요. ### iOS 실기기에서 실행하기 ### 서버 주소 입력하기 아이폰에서 **앱인토스 샌드박스 앱**을 실행하려면 로컬 서버와 같은 와이파이에 연결되어 있어야 해요. 아래 단계를 따라 설정하세요. 1. **샌드박스 앱**을 실행하면 **"로컬 네트워크" 권한 요청 메시지**가 표시돼요. 이때 **"허용"** 버튼을 눌러주세요. 2) **샌드박스 앱**에서 서버 주소를 입력하는 화면이 나타나요. 3) 컴퓨터에서 로컬 서버 IP 주소를 확인하고, 해당 주소를 입력한 뒤 저장해주세요. * IP 주소는 한 번 저장하면 앱을 다시 실행해도 변경되지 않아요. * macOS를 사용하는 경우, 터미널에서 `ipconfig getifaddr en0` 명령어로 로컬 서버의 IP 주소를 확인할 수 있어요. 4) **"스키마 열기"** 버튼을 눌러주세요. 5) 화면 상단에 `Bundling {n}%...` 텍스트가 표시되면 로컬 서버에 성공적으로 연결된 거예요. ::: details "로컬 네트워크"를 수동으로 허용하는 방법 **"로컬 네트워크" 권한을 허용하지 못한 경우, 아래 방법으로 수동 설정이 가능해요.** 1. 아이폰의 \[설정] 앱에서 **"앱인토스"** 를 검색해 이동해요. 2. **"로컬 네트워크"** 옵션을 찾아 켜주세요. ::: *** ### Android 실기기 또는 에뮬레이터 연결하기 1. Android 실기기(휴대폰 또는 태블릿)를 컴퓨터와 USB로 연결하세요. ([USB 연결 가이드](/development/client/android.html#기기-연결하기)) 2. `adb` 명령어를 사용해서 `8081` 포트와 `5173`포트를 연결하고 연결 상태를 확인해요. **8081 포트, 5173 포트 연결하기** 기기가 하나만 연결되어 있다면 아래 명령어만 실행해도 돼요. ```shell adb reverse tcp:8081 tcp:8081 adb reverse tcp:5173 tcp:5173 ``` 특정 기기를 연결하려면 `-s` 옵션과 디바이스 아이디를 추가해요. ```shell adb -s {디바이스아이디} reverse tcp:8081 tcp:8081 # 예시: adb -s R3CX30039GZ reverse tcp:8081 tcp:8081 adb -s {디바이스아이디} reverse tcp:5173 tcp:5173 # 예시: adb -s R3CX30039GZ reverse tcp:5173 tcp:5173 ``` **연결 상태 확인하기** 연결된 기기와 포트를 확인하려면 아래 명령어를 사용하세요. ```shell adb reverse --list # 연결된 경우 예시: UsbFfs tcp:8081 tcp:8081 ``` 특정 기기를 확인하려면 `-s` 옵션을 추가해요. ```shell adb -s {디바이스아이디} reverse --list # 예시: adb -s R3CX30039GZ reverse --list # 연결된 경우 예시: UsbFfs tcp:8081 tcp:8081 ``` 3. **앱인토스 샌드박스 앱**에서 스킴을 실행하세요. 예를 들어, 서비스 이름이 `kingtoss`라면 `intoss://kingtoss`를 입력하고 실행 버튼을 누르세요. 아래는 Android 시뮬레이터에서 로컬 서버를 연결한 후 서비스를 표시하는 예시예요. ### 자주 쓰는 `adb` 명령어 (Android) 개발 중에 자주 쓰는 `adb` 명령어를 정리했어요. #### 연결 끊기 ```shell adb kill-server ``` #### 8081 포트 연결하기 ```shell adb reverse tcp:8081 tcp:8081 adb reverse tcp:5173 tcp:5173 # 특정 기기 연결: adb -s {디바이스아이디} reverse tcp:8081 tcp:8081 ``` #### 연결 상태 확인하기 ```shell adb reverse --list # 특정 기기 확인: adb -s {디바이스아이디} reverse --list ``` ### 트러블슈팅 ::: details Q. `서버에 연결할 수 없습니다` 에러가 발생해요. `granite.config.ts` 의 `web.commands`에 '--host'를 추가 후, 서비스를 실행하여 어떤 호스트 주소로 서비스가 실행되는지 확인해요 ```tsx // granite.config.ts web: { ... commands: { dev: 'vite --host', // --host를 추가해요. build: 'tsc -b && vite build', }, ... }, ``` '--host' 추가 후, 서비스를 실행하여 주소를 확인해요 ```tsx // granite.config.ts web: { host: 'x.x.x.x', // 서비스가 실행되는 호스트 주소를 입력해요. ... }, ``` 샌드박스 앱에서 서비스 실행 전, metro 서버 주소도 호스트 주소로 변경해주세요. ::: ::: details Q. Metro 개발 서버가 열려 있는데 `잠시 문제가 생겼어요`라는 메시지가 표시돼요. 개발 서버에 제대로 연결되지 않은 문제일 수 있어요. `adb` 연결을 끊고 다시 `8081` 포트를 연결하세요. ::: ::: details Q. PC웹에서 Not Found 오류가 발생해요. 8081 포트는 샌드박스 내에서 인식하기 위한 포트예요.\ PC웹에서 8081 포트는 Not Found 오류가 발생해요. ::: ## 토스앱에서 테스트하기 토스앱에서 테스트하는 방법은 [토스앱](/development/test/toss) 문서를 참고하세요. ## 출시하기 출시하는 방법은 [미니앱 출시](/development/deploy) 문서를 참고하세요. --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/속성 제어/webview-props.md --- # WebView의 속성 제어하기 웹으로 개발한 서비스는 내부적으로 WebView가 사용돼요. WebView의 설정을 변경하려면 `granite.config.ts` 파일에서 `webViewProps` 속성을 설정하면 돼요. 이를 활용하면 WebView의 동작을 조정하고 사용자 경험을 원하는 방식으로 제어할 수 있어요. ## 사용 가능한 WebView 속성 `webViewProps`에서 설정할 수 있는 주요 속성은 다음과 같아요. ### `allowsInlineMediaPlayback` HTML5 동영상을 WebView 내에서 전체 화면이 아니라 인라인으로 재생할지 설정해요. iOS 전용 속성이에요. 이 값을 `true`로 설정하고 HTML 문서 내 `
); } ``` ```tsx [React Native] import { GoogleAdMob } from '@apps-in-toss/framework'; import { useFocusEffect } from '@granite-js/native/@react-navigation/native'; import { useNavigation } from '@granite-js/react-native'; import { useCallback, useState } from 'react'; import { Button, Text, View } from 'react-native'; const AD_GROUP_ID = ''; export function GoogleAdmobExample() { const [adLoadStatus, setAdLoadStatus] = useState<'not_loaded' | 'loaded' | 'failed'>('not_loaded'); const navigation = useNavigation(); const loadAd = useCallback(() => { if (GoogleAdMob.loadAppsInTossAdMob.isSupported() !== true) { return; } const cleanup = GoogleAdMob.loadAppsInTossAdMob({ options: { adGroupId: AD_GROUP_ID, }, onEvent: (event) => { switch (event.type) { case 'loaded': console.log('광고 로드 성공', event.data); setAdLoadStatus('loaded'); cleanup(); break; } }, onError: (error) => { console.error('광고 불러오기 실패', error); cleanup?.(); }, }); }, [navigation]); const showAd = useCallback(() => { if (GoogleAdMob.showAppsInTossAdMob.isSupported() !== true) { return; } GoogleAdMob.showAppsInTossAdMob({ options: { adGroupId: AD_GROUP_ID, }, onEvent: (event) => { switch (event.type) { case 'show': console.log('광고 컨텐츠 보여졌음'); break; case 'requested': console.log('광고 보여주기 요청 완료'); break; case 'impression': console.log('광고 노출'); break; case 'clicked': console.log('광고 클릭'); break; case 'userEarnedReward': // 보상형 광고만 사용 가능 console.log('광고 보상 획득 unitType:', event.data.unitType); console.log('광고 보상 획득 unitAmount:', event.data.unitAmount); break; case 'dismissed': console.log('광고 닫힘'); navigation.navigate('/examples/google-admob-interstitial-ad-landing'); break; case 'failedToShow': console.log('광고 보여주기 실패'); break; } }, onError: (error) => { console.error('광고 보여주기 실패', error); }, }); }, []); useFocusEffect(loadAd); return ( {adLoadStatus === 'not_loaded' && '광고 로드 하지 않음 '} {adLoadStatus === 'loaded' && '광고 로드 완료'} {adLoadStatus === 'failed' && '광고 로드 실패'} ); } ``` ```tsx [React Native] import { GoogleAdMob } from '@apps-in-toss/framework'; import { useCallback, useState } from 'react'; import { Button, Text, View } from 'react-native'; const AD_UNIT_ID = ''; function GoogleAdmobRewardedAdExample() { const [adLoadStatus, setAdLoadStatus] = useState<'not_loaded' | 'loaded' | 'failed'>('not_loaded'); const loadAd = useCallback(() => { if (GoogleAdMob.loadAdMobRewardedAd.isSupported() !== true) { return; } const cleanup = GoogleAdMob.loadAdMobRewardedAd({ options: { adUnitId: AD_UNIT_ID, }, onEvent: (event) => { console.log(event.type); switch (event.type) { case 'loaded': console.log('광고 로드 성공', event.data); setAdLoadStatus('loaded'); break; case 'clicked': console.log('광고 클릭'); break; case 'dismissed': console.log('광고 닫힘'); break; case 'failedToShow': console.log('광고 보여주기 실패'); break; case 'impression': console.log('광고 노출'); break; case 'show': console.log('광고 컨텐츠 보여졌음'); break; case 'userEarnedReward': console.log('사용자가 광고 시청을 완료했음'); break; } }, onError: (error) => { console.error('광고 불러오기 실패', error); }, }); return cleanup; }, []); const showAd = useCallback(() => { if (GoogleAdMob.showAdMobRewardedAd.isSupported() !== true) { return; } GoogleAdMob.showAdMobRewardedAd({ options: { adUnitId: AD_UNIT_ID, }, onEvent: (event) => { switch (event.type) { case 'requested': console.log('광고 보여주기 요청 완료'); setAdLoadStatus('not_loaded'); break; } }, onError: (error) => { console.error('광고 보여주기 실패', error); }, }); }, []); return ( {adLoadStatus === 'not_loaded' && '광고 로드 하지 않음 '} {adLoadStatus === 'loaded' && '광고 로드 완료'} {adLoadStatus === 'failed' && '광고 로드 실패'} ); } ``` ```tsx [React Native] import { GoogleAdMob } from '@apps-in-toss/framework'; import { useEffect } from 'react'; import { View, Text } from 'react-native'; const AD_GROUP_ID = ''; function GoogleAdmobExample() { useEffect(() => { if (GoogleAdMob.loadAppsInTossAdMob.isSupported() !== true) { return; } const cleanup = GoogleAdMob.loadAppsInTossAdMob({ options: { adGroupId: AD_GROUP_ID, }, onEvent: (event) => { switch (event.type) { case 'loaded': console.log('광고 로드 성공', event.data); cleanup(); break; } }, onError: (error) => { console.error('광고 불러오기 실패', error); cleanup?.(); }, }); }, []); return ( Page ); } ``` ::: ## `LoadAdMobParams` `LoadAdMobParams` 는 광고를 불러오는 함수에 필요한 옵션 객체예요. ### 시그니처 ```typescript type LoadAdMobParams = AdMobHandlerParams; ``` ## `LoadAdMobEvent` `LoadAdMobEvent` 는 광고를 불러오는 함수에서 발생하는 이벤트 타입이에요. `loaded` 이벤트가 발생하면 광고를 성공적으로 불러온 거예요. ### 시그니처 ```typescript type LoadAdMobEvent = AdMobFullScreenEvent | { type: 'loaded'; }; ``` --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/광고/LoadAdMobOptions.md --- # 광고 옵션 객체 ## `LoadAdMobParams` `LoadAdMobParams` 는 광고를 불러오는 함수에 필요한 옵션 객체예요. ## 시그니처 ```typescript type LoadAdMobParams = AdMobHandlerParams; ``` --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/광고/ShowAdMobParams.md --- # 광고 옵션 객체 ## `ShowAdMobParams` `ShowAdMobParams` 는 불러온 광고를 보여주는 함수에 필요한 옵션 객체예요. ## 시그니처 ```typescript type ShowAdMobParams = AdMobHandlerParams; ``` --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/광고/LoadAdMobEvent.md --- # 광고 이벤트 타입 ## `LoadAdMobEvent` `LoadAdMobEvent` 는 광고를 불러오는 함수에서 발생하는 이벤트 타입이에요. `loaded` 이벤트가 발생하면 광고를 성공적으로 불러온 거예요. ## 시그니처 ```typescript type LoadAdMobEvent = AdMobFullScreenEvent | { type: 'loaded'; }; ``` --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/광고/ShowAdMobEvent.md --- # 광고 이벤트 타입 ## `ShowAdMobEvent` `ShowAdMobEvent` 는 광고를 보여주는 함수에서 발생하는 이벤트 타입이에요. `requested` 이벤트가 발생하면 광고 노출 요청이 Google AdMob에 성공적으로 전달된 거예요. ## 시그니처 ```typescript type ShowAdMobEvent = | { type: 'requested' } | { type: 'clicked' } | { type: 'dismissed' } | { type: 'failedToShow' } | { type: 'impression' } | { type: 'show' } | { type: 'userEarnedReward'; // 보상형 광고만 사용 가능 data: { unitType: string; unitAmount: number; }; }; ``` --- --- url: 'https://developers-apps-in-toss.toss.im/unity/guide/recommend-engine.md' --- # 권장 Unity 엔진 버전 앱인토스 미니앱 전환 시 최적의 성능과 안정성을 위해 권장하는 Unity 엔진 버전 가이드예요. *** ## 1. 권장 버전 개요 ### 최우선 권장 (Latest Production Ready) ``` Unity 2023.3 LTS ├── 릴리스: 2024년 6월 ├── 지원 기간: 2027년까지 ├── AppsInToss 지원: 최신 최적화 & 신기능 완전 지원 ├── WebGL 성능: 최대 40% 향상 └── 안정성: ★★★★★ ``` ### 신규 권장 (Latest Features) ``` Unity 2024.2 LTS (예정) ├── 릴리스: 2024년 10월 (베타) ├── 지원 기간: 2028년까지 예정 ├── AppsInToss 지원: 실험적 지원 (베타) ├── AI 통합: Unity Muse & Sentis 완전 통합 └── 안정성: ★★★★☆ (베타 단계) ``` ### 안정적 최고 권장 (Proven Production Ready) ``` Unity 2022.3 LTS ├── 릴리스: 2023년 6월 ├── 지원 기간: 2025년까지 ├── AppsInToss 지원: 완전 최적화 └── 안정성: ★★★★★ ``` ### 권장 (Stable) ``` Unity 2021.3 LTS ├── 릴리스: 2022년 4월 ├── 지원 기간: 2024년까지 ├── AppsInToss 지원: 완전 지원 └── 안정성: ★★★★☆ ``` *** ## 2. 버전별 상세 분석 ### Unity 2023.3 LTS (최우선 권장) #### 주요 장점 * Unity 6 기반: 완전히 새로운 아키텍처와 성능 향상 * Enhanced WebGL: 최대 40% 빠른 런타임 성능 * Universal Render Pipeline 17: 차세대 렌더링 기능 * Unity Netcode for GameObjects: 내장된 멀티플레이어 지원 * Entity Component System (ECS) 1.0: 고성능 데이터 지향 설계 * Unity Sentis: 온디바이스 AI 추론 엔진 * WebAssembly Multi-threading: 브라우저 멀티스레딩 지원 향상 * Improved Memory Management: 가비지 컬렉션 최적화 #### 최적화 ```c# // Unity 2023.3에서 지원하는 최신 최적화 기능들 var buildPlayerOptions = new BuildPlayerOptions { scenes = scenes, locationPathName = outputPath, target = BuildTarget.WebGL, options = BuildOptions.None, // Unity 6 전용 최적화 옵션들 extraScriptingDefines = new[] { "APPSINTOS_OPTIMIZED", "WEBGL_2_0", "UNITY_6_FEATURES", "ECS_ENABLED", "SENTIS_AVAILABLE" } }; // 새로운 WebGL 설정 PlayerSettings.WebGL.compressionFormat = WebGLCompressionFormat.Brotli; PlayerSettings.WebGL.webAssemblyArithmeticExceptions = false; PlayerSettings.WebGL.wasmStreaming = true; // 새로운 스트리밍 기능 ``` #### 성능 벤치마크 (vs 2022.3) | 메트릭 | Unity 2023.3 | Unity 2022.3 | 개선율 | |:--|:--:|:--:|:--:| | 빌드 시간 | 32초 | 45초 | +29% | | 시작 시간 | 1.6초 | 2.1초 | +24% | | 메모리 사용량 | 68MB | 85MB | +20% | | FPS (모바일) | 72 FPS | 55 FPS | +31% | | 렌더링 성능 | +40% | 기준 | +40% | #### Unity 6 전용 기능 * GPU Resident Drawer: 렌더링 성능 극대화 * Spatial Audio: 3D 오디오 최적화 * Web Platform Support: 향상된 브라우저 호환성 * Unity Cloud Build: 클라우드 빌드 최적화 ### Unity 2024.2 LTS (실험적 지원) #### 주요 장점 * Unity Muse 통합: AI 기반 콘텐츠 생성 * Advanced Graphics Features: 차세대 그래픽 효과 * Enhanced Multiplayer: 개선된 네트워킹 성능 * Better IDE Integration: Visual Studio Code 완전 지원 * Improved Package Manager: 의존성 관리 향상 * #### 주의사항 * 베타 단계이므로 프로덕션 사용 주의 * AppsInToss SDK와의 호환성 테스트 필요 * 일부 기능이 불안정할 수 있음 ### Unity 2022.3 LTS (안정적 검증된 선택) #### 주요 장점 * WebGL 2.0 완전 지원: 최신 그래픽 기능 활용 * IL2CPP 최적화: 향상된 성능과 코드 보안 * New Input System: 모바일 터치 입력 최적화 * Addressable Assets: 효율적인 리소스 관리 * URP 최적화: 모바일 렌더링 성능 향상 * Burst Compiler: 고성능 연산 최적화 ### 성능 벤치마크 | 메트릭 | Unity 2022.3 | Unity 2021.3 | 개선율 | |:--|:--:|:--:|:--:| | 빌드 시간 | 45초 | 60초 | +25% | | 시작 시간 | 2.1초 | 2.8초 | +25% | | 메모리 사용량 | 85MB | 105MB | +19% | | FPS (모바일) | 55 FPS | 45 FPS | +22% | ### Unity 2021.3 LTS (안정적 선택) #### 주요 장점 * 검증된 안정성: 2년간 검증된 LTS 버전 * WebGL 최적화: 우수한 WebGL 빌드 성능 * URP 지원: Universal Render Pipeline 완전 지원 * Package Manager: 안정적인 패키지 관리 * Timeline: 고급 시퀀싱 도구 * #### 제한사항 * 일부 최신 WebGL 기능 미지원 * New Input System 완전하지 않음 * 메모리 최적화가 2022.3 대비 제한적 ### Unity 2020.3 LTS (호환성 위주) #### 사용 가능한 경우 * 기존 프로젝트가 이미 2020.3에서 안정적으로 동작 * 써드파티 플러그인이 최신 버전을 지원하지 않음 * 개발팀이 버전 업그레이드에 대한 리스크를 피하고 싶은 경우 #### 한계점 * WebGL 2.0 지원 제한적 * 성능 최적화 기능 부족 * 보안 업데이트 제한적 * 새로운 AppsInToss 기능 지원 제한 *** ## 3. 버전 선택 가이드 ### 신규 프로젝트 (2024년 권장) ``` 새로운 게임 개발 ├── Unity 2023.3 LTS (1순위 권장) │ ├── Unity 6 최신 기능 활용 │ ├── 최대 40% 성능 향상 │ ├── 장기 지원 (2027년까지) │ └── AppsInToss 최신 최적화 ├── Unity 2022.3 LTS (안정적 선택) │ ├── 검증된 안정성 │ ├── 완전한 AppsInToss 지원 │ └── 보수적 개발팀 권장 └── Unity 2024.2 LTS (실험적, 얼리어답터) ├── 최신 AI 기능 ├── 차세대 그래픽 └── 베타 테스터 및 실험 프로젝트 ``` ### 기존 프로젝트 업그레이드 가이드 ``` 기존 게임 포팅 ├── 현재 Unity 2022.3 │ └── Unity 2023.3 LTS 업그레이드 권장 (성능 향상 40%) ├── 현재 Unity 2021.3 이상 │ ├── Unity 2023.3 LTS 업그레이드 (최우선) │ └── 또는 Unity 2022.3 유지 (안정적) ├── 현재 Unity 2020.3 │ └── Unity 2023.3 LTS 업그레이드 필수 (2단계 업그레이드) └── Unity 2019.4 이하 └── Unity 2023.3 LTS 마이그레이션 필수 (단계별) ``` ### 팀 상황별 권장사항 (2024 업데이트) ``` 대규모 팀 (엔터프라이즈) ├── Unity 2023.3 LTS (1순위) │ ├── 최대 성능 최적화 필요 │ ├── 충분한 테스트 리소스 확보 가능 │ └── 장기 지원 중요 ├── Unity 2022.3 LTS (대안) │ └── 보수적 접근 필요 시 중간 규모 팀 (스튜디오) ├── Unity 2023.3 LTS (권장) │ ├── 성능과 안정성 균형 │ ├── 점진적 업그레이드 가능 │ └── ROI 최적화 ├── Unity 2022.3 LTS (현실적 선택) │ └── 안정성 최우선 시 소규모/개인 개발 (인디) ├── Unity 2023.3 LTS (강력 권장) │ ├── 최신 기능으로 경쟁력 확보 │ ├── 빠른 개발 주기 지원 │ └── 성능 최적화 자동화 ├── Unity 2024.2 LTS (얼리어답터) │ └── 실험적 프로젝트 및 학습용 ``` *** ## 4. 업그레이드 가이드 ### Unity 2022.3 → 2023.3 업그레이드 (권장) #### 1단계: 사전 준비 ```bash # Git을 사용하는 경우 git checkout -b unity-upgrade-2023-3 git commit -am "Pre-upgrade backup for Unity 2023.3" ``` #### 2단계: Unity 2023.3 설치 * Unity Hub에서 Unity 2023.3 LTS 설치 * WebGL Build Support 모듈 포함 설치 * AppsInToss SDK 최신 버전 (v2.8+) 호환 확인 #### 3단계: 프로젝트 변환 ```c# // Unity 2023.3 전용 최적화 옵션 적용 #if UNITY_2023_3_OR_NEWER // Unity 6 기능 활용 PlayerSettings.WebGL.wasmStreaming = true; PlayerSettings.WebGL.powerPreference = WebGLPowerPreference.HighPerformance; // ECS 시스템 설정 #if ECS_ENABLED // ECS 기반 컴포넌트 최적화 #endif #endif ``` #### 4단계: 성능 최적화 설정 * Universal Render Pipeline 17 업그레이드 * Burst Compiler 최신 버전 적용 * Addressable Assets 최신 버전 업데이트 * Unity Sentis 설정 (AI 기능 사용 시) #### 5단계: AppsInToss SDK 업데이트 * SDK를 v2.8 이상으로 업데이트 * 새로운 Unity 6 기능 연동 설정 * 성능 모니터링 도구 업데이트 ### Unity 2019.4 → 2023.3 완전 마이그레이션 (권장) #### 1단계: 프로젝트 백업 ``` # Git을 사용하는 경우 git checkout -b unity-upgrade-2022-3 git commit -am "Pre-upgrade backup" ``` #### 2단계: 점진적 업그레이드 ``` 2019.4 → 2020.3 → 2021.3 → 2022.3 각 단계별로 빌드 테스트 및 문제 해결 ``` #### 3단계: API 업데이트 ```c# // 구버전 코드 #if UNITY_2019_4_OR_NEWER // Legacy code #endif // 신버전 코드 #if UNITY_2022_3_OR_NEWER // New optimized code #endif ``` #### 4단계: 설정 마이그레이션 * Player Settings 재검토 * Package Manager 업데이트 * Build Settings 최적화 * AppsInToss SDK 최신버전 설치 ### 일반적인 업그레이드 이슈 #### 스크립팅 API 변경 ```c# // Unity 2019.x WWW www = new WWW(url); yield return www; // Unity 2022.x using (UnityWebRequest www = UnityWebRequest.Get(url)) { yield return www.SendWebRequest(); } ``` #### 렌더링 파이프라인 변경 ```c# // Built-in → URP 마이그레이션 // 머티리얼 자동 변환 도구 사용 // 셰이더 호환성 검사 필요 ``` ## 5. 특수 상황별 권장사항 ### WebGL 2.0 필수 프로젝트 ``` 고품질 3D 게임 ├── Unity 2022.3 LTS 필수 ├── WebGL 2.0 전용 기능 활용 ├── Compute Shader 제한적 지원 └── 고급 셰이더 효과 ``` ### 레거시 플러그인 의존성 ``` 써드파티 플러그인 사용 ├── 플러그인 호환성 먼저 확인 ├── 업체에 업데이트 요청 ├── 대안 솔루션 검토 └── 필요시 커스텀 포팅 ``` ### 빠른 출시 일정 ``` 타이트한 개발 일정 ├── 현재 안정 버전 유지 ├── Unity 2021.3 LTS 권장 ├── 업그레이드는 차기 버전에서 └── 최소한의 변경으로 포팅 ``` *** ## 6. 성능 최적화 by 버전 ### Unity 2023.3 LTS 최적화 설정 (최우선 권장) ```c# // Unity 6 기반 최고 성능 최적화 PlayerSettings.WebGL.compressionFormat = WebGLCompressionFormat.Brotli; PlayerSettings.WebGL.memorySize = 1024; // 더 큰 메모리 풀 지원 PlayerSettings.WebGL.exceptionSupport = WebGLExceptionSupport.ExplicitlyThrownExceptionsOnly; PlayerSettings.WebGL.threadsSupport = true; // Unity 6에서 향상된 멀티스레딩 PlayerSettings.WebGL.wasmStreaming = true; // 스트리밍 최적화 PlayerSettings.WebGL.powerPreference = WebGLPowerPreference.HighPerformance; PlayerSettings.WebGL.webAssemblyArithmeticExceptions = false; // 성능 최적화 // Unity 6 전용 고급 설정 PlayerSettings.WebGL.showUnityLogo = false; PlayerSettings.WebGL.dataCaching = true; PlayerSettings.WebGL.debugSymbolMode = WebGLDebugSymbolMode.External; // ECS 최적화 (Unity 6) #if ECS_ENABLED PlayerSettings.SetScriptingDefineSymbolsForGroup(BuildTargetGroup.WebGL, "UNITY_6_FEATURES;ECS_ENABLED;BURST_OPTIMIZED"); #endif ``` ### Unity 2024.2 LTS 실험적 설정 ```c# // Unity Muse & AI 기능 통합 설정 PlayerSettings.WebGL.compressionFormat = WebGLCompressionFormat.Brotli; PlayerSettings.WebGL.memorySize = 1536; // AI 모델을 위한 더 큰 메모리 PlayerSettings.WebGL.wasmStreaming = true; PlayerSettings.WebGL.powerPreference = WebGLPowerPreference.HighPerformance; // AI 기능 활성화 (Unity Muse) #if UNITY_MUSE_AVAILABLE PlayerSettings.SetScriptingDefineSymbolsForGroup(BuildTargetGroup.WebGL, "UNITY_MUSE_ENABLED;AI_CONTENT_GENERATION"); #endif ``` ### Unity 2022.3 최적화 설정 (안정적 검증됨) ```c# // PlayerSettings 최적화 PlayerSettings.WebGL.compressionFormat = WebGLCompressionFormat.Brotli; PlayerSettings.WebGL.memorySize = 512; PlayerSettings.WebGL.exceptionSupport = WebGLExceptionSupport.ExplicitlyThrownExceptionsOnly; PlayerSettings.WebGL.threadsSupport = false; // 브라우저 호환성 ``` ### Unity 2021.3 최적화 설정 (호환성 위주) ```c# // 안전한 설정값들 PlayerSettings.WebGL.compressionFormat = WebGLCompressionFormat.Gzip; PlayerSettings.WebGL.memorySize = 256; PlayerSettings.WebGL.linkerTarget = WebGLLinkerTarget.Wasm; ``` *** ## 7. 버전 업그레이드 체크리스트 ### 사전 준비 * 프로젝트 전체 백업 * 현재 버전에서 완전한 빌드 성공 확인 * 사용 중인 Asset Store 플러그인 목록 작성 * 커스텀 셰이더 및 스크립트 검토 ### 업그레이드 후 검증 * 프로젝트 오류 없이 열림 * WebGL 빌드 성공 * AppsInToss SDK 정상 동작 * 게임 핵심 기능 테스트 * 성능 벤치마크 비교 * 모바일 환경 테스트 * ### 최적화 작업 * Build Settings 재설정 * Quality Settings 조정 * Graphics Settings 최적화 * Package 의존성 정리 *** ## 8. 지원 및 문제 해결 ### Unity 버전 관련 문제 ``` 일반적인 문제들 ├── 빌드 오류: API 변경사항 확인 ├── 성능 저하: 설정 재검토 필요 ├── 호환성 문제: 플러그인 업데이트 └── 기능 누락: 대안 구현 검토 ``` 앱인토스 SDK 호환성 (2024년 업데이트) * Unity 2023.3 LTS: 모든 기능 완전 지원 + Unity 6 신기능 최적화 * Unity 2024.2 LTS: 실험적 지원 (베타, AI 기능 포함) * Unity 2022.3 LTS: 모든 기능 완전 지원 (검증됨) * Unity 2021.3 LTS: 주요 기능 완전 지원 * Unity 2020.3 LTS: 기본 기능 지원 (제한적) * Unity 2019.4 이하: 제한적 지원 (업그레이드 필수) --- --- url: 'https://developers-apps-in-toss.toss.im/design/resources.md' description: '토스가 제공하는 그래픽 리소스 가이드입니다. 아이콘, 이모지, 스마트폰 목업, 일러스트레이션 등 그래픽 자산과 활용 방법을 확인하세요.' --- # {{ $frontmatter.title }} 토스에서 제공하는 그래픽 리소스와 올바른 활용 방법을 안내드려요. ::: tip 그래픽 저작권 안내 토스의 모든 그래픽 자산 및 토스트를 통해 생성된 그래픽은 「저작권법」 및 관련 법령에 따라 보호받는 ㈜비바리퍼블리카의 지식재산권입니다. 제공된 그래픽은 앱인토스 제휴 환경 내에서의 서비스 운영 및 홍보 목적으로만 사용할 수 있으며, 다른 서비스나 매체에서 복제·수정·배포·전송·공중송신 등으로 활용하는 행위는 금지됩니다. ::: ## 토스에서 제공하는 그래픽 리소스 *** ### 1. 아이콘 & 이모지 약 7,000개 이상의 토스 아이콘 & 이모지 세트가 제공됩니다. 자체적으로 제작할 경우, 아래 제작 가이드라인을 참고해주세요. [앱인토스 아이콘 제작 가이드](https://www.notion.so/21b714bbfde780fb84bac2acfbb4a6b9?pvs=21) ![](/assets/resources-1.TUiom1wA.png) **\[사용 시 유의사항]** ⚠️ 화면에서 24~40px 사이의 크기로 사용해 주세요.\ ⚠️ 아이콘을 두 개 이상 병렬로 조합하는 것은 지양하고 있어요. 한 번에 하나씩만 사용해주세요. ### 2. 스마트폰 목업 파일 스마트폰 목업을 사용하여 리소스를 만드시는 경우 위 파일을 사용해주세요.\ 크기별로 사용하실 수 있게 제공되니 임의로 크롭, 색상 보정, 왜곡하지 말아주세요. ::: details 디자인 업데이트 반영을 위해, 개발 시에도 아래 URL로 넣으시는 것을 권장드려요. * **Full** * https://static.toss.im/illusts/mockup-template-250508.png * **Large** * Top: https://static.toss.im/illusts/mockup-large-top-0513.png * Bottom: https://static.toss.im/illusts/mockup-large-bottom-0513.png * **Medium** * Top: https://static.toss.im/illusts/mockup-medium-top-0513.png * Bottom: https://static.toss.im/illusts/mockup-medium-bottom-0513.png * **Small** * Top: https://static.toss.im/illusts/mockup-small-top-0513.png * Bottom: https://static.toss.im/illusts/mockup-small-bottom-0513.png ::: ![](/assets/resources-2.NIPZ3nsX.png) ### 3. 토스트 (AI 이미지 생성 툴) 1번의 아이콘 & 이모지를 바탕으로 3D 이미지를 생성할 수 있어요.\ 생성한 그래픽은 토스 그래픽 디자인 팀의 사용 승인(1일 이내)을 받아야 실제 화면에 사용할 수 있어요. ### 4. 그 외 3D, 애니메이션 등의 그래픽은 저희가 제공해드린 모듈 속에 포함된 것만 사용 가능해요.\ 앞으로 점진적으로 제공 범위를 넓혀갈 예정이에요. ## 그래픽의 올바른 사용법 *** ### 1. 문맥에 맞는 그래픽을 사용하세요. 그래픽은 장식이 아닌 사용자의 이해를 더 잘 돕기 위한 용도입니다. ![](/assets/resources-4.Br_-MChs.png) ### 2. 밀도에 맞는 크기로 사용하세요. 단순한 그래픽은 작게, 디테일한 그래픽은 크게 사용해 주세요. ![](/assets/resources-5.-eIbviLB.png) ### 3. 너무 많은 그래픽을 사용하지 마세요. 한 화면에 비슷한 크기의 그래픽이 많을수록 오히려 시선이 분산돼요.\ 가장 핵심적인 그래픽 하나만 사용하고, 다른 곳은 보조적인 그래픽(ex. 아이콘)을 활용해주세요. ![](/assets/resources-6.CMjXw9_f.png) ### 4. 실질적인 내용이 가리지 않게 배치하세요. 중요한 정보가 아래로 밀려서 불필요한 스크롤이 생기지 않도록, 크기와 위치를 적절히 조정해서 사용하세요. ![](/assets/resources-7.CT_gLHIa.png) ### 5. 부정적이거나 호소성 감정 표현에 주의하세요. 사용자에게 불쾌감을 주거나, 애원하거나 호소하는 등 부정적인 감정 표현은 다크 패턴이므로 지양합니다. ![](/assets/resources-8.BpwXKF8R.png) ![](/assets/resources-9.CLiXSXkB.png) ### 6. 장식적인 효과나 이펙트를 사용하지 마세요. 의미없는 묘사, 파티클, 그라데이션 등 지나친 장식적인 요소는 화면을 복잡해보이게 만들 수 있어요. ![](/assets/resources-10.CySczKw1.png) ### 7. 상황을 정확히 전달하는 그래픽을 사용하세요. 오류 상황이 아닌데 느낌표 아이콘을 사용하거나, 기다려야하지 않는데 로딩 애니메이션을 쓰는 등의 액션은 사용자의 오해를 불러일으킬 수 있어요. ![](/assets/resources-11.hgGSeVuc.png) ![](/assets/resources-12.LHJaINLp.png) ## 파트너사에서 직접 그래픽을 만들 때 유의사항 *** ### 1. 토스 스타일의 일관성을 지켜주세요. 토스는 단순 명료하고 깨끗한 디지털 그래픽 스타일을 지향해요.\ 손그림, 서정적인 화풍, 만화적인 표현 등은 이질적으로 느껴질 수 있어요. ✅ **토스의 그래픽 스타일 예시** ![](/assets/resources-24.DxhYyEzy.png) ![](/assets/resources-13.n5Po5S88.png) ![](/assets/resources-14.CO1qTqfU.png) ![](/assets/resources-15.CsC1XC37.png) ### 2. 고화질의 그래픽을 만들어주세요. 깨끗하고 선명한 고화질의 그래픽을 만들어주세요.\ 열화되거나, 파티클과 같은 자잘한 효과는 퀄리티가 낮아보일 수 있어 주의해주세요. ![](/assets/resources-16.8S9nvyAA.png) ![](/assets/resources-17.DEJYoggt.png) ### 3. 다크 / 라이트 모드 양쪽에서 가시성을 확보하세요. 토스 앱에 사용되는 그래픽은 다크 / 라이트 모드 둘 다 대응되어야 해요.\ 너무 밝거나 어두워서 특정 모드에서 안 보이는 일이 없도록 중간 명도의 그래픽을 만들어주세요. ![](/assets/resources-18.ogwKFe8r.png) ### 4. 화면에 어울리는 컬러와 레이아웃으로 구성하세요. 텍스트, CTA 버튼 등 화면의 다른 요소들과 그래픽의 균형을 맞춰주세요. ![](/assets/resources-19.DeJ794ga.png) ### 5. 긍정적이고 정돈된 인상으로 만들어주세요. 서비스의 안정성과 신뢰감을 만드는데 그래픽이 큰 역할을 합니다.\ 그래픽에 모노톤이 너무 많거나, 뿌옇고 칙칙해보이지 않게 디자인 해주세요. ![](/assets/resources-20.BZrEdioA.png) --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/환경 확인/getDeviceId.md --- # 기기 고유식별자 확인하기 ## `getDeviceId` `getDeviceId` 함수는 사용 중인 기기의 고유 식별자를 문자열로 반환해요. 이 함수는 현재 사용 중인 기기의 고유 식별자를 문자열로 반환해요. 기기별로 설정이나 데이터를 저장하거나 사용자의 기기를 식별해서 로그를 기록하고 분석하는 데 사용할 수 있어요. 같은 사용자의 여러 기기를 구분하는 데도 유용해요. ## 시그니처 ```typescript function getDeviceId(): string; ``` ### 반환 값 ## 예제 ### 기기 고유 식별자 가져오기 ::: code-group ```js [js] import { getDeviceId } from "@apps-in-toss/web-framework"; const deviceId = getDeviceId(); ``` ```tsx [React] import { getDeviceId } from "@apps-in-toss/web-framework"; import { useState } from "react"; const DeviceInfo = () => { const [deviceId, setDeviceId] = useState(null); const fetchDeviceId = async () => { setDeviceId(getDeviceId()); }; return (
{deviceId &&

Device ID: {deviceId}

}
); }; } ``` ```tsx [React Native] import { getDeviceId } from '@apps-in-toss/framework'; import { Text } from '@toss/tds-react-native'; function MyPage() { const id = getDeviceId(); return 사용자의 기기 고유 식별자: {id}; } ``` ::: --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/UI/NavigationBar.md --- # 내비게이션 바 설정 내비게이션 바는 화면 상단에 고정되어 있는 공통 UI 컴포넌트예요.\ 이 문서에서는 **오른쪽 액세서리 영역에 아이콘을 추가하는 방법**과 **왼쪽 영역에 홈 버튼을 추가하는 방법** 두 가지를 설명해요. ## 디자인 가이드 상단 내비게이션은 사용자에게 일관된 정보 구조를 전달하기 위해 **모노톤 아이콘**만을 사용해요.\ 컬러 아이콘은 시각적 주의를 과도하게 분산시키고, 불필요한 강조로 혼란을 줄 수 있기 때문이에요.\ 토스에서는 기능 중심의 통일된 인터페이스를 위해,\ 특수한 케이스를 제외하고는 모두 **모노톤 아이콘으로 통일**해 사용하고 있어요. ![](/assets/navi.BvEf_6ol.png) ## 1. 액세서리 아이콘 추가하기 게임, 비게임 미니앱 모두 우측 상단 **더보기 버튼 왼쪽 영역**에는 기능 버튼을 의미하는 아이콘을 한 개 추가할 수 있어요. ### 플랫폼별 설정 방식 * **WebView** * `partner.addAccessoryButton()`으로 런타임에 버튼을 추가할 수 있어요. * 클릭 이벤트는 `tdsEvent.addEventListener('navigationAccessoryEvent')`로 받아요. * 초기 노출은 `defineConfig`의 `navigationBar.initialAccessoryButton` 옵션을 사용해요. * **React Native** * `useTopNavigation()`의 `addAccessoryButton()`으로 런타임에 버튼을 추가할 수 있어요. * 또는 `granite.config.ts`의 `navigationBar.initialAccessoryButton`을 사용해 초기 상태에서 버튼을 노출할 수 있어요. ### 시그니처 ```typescript interface NavigationBarOptions { withBackButton?: boolean; // 뒤로가기 버튼 유무 withHomeButton?: boolean; // 홈버튼 유무 initialAccessoryButton?: InitialAccessoryButton; // 1개만 노출 가능 } interface InitialAccessoryButton { id: string; title?: string; icon: { name: string; }; } ``` ### 예제 #### 아이콘 버튼 추가하기 (초기 설정) ::: code-group ```tsx [Web] import { defineConfig } from '@apps-in-toss/web-framework/config'; export default defineConfig({ // ... navigationBar: { withBackButton: true, withHomeButton: true, initialAccessoryButton: { id: 'heart', title: 'Heart', icon: { name: 'icon-heart-mono', }, } }, }); ``` ```tsx [React Native] import { appsInToss } from '@apps-in-toss/framework/plugins'; import { defineConfig } from '@granite-js/react-native/config'; export default defineConfig({ // ... navigationBar: { withBackButton: true, withHomeButton: true, initialAccessoryButton: { icon: { name: 'icon-heart-mono', }, id: 'heart', title: '하트', }, }, }), ], }); ``` ::: #### 아이콘 추가하기 (동적 추가) ::: code-group ```js [js] import { partner, tdsEvent } from '@apps-in-toss/web-framework' partner.addAccessoryButton({ id: 'heart', title: '하트', icon: { name: 'icon-heart-mono', }, }); const cleanup = tdsEvent.addEventListener('navigationAccessoryEvent', { onEvent: ({ id }) => { if (id === 'heart') { console.log('버튼 클릭'); } }, }); window.addEventListener('pagehide', () => { cleanup(); }); ``` ```tsx [React] import { partner, tdsEvent } from '@apps-in-toss/web-framework' // ... useEffect(() => { partner.addAccessoryButton({ // 하트 아이콘 버튼 추가 id: 'heart', title: '하트', icon: { name: 'icon-heart-mono', }, }); // 네비게이션 액세서리 버튼 클릭 이벤트 리스너 등록 const cleanup = tdsEvent.addEventListener('navigationAccessoryEvent', { onEvent: ({ id }) => { if (id === 'heart') { console.log('버튼 클릭'); } }, }); return cleanup; }, []); ``` ```tsx [React Native] import { useTopNavigation } from '@apps-in-toss/framework'; import { tdsEvent } from '@toss/tds-react-native'; // ... const { addAccessoryButton } = useTopNavigation(); addAccessoryButton({ // 하트 아이콘 버튼 추가 title: '하트', icon: { name: 'icon-heart-mono', }, id: 'heart', onPress: () => console.log('버튼 클릭'), }); // 이벤트 리스너 useEffect(() => { const cleanup = tdsEvent.addEventListener('navigationAccessoryEvent', { onEvent: ({ id }) => { if (id === 'heart') { console.log('heart 클릭됨'); } }, }); // 컴포넌트 언마운트 시 이벤트 리스너 제거 return () => { cleanup(); }; }, []); ``` ::: ## 2. 홈 버튼 추가하기 비게임 미니앱에서는 왼쪽 상단에 **홈으로 이동하는 버튼**을 표시할 수 있어요.\ 홈 버튼은 서비스 이름 오른쪽에 위치하며, 사용자가 언제든 첫 화면으로 돌아올 수 있도록 도와줘요. ::: tip 주의해주세요 * 오른쪽 액세서리 버튼 영역에는 홈 버튼을 중복 추가하지 말아주세요. * 홈 버튼은 "서비스 진입점" 역할만 수행하며, 커스텀 기능이나 문구 추가는 불가능해요. ::: ### 설정 방법 홈 버튼을 추가하려면 `navigationBar` 설정에 `withHomeButton: true` 옵션을 추가해 주세요. ```tsx interface NavigationBarOptions { withHomeButton?: boolean; // 홈 버튼 표시 여부 } ``` ### 예시 ```tsx navigationBar: { withBackButton: true, withHomeButton: true, // 홈 버튼 표시 } ``` ## 참고사항 * 액세서리 버튼은 **모노톤 아이콘**만 지원돼요. * 한 번에 표시할 수 있는 액세서리 버튼은 1개뿐이에요. * 컬러 아이콘이나 커스텀 UI 추가는 지원하지 않아요. * 홈 버튼은 비게임 미니앱에서만 사용 가능해요. --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/저장소/Storage.md --- # 네이티브 저장소 이용하기 ## `Storage` `Storage` 로 네이티브의 저장소를 사용할 수 있어요. ### 시그니처 ```typescript Storage: { getItem: typeof getItem; setItem: typeof setItem; removeItem: typeof removeItem; clearItems: typeof clearItems; } ``` ### 프로퍼티 ### 예제 앱 체험하기 [apps-in-toss-examples](https://github.com/toss/apps-in-toss-examples) 저장소에서 [with-storage](https://github.com/toss/apps-in-toss-examples/tree/main/with-storage) 코드를 내려받거나, 아래 QR 코드를 스캔해 직접 체험해 보세요. ## `setItem` `setItem` 함수는 모바일 앱의 로컬 저장소에 문자열 데이터를 저장해요. 주로 앱이 종료되었다가 다시 시작해도 데이터가 유지되어야 하는 경우에 사용해요. ### 시그니처 ```typescript function setItem(key: string, value: string): Promise; ``` ### 파라미터 ### 반환 값 ### 예제 `my-key`에 아이템 저장하기 ::: code-group ```js [js] import { Storage } from '@apps-in-toss/web-framework'; const KEY = 'my-key'; async function handleSetStorageItem(value) { const storageValue = await Storage.setItem(KEY, value); } async function handleGetStorageItem() { const storageValue = await Storage.getItem(KEY); return storageValue; } async function handleRemoveStorageItem() { await Storage.removeItem(KEY); } ``` ```tsx [React] import { Storage } from '@apps-in-toss/web-framework'; import { Button, Text } from '@toss/tds-mobile'; import { useState } from 'react'; const KEY = 'my-key'; function StorageTestPage() { const [storageValue, setStorageValue] = useState(null); async function handleSet() { await Storage.setItem(KEY, 'my-value'); } async function handleGet() { const storageValue = await Storage.getItem(KEY); setStorageValue(storageValue); } async function handleRemove() { await Storage.removeItem(KEY); } return ( <> {storageValue} ); } ``` ```tsx [React Native] import { Storage } from '@apps-in-toss/framework'; import { Button, Text } from '@toss/tds-react-native'; import { useState } from 'react'; const KEY = 'my-key'; function StorageTestPage() { const [storageValue, setStorageValue] = useState(null); async function handleSet() { await Storage.setItem(KEY, 'my-value'); } async function handleGet() { const storageValue = await Storage.getItem(KEY); setStorageValue(storageValue); } async function handleRemove() { await Storage.removeItem(KEY); } return ( <> {storageValue} ); } ``` ::: ## `getItem` `getItem` 함수는 모바일 앱의 로컬 저장소에서 문자열 데이터를 가져와요. 주로 앱이 종료되었다가 다시 시작해도 데이터가 유지되어야 하는 경우에 사용해요. ### 시그니처 ```typescript function getItem(key: string): Promise; ``` ### 파라미터 ### 반환 값 ### 예제 `my-key`에 저장된 아이템 가져오기 ::: code-group ```js [js] import { Storage } from '@apps-in-toss/web-framework'; const KEY = 'my-key'; async function handleGetItem() { const storageValue = await Storage.getItem(KEY); return storageValue; } ``` ```tsx [React] import { Storage } from '@apps-in-toss/web-framework'; import { Button } from '@toss/tds-mobile'; const KEY = 'my-key'; function StorageClearButton() { async function handleGet() { const storageValue = await Storage.getItem(KEY); setStorageValue(storageValue); } return ; } ``` ```tsx [React Native] import { Storage } from '@apps-in-toss/framework'; import { Button } from '@toss/tds-react-native'; const KEY = 'my-key'; function StorageClearButton() { async function handleGet() { const storageValue = await Storage.getItem(KEY); setStorageValue(storageValue); } return ; } ``` ::: ## `removeItem` `removeItem` 함수는 모바일 앱의 로컬 저장소에서 특정 키에 해당하는 아이템을 삭제해요. ### 시그니처 ```typescript declare function removeItem(key: string): Promise; ``` ### 파라미터 ### 반환 값 ### 예제 `my-key`에 저장된 아이템 삭제하기 ::: code-group ```js [js] import { Storage } from '@apps-in-toss/web-framework'; const KEY = 'my-key'; async function handleRemoveItem() { await Storage.removeItem(KEY); } ``` ```tsx [React] import { Storage } from '@apps-in-toss/web-framework'; import { Button } from '@toss/tds-mobile'; const KEY = 'my-key'; function StorageClearButton() { async function handleRemove() { await Storage.removeItem(KEY); } return ; } ``` ```tsx [React Native] import { Storage } from '@apps-in-toss/framework'; import { Button } from '@toss/tds-react-native'; const KEY = 'my-key'; function StorageClearButton() { async function handleRemove() { await Storage.removeItem(KEY); } return ; } ``` ::: ## `clearItems` `clearItems` 함수는 모바일 앱의 로컬 저장소의 모든 아이템을 삭제해요. ### 시그니처 ```typescript declare function clearItems(): Promise; ``` ### 반환 값 ### 예제 저장소 초기화하기 ::: code-group ```js [js] import { Storage } from '@apps-in-toss/web-framework'; async function handleClearItems() { await Storage.clearItems(); console.log('Storage cleared'); } ``` ```tsx [React] import { Storage } from '@apps-in-toss/web-framework'; import { Button } from '@toss/tds-mobile'; function StorageClearButton() { async function handleClick() { await Storage.clearItems(); console.log('Storage cleared'); } return ; } ``` ```tsx [React Native] import { Storage } from '@apps-in-toss/framework'; import { Button } from '@toss/tds-react-native'; function StorageClearButton() { async function handlePress() { await Storage.clearItems(); console.log('Storage cleared'); } return ; } ``` ::: --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/네트워크/getNetworkStatus.md --- # 네트워크 연결 상태 확인하기 ## `getNetworkStatus` `getNetworkStatus` 는 디바이스의 현재 네트워크 연결 상태를 가져오는 함수예요. 반환 값은 `NetworkStatus` 타입으로, 인터넷 연결 여부와 연결 유형(Wi-Fi, 모바일 데이터 등)을 나타내요. 값은 다음 중 하나예요. * `OFFLINE`: 인터넷에 연결되지 않은 상태예요. * `WIFI`: Wi-Fi에 연결된 상태예요. * `2G`: 2G 네트워크에 연결된 상태예요. * `3G`: 3G 네트워크에 연결된 상태예요. * `4G`: 4G 네트워크에 연결된 상태예요. * `5G`: 5G 네트워크에 연결된 상태예요. * `WWAN`: 인터넷은 연결되었지만, 연결 유형(Wi-Fi, 2G~5G)을 알 수 없는 상태예요. 이 상태는 iOS에서만 확인할 수 있어요. * `UNKNOWN`: 인터넷 연결 상태를 알 수 없는 상태예요. 이 상태는 안드로이드에서만 확인할 수 있어요. ## 시그니처 ```typescript function getNetworkStatus(): Promise; ``` ### 반환 값 ## 예제 ### 현재 네트워크 상태 가져오기 네트워크 연결 상태를 가져와 화면에 표시하는 예제예요. ```tsx import { useState, useEffect } from 'react'; import { Text, View } from 'react-native'; import { getNetworkStatus, NetworkStatus } from '@apps-in-toss/framework'; function GetNetworkStatus() { const [status, setStatus] = useState(''); useEffect(() => { async function fetchStatus() { const networkStatus = await getNetworkStatus(); setStatus(networkStatus); } fetchStatus(); }, []); return ( 현재 네트워크 상태: {status} ); } ``` ### 예제 앱 체험하기 [apps-in-toss-examples](https://github.com/toss/apps-in-toss-examples) 저장소에서 [with-network-status](https://github.com/toss/apps-in-toss-examples/tree/main/with-network-status) 코드를 내려받거나, 아래 QR 코드를 스캔해 직접 체험해 보세요. --- --- url: 'https://developers-apps-in-toss.toss.im/design/consumer-ux-guide.md' description: >- 앱인토스 다크패턴 방지 정책입니다. 고객의 예상을 벗어난 설계를 피하고 일관되고 신뢰할 수 있는 사용자 경험을 제공하기 위한 필수 가이드를 확인하세요. --- # 다크패턴 방지 정책 본 가이드라인은 사용자에게 불필요한 혼란이나 불이익을 주는 다크 패턴을 피하기 위해 마련된 지침입니다.\ 아래 내용을 반드시 확인해 주세요. ## 고객의 예상을 벗어난 설계는 앱인토스 서비스로 출시할 수 없어요. 토스 사용자는 언제 어디서든 일관되고 신뢰할 수 있는 경험을 기대해요.\ 각기 다른 기준으로 화면을 설계하면 사용자가 혼란을 느끼고, 이는 서비스에 대한 신뢰 저하로 이어질 수 있어요. UX 가이드라인은 창의성을 제한하기 위한 것이 아니라, 고객에게 예측 가능하고 편리한 경험을 제공하기 위한 **최소한의 기준**이에요.\ 이를 지켜주시는 것이 곧 고객 만족을 높이고, 서비스의 성과와 경쟁력을 함께 강화하는 길이에요. ## 고객의 예상을 벗어난 설계는 앱인토스 서비스로 출시할 수 없어요. 앱인토스 서비스를 포함한 토스 앱은 누구나 직관적으로 사용할 수 있어야 해요.\ 이를 위해 반드시 지켜야 할 최소한의 사용 경험 기준을 정의했어요. 여기서 ‘최소한의 기준’이란, 고객이 제품을 제대로 쓰지 못하게 만드는 치명적인 사용성 오류를 의미해요.\ 아래 공통 규칙들은 고객의 예상을 벗어난 설계 사례들이에요. ## 아래와 같은 설계가 적용된 앱은 출시할 수 없어요. 고객이 예상한 서비스 하면이 아닌 전면을 가로막는 광고형 바텀싯이 뜨는 사례를 의미해요.\ (알림동의를 받는 바텀싯도 포함)\ 사용자는 서비스에 들어가는 순간, 자신이 예상한 목적을 바로 실행하기를 기대해요.\ 에상치 못한 인터럽트가 등장하면 몰입이 끊기도 이탈 가능성이 높아져요. ![](/assets/ux_guide_1_1.BhtAsgnB.png) ![](/assets/ux_guide_1_2.BsyKX1HE.png) 고객이 이전 화면으로 돌아가 다른 서비스를 탐색하려는 순간, 예상과 달리 서비스 알림동의를 유도하는 바텀싯이 뜨는 사례를 의미해요.\ 이탈을 막기 위한 추가 설계는 사용자의 자율성을 침해한다고 느껴질 수 있어요. ![](/assets/ux_guide_2_1.YAWKj18p.png) ![](/assets/ux_guide_2_2.DqWILSqb.png) 공급자가 유도한 CTA를 누르는 것 말고는 다른 선택지가 없는 사례를 의미해요.\ 거절할 수 없는 구조는 강제적으로 느껴지고, 서비스에 대한 반감과 불신으로 이어질 수 있어요. ![](/assets/ux_guide_3_1.BNkB1d0c.png) ![](/assets/ux_guide_3_2.Dfu6YZgz.png) 사용자가 서비스에서 아이템을 받기 위해 메뉴를 누르자, 예상치 못하게 전면 광고가 뜨는 사례를 의미해요.\ 사용 흐름 중 예상치 못한 광고는 사용자의 몰입을 방해하고, 브랜드에 대한 불쾌감을 유발할 수 있어요. ![](/assets/ux_guide_4_1.DZbXinrD.png) ![](/assets/ux_guide_4_2.BwcDtzsg.png) CTA에 이미 화면에서 설명하고 있는 가치를 중복 설명하여, 고객이 버튼을 눌렀을 때 어떤 화면이 나올지 전혀 예상할 수 없는 사례를 의미해요.\ CTA는 행동을 명확하게 유도하는 장치예요.\ 버튼 라벨이 모호하거나 중복된 설명만 담고 있으면, 사용자는 결과를 예측하지 못해 불안감을 느끼고 클릭을 주저할 수 있어요. 이는 전환율 저하로 이어질 수 있어요.\ 그리고 CTA 위에 보조설명으로 과장되거나 중복되는 문구를 넣는 것도 사용자에게 혼란을 줄 수 있어요. ![](/assets/ux_guide_5.7MlSjYms.png) --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/인앱 결제/getPendingOrders.md --- # 대기 중인 목록 가져오기 ## `getPendingOrders` `getPendingOrders` 는 **결제는 완료되었지만 상품이 아직 지급되지 않은 주문 목록**을 가져오는 함수예요.\ 조회된 주문 정보를 확인하여 사용자에게 상품을 지급하세요.\ `createOneTimePurchaseOrder` 함수 호출 후 결과를 받지 못한 경우에도 해당 주문을 조회할 수 있어요. 앱 버전이 최소 지원 버전(안드로이드 5.234.0, iOS 5.231.0)보다 낮으면 `undefined`를 반환해요. ## 시그니처 ```typescript function getPendingOrders(): Promise<{ orders: Order[] } | undefined>; ``` ### 반환값 ### 반환 객체 프로퍼티 ```tsx interface Order { orderId: string; sku: string; } ``` ::: tip sku 필드가 추가되었어요 SDK 1.4.2 버전에서 sku 필드가 추가되었어요.\ 해당 필드는 **안드로이드 5.234.0 이상**, **iOS 5.231.0 이상** 에서만 반환돼요. ::: ## 예제 ::: code-group ```js [js] import { IAP } from '@apps-in-toss/web-framework'; async function fetchOrders() { try { const pendingOrders = await IAP.getPendingOrders(); return pendingOrders; } catch (error) { console.error(error); } } ``` ```tsx [React] import { IAP } from '@apps-in-toss/web-framework'; async function fetchOrders() { try { const pendingOrders = await IAP.getPendingOrders(); return pendingOrders; } catch (error) { console.error(error); } } ``` ```tsx [React Native] import { IAP } from '@apps-in-toss/framework'; async function fetchOrders() { try { const pendingOrders = await IAP.getPendingOrders(); return pendingOrders; } catch (error) { console.error(error); } } ``` ::: --- --- url: 'https://developers-apps-in-toss.toss.im/analytics/dashboard.md' description: 데이터 분석 대시보드 사용 가이드입니다. 지표 확인 및 분석 방법을 확인하세요. --- # 대시보드 **대시보드는 미니앱 성공을 위해 꼭 활용해야 하는 핵심 기능이에요.**\ 앱인토스 콘솔 대시보드에서는 **DAU, 성별, 연령, 리텐션 등 주요 지표를 한눈에 확인**하고, 이를 바탕으로 **서비스 개선과 트래픽 향상 전략을 세울 수 있어요.** ![](/assets/dashboard_1.DPaagIGH.png) 미니앱이 런칭되면 콘솔 홈 메뉴에서 **'분석하기' 탭**을 통해 대시보드를 확인할 수 있어요.\ 이곳에서 제공되는 데이터는 **사용자의 행동을 이해하고, 전환율을 높이는 가장 중요한 근거**가 돼요. * **SDK 0.0.26 버전 이상**이 적용된 미니앱만 데이터 확인이 가능해요. * 샌드박스나 출시 준비 단계의 데이터는 제공되지 않으며, 실제 런칭 이후 데이터만 확인할 수 있어요. * **서비스 런칭 후 하루 뒤부터** 데이터를 볼 수 있어요. ::: tip 참고하세요 **SDK 0.0.36 버전 이상**을 적용하면 추후 미니앱의 **체류 시간**도 측정할 수 있어요.\ 이 데이터는 대시보드 UI에 반영될 예정이며, **사용자 경험을 더욱 정교하게 분석하는 데 도움**이 돼요. ::: ## 대시보드에서 확인할 수 있는 데이터 ![](/assets/dashboard_2.B7vSio3T.png) * **DAU(일간 활성 사용자 수)** : 최근 4주간 하루 단위로 앱을 사용한 사용자 수를 확인할 수 있어요.\ → **서비스 성장세를 가장 직관적으로 보여주는 핵심 지표**예요. * **OS별 사용자 수** : 최근 4주간 안드로이드, iOS 사용자를 구분해서 볼 수 있어요.\ → 운영체제별 **최적화 전략 수립**에 활용할 수 있어요. * **성별 분포** : 최근 4주간 남성, 여성 사용자 수를 확인할 수 있어요.\ → **타겟 맞춤형 콘텐츠 기획**에 꼭 필요한 데이터예요. * **연령 분포** : 최근 4주간 10대부터 60대 이상까지 연령대별 사용자 수를 확인할 수 있어요.\ → **주요 고객층 파악과 마케팅 전략 설계**에 활용할 수 있어요. * **리텐션(재방문율)** : 사용자가 첫 방문 이후 일정 기간 내에 다시 앱을 이용한 비율을 확인할 수 있어요.\ → **서비스 충성도와 만족도를 측정하는 핵심 지표**예요. ✅ **대시보드는 단순한 통계 화면이 아니라, 파트너사의 미니앱 성장을 가속화하는 전략 도구**예요.\ 데이터를 꾸준히 확인하고 개선에 활용하면 **트래픽 증가와 전환율 향상 효과**를 반드시 체감할 수 있을 거예요. 추가로 필요한 데이터 항목이 있다면 언제든 채널톡으로 문의해주세요. --- --- url: 'https://developers-apps-in-toss.toss.im/growth/insight.md' description: >- 앱인토스 데이터 기반 성장 인사이트 가이드입니다. DAU, 리텐션 등의 지표를 활용해 앱 성장을 분석하고 다음 액션을 계획하는 방법을 확인하세요. --- # 데이터 기반 성장 인사이트 만들기 앱이 성장하고 있는지 확인하려면, 지금의 상태를 정확히 알아야 해요.\ 앱인토스의 콘솔 대시보드를 통해 AU, 리텐션 등의 지표를 한눈에 보고 성장의 흐름을 파악해보세요.\ 데이터를 기반으로 다음 액션을 정하면, 더 빠르고 확실한 성장을 만들 수 있어요. *** ## 토스 기능으로 데이터 인사이트 발견하기 ### ① DAU DAU는 Daily Active Users로서, **일간 활성 사용자 수**를 의미해요.\ 즉, 하루 동안 앱을 실제로 이용한 사용자 수를 나타내요.\ DAU를 통해서 일별 사용자 활동 변화를 통해 앱의 **'실제 이용 규모'** 와 **'활동 흐름'** 을 확인할 수 있어요. ![](/assets/growth_insight_1.aufQHhsU.png) DAU의 해석 포인트는 아래와 같아요. * **지속적인 하락**은 **신규 유입이 줄거나 리텐션이 약화된 신호**예요. * **일시적 급등/급락**은 캠페인, 이벤트, 업데이트 등 **외부 요인의 영향을 반영**할 수 있어요. * **유입과 리텐션 데이터를 함께 비교**하면 감소의 원인이 **‘유입 부진’** 인지 **‘이탈 증가’** 인지 판단할 수 있어요. ::: tip 👉 액션 아이템 * 하락 추세 시: 신규 유입 경로(푸시, 토스 홈 광고 등)를 점검하고, 유입 캠페인 강화를 고려해요. * 급등 시: 일시적 반응인지, 장기적 이용으로 이어지는지 리텐션 지표로 검증해요. * 요일별 DAU 분석을 통해 사용자 이용 패턴에 맞춘 푸시 발송 타이밍을 최적화해요. ::: ### ② 사용자 특성 정보 대시보드에서 사용자 특성 정보인 **OS, 성별, 연령대 분포**를 확인할 수 있어요.\ 앱을 사용하는 주요 타겟이 누구인지, 실제 이용층이 예상과 일치하는지 판단할 수 있어요. ![](/assets/growth_insight_2.DPhxi3qu.png) 사용자 특성 정보의 해석 포인트는 아래와 같아요. * **Android와 iOS 사용 비율이 크게 차이**나면, **플랫폼별 UI/UX 또는 마케팅 효율을 점검**할 필요가 있어요. * 서비스 특성상 어떤 성별과 연령이 더 반응하는지 확인할 수 있어요. ::: tip 👉 액션 아이템 * OS별 성과 차이가 있다면 UI/UX 테스트 또는 이벤트 노출 방식을 조정해요. * 특정 연령·성별 비중이 높을 경우, 그에 맞는 콘텐츠 톤앤매너·혜택 구성으로 타겟 메시지를 강화해요. * 예상 타겟과 실제 이용층이 다를 때, 세그먼트 기준을 재정의하거나 신규 타겟 확장 전략을 세워요. ::: ### ③ 리텐션 리텐션은 **최초 방문 이후 일정 기간이 지난 뒤**에도 **앱을 다시 이용하는 사용자의 비율**을 나타내요.\ 리텐션을 보면 ‘앱이 얼마나 오랫동안 사용자를 붙잡고 있는지’를 확인할 수 있어요. ![](/assets/growth_insight_3.B0hPXivb.png) 리텐션의 해석 포인트는 아래와 같아요. * **초기 리텐션(WEEK 1)은 첫 경험의 만족도**를, **장기 리텐션(WEEK 4 이후)은 습관화 정도**를 반영해요. * 리텐션 하락 구간을 찾으면, 사용자 이탈이 발생하는 시점을 명확히 알 수 있어요. * 리텐션은 단독 지표로 보기보다, 푸시·프로모션·UI 개선 등 실험 결과와 함께 해석해야 정확한 인사이트를 얻을 수 있어요. ::: tip 👉 액션 아이템 * 리텐션 강화: 주기적 혜택, 도전 미션, 알림 리마인드 등으로 반복 사용 동기를 만들어요. * 리텐션 하락 시점 분석을 통해 UI 개선, 콘텐츠 갱신, 푸시 타이밍 조정 등 구체적 개선 실험을 실행해요. ::: --- --- url: 'https://developers-apps-in-toss.toss.im/unity/guide/runtime-structure.md' --- # 동작 방식 ![](/assets/unity_webgl_structure.C3zMnjOQ.png) ## 1. 동작 원리 Unity의 BuildTarget은 WebGL 플랫폼을 지원하며, WebGL 내보내기 패키지는 WebAssembly 기술을 기반으로 브라우저 환경에서 실행됩니다.\ 내보내기 패키지가 AppsInToss 미니앱 환경에서 실행될 수 있도록 다음과 같은 지원을 제공합니다: * 개발 단계: 플랫폼 기능의 TypeScript SDK 제공으로 개발자가 플랫폼 기능에 빠르게 연동 * 내보내기 단계: 전환 패키징 도구로 Unity WebGL 접착층 적응을 통해 미니앱 패키지로 직접 전환 * 실행 단계: WebAssembly 기본 기능 및 AppsInToss 하위 레벨 인터페이스 지원 제공 *** ## 2. 핵심 기술 스택 ### WebAssembly (WASM) * Unity C# 코드를 WebAssembly로 컴파일하여 네이티브에 가까운 성능 제공 * 브라우저 환경에서 고성능 게임 실행 가능 * 메모리 관리 및 가비지 컬렉션 최적화 ### JavaScript Bridge * Unity C#과 AppsInToss JavaScript API 간 양방향 통신 제공 * 플랫폼별 기능(결제, 광고, 소셜 등)에 원활한 접근 * 타입 안전성을 보장하는 자동 마샬링 ### Vite + React 프레임워크 * 모던 웹 개발 환경 제공 * 빠른 개발 서버와 최적화된 번들링 * TypeScript 완전 지원 ### Granite 빌드 시스템 * AppsInToss 플랫폼 최적화된 빌드 파이프라인 * 자동 리소스 압축 및 CDN 배포 * 점진적 로딩 및 캐싱 전략 *** ## 3. 아키텍처 개요 ``` ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Unity Game │ │ AppsInToss │ │ Platform │ │ │ │ Bridge │ │ Services │ │ ┌───────────┐ │ │ │ │ │ │ │ C# Logic │ │◄──►│ TypeScript SDK │◄──►│ Login API │ │ └───────────┘ │ │ │ │ Storage API │ │ ┌───────────┐ │ │ ┌─────────────┐ │ │ Payment API │ │ │ Render │ │ │ │ JS Runtime │ │ │ Admob │ │ └───────────┘ │ │ └─────────────┘ │ │ Analytics │ │ ┌───────────┐ │ │ ┌─────────────┐ │ │ │ │ │WebAssembly│ │ │ │ WebGL Glue │ │ │ │ │ └───────────┘ │ │ └─────────────┘ │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ ``` *** ## 4. 주요 특징 ### 고성능 * WebAssembly 기반으로 네이티브에 가까운 성능 * GPU 가속 렌더링 지원 * 메모리 효율적인 리소스 관리 ### 호환성 * Unity 2018.4 LTS 이상 모든 버전 지원 * 대부분의 Unity 기능과 써드파티 플러그인 지원 * 기존 게임 코드 95% 이상 재사용 가능 ### 모바일 최적화 * 터치 입력 및 제스처 완전 지원 * 모바일 성능에 최적화된 렌더링 * 배터리 효율성 고려한 프레임 레이트 관리 ### 개발 도구 * Unity 에디터 통합 빌드 도구 * 실시간 디버깅 및 프로파일링 * 자동화된 배포 파이프라인 *** ## 5. 포팅 순서 전환 흐름은 [Unity WebGL 앱인토스 미니앱 전환 가이드 문서](/unity/intro/migration-guide)를 참조하세요. *** ## 6. 성능 벤치마크 | 메트릭 | 네이티브 앱 | AppsInToss WebGL | 성능 비율 | |:--|:--:|:--:|:--:| | 시작 시간 | 1.2초 | 2.8초 | 85% | | FPS (3D 게임) | 60 FPS | 50–55 FPS | 90% | | 메모리 사용량 | 100MB | 120MB | 83% | | 배터리 소모 | 100% | 110% | 91% | *** ## 7. 지원 플랫폼 ### 완전 지원 * iOS: Safari WebView (iOS 13+) * Android: Chrome WebView (Android 7+) * Desktop: Chrome, Firefox, Safari, Edge ### 최적화 지원 * 토스앱 WebView: 네이티브 수준 성능 * AppsInToss 브라우저: 전용 최적화 ### 브라우저 호환성 * WebAssembly 지원: 95%+ 커버리지 * WebGL 2.0 지원: 90%+ 커버리지 * SharedArrayBuffer: 85%+ 커버리지 *** ## 8. 보안 및 안정성 ### 코드 보호 * WebAssembly 바이너리 형태로 코드 배포 * 선택적 코드 난독화 지원 * 워터마크를 통한 무단 복제 방지 ### 데이터 보안 * HTTPS 강제 적용 * 민감한 데이터는 서버사이드 검증 * 클라우드 저장소 암호화 *** ## 9. 제한 사항 ### 기술적 제약 * 멀티스레딩 제한적 지원 * 파일 시스템 접근 불가 * 네이티브 플러그인 사용 불가 ### 성능 고려사항 * 메모리 사용량이 네이티브 앱 대비 20-30% 높음 * 초기 로딩 시간이 네이티브 앱 대비 2-3배 * 배터리 소모가 약 10-15% 높음 *** ### 10. 참고자료 * [Unity WebGL 가이드](https://docs.unity3d.com/Manual/webgl-gettingstarted.html) * [Unity WebGL 포럼](https://discussions.unity.com/c/unity-engine/52) * [MDN WebAssembly 문서](https://developer.mozilla.org/ko/docs/WebAssembly) * [앱인토스 개발자 센터](https://developers-apps-in-toss.toss.im/) --- --- url: >- https://developers-apps-in-toss.toss.im/bedrock/reference/framework/화면 제어/useBackEvent.md --- # 뒤로가기 이벤트 제어하기 ## `useBackEvent` `useBackEvent` 는 뒤로 가기 이벤트를 등록하고 제거할 수 있는 컨트롤러 객체를 반환하는 Hook이에요. 이 Hook을 사용하면 특정 컴포넌트가 활성화되었을 때만 뒤로 가기 이벤트를 처리할 수 있어요. `addEventListener` 를 쓰면 뒤로 가기 이벤트를 등록할 수 있고, `removeEventListener` 를 쓰면 뒤로 가기 이벤트를 제거할 수 있어요. 사용자가 화면을 보고 있을 때만 등록된 뒤로 가기 이벤트가 등록돼요. 화면을 보고 있다는 조건은 [useVisibility](/bedrock/reference/framework/화면%20제어/useVisibility.md) 을 사용해요. 이 Hook을 사용해 특정 컴포넌트에서 뒤로 가기 이벤트를 처리하는 로직을 정의할 수 있어요. ## 시그니처 ```typescript function useBackEvent(): BackEventControls; ``` ### 반환 값 ### 에러 ## 예제 ### 뒤로 가기 이벤트 등록 및 제거 예제 * **"Add BackEvent" 버튼을 누르면 뒤로 가기 이벤트가 등록돼요.** 이후 뒤로 가기 버튼을 누르면 "back"이라는 알림이 뜨고, 실제로 뒤로 가지 않아요. * **"Remove BackEvent" 버튼을 누르면 등록된 이벤트가 제거돼요.** 이후 뒤로 가기 버튼을 누르면 기존 동작대로 정상적으로 뒤로 가요. ```tsx import { useEffect, useState } from "react"; import { Alert, Button, View } from "react-native"; import { useBackEvent } from '@granite-js/react-native'; function UseBackEventExample() { const backEvent = useBackEvent(); const [handler, setHandler] = useState<{ callback: () => void } | undefined>( undefined ); useEffect(() => { const callback = handler?.callback; if (callback != null) { backEvent.addEventListener(callback); return () => { backEvent.removeEventListener(callback); }; } return; }, [backEvent, handler]); return (