---
title: "목록은 보이는 만큼만 만든다"
description: "ListView 는 항목이 천 개여도 요소를 열몇 개만 만들고 돌려 씁니다. makeItem 과 bindItem 이 갈려 있는 이유, 높이를 아느냐가 가르는 두 가지 가상화 방식, Rebuild 와 RefreshItems 의 차이, 그리고 안 되는 것까지 정리했습니다."
author: "유니얼"
pubDate: 2026-09-20
category: "game-dev/unity/ui"
tags: ["Unity", "UI", "UI Toolkit", "최적화"]
canonical: https://blog.unialgames.com/posts/uitk-listview/
---
[한 배치에 텍스처 여덟 장까지다](/posts/uitk-batching-cost/)에서 오버드로를 줄이는 방법 중 하나로 "목록이 길면 ListView 로 가상화" 를 적어 두고 넘어갔습니다. 이번이 그 얘기입니다.

[스크롤 목록은 창문 뒤에서 긴 종이를 움직이는 것이다](/posts/unity-ui-grid-scroll-mask/)에서 uGUI 의 스크롤을 봤습니다. 긴 종이를 만들어 놓고 창문 뒤에서 움직이는 구조였고, **종이가 길어지면 그만큼 오브젝트가 늘어났습니다.** 항목 천 개짜리 목록이면 오브젝트가 천 개였고, 그래서 재활용하는 스크롤을 직접 만들거나 라이브러리를 가져다 썼습니다.

UI Toolkit 에는 그게 컨트롤로 들어와 있습니다.

## 한 화면을 채울 만큼만 만듭니다

<figure class="diagram">
<svg viewBox="0 0 680 258" role="img" aria-label="ScrollView 는 항목 수만큼 오브젝트를 만들고 ListView 는 화면을 채울 만큼만 만들어 돌려 쓰는 비교">
<text x="24" y="24" font-size="13" fill="var(--text)">항목 1,000 개짜리 목록</text>
<text x="24" y="50" font-size="11.5" fill="var(--warn)">전부 만들면</text>
<rect x="24" y="62" width="256" height="150" rx="8" fill="var(--bg-subtle)" stroke="var(--warn)" stroke-dasharray="4 3"></rect>
<rect x="40" y="72" width="224" height="11" rx="2" fill="var(--warn)" fill-opacity="0.18" stroke="var(--warn)" stroke-opacity="0.5"></rect>
<rect x="40" y="87" width="224" height="11" rx="2" fill="var(--warn)" fill-opacity="0.18" stroke="var(--warn)" stroke-opacity="0.5"></rect>
<rect x="40" y="102" width="224" height="11" rx="2" fill="var(--warn)" fill-opacity="0.18" stroke="var(--warn)" stroke-opacity="0.5"></rect>
<rect x="40" y="117" width="224" height="11" rx="2" fill="var(--warn)" fill-opacity="0.18" stroke="var(--warn)" stroke-opacity="0.5"></rect>
<rect x="40" y="132" width="224" height="11" rx="2" fill="var(--warn)" fill-opacity="0.18" stroke="var(--warn)" stroke-opacity="0.5"></rect>
<rect x="40" y="147" width="224" height="11" rx="2" fill="var(--warn)" fill-opacity="0.18" stroke="var(--warn)" stroke-opacity="0.5"></rect>
<rect x="40" y="162" width="224" height="11" rx="2" fill="var(--warn)" fill-opacity="0.18" stroke="var(--warn)" stroke-opacity="0.5"></rect>
<rect x="40" y="177" width="224" height="11" rx="2" fill="var(--warn)" fill-opacity="0.18" stroke="var(--warn)" stroke-opacity="0.5"></rect>
<rect x="40" y="192" width="224" height="11" rx="2" fill="var(--warn)" fill-opacity="0.18" stroke="var(--warn)" stroke-opacity="0.5"></rect>
<text x="152" y="200" font-size="9.5" fill="var(--warn)" text-anchor="middle">그 아래로 계속</text>
<text x="152" y="232" font-size="10.5" fill="var(--warn)" text-anchor="middle">오브젝트 1,000 개</text>
<text x="152" y="250" font-size="9.5" fill="var(--text-muted)" text-anchor="middle">보이지도 않는 것까지</text>
<text x="330" y="50" font-size="11.5" fill="var(--accent)">ListView 는</text>
<rect x="330" y="62" width="256" height="150" rx="8" fill="var(--bg-subtle)" stroke="var(--border)" stroke-dasharray="4 3"></rect>
<rect x="346" y="72" width="224" height="11" rx="2" fill="none" fill-opacity="0" stroke="var(--border)" stroke-opacity="0.5"></rect>
<rect x="346" y="87" width="224" height="11" rx="2" fill="none" fill-opacity="0" stroke="var(--border)" stroke-opacity="0.5"></rect>
<rect x="346" y="102" width="224" height="11" rx="2" fill="var(--accent)" fill-opacity="0.22" stroke="var(--accent)" stroke-opacity="1"></rect>
<rect x="346" y="117" width="224" height="11" rx="2" fill="var(--accent)" fill-opacity="0.22" stroke="var(--accent)" stroke-opacity="1"></rect>
<rect x="346" y="132" width="224" height="11" rx="2" fill="var(--accent)" fill-opacity="0.22" stroke="var(--accent)" stroke-opacity="1"></rect>
<rect x="346" y="147" width="224" height="11" rx="2" fill="var(--accent)" fill-opacity="0.22" stroke="var(--accent)" stroke-opacity="1"></rect>
<rect x="346" y="162" width="224" height="11" rx="2" fill="var(--accent)" fill-opacity="0.22" stroke="var(--accent)" stroke-opacity="1"></rect>
<rect x="346" y="177" width="224" height="11" rx="2" fill="none" fill-opacity="0" stroke="var(--border)" stroke-opacity="0.5"></rect>
<rect x="346" y="192" width="224" height="11" rx="2" fill="none" fill-opacity="0" stroke="var(--border)" stroke-opacity="0.5"></rect>
<rect x="338" y="98" width="240" height="79" rx="5" fill="none" stroke="var(--accent)" stroke-width="1.4"></rect>
<text x="596" y="128" font-size="9.5" fill="var(--accent)">보이는 창</text>
<line x1="590" y1="140" x2="590" y2="96" stroke="var(--accent)" stroke-width="1"></line>
<polygon points="590,90 585,102 595,102" fill="var(--accent)"></polygon>
<line x1="590" y1="152" x2="590" y2="180" stroke="var(--accent)" stroke-width="1"></line>
<polygon points="590,186 585,174 595,174" fill="var(--accent)"></polygon>
<text x="458" y="200" font-size="9.5" fill="var(--text-muted)" text-anchor="middle">나머지는 아직 요소가 없습니다</text>
<text x="458" y="232" font-size="10.5" fill="var(--accent)" text-anchor="middle">요소 몇 개</text>
<text x="458" y="250" font-size="9.5" fill="var(--text-muted)" text-anchor="middle">스크롤하면 위에서 빠진 것을 아래에 다시 씁니다</text>
</svg>
<figcaption>목록이 길어져도 만들어지는 요소 수는 그대로입니다.</figcaption>
</figure>

