20 KiB
Unity 개발 지침 (UI Toolkit · MVVM · 성능 · Nullable)
본 지침은 Unity UI Toolkit 기반 프로젝트의 아키텍처, 성능, 코드 품질을 일관되게 유지하기 위한 규칙입니다.
프로젝트 스타일: #nullable enable, UniTask, DOTween, 한국어 주석
0) 작업 진행 규칙
⚠️ 최우선 규칙: 임의로 진행하지 않고, 반드시 사용자에게 확인 후 진행합니다.
- 코드 수정, 파일 생성/삭제, 리팩토링 등 모든 변경 작업은 사전에 계획을 설명하고 승인을 받은 후 진행합니다.
- 요구사항이 모호하거나 여러 접근 방식이 가능한 경우, 추측하지 말고 질문합니다.
- 버그 수정이라도 원인 분석 결과를 먼저 공유하고, 수정 방향에 대해 합의 후 코드를 변경합니다.
- 단순한 오타 수정, 한 줄 변경 등 명백하고 사소한 작업만 즉시 진행할 수 있습니다.
1) 핵심 원칙
UI 프레임워크
- UI Toolkit(UIElements) 필수 사용. uGUI(Canvas 기반)는 레거시로 취급합니다.
- UXML(구조)과 USS(스타일)를 분리하고, C# 코드에서 인라인 스타일 지정을 지양합니다.
이벤트 콜백 등록 규칙
⚠️ 중요: RegisterValueChangedCallback 대신 RegisterCallback<ChangeEvent<T>>를 사용합니다.
RegisterValueChangedCallback은 확장 메서드로 UnregisterCallback과 대칭이 맞지 않아 이벤트 해제가 어렵습니다.
// ❌ 잘못된 예: 해제가 어려운 방식
field.RegisterValueChangedCallback(OnValueChanged);
// field.UnregisterValueChangedCallback(OnValueChanged); // 이런 메서드 없음!
// ✅ 올바른 예: 대칭적인 등록/해제
field.RegisterCallback<ChangeEvent<float>>(OnValueChanged);
field.UnregisterCallback<ChangeEvent<float>>(OnValueChanged);
private void OnValueChanged(ChangeEvent<float> evt)
{
// evt.newValue, evt.previousValue 사용
}
| 메서드 | 권장 | 이유 |
|---|---|---|
RegisterValueChangedCallback |
❌ | 해제용 메서드 없음 |
RegisterCallback<ChangeEvent<T>> |
✅ | UnregisterCallback 대칭 |
커스텀 VisualElement (Unity 6)
Unity 6에서는 레거시 UxmlFactory/UxmlTraits 방식을 사용하지 않고, 소스 생성기 기반의 [UxmlElement]와 [UxmlAttribute]를 사용합니다.
필수 규칙
- 클래스에
[UxmlElement]어트리뷰트 추가 - 클래스를
partial로 선언 (소스 생성기 요구사항) - UXML 속성은
[UxmlAttribute]로 케밥 케이스 소문자 명시
// ✅ 올바른 예: Unity 6 방식
[UxmlElement]
public partial class UTKCodeBlock : VisualElement
{
[UxmlAttribute("title")]
public string Title { get; set; }
[UxmlAttribute("is-enabled")]
public bool IsEnabled { get; set; }
[UxmlAttribute("border-width")]
public int BorderWidth { get; set; }
}
// ❌ 잘못된 예: 레거시 방식 (사용 금지)
public class UTKCodeBlock : VisualElement
{
public new class UxmlFactory : UxmlFactory<UTKCodeBlock, UxmlTraits> { }
public new class UxmlTraits : VisualElement.UxmlTraits { ... }
}
// ❌ 잘못된 예: 케밥 케이스 미사용
[UxmlAttribute] // Unity 6에서 UXML 속성 매핑 실패
public string Text { get; set; }
[UxmlAttribute("Text")] // 대문자는 UXML과 불일치
public string Text { get; set; }
UXML 사용 예:
<utk:UTKButton text="확인" variant="Primary" is-enabled="true" />
아키텍처 (MVVM/MVC)
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ View │◄────│ ViewModel │◄────│ Model │
│ (UXML/USS) │ │ (Presenter) │ │ (Service) │
└─────────────┘ └──────────────┘ └─────────────┘
│ │
└── 이벤트 전달 ──────┘
| 레이어 | 책임 | 금지 사항 |
|---|---|---|
| View | 표시/레이아웃, 이벤트 라우팅 | 비즈니스 로직, 상태 보유 |
| ViewModel/Presenter | 상태 관리, 데이터 변환, 바인딩 속성 | Unity API 직접 호출 (테스트 용이) |
| Model/Service | 도메인 로직, 데이터 접근 | UI 참조 |
- MVVM: UI 상태/양방향 동기화가 많을 때
- MVC: 입력 → 도메인 액션 → UI 반영 흐름이 단순할 때
필수 규약
- 파일 선두에
#nullable enable, 모든 참조형에?명시 - 비동기는
UniTask+CancellationToken사용 (Task/코루틴 지양) - 느슨한 결합: 인터페이스/이벤트로 연결
2) 폴더 구조
Assets/
├── Scripts/
│ ├── {프로젝트명}/ # 프로젝트별 코드
│ │ ├── Config/ # 설정, 상수
│ │ ├── Manager/ # 매니저 클래스
│ │ ├── Command/ # Command 패턴 (Undo/Redo)
│ │ └── ...
│ └── UVC/ # 공통 라이브러리 ⭐
│ ├── Core/ # DI, Injector, Singleton
│ ├── Data/ # DataMapper, MQTT/HTTP 통신
│ ├── Pool/ # 오브젝트 풀링
│ ├── UI/ # uGUI 컴포넌트 (Modal, Tab)
│ └── UIToolkit/ # UI Toolkit 컴포넌트 ⭐
├── Resources/
│ ├── {프로젝트명}/ # 프로젝트별 리소스
│ │ ├── Materials/
│ │ ├── Models/
│ │ └── Prefabs/
│ └── UIToolkit/ # 공통 UI 리소스 ⭐
│ ├── Common/ # 공통 스타일 (USS)
│ ├── List/ # 리스트 컴포넌트 (UXML)
│ ├── Modal/ # 모달 컴포넌트 (UXML)
│ ├── Property/ # 속성 편집기 (UXML)
│ └── Window/ # 윈도우 컴포넌트 (UXML)
├── Plugins/ # 서드파티 (Best.HTTP, DOTween 등)
├── Sample/ # 샘플 씬
└── Scenes/ # 앱 씬
현재 프로젝트:
| 폴더 | 설명 |
|---|---|
Scripts/Factory |
스마트 팩토리 3D 시각화 (MQTT 실시간) |
Scripts/Simulator |
Factory 시뮬레이션 버전 |
Scripts/Studio |
3D 씬 에디터 (Undo/Redo, Gizmo) |
Scripts/SHI |
조선소 공정 모달 (TreeList, Chart) |
Scripts/NHN |
무한 스크롤 컴포넌트 (uGUI 레거시) |
참고: 각 폴더에
CLAUDE.md파일이 있어 모듈별 상세 가이드를 제공합니다.
3) 성능 최적화
VisualElement 쿼리
// ❌ 나쁜 예: 매 프레임 쿼리
void Update() {
rootVisualElement.Q<Label>("title").text = _title;
}
// ✅ 좋은 예: 캐싱
private Label? _titleLabel;
void OnEnable() {
_titleLabel = rootVisualElement.Q<Label>("title");
}
체크리스트
Q<T>(),Query<T>()결과는 필드에 캐싱- 변경된 데이터만 업데이트 (전체 리빌드 지양)
- 대량 목록은
ListView/TreeView가상화 활용 - USS 선택자 복잡도 최소화 (
>중첩, 와일드카드 지양) - 동적 생성/파괴 대신 풀링 또는
display: none토글 - 지연/반복 작업은
schedule.Execute()사용 - Update에서 GC 할당 금지 (LINQ/문자열 연결/클로저 지양)
- DOTween: 핸들 보관, 수명 종료 시
Kill()
4) 메모리 관리
이벤트 구독/해제
private EventCallback<ClickEvent>? _onClick;
void OnEnable() {
_onClick = OnButtonClick;
_button?.RegisterCallback(_onClick);
}
void OnDisable() {
_button?.UnregisterCallback(_onClick);
}
체크리스트
RegisterCallback<T>↔UnregisterCallback<T>대칭 확인CancellationTokenSource는OnDestroy에서Cancel/Dispose- VisualTreeAsset/USS 동일 리소스 반복 로드 방지 (캐싱)
- 클로저/람다 캡처로 인한 누수 점검
- 오브젝트 풀:
IPoolable.OnRent/OnReturn훅, 반환 시DOTween.Kill()
5) 비동기 (UniTask)
// 공개 API는 UniTask 반환, CancellationToken 필수
public async UniTask<Data?> LoadDataAsync(CancellationToken ct)
{
var result = await _repository.FetchAsync().AttachExternalCancellation(ct);
return result;
}
// Fire-and-forget은 예외 로깅 후 .Forget()
LoadDataAsync(_cts.Token).Forget(ex => Debug.LogError(ex));
규칙
async void지양,UniTask/UniTask<T>반환- 토큰 결합:
CreateLinkedTokenSource(parent, local) - 타임아웃:
cts.CancelAfter(TimeSpan.FromSeconds(5)) - CPU 바운드:
UniTask.Run→UniTask.SwitchToMainThread
6) 리소스 로드 (Addressables/Resources)
[Serializable]
public struct AssetRef<T> where T : UnityEngine.Object
{
[SerializeField] private string _path;
public async UniTask<T?> LoadAsync(CancellationToken ct = default)
{
#if USE_ADDRESSABLES
var handle = Addressables.LoadAssetAsync<T>(_path);
await handle.Task.AsUniTask().AttachExternalCancellation(ct);
return handle.Status == AsyncOperationStatus.Succeeded ? handle.Result : null;
#else
var request = Resources.LoadAsync<T>(_path);
await request.AsUniTask(cancellationToken: ct);
return request.asset as T;
#endif
}
}
- Addressables 사용 시
USE_ADDRESSABLES전처리기 정의 LoadAssetAsync핸들은 수명 관리 후Release- GameObject 직접 참조 대신 경로 문자열 직렬화
UXML/USS 파일 네이밍 규칙
⚠️ 중요: UXML과 USS 파일명은 반드시 다르게 지정해야 합니다.
Resources.Load<T>(path)는 확장자 없이 경로를 받기 때문에, 동일한 경로에 UXML과 USS가 모두 존재하면 로드 충돌이 발생할 수 있습니다.
// ❌ 잘못된 예: 동일한 경로명 사용
private const string UXML_PATH = "UIToolkit/Window/UTKAccordionListWindow";
private const string USS_PATH = "UIToolkit/Window/UTKAccordionListWindow";
// Resources.Load<VisualTreeAsset>(UXML_PATH); // .uxml 로드
// Resources.Load<StyleSheet>(USS_PATH); // .uss 로드 실패 가능
// ✅ 올바른 예: USS 파일명에 접미사 추가
private const string UXML_PATH = "UIToolkit/Window/UTKAccordionListWindow";
private const string USS_PATH = "UIToolkit/Window/UTKAccordionListWindowUss";
// 파일 구조:
// - UTKAccordionListWindow.uxml
// - UTKAccordionListWindowUss.uss
네이밍 규칙:
| 파일 유형 | 네이밍 패턴 | 예시 |
|---|---|---|
| UXML | {ComponentName}.uxml |
UTKAccordionListWindow.uxml |
| USS | {ComponentName}Uss.uss |
UTKAccordionListWindowUss.uss |
7) USS 스타일 가이드
디자인 참조
UI Toolkit 개발 시 다음 위치의 스타일 리소스를 참조하세요:
우선 참조: Assets/Resources/UIToolkit/Style/
- 프로젝트에서 사용 중인 실제 USS 스타일 파일들이 위치합니다.
- 컴포넌트별로 정리된 스타일을 먼저 확인하세요.
보조 참조: StyleGuide/ 폴더
Assets/Resources/UIToolkit/Style/에 구현되지 않은 스타일만 참조합니다.- 새 컴포넌트 개발 시 디자인 가이드로 활용합니다.
| 파일 | 설명 |
|---|---|
style_guide_Colors.png |
색상 팔레트 |
style_guide_Typography.png |
타이포그래피 (폰트, 크기) |
style_guide_Buttons.png |
버튼 스타일 |
style_guide_Text Field.png |
텍스트 필드 |
style_guide_Dropdowns.png |
드롭다운 |
style_guide_Checkbox.png |
체크박스 |
style_guide_List.png |
리스트 |
style_guide_Tabs.png |
탭 |
style_guide_Panel.png |
패널 |
style_guide_Sidebar.png |
사이드바 |
style_guide_Menu.png |
메뉴 |
style_guide_Dialogs.png |
다이얼로그/모달 |
style_guide_Notifications.png |
알림 |
style_guide_Status Bar.png |
상태 바 |
style_guide_Extensions.png |
확장 컴포넌트 |
style_guide_Templates.png |
템플릿 |
BEM 네이밍
.panel { }
.panel__header { }
.panel__header--highlighted { }
.panel__content { }
규칙
- 반복 값은 USS 변수 사용:
--color-primary,--spacing-md - 라이트/다크 테마는 별도 USS로 분리
- 새 UI 컴포넌트 개발 시 StyleGuide 이미지와 일치하는 스타일 적용
스타일 우선순위 제어 방법
-
더 높은 특수성(Specificity) 사용
- 클래스 이름(
.사용)보다 ID(#사용)가 우선순위가 높음 - ID보다 UXML에 직접 작성된 인라인 스타일이 우선순위가 높음
- 여러 클래스를 조합하여 특수성을 높이는 방법도 효과적
- 예시:
H3.title-text와 같이 선택자를 더 구체적으로 만듦
- 클래스 이름(
-
인라인 스타일 활용
- C# 스크립트나 UXML 파일 내에 직접 인라인 스타일을 적용하면 USS 파일의 어떤 스타일보다도 우선 적용됨
-
스타일 시트 순서
- 동일한 특수성을 가진 스타일의 경우, 나중에 로드된 스타일 시트의 규칙이 우선함
8) 아이콘 사용 가이드 (Icons)
아이콘 사용 우선순위
UI 아이콘 적용 시 다음 순서를 반드시 준수해야 합니다.
-
1순위 (Material Icons):
UTKMaterialIcons클래스 확인- 폰트 기반 아이콘(Unicode)을 우선 사용합니다.
- 예:
UTKButton.SetMaterialIcon(UTKMaterialIcons.Home)
-
2순위 (Image Icons):
UTKImageIcons클래스 사용- 필요한 아이콘이
UTKMaterialIcons에 없는 경우에만UTKImageIcons를 사용합니다. - 예:
UTKButton.SetImageIcon(UTKImageIcons.CustomIcon)
- 필요한 아이콘이
권장: 일관된 UI 스타일과 메모리 효율을 위해 가능한 Material Icons 사용을 권장합니다.
9) 주석 원칙 (C# XML)
/// <summary>
/// 사용자 데이터를 비동기로 로드합니다.
/// </summary>
/// <param name="userId">사용자 ID.</param>
/// <param name="ct">취소 토큰.</param>
/// <returns>사용자 데이터 또는 null.</returns>
public async UniTask<UserData?> LoadUserAsync(string userId, CancellationToken ct)
- 클래스: 역할/책임/사용 예
- 메서드: 요약 + 파라미터/반환 (복잡 로직만 상세)
- 속성: 한 줄 요약
10) 디자인 패턴 요약
| 패턴 | 사용 시점 |
|---|---|
| DI/Composition Root | 서비스/ViewModel 주입 일원화 |
| Event Aggregator | 컴포넌트 간 느슨한 통신 |
| Command + Undo | UI 액션에 되돌리기 필요 시 |
| Strategy | 정렬/필터 규칙 교체 |
| State/FSM | 모드 전환 (편집/선택/드래그) |
| Factory | 뷰/프리팹 생성 캡슐화 |
| Object Pool | 대량 아이템 재사용 |
| Repository | 데이터 소스 추상화 |
11) UTK 컴포넌트 기본 패턴
UTK 컴포넌트는 다음 패턴을 따릅니다. UTKHelpBox를 예시로 설명합니다.
#nullable enable
using System;
using UnityEngine;
using UnityEngine.UIElements;
namespace UVC.UIToolkit
{
/// <summary>
/// 컴포넌트 설명.
/// </summary>
[UxmlElement]
public partial class UTKExample : VisualElement, IDisposable
{
#region Constants
private const string UXML_PATH = "UIToolkit/Common/UTKExample";
private const string USS_PATH = "UIToolkit/Common/UTKExampleUss";
#endregion
#region Fields
private bool _disposed;
private Label? _label;
private string _text = "";
#endregion
#region Properties
/// <summary>텍스트</summary>
[UxmlAttribute("text")]
public string Text
{
get => _text;
set
{
_text = value;
if (_label != null)
_label.text = value;
}
}
#endregion
#region Constructor
public UTKExample() : base()
{
// 1. 테마 적용
UTKThemeManager.Instance.ApplyThemeToElement(this);
// 2. USS 로드
var uss = Resources.Load<StyleSheet>(USS_PATH);
if (uss != null)
{
styleSheets.Add(uss);
}
// 3. UI 생성 (UXML 또는 Fallback)
CreateUI();
// 4. 테마 변경 구독
SubscribeToThemeChanges();
}
public UTKExample(string text) : this()
{
Text = text;
}
#endregion
#region Setup
private void CreateUI()
{
AddToClassList("utk-example");
var asset = Resources.Load<VisualTreeAsset>(UXML_PATH);
if (asset != null)
{
CreateUIFromUxml(asset);
}
else
{
CreateUIFallback();
}
}
private void CreateUIFromUxml(VisualTreeAsset asset)
{
var root = asset.Instantiate();
// UXML 요소 참조 가져오기
_label = root.Q<Label>("label");
Add(root);
}
private void CreateUIFallback()
{
_label = new Label(_text);
_label.AddToClassList("utk-example__label");
Add(_label);
}
private void SubscribeToThemeChanges()
{
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
RegisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
}
private void OnAttachToPanelForTheme(AttachToPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
private void OnDetachFromPanelForTheme(DetachFromPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
}
private void OnThemeChanged(UTKTheme theme)
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
#endregion
#region IDisposable
public void Dispose()
{
if (_disposed) return;
_disposed = true;
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UnregisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
UnregisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
}
#endregion
}
}
주요 패턴 요약
| 항목 | 설명 |
|---|---|
[UxmlElement] |
UXML에서 사용 가능하도록 등록 |
partial class |
소스 생성기 요구사항 |
[UxmlAttribute] |
UXML 속성 매핑 (케밥 케이스) |
IDisposable |
리소스 정리 인터페이스 |
UTKThemeManager |
테마 스타일시트 적용 |
DetachFromPanelEvent |
패널에서 분리 시 이벤트 해제 |
| UXML 경로 | Resources.Load<VisualTreeAsset>() 사용 |
| USS 경로 | Resources.Load<StyleSheet>() 사용, 파일명에 Uss 접미사 |
| Fallback 패턴 | UXML 로드 실패 시 코드로 UI 생성 |
12) Unity Nullable 주의
// Unity Object는 == null 오버로드됨
if (gameObject == null) { } // 파괴된 객체도 true
// 순수 참조 null 확인 시
if (ReferenceEquals(obj, null)) { }
- 직렬화 필드에
?표기, 런타임 Null/파괴 상태 방어적 처리
13) 품질 자동화
.editorconfig 권장
dotnet_diagnostic.CS1591.severity = error # 공개 멤버 문서 주석 필수
dotnet_analyzer_diagnostic.category-Nullable.severity = error
dotnet_diagnostic.IDE0060.severity = warning # 미사용 매개변수
CI
- XML 문서 누락/nullable 경고 → 빌드 실패 처리
- 에디트 모드/플레이 모드 테스트 분리 실행