문제 - 순차 초기화의 한계
게임 클라이언트에는 수십 개의 매니저가 존재하고, 이들은 게임 진입 시 초기화되어야 합니다. 기존 시스템(초기에 임시 구현해놓은게 어쩌다 보니 끝까지..)은 매니저들을 고정된 순서로 나열하고, 동기 초기화(Init)와 비동기 초기화(InitAsync)를 각각 순차적으로 호출하는 방식이었습니다.
// 동기 초기화 - 고정된 순서로 하나씩 호출
buffManager.Init();
playerManager.Init();
inventoryManager.Init();
turnManager.Init();
boardManager.Init();
// ... 15개 이상
// 비동기 초기화 - 일부만 WhenAll, 나머지는 순차
await eventManager.InitAsync();
await UniTask.WhenAll(
shopManager.InitAsync(),
questManager.InitAsync(),
challengeManager.InitAsync()
);
// ... 나머지도 순차 await이 구조에는 두 가지 문제가 있습니다.
1. 암묵적 의존성
BoardManager는 TurnManager와 PlayerManager가 먼저 초기화되어야 정상 동작한다 - 는 사실이 코드의 나열 순서에만 암묵적으로 기록되어 있었습니다. 새 매니저를 추가하거나 순서를 재배치할 때 이런 암묵적 의존성을 깨뜨리면 런타임 버그가 발생했고, 원인 추적이 어려웠습니다.
2. 불필요한 직렬 실행
서로 의존 관계가 없는 매니저들도 순차적으로 await되고 있었습니다. 비동기 초기화에서 일부 매니저만 WhenAll로 묶여 있었고, 나머지는 왜 병렬화되지 않았는지 알 수 없는 상태였습니다. 어떤 매니저끼리 병렬 실행해도 안전한지 판단할 근거가 코드에 없었기 때문에 이런 부분에 대해 조심스러울 수 밖에 없었습니다.
설계 목표
새로 설계하는 시스템은 아래 3가지 목표를 만족해야합니다.
- 의존성의 명시적 선언: 각 매니저가 자신의 의존성을 Attribute로 직접 선언하고, 초기화 순서는 시스템이 자동으로 결정합니다
- 최대 병렬 실행: 서로 의존 관계가 없는 비동기 초기화는 자동으로 병렬 실행합니다
- 빌드 타임 검증: 순환 의존성, 누락된 등록 등 구성 오류를 런타임이 아닌 테스트 단계에서 검출합니다
핵심 구현
Attribute 기반 의존성 선언
매니저 클래스에 Attribute를 부착하여 의존성을 선언합니다. 동기 초기화(Init, Cycle 1)와 비동기 초기화(InitAsync, Cycle 2)의 의존성을 각각 별도로 선언할 수 있습니다. (이 두가지 사이클 구조는 저희 게임 특성에 따른 것이고 핵심은 Attribute 부착입니다.)
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public abstract class InitOrderAttribute : Attribute
{
public Type Dependency { get; }
protected InitOrderAttribute(Type dependency) { Dependency = dependency; }
}
public sealed class InitAfterAttribute : InitOrderAttribute
{
public InitAfterAttribute(Type dependency) : base(dependency) { }
}
public sealed class InitAsyncAfterAttribute : InitOrderAttribute
{
public InitAsyncAfterAttribute(Type dependency) : base(dependency) { }
}사용하는 쪽에서는 클래스 선언에 Attribute를 붙이기만 하면 됩니다.
// 의존성 없음
public class BuffManager : BaseCoreManager { /* ... */ }
// BuffManager 이후에 동기 초기화
[InitAfter(typeof(BuffManager))]
public class PlayerManager : BaseCoreManager { /* ... */ }
// TurnManager, PlayerManager 이후에 동기 초기화
// EventManager 이후에 비동기 초기화
[InitAfter(typeof(TurnManager))]
[InitAfter(typeof(PlayerManager))]
[InitAsyncAfter(typeof(EventManager))]
public class BoardManager : BaseCoreManager { /* ... */ }
// InventoryManager 이후에 비동기 초기화
[InitAsyncAfter(typeof(InventoryManager))]
public class ShopManager : BaseCoreManager { /* ... */ }typeof를 사용하는 이유는 매니저 계층이 모두 BaseCoreManager를 상속하는 구체 클래스이므로 타입 자체가 고유 식별자 역할을 할 수 있기 때문입니다. 별도의 enum이나 문자열 ID가 필요 없습니다.
공통 베이스 클래스가 없는 경우
Registry 밖에서 독립적으로 존재하는 컨트롤러들(싱글톤 등)은 공통 베이스 타입이 없어
typeof방식을 쓸 수 없습니다. 이 경우에는 enum ID 기반의 Method-Level Attribute를 사용했습니다.[InitStep(InitStepId.Audio, After = new[] { InitStepId.Asset })]처럼 메서드에 직접 부착하고, Assembly Scanning으로 수집하는 방식입니다. 핵심 알고리즘(위상정렬 + Wave 병렬 실행)은 동일합니다.
Kahn’s Algorithm으로 위상정렬
의존성 그래프가 구성되면 Kahn’s Algorithm으로 위상정렬을 수행합니다.
private static List<BaseCoreManager> TopologicalSort(
List<BaseCoreManager> managers,
Func<Type, IEnumerable<Type>> getDependencies,
string attributeName)
{
var byType = managers.ToDictionary(m => m.GetType());
var inDegree = managers.ToDictionary(m => m, _ => 0);
var dependents = managers.ToDictionary(
m => m, _ => new List<BaseCoreManager>());
// 1. 진입 차수(in-degree) 계산
foreach (var manager in managers)
{
foreach (var depType in getDependencies(manager.GetType()))
{
if (!byType.TryGetValue(depType, out var dep))
throw new InvalidOperationException(
$"{manager.GetType().Name} declares " +
$"{attributeName}(typeof({depType.Name})), " +
$"but {depType.Name} is not registered.");
dependents[dep].Add(manager);
inDegree[manager]++;
}
}
// 2. in-degree가 0인 노드부터 시작
var queue = new Queue<BaseCoreManager>(
managers.Where(m => inDegree[m] == 0));
var sorted = new List<BaseCoreManager>(managers.Count);
while (queue.Count > 0)
{
var current = queue.Dequeue();
sorted.Add(current);
foreach (var next in dependents[current])
{
if (--inDegree[next] == 0)
queue.Enqueue(next);
}
}
// 3. 순환 의존성 검출
if (sorted.Count != managers.Count)
{
var cycleMembers = managers
.Where(m => inDegree[m] > 0)
.Select(m => m.GetType().Name);
throw new InvalidOperationException(
$"Circular dependency detected among: " +
$"{string.Join(" -> ", cycleMembers)}. " +
$"Review {attributeName} attributes.");
}
return sorted;
}이 메서드는 의존성 추출 함수(getDependencies)를 인자로 받기 때문에, [InitAfter]와 [InitAsyncAfter] 두 그래프에 동일하게 재사용됩니다.
런타임 위상정렬의 비용
게임에서 매니저는 보통 많아야 수십 개입니다. Kahn’s Algorithm은 이므로 이 규모에서는 사실상 무시할 수 있는 수준입니다. 오히려 Attribute를 읽기 위한 Reflection 비용이 위상정렬 자체보다 큽니다. 빌드 타임 코드 생성 없이 런타임에 수행해도 전혀 문제없다고 판단했습니다.
Wave 기반 병렬 실행
동기 초기화(Init)는 위상정렬 결과 순서대로 순차 실행하면 충분합니다. 핵심은 비동기 초기화(InitAsync)에서의 병렬화입니다.
위상정렬된 결과를 그대로 순차 실행하면 의존성 위반은 방지되지만, 병렬 실행의 이점을 살릴 수 없습니다. 이 시스템은 정렬된 매니저들을 Wave 단위로 그룹화하여 같은 Wave 내의 매니저를 병렬 실행합니다.
private static List<List<BaseCoreManager>> BuildAsyncWaves(
List<BaseCoreManager> managers)
{
var sorted = TopologicalSort(
managers, GetInitAsyncAfterDependencies, "[InitAsyncAfter]");
var byType = sorted.ToDictionary(m => m.GetType());
var depth = sorted.ToDictionary(m => m, _ => 0);
foreach (var manager in sorted)
{
foreach (var depType in GetInitAsyncAfterDependencies(manager.GetType()))
depth[manager] = Math.Max(depth[manager], depth[byType[depType]] + 1);
}
return sorted
.GroupBy(m => depth[m])
.OrderBy(g => g.Key)
.Select(g => g.ToList())
.ToList();
}각 매니저의 depth는 “의존성 체인의 최대 길이”입니다. 같은 depth에 있는 매니저들은 서로 의존 관계가 없으므로 안전하게 병렬 실행할 수 있습니다.

