Unity/Blue Archive 2차 창작 프로젝트

[Unity- Operation Kivotos] 3. 리소스 & 비동기 로딩

Vfly 2026. 3. 3. 04:59

https://github.com/vfly1189/OperationKivotos

 

GitHub - vfly1189/OperationKivotos: 유니티 공부용 창작 프로젝트

유니티 공부용 창작 프로젝트. Contribute to vfly1189/OperationKivotos development by creating an account on GitHub.

github.com


서론

아마 이전 글에서 매니저 각각에 대해 자세히 알아본다고 했었는데, 뭔가 매니저 시스템들이 계속 변경될 부분이 많을 것 같아서 나중에 한번에 정리하기로 하고, 이번에는 프로젝트 시작부터 매우 귀찮고 프로그램이 계속 터졌던 원인이 비동기로딩과 리소스 관리쪽에 관한 내용을 정리해 보겠다.

 

아마 이 부분은 유니티를 필자처럼 유니티를 처음 시작하는 학생들에게 도움이 될만한 로드맵이나 오류는 찾는 가이드맵? 정도의 역할이 되었으면 좋겠다.

 

그래서 이 글은 약간 연대기 느낌으로 프로젝트 시작부터 지금까지 리소스와 비동기로딩을 작업했던 순서대로 작성을 해보려고한다.

 


본론

전체적인 흐름은 다음과 같다.

 

(문자열 경로 → Inspector → Addressables(Task) → UniTask 안정화)

 

프로젝트를 진행하면서 "리소스" 이 녀석을 관리하는 일이 가장 힘들었다.

 

이전 모든 프로젝트들은 리소스의 양이 많지 않다보니 그냥 프로그램 시작할때 모든 리소스들을 메모리에 올리는 방식으로 작업했었다. 하지만 이 방식은 효율이나 구조 따위는 없는 가장 간단하면서도 가장 무식한 방법이라서 그렇게 좋은 방법이 아니다.

 

 

1) Scene별 문자열 경로 배열 + LoadingScene에서의 로딩

목표

  • 다음 씬이 필요로 하는 리소스를 미리 정의하고, LoadingScene에서 미리 로딩한 뒤 다음 Scene에서 사용
  • 핵심은 "Scene이 필요한 리소스를 안다" + "LoadingScene이 대신 로드한다(실행)"

 

내가 했던 방식

  • StartScene을 제외한 모든 Scene이 "REQUIRED_RESOURCES" 문자열 배열의 형태로 리소스 경로를 저장하고 있다.
  • [이전 씬 LoadingScene 다음 씬] 의 흐름에서 LoadingScene이 미리 다음 씬의 REQUIRED_RESOURCES 배열을 읽어서 동기or비동기 방식으로 리소스들을 로딩한 뒤 다음 씬의 Dictionary로 소유하는 방식.
  • 로딩된 리소스는 다음 씬이 가지고, 씬 전환 매니저나 LoadingScene은 로드된 리소스를 전달하는 방식.
public class SelectScene : BaseScene
{
    public static readonly string[] REQUIRED_RESOURCES = new string[]
    {
        "Characters/Abydos/Hoshino",
        "Characters/Abydos/Nonomi",
        "Characters/Abydos/Shiroko",
        "Characters/Abydos/Serika",
        "Characters/Gehenna/Aru",
        "Characters/Gehenna/Hina",
        "Characters/Gehenna/Ako",
        // ...
    };
}

 

이전 Scene에서 LoadingScene으로 리로스 요청 전달

void OnStartButtonClicked()
{
    Managers.SceneEx.LoadScene(Define.Scene.Select, SelectScene.REQUIRED_RESOURCES);
}

 

문제상황

처음에는 위와 같은 방법으로 리소스들을 로딩했었는데.... 이 방법에는 크나큰 문제들이 몇가지 있다.

  • 다음 Scene 오브젝트가 없는데 해당 문자열 배열을 가져올려고 하니 에러가 뜨거나 아예 실행이 안됨
  • 해당 Scene에 리소스가 추가되면 수작업으로 하나씩 경로를 추가해줘야됨

