-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathScriptApiMigrationPlan.html
More file actions
740 lines (659 loc) · 55.6 KB
/
Copy pathScriptApiMigrationPlan.html
File metadata and controls
740 lines (659 loc) · 55.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CreatorEngine 스크립트 API 이전 설계 (ModuleBehavior → CoreCLR)</title>
<style>
:root {
--bg:#0f1218; --surface:#171c26; --surface2:#1e2532; --border:#2b3444;
--text:#dde3ec; --dim:#8b95a7; --accent:#4da3ff; --green:#7ee0b8;
--warn:#f0b25c; --err:#f07178; --purple:#c8a0ff; --code:#12161f;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:"Segoe UI","Malgun Gothic",sans-serif;line-height:1.7;font-size:15px}
.container{max-width:1040px;margin:0 auto;padding:0 28px 80px}
header.hero{padding:56px 0 32px;border-bottom:1px solid var(--border);margin-bottom:40px}
.eyebrow{color:var(--accent);font-size:13px;letter-spacing:3px;text-transform:uppercase;margin-bottom:10px}
h1{font-size:clamp(26px,4vw,38px);font-weight:800;line-height:1.25;margin-bottom:12px}
h1 span{color:var(--accent)}
.meta{color:var(--dim);font-size:14px}
.meta a{color:var(--green)}
section{margin-bottom:52px}
h2.sec{font-size:24px;font-weight:800;padding-bottom:10px;border-bottom:2px solid var(--accent);margin-bottom:20px;display:flex;align-items:baseline;gap:12px}
h2.sec .no{color:var(--accent);font-size:16px}
h3{font-size:18px;margin:26px 0 10px;color:var(--green)}
h4{font-size:15.5px;margin:20px 0 8px}
p{margin-bottom:12px}
strong{color:#fff}
ul,ol{margin:0 0 14px 22px}
li{margin-bottom:5px}
code{background:var(--code);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-family:Consolas,monospace;font-size:13px;color:var(--green)}
pre{background:var(--code);border:1px solid var(--border);border-radius:10px;padding:18px 20px;overflow-x:auto;margin:14px 0 20px}
pre code{background:none;border:none;padding:0;color:var(--text);font-size:13px;line-height:1.6}
.cmt{color:var(--dim)}
.kw{color:var(--purple)}
.str{color:var(--green)}
table{width:100%;border-collapse:collapse;margin:14px 0 20px;font-size:13.5px}
th{background:var(--surface2);text-align:left;padding:10px 12px;border:1px solid var(--border);font-weight:700;white-space:nowrap}
td{padding:9px 12px;border:1px solid var(--border);vertical-align:top}
tr:nth-child(even) td{background:rgba(255,255,255,.02)}
.tw{overflow-x:auto}
.callout{border-left:4px solid var(--accent);background:var(--surface);border-radius:0 10px 10px 0;padding:14px 18px;margin:16px 0}
.callout.good{border-left-color:var(--green)}
.callout.warn{border-left-color:var(--warn)}
.callout.risk{border-left-color:var(--err)}
.callout .t{font-weight:700;margin-bottom:4px}
.callout.good .t{color:var(--green)}
.callout.warn .t{color:var(--warn)}
.callout.risk .t{color:var(--err)}
.callout p:last-child{margin-bottom:0}
.two{display:grid;grid-template-columns:1fr 1fr;gap:18px}
@media(max-width:760px){.two{grid-template-columns:1fr}}
.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px 20px}
.card h4{margin-top:0}
.badge{display:inline-block;font-size:11.5px;font-weight:700;padding:2px 9px;border-radius:20px;margin-right:6px}
.b-rec{background:rgba(126,224,184,.13);color:var(--green);border:1px solid rgba(126,224,184,.4)}
.b-no{background:rgba(240,113,120,.12);color:var(--err);border:1px solid rgba(240,113,120,.4)}
.b-mid{background:rgba(240,178,92,.13);color:var(--warn);border:1px solid rgba(240,178,92,.4)}
footer{border-top:1px solid var(--border);padding-top:22px;color:var(--dim);font-size:13px}
</style>
</head>
<body>
<div class="container">
<header class="hero">
<div class="eyebrow">Script API Migration Design</div>
<h1><span>ModuleBehavior</span> → CoreCLR 스크립트 API 이전 설계</h1>
<div class="meta">
전제: C++ 핫리로드(<code>Dynamic_CPP</code>)를 CoreCLR로 <strong>전면 대체</strong> ·
관련: <a href="RefactoringPlanDashboard.html">리팩토링 대시보드</a> 2-0 / 2-10 ·
작성일: 2026-07-29
</div>
</header>
<!-- ============================================ -->
<section id="principle">
<h2 class="sec"><span class="no">01</span>설계 원칙</h2>
<div class="callout good">
<div class="t">핵심 원칙 — 경계는 얇게, 순회는 안쪽에서</div>
<p>네이티브↔관리 경계를 넘는 <strong>횟수</strong>가 성능을 지배합니다. 개별 스크립트마다 경계를 넘으면
프레임당 수천 회가 되지만, 틱마다 한 번만 넘고 C# 내부에서 순회하면 <strong>프레임당 몇 회</strong>로 끝납니다.
이 원칙 하나가 아래 모든 설계를 규정합니다.</p>
</div>
<div class="tw"><table>
<tr><th>원칙</th><th>이유</th></tr>
<tr><td>객체는 <strong>세대 핸들</strong>로만 주고받는다</td><td>관리 코드는 포인터의 유효성을 알 수 없다. 세대가 있으면 해제된 참조가 자동 무효화된다.</td></tr>
<tr><td>스크립트 간 상호작용은 <strong>C# 내부에서 완결</strong></td><td><code>Entity* sender</code> 같은 참조가 경계를 넘을 필요가 없어져 바인딩 표면이 급감한다.</td></tr>
<tr><td>필드 노출은 <strong>소스 제너레이터</strong>로</td><td>런타임 리플렉션은 Native AOT에서 트리밍된다. 엔진의 C++ 코드젠 철학과도 일치.</td></tr>
<tr><td>관리 코드 호출은 <strong>게임 스레드에서만</strong></td><td>CoreCLR GC는 스레드를 정지시킨다. 렌더 스레드가 물리면 프레임이 GC에 묶인다.</td></tr>
<tr><td>경계 함수는 <strong>평면 C ABI</strong></td><td>P/Invoke는 C++ 객체를 넘길 수 없다. JIT/AOT 두 모드가 같은 바인딩을 공유하게 된다.</td></tr>
</table></div>
</section>
<!-- ============================================ -->
<section id="lifecycle">
<h2 class="sec"><span class="no">02</span>라이프사이클 디스패치</h2>
<p>현재 <code>ModuleBehavior</code>는 가상 함수 14종과 델리게이트 핸들 14개로 구성됩니다.
이름은 Unity 관례를 따르고 있어 <strong>그대로 유지</strong>하길 권합니다 — 팀의 학습 비용이 0이고,
기존 348개 스크립트를 포팅할 때 구조를 그대로 옮길 수 있습니다.</p>
<h3>2.1 디스패치 방식 비교</h3>
<div class="tw"><table>
<tr><th style="width:80px">방식</th><th>구조</th><th>평가</th></tr>
<tr>
<td><span class="badge b-no">비권장</span></td>
<td>스크립트 인스턴스마다 네이티브가 개별 콜백 호출</td>
<td>현재 C++ 델리게이트 구조를 그대로 옮기는 형태. 직관적이지만 <strong>프레임당 경계 통과가 스크립트 수만큼</strong> 발생해 P/Invoke 오버헤드가 누적된다.</td>
</tr>
<tr>
<td><span class="badge b-rec">권장</span></td>
<td>틱당 한 번 진입 → C# 내부에서 순회</td>
<td>네이티브는 <code>OnUpdate(dt)</code>를 한 번만 호출하고, C#의 <code>BehaviourRegistry</code>가 활성 스크립트를 순회한다. 경계 통과가 <strong>프레임당 3~4회</strong>(Fixed/Update/LateUpdate)로 고정된다.</td>
</tr>
<tr>
<td><span class="badge b-mid">부분</span></td>
<td>물리·충돌 콜백은 이벤트 시점에 개별 전달</td>
<td>발생 빈도가 낮아 개별 호출이 타당하다. 다만 한 프레임에 몰릴 수 있으므로 <strong>큐에 모아 틱 경계에서 일괄 전달</strong>하는 편이 안전하다.</td>
</tr>
</table></div>
<h3>2.2 권장 형태</h3>
<pre><code><span class="cmt">// ── 네이티브 → 관리: 진입점은 이것뿐 (P/Invoke 대상)</span>
<span class="cmt">// [UnmanagedCallersOnly] 로 export 하여 함수 포인터로 호출</span>
Script_Awake() <span class="cmt">// 새로 활성화된 것들 일괄 처리</span>
Script_FixedUpdate(float) <span class="cmt">// 고정 틱</span>
Script_Update(float) <span class="cmt">// 프레임 틱</span>
Script_LateUpdate(float)
Script_FlushPhysicsEvents() <span class="cmt">// 큐에 모인 충돌/트리거 일괄 디스패치</span>
Script_OnSceneUnload() <span class="cmt">// OnDisable/OnDestroy 일괄</span>
<span class="cmt">// ── C# 내부: 순회는 여기서</span>
<span class="kw">internal static class</span> BehaviourRegistry
{
<span class="kw">static readonly</span> List<Behaviour> _update = <span class="kw">new</span>();
<span class="kw">public static void</span> Update(<span class="kw">float</span> dt)
{
<span class="cmt">// 순회 중 추가/삭제가 일어나므로 스냅샷 또는 인덱스 순회</span>
<span class="kw">for</span> (<span class="kw">int</span> i = 0; i < _update.Count; ++i)
{
<span class="kw">var</span> b = _update[i];
<span class="kw">if</span> (b.IsAlive && b.Enabled) b.Update(dt);
}
}
}</code></pre>
<div class="callout warn">
<div class="t">순회 중 수정 문제</div>
<p>Unity가 겪은 것과 같은 문제입니다 — <code>Update</code> 안에서 오브젝트를 파괴하거나 컴포넌트를 추가하면
순회 중인 리스트가 바뀝니다. <strong>추가·삭제를 보류 큐에 모아 틱 경계에서 반영</strong>하는 규칙을
처음부터 넣어야 합니다. 지금 C++ 구조도 <code>AllDestroyMark</code> 같은 지연 파괴를 쓰고 있으므로 같은 철학입니다.</p>
</div>
</section>
<!-- ============================================ -->
<section id="objects">
<h2 class="sec"><span class="no">03</span>객체 참조 모델</h2>
<p>엔진에는 이미 <code>HashedGuid</code>(인스턴스 식별)와 <code>FileGuid</code>(에셋 식별)가 있습니다.
핸들 인프라를 밑바닥부터 만들 필요는 없고, <strong>세대 필드만 더하면</strong> 됩니다.</p>
<pre><code><span class="cmt">// 네이티브 측: 기존 HashedGuid에 세대를 더한 형태</span>
<span class="kw">struct</span> ObjectHandle
{
uint32 index; <span class="cmt">// 슬롯 인덱스 (O(1) 조회)</span>
uint32 generation; <span class="cmt">// 슬롯 재사용 시 증가 → 낡은 핸들 자동 무효</span>
};
<span class="cmt">// C# 측: blittable struct 그대로 대응 (마샬링 비용 없음)</span>
<span class="kw">public readonly struct</span> ObjectHandle : IEquatable<ObjectHandle>
{
<span class="kw">readonly uint</span> _index;
<span class="kw">readonly uint</span> _generation;
}
<span class="cmt">// 사용자에게 보이는 얼굴은 얇은 래퍼</span>
<span class="kw">public class</span> GameObject
{
<span class="kw">internal</span> ObjectHandle Handle;
<span class="kw">public</span> Transform transform => <span class="kw">new</span>(Native.GetTransform(Handle));
<span class="kw">public bool</span> IsAlive => Native.IsAlive(Handle); <span class="cmt">// 세대 비교 한 번</span>
}</code></pre>
<div class="callout good">
<div class="t">이 모델이 해결하는 것</div>
<p>Phase 1에서 잡은 본 팔레트 UAF(12.3-②)와 같은 부류의 문제가 <strong>구조적으로 불가능</strong>해집니다.
네이티브가 객체를 파괴하면 세대가 올라가고, C#이 들고 있던 핸들은 다음 접근에서 자동으로 무효 판정됩니다.
Unity의 "destroyed object 접근" 예외와 같은 안전망입니다.</p>
</div>
<h3>3.1 스크립트 간 참조는 경계를 넘지 않는다</h3>
<p>현재 <code>Entity::SendDamage(Entity* sender, int, HitInfo)</code>처럼 스크립트가 서로를 직접 참조합니다.
C#으로 옮기면 이건 <strong>평범한 C# 참조</strong>가 되어 경계와 무관해집니다. <code>HitInfo</code> 같은 커스텀 구조체도
C# 내부 타입이면 바인딩이 필요 없습니다.</p>
<p>즉 <strong>바인딩해야 할 표면은 "엔진이 제공하는 것"뿐</strong>입니다 — Transform, 물리 질의, 입력, 에셋 로드,
사운드 등. 스크립트끼리의 상호작용은 전부 관리 영역에서 끝납니다. 이것이 이전 작업량을 크게 줄여줍니다.</p>
</section>
<!-- ============================================ -->
<section id="fields">
<h2 class="sec"><span class="no">04</span>필드 노출 (인스펙터·직렬화)</h2>
<p>현재 C++ 스크립트는 <code>[[ScriptReflectionField]]</code> 어노테이션 →
<code>ScriptReflectionHeaderTool</code> → <code>.generated.h</code> 흐름으로 인스펙터에 필드를 노출합니다.</p>
<div class="callout risk">
<div class="t">런타임 리플렉션을 쓰면 AOT 문이 닫힌다</div>
<p>C#에서 <code>System.Reflection</code>으로 필드를 순회하는 방식이 가장 쉬워 보이지만,
Native AOT는 사용처가 정적으로 보이지 않는 멤버를 <strong>트리밍</strong>합니다.
나중에 iOS·콘솔로 갈 때 인스펙터가 통째로 동작하지 않는 상황이 됩니다.</p>
</div>
<p><strong>권장: 소스 제너레이터.</strong> 엔진이 이미 C++에서 하고 있는 것과 동일한 철학이고,
컴파일 타임에 메타데이터가 확정되므로 AOT에서도 안전합니다.</p>
<pre><code><span class="cmt">// 사용자가 쓰는 모습 — Unity와 동일한 감각</span>
<span class="kw">public partial class</span> Player : Behaviour
{
[SerializeField] <span class="kw">float</span> _moveSpeed = 5f;
[SerializeField] <span class="kw">int</span> _maxHp = 100;
<span class="kw">public override void</span> Update(<span class="kw">float</span> dt) { ... }
}
<span class="cmt">// 소스 제너레이터가 생성 (partial 로 합쳐짐)</span>
<span class="kw">partial class</span> Player
{
<span class="kw">public override int</span> FieldCount => <span class="num">2</span>;
<span class="kw">public override string</span> GetFieldName(<span class="kw">int</span> i) => i <span class="kw">switch</span>
{
<span class="num">0</span> => <span class="str">"Move Speed"</span>, <span class="cmt">// _moveSpeed 를 다듬은 표시 이름</span>
<span class="num">1</span> => <span class="str">"Max Hp"</span>,
_ => <span class="kw">string</span>.Empty,
};
<span class="kw">public override float</span> GetFloat(<span class="kw">int</span> i) => i <span class="kw">switch</span> { <span class="num">0</span> => _moveSpeed, _ => <span class="num">0f</span> };
<span class="kw">public override void</span> SetFloat(<span class="kw">int</span> i, <span class="kw">float</span> v) { <span class="kw">switch</span> (i) { <span class="kw">case</span> <span class="num">0</span>: _moveSpeed = v; <span class="kw">break</span>; } }
}</code></pre>
<div class="note">
<h4>오프셋이 아니라 인덱스 + 접근자 (구현하며 수정됨)</h4>
<p>이 절의 초안은 <code>FieldDesc</code>에 필드 오프셋을 담아 네이티브가 주소로 직접 읽고 쓰는 형태였습니다.
<strong>C#에서는 성립하지 않습니다</strong> — 관리 객체의 필드 배치는 런타임이 정하고 GC가 객체를 옮길 수도 있어,
네이티브가 잡아 둔 주소는 언제든 어긋납니다.</p>
<p>그래서 인덱스를 키로 하는 타입별 접근자를 생성하는 쪽으로 바꿨습니다. 생성된 <code>switch</code>는
AOT에서도 그대로 남고 비용은 분기 한 번뿐입니다. 부수 효과로 <code>__Serialize</code>/<code>__Deserialize</code>를
따로 만들 필요가 없어졌습니다 — <strong>인스펙터와 직렬화가 같은 접근자를 공유</strong>하면 되기 때문입니다.
네이티브는 <code>FieldCount</code>만큼 돌며 이름·타입을 묻고 값을 주고받습니다.
기존 YAML 포맷과의 호환은 네이티브 쪽 저장 코드에서 흡수합니다.</p>
</div>
</section>
<!-- ============================================ -->
<section id="hotreload">
<h2 class="sec"><span class="no">05</span>핫리로드 (AssemblyLoadContext)</h2>
<p>현재는 MSBuild로 <code>Dynamic_CPP</code>를 재빌드하고 <code>LoadLibrary</code>로 교체한 뒤,
<code>HotLoadSystem::ReplaceScriptComponent()</code>로 씬의 인스턴스를 새 클래스로 마이그레이션합니다.
CoreCLR에서는 이 흐름이 <strong>더 단순해집니다</strong>.</p>
<div class="tw"><table>
<tr><th>단계</th><th>현재 (C++ DLL)</th><th>CoreCLR</th></tr>
<tr><td>빌드</td><td>MSBuild로 vcxproj 재빌드</td><td><code>dotnet build</code> (훨씬 빠름)</td></tr>
<tr><td>로드</td><td><code>LoadLibrary</code> + <code>GetProcAddress</code> 팩토리 바인딩</td><td>collectible <code>AssemblyLoadContext</code>에 로드</td></tr>
<tr><td>상태 보존</td><td>인스턴스 교체 + 필드 수동 복사</td><td>직렬화 → 언로드 → 재로드 → 역직렬화</td></tr>
<tr><td>언로드</td><td><code>FreeLibrary</code> (참조 남으면 크래시)</td><td>ALC 언로드 (참조 남으면 <strong>언로드 실패</strong>로 안전하게 드러남)</td></tr>
</table></div>
<div class="callout warn">
<div class="t">언로드의 유일한 조건</div>
<p>collectible ALC는 <strong>그 컨텍스트의 객체에 대한 참조가 하나도 남지 않아야</strong> 언로드됩니다.
네이티브가 <code>GCHandle</code>을 붙들고 있으면 조용히 실패하고 메모리가 쌓입니다.
따라서 <strong>네이티브는 관리 객체를 직접 들지 않고</strong>, 스크립트 인스턴스도 핸들(정수)로만 참조해야 합니다.
이것이 03절의 핸들 모델이 핫리로드에서도 필수인 이유입니다.</p>
</div>
<p>상태 보존은 04절의 생성된 직렬화 코드를 재사용하면 됩니다 — 리로드 전 <code>__Serialize</code>로 덤프하고,
새 어셈블리 로드 후 <code>__Deserialize</code>로 복원합니다. 현재 필드 수동 복사보다 견고합니다.</p>
</section>
<!-- ============================================ -->
<section id="aot">
<h2 class="sec"><span class="no">06</span>AOT 대응</h2>
<div class="callout good">
<div class="t">IL2CPP 같은 별도 라이브러리는 필요 없다</div>
<p>Unity가 IL2CPP를 만든 건 <strong>Mono의 AOT가 부족했기 때문</strong>입니다.
CoreCLR은 <strong>Native AOT를 공식 제공</strong>하므로 그 계층을 직접 만들 이유가 없습니다.
Mono를 버리고 CoreCLR로 가는 판단의 실질적 이득 중 하나입니다.</p>
</div>
<h3>6.1 모드 분리</h3>
<p>핫리로드와 Native AOT는 <strong>양립할 수 없습니다</strong>(AOT에는 동적 어셈블리 로딩이 없음).
따라서 하나를 고르는 게 아니라 빌드 모드를 나눕니다.</p>
<div class="tw"><table>
<tr><th>모드</th><th>런타임</th><th>핫리로드</th><th>용도</th></tr>
<tr><td>에디터</td><td>CoreCLR JIT + collectible ALC</td><td>가능</td><td>개발 중 반복</td></tr>
<tr><td>배포 (기본)</td><td>CoreCLR JIT 또는 ReadyToRun</td><td>불필요</td><td>Windows 데스크톱</td></tr>
<tr><td>배포 (AOT)</td><td>Native AOT 정적 링크</td><td>불가</td><td>iOS·일부 콘솔 진출 시</td></tr>
</table></div>
<h3>6.2 지금 지켜야 할 것</h3>
<p>AOT를 <strong>지금 구현할 필요는 없습니다</strong>. Windows 데스크톱 타이틀이면 JIT가 금지되지 않고,
시작 시간은 ReadyToRun으로 해결됩니다. 대신 <strong>나중에 켤 수 있는 상태만 유지</strong>하면 됩니다.</p>
<div class="two">
<div class="card">
<h4>금지</h4>
<ul>
<li><code>System.Reflection</code> 기반 필드 순회</li>
<li><code>Expression.Compile()</code>, <code>Reflection.Emit</code></li>
<li><code>Activator.CreateInstance(Type)</code> 동적 생성</li>
<li>런타임 제네릭 인스턴스화에 의존하는 설계</li>
</ul>
</div>
<div class="card">
<h4>권장</h4>
<ul>
<li>소스 제너레이터로 메타데이터·직렬화 생성</li>
<li>팩토리는 생성된 <code>switch</code> 테이블로</li>
<li>blittable struct로 경계 마샬링 회피</li>
<li>CI에 AOT 빌드 검증 잡을 <em>일찍</em> 추가</li>
</ul>
</div>
</div>
<p style="color:var(--dim);font-size:13.5px">마지막 항목이 중요합니다 — AOT 호환성은 한 번 깨지면 원인을 찾기 어렵습니다.
실제 AOT 배포를 안 하더라도 CI에서 주기적으로 AOT 빌드를 돌려두면 위반을 즉시 잡습니다.</p>
</section>
<!-- ============================================ -->
<section id="migration">
<h2 class="sec"><span class="no">07</span>이전 전략</h2>
<p>스크립트가 348개입니다. 한 번에 옮기는 것은 비현실적이고, 그렇다고 두 스크립팅 계층을
오래 공존시키면 복잡도가 두 배가 됩니다.</p>
<h3>권장 순서</h3>
<ol>
<li><strong>수직 슬라이스 먼저</strong> — 가장 단순한 스크립트 <em>하나</em>를 C#으로 옮겨 전 구간(빌드 → 로드 → 라이프사이클 → 인스펙터 → 직렬화 → 핫리로드)을 관통시킵니다. 여기서 설계 구멍이 전부 드러납니다.</li>
<li><strong>대표 케이스 검증</strong> — 물리 콜백을 쓰는 것, 다른 스크립트를 참조하는 것, 코루틴 성격의 것을 각각 하나씩. 바인딩 표면이 여기서 확정됩니다.</li>
<li><strong>공존 기간 최소화</strong> — 위 검증이 끝나면 일괄 포팅에 들어갑니다. C++ 스크립트와 C# 스크립트가 <strong>서로를 참조하지 못하게</strong> 막아두면(같은 씬에 섞이더라도) 공존 복잡도를 낮출 수 있습니다.</li>
<li><strong>Dynamic_CPP 제거</strong> — 포팅 완료 후 <code>HotLoadSystem</code>·<code>CreateFactory</code>·코드젠 도구 2종을 정리합니다. Phase 4의 커플링 제거와 맞물립니다.</li>
</ol>
<div class="callout warn">
<div class="t">자동 변환은 기대하지 마십시오</div>
<p>C++ → C# 자동 변환은 포인터·매크로·템플릿 때문에 실무적으로 신뢰할 수 없습니다.
다만 <strong>라이프사이클 골격과 <code>[[Property]]</code> 필드 선언은 기계적</strong>이므로,
스켈레톤 생성기 정도는 만들 가치가 있습니다. 본문 로직은 수동 포팅이 전제입니다.</p>
</div>
</section>
<!-- ============================================ -->
<section id="verified">
<h2 class="sec"><span class="no">08</span>프로토타입 검증 결과</h2>
<p>설계 타당성과 디버깅 환경을 실제 코드로 확인했습니다. 네이티브 호스트(C++ exe)가 CoreCLR을 임베딩하고
C# 어셈블리를 호출하는 최소 구성으로 검증했습니다.</p>
<div class="callout good">
<div class="t">검증 통과 — 설계가 실제로 동작합니다</div>
<p>02~03절에서 제안한 구조(틱당 1회 경계 통과 + 관리 내부 순회 + 핸들 전달 + 역방향 콜백)가
그대로 작동하는 것을 확인했습니다.</p>
</div>
<div class="tw"><table>
<tr><th style="width:52px">결과</th><th>항목</th><th>확인 내용</th></tr>
<tr><td><span class="badge b-rec">PASS</span></td><td>hostfxr 로드 / 런타임 초기화</td>
<td><code>get_hostfxr_path</code> → <code>hostfxr_initialize_for_runtime_config</code> 정상. 클래스 라이브러리는 <code>GenerateRuntimeConfigurationFiles</code>를 켜야 <code>runtimeconfig.json</code>이 생성된다.</td></tr>
<tr><td><span class="badge b-rec">PASS</span></td><td>UnmanagedCallersOnly 바인딩</td>
<td><code>UNMANAGEDCALLERSONLY_METHOD</code>로 4개 진입점을 함수 포인터로 획득. 델리게이트 마샬링 없이 직접 호출된다.</td></tr>
<tr><td><span class="badge b-rec">PASS</span></td><td>틱당 1회 경계 + 내부 순회</td>
<td>스크립트 3개 × 5프레임 = 누적 15. 경계 통과는 프레임당 1회로 고정.</td></tr>
<tr><td><span class="badge b-rec">PASS</span></td><td>관리 → 네이티브 역호출</td>
<td>네이티브 함수 포인터를 <code>nint</code>로 넘겨 C#에서 <code>delegate* unmanaged<></code>로 호출. 엔진 API 제공 방식으로 유효.</td></tr>
<tr><td><span class="badge b-rec">PASS</span></td><td>런타임 모듈 적재</td>
<td>네이티브 프로세스 안에 <code>coreclr.dll</code>, <code>clrjit.dll</code>, <code>System.Private.CoreLib.dll</code>, 스크립트 어셈블리가 모두 로드됨(총 35개 모듈).</td></tr>
<tr><td><span class="badge b-no">제약</span></td><td>혼합 모드 디버깅</td>
<td><strong>x64에서 미지원.</strong> 아래 참조.</td></tr>
</table></div>
<h3>8.1 혼합 모드 디버깅 — 확인된 제약</h3>
<div class="callout risk">
<div class="t">CoreCLR은 x64 혼합 모드 디버깅을 지원하지 않습니다</div>
<p>PDB와 런타임 모듈은 모두 갖춰지지만, <strong>관리 코드에서 네이티브로 스텝인하거나 한 세션에서
양쪽 콜스택을 함께 보는 것이 불가능</strong>합니다. .NET Framework에서는 되던 것이 CoreCLR에서
빠진 회귀 사항이며(dotnet/runtime #7547), 현재까지 해소되지 않았습니다.</p>
</div>
<h4>실제 영향 범위</h4>
<p>다행히 <strong>타격은 경계 지점에 국한</strong>됩니다. 01절의 "경계는 얇게" 원칙이 여기서 두 번째 이득을 줍니다.</p>
<div class="tw"><table>
<tr><th>작업</th><th>디버깅</th><th>영향</th></tr>
<tr><td>게임 로직 개발 (스크립트)</td><td>관리 전용 디버거</td><td><strong>영향 없음</strong> — C# 세계 안에서 완결된다</td></tr>
<tr><td>엔진 개발 (렌더/물리)</td><td>네이티브 전용 디버거</td><td><strong>영향 없음</strong> — 지금과 동일</td></tr>
<tr><td>바인딩 경계 문제 추적</td><td>세션 분리 필요</td><td><strong>불편</strong> — 아래 대응 필요</td></tr>
</table></div>
<h4>대응 방안</h4>
<ol>
<li><strong>경계에 로깅을 의무화</strong> — 이미 HTML 로그 시스템(0-6)과 크래시 훅이 있습니다. 경계 통과 지점(진입점 6개 내외)에 진단 로그를 넣으면 스텝 디버깅 없이도 흐름을 추적할 수 있습니다. 경계가 얇기 때문에 이 방법이 현실적으로 통합니다.</li>
<li><strong>디버거 세션 분리</strong> — 네이티브는 VS로 시작하고, 관리 코드는 별도로 "프로세스에 연결(관리 전용)"합니다. 두 세션을 동시에 띄울 수 있으며, 각각의 브레이크포인트는 정상 동작합니다.</li>
<li><strong>경계 계약을 단순하게 유지</strong> — 경계 함수가 blittable 타입만 주고받으면 마샬링 버그 자체가 발생하지 않아 디버깅 필요성이 줄어듭니다.</li>
<li><strong>관리 측 예외를 네이티브로 전파하지 않기</strong> — 경계에서 예외를 잡아 오류 코드로 변환하고 로그를 남깁니다. 경계를 넘는 예외는 디버깅이 가장 어려운 형태입니다.</li>
</ol>
<div class="callout warn">
<div class="t">전환 판단에 미치는 영향</div>
<p>이 제약은 <strong>CoreCLR 전환을 재고할 사유는 아닙니다</strong>. 현재 C++ 핫리로드 구조에서도
DLL 경계를 넘는 디버깅은 이미 제한적이고, Mono를 택해도 같은 문제가 있습니다.
다만 <strong>팀에 미리 공유</strong>해야 할 사항입니다 — 모르고 있다가 겪으면 전환 자체를 의심하게 됩니다.</p>
</div>
</section>
<!-- ============================================ -->
<section id="decided">
<h2 class="sec"><span class="no">09</span>확정 사항</h2>
<h3>9.0 런타임 버전 — .NET 10 (LTS)</h3>
<p><strong>확정.</strong> .NET은 짝수 버전이 LTS이며, 10은 3년 지원을 받습니다.
게임 프로젝트는 개발·서비스 기간이 길어 LTS 고정이 맞습니다.
현재 개발 머신에 <code>Microsoft.NETCore.App 10.0.10</code>이 설치되어 있어 즉시 착수 가능합니다.</p>
<p><code>TargetFramework</code>는 <code>net10.0</code>으로 고정하고, 배포 시 <code>RollForward</code> 정책을
<code>latestPatch</code>로 두어 보안 패치만 따라가게 합니다(마이너 버전 자동 상승 금지).</p>
<h3>9.1 코루틴 — C# <code>async</code> / <code>IEnumerator</code></h3>
<p><strong>확정.</strong> C++의 <code>Core.Coroutine</code>은 스크립트 관례에서 제외하고 C# 표준 방식을 씁니다.
둘의 성격이 다르므로 용도를 나누는 편이 혼란이 적습니다.</p>
<div class="tw"><table>
<tr><th style="width:130px">방식</th><th>용도</th><th>주의</th></tr>
<tr><td><code>IEnumerator</code> + 커스텀 스케줄러</td><td>프레임 단위 연출 (Unity의 <code>WaitForSeconds</code> 감각)</td><td>엔진 틱에 맞춰 직접 펌핑해야 한다. 게임 시간·일시정지와 연동 가능.</td></tr>
<tr><td><code>async</code>/<code>await</code></td><td>에셋 로드, 파일 I/O 등 비동기 작업</td><td>기본 <code>SynchronizationContext</code>는 스레드풀이다. <strong>게임 스레드 컨텍스트를 직접 설치</strong>하지 않으면 continuation이 워커 스레드에서 돌아 엔진 API를 건드린다.</td></tr>
</table></div>
<div class="callout warn">
<div class="t">필수 선행: SynchronizationContext</div>
<p>01절의 "관리 코드는 게임 스레드에서만" 규칙을 지키려면, 프레임 틱에서 continuation을 펌핑하는
커스텀 <code>SynchronizationContext</code>를 반드시 설치해야 합니다. 이걸 빼먹으면
<code>await</code> 이후 코드가 조용히 워커 스레드로 넘어가 재현이 어려운 크래시가 됩니다.</p>
</div>
<h4>구현 범위 — Unity 친화적이되 전부는 아님</h4>
<p>Unity 사용 경험을 그대로 살릴 수 있는 핵심만 구현하고, 렌더 파이프라인에 깊이 얽히거나
대체 수단이 있는 것은 제외합니다.</p>
<div class="tw"><table>
<tr><th style="width:52px">구분</th><th style="width:210px">원형</th><th>비고</th></tr>
<tr><td><span class="badge b-rec">필수</span></td><td><code>yield return null</code></td><td>다음 프레임까지 대기. 가장 기본.</td></tr>
<tr><td><span class="badge b-rec">필수</span></td><td><code>WaitForSeconds(t)</code></td><td>게임 시간 기준. 타임스케일·일시정지 영향을 받는다.</td></tr>
<tr><td><span class="badge b-rec">필수</span></td><td><code>WaitForSecondsRealtime(t)</code></td><td>일시정지 중에도 흐르는 UI·연출용. 위와 짝이라 함께 두는 편이 혼란이 적다.</td></tr>
<tr><td><span class="badge b-rec">필수</span></td><td><code>WaitUntil(pred)</code> / <code>WaitWhile(pred)</code></td><td>조건 대기. 구현이 단순하고 활용도가 높다.</td></tr>
<tr><td><span class="badge b-rec">필수</span></td><td><code>WaitForFixedUpdate()</code></td><td>물리 틱 동기화. 물리 코드와 엮인 연출에 필요.</td></tr>
<tr><td><span class="badge b-rec">필수</span></td><td>중첩 코루틴 (<code>yield return StartCoroutine(...)</code>)</td><td>하위 시퀀스 대기. 없으면 연출 조합이 어렵다.</td></tr>
<tr><td><span class="badge b-mid">선택</span></td><td><code>WaitForEndOfFrame()</code></td><td>렌더 완료 시점 의존. 현재 멀티스레드 렌더 구조(프록시 커맨드 큐)에서 의미가 모호해 <strong>보류 권장</strong>. 스크린샷 등 실제 요구가 생기면 그때 정의.</td></tr>
<tr><td><span class="badge b-no">제외</span></td><td><code>AsyncOperation</code> yield</td><td>에셋 로딩 대기는 <code>async</code>/<code>await</code> 쪽으로 일원화한다. 두 갈래를 만들면 혼란만 커진다.</td></tr>
<tr><td><span class="badge b-no">제외</span></td><td>Unity 특화 타입 (<code>UnityWebRequest</code> 등)</td><td>해당 없음.</td></tr>
</table></div>
<h4>수명 규칙 (Unity와 동일하게)</h4>
<ul>
<li>오브젝트가 파괴되면 그 오브젝트의 코루틴은 <strong>자동 중단</strong>된다.</li>
<li>컴포넌트가 비활성화되면 <strong>중단</strong>된다(재활성화해도 재개되지 않음). Unity 동작과 같게 하여 학습 비용을 없앤다.</li>
<li><code>StartCoroutine</code> / <code>StopCoroutine</code> / <code>StopAllCoroutines</code> 제공.</li>
<li>펌핑은 <code>Update</code> 직후 게임 스레드에서 수행하고, <code>WaitForFixedUpdate</code>만 물리 틱에서 처리한다.</li>
</ul>
<h3>9.2 BehaviorTree · AniBehavior — C#으로 함께 이동</h3>
<p><strong>확정.</strong> 현재 <code>Dynamic_CPP</code>가 <code>CreateBTActionNode</code>,
<code>CreateAniBehavior</code> 팩토리를 함께 제공하므로, 스크립트만 옮기고 이 둘을 남기면
<strong>네이티브 핫리로드 인프라를 계속 유지해야 합니다</strong>. 함께 옮겨야 <code>HotLoadSystem</code>과
코드젠 도구 2종을 완전히 제거할 수 있습니다.</p>
<p>노드 팩토리는 소스 제너레이터로 생성한 <code>switch</code> 테이블로 대체하면 되고(06절 AOT 규칙과 동일),
기존 <code>.bt</code> 에셋 포맷은 그대로 유지한 채 노드 구현체만 관리 측으로 옮기는 형태가 됩니다.</p>
<h3>9.3 에디터 인스펙터 — <span style="color:var(--green)">렌더링은 네이티브 유지</span> (권고)</h3>
<div class="callout good">
<div class="t">결론: 에디터 UI를 관리 코드로 옮기지 않기를 권합니다</div>
<p>현재 인스펙터가 <strong>이미 타입 무관 범용 렌더러</strong>이기 때문입니다.
<code>ImGuiDrawHelperModuleBehavior.cpp</code>는 스크립트 종류마다 코드가 있는 게 아니라,
<code>Meta::Find(타입명)</code>으로 메타데이터를 찾아 <code>Meta::DrawProperties()</code>에 넘기는
50줄짜리 위임 코드입니다. <strong>C# 필드 메타데이터를 같은 형태로 등록하면 기존 렌더러가 그대로 그립니다.</strong></p>
</div>
<div class="tw"><table>
<tr><th>관점</th><th>렌더링을 관리 코드로 이전</th><th>메타데이터만 이전 (권고)</th></tr>
<tr><td>경계 통과 빈도</td><td><span class="badge b-no">치명적</span> ImGui는 즉시 모드라 <strong>매 프레임 수백~수천 회</strong></td><td><span class="badge b-rec">양호</span> 선택 변경 시 1회, 값 편집 시 1회. 매 프레임 0회</td></tr>
<tr><td>기존 자산</td><td>InspectorWindow(60KB)·MenuBar(110KB) 등 전면 재작성</td><td>그대로 재사용</td></tr>
<tr><td>디버깅</td><td>UI가 경계에 걸쳐 있어 08절 제약이 상시 발목</td><td>UI는 순수 네이티브라 영향 없음</td></tr>
<tr><td>추가 의존성</td><td>ImGui.NET 바인딩 유지보수</td><td>없음</td></tr>
</table></div>
<p>즉 <strong>C#은 "무엇을 그릴지"(필드 이름·타입·값)만 넘기고, "어떻게 그릴지"는 네이티브가 담당</strong>합니다.
01절의 경계 원칙과도 정확히 일치합니다.</p>
<p style="color:var(--dim);font-size:13.5px">다만 "C#으로 에디터 확장 작성"(Unity의 커스텀 에디터 같은 것)은
별개 주제입니다. 지금 결정할 필요는 없고, 필요해지면 확장 지점만 열어주는 방식으로 나중에 추가할 수 있습니다.</p>
<h3>9.4 배포 형태 — <span style="color:var(--green)">에디터와 게임을 분리</span> (권고)</h3>
<div class="callout warn">
<div class="t">게임 배포에는 framework-dependent를 권하지 않습니다</div>
<p>최종 사용자에게 <strong>".NET 런타임을 설치하세요"를 요구하는 순간 이탈이 발생</strong>합니다.
Steam 등 배포 플랫폼에서도 마찰이 되고, 사용자가 다른 버전을 설치해 두면 버전 불일치 문제가 생깁니다.
게임은 이미 수 GB 규모라 런타임 동봉분(약 70MB)은 실질적 부담이 아닙니다.</p>
</div>
<div class="tw"><table>
<tr><th style="width:90px">대상</th><th>권장</th><th>이유</th></tr>
<tr><td><strong>에디터</strong></td><td>framework-dependent</td><td>개발자는 SDK를 이미 갖고 있다. 빌드가 빠르고 산출물이 가볍다. <strong>말씀하신 방향이 여기서는 맞습니다.</strong></td></tr>
<tr><td><strong>게임 배포</strong></td><td>self-contained + ReadyToRun</td><td>설치 요구 제거, 버전 고정. R2R로 시작 시간 단축. 필요하면 트리밍으로 크기 축소.</td></tr>
</table></div>
<p>두 형태는 <strong>같은 스크립트 어셈블리를 공유</strong>하므로 코드가 갈라지지 않습니다.
차이는 배포 시 <code>PublishSelfContained</code> 설정뿐이라 전환 비용이 거의 없습니다.</p>
</section>
<!-- ============================================ -->
<section id="workflow">
<h2 class="sec"><span class="no">10</span>디버깅 워크플로 규칙 (확정)</h2>
<p>08절에서 확인한 제약(x64 혼합 모드 미지원)을 전제로 한 운영 규칙입니다.</p>
<h3>10.1 경계 로깅 — 디버그는 항상, 릴리스는 옵트인</h3>
<p>혼합 모드 디버깅이 막힌 만큼 경계 로그가 사실상 유일한 추적 수단입니다.
개발 중에는 항상 남기고, 배포 빌드에서는 필요할 때만 켭니다.</p>
<div class="tw"><table>
<tr><th style="width:110px">빌드</th><th>동작</th></tr>
<tr><td>Debug / Editor</td><td>경계 통과를 <strong>TRACE 레벨로 항상 기록</strong>. 기존 HTML 로그의 레벨 필터로 화면에서 걸러 볼 수 있다.</td></tr>
<tr><td>Release / 배포</td><td>기본 <strong>꺼짐</strong>. 환경 변수 또는 실행 인자(<code>--clr-trace</code>)로 켠다. 사용자 환경에서 문제가 보고되면 로그만 켜서 재현 요청 가능.</td></tr>
</table></div>
<h4>로그 포맷</h4>
<pre><code><span class="cmt">// 진입점 이름 · 인자 요약 · 처리량 · 소요 시간</span>
[CLR] Update(dt=0.0160) → behaviours=142, 0.21ms
[CLR] FlushPhysicsEvents() → events=7, 0.03ms
[CLR] CreateBehaviour(handle=1042, type=<span class="str">"PlayerController"</span>) → ok</code></pre>
<p>소요 시간을 함께 남기는 이유는, 경계 비용이 설계 가정(틱당 1회)을 벗어나는지 감시하기 위해서입니다.
<code>Update</code> 한 번이 수 ms로 늘어나면 관리 측 순회에 문제가 생겼다는 신호입니다.</p>
<h3>10.2 예외 정책 — 잡아서 로그 남기고 해당 스크립트만 비활성화</h3>
<p>스크립트 하나의 버그가 에디터 전체를 죽이지 않도록 합니다. Unity와 같은 감각이라 학습 비용도 없습니다.</p>
<pre><code><span class="kw">public static int</span> Update(<span class="kw">float</span> dt)
{
<span class="kw">for</span> (<span class="kw">int</span> i = 0; i < _active.Count; ++i)
{
<span class="kw">var</span> b = _active[i];
<span class="kw">if</span> (!b.IsAlive || !b.Enabled) <span class="kw">continue</span>;
<span class="kw">try</span> { b.Update(dt); }
<span class="kw">catch</span> (Exception ex)
{
<span class="cmt">// 스택 트레이스를 그대로 로그에 남긴다(ToString에 내부 예외까지 포함).</span>
Engine.LogError($<span class="str">"[{b.GetType().Name}] Update 예외 — 이 스크립트를 비활성화합니다.\n{ex}"</span>);
<span class="cmt">// 같은 예외가 매 프레임 반복되어 로그가 폭주하는 것을 막는다.</span>
b.Enabled = <span class="kw">false</span>;
}
}
<span class="kw">return</span> 0;
}</code></pre>
<div class="callout good">
<div class="t">비활성화를 사용자에게 알리기</div>
<p>예외로 꺼진 스크립트는 인스펙터에 <strong>경고 표시</strong>를 남깁니다.
조용히 동작을 멈추면 "왜 안 되지"로 시간을 버리게 됩니다.
핫리로드 시 다시 활성화되므로, 코드를 고쳐 저장하면 자연스럽게 복구됩니다.</p>
</div>
<p>물리 콜백·<code>Awake</code>·<code>Start</code> 등 다른 진입점도 동일하게 감쌉니다.
경계를 넘어 네이티브로 전파되는 관리 예외는 <strong>0건이어야 합니다</strong>.</p>
<h3>10.3 세션 분리 절차</h3>
<p>네이티브와 관리를 한 세션에서 동시에 볼 수 없으므로, 필요에 따라 두 방식 중 하나를 씁니다.</p>
<div class="tw"><table>
<tr><th style="width:130px">상황</th><th>절차</th></tr>
<tr><td>엔진 코드 디버깅</td><td>평소대로 VS에서 <code>Academy_4Q</code>를 F5로 시작. <strong>지금과 완전히 동일하다.</strong></td></tr>
<tr><td>스크립트 디버깅</td><td>① 에디터를 실행(F5 또는 직접 실행) → ② VS <em>디버그 → 프로세스에 연결</em> → ③ 대상 <code>Academy_4Q.exe</code> 선택 → ④ <strong>연결할 코드 유형에서 "관리(.NET Core)" 선택</strong> → ⑤ C# 브레이크포인트 설정.</td></tr>
<tr><td>경계 문제 추적</td><td>스텝인이 불가하므로 <strong>10.1의 경계 로그로 좁힌 뒤</strong>, 어느 쪽 문제인지 판명되면 해당 유형으로 연결해 디버깅.</td></tr>
</table></div>
<p style="color:var(--dim);font-size:13.5px">④를 빠뜨리면 네이티브 유형으로 연결되어 C# 브레이크포인트가 걸리지 않습니다.
가장 흔한 실수이므로 팀 온보딩 문서에 명시할 것.</p>
<h3>10.4 경계 시그니처 체크리스트</h3>
<p>마샬링 버그는 디버깅이 가장 어려운 부류이므로, 경계 함수는 리뷰 시 아래를 확인합니다.</p>
<div class="two">
<div class="card">
<h4>허용</h4>
<ul>
<li>정수·부동소수 (<code>int</code>, <code>float</code>, <code>long</code>…)</li>
<li>포인터/핸들 (<code>nint</code>, <code>ObjectHandle</code>)</li>
<li>blittable 고정 크기 struct</li>
<li>UTF-8 바이트 포인터 + 길이 (문자열)</li>
</ul>
</div>
<div class="card">
<h4>금지</h4>
<ul>
<li><code>string</code> 직접 전달 (마샬링 발생)</li>
<li>배열·컬렉션 직접 전달</li>
<li>클래스 참조</li>
<li><code>bool</code> (크기가 플랫폼마다 다름 → <code>int</code> 사용)</li>
</ul>
</div>
</div>
</section>
<section id="surface">
<h2 class="sec"><span class="no">11</span>바인딩 표면 설계 (실측 기반)</h2>
<p>07절이 “바인딩 표면은 대표 케이스 검증에서 확정된다”고 미뤄둔 부분입니다.
추측으로 API 목록을 만드는 대신, 기존 스크립트 <strong>348개 파일 · 32,010줄</strong>이
실제로 무엇을 얼마나 호출하는지 전수 조사한 결과를 근거로 삼았습니다.</p>
<h3>11.1 실측 호출 분포</h3>
<p>이 분포가 바인딩 우선순위와 최적화 대상을 결정합니다. 상위 6종이 전체 호출의 대부분을 차지합니다.</p>
<div class="tw"><table>
<tr><th style="width:230px">호출</th><th style="width:80px">횟수</th><th>이전 시 성격</th></tr>
<tr><td><code>GetComponent<T></code> 계열</td><td><strong>734</strong></td><td>압도적 1위. 이 하나의 설계가 전체 성능을 좌우한다.</td></tr>
<tr><td><code>GetOwner()</code></td><td><strong>497</strong></td><td>컴포넌트 → GameObject. C# 객체 내부 필드로 해결 가능(경계 불필요).</td></tr>
<tr><td><code>GameObject::Find(name)</code></td><td>181</td><td>문자열 인자. 해시 전달로 마샬링 제거 대상.</td></tr>
<tr><td><code>SetEnabled(bool)</code></td><td>148</td><td>대상이 C# 스크립트면 경계 불필요.</td></tr>
<tr><td>사운드 계열 합계</td><td><strong>435</strong></td><td><code>GetSoudNameRandom</code> 99, <code>SetParameter</code> 94, <code>GetSoundName</code> 84, <code>PlayOneShot</code> 75, <code>GetSFXPool</code> 43 등. 예상보다 훨씬 큰 비중 — 1급 시민으로 취급해야 한다.</td></tr>
<tr><td>프리팹 <code>LoadPrefab</code>/<code>InstantiatePrefab</code></td><td>172</td><td>문자열 인자 + 핸들 반환.</td></tr>
<tr><td><code>FindIndex(idx)</code></td><td>100</td><td>인덱스 기반. blittable.</td></tr>
<tr><td>Transform <code>GetWorldPosition</code>/<code>SetPosition</code></td><td>131</td><td>float3 왕복. blittable이라 비용이 낮다.</td></tr>
<tr><td><code>Destroy</code></td><td>34</td><td>지연 파괴 큐로 처리.</td></tr>
<tr><td>물리 <code>SphereOverlap</code>/<code>RaycastAll</code></td><td>28</td><td>결과 배열 반환 — 버퍼 규약 필요.</td></tr>
<tr><td>입력 <code>IsKeyDown</code> 등</td><td>16</td><td>의외로 적다. 대부분 <code>Player</code>에 집중.</td></tr>
</table></div>
<div class="note">
<h4>조사가 뒤집은 가정 세 가지</h4>
<ul>
<li><strong>코루틴은 실제로 쓰이지 않는다.</strong> 스크립트 전체에서 <code>Coroutine</code>·<code>co_await</code>·<code>IEnumerator</code> 검색 결과 <strong>0건</strong>입니다.
상태 플래그 + <code>Update(tick)</code> 폴링으로 대체하는 패턴이 일관되게 쓰이고 있습니다.
따라서 09.1에서 확정한 코루틴 스케줄러(필수 6종)는 <strong>포팅 경로의 선행 과제가 아니라 신규 편의 기능</strong>입니다.
이전 작업에서 빼고 나중에 독립적으로 넣어도 됩니다 — 일정에서 상당 부분을 덜어냅니다.</li>
<li><strong><code>Time</code> 클래스도 거의 쓰이지 않는다.</strong> 델타타임은 <code>Update(float tick)</code> 매개변수로 직접 받는 방식이 202곳입니다.
<code>Time</code> 바인딩은 최소 표면만 두면 됩니다.</li>
<li><strong><code>Transform</code>은 컴포넌트가 아니다.</strong> <code>GameObject</code>의 값 멤버(<code>m_transform</code>)입니다.
다만 스크립트가 <code>GetComponent<Transform>()</code>도 실제로 쓰고 있어(<code>Player.cpp:79</code>), C# 측은 두 접근을 모두 지원해야 합니다.</li>
</ul>
</div>
<h3>11.2 경계를 넘는 것과 넘지 않는 것</h3>
<p>03.1에서 확정한 “스크립트 간 참조는 경계를 넘지 않는다”를 실측에 적용하면, 상위 호출 상당수가 <strong>경계를 통과하지 않습니다</strong>.
이것이 이 설계의 핵심 이점입니다.</p>
<div class="tw"><table>
<tr><th style="width:150px">구분</th><th>대상</th><th>처리</th></tr>
<tr><td><strong>C# 내부 완결</strong></td><td>스크립트 → 스크립트 참조, <code>GetOwner()</code>, 스크립트의 <code>SetEnabled</code>, 스크립트 간 이벤트</td><td>관리 객체 그래프에서 직접 해결. P/Invoke 없음.</td></tr>
<tr><td><strong>경계 통과</strong></td><td>엔진 컴포넌트(<code>Transform</code>·<code>Animator</code>·<code>SoundComponent</code>·<code>ImageComponent</code>) 접근, 씬 조회, 프리팹, 물리, 입력</td><td>평면 C ABI. 아래 표면 정의.</td></tr>
</table></div>
<pre><code><span class="cmt">// GetComponent는 T가 무엇이냐에 따라 갈린다 — 685회 호출의 성격을 나누는 지점</span>
<span class="kw">public</span> T GetComponent<T>() <span class="kw">where</span> T : Component
{
<span class="kw">if</span> (ComponentKind<T>.IsManaged) <span class="cmt">// 스크립트(Behaviour 파생)</span>
<span class="kw">return</span> _managedComponents.Get<T>(); <span class="cmt">// 경계 없음</span>
<span class="cmt">// 엔진 컴포넌트 — 핸들만 받아 얇은 래퍼로 감싼다</span>
ObjectHandle h = Native.GameObject_GetComponent(_handle, ComponentKind<T>.NativeTypeId);
<span class="kw">return</span> h.IsValid ? NativeComponentCache.Wrap<T>(h) : <span class="kw">null</span>;
}</code></pre>
<p><code>ComponentKind<T></code>의 <code>IsManaged</code>와 <code>NativeTypeId</code>는 소스 제너레이터가 컴파일 타임에 채웁니다(04절·06절과 같은 방식).
런타임 리플렉션을 쓰지 않으므로 AOT에서도 안전합니다.</p>
<h3>11.3 문자열 — 해시로 넘긴다</h3>
<p>문자열 인자를 받는 호출이 <code>Find</code> 181 + <code>LoadPrefab</code> 94 + 사운드 이름 183 등 <strong>460회 이상</strong>입니다.
<code>string</code>을 그대로 넘기면 호출마다 마샬링이 발생합니다.
다행히 엔진은 이미 <code>HashingString</code>을 쓰고 있으므로, <strong>해시 값(blittable)을 넘기는 것이 자연스러운 대응</strong>입니다.</p>
<pre><code><span class="cmt">// 사용자가 쓰는 모습은 그대로 문자열</span>
<span class="kw">var</span> boss = GameObject.Find(<span class="str">"Boss"</span>);
<span class="cmt">// 소스 제너레이터가 리터럴을 컴파일 타임 해시로 치환 → 마샬링 0</span>
<span class="kw">var</span> boss = GameObject.FindByHash(<span class="num">0x8A31_C4E2</span> <span class="cmt">/* "Boss" */</span>);
<span class="cmt">// 런타임 문자열은 캐시를 거친다(같은 문자열 반복 조회가 대부분이다)</span>
<span class="kw">var</span> obj = GameObject.Find(runtimeName); <span class="cmt">// 내부에서 해시 캐시 조회</span></code></pre>
<p>해시 충돌 시 동작은 네이티브 쪽 기존 규칙을 그대로 따릅니다(이름 조회는 원래도 충돌 가능성을 안고 있었습니다).
진단이 필요한 디버그 빌드에서만 원본 문자열을 함께 넘겨 검증하는 옵션을 둡니다.</p>
<h3>11.4 티어별 바인딩 표면</h3>
<p>실측 빈도에 따라 4단계로 나눕니다. <strong>T1만 있으면 수직 슬라이스가 돌아가고</strong>, T1+T2면 대다수 스크립트가 포팅 가능합니다.</p>
<div class="tw"><table>
<tr><th style="width:70px">티어</th><th style="width:200px">표면</th><th>비고</th></tr>
<tr><td><strong>T1</strong><br><span class="dim">필수</span></td>
<td><code>GameObject_GetComponent</code><br><code>GameObject_FindByHash</code> / <code>FindIndex</code><br><code>GameObject_SetEnabled</code> / <code>Destroy</code><br><code>Transform_Get/SetPosition</code><br><code>Transform_Get/SetRotation</code><br><code>Transform_GetForward/Right/Up</code></td>
<td>전부 blittable. 반환은 <code>ObjectHandle</code> 또는 <code>float3/float4</code>.
이것만으로 <em>대표 스크립트 하나를 끝까지 관통</em>시킬 수 있다.</td></tr>
<tr><td><strong>T2</strong><br><span class="dim">게임 필수</span></td>
<td>프리팹 <code>Load</code>/<code>Instantiate</code><br>사운드 <code>PlayOneShot</code>/<code>SetParameter</code>/풀 조회<br><code>Animator_SetAnimation</code>/컨트롤러 파라미터</td>
<td>사운드가 435회로 예상보다 크다. 풀 매니저는 게임 코드이므로 <strong>C#으로 통째 이전</strong>하고, 경계는 FMOD 래퍼(<code>SoundComponent</code>)까지만 둔다.</td></tr>
<tr><td><strong>T3</strong><br><span class="dim">기능</span></td>
<td>물리 <code>RaycastAll</code>/<code>SphereOverlap</code>/<code>Sweep</code><br>입력 키·마우스·패드<br>UI <code>ImageComponent</code>/<code>TextComponent</code></td>
<td>물리는 결과가 배열이라 <strong>호출자 제공 버퍼</strong> 규약이 필요하다(<code>int Raycast(..., HitInfo* buf, int cap)</code>).
입력은 호출이 적어 단순 래핑으로 충분하다.</td></tr>
<tr><td><strong>T4</strong><br><span class="dim">후순위</span></td>
<td>씬 조회, <code>Time</code>, 코루틴 스케줄러</td>
<td>실측 사용량이 낮거나(씬 40회, Time 1회) 아예 없다(코루틴 0회). 마지막에 넣어도 포팅을 막지 않는다.</td></tr>
</table></div>
<h3>11.5 물리 결과 배열 규약</h3>
<p>T3에서 유일하게 형태를 미리 정해둬야 하는 부분입니다. 관리 측 배열을 네이티브가 할당하게 하면 소유권이 복잡해지므로,
<strong>호출자가 버퍼를 주고 네이티브는 채우기만 하는</strong> 형태로 고정합니다.</p>
<pre><code><span class="cmt">// 네이티브: 채운 개수를 반환. 버퍼가 모자라면 cap까지만 채우고 실제 필요 개수를 음수로 알린다.</span>
<span class="kw">extern</span> <span class="str">"C"</span> <span class="kw">int</span> Physics_RaycastAll(float3 origin, float3 dir, <span class="kw">float</span> dist,
<span class="kw">uint32</span> layerMask, RaycastHit* buffer, <span class="kw">int</span> capacity);
<span class="cmt">// C#: stackalloc으로 할당 없이 처리. 대부분의 호출은 결과가 몇 개뿐이다.</span>
Span<RaycastHit> hits = <span class="kw">stackalloc</span> RaycastHit[<span class="num">16</span>];
<span class="kw">int</span> n = Physics.RaycastAll(origin, dir, dist, mask, hits);</code></pre>
<p><code>RaycastHit</code>은 blittable struct로 정의합니다(위치·법선·거리·<code>ObjectHandle</code>). 08절에서 검증한 물리 콜백 구조체와 같은 원칙입니다.</p>
<h3>11.6 이전 순서 (07절 구체화)</h3>
<div class="tw"><table>
<tr><th style="width:50px">단계</th><th style="width:170px">대상</th><th>완료 기준</th></tr>
<tr><td>S1</td><td>T1 표면 + <code>Behaviour</code> 기반 클래스 + 소스 제너레이터 골격</td>
<td>필드 2개짜리 단순 스크립트(<code>Bomb</code> 수준) 하나가 <strong>빌드 → 로드 → 라이프사이클 → 인스펙터 → 직렬화 → 핫리로드</strong> 전 구간을 통과.</td></tr>
<tr><td>S2</td><td>대표 케이스 3종</td>
<td>물리 콜백을 쓰는 것 / 다른 스크립트를 참조하는 것 / 프리팹을 생성하는 것 각각 하나씩. 여기서 T2 표면이 확정된다.</td></tr>
<tr><td>S3</td><td><code>Entity</code> 계층</td>
<td>게임 공통 2차 베이스(<code>Entity</code>)를 옮긴다. 몬스터·아이템 계열 파생이 전부 여기에 매달려 있어, 이것이 서야 대량 포팅이 가능하다.</td></tr>
<tr><td>S4</td><td>일괄 포팅 (32,010줄)</td>
<td>큰 것부터 위험하다 — <code>Player</code>(2,831줄) · <code>TBoss1</code>(1,951줄)은 마지막에. 작은 스크립트로 관례를 굳힌 뒤 진입한다.</td></tr>
<tr><td>S5</td><td>BehaviorTree · AniBehavior</td>
<td>BT 콘텐츠 3,055줄 + 인프라 902줄, AniBehavior 1,372줄. 09.2 확정대로 함께 이동해야 <code>HotLoadSystem</code>과 코드젠 도구 2종을 제거할 수 있다.
<code>.bt</code>/<code>.blackboard</code> 에셋 포맷은 유지하고 노드 구현체만 옮긴다.</td></tr>
<tr><td>S6</td><td><code>Dynamic_CPP</code> 제거</td>
<td>Phase 4 커플링 제거와 맞물린다.</td></tr>
</table></div>
<div class="note">
<h4>규모 재평가</h4>
<p>스크립트 본문 32,010줄에 BT·AniBehavior 5,300줄이 더해집니다. 자동 변환은 여전히 기대할 수 없지만(07절),
조사에서 <strong>코루틴 0건 · <code>Time</code> 사실상 미사용</strong>이 확인되어 선행 인프라가 줄었습니다.
반대로 <strong>사운드 API 435회</strong>는 초기 예상보다 훨씬 큰 비중이라 T2에서 확실히 다뤄야 합니다.
<code>[[Property]]</code> 413개 · <code>[[Method]]</code> 112개는 소스 제너레이터가 기계적으로 흡수하므로 수동 작업 대상이 아닙니다.</p>
</div>
</section>
<footer>
이 문서는 스크립트 계층을 <code>Dynamic_CPP</code>(C++ 핫리로드)에서 CoreCLR로 전면 대체한다는 결정을 전제로 작성되었습니다.
구현 전 대시보드 2-0(경계 설계)에서 핸들 타입과 소유권 규칙이 먼저 확정되어야 합니다.
11절의 호출 분포는 2026-07-29 기준 <code>Dynamic_CPP\Assets\Script</code> 전수 조사 결과입니다.
</footer>
</div>
</body>
</html>