iOS 메모리 경고 대응 정책: Unity에서 캐시·미리보기·비필수 리소스를 단계적으로 비우는 방법

iOS 메모리 경고를 받았을 때 Unity 클라이언트가 캐시, UI 미리보기, 비필수 에셋을 우선순위에 따라 해제하는 단계적 메모리 관리 정책과 C# 구현 예제를 설명합니다.

✓ 복사 완료!
챕터일반 가이드
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
iOS 메모리 경고 대응 정책: Unity에서 캐시·미리보기·비필수 리소스를 단계적으로 비우는 방법

핵심 요약

iOS 메모리 경고는 즉시 종료를 뜻하는 신호가 아니라 앱이 메모리 사용량을 줄여야 한다는 알림이다. Unity에서는 Application.lowMemory를 진입점으로 사용하고 캐시, 미리보기 텍스처, 비필수 리소스, 대형 오브젝트 순서로 해제하는 단계적 정책을 적용한다.

핵심은 모든 리소스를 한 번에 지우지 않는 것이다. 현재 씬과 입력에 필요한 리소스는 유지하면서 재생성 비용이 낮고 우선순위가 낮은 데이터부터 비우고 해제 결과와 다음 프레임의 메모리 상태를 기록해야 한다.

iOS 메모리 경고는 왜 발생하는가?

iOS는 앱마다 고정된 메모리 한도를 단순히 제공하지 않는다. 기기 모델, iOS 버전, 다른 프로세스의 메모리 사용량, 앱의 네이티브·그래픽 리소스 상태에 따라 실제 종료 위험이 달라진다. 따라서 특정 기기에서 동작했다는 이유만으로 메모리 안전을 보장할 수 없다.

UIKit은 UIApplication.didReceiveMemoryWarningNotification 알림을 전달하며 앱 델리게이트에서는 applicationDidReceiveMemoryWarning: 메서드를 사용할 수 있다. Unity 프로젝트에서는 플랫폼별 네이티브 브리지를 직접 만들기보다 Application.lowMemory 이벤트를 공통 진입점으로 연결하는 방식이 일반적이다.

메모리 경고가 발생했을 때 다음 현상이 함께 나타날 수 있다.

  • 사용하지 않는 텍스처와 오디오 데이터가 메모리에 남아 있음
  • 인벤토리나 상점 UI가 대량의 미리보기 이미지를 보유함
  • AssetBundle 또는 Addressables 에셋의 참조가 해제되지 않음
  • RenderTexture, 네이티브 플러그인 버퍼, 동영상 프레임 버퍼가 누적됨
  • GC 힙은 줄었지만 텍스처·메시·오디오 같은 네이티브 메모리는 그대로 남아 있음

Resources.UnloadUnusedAssets()는 참조되지 않는 Unity 오브젝트를 검색해 해제하는 데 도움을 주지만 모든 메모리 문제를 해결하지는 않는다. C# 참조가 남아 있거나 플러그인이 직접 할당한 메모리와 GPU 리소스가 있으면 별도의 해제 경로가 필요하다.

단계적 메모리 회수 정책은 어떻게 설계할까?

메모리 경고 대응은 단일 ClearEverything() 함수가 아니라 우선순위가 있는 회수 단계로 설계한다. 각 단계는 멱등적이어야 한다. 즉 같은 단계가 여러 번 실행되어도 예외가 발생하거나 이미 필요한 리소스가 삭제되지 않아야 한다.

단계우선 해제 대상예시사용자 영향
Level 1임시 캐시HTTP 응답 캐시, 디코딩 결과, 검색 기록거의 없음
Level 2UI 미리보기아이템 썸네일, 상점 프리뷰, 비활성 탭 이미지다시 열 때 재생성
Level 3비활성 그래픽 리소스사용하지 않는 RenderTexture, 풀의 유휴 오브젝트다음 사용 시 재할당
Level 4비필수 에셋다음 지역의 프리로드 텍스처, 선택적 오디오, 장식용 VFX로딩 또는 스트리밍 증가
Level 5엔진 미사용 리소스Resources.UnloadUnusedAssets(), 필요 시 GC프레임 지연 가능