이 방법의 문제를 해결해보려고 했지만, 아무리 다시 생각해도 이 방식은 2번 문제 때문이라도 말이 안되는 방식이라고 생각해서 처음부터 다시 생각해보기로 했다.

 

 

2) ResourceManager  + (SerializeField + Insepector) 연결

목표

  • ResourceManager 중앙화 : Scene마다 중구난방이던 로딩 로직을 ResourceManager로 통합하고, 한 번 로드한건 Dictionary에 캐싱하여 재사용
  • 직접 참조 병행 : 무조건 있어야 하는 필수 UI나 오브젝트는 문자열 오타 위험을 피하고자 SerializeField를 이용해 Inspector에서 직접 연결 하는 방식을 섞어서 사용

내가 했던 방식

  • 동기 로딩 및 캐싱 : 런타임에 필요한 리소스는 Managers.Resource.Load<T>(path)를 통해 부르고, 내부적으로 Resource.Load를 호출한 뒤 Dictionary에 캐싱
  • Inspector 연결 : 필요한 Prefab이 있을 경우 가능한 SerializeField를 이용해 연결해서 사용함.
public class ResourceManager
{
    Dictionary<string, UnityEngine.Object> _resources = new();

    public T Load<T>(string path) where T : UnityEngine.Object
    {
        if (_resources.TryGetValue(path, out var cached))
            return cached as T;

        T loaded = Resources.Load<T>(path);
        if (loaded != null)
            _resources[path] = loaded;

        return loaded;
    }
}

 

문제상황

이 방법도 첫번째 방법과 비슷한 문제들이 있었다.

  1. 하드 레퍼런스의 유지보수 지옥 ( SerializeField의 한계 )
    • Inspector 상에서 수작업으로 수십 개의 프리팹을 하나씩 끌어다 연결해야 함.
    • 오브젝트가 변경되거나 참조가 끊기면 에디터에서 눈치채기 어렵고, 실행 시 바로 NullReferenceException 버그로 터짐
    • 처음에 "오...개꿀!!" 하면서 사용했었는데 연결 끊길때마다 다시 연결하는게 매우 매우 귀찮았음.
  2. 문자열 기반 로딩의 불안정성 ( Resources.Load 방식의 한계 )
    • 오타가 있거나 Instantiate 시 내부적으로 "Prefabs/" 같은 경로 규칙이 엇갈리면, 분명 로딩을 했는데 캐시 Key 불일치로 꺼내오지 못하는 문제들이 발생.
    • 폴더 구조를 바꾸면 수동으로 바꾸는 부분도 매우 귀찮아짐.
  3. 메모리 제어 불가 ( 핵심 )
    • 해당 오브젝트가 화면에 보이지 않더라도, 심지어 게임 내내 사용하지 않더라도 SerializeField로 한 번 연결된 에셋들은 Scene이 로드되는 순간 강제로 VRAM과 RAM에 동반 로드된다.
    • Resources.UnloadUnusedAssets()를 호출해도, Inspector에서 꽉 쥐고 있는 상태라 해당 Scene이 완전히 파괴되기 전까지는 절대 메모리에서 내려가지 않고 상주하는 심각한 구조적 결함이 있다.
    • 초반에는 사용된 리소스들이 몇개 없었는데 시간이 가면 갈수록 Scene이 시작되는 시간이 점차 늦어지는게 체감이 될 정도였다.

 

3) Addressable 도입 + SceneDataSO ( 비동기 로딩과 메모리 제어 )

목표

  • 메모리 상주 문제 해결: Inspector 연결 방식의 치명적인 단점인 Scene이 파괴되기 전까지 강제로 메모리에 계속 올라가 있는 현상을 해결하기 위함.
  • 비동기 로딩(Task) 도입: 게임의 멈춤 현상없이 백그라운드에서 리소스를 로드하고, 필요 없을 때  메모리에서 내릴 수 있는 유연한 구조 설계.
  • 로딩 분기: 씬마다 필요한 프리로드 대상을 하드코딩하지 않고 SceneDataSO로 외부화하여 관리.

