633 lines
27 KiB
Markdown
633 lines
27 KiB
Markdown
|
|
# 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
|
||
|
|
{
|
||
|
|
/// <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(초보자용 주석 포함, 단순 로직은 간단히)
|
||
|
|
```c#
|
||
|
|
#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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
```c#
|
||
|
|
#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 바인딩 예, 간단·상세 주석 구분)
|
||
|
|
```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
|
||
|
|
{
|
||
|
|
/// <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/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<T>`를 반환합니다. `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<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/파괴 상태를 방어적으로 처리합니다.
|
||
|
|
|
||
|
|
---
|
||
|
|
본 문서는 템플릿으로 재사용 가능하며, 신규 컴포넌트(클래스/메서드/속성)마다 동일한 주석·패턴을 유지하면 초보자도 구조와 의도를 빠르게 파악할 수 있습니다.
|