게임플레이에 직접 필요한 데이터는 회수 대상에서 제외한다. 예를 들어 현재 씬의 플레이어 애니메이션, 충돌 메시, 입력 처리 객체, 현재 UI의 폰트와 핵심 아이콘을 삭제하면 메모리는 줄어도 즉시 재할당 비용과 프레임 드롭이 커질 수 있다.

메모리 회수 우선순위는 무엇으로 정하는가?

각 리소스에 다음 값을 부여하면 정책을 일관되게 만들 수 있다.

  • RequiredNow: 현재 프레임 또는 현재 씬에서 반드시 필요한가
  • RebuildCost: 다시 생성하거나 다운로드하는 비용이 큰가
  • LastUsedTime: 마지막 사용 시각이 언제인가
  • MemorySize: 해제했을 때 확보되는 메모리가 얼마나 큰가
  • UserVisible: 해제 과정이 사용자에게 보이는가

캐시 후보의 우선순위는 다음처럼 계산할 수 있다.

EvictionScore=(1−RequiredNow)×MemorySize×AgeWeight÷(1+RebuildCost)EvictionScore = (1 - RequiredNow) \times MemorySize \times AgeWeight \div (1 + RebuildCost)

점수가 높을수록 먼저 해제한다. 실제 구현에서는 복잡한 수식을 사용하지 않고 RequiredNow와 리소스 그룹 우선순위를 명시해도 충분하다. 중요한 것은 “크기가 크고 오래 사용하지 않았으며 다시 만들기 쉬운 리소스”를 먼저 선택하는 것이다.

iOS 메모리 경고 발생 후 리소스를 우선순위별로 해제하는 Unity 클라이언트 정책

Unity에서는 Application.lowMemory를 어떻게 연결할까?

Unity는 Application.lowMemory 이벤트를 통해 저메모리 상황을 애플리케이션 코드에 전달한다. 초기화 시 이벤트를 한 번 구독하고 종료 시 해제한다. 여러 시스템이 각각 이벤트를 구독하면 같은 캐시를 중복 삭제하거나 회수 순서가 뒤섞일 수 있으므로 중앙 정책 객체를 두는 편이 안전하다.

using System;
using System.Collections;
using UnityEngine;

public sealed class MemoryPressureCoordinator : MonoBehaviour
{
    public enum PressureLevel
    {
        None = 0,
        Soft = 1,
        Aggressive = 2,
        Emergency = 3
    }

    private PressureLevel currentLevel;
    private bool isCollecting;

    private void OnEnable()
    {
        Application.lowMemory += OnLowMemory;
    }

    private void OnDisable()
    {
        Application.lowMemory -= OnLowMemory;
    }

    private void OnLowMemory()
    {
        if (isCollecting)
        {
            // 같은 경고가 연속으로 들어오면 다음 단계만 예약한다.
            currentLevel = PressureLevel.Emergency;
            return;
        }

        currentLevel = PressureLevel.Soft;
        StartCoroutine(CollectInStages());
    }

    private IEnumerator CollectInStages()
    {
        isCollecting = true;

        yield return CollectStage(PressureLevel.Soft, ClearTemporaryCaches);

        if (currentLevel >= PressureLevel.Aggressive)
        {
            yield return CollectStage(PressureLevel.Aggressive, ClearPreviewImages);
        }

        if (currentLevel >= PressureLevel.Emergency)
        {
            yield return CollectStage(PressureLevel.Emergency, ClearNonEssentialResources);
        }

        yield return CollectUnusedUnityAssets();
        isCollecting = false;
    }

    private IEnumerator CollectStage(PressureLevel level, Action action)
    {
        action?.Invoke();
        yield return null;
    }