내가 했던 방식

  1. "Scene 종속성"에서 "핸들(Handle) 기반 독립 관리"로 전환
    1. 가장 큰 문제였던 메모리 강제 상주를 피하기 위해, 에셋들을 Addressable로 전환하고 문자열(Key/Label)로만 참조하게 만들었다.
    2. 이를 통해 씬이 시작될 때 불필요한 리소스가 한꺼번에 올라가는 것을 막고, ResourceManager에서 Task를 활용한 비동기 메서드(LoadAsync)로 필요한 타이밍에만 메모리에 올릴 수 있게 되었다.
  2. 메모리 해제
    1. Inspector 연결 방식에서는 가비지 컬렉터나 Scene 파괴만 기다려야 했지만, Addressable은 레퍼런스 카운팅(Reference Counting) 방식을 지원한다.
    2. 비동기로 로드할 때 반환받은 비동기 핸들(AsyncOperationHandle)을 ResourceManager가 캐싱하고 있다가, 특정 리소스가 더 이상 필요 없거나 Scene을 전환할 때 Addressables.Release(handle)를 호출하여 Scene 파괴 전이라도 메모리를 해제할 수 있게 되었다.
  3. SceneDataSO를 통한 비동기 프리로드(Preload)
    1. Scene 진입 시 발생할 수 있는 팝인(Pop-in)을 막기 위해 LoadingScene에서 비동기 로딩을 수행해야 했다. 이때 분기문 지옥을 피하기 위해 SceneDataSO에 해당 씬에서 필요한 Addressable 라벨 배열을 세팅했다.
    2. LoadingScene은 단지 이 SO 데이터를 건네받아 비동기로 모두 메모리에 올릴 때까지 부드러운 로딩바를 보여주기만 하면 되었다.

 

ResourceManager의 비동기 로드와 캐싱 ( Task 기반 )

 

Scene의 생명주기와 상관없이, 내가 원할 때 비동기로 올리고 핸들을 가지고 있는 로직

public class ResourceManager
{
    // 로드된 에셋의 핸들을 쥐고 있어서 언제든 메모리에서 날릴 수 있음
    private Dictionary<string, AsyncOperationHandle> _handles = new();

    // Task 기반의 비동기 로딩
    public async Task<T> LoadAsync<T>(string key) where T : UnityEngine.Object
    {
        if (_handles.ContainsKey(key))
        {
            return _handles[key].Result as T; // 이미 있으면 즉시 반환
        }

        var handle = Addressables.LoadAssetAsync<T>(key);
        await handle.Task; // 로딩 씬이 멈추지 않게 비동기 대기

        if (handle.Status == AsyncOperationStatus.Succeeded)
        {
            _handles[key] = handle;
            return handle.Result;
        }

        return null;
    }
    
    // 씬 파괴 여부와 상관없이 즉시 메모리에서 날려버릴 수 있는 함수
    public void Release(string key)
    {
        if (_handles.TryGetValue(key, out var handle))
        {
            Addressables.Release(handle); // 레퍼런스 카운트 차감 및 즉시 언로드
            _handles.Remove(key);
        }
    }
}

 

SceneDataSO와 LoadingScene의 연동

각 Scene 데이터에 라벨을 정의하고, 이를 비동기로 한 번에  퍼올리는 구조.

// 던전 씬 전용 SO 에셋 (인스펙터에서 "Preload_Dungeon", "Preload_Monster" 할당)
[CreateAssetMenu(menuName = "SceneData/NormalDungeon")]
public class NormalDungeonSceneDataSO : SceneDataSO
{
    public string dungeonType;
}

// LoadingScene의 비동기 로딩 처리
public async Task LoadDependenciesAsync(SceneDataSO sceneData)
{
    if (sceneData == null || sceneData.preloadLabels == null) return;

    // 전달받은 라벨들에 해당하는 에셋들을 전부 백그라운드에서 로드
    foreach (string label in sceneData.preloadLabels)
    {
        var handle = Addressables.LoadResourceLocationsAsync(label);
        await handle.Task;
        
        // ... (실제 다운로드 및 로딩바 갱신 로직) ...
    }
}

 

문제 상황

