에셋 프로바이더

Addressables 기반 비동기 에셋 로딩 — 키별 레퍼런스 카운팅과 스코프 단위 일괄 정리를 제공합니다.

한 줄이면 되는 비동기 에셋 로딩 — 자동 공유와 누수 없는 정리까지.

인터페이스IAssetProvider
끄기 스위치NullAssetProvider
어셈블리CommonGameSystem.Assets (선택 — Addressables 패키지 필요)
시작부팅 시 자동 등록 — 별도 설정 불필요

하는 일

에셋 프로바이더는 Unity의 Addressables 시스템을 단순한 레퍼런스 카운팅 인터페이스 뒤로 감쌉니다. 주소 문자열로 에셋을 로드하면 그 키를 사용하는 모든 호출자가 동일한 로드된 에셋을 공유합니다 — 레퍼런스 카운트가 몇 명의 호출자가 들고 있는지 추적하고, Release(key)는 마지막 호출자가 해제했을 때만 언로드합니다. 덕분에 "이 텍스처는 누가 언로드하지?"라는 조율 문제가 프레임워크가 대신해 주는 장부 정리로 바뀝니다.

로드를 스코프로 묶을 수도 있습니다: CreateScope()로 스코프를 만들고 그 스코프를 통해 로드하면, 스코프를 dispose하는 한 번의 호출로 스코프가 로드한 모든 것이 해제됩니다. 스코프는 "이 화면에 필요했던 모든 것"을 담는 자연스러운 단위입니다.

메모리 예산도, 자동 축출도 없습니다 — 로드하고, 레퍼런스를 세고, 스코프 단위로 정리할 뿐입니다. 여러분이 들고 있는 것이 곧 메모리에 남는 것입니다.

이 서비스는 선택적 CommonGameSystem.Assets 어셈블리에 들어 있습니다. 프로젝트에서 Addressables 패키지를 제거하면 이 어셈블리는 스스로 컴파일에서 빠지고, 프레임워크의 나머지는 그대로 컴파일됩니다.

빠른 시작

Awake에서 서비스를 resolve해 캐시하세요:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class MyGameManager : MonoBehaviour
{
    private IAssetProvider _assets;

    private void Awake()
    {
        _assets = ServiceLocator.Resolve<IAssetProvider>();
    }

    private async void OnEnable()
    {
        var prefab = await _assets.LoadAsync<GameObject>("assets/my-prefab");
        if (prefab != null)
            Instantiate(prefab);
    }
}

짧은 별칭도 사용할 수 있습니다: IAssetProvider assets = SL.Resolve<IAssetProvider>();

API 레퍼런스

Load / Release (레퍼런스 카운팅)

  • Task<T> LoadAsync<T>(string key, CancellationToken ct = default) — 주어진 주소의 에셋을 T 타입(GameObject, ScriptableObject, Sprite, TextAsset, AudioClip 등)으로 로드합니다. 같은 키를 다시 로드하면 같은 에셋을 반환하며 레퍼런스 카운트가 올라갑니다. 반환되는 Task<T> 자체는 절대 null이 아닙니다. 런타임 실패 — 없는 키나 실패한 로드 — 는 default(T)를 반환하고 경고를 로그하며, 절대 throw하지 않습니다.
  • void Release(string key) — 키의 레퍼런스 카운트를 내립니다. 카운트가 0에 도달하면 에셋이 언로드됩니다. 이미 0이라면 경고를 로그하고 아무것도 하지 않으며, 절대 throw하지 않습니다.

Preload / 조회

  • Task PreloadAsync<T>(string key, CancellationToken ct = default) — 에셋을 반환하지 않고 캐시에 로드해 붙잡아 둡니다. LoadAsync<T>와 동일한 레퍼런스 카운팅을 사용하므로 나중의 Release(key)와 짝을 맞추세요. 현재 씬이 진행 중인 동안 다음 씬의 에셋을 미리 데워 두는 데 유용합니다.
  • bool IsLoaded(string key) — 키의 레퍼런스 카운트가 0보다 크면 true를 반환합니다. throw하지 않고, 로그도 남기지 않습니다.
  • int LoadedCount { get; } — 현재 보유 중인 서로 다른 키의 개수입니다. 누수 신호로 쓰세요: 모든 레퍼런스 카운트의 합이 아니라 키 개수를 셉니다.

