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.
Files changed (71) hide show
  1. package/.github/workflows/publish.yml +22 -0
  2. package/CHANGELOG.md +17 -0
  3. package/CLAUDE.md +58 -0
  4. package/LICENSE +21 -0
  5. package/README.md +704 -0
  6. package/Runtime/Attributes/InjectAttribute.cs +11 -0
  7. package/Runtime/Attributes/InjectAttribute.cs.meta +2 -0
  8. package/Runtime/Attributes/ViewModelAttribute.cs +17 -0
  9. package/Runtime/Attributes/ViewModelAttribute.cs.meta +2 -0
  10. package/Runtime/Attributes.meta +8 -0
  11. package/Runtime/Core/DIBehaviour.cs +70 -0
  12. package/Runtime/Core/DIBehaviour.cs.meta +2 -0
  13. package/Runtime/Core/LifetimeScope.cs +264 -0
  14. package/Runtime/Core/LifetimeScope.cs.meta +2 -0
  15. package/Runtime/Core.meta +8 -0
  16. package/Runtime/DI/DependencyBuilder.cs +114 -0
  17. package/Runtime/DI/DependencyBuilder.cs.meta +2 -0
  18. package/Runtime/DI/DependencyInjector.cs +104 -0
  19. package/Runtime/DI/DependencyInjector.cs.meta +2 -0
  20. package/Runtime/DI/InstanceFactory.cs +36 -0
  21. package/Runtime/DI/InstanceFactory.cs.meta +2 -0
  22. package/Runtime/DI/KDI.cs +54 -0
  23. package/Runtime/DI/KDI.cs.meta +2 -0
  24. package/Runtime/DI/Registration.cs +29 -0
  25. package/Runtime/DI/Registration.cs.meta +2 -0
  26. package/Runtime/DI/Scope.cs +183 -0
  27. package/Runtime/DI/Scope.cs.meta +2 -0
  28. package/Runtime/DI/ScopeBuilder.cs +68 -0
  29. package/Runtime/DI/ScopeBuilder.cs.meta +2 -0
  30. package/Runtime/DI/ScopeExtensions.cs +70 -0
  31. package/Runtime/DI/ScopeExtensions.cs.meta +2 -0
  32. package/Runtime/DI.meta +8 -0
  33. package/Runtime/Debug/ClosureAnalyzer.cs +377 -0
  34. package/Runtime/Debug/ClosureAnalyzer.cs.meta +2 -0
  35. package/Runtime/Debug/ClosureProfilerWindow.cs +435 -0
  36. package/Runtime/Debug/ClosureProfilerWindow.cs.meta +2 -0
  37. package/Runtime/Debug/SubscriberInfo.cs +661 -0
  38. package/Runtime/Debug/SubscriberInfo.cs.meta +3 -0
  39. package/Runtime/Debug.meta +3 -0
  40. package/Runtime/Kylin.DI.asmdef +14 -0
  41. package/Runtime/Kylin.DI.asmdef.meta +7 -0
  42. package/Runtime/SubscribableProperty/Reaction.cs +61 -0
  43. package/Runtime/SubscribableProperty/Reaction.cs.meta +2 -0
  44. package/Runtime/SubscribableProperty/SubscribableCollection.cs +325 -0
  45. package/Runtime/SubscribableProperty/SubscribableCollection.cs.meta +3 -0
  46. package/Runtime/SubscribableProperty/SubscribableCollectionExtensions.cs +24 -0
  47. package/Runtime/SubscribableProperty/SubscribableCollectionExtensions.cs.meta +3 -0
  48. package/Runtime/SubscribableProperty/SubscribableCommand.cs +52 -0
  49. package/Runtime/SubscribableProperty/SubscribableCommand.cs.meta +2 -0
  50. package/Runtime/SubscribableProperty/SubscribableDictionary.cs +350 -0
  51. package/Runtime/SubscribableProperty/SubscribableDictionary.cs.meta +3 -0
  52. package/Runtime/SubscribableProperty/SubscribableProperty.cs +119 -0
  53. package/Runtime/SubscribableProperty/SubscribableProperty.cs.meta +2 -0
  54. package/Runtime/SubscribableProperty/SubscribablePropertyExtensions.cs +39 -0
  55. package/Runtime/SubscribableProperty/SubscribablePropertyExtensions.cs.meta +3 -0
  56. package/Runtime/SubscribableProperty/SubscribablePropertyLinq.cs +86 -0
  57. package/Runtime/SubscribableProperty/SubscribablePropertyLinq.cs.meta +2 -0
  58. package/Runtime/SubscribableProperty.meta +8 -0
  59. package/Runtime/Update/IFixedUpdatable.cs +16 -0
  60. package/Runtime/Update/IFixedUpdatable.cs.meta +2 -0
  61. package/Runtime/Update/ILateUpdatable.cs +16 -0
  62. package/Runtime/Update/ILateUpdatable.cs.meta +2 -0
  63. package/Runtime/Update/IUpdatable.cs +16 -0
  64. package/Runtime/Update/IUpdatable.cs.meta +2 -0
  65. package/Runtime/Update/IUpdatePriority.cs +15 -0
  66. package/Runtime/Update/IUpdatePriority.cs.meta +2 -0
  67. package/Runtime/Update/UpdateLoopManager.cs +240 -0
  68. package/Runtime/Update/UpdateLoopManager.cs.meta +2 -0
  69. package/Runtime/Update.meta +8 -0
  70. package/Runtime.meta +8 -0
  71. 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
+ ```
@@ -0,0 +1,11 @@
1
+ using System;
2
+
3
+ namespace Kylin.DI
4
+ {
5
+ [AttributeUsage(AttributeTargets.Field)]
6
+ public class InjectAttribute : Attribute
7
+ {
8
+
9
+ }
10
+ }
11
+