com.kylin.di 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +22 -0
- package/CHANGELOG.md +17 -0
- package/CLAUDE.md +58 -0
- package/LICENSE +21 -0
- package/README.md +704 -0
- package/Runtime/Attributes/InjectAttribute.cs +11 -0
- package/Runtime/Attributes/InjectAttribute.cs.meta +2 -0
- package/Runtime/Attributes/ViewModelAttribute.cs +17 -0
- package/Runtime/Attributes/ViewModelAttribute.cs.meta +2 -0
- package/Runtime/Attributes.meta +8 -0
- package/Runtime/Core/DIBehaviour.cs +70 -0
- package/Runtime/Core/DIBehaviour.cs.meta +2 -0
- package/Runtime/Core/LifetimeScope.cs +264 -0
- package/Runtime/Core/LifetimeScope.cs.meta +2 -0
- package/Runtime/Core.meta +8 -0
- package/Runtime/DI/DependencyBuilder.cs +114 -0
- package/Runtime/DI/DependencyBuilder.cs.meta +2 -0
- package/Runtime/DI/DependencyInjector.cs +104 -0
- package/Runtime/DI/DependencyInjector.cs.meta +2 -0
- package/Runtime/DI/InstanceFactory.cs +36 -0
- package/Runtime/DI/InstanceFactory.cs.meta +2 -0
- package/Runtime/DI/KDI.cs +54 -0
- package/Runtime/DI/KDI.cs.meta +2 -0
- package/Runtime/DI/Registration.cs +29 -0
- package/Runtime/DI/Registration.cs.meta +2 -0
- package/Runtime/DI/Scope.cs +183 -0
- package/Runtime/DI/Scope.cs.meta +2 -0
- package/Runtime/DI/ScopeBuilder.cs +68 -0
- package/Runtime/DI/ScopeBuilder.cs.meta +2 -0
- package/Runtime/DI/ScopeExtensions.cs +70 -0
- package/Runtime/DI/ScopeExtensions.cs.meta +2 -0
- package/Runtime/DI.meta +8 -0
- package/Runtime/Debug/ClosureAnalyzer.cs +377 -0
- package/Runtime/Debug/ClosureAnalyzer.cs.meta +2 -0
- package/Runtime/Debug/ClosureProfilerWindow.cs +435 -0
- package/Runtime/Debug/ClosureProfilerWindow.cs.meta +2 -0
- package/Runtime/Debug/SubscriberInfo.cs +661 -0
- package/Runtime/Debug/SubscriberInfo.cs.meta +3 -0
- package/Runtime/Debug.meta +3 -0
- package/Runtime/Kylin.DI.asmdef +14 -0
- package/Runtime/Kylin.DI.asmdef.meta +7 -0
- package/Runtime/SubscribableProperty/Reaction.cs +61 -0
- package/Runtime/SubscribableProperty/Reaction.cs.meta +2 -0
- package/Runtime/SubscribableProperty/SubscribableCollection.cs +325 -0
- package/Runtime/SubscribableProperty/SubscribableCollection.cs.meta +3 -0
- package/Runtime/SubscribableProperty/SubscribableCollectionExtensions.cs +24 -0
- package/Runtime/SubscribableProperty/SubscribableCollectionExtensions.cs.meta +3 -0
- package/Runtime/SubscribableProperty/SubscribableCommand.cs +52 -0
- package/Runtime/SubscribableProperty/SubscribableCommand.cs.meta +2 -0
- package/Runtime/SubscribableProperty/SubscribableDictionary.cs +350 -0
- package/Runtime/SubscribableProperty/SubscribableDictionary.cs.meta +3 -0
- package/Runtime/SubscribableProperty/SubscribableProperty.cs +119 -0
- package/Runtime/SubscribableProperty/SubscribableProperty.cs.meta +2 -0
- package/Runtime/SubscribableProperty/SubscribablePropertyExtensions.cs +39 -0
- package/Runtime/SubscribableProperty/SubscribablePropertyExtensions.cs.meta +3 -0
- package/Runtime/SubscribableProperty/SubscribablePropertyLinq.cs +86 -0
- package/Runtime/SubscribableProperty/SubscribablePropertyLinq.cs.meta +2 -0
- package/Runtime/SubscribableProperty.meta +8 -0
- package/Runtime/Update/IFixedUpdatable.cs +16 -0
- package/Runtime/Update/IFixedUpdatable.cs.meta +2 -0
- package/Runtime/Update/ILateUpdatable.cs +16 -0
- package/Runtime/Update/ILateUpdatable.cs.meta +2 -0
- package/Runtime/Update/IUpdatable.cs +16 -0
- package/Runtime/Update/IUpdatable.cs.meta +2 -0
- package/Runtime/Update/IUpdatePriority.cs +15 -0
- package/Runtime/Update/IUpdatePriority.cs.meta +2 -0
- package/Runtime/Update/UpdateLoopManager.cs +240 -0
- package/Runtime/Update/UpdateLoopManager.cs.meta +2 -0
- package/Runtime/Update.meta +8 -0
- package/Runtime.meta +8 -0
- package/package.json +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
# KDI (Kylin Dependency Injection)
|
|
2
|
+
|
|
3
|
+
Unity 6 전용 Scope 기반 경량 DI 프레임워크. 필드 주입 전용, 계층적 Scope, 반응형 프로퍼티 내장.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
com.kylin.di v1.0.0 | Unity 6000.0+ | MIT License
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 목차
|
|
12
|
+
|
|
13
|
+
- [설치](#설치)
|
|
14
|
+
- [핵심 개념](#핵심-개념)
|
|
15
|
+
- [기본 사용법](#기본-사용법)
|
|
16
|
+
- [1. 서비스 정의](#1-서비스-정의)
|
|
17
|
+
- [2. LifetimeScope에 등록](#2-lifetimescope에-등록)
|
|
18
|
+
- [3. MonoBehaviour에서 사용](#3-monobehaviour에서-사용)
|
|
19
|
+
- [Scope 계층 구성](#scope-계층-구성)
|
|
20
|
+
- [씬 하이어라키 구조](#씬-하이어라키-구조)
|
|
21
|
+
- [부모-자식 Scope 연결](#부모-자식-scope-연결)
|
|
22
|
+
- [Resolution 우선순위](#resolution-우선순위)
|
|
23
|
+
- [등록 API](#등록-api)
|
|
24
|
+
- [Fluent Binding](#fluent-binding)
|
|
25
|
+
- [Lifetime 규칙](#lifetime-규칙)
|
|
26
|
+
- [팩토리 등록](#팩토리-등록)
|
|
27
|
+
- [동적 객체 생성](#동적-객체-생성)
|
|
28
|
+
- [Update Loop 시스템](#update-loop-시스템)
|
|
29
|
+
- [SubscribableProperty (반응형 프로퍼티)](#subscribableproperty-반응형-프로퍼티)
|
|
30
|
+
- [디버그 도구](#디버그-도구)
|
|
31
|
+
- [상용 DI 프레임워크와의 비교](#상용-di-프레임워크와의-비교)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 설치
|
|
36
|
+
|
|
37
|
+
Unity Package Manager에서 Git URL로 추가:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
https://github.com/user/KDIPackage.git
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
또는 `Packages/manifest.json`에 직접 추가:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"com.kylin.di": "https://github.com/user/KDIPackage.git"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 핵심 개념
|
|
56
|
+
|
|
57
|
+
KDI는 세 가지 마커 인터페이스로 동작한다:
|
|
58
|
+
|
|
59
|
+
| 인터페이스 | 역할 | 필수 여부 |
|
|
60
|
+
|-----------|------|----------|
|
|
61
|
+
| `IDependencyObject` | DI 컨테이너에 등록 가능한 타입 표시 | `To<T>()`, `FromInstance()` 사용 시 필수 |
|
|
62
|
+
| `IInjectable` | `[Inject]` 필드 주입 대상 표시 | 필드 주입을 받으려면 필수 |
|
|
63
|
+
| `IPostInjectable` | 주입 완료 후 `PostInject()` 콜백 | 선택 |
|
|
64
|
+
|
|
65
|
+
`IInjectable` 없이 `[Inject]` 필드를 선언하면 **주입되지 않고 경고만 출력된다.** 이는 의도적 설계로, 주입 대상을 명시적으로 표시하도록 강제한다.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 기본 사용법
|
|
70
|
+
|
|
71
|
+
### 1. 서비스 정의
|
|
72
|
+
|
|
73
|
+
```csharp
|
|
74
|
+
// 인터페이스 — IDependencyObject를 상속
|
|
75
|
+
public interface IScoreService : IDependencyObject
|
|
76
|
+
{
|
|
77
|
+
SubscribableProperty<int> Score { get; }
|
|
78
|
+
void AddScore(int amount);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 구현체 — IInjectable로 필드 주입 활성화
|
|
82
|
+
public class ScoreService : IScoreService, IInjectable
|
|
83
|
+
{
|
|
84
|
+
[Inject] private IGameConfig _config;
|
|
85
|
+
|
|
86
|
+
public SubscribableProperty<int> Score { get; } = new(0);
|
|
87
|
+
|
|
88
|
+
public void AddScore(int amount)
|
|
89
|
+
{
|
|
90
|
+
Score.Value += amount * _config.ScoreMultiplier;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
주입 완료 후 초기화가 필요하면 `IPostInjectable`을 추가한다:
|
|
96
|
+
|
|
97
|
+
```csharp
|
|
98
|
+
public class BattleService : IDependencyObject, IInjectable, IPostInjectable
|
|
99
|
+
{
|
|
100
|
+
[Inject] private IUnitRepository _unitRepo;
|
|
101
|
+
[Inject] private IMapService _mapService;
|
|
102
|
+
|
|
103
|
+
private BattleState _state;
|
|
104
|
+
|
|
105
|
+
public void PostInject()
|
|
106
|
+
{
|
|
107
|
+
// 이 시점에서 모든 [Inject] 필드가 주입 완료됨
|
|
108
|
+
_state = new BattleState(_unitRepo.GetAllUnits(), _mapService.CurrentMap);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 2. LifetimeScope에 등록
|
|
114
|
+
|
|
115
|
+
`LifetimeScope`를 상속하고 `Configure` 메서드에서 서비스를 등록한다:
|
|
116
|
+
|
|
117
|
+
```csharp
|
|
118
|
+
public class GameSceneScope : LifetimeScope
|
|
119
|
+
{
|
|
120
|
+
protected override void Configure(ScopeBuilder builder)
|
|
121
|
+
{
|
|
122
|
+
builder.Bind<IGameConfig>().To<GameConfig>().AsScoped();
|
|
123
|
+
builder.Bind<IScoreService>().To<ScoreService>().AsScoped();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
이 컴포넌트를 씬의 GameObject에 추가하면, `Awake` 시 자동으로 Scope가 빌드되고 **하위 Transform의 모든 `IInjectable` 컴포넌트에 주입이 실행된다** (Push 주입).
|
|
129
|
+
|
|
130
|
+
### 3. MonoBehaviour에서 사용
|
|
131
|
+
|
|
132
|
+
`DIBehaviour`를 상속하면 `[Inject]` 필드 주입과 구독 수명 관리를 모두 받는다:
|
|
133
|
+
|
|
134
|
+
```csharp
|
|
135
|
+
public class ScoreUI : DIBehaviour
|
|
136
|
+
{
|
|
137
|
+
[Inject] private IScoreService _scoreService;
|
|
138
|
+
|
|
139
|
+
[SerializeField] private TMP_Text _scoreText;
|
|
140
|
+
|
|
141
|
+
void Start()
|
|
142
|
+
{
|
|
143
|
+
_scoreService.Score
|
|
144
|
+
.Subscribe(score => _scoreText.text = $"Score: {score}", invokeInitial: true)
|
|
145
|
+
.AddTo(_cd); // OnDisable 시 자동 구독 해제
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`DIBehaviour`가 제공하는 것:
|
|
151
|
+
- `[Inject]` 필드 자동 주입 (`IInjectable` 구현 내장)
|
|
152
|
+
- `_cd` (`CompositeDisposable`) — `OnDisable` 시 모든 구독 자동 정리
|
|
153
|
+
- `Scope` 프로퍼티 — 현재 주입된 Scope 접근 (동적 생성 시 사용)
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Scope 계층 구성
|
|
158
|
+
|
|
159
|
+
### 씬 하이어라키 구조
|
|
160
|
+
|
|
161
|
+
KDI의 Scope는 Unity 하이어라키와 1:1로 대응된다. **LifetimeScope 컴포넌트가 붙은 GameObject의 하위 Transform이 해당 Scope의 주입 영역**이다.
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
씬 하이어라키 Scope 구조
|
|
165
|
+
───────────── ──────────
|
|
166
|
+
[RootScope] ← LifetimeScope RootScope (Singleton 등록)
|
|
167
|
+
├── GlobalUI │
|
|
168
|
+
└── [BattleScope] ← LifetimeScope └── BattleScope (Scoped 등록)
|
|
169
|
+
├── Player │
|
|
170
|
+
│ └── HealthBar (DIBehaviour) ├── HealthBar에 주입
|
|
171
|
+
├── EnemySpawner (DIBehaviour) ├── EnemySpawner에 주입
|
|
172
|
+
└── [UIScope] ← LifetimeScope └── UIScope (별도 Scope)
|
|
173
|
+
└── DamagePopup (DIBehaviour) └── DamagePopup에 주입
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**핵심 규칙**: LifetimeScope가 하위 Transform을 순회하며 주입할 때, **다른 LifetimeScope를 만나면 탐색을 중단**한다. UIScope 아래의 컴포넌트는 BattleScope가 아닌 UIScope에서 주입받는다.
|
|
177
|
+
|
|
178
|
+
### 부모-자식 Scope 연결
|
|
179
|
+
|
|
180
|
+
Inspector에서 `_parent` 필드를 지정하여 Scope 계층을 구성한다:
|
|
181
|
+
|
|
182
|
+
```csharp
|
|
183
|
+
// Root — parent 없음 → RootScope로 자동 설정
|
|
184
|
+
public class AppRootScope : LifetimeScope
|
|
185
|
+
{
|
|
186
|
+
protected override void Configure(ScopeBuilder builder)
|
|
187
|
+
{
|
|
188
|
+
builder.Bind<ILogger>().To<GameLogger>().AsSingleton();
|
|
189
|
+
builder.Bind<IAudioService>().To<AudioService>().AsSingleton();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Child — Inspector에서 _parent = AppRootScope 지정
|
|
194
|
+
public class BattleSceneScope : LifetimeScope
|
|
195
|
+
{
|
|
196
|
+
protected override void Configure(ScopeBuilder builder)
|
|
197
|
+
{
|
|
198
|
+
builder.Bind<IBattleService>().To<BattleService>().AsScoped();
|
|
199
|
+
builder.Bind<IUnitManager>().To<UnitManager>().AsScoped();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`_parent`가 `null`인 LifetimeScope는 **RootScope**로 동작하며, `KDI.RootScope`에 자동 등록된다.
|
|
205
|
+
|
|
206
|
+
`_autoInitialize`(기본값 `true`)를 `false`로 설정하면 `Awake`에서 자동 초기화하지 않고, 수동으로 `Initialize()`를 호출해야 한다. parent가 아직 초기화되지 않은 경우 자동으로 parent를 먼저 초기화한다.
|
|
207
|
+
|
|
208
|
+
### Resolution 우선순위
|
|
209
|
+
|
|
210
|
+
Resolve 요청은 **현재 Scope → 부모 Scope → ... → RootScope** 순으로 탐색한다:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
BattleScope에서 Resolve<ILogger>() 호출 시:
|
|
214
|
+
|
|
215
|
+
1. BattleScope에 ILogger 인스턴스가 캐싱되어 있는가? → 없음
|
|
216
|
+
2. BattleScope에 ILogger 등록(Registration)이 있는가? → 없음
|
|
217
|
+
3. Parent(RootScope)에게 위임
|
|
218
|
+
4. RootScope에 ILogger 등록이 있는가? → 있음! → 반환
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**부모와 자식에 같은 인터페이스가 등록된 경우, 자식 Scope의 등록이 우선한다.** 이는 Scope 체인 탐색이 현재 Scope부터 시작하기 때문이다. 부모까지 올라가기 전에 자식에서 이미 찾기 때문에, 자식 Scope에서 부모의 서비스를 **오버라이드**할 수 있다.
|
|
222
|
+
|
|
223
|
+
```csharp
|
|
224
|
+
// RootScope: 기본 구현 등록
|
|
225
|
+
public class AppRootScope : LifetimeScope
|
|
226
|
+
{
|
|
227
|
+
protected override void Configure(ScopeBuilder builder)
|
|
228
|
+
{
|
|
229
|
+
builder.Bind<IDamageCalculator>().To<DefaultDamageCalculator>().AsSingleton();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// BattleScope: 전투 전용 구현으로 오버라이드
|
|
234
|
+
public class BattleSceneScope : LifetimeScope
|
|
235
|
+
{
|
|
236
|
+
protected override void Configure(ScopeBuilder builder)
|
|
237
|
+
{
|
|
238
|
+
// BattleScope 하위에서 Resolve<IDamageCalculator>() 시
|
|
239
|
+
// → BossDamageCalculator가 반환됨 (자식 우선)
|
|
240
|
+
builder.Bind<IDamageCalculator>().To<BossDamageCalculator>().AsScoped();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
이 패턴을 활용하면:
|
|
246
|
+
- **테스트**: 테스트용 Scope에서 Mock 구현으로 오버라이드
|
|
247
|
+
- **씬별 특화**: 같은 인터페이스의 씬 특화 구현 등록
|
|
248
|
+
- **기능 전환**: 특정 구간에서만 다른 동작 적용
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## 등록 API
|
|
253
|
+
|
|
254
|
+
### Fluent Binding
|
|
255
|
+
|
|
256
|
+
`Configure` 메서드 내에서 `ScopeBuilder`의 Fluent API를 사용한다:
|
|
257
|
+
|
|
258
|
+
```csharp
|
|
259
|
+
protected override void Configure(ScopeBuilder builder)
|
|
260
|
+
{
|
|
261
|
+
// 인터페이스 → 구현체 바인딩
|
|
262
|
+
builder.Bind<IService>().To<ServiceImpl>().AsScoped();
|
|
263
|
+
builder.Bind<IService>().To<ServiceImpl>().AsSingleton(); // RootScope에서만
|
|
264
|
+
builder.Bind<IService>().To<ServiceImpl>().AsTransient();
|
|
265
|
+
|
|
266
|
+
// 기존 인스턴스 등록 (항상 Scoped 취급)
|
|
267
|
+
builder.Bind<IService>().FromInstance(existingInstance);
|
|
268
|
+
|
|
269
|
+
// 팩토리 등록 — 복잡한 생성 로직이 필요할 때
|
|
270
|
+
builder.Bind<IService>().FromFactory(scope => {
|
|
271
|
+
var dep = scope.Resolve<IDependency>();
|
|
272
|
+
return new ServiceImpl(dep);
|
|
273
|
+
}).AsScoped();
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`To<T>()`의 타입 제약: `T`는 반드시 `IDependencyObject`와 바인딩 인터페이스를 동시에 구현해야 한다.
|
|
278
|
+
|
|
279
|
+
### Lifetime 규칙
|
|
280
|
+
|
|
281
|
+
| Lifetime | 동작 | 등록 위치 |
|
|
282
|
+
|----------|------|-----------|
|
|
283
|
+
| `AsSingleton()` | 앱 전체에서 인스턴스 하나 | **RootScope만** (다른 곳에서 사용 시 빌드 에러) |
|
|
284
|
+
| `AsScoped()` | 해당 Scope 내에서 인스턴스 하나 | 모든 Scope |
|
|
285
|
+
| `AsTransient()` | Resolve할 때마다 새 인스턴스 | 모든 Scope |
|
|
286
|
+
| `FromInstance()` | 이미 생성된 인스턴스 등록 | 모든 Scope (Scoped로 처리) |
|
|
287
|
+
|
|
288
|
+
**Singleton을 RootScope에서만 허용하는 이유**: child scope에서 Singleton을 등록하면, scope 파괴 시 인스턴스도 파괴되어 "Singleton"이라는 의미와 모순된다. `ScopeBuilder.Build()` 시점에 parent가 존재하면 Singleton 등록을 차단하여 이 혼란을 원천 방지한다.
|
|
289
|
+
|
|
290
|
+
**Scope Freeze**: `Build()` 이후에는 `ScopeBuilder`에 추가 등록이 불가능하다. 런타임 중 등록 변경으로 인한 추적 불가 버그를 방지한다.
|
|
291
|
+
|
|
292
|
+
### 팩토리 등록
|
|
293
|
+
|
|
294
|
+
복잡한 생성 로직이나 외부 인자가 필요한 경우 팩토리를 사용한다:
|
|
295
|
+
|
|
296
|
+
```csharp
|
|
297
|
+
protected override void Configure(ScopeBuilder builder)
|
|
298
|
+
{
|
|
299
|
+
// FromFactory — Scope 접근 가능
|
|
300
|
+
builder.Bind<IBattleService>().FromFactory(scope => {
|
|
301
|
+
var config = scope.Resolve<IBattleConfig>();
|
|
302
|
+
var logger = scope.Resolve<ILogger>();
|
|
303
|
+
var service = new BattleService();
|
|
304
|
+
service.Initialize(config, logger);
|
|
305
|
+
return service;
|
|
306
|
+
}).AsScoped();
|
|
307
|
+
|
|
308
|
+
// RegisterFactory — ScopeBuilder 직접 API
|
|
309
|
+
builder.RegisterFactory<IWeaponFactory>(scope => {
|
|
310
|
+
return new WeaponFactory(scope);
|
|
311
|
+
}, Lifetime.Scoped);
|
|
312
|
+
|
|
313
|
+
// RegisterInstance — 인스턴스 직접 등록
|
|
314
|
+
builder.RegisterInstance<IGameSettings>(loadedSettings);
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**팩토리에서 Scope를 활용한 동적 생성 패턴**:
|
|
319
|
+
|
|
320
|
+
```csharp
|
|
321
|
+
public interface IEnemyFactory : IDependencyObject
|
|
322
|
+
{
|
|
323
|
+
GameObject Create(EnemyType type, Vector3 position);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
public class EnemyFactory : IEnemyFactory, IInjectable
|
|
327
|
+
{
|
|
328
|
+
[Inject] private IScope _scope; // 불가 — IScope는 직접 주입 불가
|
|
329
|
+
|
|
330
|
+
// 대신 DIBehaviour에서 Scope 프로퍼티를 사용하거나,
|
|
331
|
+
// 팩토리 생성 시 scope를 주입한다:
|
|
332
|
+
private readonly IScope _scope;
|
|
333
|
+
|
|
334
|
+
public EnemyFactory(IScope scope) // FromFactory에서 전달
|
|
335
|
+
{
|
|
336
|
+
_scope = scope;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
public GameObject Create(EnemyType type, Vector3 position)
|
|
340
|
+
{
|
|
341
|
+
var prefab = LoadPrefab(type);
|
|
342
|
+
// Scope.Instantiate로 프리팹 생성 + DI 주입
|
|
343
|
+
return _scope.Instantiate(prefab, position, Quaternion.identity);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 등록
|
|
348
|
+
builder.Bind<IEnemyFactory>().FromFactory(scope => {
|
|
349
|
+
return new EnemyFactory(scope);
|
|
350
|
+
}).AsScoped();
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## 동적 객체 생성
|
|
356
|
+
|
|
357
|
+
런타임에 프리팹을 인스턴스화할 때, `Object.Instantiate` 대신 `IScope` 확장 메서드를 사용해야 `[Inject]` 필드가 주입된다:
|
|
358
|
+
|
|
359
|
+
```csharp
|
|
360
|
+
public class EnemySpawner : DIBehaviour
|
|
361
|
+
{
|
|
362
|
+
[Inject] private IEnemyConfig _config;
|
|
363
|
+
[SerializeField] private GameObject _enemyPrefab;
|
|
364
|
+
|
|
365
|
+
public void SpawnEnemy(Vector3 position)
|
|
366
|
+
{
|
|
367
|
+
// Scope.Instantiate = Object.Instantiate + 하위 IInjectable 자동 주입
|
|
368
|
+
var enemy = Scope.Instantiate(_enemyPrefab, position, Quaternion.identity);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
public void InjectExisting(GameObject go)
|
|
372
|
+
{
|
|
373
|
+
// 이미 존재하는 GameObject에 주입
|
|
374
|
+
Scope.InjectGameObject(go);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`ScopeExtensions`가 제공하는 오버로드:
|
|
380
|
+
|
|
381
|
+
```csharp
|
|
382
|
+
scope.Instantiate(prefab); // 기본
|
|
383
|
+
scope.Instantiate(prefab, parent); // 부모 Transform 지정
|
|
384
|
+
scope.Instantiate(prefab, position, rotation); // 위치/회전 지정
|
|
385
|
+
scope.Instantiate(prefab, position, rotation, parent); // 전체 지정
|
|
386
|
+
scope.InjectGameObject(existingGameObject); // 기존 오브젝트에 주입
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`DIBehaviour`의 `Scope` 프로퍼티는 Push 주입 시 자동으로 설정된다. 동적 생성된 오브젝트도 `Scope.Instantiate()`를 통하면 내부의 `DIBehaviour`에 Scope가 올바르게 설정된다.
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## Update Loop 시스템
|
|
394
|
+
|
|
395
|
+
MonoBehaviour가 아닌 순수 C# 클래스에서 매 프레임 로직이 필요할 때 사용한다. Scope를 통해 Resolve되면 `UpdateLoopManager`에 **자동 등록**되고, Scope Dispose 시 **자동 해제**된다.
|
|
396
|
+
|
|
397
|
+
```csharp
|
|
398
|
+
// Update 루프
|
|
399
|
+
public class GameSimulation : IDependencyObject, IInjectable, IUpdatable
|
|
400
|
+
{
|
|
401
|
+
[Inject] private IGameState _state;
|
|
402
|
+
|
|
403
|
+
public void KDIUpdate(float deltaTime)
|
|
404
|
+
{
|
|
405
|
+
_state.Tick(deltaTime);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// FixedUpdate 루프
|
|
410
|
+
public class PhysicsSimulation : IDependencyObject, IFixedUpdatable
|
|
411
|
+
{
|
|
412
|
+
public void KDIFixedUpdate(float fixedDeltaTime)
|
|
413
|
+
{
|
|
414
|
+
StepSimulation(fixedDeltaTime);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// LateUpdate 루프
|
|
419
|
+
public class CameraFollow : IDependencyObject, ILateUpdatable
|
|
420
|
+
{
|
|
421
|
+
public void KDILateUpdate(float deltaTime)
|
|
422
|
+
{
|
|
423
|
+
UpdateCameraPosition(deltaTime);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### 실행 순서 제어
|
|
429
|
+
|
|
430
|
+
`IUpdatePriority`를 구현하면 실행 순서를 제어할 수 있다. **값이 낮을수록 먼저 실행**된다:
|
|
431
|
+
|
|
432
|
+
```csharp
|
|
433
|
+
public class InputProcessor : IDependencyObject, IUpdatable, IUpdatePriority
|
|
434
|
+
{
|
|
435
|
+
public int UpdatePriority => -100; // 가장 먼저 실행
|
|
436
|
+
|
|
437
|
+
public void KDIUpdate(float deltaTime) { /* 입력 처리 */ }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
public class GameLogic : IDependencyObject, IUpdatable, IUpdatePriority
|
|
441
|
+
{
|
|
442
|
+
public int UpdatePriority => 0; // 기본값 (입력 처리 이후)
|
|
443
|
+
|
|
444
|
+
public void KDIUpdate(float deltaTime) { /* 게임 로직 */ }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
public class Renderer : IDependencyObject, IUpdatable, IUpdatePriority
|
|
448
|
+
{
|
|
449
|
+
public int UpdatePriority => 100; // 가장 나중에 실행
|
|
450
|
+
|
|
451
|
+
public void KDIUpdate(float deltaTime) { /* 렌더링 준비 */ }
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
`IUpdatePriority`를 구현하지 않으면 기본 우선순위 0으로 동작한다.
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
## SubscribableProperty (반응형 프로퍼티)
|
|
460
|
+
|
|
461
|
+
값 변경을 관찰할 수 있는 반응형 프로퍼티 시스템. UI 바인딩, 상태 동기화에 사용한다. 별도 외부 라이브러리(UniRx, R3) 없이 프레임워크에 내장되어 있다.
|
|
462
|
+
|
|
463
|
+
### 기본 사용
|
|
464
|
+
|
|
465
|
+
```csharp
|
|
466
|
+
// 서비스에서 상태 노출
|
|
467
|
+
public class PlayerService : IDependencyObject
|
|
468
|
+
{
|
|
469
|
+
public SubscribableProperty<int> Health { get; } = new(100);
|
|
470
|
+
public SubscribableProperty<string> Name { get; } = new("Player");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// UI에서 구독
|
|
474
|
+
public class PlayerHUD : DIBehaviour
|
|
475
|
+
{
|
|
476
|
+
[Inject] private PlayerService _player;
|
|
477
|
+
[SerializeField] private TMP_Text _healthText;
|
|
478
|
+
|
|
479
|
+
void Start()
|
|
480
|
+
{
|
|
481
|
+
_player.Health
|
|
482
|
+
.Subscribe(hp => _healthText.text = $"HP: {hp}", invokeInitial: true)
|
|
483
|
+
.AddTo(_cd);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
`Subscribe`의 `invokeInitial: true`는 구독 시점에 현재 값으로 즉시 콜백을 호출한다. `.AddTo(_cd)`로 `OnDisable` 시 구독이 자동 해제된다.
|
|
489
|
+
|
|
490
|
+
### LINQ 변환
|
|
491
|
+
|
|
492
|
+
```csharp
|
|
493
|
+
// Select — 값 변환
|
|
494
|
+
_player.Health
|
|
495
|
+
.Select(hp => hp / 100f) // int → float (0.0~1.0)
|
|
496
|
+
.Subscribe(ratio => _slider.value = ratio)
|
|
497
|
+
.AddTo(_cd);
|
|
498
|
+
|
|
499
|
+
// Where — 조건 필터링
|
|
500
|
+
_player.Health
|
|
501
|
+
.Where(hp => hp <= 0)
|
|
502
|
+
.Subscribe(_ => ShowDeathScreen())
|
|
503
|
+
.AddTo(_cd);
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### SubscribableCollection
|
|
507
|
+
|
|
508
|
+
리스트의 변경(추가/삭제/교체/이동/초기화)을 개별적으로 관찰할 수 있다:
|
|
509
|
+
|
|
510
|
+
```csharp
|
|
511
|
+
public class InventoryService : IDependencyObject
|
|
512
|
+
{
|
|
513
|
+
public SubscribableCollection<Item> Items { get; } = new();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
public class InventoryUI : DIBehaviour
|
|
517
|
+
{
|
|
518
|
+
[Inject] private InventoryService _inventory;
|
|
519
|
+
|
|
520
|
+
void Start()
|
|
521
|
+
{
|
|
522
|
+
// 전체 변경 구독
|
|
523
|
+
_inventory.Items.Subscribe(change =>
|
|
524
|
+
{
|
|
525
|
+
switch (change.Type)
|
|
526
|
+
{
|
|
527
|
+
case CollectionChangeType.Add:
|
|
528
|
+
CreateSlot(change.Index, change.NewValue);
|
|
529
|
+
break;
|
|
530
|
+
case CollectionChangeType.Remove:
|
|
531
|
+
RemoveSlot(change.Index);
|
|
532
|
+
break;
|
|
533
|
+
case CollectionChangeType.Clear:
|
|
534
|
+
ClearAllSlots();
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
}, invokeForExisting: true).AddTo(_cd);
|
|
538
|
+
|
|
539
|
+
// 특정 이벤트만 구독
|
|
540
|
+
_inventory.Items.SubscribeAdd((index, item) => CreateSlot(index, item)).AddTo(_cd);
|
|
541
|
+
_inventory.Items.SubscribeCount(count => UpdateCountText(count), invokeInitial: true).AddTo(_cd);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
### SubscribableDictionary
|
|
547
|
+
|
|
548
|
+
```csharp
|
|
549
|
+
public SubscribableDictionary<string, int> Stats { get; } = new();
|
|
550
|
+
|
|
551
|
+
Stats.SubscribeAdd((key, value) => Debug.Log($"스탯 추가: {key}={value}")).AddTo(_cd);
|
|
552
|
+
Stats.SubscribeReplace((key, oldVal, newVal) => Debug.Log($"스탯 변경: {key} {oldVal}→{newVal}")).AddTo(_cd);
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### SubscribableCommand
|
|
556
|
+
|
|
557
|
+
조건부 실행이 가능한 커맨드 패턴:
|
|
558
|
+
|
|
559
|
+
```csharp
|
|
560
|
+
var canAttack = new SubscribableProperty<bool>(true);
|
|
561
|
+
var attackCommand = new SubscribableCommand(canAttack, () => PerformAttack());
|
|
562
|
+
|
|
563
|
+
// canAttack.Value가 true일 때만 실행됨
|
|
564
|
+
attackCommand.Execute();
|
|
565
|
+
|
|
566
|
+
// UI 바인딩 — 버튼 활성화 상태 동기화
|
|
567
|
+
attackCommand.CanExecute
|
|
568
|
+
.Subscribe(can => _attackButton.interactable = can)
|
|
569
|
+
.AddTo(_cd);
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
574
|
+
## 디버그 도구
|
|
575
|
+
|
|
576
|
+
### Closure Profiler (에디터 전용)
|
|
577
|
+
|
|
578
|
+
`SubscribableProperty` 구독 시 생성되는 클로저의 메모리 캡처를 분석하는 에디터 윈도우. 메모리 누수 진단에 유용하다.
|
|
579
|
+
|
|
580
|
+
- `this` 캡처 감지 (Critical 위험도)
|
|
581
|
+
- 캡처된 변수별 메모리 추정
|
|
582
|
+
- 활성/해제된 구독 히스토리 추적
|
|
583
|
+
- `ClosureProfilerWindow`에서 실시간 모니터링
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## 상용 DI 프레임워크와의 비교
|
|
588
|
+
|
|
589
|
+
### 기능 비교
|
|
590
|
+
|
|
591
|
+
| 항목 | VContainer | Zenject | KDI |
|
|
592
|
+
|------|-----------|---------|-----|
|
|
593
|
+
| **주입 방식** | 생성자 + 메서드 + 필드 | 생성자 + 메서드 + 필드 + 프로퍼티 | **필드 전용** |
|
|
594
|
+
| **Scope 모델** | LifetimeScope 계층 | Context 계층 | LifetimeScope 계층 |
|
|
595
|
+
| **인스턴스 생성** | IL Emit / Source Generator | Reflection + 캐시 | `Expression.Compile` 캐시 |
|
|
596
|
+
| **순환 참조 감지** | 있음 | 있음 | 있음 (`ThreadStatic`) |
|
|
597
|
+
| **Update 루프** | ITickable 등 | ITickable 등 | IKDIUpdatable 등 |
|
|
598
|
+
| **반응형 시스템** | 없음 (외부 R3 필요) | 없음 (외부 UniRx 필요) | **내장** (SubscribableProperty) |
|
|
599
|
+
| **코드 규모** | ~수천 줄 | ~수만 줄 | **~500줄** (코어) |
|
|
600
|
+
| **학습 곡선** | 보통 | 높음 | **낮음** |
|
|
601
|
+
|
|
602
|
+
### 왜 필드 주입만 사용하는가
|
|
603
|
+
|
|
604
|
+
KDI는 **의도적으로 생성자 주입을 지원하지 않는다.** 이것은 제한이 아니라 설계 결정이다.
|
|
605
|
+
|
|
606
|
+
1. **Unity 호환성**: `MonoBehaviour`는 생성자를 사용할 수 없다. 필드 주입으로 통일하면 MonoBehaviour든 순수 C# 클래스든 **동일한 패턴**으로 DI를 사용한다. "이 클래스는 생성자 주입, 저 클래스는 필드 주입"이라는 혼란이 없다.
|
|
607
|
+
|
|
608
|
+
2. **고속 인스턴스 생성**: 모든 DI 관리 타입이 파라미터 없는 생성자를 가지므로, `Expression.Lambda.Compile()` 기반 고속 팩토리 캐시가 가능하다. 생성자 인자 해석 오버헤드가 없다.
|
|
609
|
+
|
|
610
|
+
3. **학습 비용 최소화**: `[Inject]`를 필드에 붙이면 끝. 팩토리 메서드 시그니처, 생성자 파라미터 순서, `[Inject]` vs 생성자 선택 고민이 없다.
|
|
611
|
+
|
|
612
|
+
### KDI의 장점
|
|
613
|
+
|
|
614
|
+
- **극단적 단순성**: 코어 DI 로직 500줄 미만. 전체 소스를 읽고 이해하는 데 30분이면 충분하다. 프레임워크 내부 동작이 투명하다.
|
|
615
|
+
- **하나의 패턴**: 필드 주입만 지원하므로 프로젝트 전체가 일관된 스타일을 유지한다. 코드 리뷰에서 "왜 여기는 생성자 주입이지?"라는 논쟁이 없다.
|
|
616
|
+
- **반응형 시스템 내장**: `SubscribableProperty`, `SubscribableCollection`, `SubscribableDictionary`가 프레임워크에 포함되어 별도 라이브러리 의존 없이 옵저버 패턴을 사용할 수 있다.
|
|
617
|
+
- **Unity 친화적 설계**: 하이어라키 기반 Push 주입, Transform.IsChildOf 기반 Scope 탐색, MonoBehaviour 생명주기와의 자연스러운 통합.
|
|
618
|
+
- **안전한 구독 관리**: `DIBehaviour`의 `_cd` + `AddTo()` 패턴으로 `OnDisable` 시 구독이 자동 정리된다. 메모리 누수 걱정 없이 사용 가능하다.
|
|
619
|
+
|
|
620
|
+
### KDI의 한계
|
|
621
|
+
|
|
622
|
+
- **필드 주입 전용**: 생성자 주입이 필요한 아키텍처(CQRS 핸들러 자동 등록 등)에는 적합하지 않다.
|
|
623
|
+
- **순수 C# 프로젝트 미지원**: Unity 6 전용이며, `MonoBehaviour`/`Transform` 기반 설계다.
|
|
624
|
+
- **대규모 팀 관습 차이**: VContainer/Zenject에 익숙한 팀원이 있다면 필드 주입 전용 방식에 적응이 필요하다.
|
|
625
|
+
- **생태계 규모**: 상용 프레임워크 대비 커뮤니티 지원, 서드파티 통합이 적다.
|
|
626
|
+
|
|
627
|
+
---
|
|
628
|
+
|
|
629
|
+
## 전체 예시
|
|
630
|
+
|
|
631
|
+
```csharp
|
|
632
|
+
// ── 인터페이스 ──
|
|
633
|
+
public interface IPlayerService : IDependencyObject
|
|
634
|
+
{
|
|
635
|
+
SubscribableProperty<int> Health { get; }
|
|
636
|
+
void TakeDamage(int amount);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
public interface IAudioService : IDependencyObject
|
|
640
|
+
{
|
|
641
|
+
void PlaySFX(string clipName);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── 구현 ──
|
|
645
|
+
public class PlayerService : IPlayerService, IInjectable
|
|
646
|
+
{
|
|
647
|
+
[Inject] private IAudioService _audio;
|
|
648
|
+
|
|
649
|
+
public SubscribableProperty<int> Health { get; } = new(100);
|
|
650
|
+
|
|
651
|
+
public void TakeDamage(int amount)
|
|
652
|
+
{
|
|
653
|
+
Health.Value = Mathf.Max(0, Health.Value - amount);
|
|
654
|
+
_audio.PlaySFX("hit");
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
public class AudioService : IAudioService
|
|
659
|
+
{
|
|
660
|
+
public void PlaySFX(string clipName) { /* 재생 로직 */ }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ── Scope 등록 ──
|
|
664
|
+
public class GameRootScope : LifetimeScope
|
|
665
|
+
{
|
|
666
|
+
protected override void Configure(ScopeBuilder builder)
|
|
667
|
+
{
|
|
668
|
+
builder.Bind<IAudioService>().To<AudioService>().AsSingleton();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
public class BattleScope : LifetimeScope
|
|
673
|
+
{
|
|
674
|
+
// Inspector에서 _parent = GameRootScope 지정
|
|
675
|
+
protected override void Configure(ScopeBuilder builder)
|
|
676
|
+
{
|
|
677
|
+
builder.Bind<IPlayerService>().To<PlayerService>().AsScoped();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ── UI ──
|
|
682
|
+
public class HealthBar : DIBehaviour
|
|
683
|
+
{
|
|
684
|
+
[Inject] private IPlayerService _player;
|
|
685
|
+
[SerializeField] private Slider _slider;
|
|
686
|
+
|
|
687
|
+
void Start()
|
|
688
|
+
{
|
|
689
|
+
_player.Health
|
|
690
|
+
.Select(hp => hp / 100f)
|
|
691
|
+
.Subscribe(ratio => _slider.value = ratio, invokeInitial: true)
|
|
692
|
+
.AddTo(_cd);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
씬 하이어라키:
|
|
698
|
+
```
|
|
699
|
+
[GameRootScope] ← RootScope (Singleton 등록)
|
|
700
|
+
└── [BattleScope] ← ChildScope (Inspector에서 parent 지정)
|
|
701
|
+
├── Player
|
|
702
|
+
│ └── HealthBar ← DIBehaviour, [Inject] 자동 주입
|
|
703
|
+
└── EnemySpawner ← DIBehaviour, Scope.Instantiate()로 동적 생성
|
|
704
|
+
```
|