이전 방식(Resources.Load, Inspector 하드 레퍼런스)은 문제 나면 대체로 “항상” 터졌고, 크래시/프리징처럼 증상이 명확해서 원인 위치를 찾기 쉬웠다.
하지만 Addressables + Task 비동기 로딩으로 넘어오면서부터는, 같은 코드를 두고도 어떤 날은 정상이고 어떤 날은 실패하는 Race Condition(경쟁 상태) 형태로 바뀌었다.

가장 많이 만난 오류는 아래였다.

  • AssetBundle.Unload could not complete because the asset bundle still has an async load operation in progress.

그리고 이 오류는 특히 GameScene ↔ DungeonScene 씬 전환(LoadSceneMode.Single)에서 자주 발생했다.

 

이 끔찍한 확률형 버그와 "AssetBundle.Unload" 에러의 근본적인 원인은 C#의 기본 비동기 방식인 Task가 유니티 엔진의 생명주기에 종속적이지 않다는 데 있었다.

 

  1. 제어권이 없는 백그라운드 스레드 (Task)
    • 기본적으로 Task는 닷넷(C#)의 스레드 풀에서 돌아가는 녀석이다. 내가 await Addressables.LoadAssetAsync()를 호출해 두고 씬 전환(Clear)을 시도하면, 유니티 엔진은 씬을 파괴하고 메모리를 비우려(Unload) 하는데, 백그라운드의 Task는 "어? 나 아직 에셋 로딩 중인데?" 하면서 로딩을 계속 밀어붙인다.
    • 즉, 엔진의 메모리 청소 타이밍(메인 스레드)과 C# Task의 로딩 타이밍(백그라운드)이 서로 엇박자가 나면서 내부적으로 심각한 충돌(Race Condition)이 발생한 것이다.
  2. 씬 전환 시 발생하는 데드락(Deadlock)과 멈춤 현상
    • 특히 LoadingScene에서 GameScene으로, 혹은 DungeonScene으로 넘어갈 때 이 문제가 극대화되었다.
    • 씬이 전환될 때 유니티는 내부적으로 Resources.UnloadUnusedAssets()를 강제로 호출하여 메모리를 싹 정리한다. 이때 Task 기반의 로딩 로직이 "완료를 대기(await Task.Yield(), while (!IsDone))하며 루프"를 돌고 있으면, 유니티 메인 스레드의 메모리 해제 작업과 Task의 대기 상태가 락(Lock)을 걸어버려 로딩 화면이 영원히 멈추는(Deadlock) 현상이 발생했다.
  3. 종료 시에도 살아남는 "좀비 Task"
    • 심지어 유니티 에디터에서 플레이 모드를 강제로 껐을 때조차, Task는 유니티 엔진 소속이 아니기 때문에 죽지 않고 백그라운드에서 계속 실행되려다가 빨간색 에러(InvalidOperationException)를 뱉어내는 등 통제 불능 상태에 빠지기 일쑤였다.

이 과정에서 뼈저리게 느낀 점은 하나였다.


"유니티에서 비동기 프로그래밍을 하려면, 반드시 유니티의 메인 스레드(PlayerLoop)와 완벽하게 동기화되는 비동기 방식을 써야 한다."

Addressable 자체는 훌륭한 시스템이지만, 그것을 제어하는 뼈대인 C# Task가 유니티 환경과 맞지 않아 발생한 한계였다. 이 골치 아픈 엇박자와 타이밍 이슈를 근본적으로 해결하기 위해, 나는 기존의 모든 Task 로직을 들어내고 유니티 엔진에 최적화된  비동기 라이브러리인 UniTask를 도입하기로 결심했다.

 

 


4) UniTask​ 도입

목표

  • 엔진 동기화 : 백그라운드 스레드에서 제멋대로 도는 C# Task 대신, 유니티의 메인 스레드 생명주기와 완벽하게 궤를 같이하는 비동기 처리를 구축.
  • 타이밍 버그 및 데드락 제거 : Scene 전환 시 언로드와 비동기 로딩이 충돌해서 발생하는 확률적 크래시 및 멈춤 현상을 차단.

 

