Unity·Unreal 실전

Unity UI Toolkit 실전 — IMGUI 없이 커스텀 EditorWindow 제작하기

kr-gamedev 2026. 6. 11. 09:02

Unity 에디터 도구를 만들 때 오랫동안 표준이었던 OnGUI 기반 IMGUI는 단점이 뚜렷하다. 매 프레임 전체 UI를 다시 그리고, 레이아웃 코드와 로직 코드가 뒤섞이며, 복잡한 UI일수록 유지보수가 기하급수적으로 어려워진다. "이 슬라이더 위에 레이블 하나 얹으려면 GUILayout 세 줄을 수정해야 한다"는 상황이 낯설지 않을 것이다.

Unity 2021.1부터 안정화된 UI Toolkit(구 UIElements)은 이 문제를 구조적으로 해결한다. HTML에 해당하는 UXML로 레이아웃을 선언하고, CSS에 해당하는 USS로 스타일을 분리하며, C# 코드는 이벤트 처리와 데이터 바인딩만 담당한다. 역할이 명확히 나뉘어 UI 디자이너와 프로그래머가 같은 화면을 동시에 작업할 수 있고, Unity 6.x 이후로는 Runtime UI도 같은 시스템으로 통합되는 방향이다.

이 글에서는 실제로 씬에서 쓸 수 있는 에디터 도구 하나를 만들어본다. 씬 안의 GameObject를 선택한 뒤 태그를 일괄 변경하는 Tag Manager 창이다. IMGUI로는 OnGUI 스파게티가 되기 쉬운 작업을 UI Toolkit으로 깔끔하게 구현한다.


1. 프로젝트 구조 설계

UI Toolkit 에디터 창은 세 파일로 나눈다.

Assets/
  Editor/
    TagManagerWindow.cs       ← C# 로직
    TagManagerWindow.uxml     ← 레이아웃 선언
    TagManagerWindow.uss      ← 스타일 시트

Unity 6.x 기준 별도 패키지 의존성은 없다. com.unity.ui는 기본 내장이다.


2. UXML 레이아웃 작성

<!-- Editor/TagManagerWindow.uxml -->
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
    <ui:VisualElement class="root">
        <ui:Label text="태그 일괄 변경기" class="title" />
        <uie:TagField label="변경할 태그" name="tag-field" />
        <ui:Button text="씬에서 선택된 오브젝트 로드" name="btn-load" />
        <ui:ListView name="object-list"
                     show-border="true"
                     show-alternating-row-backgrounds="All"
                     fixed-item-height="22" />
        <ui:Button text="일괄 적용" name="btn-apply" class="apply-btn" />
    </ui:VisualElement>
</ui:UXML>

name 속성은 C# 코드에서 Q<T>("name")으로 참조할 때 사용한다. IMGUI의 GUILayout.BeginVertical() 중첩 구조가 UXML에서는 선언형 계층으로 표현되어 훨씬 읽기 쉽다.


3. USS 스타일 적용

/* Editor/TagManagerWindow.uss */
.root {
    padding: 10px;
    flex-direction: column;
}
.title {
    font-size: 16px;
    -unity-font-style: bold;
    margin-bottom: 8px;
}
.apply-btn {
    background-color: #2d6a2d;
    color: white;
    margin-top: 8px;
    height: 32px;
}

--unity-colors-* 같은 USS 변수를 활용하면 에디터 다크·라이트 테마에 자동 대응하는 팔레트를 만들 수 있다. 이 예시는 단순화를 위해 하드코딩했지만, 팀 내 공통 도구를 만들 때는 변수 사용을 권장한다.


4. C# EditorWindow 구현

// Editor/TagManagerWindow.cs
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;

public class TagManagerWindow : EditorWindow
{
    [MenuItem("Tools/태그 매니저")]
    public static void Open() => GetWindow<TagManagerWindow>("태그 매니저");

    private TagField _tagField;
    private ListView _listView;
    private readonly List<GameObject> _targets = new();

