https://github.com/vfly1189/OperationKivotos
GitHub - vfly1189/OperationKivotos: 유니티 공부용 창작 프로젝트
유니티 공부용 창작 프로젝트. Contribute to vfly1189/OperationKivotos development by creating an account on GitHub.
github.com
서론
이번에 정리해볼 내용은 게임에서 빼놓을 수 없는 Manager 시스템에 대해 정리해보려 한다.
게임을 만들다 보면 항상 드는 생각이 있다. "이 기능은 도대체 어디다 넣어야 되는거지....?"
씬이 바뀌어도 살아있어야 하는 데이터, 여러 곳에서 동시에 접근해야 하는 공통 기능들.... 이걸 그냥 일단 넣어두고 생각하자 라는 생각으로 하는 순간 나중에 손 댈 수조차 없는 스파게티 코드가 되어 버린다.
그래서 이번 프로젝트는 Managers 시스템을 적극적으로 활용해보고 구조적으로 제대로 잡고 시작했다. 이번에 한번 중간점검으로 한번 정리해보려 한다.
본론
전체 구조
Managers (허브)
├── InputManager : 입력
├── ResourceManager : 리소스 로드 / 생성 / 제거
├── PoolManager : 오브젝트 풀링
├── UIManager : UI 팝업 / 씬 UI
├── SoundManager : BGM / SFX / Voice
├── SceneManagerEx : 씬 전환 (로딩 씬 경유)
├── DataManager : JSON 데이터 파싱
├── WalletManager : 게임 내 재화
└── PartyManager : 파티 캐릭터 관리
이 프로젝트의 Manager의 구조는 Managers 라는 중앙 허브 클래스 하나가 모든 하위 매니저를 소유하고, 외부에서는 항상 Managers.XXX 의 형태로 접근하는 방식이다.
Managers.cs - 싱글톤 허브
가장 매우 핵심이 되는 클래스다. Monobehaviour를 상속받아 씬 어디서든 DDOL(DontDestroyOnLoad)로 살아남고, 싱글톤으로 유일성을 보장한다.
public class Managers : MonoBehaviour
{
static Managers s_instance;
static Managers Instance { get { Init(); return s_instance; } }
InputManager _input = new InputManager();
PoolManager _pool = new PoolManager();
ResourceManager _resource = new ResourceManager();
SceneManagerEx _scene = new SceneManagerEx();
SoundManager _sound = new SoundManager();
UIManager _ui = new UIManager();
DataManager _data = new DataManager();
WalletManager _wallet = new WalletManager();
PartyManager _party;
public static InputManager Input { get { return Instance._input; } }
public static PoolManager Pool { get { return Instance._pool; } }
// ... (나머지도 동일)
}
외부에서는 Mangers.Input , Managers.Sound 이런식으로 바로 접근하면 된다. 하위 매니저들은 Monobehaviour가 아닌 순수 C# 클래스로 만들었고, 오직 Managers 하나만 씬에 올라가는 구조다.
초기화 순서
처음에 Start() 함수를 일반 void 형태로 했었다. 근데 DataManager쪽에서 아이템 레벨별 스탯 , 강화 확률 같은 부분들을 JSON파일을 읽어서 Dictionary형태로 저장하는 기능을 담당하고 있었는데, 처음에는 동기로딩으로 코드를 짜다가 비동기로 바꿨는데 프로그램 자체가 실행도 안되고 그냥 먹통이 되는 대참사가 한번 일어났었다.
Managers의 Init() 에서 _data.Init()을 주석 처리하면 아무 문제 없이 게임이 잘 돌아가는데, 주석을 풀고 _data.Init()을 호출하는 순간 두가지 증상이 나타났다.
- 에디터 환경 : 유니티 에디터 전체가 완전히 먹통. 마우스도 안 움직이고, 강제 종료밖에 답이 없다.
- 빌드 환경 : 빌드한 .exe를 실행해도 StartScene 자체가 아예 안 뜬다. 그냥 까만 화면.
당시 Addressable Group의 Play Mode Script를 Use Existing Build로 설정한 상태였다.
당시 문제가 된 코드는 아래와 같다.
// Managers.cs (문제 버전)
void Start()
{
Init();
}
static void Init()
{
// ...
s_instance._data.Init(); // <- 동기 함수 호출
}
// DataManager.cs (문제 버전)
public void Init()
{
// Addressable로 JSON을 로드하고, 로딩이 끝날 때까지 강제로 기다림
WeaponDict = LoadJson<WeaponDataLoader, string, WeaponData>("Character_Weapon_Data").MakeDict();
}
private Loader LoadJson<Loader, Key, Value>(string key) where Loader : ILoader<Key, Value>
{
var handle = Addressables.LoadAssetAsync<TextAsset>(key);
TextAsset textAsset = handle.WaitForCompletion(); // ← 이 놈이 범인
// ...
}
문제의 핵심은 WaitForCompletion() 이 한 줄이었다. 왜 WaitForCompletion()이 에디터를 죽이는가?
Addressables.LoadAssetAsync는 이름 그대로 비동기 함수다. 유니티의 비동기 작업들은 완료 처리를 메인 스레드의 이벤트 루프(Update 사이클) 에서 한다.
즉, 한 프레임이 끝나고 다음 프레임으로 넘어가는 그 타이밍에 "아, 로딩 끝났다"를 체크하는 방식이다.
그런데 WaitForCompletion()은 메인 스레드를 그 자리에서 멈추고 완료를 기다린다. 여기서 교착 상태(Deadlock)가 만들어진다.
[메인 스레드]
1. Addressables.LoadAssetAsync 시작
2. WaitForCompletion() 호출 → 메인 스레드 정지, 대기 시작
[Addressables 내부]
3. "완료 처리를 하려면 메인 스레드의 다음 프레임이 필요한데..."
4. "근데 메인 스레드가 WaitForCompletion()에 막혀서 다음 프레임이 안 옴"
5. 그래서 완료 신호를 못 보냄 → WaitForCompletion()은 영원히 기다림
서로가 서로를 기다리는 전형적인 데드락(Deadlock) 이다. 에디터 입장에서는 메인 스레드가 완전히 멈춰버리니 당연히 에디터 자체의 UI도 안 움직이고 먹통이 된다.
이 문제를 해결했던 방법은 하나밖에 없다. 메인 스레드를 절대 막지 말 것.
WaitForCompletion()으로 강제로 기다리는 게 아니라, 유니티의 코루틴 시스템을 활용해서 "로딩 끝나면 그때 이어서 해줘" 방식으로 바꿔야 했다.
// DataManager.cs (수정 후)
public IEnumerator InitCoroutine()
{
yield return LoadJsonCoroutine<WeaponDataLoader, string, WeaponData>(
"Character_Weapon_Data",
loader => WeaponDict = loader.MakeDict()
);
yield return LoadJsonCoroutine<WeaponEnhanceMentDataLoader, int, EnhancementRateData>(
"Weapon_Enhancement_Rate_Data",
loader => EnhanceRateDict = loader.MakeDict()
);
Debug.Log("DataManager Init Complete");
}
private IEnumerator LoadJsonCoroutine<Loader, Key, Value>(string key, Action<Loader> onLoaded)
where Loader : ILoader<Key, Value>
{
var handle = Addressables.LoadAssetAsync<TextAsset>(key);
yield return handle; // 메인 스레드를 안 막고, 완료될 때까지 제어권을 유니티에게 넘김
if (handle.Status == AsyncOperationStatus.Succeeded)
{
Loader loader = JsonUtility.FromJson<Loader>(handle.Result.text);
Addressables.Release(handle);
onLoaded?.Invoke(loader);
}
}
WaitForCompletion() 대신 yield return handle을 쓴다. 이렇게 하면 유니티가 매 프레임마다 핸들의 완료 여부를 체크하고, 완료가 되면 그 다음 줄을 이어서 실행해준다. 메인 스레드는 중간에 전혀 막히지 않는다.
Managers - Start()를 코루틴으로 변경
// Managers.cs (수정 후)
IEnumerator Start()
{
Init(); // 나머지 매니저들 동기 초기화
// DataManager 로딩이 완전히 끝날 때까지 여기서 대기
yield return StartCoroutine(_data.InitCoroutine());
// 이 줄에 도달했다 = 딕셔너리에 데이터가 100% 채워진 상태
}
void Start()를 IEnumerator Start()로 바꾸는 것만으로도 유니티가 자동으로 코루틴으로 실행해준다. 여기서 yield return StartCoroutine(...)으로 DataManager의 초기화가 완전히 끝날 때까지 대기한 뒤에 게임이 시작된다.
수정 전후 실행 흐름 비교
// 수정 전 (Deadlock)
Start() 호출
└─ Init() 호출
└─ _data.Init() 호출
└─ WaitForCompletion() ← 여기서 메인 스레드 멈춤
└─ Addressables는 메인 스레드가 필요한데 막혀있음
└─ 영원히 기다림 (에디터 먹통)
// 수정 후 (정상)
IEnumerator Start() 호출
└─ Init() 호출 (동기 초기화)
└─ yield return StartCoroutine(_data.InitCoroutine())
└─ LoadJsonCoroutine 실행
└─ yield return handle ← 프레임마다 체크, 메인 스레드는 자유
└─ 로딩 완료 → 딕셔너리에 데이터 적재
└─ 게임 시작
처음에 WaitForCompletion을 썼던 가장 큰 이유는 "게임 로직의 순서를 보장하기 위해서"였다.
특히 강화 확률이나 무기 스탯 같은 데이터는 게임의 핵심 뼈대다.
만약 이 데이터들이 완전히 로딩되지 않은 상태에서
- 플레이어 캐릭터가 스폰되어 무기 공격력을 달라고 하거나
- UI가 켜지면서 강화 확률표를 화면에 그리려고 한다면?
당연히 데이터가 없으니 0이 뜨거나 NullReferenceException이 터지며 게임이 망가진다.
그래서 다른 건 몰라도 이 JSON 데이터만큼은 무조건 로딩이 100% 끝난 다음에 다음 코드로 넘어가야 해! 라는 확고한 의도가 있었다.
Addressables는 기본적으로 비동기(Async)라서 로딩을 시켜놓고 바로 다음 줄을 실행해버린다. 이를 억지로 기존의 Resources.Load 같은 동기(Sync) 방식처럼 꽉 붙잡아두기 위해 선택한 무기가 바로 WaitForCompletion() 였다.
// "여기서 무조건 데이터 다 가져올 때까지 딱 기다려!" 라는 의도
TextAsset textAsset = handle.WaitForCompletion();
하지만 유니티의 비동기 시스템과 메인 스레드의 구조를 완벽히 이해하지 못한 채 메인 스레드를 강제로 멈춰버리니 데드락(Deadlock)이 발생해 에디터가 죽어버렸다.
결국 "로딩을 기다려야 한다"는 본질적인 목적은 코루틴(yield return)으로 달성하고, 엔진을 멈춰 세우는 치명적인 부작용은 피하는 방식으로 구조를 변경해서 해결한 부분이다.
InputManager - 입력관리
키 입력을 한 곳에서 관리하고, 이벤트 방식으로 필요한 곳에 뿌려준다. 직접 Input.GetKey를 사용처마다 호출하는 방식은 쓰지 않았다.
public class InputManager
{
public event Action OnEscapePressed;
public event Action<Vector2> OnMoveInput;
public event Action<Define.MouseEvent> MouseAction;
// 동적 키 (리맵핑 가능)
private Dictionary<string, Key> _keyMap = new Dictionary<string, Key>();
private Dictionary<string, Action> _actionMap = new Dictionary<string, Action>();
}
키 입력은 두 가지로 나뉜다.
- 고정 키 : ESC, WASD 이동, 마우스 클릭처럼 항상 쓰는 것들. 직접 이벤트로 정의해뒀다.
- 동적 키 : _keyMap에 이름-키를 등록하고, _actionMap에 콜백을 달아두는 방식. RemapKey()를 통해 나중에 키 변경도 가능하다.
Managers의 Update()에서 _input.OnUpdate()를 매 프레임 호출해주는 방식이라, InputManager 자체는 MonoBehaviour 없이도 동작한다.
ResourceManager - 리소스 관리
초기에는 [SerializeField]를 이용해 인스펙터에서 프리팹을 일일이 연결하고 Instantiate 하는 방식을 사용했었다. 하지만 프로젝트 규모가 커지면 인스펙터 연결이 끊기거나(Missing Reference), 메모리 관리가 힘들어지는 문제가 있어 실무에서는 잘 사용하지 않는 방식임을 알게 되었다. (Prefab 수정하면 어느샌가 끊어져서 화딱지가....)
그래서 현재는 Addressable Asset System을 도입하였고, ResourceManager를 통해 리소스를 비동기로 로드(LoadAssetAsync)하거나, 생성(Instantiate) 및 파괴(Destroy)하는 과정을 중앙에서 관리하도록 구조를 변경했다.
public T Load<T>(string path) where T : Object
{
// 1. 캐시에 있는지 확인
if (_resources.TryGetValue(path, out Object resource))
{
return resource as T;
}
// 2. 없으면 리소스 폴더에서 로드
T loadResult = Resources.Load<T>(path);
// 3. 찾았으면 캐시에 저장
if (loadResult != null)
{
_resources.Add(path, loadResult);
}
else
{
Debug.LogError($"Failed to Load Resource: {path}");
}
return loadResult;
}
public GameObject Instantiate(GameObject original, Transform parent = null)
{
// 1. Poolable이 붙어있으면 풀 매니저에게 위임
if (original.GetComponent<Poolable>() != null)
{
return Managers.Pool.Pop(original, parent).gameObject;
}
// 2. 아니면 그냥 생성
GameObject go = Object.Instantiate(original, parent);
go.name = original.name; // (Clone) 떼기
return go;
}
근데 Load<T>를 쓰는 곳이 딱 한군데 있다. 바로 로딩 씬의 리소스들을 불러올때는 Load<T>를 이용해서 비동기가 아닌 동기 방식으로 리소스를 가져온다. 이 부분은 나중에 따로 다루도록 하겠다.
PoolManager - 오브젝트 풀링
Instantiate / Destroy는 GC(가비지 컬렉터)를 자극하는 주범이다. 특히 총알이나 이펙트처럼 자주 생겼다 사라지는 오브젝트는 풀링으로 재사용하는 게 맞다.
class Pool
{
Stack<Poolable> _poolStack = new Stack<Poolable>();
public void Push(Poolable poolable) { /* 비활성화 후 스택에 넣기 */ }
public Poolable Pop(Transform parent) { /* 스택에서 꺼내거나 새로 생성 */ }
}
내부적으로 프리팹 이름 기반의 딕셔너리로 풀을 관리한다. Pop() 시 스택이 비어있으면 그냥 새로 생성하기 때문에, 풀이 부족하다고 에러가 나지 않는다 는 점이 편하다.
Addressable로 생성한 풀은 AsyncOperationHandle을 같이 저장해뒀다가 Clear() 시 Addressables.Release(handle)로 메모리를 해제한다.
UIManager - UI 관리
UI는 크게 두 종류로 나뉜다.
- UI_Scene : 씬에 하나만 존재하는 전체화면 UI (인벤토리, HUD 등)
- UI_PopUp : 스택 방식으로 열고 닫는 팝업 UI
Stack<UI_PopUp> _popupStack = new Stack<UI_PopUp>();
public void ClosePopupUI()
{
UI_PopUp popup = _popupStack.Pop();
Managers.Resource.Destroy(popup.gameObject);
if (_popupHandles.TryGetValue(popup, out var handle))
{
Addressables.Release(handle); // 메모리 해제
_popupHandles.Remove(popup);
}
_order--;
}
팝업을 스택으로 관리하면 ESC를 눌렀을 때 가장 위의 팝업만 닫기 같은 로직을 깔끔하게 구현할 수 있다. _order를 하나씩 올려가며 sortingOrder를 부여하기 때문에 팝업이 겹쳐도 항상 나중에 열린 게 위에 표시된다.
Addressable 비동기 로드도 지원한다. ShowPopupUIAsync<T>()를 사용하면 프리팹 이름을 키로 Addressable에서 꺼내온 뒤 팝업을 띄워준다.
지금 정리하면서 깨달은 건데 UI_Scene을 쓴적이 없네.....? 이 부분은 찾아서 수정해봐야겠다.
SoundManager - 사운드 관리
| 채널 | 용도 | 재생방식 |
| BGM | 배경음악 | audioSource.Play() + Loop |
| Voice | 캐릭터 음성 | PlayOnShot() |
| Effect | 효과음 | PlayOnShot() |
AudioSource[] _audioSources = new AudioSource[(int)Define.Sound.MaxCount];
Init()에서 @Sound 오브젝트를 만들고 채널 수만큼 AudioSource를 붙여준다. 볼륨 조절은 SetBgmVolume(), SetSfxVolume() 등으로 분리해두었다.
SceneManagerEX - 씬 전환
유니티 기본 SceneManager.LoadScene()을 직접 쓰지 않고, 무조건 이 클래스를 통해 씬을 바꾼다. 씬 전환 시 로딩 커버 UI를 씌우고, 현재 씬을 정리한 뒤, 로딩 씬을 경유해서 다음 씬으로 넘어가는 흐름을 코루틴으로 처리한다.
IEnumerator CoLoadScene(Define.Scene type)
{
// 1. 커버 활성화 (화면에 그려질 때까지 1프레임 대기)
_transitionUI.gameObject.SetActive(true);
yield return null;
// 2. 현재 씬 정리
CurrentScene.Clear();
Managers.Clear();
// 3. 씬 데이터 준비 후 Loading 씬으로 이동
NextSceneData = _sceneTable.GetSceneData(type);
SceneManager.LoadScene("Loading");
}
yield return null 한 줄이 꽤 중요하다. 커버를 SetActive(true) 해도 실제로 화면에 그려지는 건 다음 프레임이기 때문에, 이 한 프레임을 기다리지 않으면 유니티의 기본 화면이 한 프레임 번쩍이는 문제가 생긴다.

