# Unity 개발 지침(MVVM/MVC · 성능 · 주석 · 직렬화 · Nullable) 본 지침은 Unity에서 디자인(View)과 로직(Model/Service/ViewModel)을 분리하고, 성능과 주석 품질을 일관되게 유지하기 위한 실전 규칙과 샘플 코드를 제공합니다. 현 프로젝트 스타일(#nullable enable, UniTask, DOTween, 한국어 주석)에 맞춰 작성되었습니다. ## 1) 권장 아키텍처(요약) - View(UI): 데이터 바인딩/이벤트 라우팅만 담당. 연산/상태 보유 금지. - Model/Service/ViewModel: 상태, 연산, 데이터 흐름을 담당. MonoBehaviour 비의존(테스트 용이). - 느슨한 결합: 인터페이스/이벤트/메시지로 연결해 교체·테스트 쉬움. - Prefab 경로 직렬화: GameObject 직접 참조 대신 문자열 경로 보관, 런타임 로드. - C# Nullable: 파일 선두에 `#nullable enable`, 모든 참조형에 `?`를 명시하고 널 처리. - 비동기: 전역적으로 `Cysharp.Threading.Tasks.UniTask` 사용. `Task`/코루틴 대신 UniTask 우선, `CancellationToken` 전달. - 패턴 선택: - MVVM: UI 상태/양방향 동기화가 많을 때 권장. - MVC: 입력 → 도메인 액션 → UI 반영 흐름이 단순할 때 권장. ## 2) 폴더 구조(예시) - `Assets/Scripts/Architecture` - `Model`, `View`, `ViewModel(or Controller)`, `Services(Loader/Repository)`, `Serialization` - `Assets/Prefabs`, `Assets/Resources`(또는 Addressables) - Scene 수와 무관하게 ViewModel은 Scene에 종속되지 않게 유지. ## 3) 주석·문서화 원칙(C# XML 스타일) - 클래스 주석: 역할/책임/사용 예를 자세히. - 메서드 주석: 요약 + 파라미터/반환/예외. 복잡 로직만 상세, 단순 로직은 한 줄. - 속성 주석: 한 줄 요약. - 코드와 불일치한 주석은 즉시 수정(“왜 변경했는지”를 남김). - 인텔리센스 문서 출력: - 프로젝트 속성 > Build > __XML documentation file__ - 솔루션 전체 분석: __Tools > Options > Text Editor > C# > Advanced > Enable full solution analysis__ - 정적 분석: __Analyze > Run Code Analysis__ - 경고 엄격: 프로젝트 속성 > Build > __Treat warnings as errors__ ## 4) 성능 체크리스트(UI/게임 루프) - Update에서 GC 할당 금지: LINQ/문자열 연결/클로저 생성 지양, 캐시·풀 사용. - DOTween: 트윈 핸들 보관, 수명 종료 시 `Kill()`, `SetUpdate(true)` 사용 여부 명확화. - RectTransform 픽셀 정렬: 비정수 스케일/서브픽셀로 인한 블러 방지. - 텍스처 Import: - 과도 축소/회전 시 `MipMaps` + `Trilinear`, 압축 품질 조정. - 초소형 아이콘은 전용 해상도 제작(예: 10/16/24px). - 비동기 처리: UniTask + `CancellationToken` 지원. - 대량 목록: 가상화(Recycler), 청크 처리(예: 100개 처리마다 `await UniTask.Yield()`). - Canvas Rebuild 최소화: 대량 활성/비활성 시 부모를 잠시 비활성 후 일괄 적용, 마지막에 활성. - Layout 강제 재빌드 사용 절제: `LayoutRebuilder.ForceRebuildLayoutImmediate`는 배치/프레임 단위 최소 호출. - TextMeshPro: 기본은 오토 사이즈 비활성, 필요한 화면만 전용 폰트/사이즈 프리셋 사용. ## 5) 직렬화 가이드(Prefab 경로 기반) - ScriptableObject/MonoBehaviour에는 GameObject 참조 대신 “경로 문자열”을 직렬화. - 로더 레이어는 Addressables(권장) 또는 `Resources.Load`로 추상화. - 경로 변환/검증 유틸은 한 곳에서 관리하고, 런타임 Null을 안전하게 처리. ## 6) MVVM/MVC 선택 가이드 - MVVM: - ViewModel은 순수 C#(MonoBehaviour 비의존). - 다수의 UI 상태/양방향 바인딩, 테스트·모킹 용이. - MVC: - 입력 흐름 단순, 컨트롤러가 명령을 조정하고 View는 표시만 수행. --- ## 7) 샘플 코드 ### 7.1 Prefab 경로 직렬화 유틸(Nullable, 예제 포함) ```c# #nullable enable using System; using System.Threading; using Cysharp.Threading.Tasks; using UnityEngine; namespace Architecture.Serialization { /// /// 프리팹 참조를 직렬화 친화적인 문자열 경로로 보관합니다. /// /// 사용 예: /// /// CreateSpinnerAsync(CancellationToken ct) /// { /// var prefab = await spinnerPrefab.LoadAsync(ct); /// return prefab != null ? GameObject.Instantiate(prefab) : null; /// } /// ]]> /// /// [Serializable] public struct PrefabRef { [SerializeField] private string _path; // "UI/Spinner" (Resources) 또는 Addressables 키 /// 프리팹 경로(읽기 전용). public string Path => _path ?? string.Empty; /// 비어있는지 여부. public bool IsEmpty => string.IsNullOrWhiteSpace(_path); /// /// 프리팹을 동기 로드(Resources). Addressables는 LoadAsync 사용 권장. /// public GameObject? Load() { #if USE_ADDRESSABLES Debug.LogWarning("Addressables 사용 시 LoadAsync를 사용하세요."); return null; #else return string.IsNullOrEmpty(_path) ? null : Resources.Load(_path); #endif } /// /// 프리팹을 비동기 로드합니다. /// /// 취소 토큰. public async UniTask LoadAsync(CancellationToken ct = default) { #if USE_ADDRESSABLES if (string.IsNullOrEmpty(_path)) return null; var handle = UnityEngine.AddressableAssets.Addressables.LoadAssetAsync(_path); try { await handle.Task.AsUniTask().AttachExternalCancellation(ct); return handle.Status == UnityEngine.ResourceManagement.AsyncOperations.AsyncOperationStatus.Succeeded ? handle.Result : null; } finally { // 필요 시 Release는 Instantiate 후에 호출합니다. // Addressables.Release(handle); } #else if (string.IsNullOrEmpty(_path)) return null; var request = Resources.LoadAsync(_path); await request.AsUniTask(cancellationToken: ct); return request.asset as GameObject; #endif } /// 경로를 명시적으로 설정합니다. public PrefabRef(string path) => _path = path; } } ``` ### 7.2 Model + ViewModel(초보자용 주석 포함, 단순 로직은 간단히) ```c# #nullable enable using System; using Architecture.Serialization; using UnityEngine; namespace Architecture.Model { /// /// 할 일 아이템 모델. /// /// 책임: /// - 데이터 보유(Name/완료 여부/아이콘 프리팹) /// - 변경 통지 이벤트 제공 /// /// 사용 예: /// /// Debug.Log($"Changed: {i.Name}"); /// ]]> /// /// [Serializable] public sealed class TodoItem { /// 표시 이름. public string Name { get; private set; } /// 완료 여부. public bool IsCompleted { get; private set; } /// 아이콘 프리팹 경로. public PrefabRef IconPrefab { get; private set; } /// 모델 변경 이벤트. public event Action? OnChanged; public TodoItem(string name, bool isCompleted = false, PrefabRef iconPrefab = default) { Name = name; IsCompleted = isCompleted; IconPrefab = iconPrefab; } /// /// 이름을 변경합니다. /// /// 새 이름. public void Rename(string name) { if (Name == name) return; // 단순 가드 Name = name; OnChanged?.Invoke(this); } /// 완료/미완료 토글. public void ToggleCompleted() { IsCompleted = !IsCompleted; OnChanged?.Invoke(this); } } } ``` ```c# #nullable enable using System; using System.Collections.Generic; using System.Linq; using Architecture.Model; namespace Architecture.ViewModel { /// /// Todo 목록의 프레젠테이션 상태를 관리하는 ViewModel. /// /// 책임: /// - 모델 컬렉션 보유/필터/정렬 /// - View에 필요한 이벤트(추가/제거/갱신) 제공 /// - MonoBehaviour에 의존하지 않음(테스트 용이) /// /// 사용 예: /// /// Debug.Log(item.Name); /// vm.Add(new TodoItem("씬 로딩 최적화")); /// ]]> /// /// public sealed class TodoListViewModel { private readonly List _items = new(); /// 현재 아이템 스냅샷. public IReadOnlyList Items => _items; public event Action? OnItemAdded; public event Action? OnItemRemoved; public event Action? OnItemChanged; /// /// 아이템을 추가합니다. /// /// 추가할 모델. public void Add(TodoItem item) { _items.Add(item); item.OnChanged += HandleItemChanged; OnItemAdded?.Invoke(item); } /// /// 아이템을 제거합니다. /// /// 제거할 모델. public void Remove(TodoItem item) { if (_items.Remove(item)) { item.OnChanged -= HandleItemChanged; OnItemRemoved?.Invoke(item); } } /// /// 간단한 텍스트 필터를 적용한 결과를 반환합니다. /// /// 포함할 텍스트(대소문자 무시). public IEnumerable Filter(string keyword) { if (string.IsNullOrWhiteSpace(keyword)) return _items; var lower = keyword.ToLowerInvariant(); return _items.Where(i => i.Name.ToLowerInvariant().Contains(lower)); } private void HandleItemChanged(TodoItem item) => OnItemChanged?.Invoke(item); } } ``` ### 7.3 View(MVVM 바인딩 예, 간단·상세 주석 구분) ```c# #nullable enable using System; using System.Collections.Generic; using System.Threading; using Architecture.Model; using Architecture.Serialization; using Architecture.ViewModel; using Cysharp.Threading.Tasks; using TMPro; using UnityEngine; using UnityEngine.UI; namespace Architecture.View { /// /// TodoList의 View. /// /// 책임: /// - ViewModel 이벤트를 구독해 UI 생성/갱신 /// - 사용자 입력을 ViewModel에 전달 /// - PrefabRef를 사용해 아이템 프리팹을 런타임 로드/생성 /// /// 개선 사항: /// - Model-View 매핑을 위해 Dictionary를 사용, O(1) 검색으로 성능 향상. /// - 명시적 핸들러 메서드로 이벤트 구독/해제 대칭성 확보. /// - async void 대신 UniTask 반환 및 .Forget() 패턴으로 예외 처리 강화. /// /// 주의: /// - 모든 구독/비동기 작업은 OnDestroy에서 해제/취소합니다. /// public sealed class TodoListView : MonoBehaviour { [Header("UI")] [SerializeField] private RectTransform _content = null!; [SerializeField] private TMP_InputField _filterInput = null!; [SerializeField] private Button _addButton = null!; [SerializeField] private PrefabRef _itemPrefab; // "UI/TodoItem" 등 private readonly CancellationTokenSource _cts = new(); private readonly Dictionary _rowViews = new(); private TodoListViewModel _vm = null!; private void Awake() { // Composition Root에서 주입받는 것을 권장. 여기서는 예시로 직접 생성. Init(new TodoListViewModel()); } /// /// ViewModel을 주입하고 UI를 초기화합니다. /// public void Init(TodoListViewModel viewModel) { _vm = viewModel; // 이벤트 구독 (명시적 핸들러 메서드 사용) _vm.OnItemAdded += OnItemAddedHandler; _vm.OnItemRemoved += HandleItemRemoved; _vm.OnItemChanged += HandleItemChanged; // 입력 이벤트 _addButton.onClick.AddListener(OnAddButtonClicked); _filterInput.onValueChanged.AddListener(OnFilterInputChanged); } /// ViewModel의 OnItemAdded 이벤트 핸들러. private void OnItemAddedHandler(TodoItem item) { HandleItemAdded(item).Forget(ex => Debug.LogError($"[TodoListView] Failed to add item: {ex}")); } /// Add 버튼 클릭 핸들러. private void OnAddButtonClicked() { _vm.Add(new TodoItem("New Task")); } /// 필터 입력 변경 핸들러. private void OnFilterInputChanged(string value) { RebuildFiltered(); } private async UniTask HandleItemAdded(TodoItem item) { var prefab = await _itemPrefab.LoadAsync(_cts.Token); if (prefab == null || _cts.IsCancellationRequested) return; var go = Instantiate(prefab, _content); var row = go.GetComponent(); if (row != null) { row.Bind(item); _rowViews[item] = row; // 딕셔너리에 추가 } } private void HandleItemRemoved(TodoItem item) { if (_rowViews.TryGetValue(item, out var row)) { Destroy(row.gameObject); _rowViews.Remove(item); // 딕셔너리에서 제거 } } private void HandleItemChanged(TodoItem item) { if (_rowViews.TryGetValue(item, out var row)) { row.Refresh(); } } private void RebuildFiltered() { var keyword = _filterInput.text.ToLowerInvariant(); var hasKeyword = !string.IsNullOrWhiteSpace(keyword); foreach (var (item, row) in _rowViews) { var visible = !hasKeyword || item.Name.ToLowerInvariant().Contains(keyword); row.gameObject.SetActive(visible); } } private void OnDestroy() { _cts.Cancel(); _cts.Dispose(); // ViewModel 이벤트 구독 해제 (등록과 대칭) if (_vm != null) { _vm.OnItemAdded -= OnItemAddedHandler; _vm.OnItemRemoved -= HandleItemRemoved; _vm.OnItemChanged -= HandleItemChanged; } // UI 이벤트 구독 해제 _addButton.onClick.RemoveListener(OnAddButtonClicked); _filterInput.onValueChanged.RemoveListener(OnFilterInputChanged); } } /// /// 개별 Row(View) 컴포넌트. /// - 간단 로직: 바인딩/리프레시만 담당 /// public sealed class TodoRowView : MonoBehaviour { [SerializeField] private TMP_Text _nameText = null!; [SerializeField] private Toggle _completedToggle = null!; /// 현재 바인딩된 모델(읽기 전용). public TodoItem? BoundItem { get; private set; } /// /// Row를 모델에 바인딩합니다. /// /// 바인딩할 모델. public void Bind(TodoItem item) { BoundItem = item; _completedToggle.onValueChanged.AddListener(OnToggleChanged); Refresh(); } /// UI를 현재 모델 값으로 갱신합니다. public void Refresh() { if (BoundItem == null) return; _nameText.text = BoundItem.Name; _completedToggle.SetIsOnWithoutNotify(BoundItem.IsCompleted); } private void OnToggleChanged(bool value) { // 단순 전달: 모델 토글 BoundItem?.ToggleCompleted(); } private void OnDestroy() { _completedToggle.onValueChanged.RemoveListener(OnToggleChanged); } } } ``` --- ## 8) 현 코드베이스(HierarchyWindow 등) 적용 팁 - 긴 작업(검색 등)은 현재처럼 청크 처리 + `await UniTask.Yield()`로 UI 프리즈 방지. - 로딩 아이콘/초소형 UI: - 과도 축소 시 MipMap + Trilinear를 켜거나, 전용 해상도 스프라이트 사용. - 회전보다는 Image `Filled` + `fillAmount` 애니메이션이 초소형에서 더 깔끔. - DOTween 트윈: 멤버로 보관, `OnDestroy`/취소 시 `Kill()` 처리(현재 코드가 모범 사례). - `#nullable enable` 유지, `UnityEngine.Object` 계열도 `?` 표기 후 Null 체크. - 입력/검색 UI는 View, 검색 로직/필터는 ViewModel로 추출하면 테스트·교체 용이. ## 9) 품질 자동화(선택) - StyleCop.Analyzers, Roslynator로 XML 주석/네이밍 규칙 검사. - `.editorconfig`에 문서 주석 필수 규칙 설정. - VS에서 정기 점검: __Analyze > Run Code Analysis__, 빌드에서 __Treat warnings as errors__. - CI에서 XML 문서 누락/nullable 경고를 실패로 처리해 조기 차단. - 권장 `.editorconfig` 요약: - `dotnet_diagnostic.CS1591.severity = error` (공개 멤버 문서 주석 필수) - `dotnet_analyzer_diagnostic.category-Style.severity = warning` - `dotnet_analyzer_diagnostic.category-Nullable.severity = error` - `dotnet_diagnostic.IDE0060.severity = warning` (사용 안 하는 매개변수) - `dotnet_style_qualification_for_field = true:suggestion` --- ## 10) 권장 디자인 패턴 - DI/Composition Root - 언제: 서비스/로더/뷰모델 주입이 필요할 때 초기화 지점을 일원화합니다. - 팁: 경량 수동 DI 권장(프레임워크 미사용). 씬 부팅 지점에 구성 루트를 두고 팩토리/인터페이스를 통해 의존성을 명시적으로 연결합니다. - Event Aggregator/Mediator - 언제: 윈도우/패널/리스트 간 통신을 느슨하게 연결하고 싶을 때. - 팁: 강타입 메시지 구조체를 사용하고 구독 해제를 일원화합니다. 남용 시 디버깅이 어려울 수 있습니다. - Command (+ Undo/Memento) - 언제: UI 액션(삭제/이동/이름 변경 등)에 되돌리기가 필요할 때. - 팁: `ExecuteAsync(CancellationToken)`/`UndoAsync(...)`는 `UniTask`를 반환하고, 스택으로 관리합니다. - Strategy - 언제: 정렬/필터/드롭 배치 규칙을 교체 가능하게 설계할 때. - 팁: `ISortStrategy`/`IFilterStrategy` 등 인터페이스를 정의하고 Update 중 할당을 피합니다. - State/Finite State Machine - 언제: 편집/선택/드래그 등 모드 전환이 분명할 때. - 팁: `EnterAsync/ExitAsync`는 `UniTask`를 사용하고 `CancellationToken`을 전파합니다. - Factory/Abstract Factory - 언제: 프리팹/뷰 생성 경로와 정책을 캡슐화할 때. - 팁: `PrefabRef`(+Addressables)를 `IViewFactory`로 감싸고 토큰을 전달합니다. - Object Pool - 언제: 다량의 리스트 아이템/툴팁/이펙트를 재사용할 때. - 팁: `IPoolable.OnRent/OnReturn` 훅을 제공하고 `DOTween.Kill()` 등 수명 종료 처리를 확실히 합니다. - Repository + Unit of Work - 언제: 데이터 소스(파일/원격/메모리)를 교체 가능하게 할 때. - 팁: VM은 `ITodoRepository`에 의존하고 배치 저장은 UoW로 묶습니다. - Facade/Adapter - 언제: 서드파티/플랫폼 API를 감싸서 단순화할 때. - 팁: 테스트 더블을 쉽게 주입할 수 있습니다. - Composite - 언제: 트리 구조를 모델링할 때. - 팁: 노드 공통 인터페이스와 자식 컬렉션만 노출하고 뷰는 Composite만 알도록 합니다. - Visitor - 언제: 트리 순회 작업(검색/선택 동기화/통계)을 추가할 때. - 팁: 순회 로직을 방문자로 분리해 재사용성을 높입니다. - Template Method - 언제: 리스트 아이템 바인딩/갱신 단계를 표준화할 때. - 팁: 공통 베이스 `ListItemView`에 훅 메서드를 제공합니다. - Builder - 언제: 복잡한 UI 구성이나 `DOTween` 시퀀스 생성을 읽기 쉽게 만들 때. - 팁: 체이닝으로 가독성을 높이고 재사용 가능한 빌더를 제공합니다. - Flyweight/Prototype (ScriptableObject) - 언제: 공유 불변 데이터/기본 설정을 여러 객체에서 공용으로 사용할 때. - 팁: ScriptableObject로 메모리를 공유하고 런타임 인스턴스는 얕은 복제를 사용합니다. - Null Object - 언제: 선택 없음/루트 노드 등에서 null 분기를 제거하고 싶을 때. - 팁: `INode`의 빈 구현을 제공해 분기 삭제와 안정성을 높입니다. ## 11) 비동기(UniTask) 규약 - 공개 비동기 API는 `UniTask`/`UniTask`를 반환합니다. `async void`는 지양합니다. - 모든 비동기 메서드에 `CancellationToken` 인자를 제공하고 `AttachExternalCancellation`로 연결합니다. - Fire-and-forget은 예외 로깅 후 `.Forget()`을 제한적으로 사용합니다. - 메인 스레드가 필요한 경우 `UniTask.SwitchToMainThread()`를 명시적으로 호출합니다. ## 12) Addressables/Resources 규약 - 전처리기 기호: Addressables 사용 시 `Player Settings > Scripting Define Symbols`에 `USE_ADDRESSABLES`를 설정합니다. - 로드/수명: `LoadAssetAsync`로 얻은 핸들은 참조 수명 관리 후 `Addressables.Release(handle)`로 해제합니다. 인스턴스 생성 후에는 에셋 참조를 유지할 필요가 없으면 해제합니다. - Instantiate 정책: 다중 인스턴스가 필요하면 `Addressables.InstantiateAsync` 사용을 우선 고려합니다(완료 후 `Addressables.ReleaseInstance`). - Resources 경로 규약: 경로는 `Resources/` 하위 루트 기준 상대 경로를 사용합니다. Addressables 전환 시 키/그룹/레이블 매핑 표를 준비합니다. - 전환 체크리스트: 키 네임 규칙, 그룹 빌드/로드 모드, 레이블 기반 로드, Release 누락 검토, 메모리 프로파일 확인. ## 13) UniTask 취소/예외 처리 상세 - `CancellationTokenSource` 소유권은 생성한 컴포넌트가 갖습니다. `MonoBehaviour`는 `OnDestroy`에서 `Cancel/Dispose`를 호출합니다. - 토큰 결합: `CancellationTokenSource.CreateLinkedTokenSource(parent, this._cts.Token)`를 사용해 상위/로컬 토큰을 결합합니다. - 타임아웃: `using var timeout = new CancellationTokenSource(); timeout.CancelAfter(TimeSpan.FromSeconds(5));` - 예외 정책: 공개 API는 예외를 전파하고, fire-and-forget은 로깅 후 `.Forget()`을 사용합니다. ## 14) 스레딩 모델 - CPU 바운드 작업은 `UniTask.Run`으로 워커 스레드에서 처리하고, Unity API 접근 전 `UniTask.SwitchToMainThread`를 호출합니다. - IO 바운드는 가능한 비동기 API를 우선 사용합니다(파일/네트워크/주소가능). ## 15) 이벤트/구독 수명 주기 - 모든 구독은 등록 지점과 해제 지점을 명시합니다. 컬렉션/모델 이벤트는 명시적으로 해제합니다. - UI 이벤트(`Button.onClick`, `Toggle.onValueChanged`)는 `OnDestroy`에서 일괄 해제합니다. - `Bind/Unbind` 패턴을 사용해 View의 수명과 구독 수명을 일치시킵니다. ## 16) 오브젝트 풀 정책 - 대량 생성/파괴 대상은 풀을 사용합니다. 항목은 `IPoolable.OnRent/OnReturn` 훅을 구현합니다. - 트윈/코루틴 등 외부 작업은 반환 시 `DOTween.Kill()` 또는 `DOKill()`로 종료합니다. - 부모 재설정 최적화: 반환 시 풀 컨테이너로 재부모, 좌표/스케일 초기화 최소화. - 풀 용량 정책: 최대치/증가 단위 정의, 초과 시 로그 경고 옵션 제공. ## 17) 수동 DI(Composition Root) 예시 - 씬 부팅 스크립트에서 인터페이스 구현체를 생성하고 주입합니다. 프레임워크 없이 팩토리/생성자 주입을 사용합니다. ```c# public sealed class AppBootstrap : MonoBehaviour { private void Awake() { // ILogger logger = new UnityLogger(); // ITodoRepository repo = new FileTodoRepository(logger); // IViewFactory viewFactory = new AddressablesViewFactory(logger); // 1. ViewModel 생성 var vm = new Architecture.ViewModel.TodoListViewModel(); // 2. View 인스턴스 찾기 var window = FindObjectOfType(); // 3. 의존성 주입 if (window != null) { // window.Init(vm, viewFactory, logger); // 더 많은 의존성이 있다면 Init 메서드를 확장 window.Init(vm); } } } ``` - 네이밍 컨벤션: 인터페이스 `I*`, 팩토리 `*Factory`, 리포지토리 `*Repository`. ## 18) 테스트 가이드 - ViewModel 단위 테스트(NUnit) 권장: MonoBehaviour에 의존하지 않게 유지합니다. - 에디트 모드/플레이 모드 테스트를 분리하고 CI에서 두 모드를 실행합니다. - 기본 템플릿(요약): 생성/추가/삭제/이벤트 발행 검증, 필터 로직 검증. ## 19) 대규모 리스트 가상화 - `ScrollRect` + Recycler 패턴을 권장합니다. - 핵심 인터페이스 예시: `IItemProvider`(데이터 크기/아이템 생성), `IItemRenderer`(Bind/Unbind/Refresh). - 스크롤 오프셋 ↔ 인덱스 매핑 규약을 문서화하고, 배치 업데이트 시 `await UniTask.Yield()`로 프리즈를 방지합니다. ## 20) UnityEngine.Object와 Nullable 주의 - Unity는 `== null` 연산자를 오버로드하므로 순수 참조 null 확인 시 `ReferenceEquals(obj, null)` 사용을 고려합니다. - `UnityEngine.Object` 파괴된 인스턴스는 `null`처럼 동작할 수 있으므로 사용 전 유효성 검사를 수행합니다. - 직렬화 필드에 `?`를 표기하고, 런타임 Null/파괴 상태를 방어적으로 처리합니다. --- 본 문서는 템플릿으로 재사용 가능하며, 신규 컴포넌트(클래스/메서드/속성)마다 동일한 주석·패턴을 유지하면 초보자도 구조와 의도를 빠르게 파악할 수 있습니다.