문제 - 순차 초기화의 한계
게임 클라이언트에는 수십 개의 매니저가 존재하고, 이들은 게임 진입 시 초기화되어야 한다. 기존 시스템은 매니저들을 고정된 순서로 나열하고, 동기 초기화(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로 묶여 있었고, 나머지는 왜 병렬화되지 않았는지 알 수 없는 상태였다. 어떤 매니저끼리 병렬 실행해도 안전한지 판단할 근거가 코드에 없었기 때문이다.
설계 목표
문제 분석을 바탕으로 세 가지 설계 목표를 설정했다.
- 의존성의 명시적 선언: 각 매니저가 자신의 의존성을 Attribute로 직접 선언하고, 초기화 순서는 시스템이 자동으로 결정한다
- 최대 병렬 실행: 서로 의존 관계가 없는 비동기 초기화는 자동으로 병렬 실행한다
- 빌드 타임 검증: 순환 의존성, 누락된 등록 등 구성 오류를 런타임이 아닌 테스트 단계에서 검출한다
핵심 구현
Attribute 기반 의존성 선언
매니저 클래스에 Attribute를 부착하여 의존성을 선언한다. 동기 초기화(Init)와 비동기 초기화(InitAsync)의 의존성을 각각 별도로 선언할 수 있다.
[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 })]{:csharp}처럼 메서드에 직접 부착하고, 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;
}알고리즘의 흐름을 정리하면 이렇다.
- 각 노드의 진입 차수(in-degree)를 계산한다 - 자신이 의존하는 노드가 몇 개인지
- in-degree가 0인 노드(의존성이 없는 노드)를 큐에 넣고 처리를 시작한다
- 노드를 하나 꺼낼 때마다 그 노드에 의존하는 다른 노드들의 in-degree를 1씩 감소시킨다
- in-degree가 0이 된 노드를 다시 큐에 넣는다
- 모든 노드가 처리되면 정렬 완료. 처리되지 못한 노드가 남아있다면 순환 의존성이 존재한다는 의미다
이 TopologicalSort 메서드는 의존성 추출 함수(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에 있는 매니저들은 서로 의존 관계가 없으므로 안전하게 병렬 실행할 수 있다.

Registry - 조립
이 모든 것이 CoreManagerRegistry에서 조립된다. 생성자에서 동기 초기화를, 별도 메서드에서 비동기 초기화를 수행한다.
public class CoreManagerRegistry
{
public readonly BuffManager BuffManager = new();
public readonly PlayerManager PlayerManager = new();
public readonly InventoryManager InventoryManager = new();
public readonly TurnManager TurnManager = new();
public readonly BoardManager BoardManager = new();
// ... 20개 매니저 필드
public readonly List<BaseCoreManager> Managers;
public CoreManagerRegistry(CoreConfig config)
{
// Reflection으로 필드에서 매니저 목록 수집
var raw = GetType()
.GetFields(BindingFlags.Public | BindingFlags.Instance)
.Where(f => typeof(BaseCoreManager).IsAssignableFrom(f.FieldType))
.Select(f => (BaseCoreManager)f.GetValue(this))
.ToList();
// [InitAfter] 기반 위상정렬 -> 순차 실행
Managers = TopologicalSort(raw, GetInitAfterDependencies, "[InitAfter]");
foreach (var manager in Managers)
manager.InjectConfig(config);
foreach (var manager in Managers)
manager.InjectDependencies(this);
foreach (var manager in Managers)
manager.Init();
}
public async UniTask AsyncInitialize()
{
// [InitAsyncAfter] 기반 위상정렬 -> Wave 병렬 실행
var waves = BuildAsyncWaves(Managers);
foreach (var wave in waves)
await UniTask.WhenAll(wave.Select(m => m.InitAsync()));
}
}동기 초기화는 위상정렬 순서대로 순차 실행하고, 비동기 초기화는 Wave 단위로 UniTask.WhenAll을 통해 병렬 실행한다. 매니저 등록은 필드 선언만으로 자동 수집되고, 초기화 순서는 각 매니저의 Attribute 선언에 의해 자동으로 결정된다.
정적 검증 테스트
런타임에 초기화 순서가 틀려서 버그가 발생하는 일을 원천 차단하기 위해, 의존성 그래프의 무결성을 검증하는 에디터 테스트를 작성했다. 테스트는 Reflection으로 실제 코드에 부착된 Attribute를 읽어서 검증하므로, 코드 변경 시 자동으로 문제를 감지한다.
[InitAfter] 4개 + [InitAsyncAfter] 4개, 총 8개의 테스트가 동기/비동기 의존성 그래프를 각각 독립적으로 검증한다.
| 테스트 | 검증 내용 |
|---|---|
| 등록 검사 (Sync/Async) | 의존 대상이 Registry에 등록되어 있는지 |
| 순환 검사 (Sync/Async) | 의존성 그래프에 Cycle이 없는지 |
| 타입 검사 (Sync/Async) | 의존 대상이 BaseCoreManager 서브클래스인지 |
| 자기 참조 검사 (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에서 에디터 테스트로 실행되므로, 누군가 잘못된 의존성을 추가하면 머지 전에 바로 실패한다.
결과
성능
이 위상정렬 + Wave 병렬 실행 패턴을 인게임 매니저뿐 아니라 앱 시작 시 실행되는 컨트롤러 초기화 계층에도 동일하게 적용했다. 컨트롤러 계층에서 측정한 결과는 다음과 같다.
| Wave | Before | After |
|---|---|---|
| Wave 1 (14개 병렬) | 2,367ms | 1,952ms |
| Wave 2 (5개 병렬) | 4,334ms | 646ms |
| Wave 3 | 2ms | 2ms |
| Wave 4 | 158ms | 109ms |
| Total | ~6.95s | ~2.75s (60% 단축) |
Wave 2에서의 대폭적인 감소는 기존에 순차 실행되던 무거운 컨트롤러들이 병렬화된 결과다. 추가로 개별 모듈 내부의 I/O도 UniTask.WhenAll로 병렬화하여, 단일 모듈 기준 최대 90%까지 초기화 시간을 단축한 사례도 있었다.
구조적 이점
의존성 가시성 - 각 매니저의 의존성이 Attribute로 명시되어 코드 리뷰 시 바로 파악 가능
안전한 확장 - 새 매니저 추가 시 Attribute만 붙이면 자동으로 올바른 위치에 삽입
빌드 타임 보호 - 순환 의존성이나 누락된 등록을 CI에서 즉시 검출
Init/InitAsync 분리 - 동기와 비동기 의존성을 독립적으로 관리하여 세밀한 제어 가능
마무리
이 시스템의 핵심은 “의존성 선언과 실행 순서 결정의 분리”다. 각 매니저는 자신이 무엇에 의존하는지만 선언하고, 언제/어떻게 실행될지는 레지스트리가 결정한다. 위상정렬이라는 잘 알려진 알고리즘을 활용하면 이 분리를 자연스럽게 달성할 수 있다.
특히 초기화 시스템처럼 “여러 컴포넌트가 특정 순서를 지키면서도 최대한 병렬로 실행되어야 하는” 상황은 위상정렬 + Wave 병렬화 패턴이 잘 맞는다. 빌드 시스템(Make, Gradle), 패키지 매니저(npm), CI/CD 파이프라인 등에서도 동일한 패턴이 사용되고 있다.
정적 검증 테스트를 함께 갖추면 “런타임에 발견되는 초기화 순서 버그”라는 카테고리 자체를 없앨 수 있다.