---
title: "내가 만든 요소도 UXML 에 태그로 들어간다"
description: "같은 묶음이 화면마다 반복되면 묶습니다. uxml 템플릿으로 다시 쓰는 방법과 C# 으로 요소를 만드는 방법, UxmlElement 와 UxmlAttribute, 옛 UxmlFactory 방식과의 차이, 에디터 창까지 정리했습니다."
author: "유니얼"
pubDate: 2026-09-18
category: "game-dev/unity/ui"
tags: ["Unity", "UI", "UI Toolkit"]
canonical: https://blog.unialgames.com/posts/uitk-custom-controls/
---
[움직임은 클립이 아니라 두 값 사이에서 만들어진다](/posts/uitk-transitions-binding/)까지가 유니티가 준 것으로 조립하는 이야기였습니다. Button, Label, TextField 를 놓고 스타일을 걸었습니다.

화면이 늘어나면 같은 묶음이 반복됩니다. 아이콘과 이름과 수치가 한 줄로 붙은 것이 목록마다 나오고, 제목과 버튼 두 개가 붙은 팝업 틀이 화면마다 나옵니다. uGUI 에서는 그걸 프리팹으로 묶었습니다. 여기서는 방법이 둘입니다.

## 구조만 같으면 uxml 을 다시 씁니다

uxml 파일 하나를 다른 uxml 안에서 불러 쓸 수 있습니다. 두 줄입니다.

```xml
<ui:Template src="Portrait.uxml" name="Portrait" />
<ui:Instance template="Portrait" name="player1" />
<ui:Instance template="Portrait" name="player2" />
```

`Template` 이 파일을 가리키면서 이름을 붙이고, `Instance` 가 그 이름으로 찍어 냅니다. **프리팹과 제일 비슷한 것이 이것입니다.**

구조만 같고 값은 다르게 넣고 싶으면 [앞 편](/posts/uitk-transitions-binding/)에서 본 데이터 바인딩을 걸거나, 코드에서 찾아서 채웁니다. 원본 uxml 을 고치면 찍어 낸 자리가 전부 따라오는 것도 프리팹과 같습니다.

여기까지로 되는 경우가 생각보다 많습니다. **구조가 고정이고 동작이 붙지 않는다면 C# 을 쓸 이유가 없습니다.** 문서도 이쪽을 "a simpler solution with a fixed UI structure" 라고 적어 뒀습니다.

## 동작이 붙으면 C# 으로 요소를 만듭니다

값을 넣으면 막대가 그만큼 차야 한다든가, 누르면 속이 접혀야 한다든가 하면 그때부터는 요소를 만듭니다. 만드는 조건이 셋입니다.

- `[UxmlElement]` 를 클래스에 붙입니다
- 클래스를 `partial` 로 선언합니다
- `VisualElement` 나 그 자손을 상속합니다

```csharp
using UnityEngine.UIElements;

[UxmlElement]
public partial class StatBar : VisualElement { }
```

이 세 줄이면 uxml 에서 태그로 씁니다.

```xml
<ui:UXML xmlns:ui="UnityEngine.UIElements">
    <StatBar />
</ui:UXML>
```

`partial` 이어야 하는 이유가 있습니다. 문서 표현으로 "a corresponding `UxmlSerializedData` class is generated in the partial class" 입니다. 유니티가 그 클래스 안에 직렬화용 코드를 생성해 넣습니다. 우리가 적지 않은 절반이 컴파일 때 붙는 셈입니다.

UI Builder 의 Library 탭에도 자동으로 올라옵니다. 안 보이게 하려면 `[HideInInspector]` 를 붙입니다.

## 인스펙터에 값을 내놓으려면 UxmlAttribute

[이벤트는 루트에서 내려갔다가 다시 올라온다](/posts/uitk-events/)에서 인스펙터로 **함수** 를 걸 수 없다고 했습니다. **값은 내놓을 수 있습니다.** 필드나 프로퍼티에 `[UxmlAttribute]` 를 붙이면 됩니다.

```csharp
[UxmlElement]
public partial class StatBar : VisualElement
{
    [UxmlAttribute] public string title { get; set; }
    [UxmlAttribute] public float value { get; set; }
    [UxmlAttribute("max-value")] public float myMaxValue { get; set; }
}
```