    private void ClearTemporaryCaches()
    {
        RuntimeCache.Instance?.ClearDecodedResponses();
        ThumbnailCache.Instance?.TrimToCount(8);
        AudioClipCache.Instance?.TrimUnused();
    }

    private void ClearPreviewImages()
    {
        ShopPreviewCache.Instance?.ReleaseInactivePreviews();
        InventoryPreviewCache.Instance?.ReleaseExceptVisibleItems();
        RenderTexturePool.Instance?.ReleaseIdleTextures();
    }

    private void ClearNonEssentialResources()
    {
        PreloadManager.Instance?.ReleaseNextAreaAssets();
        VfxPool.Instance?.ReleaseOptionalEffects();
        OptionalAudioManager.Instance?.UnloadAmbientLayers();
    }

    private IEnumerator CollectUnusedUnityAssets()
    {
        AsyncOperation operation = Resources.UnloadUnusedAssets();
        yield return operation;
    }
}

위 예제의 핵심은 메모리 경고 이벤트 안에서 모든 작업을 동기적으로 실행하지 않는 것이다. 캐시 삭제 자체는 짧게 끝날 수 있지만 Resources.UnloadUnusedAssets()는 씬의 참조 그래프를 검사하므로 프레임을 멈출 수 있다. 코루틴으로 나누고 로딩 화면이나 안전한 전환 구간에서 실행할 수 있도록 설계한다.

GC.Collect()를 무조건 호출하는 것도 피해야 한다. 관리 힙 회수는 일시적인 CPU 비용과 프레임 정지를 유발할 수 있으며 텍스처·메시·오디오처럼 네이티브 메모리에 있는 데이터까지 직접 줄여 주지 않는다. 먼저 명시적인 참조 해제와 엔진 API 호출을 수행하고 꼭 필요한 경우에만 GC 정책을 추가한다.

Unity 캐시와 미리보기 리소스는 어떤 순서로 비울까?

1단계: 임시 캐시를 축소한다

네트워크 응답의 역직렬화 결과, 이미지 디코딩 버퍼, 검색 필터 결과, 최근 방문한 목록처럼 다시 만들 수 있는 데이터가 첫 번째 대상이다. 단순히 Dictionary.Clear()만 호출하지 말고 캐시가 보유한 Texture2D, Sprite, NativeArray의 수명도 함께 관리해야 한다.

public sealed class ThumbnailCache
{
    public static ThumbnailCache Instance { get; } = new ThumbnailCache();

    private readonly Dictionary<string, Texture2D> textures = new();

    public void TrimToCount(int keepCount)
    {
        if (textures.Count <= keepCount)
        {
            return;
        }

        List<string> candidates = FindLeastRecentlyUsedKeys(keepCount);

        foreach (string key in candidates)
        {
            if (textures.Remove(key, out Texture2D texture) && texture != null)
            {
                UnityEngine.Object.Destroy(texture);
            }
        }
    }

    private List<string> FindLeastRecentlyUsedKeys(int keepCount)
    {
        // 실제 프로젝트에서는 사용 시각 또는 LRU 링크를 기준으로 정렬한다.
        return textures.Keys.Take(Math.Max(0, textures.Count - keepCount)).ToList();
    }
}

실제 코드에서는 System.Collections.Generic, System.Linq를 추가하고 LRU 목록을 별도로 유지하는 편이 좋다. Texture2D를 캐시에서 제거한 뒤에도 UI의 Image.sprite나 머티리얼이 해당 텍스처를 참조하고 있으면 엔진이 메모리를 즉시 회수하지 않을 수 있다.

2단계: 보이지 않는 미리보기를 해제한다

상점, 인벤토리, 캐릭터 커스터마이징 UI는 한 화면에 보이는 것보다 훨씬 많은 아이템 미리보기를 미리 생성하는 경우가 많다. 다음 조건을 만족하는 미리보기부터 해제한다.

  • 현재 화면에 표시되지 않음
  • 선택 상태가 아님
  • 사용자가 최근에 스크롤한 영역에 속하지 않음
  • 같은 원본 에셋으로 다시 생성할 수 있음
  • 네트워크 다운로드보다 로컬 재생성 비용이 낮음

