공용 스크립트 제작기🎈 - 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: 디버그 정보 콘솔 출력
🔄 데이터 흐름
리소스 로드 프로세스
LoadAssetAsync<T>(address)호출- 이미 로드된 경우?
- Yes → 참조 카운트 증가 후 즉시 반환
- No → 3번으로
- 로딩 중인 작업이 있는 경우?
- Yes → 해당 작업 완료까지 대기 후 참조 증가
- No → 4번으로
- 새로 로드
- UniTaskCompletionSource 생성
- AssetLoadCache에 로딩 작업 등록
- Addressables.LoadAssetAsync() 실행
- 성공 시: AssetReferenceTracker에 핸들 저장 (참조 1)
- 완료 후: AssetLoadCache에서 제거
리소스 해제 프로세스
- Release(address) 호출
- AssetReferenceTracker.DecreaseReference()
- 참조 카운트 감소
- 참조 카운트가 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를 호출해야 실제로 메모리에서 해제된다.
결론
이것저것 많이 바꿔가면서 생각해봤는데 이렇게 리소스만 딱 관리하게 두는게 가장 깔끔한 것 같다. 사용하면서 더 바뀔지도 모르겠지만… 일단은 이렇게 두자.