<figure class="diagram">
<svg viewBox="0 0 680 210" role="img" aria-label="C# 프로퍼티 이름이 uxml 속성과 UI Builder 인스펙터 칸으로 바뀌어 나타나는 그림">
<text x="24" y="24" font-size="13" fill="var(--text)">이름 하나가 세 곳에 나타납니다</text>
<rect x="24" y="52" width="190" height="72" rx="8" fill="var(--bg-subtle)" stroke="var(--accent)"></rect>
<text x="40" y="74" font-size="9.5" fill="var(--text-muted)">C#</text>
<text x="40" y="94" font-size="10.5" fill="var(--accent)" font-family="var(--font-mono)">[UxmlAttribute]</text>
<text x="40" y="110" font-size="10.5" fill="var(--text)" font-family="var(--font-mono)">float myMaxValue</text>
<line x1="214" y1="88" x2="242" y2="88" stroke="var(--text-muted)" stroke-width="1"></line>
<polygon points="250,88 238,83 238,93" fill="var(--text-muted)"></polygon>
<rect x="254" y="52" width="190" height="72" rx="8" fill="var(--bg-subtle)" stroke="var(--border)"></rect>
<text x="270" y="74" font-size="9.5" fill="var(--text-muted)">uxml</text>
<text x="270" y="100" font-size="10.5" fill="var(--text)" font-family="var(--font-mono)">my-max-value="100"</text>
<line x1="444" y1="88" x2="472" y2="88" stroke="var(--text-muted)" stroke-width="1"></line>
<polygon points="480,88 468,83 468,93" fill="var(--text-muted)"></polygon>
<rect x="484" y="52" width="172" height="72" rx="8" fill="var(--bg-subtle)" stroke="var(--border)"></rect>
<text x="500" y="74" font-size="9.5" fill="var(--text-muted)">UI Builder 인스펙터</text>
<rect x="500" y="84" width="140" height="26" rx="5" fill="var(--bg)" stroke="var(--border)"></rect>
<text x="510" y="101" font-size="10" fill="var(--text)">My Max Value</text>
<text x="24" y="160" font-size="11.5" fill="var(--text-muted)">대문자에서 잘라 하이픈으로 잇습니다. myIntValue 는 my-int-value 가 됩니다</text>
<text x="24" y="182" font-size="11.5" fill="var(--text-muted)">마음에 안 들면 [UxmlAttribute("max-value")] 처럼 직접 적습니다</text>
</svg>
<figcaption>한 곳에 적으면 나머지 둘이 따라옵니다.</figcaption>
</figure>

이름은 알아서 바뀝니다. 대문자에서 잘라 하이픈으로 잇습니다. `myIntValue` 는 `my-int-value` 가 됩니다. 마음에 안 들면 위의 셋째 줄처럼 직접 적습니다.

붙이고 나면 세 곳에서 같이 보입니다. uxml 에 속성으로 적을 수 있고, UI Builder 의 인스펙터에 칸이 생기고, 코드에서 그냥 프로퍼티입니다.

```xml
<StatBar title="HP" value="72" max-value="100" />
```

## 속을 채우고 클래스를 답니다

요소를 만들었으면 그 안에 무엇이 들어갈지는 생성자에서 정합니다. 여기서 [2편에서 본 규칙](/posts/uitk-uss-selectors/)을 그대로 씁니다. 유니티가 `unity-toggle__label` 같은 이름을 붙여 두는 그 방식입니다.