스코프

  • IAssetScope CreateScope() — 스코프를 만듭니다. 스코프를 통해 로드하면 Dispose()가 스코프가 로드한 모든 것을 해제합니다 — 기록된 로드 하나당 Release(key) 한 번씩.
  • IAssetScope — 동일한 LoadAsync<T>, PreloadAsync<T>, IsLoaded, LoadedCount 멤버에 더해, 일괄 정리를 위한 Dispose()를 제공합니다.

예제

씬의 프리팹들을 로드하고, 작업이 끝나면 한 번에 정리하기:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class ScenePopulator : MonoBehaviour
{
    private IAssetProvider _assets;

    private async void Start()
    {
        _assets = ServiceLocator.Resolve<IAssetProvider>();
        using var scope = _assets.CreateScope();

        var player = await scope.LoadAsync<GameObject>("assets/player");
        var ui = await scope.LoadAsync<GameObject>("assets/ui-root");

        if (player != null) Instantiate(player, transform);
        if (ui != null) Instantiate(ui);

        // When the scope is disposed, it releases both
        // "assets/player" and "assets/ui-root".
    }
}

공유된 키에서 레퍼런스 카운팅이 동작하는 방식:

var go1 = await _assets.LoadAsync<GameObject>("assets/coin");  // reference count 1
var go2 = await _assets.LoadAsync<GameObject>("assets/coin");  // reference count 2, SAME asset
_assets.Release("assets/coin");  // reference count 1
_assets.Release("assets/coin");  // reference count 0 — the asset unloads

끄는 방법

ServiceLocator.Replace<IAssetProvider>(new NullAssetProvider());

이렇게 하면 모든 로딩이 무음 처리됩니다. LoadAsync<T>()default(T)를 담은 이미 완료된 태스크를 반환하고, IsLoaded는 항상 false, LoadedCount는 항상 0입니다. Addressables는 전혀 건드리지 않습니다. 테스트에서, 또는 에셋 기능을 통째로 끌 때 유용합니다. 생성 시 한 번만 — 호출마다가 아니라 — 경고를 로그하므로 활성화 여부를 알 수 있습니다.

흔한 함정

  • 레퍼런스 카운트가 유일한 언로드 트리거입니다. 메모리 예산도, LRU(least-recently-used) 축출도, 언로드 타이머도 없습니다. 로드하고 해제하지 않으면 에셋은 메모리에 남습니다. LoadedCount를 지켜보며 누수를 잡으세요.
  • LoadAsync<T>는 컴파일 타임 제네릭 메서드입니다. IL2CPP 빌드에서는 로드하는 에셋 타입(GameObject, ScriptableObject, Sprite, TextAsset, AudioClip)이 프로젝트의 link.xml에 보존되어야 합니다. 패키지 README의 IL2CPP 섹션에 정확한 항목이 나와 있습니다.
  • 스코프는 진행 중인 로드도 붙잡습니다. 스코프의 로드 중 하나가 아직 실행 중일 때 스코프를 dispose하면, 그 로드는 정상적으로 완료된 뒤 즉시 해제됩니다. 고아가 되는 것은 아무것도 없습니다.
  • 타입 불일치는 안전합니다. 어떤 키를 처음에 LoadAsync<GameObject>("key")로 로드했는데 나중에 LoadAsync<Sprite>("key")를 호출하면, 두 번째 호출은 null을 반환하고 경고를 로그합니다 — 예외는 없습니다. 레퍼런스 카운트는 타입이 아니라 키 이름에 속합니다.
  • null이거나 빈 키는 코드의 버그로 취급됩니다. LoadAsync(null)ArgumentNullException을, 공백뿐인 키는 ArgumentException을 throw합니다. 절대 throw하지 않는 위의 런타임 데이터 오류와 달리, 이것은 프로그래밍 오류입니다.
  • 메인 스레드 전용입니다. Release, IsLoaded, CreateScope, 그리고 스코프의 Dispose를 포함한 모든 public 멤버는 메인 스레드에서 실행해야 합니다. 워커 스레드에서는 스케줄러Post()로 호출을 메인 스레드로 되돌리세요.

관련 페이지