UI 가상화와 함께 사용하면 메모리 사용량을 더 안정적으로 제한할 수 있다. 예를 들어 화면에 보이는 12개 아이템과 앞뒤 버퍼 6개만 유지하고 나머지 썸네일은 ReleaseInactivePreviews()에서 제거한다.

3단계: 유휴 풀과 선택적 리소스를 정리한다

오브젝트 풀은 Instantiate 비용을 줄이는 데 효과적이지만 풀의 최대 크기가 기기 메모리에 비해 크면 오히려 메모리 압박의 원인이 된다. 메모리 경고가 오면 다음 풀을 우선 축소한다.

  • 이미 종료된 전투 이펙트 풀
  • 현재 무기와 무관한 투사체 풀
  • 보이지 않는 UI 팝업 풀
  • 다음 지역을 위한 장식 오브젝트 풀
  • 사용하지 않는 RenderTexture 풀

풀을 완전히 비우기보다 유휴 개수를 0 또는 작은 안전값으로 줄이고 현재 활성 객체는 유지해야 한다.

Addressables와 AssetBundle은 어떻게 안전하게 해제할까?

Addressables를 사용한다면 Addressables.Release() 호출 횟수가 로드 횟수와 대응해야 한다. 핸들을 잃어버린 상태에서 임의로 번들을 언로드하면 현재 사용 중인 머티리얼이나 텍스처가 깨질 수 있다.

정책 객체는 로드 핸들을 직접 추적하고 다음 조건을 모두 만족하는 그룹만 회수해야 한다.

검사 항목회수 가능 조건
참조 횟수외부 참조가 0
현재 씬 사용 여부현재 씬에서 사용하지 않음
스트리밍 상태다운로드·압축 해제 중이 아님
재생성 경로다시 로드할 키 또는 번들이 존재함
플레이 상태해당 리소스를 사용하는 애니메이션·VFX가 종료됨

간단한 래퍼를 두면 해제 책임을 명확히 할 수 있다.

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

public sealed class AddressableLease<T> where T : UnityEngine.Object
{
    private AsyncOperationHandle<T> handle;
    private bool released;

    public T Asset => handle.IsValid() ? handle.Result : null;

    public AddressableLease(AsyncOperationHandle<T> handle)
    {
        this.handle = handle;
    }

    public void Release()
    {
        if (released || !handle.IsValid())
        {
            return;
        }

        released = true;
        Addressables.Release(handle);
    }
}

이 래퍼는 참조 카운팅 전체를 대신하지 않는다. 화면, 씬, 시스템이 동일한 에셋을 공유한다면 소유권을 별도로 관리해야 한다. 특히 AssetReference를 여러 UI 셀에서 공유하는 구조에서는 셀 하나가 닫혔다고 곧바로 Release하면 안 된다.

메모리 경고 처리 흐름은 어떻게 구성할까?

메모리 압박이 반복해서 발생하는 상황을 고려하면 경고마다 같은 단계만 실행하는 구조보다 현재 상태를 승격시키는 상태 머신이 적합하다.

flowchart TD
    A[iOS memory warning 또는 Application.lowMemory] --> B{회수 중인가?}
    B -- 아니오 --> C[Soft: 임시 캐시 축소]
    B -- 예 --> D[회수 목표를 Emergency로 승격]
    C --> E{추가 경고 또는 메모리 부족 지속?}
    E -- 아니오 --> F[메모리 지표 기록 후 종료]
    E -- 예 --> G[Aggressive: 미리보기와 유휴 풀 해제]
    G --> H{여전히 위험한가?}
    H -- 아니오 --> F
    H -- 예 --> I[Emergency: 비필수 에셋 해제 및 UnloadUnusedAssets]
    I --> F