DataManager - 데이터 관리
게임 데티어 (현재는 무기, 강화 확률만)를 JSON으로 관리하고, 시작 시 Addressable 비동기 로드로 Dictionary에 올려놓는다.
public IEnumerator InitCoroutine()
{
yield return LoadJsonCoroutine<WeaponDataLoader, string, WeaponData>(
"Character_Weapon_Data",
loader => WeaponDict = loader.MakeDict()
);
yield return LoadJsonCoroutine<WeaponEnhanceMentDataLoader, int, EnhancementRateData>(
"Weapon_Enhancement_Rate_Data",
loader => EnhanceRateDict = loader.MakeDict()
);
}
제네릭 LoadJsonCoroutine<Loader, Key, Value>() 하나로 어떤 타입의 데이터든 처리할 수 있게 만들었다. 새로운 데이터가 생겨도 InitCoroutine()에 yield return 한 줄만 추가하면 된다.
처음에는 csv파일로 정리할까 했는데 parsing하는게 너무 귀찮아서 그냥 유니티에서 제공하는 유틸리티들을 이용하기로 한다. (사실 JSON파일 구성하는것도 매우 귀찮긴하다)
WalletManager - 재화 관리
크레딧(골드), 강화석 등의 게임 내 재화를 관리하는 시스템이다
public bool ConsumeCurrency(CurrencyType type, int amount)
{
if (GetCurrency(type) >= amount)
{
_currencies[type] -= amount;
OnCurrencyChanged?.Invoke(type, _currencies[type]); // UI 갱신 이벤트
return true;
}
return false; // 잔액 부족
}
재화가 바뀔 때마다 OnCurrencyChanged 이벤트를 발행하고, UI가 이를 구독해서 자동으로 갱신되는 구조다. 잔액 부족 시 false를 리턴하기 때문에 소모 실패 처리도 된다. 다중 재화를 동시에 소모해야 하는 경우(강화 = 크레딧 + 강화석)를 위해TryConsumeMultiple()도 만들어뒀다.
PartyManager - 파티 관리
이 프로젝트의 핵심 시스템 중 하나다. 파티 캐릭터 관리, 교체, 사망 처리까지 담당한다. 처음엔 PartyManager 하나에 다 때려 넣었는데, 역할이 너무 많아지면서 하위 시스템으로 분리했다.
PartyManager (파사드)
├── PartyRegistry : 멤버 목록 + 현재 인덱스 (데이터)
├── PartySwapController : 교체 로직 + 쿨타임
├── PartyCharacterActivator: 활성화/비활성화 처리
├── PartyDeathHandler : 사망 감지 + 자동 교체
└── PartyInputHandler : 키 입력 → 교체 요청 연결
각 클래스가 딱 하나의 역할만 하도록 나눈 것이 포인트다. 예를 들어 캐릭터가 사망하면
- PartyDeathHandler가 OnDead 이벤트를 감지
- 2초 대기 코루틴 실행 → 그냥 연출용임
- PartyRegistry에서 살아있는 다음 멤버 탐색
- PartySwapController.TrySwap() 호출
- 아무도 없으면 OnPartyWiped 이벤트 발행 → 게임 오버
- 파티 전멸 시에는 FinishGame(false)를 호출하고, 4초 후에 게임 씬을 다시 로드하는 방식으로 게임 오버를 처리한다.
마무리
정리하고 나니 꽤 많은 걸 만들었다는 게 실감된다. 처음에는 이게 다 필요한가 싶었는데, 막상 쓰다 보면 각 매니저가 없을 때 얼마나 불편한지 바로 느껴진다.
특히 ResourceManager에서 Instantiate/Destroy를 가로채는 구조, PartyManager를 하위 시스템으로 분리한 것들은 나중에 유지보수할 때 크게 도움이 됐다.
또한 정리하면서 Manager 기능들을 만들었고 안쓰는 경우를 찾을 수 있었다는 점에서 이번 글작성이 도움이 됐던거같다.
다음에는 각 매니저별로 좀 자세하게 다뤄보도록하겠다.

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