정적 검증 테스트
런타임에 초기화 순서가 틀려서 버그가 발생하는 일을 원천 차단하기 위해, 의존성 그래프의 무결성을 검증하는 에디터 테스트를 작성했습니다. 테스트는 Reflection으로 실제 코드에 부착된 Attribute를 읽어서 검증하므로, 코드 변경 시 자동으로 문제를 감지합니다.
[InitAfter] 4개 + [InitAsyncAfter] 4개, 총 8개의 테스트가 동기/비동기 의존성 그래프를 각각 독립적으로 검증합니다.
| 테스트 | 검증 내용 |
|---|---|
| 등록 검사 (Sync/Async) | 의존 대상이 Registry에 등록되어 있는지 |
| 순환 검사 (Sync/Async) | 의존성 그래프에 Cycle이 없는지 |
| 타입 검사 (Sync/Async) | (게임 특성에 따른 검사) |
| 자기 참조 검사 (Sync/Async) | 자기 자신에 대한 의존성이 없는지 |
순환 의존성 검사는 런타임 코드와 동일한 Kahn’s Algorithm을 사용합니다.
[Test]
public void InitAfter_의존성_그래프에_순환이_없어야_한다()
{
var inDegree = _registeredTypes.ToDictionary(t => t, _ => 0);
var dependents = _registeredTypes.ToDictionary(t => t, _ => new List<Type>());
foreach (var (type, deps) in _syncGraph)
{
foreach (var dep in deps)
{
if (!dependents.ContainsKey(dep)) continue;
dependents[dep].Add(type);
inDegree[type]++;
}
}
var queue = new Queue<Type>(
inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
var visited = 0;
while (queue.Count > 0)
{
var current = queue.Dequeue();
visited++;
foreach (var next in dependents[current])
{
if (--inDegree[next] == 0)
queue.Enqueue(next);
}
}
if (visited == _registeredTypes.Count) return;
var cycleMembers = inDegree
.Where(kv => kv.Value > 0)
.Select(kv => kv.Key.Name)
.ToList();
Assert.Fail($"순환 의존성 발견: {string.Join(" -> ", cycleMembers)}");
}이 테스트들은 CI에서 에디터 테스트로 실행되므로, 누군가 잘못된 의존성을 추가하면 머지 전에 바로 실패합니다.