단계 전환 조건은 메모리 경고 횟수만으로 결정하지 않는 편이 좋다. 가능하다면 다음 지표를 함께 기록한다.

  • 경고 발생 시각과 씬 이름
  • 현재 씬과 로딩 중인 씬
  • 메모리 사용량과 그래픽 메모리 추정치
  • 캐시 항목 수와 해제한 바이트 수
  • Resources.UnloadUnusedAssets() 소요 시간
  • 다음 프레임의 평균 프레임 시간
  • 경고 이후 크래시 또는 재실행 여부

iOS의 메모리 사용량을 정확히 하나의 수치로 계산하기는 어렵다. Unity Profiler의 총 메모리와 텍스처 메모리, Xcode Instruments의 Allocations·VM Tracker, Metal 관련 지표를 함께 사용해 원인을 분리해야 한다.

iOS 네이티브 알림을 직접 연결해야 할까?

대부분의 Unity 게임은 Application.lowMemory로 충분하다. 네이티브 플러그인이나 앱 델리게이트에서 UIKit 알림을 직접 처리해야 한다면 UIApplication.didReceiveMemoryWarningNotification을 관찰하거나 applicationDidReceiveMemoryWarning:에서 Unity 쪽 콜백을 호출할 수 있다.

다만 다음 문제가 생길 수 있으므로 네이티브 알림과 Application.lowMemory를 동시에 별도 처리하지 않는다.

  • 하나의 메모리 경고에 회수 로직이 두 번 실행됨
  • Objective-C와 C# 양쪽에서 같은 캐시를 해제함
  • 앱 생명주기 초기화 전에 콜백이 실행됨
  • 메인 스레드가 아닌 위치에서 Unity 오브젝트를 조작함

네이티브 계층은 알림을 수집하고 C# 계층은 실제 리소스 회수를 담당하는 식으로 책임을 나눈다. Unity 오브젝트와 대부분의 엔진 리소스 조작은 Unity 메인 스레드에서 실행해야 한다.

Unreal Engine에서는 어떤 방식으로 적용할까?

Unreal Engine 프로젝트도 원리는 같다. 플랫폼별 메모리 경고 이벤트를 게임 모듈 또는 플랫폼 계층에서 수신한 뒤 게임 스레드에서 중앙 메모리 압박 매니저로 전달한다.

회수 대상으로는 다음을 고려할 수 있다.

  • TObjectPtr 또는 UObject 기반 임시 미리보기 객체
  • 비활성 Slate 위젯과 브러시 리소스
  • 사용하지 않는 UTexture2D 스트리밍 리소스
  • Niagara 시스템의 선택적 풀
  • 현재 맵에 필요하지 않은 스트리밍 레벨
  • 다시 로드할 수 있는 비동기 로딩 핸들

Unreal에서 UObject 포인터를 단순히 nullptr로 바꾸는 것만으로 리소스가 즉시 회수된다고 가정하면 안 된다. 참조 그래프, UPROPERTY 보유 여부, 비동기 로딩 상태, 렌더 스레드 사용 여부를 확인해야 한다. 텍스처 스트리밍과 레벨 스트리밍을 사용하는 프로젝트라면 메모리 압박 단계에서 다음 스트리밍 우선순위를 낮추거나 비필수 스트리밍 요청을 취소하는 어댑터를 별도로 둔다.

플랫폼 콜백의 정확한 이름은 사용하는 Unreal Engine 버전과 iOS 플랫폼 계층에 따라 달라질 수 있으므로 프로젝트 버전에 맞는 FCoreDelegates, FPlatformMisc, iOS application delegate 연동 코드를 확인해야 한다. 중요한 계약은 다음 세 가지다.

  1. 플랫폼 알림은 중앙 매니저에 한 번만 전달한다.
  2. 리소스 해제는 해당 리소스의 소유 시스템이 수행한다.
  3. 렌더 스레드와 게임 스레드의 수명 규칙을 지킨다.

구현 전에 확인해야 할 테스트 시나리오는 무엇인가?

