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;
}
}
문제상황
이 방법도 첫번째 방법과 비슷한 문제들이 있었다.
- 하드 레퍼런스의 유지보수 지옥 ( SerializeField의 한계 )
- Inspector 상에서 수작업으로 수십 개의 프리팹을 하나씩 끌어다 연결해야 함.
- 오브젝트가 변경되거나 참조가 끊기면 에디터에서 눈치채기 어렵고, 실행 시 바로 NullReferenceException 버그로 터짐
- 처음에 "오...개꿀!!" 하면서 사용했었는데 연결 끊길때마다 다시 연결하는게 매우 매우 귀찮았음.
- 문자열 기반 로딩의 불안정성 ( Resources.Load 방식의 한계 )
- 오타가 있거나 Instantiate 시 내부적으로 "Prefabs/" 같은 경로 규칙이 엇갈리면, 분명 로딩을 했는데 캐시 Key 불일치로 꺼내오지 못하는 문제들이 발생.
- 폴더 구조를 바꾸면 수동으로 바꾸는 부분도 매우 귀찮아짐.
- 메모리 제어 불가 ( 핵심 )
- 해당 오브젝트가 화면에 보이지 않더라도, 심지어 게임 내내 사용하지 않더라도 SerializeField로 한 번 연결된 에셋들은 Scene이 로드되는 순간 강제로 VRAM과 RAM에 동반 로드된다.
- Resources.UnloadUnusedAssets()를 호출해도, Inspector에서 꽉 쥐고 있는 상태라 해당 Scene이 완전히 파괴되기 전까지는 절대 메모리에서 내려가지 않고 상주하는 심각한 구조적 결함이 있다.
- 초반에는 사용된 리소스들이 몇개 없었는데 시간이 가면 갈수록 Scene이 시작되는 시간이 점차 늦어지는게 체감이 될 정도였다.
3) Addressable 도입 + SceneDataSO ( 비동기 로딩과 메모리 제어 )
목표
- 메모리 상주 문제 해결: Inspector 연결 방식의 치명적인 단점인 Scene이 파괴되기 전까지 강제로 메모리에 계속 올라가 있는 현상을 해결하기 위함.
- 비동기 로딩(Task) 도입: 게임의 멈춤 현상없이 백그라운드에서 리소스를 로드하고, 필요 없을 때 메모리에서 내릴 수 있는 유연한 구조 설계.
- 로딩 분기: 씬마다 필요한 프리로드 대상을 하드코딩하지 않고 SceneDataSO로 외부화하여 관리.
내가 했던 방식
- "Scene 종속성"에서 "핸들(Handle) 기반 독립 관리"로 전환
- 가장 큰 문제였던 메모리 강제 상주를 피하기 위해, 에셋들을 Addressable로 전환하고 문자열(Key/Label)로만 참조하게 만들었다.
- 이를 통해 씬이 시작될 때 불필요한 리소스가 한꺼번에 올라가는 것을 막고, ResourceManager에서 Task를 활용한 비동기 메서드(LoadAsync)로 필요한 타이밍에만 메모리에 올릴 수 있게 되었다.
- 메모리 해제
- Inspector 연결 방식에서는 가비지 컬렉터나 Scene 파괴만 기다려야 했지만, Addressable은 레퍼런스 카운팅(Reference Counting) 방식을 지원한다.
- 비동기로 로드할 때 반환받은 비동기 핸들(AsyncOperationHandle)을 ResourceManager가 캐싱하고 있다가, 특정 리소스가 더 이상 필요 없거나 Scene을 전환할 때 Addressables.Release(handle)를 호출하여 Scene 파괴 전이라도 메모리를 해제할 수 있게 되었다.
- SceneDataSO를 통한 비동기 프리로드(Preload)
- Scene 진입 시 발생할 수 있는 팝인(Pop-in)을 막기 위해 LoadingScene에서 비동기 로딩을 수행해야 했다. 이때 분기문 지옥을 피하기 위해 SceneDataSO에 해당 씬에서 필요한 Addressable 라벨 배열을 세팅했다.
- 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가 유니티 엔진의 생명주기에 종속적이지 않다는 데 있었다.
- 제어권이 없는 백그라운드 스레드 (Task)
- 기본적으로 Task는 닷넷(C#)의 스레드 풀에서 돌아가는 녀석이다. 내가 await Addressables.LoadAssetAsync()를 호출해 두고 씬 전환(Clear)을 시도하면, 유니티 엔진은 씬을 파괴하고 메모리를 비우려(Unload) 하는데, 백그라운드의 Task는 "어? 나 아직 에셋 로딩 중인데?" 하면서 로딩을 계속 밀어붙인다.
- 즉, 엔진의 메모리 청소 타이밍(메인 스레드)과 C# Task의 로딩 타이밍(백그라운드)이 서로 엇박자가 나면서 내부적으로 심각한 충돌(Race Condition)이 발생한 것이다.
- 씬 전환 시 발생하는 데드락(Deadlock)과 멈춤 현상
- 특히 LoadingScene에서 GameScene으로, 혹은 DungeonScene으로 넘어갈 때 이 문제가 극대화되었다.
- 씬이 전환될 때 유니티는 내부적으로 Resources.UnloadUnusedAssets()를 강제로 호출하여 메모리를 싹 정리한다. 이때 Task 기반의 로딩 로직이 "완료를 대기(await Task.Yield(), while (!IsDone))하며 루프"를 돌고 있으면, 유니티 메인 스레드의 메모리 해제 작업과 Task의 대기 상태가 락(Lock)을 걸어버려 로딩 화면이 영원히 멈추는(Deadlock) 현상이 발생했다.
- 종료 시에도 살아남는 "좀비 Task"
- 심지어 유니티 에디터에서 플레이 모드를 강제로 껐을 때조차, Task는 유니티 엔진 소속이 아니기 때문에 죽지 않고 백그라운드에서 계속 실행되려다가 빨간색 에러(InvalidOperationException)를 뱉어내는 등 통제 불능 상태에 빠지기 일쑤였다.
이 과정에서 뼈저리게 느낀 점은 하나였다.
"유니티에서 비동기 프로그래밍을 하려면, 반드시 유니티의 메인 스레드(PlayerLoop)와 완벽하게 동기화되는 비동기 방식을 써야 한다."
Addressable 자체는 훌륭한 시스템이지만, 그것을 제어하는 뼈대인 C# Task가 유니티 환경과 맞지 않아 발생한 한계였다. 이 골치 아픈 엇박자와 타이밍 이슈를 근본적으로 해결하기 위해, 나는 기존의 모든 Task 로직을 들어내고 유니티 엔진에 최적화된 비동기 라이브러리인 UniTask를 도입하기로 결심했다.
4) UniTask 도입
목표
- 엔진 동기화 : 백그라운드 스레드에서 제멋대로 도는 C# Task 대신, 유니티의 메인 스레드 생명주기와 완벽하게 궤를 같이하는 비동기 처리를 구축.
- 타이밍 버그 및 데드락 제거 : Scene 전환 시 언로드와 비동기 로딩이 충돌해서 발생하는 확률적 크래시 및 멈춤 현상을 차단.
내가 했던 방식
- 프로젝트 전반의 UniTask 리팩토링
- 부분적인 수정으로는 타이밍 꼬임을 잡을 수 없다고 판단했다. 강제적으로 처리할 수는 있었지만 장기적으로는 좋은 판단은 아니라고 생각했다.
- ResourceManager는 물론이고, StartScene, SelectScene, GameScene, LoadingScene 등 씬의 초기화(Init) 흐름 전체를 Task와 코루틴에서 UniTask로 전면 교체했다.
- 플래그 변수(while)와 ContinueWith 제거, 직렬화된 흐름 구축
- 기존에는 Task.ContinueWith(task => isFinished = true)를 걸어두고 업데이트 문이나 코루틴에서 while(!isFinished)로 대기하는 등 흐름이 파편화되어 있었다.
- 이를 await UniTask를 활용해 하나의 함수 안에서 "A가 완전히 끝나면 -> B를 실행한다"는 동기적 읽기 흐름으로 통일했다.
- 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에 대해서도 한번 자세히 다뤄보록 하겠다.

'Unity > Blue Archive 2차 창작 프로젝트' 카테고리의 다른 글
| [Unity- Operation Kivotos] 4. 장비 시스템 (1) | 2026.03.20 |
|---|---|
| [Unity- Operation Kivotos] 2. Manager (1) | 2026.02.24 |
| [Unity- Operation Kivotos] 1. 개요 (0) | 2026.02.23 |