내가 했던 방식

  1. 프로젝트 전반의 UniTask 리팩토링
    • 부분적인 수정으로는 타이밍 꼬임을 잡을 수 없다고 판단했다. 강제적으로 처리할 수는 있었지만 장기적으로는 좋은 판단은 아니라고 생각했다.
    • ResourceManager는 물론이고, StartScene, SelectScene, GameScene, LoadingScene 등 씬의 초기화(Init) 흐름 전체를 Task와 코루틴에서 UniTask로 전면 교체했다.
  2. 플래그 변수(while)와 ContinueWith 제거, 직렬화된 흐름 구축
    • 기존에는 Task.ContinueWith(task => isFinished = true)를 걸어두고 업데이트 문이나 코루틴에서 while(!isFinished)로 대기하는 등 흐름이 파편화되어 있었다.
    • 이를 await UniTask를 활용해 하나의 함수 안에서 "A가 완전히 끝나면 -> B를 실행한다"는 동기적 읽기 흐름으로 통일했다.
  3. Scene/글로벌 캐시 분리 및 안전한 메모리 해제
    • UniTask를 통해 로딩 완료 시점이 완벽하게 보장되면서, Scene 전환 직후(안전한 타이밍)에 이전 씬의 리소스를 해제(Release)할 수 있게 되었다.
    • ResourceManager의 파라미터를 개선하여 게임 내내 쓸 데이터(isGlobal = true)와 씬 넘어가면 파괴할 데이터(isGlobal = false)를 명확히 구분했다.

코드 샘플

기존 방식의 문제점

// [기존 Task 방식의 한계]
// 백그라운드에서 돌다가 완료되면 플래그만 바꿈. 
// 유니티 메인 스레드 타이밍과 엇갈릴 위험이 컸음.
_ = Managers.Resource.LoadDependenciesAsync(
    sceneData.preloadLabels,
    false,
    (fileName, progress) => { /* 로딩바 갱신 */ }
).ContinueWith(task => isPreloadFinished = true);

// 다른 곳에서 무한 대기 (데드락 유발 가능성)
while(!isPreloadFinished) { yield return null; }

 

최종 개선 방식

// [최종 UniTask 적용]
// Cysharp.Threading.Tasks 네임스페이스 활용
public async UniTask InitSceneAsync(SceneDataSO sceneData)
{
    // 1. 유니티 메인스레드에서 안전하게 비동기 로딩을 끝까지 대기함
    await Managers.Resource.LoadDependenciesAsync(
        sceneData.preloadLabels,
        false, // isGlobal = false (해당 씬에서만 사용)
        (fileName, progress) => { 
            _loadingUI.UpdateProgress(progress, fileName); 
        }
    );

    // 2. 이 줄(Line)에 도달했다는 것은 '완벽하게' 로딩이 끝났음을 유니티 생명주기 내에서 보장함
    // 플래그 변수나 무한 루프(while)가 전혀 필요 없음.
    
    // 3. 로딩이 끝났으니 안전하게 다음 씬으로 진입
    Managers.SceneEx.LoadNextScene();
}

결론 (최종 아키텍처 완성)

이 길고 험난했던 트러블슈팅의 결과, 프로젝트의 리소스 관리 구조는 어느정도 완성되었다.

 

아직까지는 이전의 문제들이 발견되지는 않아서 UniTask 방식을 아직 사용중이다.

 

단순히 "이 기능이 좋대서 썼다"가 아니라, 동기 로딩의 한계를 겪고 → Addressable의 통제권을 얻고 → C# Task의 엇박자 충돌로 개고생하고 → 최종적으로 엔진에 완벽히 동기화된 UniTask 아키텍처를 이용.

 

뭔가 결과적으로는 그냥 외부 라이브러리를 사용했다 가 되어버리기는 했지만, 뭔가 그래도 거대한 퀘스트를 하나 깼다는거에서 요새 살짝 번아웃이 왔었는데 뭔가 다시 달릴만한 이유가 생긴거 같다.

 

아직 UniTask나 Addressable을 완전히 100% 사용할줄아는건 아니지만 그래도 게임이 돌아가게 적용했다는 점에서는 큰 성과가 있었다고 생각한다. 일단은 이 구조를 계속 사용해서 해보고, 나중에 기회가 된다면 이 문제 Task vs UniTask에 대해서도 한번 자세히 다뤄보록 하겠다.