Files
XRLib/instruction.md
2025-11-03 18:27:04 +09:00

27 KiB

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, 예제 포함)

#nullable enable
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Architecture.Serialization
{
    /// <summary>
    /// 프리팹 참조를 직렬화 친화적인 문자열 경로로 보관합니다.
    /// 
    /// 사용 예:
    /// <example>
    /// <![CDATA[
    /// [SerializeField] private PrefabRef spinnerPrefab;
    /// private async UniTask<GameObject?> CreateSpinnerAsync(CancellationToken ct)
    /// {
    ///     var prefab = await spinnerPrefab.LoadAsync(ct);
    ///     return prefab != null ? GameObject.Instantiate(prefab) : null;
    /// }
    /// ]]>
    /// </example>
    /// </summary>
    [Serializable]
    public struct PrefabRef
    {
        [SerializeField] private string _path; // "UI/Spinner" (Resources) 또는 Addressables 키

        /// <summary>프리팹 경로(읽기 전용).</summary>
        public string Path => _path ?? string.Empty;

        /// <summary>비어있는지 여부.</summary>
        public bool IsEmpty => string.IsNullOrWhiteSpace(_path);

        /// <summary>
        /// 프리팹을 동기 로드(Resources). Addressables는 LoadAsync 사용 권장.
        /// </summary>
        public GameObject? Load()
        {
#if USE_ADDRESSABLES
            Debug.LogWarning("Addressables 사용 시 LoadAsync를 사용하세요.");
            return null;
#else
            return string.IsNullOrEmpty(_path) ? null : Resources.Load<GameObject>(_path);
#endif
        }

        /// <summary>
        /// 프리팹을 비동기 로드합니다.
        /// </summary>
        /// <param name="ct">취소 토큰.</param>
        public async UniTask<GameObject?> LoadAsync(CancellationToken ct = default)
        {
#if USE_ADDRESSABLES
            if (string.IsNullOrEmpty(_path)) return null;
            var handle = UnityEngine.AddressableAssets.Addressables.LoadAssetAsync<GameObject>(_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<GameObject>(_path);
            await request.AsUniTask(cancellationToken: ct);
            return request.asset as GameObject;
#endif
        }

        /// <summary>경로를 명시적으로 설정합니다.</summary>
        public PrefabRef(string path) => _path = path;
    }
}

7.2 Model + ViewModel(초보자용 주석 포함, 단순 로직은 간단히)

#nullable enable
using System;
using Architecture.Serialization;
using UnityEngine;

namespace Architecture.Model
{
    /// <summary>
    /// 할 일 아이템 모델.
    /// 
    /// 책임:
    /// - 데이터 보유(Name/완료 여부/아이콘 프리팹)
    /// - 변경 통지 이벤트 제공
    /// 
    /// 사용 예:
    /// <example>
    /// <![CDATA[
    /// var item = new TodoItem("텍스처 임포트 최적화") { IsCompleted = false };
    /// item.OnChanged += i => Debug.Log($"Changed: {i.Name}");
    /// ]]>
    /// </example>
    /// </summary>
    [Serializable]
    public sealed class TodoItem
    {
        /// <summary>표시 이름.</summary>
        public string Name { get; private set; }

        /// <summary>완료 여부.</summary>
        public bool IsCompleted { get; private set; }

        /// <summary>아이콘 프리팹 경로.</summary>
        public PrefabRef IconPrefab { get; private set; }

        /// <summary>모델 변경 이벤트.</summary>
        public event Action<TodoItem>? OnChanged;

        public TodoItem(string name, bool isCompleted = false, PrefabRef iconPrefab = default)
        {
            Name = name;
            IsCompleted = isCompleted;
            IconPrefab = iconPrefab;
        }

        /// <summary>
        /// 이름을 변경합니다.
        /// </summary>
        /// <param name="name">새 이름.</param>
        public void Rename(string name)
        {
            if (Name == name) return; // 단순 가드
            Name = name;
            OnChanged?.Invoke(this);
        }

        /// <summary>완료/미완료 토글.</summary>
        public void ToggleCompleted()
        {
            IsCompleted = !IsCompleted;
            OnChanged?.Invoke(this);
        }
    }
}
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using Architecture.Model;

namespace Architecture.ViewModel
{
    /// <summary>
    /// Todo 목록의 프레젠테이션 상태를 관리하는 ViewModel.
    /// 
    /// 책임:
    /// - 모델 컬렉션 보유/필터/정렬
    /// - View에 필요한 이벤트(추가/제거/갱신) 제공
    /// - MonoBehaviour에 의존하지 않음(테스트 용이)
    /// 
    /// 사용 예:
    /// <example>
    /// <![CDATA[
    /// var vm = new TodoListViewModel();
    /// vm.OnItemAdded += item => Debug.Log(item.Name);
    /// vm.Add(new TodoItem("씬 로딩 최적화"));
    /// ]]>
    /// </example>
    /// </summary>
    public sealed class TodoListViewModel
    {
        private readonly List<TodoItem> _items = new();

        /// <summary>현재 아이템 스냅샷.</summary>
        public IReadOnlyList<TodoItem> Items => _items;

        public event Action<TodoItem>? OnItemAdded;
        public event Action<TodoItem>? OnItemRemoved;
        public event Action<TodoItem>? OnItemChanged;

        /// <summary>
        /// 아이템을 추가합니다.
        /// </summary>
        /// <param name="item">추가할 모델.</param>
        public void Add(TodoItem item)
        {
            _items.Add(item);
            item.OnChanged += HandleItemChanged;
            OnItemAdded?.Invoke(item);
        }

        /// <summary>
        /// 아이템을 제거합니다.
        /// </summary>
        /// <param name="item">제거할 모델.</param>
        public void Remove(TodoItem item)
        {
            if (_items.Remove(item))
            {
                item.OnChanged -= HandleItemChanged;
                OnItemRemoved?.Invoke(item);
            }
        }

        /// <summary>
        /// 간단한 텍스트 필터를 적용한 결과를 반환합니다.
        /// </summary>
        /// <param name="keyword">포함할 텍스트(대소문자 무시).</param>
        public IEnumerable<TodoItem> 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 바인딩 예, 간단·상세 주석 구분)

#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
{
    /// <summary>
    /// TodoList의 View.
    /// 
    /// 책임:
    /// - ViewModel 이벤트를 구독해 UI 생성/갱신
    /// - 사용자 입력을 ViewModel에 전달
    /// - PrefabRef를 사용해 아이템 프리팹을 런타임 로드/생성
    /// 
    /// 개선 사항:
    /// - Model-View 매핑을 위해 Dictionary를 사용, O(1) 검색으로 성능 향상.
    /// - 명시적 핸들러 메서드로 이벤트 구독/해제 대칭성 확보.
    /// - async void 대신 UniTask 반환 및 .Forget() 패턴으로 예외 처리 강화.
    /// 
    /// 주의:
    /// - 모든 구독/비동기 작업은 OnDestroy에서 해제/취소합니다.
    /// </summary>
    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<TodoItem, TodoRowView> _rowViews = new();
        private TodoListViewModel _vm = null!;

        private void Awake()
        {
            // Composition Root에서 주입받는 것을 권장. 여기서는 예시로 직접 생성.
            Init(new TodoListViewModel());
        }

        /// <summary>
        /// ViewModel을 주입하고 UI를 초기화합니다.
        /// </summary>
        public void Init(TodoListViewModel viewModel)
        {
            _vm = viewModel;
            
            // 이벤트 구독 (명시적 핸들러 메서드 사용)
            _vm.OnItemAdded += OnItemAddedHandler;
            _vm.OnItemRemoved += HandleItemRemoved;
            _vm.OnItemChanged += HandleItemChanged;

            // 입력 이벤트            
            _addButton.onClick.AddListener(OnAddButtonClicked);
            _filterInput.onValueChanged.AddListener(OnFilterInputChanged);
        }

        /// <summary>ViewModel의 OnItemAdded 이벤트 핸들러.</summary>
        private void OnItemAddedHandler(TodoItem item)
        {
            HandleItemAdded(item).Forget(ex => Debug.LogError($"[TodoListView] Failed to add item: {ex}"));
        }

        /// <summary>Add 버튼 클릭 핸들러.</summary>
        private void OnAddButtonClicked()
        {
            _vm.Add(new TodoItem("New Task"));
        }

        /// <summary>필터 입력 변경 핸들러.</summary>
        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<TodoRowView>();
            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);
        }
    }

    /// <summary>
    /// 개별 Row(View) 컴포넌트.
    /// - 간단 로직: 바인딩/리프레시만 담당
    /// </summary>
    public sealed class TodoRowView : MonoBehaviour
    {
        [SerializeField] private TMP_Text _nameText = null!;
        [SerializeField] private Toggle _completedToggle = null!;

        /// <summary>현재 바인딩된 모델(읽기 전용).</summary>
        public TodoItem? BoundItem { get; private set; }

        /// <summary>
        /// Row를 모델에 바인딩합니다.
        /// </summary>
        /// <param name="item">바인딩할 모델.</param>
        public void Bind(TodoItem item)
        {
            BoundItem = item;
            _completedToggle.onValueChanged.AddListener(OnToggleChanged);
            Refresh();
        }

        /// <summary>UI를 현재 모델 값으로 갱신합니다.</summary>
        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/ExitAsyncUniTask를 사용하고 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<T>를 반환합니다. async void는 지양합니다.
  • 모든 비동기 메서드에 CancellationToken 인자를 제공하고 AttachExternalCancellation로 연결합니다.
  • Fire-and-forget은 예외 로깅 후 .Forget()을 제한적으로 사용합니다.
  • 메인 스레드가 필요한 경우 UniTask.SwitchToMainThread()를 명시적으로 호출합니다.

12) Addressables/Resources 규약

  • 전처리기 기호: Addressables 사용 시 Player Settings > Scripting Define SymbolsUSE_ADDRESSABLES를 설정합니다.
  • 로드/수명: LoadAssetAsync로 얻은 핸들은 참조 수명 관리 후 Addressables.Release(handle)로 해제합니다. 인스턴스 생성 후에는 에셋 참조를 유지할 필요가 없으면 해제합니다.
  • Instantiate 정책: 다중 인스턴스가 필요하면 Addressables.InstantiateAsync 사용을 우선 고려합니다(완료 후 Addressables.ReleaseInstance).
  • Resources 경로 규약: 경로는 Resources/ 하위 루트 기준 상대 경로를 사용합니다. Addressables 전환 시 키/그룹/레이블 매핑 표를 준비합니다.
  • 전환 체크리스트: 키 네임 규칙, 그룹 빌드/로드 모드, 레이블 기반 로드, Release 누락 검토, 메모리 프로파일 확인.

13) UniTask 취소/예외 처리 상세

  • CancellationTokenSource 소유권은 생성한 컴포넌트가 갖습니다. MonoBehaviourOnDestroy에서 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) 예시

  • 씬 부팅 스크립트에서 인터페이스 구현체를 생성하고 주입합니다. 프레임워크 없이 팩토리/생성자 주입을 사용합니다.
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<Architecture.View.TodoListView>();
        
        // 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/파괴 상태를 방어적으로 처리합니다.

본 문서는 템플릿으로 재사용 가능하며, 신규 컴포넌트(클래스/메서드/속성)마다 동일한 주석·패턴을 유지하면 초보자도 구조와 의도를 빠르게 파악할 수 있습니다.