메모리 경고는 일반적인 기능 테스트만으로 재현하기 어렵다. iOS 실기기와 Xcode Instruments를 사용해 경고 전후를 비교하고 다음 시나리오를 반복한다.

Step 1. 경고 직전의 기준 상태를 기록한다

메인 메뉴, 전투, 상점, 인벤토리, 로딩 전환처럼 메모리 구성이 다른 구간에서 기준값을 수집한다. 현재 씬, 로딩 핸들, 활성 미리보기 수, 텍스처 수, 오디오 캐시 크기를 함께 기록한다.

Step 2. 각 회수 단계의 결과를 검증한다

Level 1만 실행했을 때 현재 UI와 게임플레이가 유지되는지 확인한다. 이후 Level 2와 Level 3에서 미리보기 재생성, 풀 재할당, RenderTexture 재생성에 문제가 없는지 검사한다.

Step 3. 연속 경고와 전환 상황을 테스트한다

다음 상황에서 예외와 검은 화면이 발생하지 않아야 한다.

  • 메모리 경고가 짧은 시간에 여러 번 들어옴
  • 씬 전환 중 경고가 들어옴
  • 상점 미리보기 다운로드 중 경고가 들어옴
  • 백그라운드 전환 직후 앱이 복귀함
  • 비동기 Addressables 로드와 해제가 동시에 실행됨
  • 현재 선택한 아이템의 텍스처가 회수 후보가 됨

테스트 통과 기준은 단순히 앱이 종료되지 않는 것이 아니다. 회수 후 미리보기가 정상적으로 다시 생성되는지 입력이 멈추지 않는지 프레임 시간이 허용 범위에 있는지 반복 방문할 때 메모리가 계속 증가하지 않는지를 함께 확인해야 한다.

자주 묻는 질문 (FAQ)

Application.lowMemory가 호출되면 모든 캐시를 지워야 하나요?

아니다. 현재 씬과 입력에 필요한 데이터는 유지하고 재생성 비용이 낮은 임시 캐시와 비활성 미리보기부터 단계적으로 줄이는 것이 안전하다.

Resources.UnloadUnusedAssets()만 호출하면 충분한가요?

아니다. 참조가 남은 리소스, Addressables 핸들, 네이티브 플러그인 메모리, GPU 리소스는 별도의 소유권과 해제 코드가 필요하다.

메모리 경고가 오면 GC.Collect()를 호출해도 되나요?

필수 동작은 아니다. GC.Collect()는 관리 힙에는 영향을 줄 수 있지만 CPU 정지와 프레임 드롭을 만들 수 있다. 먼저 명시적인 캐시·에셋·렌더 리소스 해제를 수행하고 프로파일링 결과가 있을 때 제한적으로 사용한다.

메모리 경고 후에도 앱이 종료되면 어떻게 해야 하나요?

경고 수신 로그만 보지 말고 Xcode의 Jetsam 관련 종료 정보, Instruments의 Allocations와 VM Tracker, Unity Profiler의 텍스처·메시·오디오 메모리를 함께 분석해야 한다. 회수 단계가 실행됐는지와 실제로 어떤 메모리가 줄었는지를 수치로 확인해야 한다.

정리

iOS 메모리 경고 대응의 목표는 한 번에 모든 데이터를 삭제하는 것이 아니라 앱이 계속 실행될 가능성을 높이면서 사용자 경험을 보존하는 것이다. Unity에서는 Application.lowMemory를 단일 진입점으로 삼고 임시 캐시, UI 미리보기, 유휴 풀, 비필수 에셋 순서로 회수한다.

각 시스템은 자신이 소유한 리소스를 해제하고 중앙 코디네이터는 순서와 중복 실행을 관리해야 한다. 마지막으로 Xcode Instruments와 Unity Profiler를 통해 회수 전후의 실제 메모리, 프레임 시간, 재생성 비용을 검증하면 기기별 메모리 압박에도 대응 가능한 클라이언트 정책을 만들 수 있다.