<x-list>
<script type="application/json">
{"label":"목록이 길어도 요소는 보이는 만큼만","note":"여유분 개수는 그림을 위해 둘로 잡았습니다. 실제 개수는 항목 높이와 창 크기에 따라 달라집니다.","default":{"count":1,"view":0}}
</script>
</x-list>

문서가 ListView 가 하는 일을 이렇게 적어 뒀습니다. "instantiate the number of visual elements required to fill a page, recycle visual elements to optimize memory handling."

**한 쪽을 채울 만큼만 만들고, 만든 것을 돌려 씁니다.** 스크롤해서 위로 빠진 줄의 요소를 버리지 않고 아래에 다시 끼웁니다. 새로 만드는 게 아니라 값만 갈아 넣습니다.

그래서 ScrollView 와의 갈림길이 분명합니다. 항목이 몇 개 안 되고 높이도 제각각이면 ScrollView 가 편합니다. **항목이 많거나 늘어날 수 있으면 ListView** 입니다. 문서도 "a ListView is more efficient than a ScrollView" 라고 적어 두고 그 조건을 나열합니다.

## 만드는 일과 채우는 일이 갈려 있습니다

돌려 쓰려면 **만드는 것과 값을 넣는 것이 따로여야** 합니다. 이게 ListView API 의 생김새를 설명합니다.

```csharp
var listView = container.Q<ListView>();
listView.makeItem = () => new Label();
listView.bindItem = (e, i) => ((Label)e).text = items[i];
listView.itemsSource = items;
listView.fixedItemHeight = 32;
```