<figure class="diagram">
<svg viewBox="0 0 680 236" role="img" aria-label="StatBar 안에 Label 과 VisualElement 가 들어가고 각각 BEM 식 클래스 이름이 붙는 그림">
<text x="24" y="24" font-size="13" fill="var(--text)">밖에서는 태그 하나, 안에는 부품 둘</text>
<text x="24" y="52" font-size="11.5" fill="var(--text-muted)">쓰는 쪽 uxml</text>
<rect x="24" y="62" width="258" height="46" rx="8" fill="var(--accent)" fill-opacity="0.16" stroke="var(--accent)"></rect>
<text x="40" y="90" font-size="11" fill="var(--text)" font-family="var(--font-mono)">&lt;StatBar title="HP" /&gt;</text>
<text x="24" y="140" font-size="11.5" fill="var(--text-muted)">생성자가 만드는 속</text>
<rect x="24" y="150" width="258" height="70" rx="8" fill="var(--bg-subtle)" stroke="var(--border)" stroke-dasharray="4 3"></rect>
<rect x="38" y="160" width="230" height="22" rx="5" fill="var(--bg)" stroke="var(--border)"></rect>
<text x="48" y="175" font-size="9.5" fill="var(--text-muted)" font-family="var(--font-mono)">Label</text>
<rect x="38" y="188" width="230" height="22" rx="5" fill="var(--bg)" stroke="var(--border)"></rect>
<text x="48" y="203" font-size="9.5" fill="var(--text-muted)" font-family="var(--font-mono)">VisualElement</text>
<line x1="153" y1="108" x2="153" y2="150" stroke="var(--border)" stroke-width="1" stroke-dasharray="3 3"></line>
<text x="330" y="52" font-size="11.5" fill="var(--accent2)">붙는 클래스 이름</text>
<rect x="330" y="62" width="326" height="34" rx="7" fill="var(--bg-subtle)" stroke="var(--accent2)"></rect>
<text x="346" y="84" font-size="10.5" fill="var(--text)" font-family="var(--font-mono)">.stat-bar</text>
<text x="646" y="84" font-size="9.5" fill="var(--text-muted)" text-anchor="end">바깥 틀</text>
<rect x="330" y="104" width="326" height="34" rx="7" fill="var(--bg-subtle)" stroke="var(--accent2)"></rect>
<text x="346" y="126" font-size="10.5" fill="var(--text)" font-family="var(--font-mono)">.stat-bar__label</text>
<text x="646" y="126" font-size="9.5" fill="var(--text-muted)" text-anchor="end">글자</text>
<rect x="330" y="146" width="326" height="34" rx="7" fill="var(--bg-subtle)" stroke="var(--accent2)"></rect>
<text x="346" y="168" font-size="10.5" fill="var(--text)" font-family="var(--font-mono)">.stat-bar__fill</text>
<text x="646" y="168" font-size="9.5" fill="var(--text-muted)" text-anchor="end">막대</text>
<text x="330" y="204" font-size="11" fill="var(--text-muted)">쓰는 쪽에서 속을 건드릴 손잡이가</text>
<text x="330" y="220" font-size="11" fill="var(--text-muted)">이 이름들뿐입니다</text>
</svg>
<figcaption>유니티가 자기 컨트롤에 이름을 붙여 두는 방식 그대로입니다.</figcaption>
</figure>

```csharp
[UxmlElement]
public partial class StatBar : VisualElement
{
    public static readonly string ussClassName = "stat-bar";
    public static readonly string labelUssClassName = ussClassName + "__label";
    public static readonly string fillUssClassName = ussClassName + "__fill";

    readonly Label m_Label = new Label();
    readonly VisualElement m_Fill = new VisualElement();

    public StatBar()
    {
        AddToClassList(ussClassName);
        m_Label.AddToClassList(labelUssClassName);
        m_Fill.AddToClassList(fillUssClassName);
        Add(m_Label);
        Add(m_Fill);
    }

    [UxmlAttribute]
    public string title
    {
        get => m_Label.text;
        set => m_Label.text = value;
    }
}
```

클래스 이름을 상수로 뽑아 두는 것은 습관 이상입니다. **쓰는 쪽에서 속을 건드릴 손잡이가 그 이름뿐** 이기 때문입니다. `.stat-bar__fill` 에 색을 걸면 막대 색이 바뀌고, 그 이름이 없으면 손댈 방법이 없습니다.

속이 복잡하면 uxml 로 그려 두고 생성자에서 불러올 수도 있습니다.

```csharp
public StatBar()
{
    var asset = Resources.Load<VisualTreeAsset>("StatBar");
    asset.CloneTree(this);
}
```

문서가 나누는 기준이 분명합니다. "use UXML to define the structure, use USS to define the look, and use C# to define the logic of your custom control." 구조가 조건에 따라 달라져야 하면 이쪽이고, 고정이면 위의 템플릿으로 충분합니다.

## 옛 방식이 아직 남아 있습니다

인터넷에서 찾은 예제가 전혀 다르게 생겼다면 옛 방식입니다. 유니티 2023.2 이전에는 `UxmlFactory` 와 `UxmlTraits` 를 직접 적었습니다.

