Files
XRLib/CLAUDE.md
2026-02-24 20:01:56 +09:00

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]를 사용합니다.

필수 규칙

  1. 클래스에 [UxmlElement] 어트리뷰트 추가
  2. 클래스를 partial로 선언 (소스 생성기 요구사항)
  3. 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> 대칭 확인
  • CancellationTokenSourceOnDestroy에서 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.RunUniTask.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 이미지와 일치하는 스타일 적용

스타일 우선순위 제어 방법

  1. 더 높은 특수성(Specificity) 사용

    • 클래스 이름(. 사용)보다 ID(# 사용)가 우선순위가 높음
    • ID보다 UXML에 직접 작성된 인라인 스타일이 우선순위가 높음
    • 여러 클래스를 조합하여 특수성을 높이는 방법도 효과적
    • 예시: H3.title-text와 같이 선택자를 더 구체적으로 만듦
  2. 인라인 스타일 활용

    • C# 스크립트나 UXML 파일 내에 직접 인라인 스타일을 적용하면 USS 파일의 어떤 스타일보다도 우선 적용됨
  3. 스타일 시트 순서

    • 동일한 특수성을 가진 스타일의 경우, 나중에 로드된 스타일 시트의 규칙이 우선함

8) 아이콘 사용 가이드 (Icons)

아이콘 사용 우선순위

UI 아이콘 적용 시 다음 순서를 반드시 준수해야 합니다.

  1. 1순위 (Material Icons): UTKMaterialIcons 클래스 확인

    • 폰트 기반 아이콘(Unicode)을 우선 사용합니다.
    • 예: UTKButton.SetMaterialIcon(UTKMaterialIcons.Home)
  2. 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 경고 → 빌드 실패 처리
  • 에디트 모드/플레이 모드 테스트 분리 실행