<figure class="diagram">
<svg viewBox="0 0 680 236" role="img" aria-label="makeItem 은 처음 몇 번만 불리고 bindItem 은 스크롤하는 내내 불린다는 그림">
<text x="24" y="24" font-size="13" fill="var(--text)">만드는 일과 채우는 일</text>
<rect x="24" y="52" width="290" height="64" rx="8" fill="var(--accent2)" fill-opacity="0.12" stroke="var(--accent2)"></rect>
<text x="40" y="74" font-size="11" fill="var(--text)" font-family="var(--font-mono)">makeItem</text>
<text x="40" y="94" font-size="9.5" fill="var(--text-muted)">빈 칸을 만듭니다. 값은 안 넣습니다</text>
<text x="40" y="108" font-size="9.5" fill="var(--accent2)">처음 몇 번만 불립니다</text>
<rect x="24" y="128" width="290" height="64" rx="8" fill="var(--accent)" fill-opacity="0.12" stroke="var(--accent)"></rect>
<text x="40" y="150" font-size="11" fill="var(--text)" font-family="var(--font-mono)">bindItem (element, index)</text>
<text x="40" y="170" font-size="9.5" fill="var(--text-muted)">그 칸에 index 번째 값을 넣습니다</text>
<text x="40" y="184" font-size="9.5" fill="var(--accent)">스크롤하는 내내 불립니다</text>
<line x1="169" y1="116" x2="169" y2="122" stroke="var(--text-muted)" stroke-width="1"></line>
<polygon points="169,128 164,120 174,120" fill="var(--text-muted)"></polygon>
<text x="352" y="72" font-size="11" fill="var(--warn)">여기서 이벤트를 걸면</text>
<text x="352" y="90" font-size="10.5" fill="var(--text-muted)">그 칸이 다른 항목에 쓰일 때</text>
<text x="352" y="106" font-size="10.5" fill="var(--text-muted)">예전 항목을 가리키는 콜백이 남습니다</text>
<text x="352" y="150" font-size="11" fill="var(--accent)">거는 것은 bindItem</text>
<text x="352" y="168" font-size="11" fill="var(--accent)">떼는 것은 unbindItem</text>
<text x="24" y="224" font-size="11" fill="var(--text-muted)">돌려 쓰는 구조라서 둘이 갈려 있습니다</text>
</svg>
<figcaption>uGUI 에서 재활용 스크롤을 직접 만들어 봤다면 낯익은 구조입니다.</figcaption>
</figure>

| 콜백 | 언제 |
| --- | --- |
| `makeItem` | "Called as needed when the ListView needs more items to render" |
| `bindItem` | "Invoked to associate the element with the matching data item" |
| `unbindItem` | 그 요소가 목록에서 떨어질 때. 걸어 둔 것을 떼는 자리 |
| `destroyItem` | 요소를 정말 버릴 때 |

`makeItem` 은 **처음 몇 번만** 불립니다. 화면을 채울 만큼 만들고 나면 더 안 부릅니다. `bindItem` 은 스크롤하는 내내 불립니다.

여기서 실수하기 쉬운 자리가 하나 있습니다. **`makeItem` 안에서 이벤트 콜백을 걸면 안 됩니다.** 그 요소가 나중에 다른 항목에 쓰이면 예전 항목을 가리키는 콜백이 남아 있습니다. 거는 것은 `bindItem`, 떼는 것은 `unbindItem` 입니다.

uGUI 에서 재활용 스크롤을 직접 만들어 본 적이 있다면 이 구조가 낯익을 것입니다. 만들어 둔 칸에 인덱스를 다시 매기는 그 코드가 여기서는 `bindItem` 하나로 들어와 있습니다.

## 높이를 아느냐가 갈림길입니다

돌려 쓰려면 **어느 줄이 지금 보이는지** 를 알아야 하고, 그러려면 줄 높이를 알아야 합니다. `virtualizationMethod` 가 그 선택입니다.

| 값 | 어떻게 |
| --- | --- |
| `FixedHeight` (기본) | `fixedItemHeight` 로 정한 값을 모든 줄에 씁니다 |
| `DynamicHeight` | "the collection will wait for the actual height to be computed" |

`FixedHeight` 는 계산이 곱셈 한 번입니다. 스크롤 위치를 높이로 나누면 몇 번째 줄인지 바로 나옵니다.

`DynamicHeight` 는 실제 높이가 나올 때까지 기다려야 합니다. 줄마다 글자 수가 달라서 높이가 제각각인 채팅 목록 같은 것이 여기에 해당합니다. 편한 대신 계산이 붙습니다.

**높이를 고정할 수 있으면 고정하는 편이 낫습니다.** 글자가 길어질 수 있는 줄이라면 말줄임을 걸어 한 줄로 고정하는 것도 방법입니다. [짧은 글자일수록 번역에서 더 길어진다](/posts/unity-ui-localization/)에서 본 문제가 여기서는 성능 문제로 나타납니다.

## 다시 그리는 방법이 셋입니다

