공용 스크립트 제작기🎈 - Addressable 편

공용 스크립트 제작기🎈 - Addressable 편

Unity 프로젝트의 공용으로 사용할 수 있는 스크립트를 제작하면서 어떻게 만들었는지, 어떤 점에 신경썼는지 설명하는 글입니다.

Unity에서 리소스를 로드하다 보면 Addressable 시스템을 사용하게 되는데, 지금껏 그냥 대충 사용해보다가 좀 더 관리를 할 수 있게 만들어보자 싶었다. 그래서 이번에는 참조 카운팅, 중복 로드 방지, 디버깅 기능까지 갖춘 AddressableLoader를 만들어보았다.

💡예시

간단하게 먼저 사용 예시부터 소개해보자면 다음과 같다.

public class GameManager : MonoBehaviour
{
    private CancellationTokenSource cts;

    async void Start()
    {
        cts = new CancellationTokenSource();

        // 단일 리소스 로드
        var prefab = await AddressableLoader.Instance
            .LoadAssetAsync<GameObject>("Player_Prefab", cts.Token);

        // 라벨로 여러 리소스 일괄 로드
        var items = await AddressableLoader.Instance
            .LoadAssetsAsync<ItemData>("Item_Label", cts.Token);

        // 프리로드 (미리 로딩)
        await AddressableLoader.Instance
            .PreloadByLabelAsync("Stage1_Assets", cts.Token);
    }

    void OnDestroy()
    {
        // 리소스 해제
        AddressableLoader.Instance.Release("Player_Prefab");

        // 또는 모든 리소스 일괄 해제
        AddressableLoader.Instance.ReleaseAll();

        cts?.Cancel();
        cts?.Dispose();
    }
}

이렇게 사용하면 된다. 내부적으로 참조 카운팅, 중복 로드 방지, 디버깅까지 자동으로 처리되도록 만들었다.

📊 클래스 다이어그램

AddressableLoader (Facade 패턴)
├─ AssetReferenceTracker
│   └─ ReferenceCounter
├─ AssetLoadCache
└─ AddressableDebugger

Facade 패턴을 사용하여 복잡한 서브시스템들을 AddressableLoader라는 단일 인터페이스로 감쌌다.

📁 시스템 아키텍처

핵심 컴포넌트

AddressableLoader

전체 Addressable 시스템을 관리하는 싱글톤 매니저. LazyMonoSingleton로 구현함.

  • 역할: 서브시스템들을 조합하여 단일 인터페이스 제공
  • 주요 기능:
    • 리소스 로드/해제
    • 라벨 기반 일괄 로드
    • 프리로드
    • 디버깅 정보 제공
AssetReferenceTracker

Addressable 에셋의 참조 카운팅을 관리.

  • 역할: 참조 카운트 기반 자동 해제
  • 주요 기능:
    • Add: 새 에셋 등록 (참조 1로 시작)
    • Increase: 참조 카운트 증가
    • Decrease: 참조 카운트 감소 (0이 되면 자동 해제)
    • TryGet: 기존 핸들 조회
  • 내부 구조:
    • ReferenceCounter<string, AsyncOperationHandle>: 참조 카운터
    • Dictionary<string, Type>: 에셋 타입 정보
AssetLoadCache

중복 로드를 방지하기 위한 로딩 작업 캐시.

  • 역할: 동일 Address를 동시 로드할 때 작업 공유
  • 주요 기능:
    • TryGet: 로딩 중인 작업 조회
    • Register: 로딩 작업 등록
    • Complete: 로딩 완료 후 캐시 제거
  • 내부 구조:
    • Dictionary<string, UniTask Object>: 로딩 중인 작업들
AddressableDebugger

디버깅 정보를 수집하고 출력한다.

  • 역할: 로드된 리소스 정보 제공
  • 주요 기능:
    • GetLoadedAssets: 로드된 모든 에셋 정보 반환
    • GetLoadedCount: 로드된 에셋 개수
    • GetLoadingCount: 로딩 중인 작업 개수
    • PrintDebugInfo: 디버그 정보 콘솔 출력

🔄 데이터 흐름

리소스 로드 프로세스
  1. LoadAssetAsync<T>(address) 호출
  2. 이미 로드된 경우?
    • Yes → 참조 카운트 증가 후 즉시 반환
    • No → 3번으로
  3. 로딩 중인 작업이 있는 경우?
    • Yes → 해당 작업 완료까지 대기 후 참조 증가
    • No → 4번으로
  4. 새로 로드
    • UniTaskCompletionSource 생성
    • AssetLoadCache에 로딩 작업 등록
    • Addressables.LoadAssetAsync() 실행
    • 성공 시: AssetReferenceTracker에 핸들 저장 (참조 1)
    • 완료 후: AssetLoadCache에서 제거
리소스 해제 프로세스
  1. Release(address) 호출
  2. AssetReferenceTracker.DecreaseReference()
  3. 참조 카운트 감소
  4. 참조 카운트가 0이 되면?
    • Addressables.Release(handle) 호출
    • assetTypes에서 제거

🎯 신경 쓴 부분

중복 로드 방지

동일한 Address를 여러 곳에서 동시에 로드할 때, 하나의 작업만 실행하고 결과를 공유하도록 했다.

// 이미 로딩 중인 작업이 있으면 대기 (중복 로드 방지)
if (loadCache.TryGetLoadingTask(address, out var loadingTask))
{
    Log($"[AddressableLoader] 로딩 중인 작업 대기: {address}");

    // 이미 로딩 중이면 완료될 때까지 대기
    var result = await loadingTask;

    // 로드 완료 후 참조 증가
    referenceTracker.IncreaseReference(address);
    return result as T;
}

// 새로 로드
var taskCompletionSource = new UniTaskCompletionSource<UnityEngine.Object>();
loadCache.RegisterLoadingTask(address, taskCompletionSource.Task);

예를 들어, 동시에 100개의 스크립트가 같은 리소스를 요청해도 실제로는 1번만 로드하고 나머지는 결과를 기다렸다가 받는다.

참조 카운팅 메모리 관리

ReferenceCounter를 사용하여 참조 카운트가 0이 될 때만 실제로 해제한다.

public AssetReferenceTracker()
{
    // 참조 카운트가 0이 되면 Addressables.Release 호출
    referenceCounter = new ReferenceCounter<string, AsyncOperationHandle>(
        onReleaseCallback: (address, handle) =>
        {
            if (handle.IsValid())
            {
                Addressables.Release(handle);
                Log($"[AssetReferenceTracker] 리소스 실제 해제: {address}");
            }
            assetTypes.Remove(address);
        },
        logName: "AssetReferenceTracker"
    );
}

따라서 여러 곳에서 같은 리소스를 사용하더라도, 모든 곳에서 Release를 호출해야 실제로 메모리에서 해제된다.


결론

이것저것 많이 바꿔가면서 생각해봤는데 이렇게 리소스만 딱 관리하게 두는게 가장 깔끔한 것 같다. 사용하면서 더 바뀔지도 모르겠지만… 일단은 이렇게 두자.


© 2022. All rights reserved.