<x-tabs>
<pre data-tab="옛 방식 (UxmlFactory)" style="background:var(--bg-subtle);border:1px solid var(--border);border-radius:var(--radius);padding:1.05rem 1.15rem;overflow-x:auto;font-size:0.8rem;line-height:1.8"><code>public class StatBar : VisualElement
{
    public new class UxmlFactory : UxmlFactory&lt;StatBar, UxmlTraits&gt; { }
    public new class UxmlTraits : VisualElement.UxmlTraits
    {
        UxmlFloatAttributeDescription m_Value =
            new UxmlFloatAttributeDescription { name = "value", defaultValue = 0 };
        public override void Init(
            VisualElement ve, IUxmlAttributes bag, CreationContext cc)
        {
            base.Init(ve, bag, cc);
            ((StatBar)ve).value = m_Value.GetValueFromBag(bag, cc);
        }
    }
    public float value { get; set; }
}</code></pre>
<pre data-tab="지금 방식 (UxmlElement)" style="background:var(--bg-subtle);border:1px solid var(--border);border-radius:var(--radius);padding:1.05rem 1.15rem;overflow-x:auto;font-size:0.8rem;line-height:1.8"><code>[UxmlElement]
public partial class StatBar : VisualElement
{
    [UxmlAttribute]
    public float value { get; set; }
}</code></pre>
</x-tabs>

옛 방식이 지워진 것은 아닙니다. 문서 표현으로 "If an element lacks the `UxmlElement` attribute, Unity defaults to the `UxmlTraits` and `UxmlFactory` systems for serialization" 입니다. **`[UxmlElement]` 가 없으면 옛 경로로 떨어집니다.** 그래서 옛 코드가 그대로 돌아가고, 새로 쓸 때만 새 방식을 쓰면 됩니다.

둘을 섞지는 마세요. 한 클래스에 둘 다 적어 두면 어느 쪽이 쓰이는지 헷갈립니다.

## 에디터 창도 같은 것으로 만듭니다

[UI Toolkit 은 화면에 오브젝트를 놓지 않는다](/posts/unity-ui-toolkit-vs-ugui/)에서 유니티가 **에디터 쪽에는 UI Toolkit 을 권한다** 고 했습니다. 그 실제 모습이 이렇습니다.

```csharp
public class StatWindow : EditorWindow
{
    [MenuItem("Window/Stat")]
    static void Open() => GetWindow<StatWindow>();

    void CreateGUI()
    {
        rootVisualElement.Add(new StatBar { title = "HP", value = 72 });
    }
}
```

`CreateGUI` 가 시작점입니다. 문서 표현으로 "UI Toolkit uses the `CreateGUI` method to add controls to Editor UI, and Unity calls the `CreateGUI` method automatically when the window needs to be displayed" 입니다. uxml 과 uss 를 읽는 코드도 여기에 넣으라고 적혀 있습니다.

여기서 하나가 드러납니다. **위에서 만든 `StatBar` 를 에디터 창에서도 그대로 씁니다.** uGUI 에서는 게임 화면의 UI 와 에디터 창이 완전히 다른 체계였습니다. 게임 쪽은 Canvas 였고 에디터 쪽은 IMGUI 였습니다. UI Toolkit 은 같은 `VisualElement` 라서 한 번 만든 것을 양쪽에서 씁니다.

툴을 만들 일이 있는 팀에게는 이게 UI Toolkit 을 배우는 이유가 되기도 합니다. 게임 UI 를 uGUI 로 두더라도 에디터 툴은 이쪽으로 짜게 됩니다.

## 정리

- 묶는 방법이 둘입니다. **구조가 고정이면 uxml 템플릿**, **동작이 붙으면 C# 요소** 입니다
- `<ui:Template src name>` 으로 이름을 붙이고 `<ui:Instance template>` 으로 찍어 냅니다. 프리팹에 제일 가깝습니다
- C# 요소는 `[UxmlElement]` + `partial` + `VisualElement` 상속 셋입니다. `partial` 인 이유는 직렬화 코드가 생성돼 붙기 때문입니다
- `[UxmlAttribute]` 를 붙이면 uxml 속성과 UI Builder 인스펙터 칸이 같이 생깁니다. 이름은 `myIntValue` → `my-int-value` 로 바뀝니다
- 속 부품에는 `블록__부품` 식으로 클래스를 답니다. **쓰는 쪽에서 속을 건드릴 손잡이가 그 이름뿐입니다**
- `[UxmlElement]` 가 없으면 옛 `UxmlFactory` 경로로 떨어집니다. 옛 코드는 그대로 돌고, 새로 쓸 때만 새 방식입니다
- 에디터 창은 `CreateGUI` 에서 `rootVisualElement` 에 붙입니다. **만든 요소를 게임과 에디터 양쪽에서 씁니다**