데이터가 바뀌면 화면을 갱신해야 하는데, 방법에 따라 비용이 다릅니다.

| 방법 | 하는 일 |
| --- | --- |
| `RefreshItem(index)` | 그 줄만 다시 `bindItem` |
| `RefreshItems()` | 보이는 줄들을 다시 `bindItem` |
| `Rebuild()` | 요소를 전부 버리고 다시 만듭니다 |

문서가 `Rebuild` 에 대해 "the collection view is completely rebuilt, which can be expensive" 라고 적어 뒀습니다. **`makeItem` 부터 다시 돕니다.**

값 하나가 바뀌었는데 `Rebuild()` 를 부르는 코드가 흔합니다. 그 줄만 바뀐 것이면 `RefreshItem(index)` 이면 됩니다. `Rebuild` 는 목록의 구조 자체가 바뀌었을 때, 예를 들어 `makeItem` 을 갈아 끼웠을 때 씁니다.

[움직임은 클립이 아니라 두 값 사이에서 만들어진다](/posts/uitk-transitions-binding/)에서 본 데이터 바인딩을 걸어 두면 이 갱신을 직접 안 불러도 됩니다. 목록 전용 바인딩 문서가 따로 있으니 목록이 데이터를 따라가야 하면 그쪽을 봅니다.

## 안 되는 것도 알아 둡니다

**가로 목록이 안 됩니다.** 문서가 "ListView doesn't support horizontal layout" 이라고 적어 뒀고, 필요하면 ScrollView 에 `flex-direction: row` 를 주라고 합니다. 가로로 흐르는 아이템 띠 같은 것은 가상화를 못 쓴다는 뜻입니다.

선택이 바뀐 것을 받을 때도 주의할 자리가 있습니다. `selectionChanged` 대신 **`selectedIndicesChanged`** 를 쓰라고 문서가 권합니다. 값 타입이 들어 있으면 박싱이 생기기 때문입니다.

줄 안에 Toggle 을 넣으면 그 토글이 클릭을 먹어서 줄 선택이 안 됩니다. 문서가 드는 해법은 [5편](/posts/uitk-events/)에서 본 그것입니다. 토글에 `PointerDownEvent` 를 `TrickleDown` 으로 걸어서 직접 `SetSelection` 을 부릅니다. 내려가는 길에서 먼저 잡는 것입니다.

스타일을 걸 손잡이는 `.unity-list-view`, `.unity-list-view__item`, `.unity-collection-view__item--selected` 같은 이름들입니다. [2편](/posts/uitk-uss-selectors/)에서 본 BEM 그대로입니다.

## 형제가 둘 더 있습니다

같은 뿌리에서 나온 컨트롤이 둘 더 있습니다.

- **`MultiColumnListView`.** 열이 여럿인 표입니다. 정렬, 드래그 앤 드롭, 열 크기 조절이 들어 있습니다
- **`TreeView`.** 접었다 펴는 트리입니다. 9편에서 본 것처럼 유니티 6 에서 "a new high-performance backend" 로 바뀌었습니다

셋 다 가상화와 `makeItem` / `bindItem` 구조를 공유합니다. 하나를 익히면 나머지는 속성 이름만 다릅니다.

에디터 도구를 만들 일이 있으면 이쪽이 특히 값이 큽니다. [7편](/posts/uitk-custom-controls/)에서 본 것처럼 에디터 창과 게임 화면이 같은 요소를 쓰니, 표 하나를 만들어 두면 양쪽에서 씁니다.

## 정리

- ListView 는 **한 화면을 채울 만큼만 요소를 만들고 돌려 씁니다.** 항목이 천 개여도 요소는 열몇 개입니다
- 돌려 쓰기 때문에 **만드는 일(`makeItem`)과 채우는 일(`bindItem`)이 갈려 있습니다**
- 이벤트 콜백은 `bindItem` 에서 걸고 `unbindItem` 에서 뗍니다. `makeItem` 에서 걸면 남습니다
- `FixedHeight` 가 기본이고 계산이 쌉니다. 높이가 제각각이면 `DynamicHeight` 인데 기다림이 붙습니다
- 갱신은 `RefreshItem` → `RefreshItems` → `Rebuild` 순으로 비쌉니다. **`Rebuild` 는 `makeItem` 부터 다시 돕니다**
- **가로 목록은 지원하지 않습니다.** ScrollView 에 `flex-direction: row` 를 씁니다
- 선택 변경은 `selectedIndicesChanged` 쪽이 박싱이 없습니다
- `MultiColumnListView` 와 `TreeView` 가 같은 구조를 씁니다