    public void CreateGUI()
    {
        var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
            "Assets/Editor/TagManagerWindow.uxml");
        var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(
            "Assets/Editor/TagManagerWindow.uss");

        rootVisualElement.Add(uxml.Instantiate());
        rootVisualElement.styleSheets.Add(uss);

        _tagField = rootVisualElement.Q<TagField>("tag-field");
        _listView = rootVisualElement.Q<ListView>("object-list");

        _listView.makeItem    = () => new Label();
        _listView.bindItem    = (elem, i) =>
            ((Label)elem).text = _targets[i] != null ? _targets[i].name : "(삭제됨)";
        _listView.itemsSource = _targets;

        rootVisualElement.Q<Button>("btn-load").clicked  += LoadSelected;
        rootVisualElement.Q<Button>("btn-apply").clicked += ApplyTag;
    }

    private void LoadSelected()
    {
        _targets.Clear();
        _targets.AddRange(Selection.gameObjects);
        _listView.Rebuild();
    }

    private void ApplyTag()
    {
        if (_targets.Count == 0)
        {
            Debug.LogWarning("[TagManager] 오브젝트를 먼저 로드하세요.");
            return;
        }

        Undo.RecordObjects(_targets.ToArray(), "태그 일괄 변경");
        foreach (var go in _targets)
            go.tag = _tagField.value;

        EditorUtility.DisplayDialog(
            "완료",
            $"{_targets.Count}개 오브젝트의 태그를 '{_tagField.value}'으로 변경했습니다.",
            "확인");
    }
}

CreateGUI()는 IMGUI의 OnGUI()를 대체하는 진입점이다. 핵심 차이는 한 번만 호출된다는 점이다. UI 트리를 한 번 구성한 뒤 콜백만 등록하므로 매 프레임 재계산 비용이 없다. Undo.RecordObjects를 함께 사용하면 Ctrl+Z로 태그 변경을 되돌릴 수 있어 에디터 안전성도 확보된다.


5. SerializedObject 자동 바인딩

UI Toolkit의 또 다른 강점은 SerializedObject와의 직접 바인딩이다. ScriptableObject나 MonoBehaviour의 필드를 추가 코드 없이 UI와 실시간 동기화할 수 있다.

// 특정 ScriptableObject를 에디터 창에 자동 바인딩하는 예시
public void CreateGUI()
{
    var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/MyConfig.uxml");
    rootVisualElement.Add(uxml.Instantiate());

    var so = new SerializedObject(myConfigAsset);
    rootVisualElement.Bind(so); // 이 한 줄로 binding-path 속성 매칭이 자동 처리됨
}

UXML에서 <ui:FloatField binding-path="spawnInterval" />처럼 binding-path를 선언하면 C# 코드 없이도 해당 필드 값이 UI에 실시간으로 반영된다. 복잡한 ScriptableObject 기반 설정 에디터를 만들 때 특히 강력하다.


마무리

IMGUI와 UI Toolkit의 근본적인 차이는 선언형 vs 절차형이다. UI 트리를 한 번 선언하고 이벤트로 반응하는 구조는 코드 분리, 재사용, 테스트 모두에 유리하다. 기존 IMGUI 코드를 당장 마이그레이션할 필요는 없지만, 새로운 에디터 도구를 만들 때는 UI Toolkit을 기본으로 선택하는 것이 장기적으로 이득이다.

핵심 패턴을 정리하면 다음과 같다.

  • CreateGUI() 안에서 UXML 로드 및 VisualElement 트리 구성
  • Q\<T>("name") 으로 요소 참조, .clicked / .RegisterValueChangedCallback 으로 이벤트 연결
  • ListView.itemsSource 에 List 직접 할당, Rebuild() 로 갱신
  • rootVisualElement.Bind(so) 한 줄로 SerializedObject 전체 필드 자동 바인딩
  • Undo.RecordObjects 로 에디터 Undo 지원

에디터 도구의 복잡도가 올라갈수록 IMGUI 대비 UI Toolkit의 이점은 더 선명해진다.