UI 최적화 (1) - 캔버스와 리빌드 시스템 - soo:bak
작성일 :
화면에 항상 있는 UI의 비용
지금까지 모바일 최적화 시리즈에서는 메모리 관리, 스크립트 최적화, 렌더링 파이프라인과 GPU 아키텍처를 다루었습니다. 이들은 게임 전반에 걸쳐 적용되는 범용적인 최적화 주제였습니다.
이제부터는 개별 서브시스템이 가진 고유한 최적화 패턴을 다룹니다. 그 첫 번째가 UI(User Interface) 입니다.
UI를 가장 먼저 다루는 이유는, 모바일 게임에서 UI가 항상 화면에 존재하는 요소이기 때문입니다. 3D 오브젝트는 카메라 밖으로 나가면 렌더링되지 않고, 파티클은 일시적으로만 존재합니다. 반면 체력바, 미니맵, 채팅창, 인벤토리 버튼 같은 UI 요소는 게임 플레이 내내 화면 위에 있습니다.
화면에 항상 있으므로 매 프레임마다 렌더링됩니다. 60fps로 실행되는 게임이라면 UI는 1초에 60번 그려집니다. UI 시스템이 매 프레임 불필요한 연산을 수행하면, 그 비용은 게임이 실행되는 모든 순간에 누적됩니다.
이 글에서는 Unity UI 시스템의 핵심인 Canvas 아키텍처와 리빌드(Rebuild) 시스템을 다룹니다. Canvas가 UI 요소를 화면에 그리기까지 내부적으로 어떤 과정을 거치는지, 그리고 어떤 변경이 비용을 발생시키는지를 이해하는 것이 UI 최적화의 출발점입니다.
Canvas의 역할
Canvas는 Unity UI 시스템에서 모든 UI 요소의 컨테이너 역할을 합니다. Button, Image, Text 같은 UI 컴포넌트는 반드시 Canvas의 자식 오브젝트로 존재해야 화면에 표시됩니다.
Canvas가 단순히 UI 요소를 담아두는 그릇이었다면 성능과는 무관했을 것입니다. Canvas가 성능에 영향을 미치는 이유는, UI 요소들을 메쉬(Mesh)로 변환하는 과정을 Canvas가 관리하기 때문입니다. 화면에 표시되는 모든 UI는 결국 GPU가 이해할 수 있는 형태인 메쉬로 바뀌어야 합니다. Canvas는 이 변환 작업과, 여러 UI 요소의 메쉬를 모아서 한꺼번에 GPU에 제출하는 작업을 담당합니다.
UI 요소와 메쉬
렌더링 기초 (1) - 메쉬의 구조에서 3D 오브젝트가 정점과 삼각형으로 구성된 메쉬로 표현된다고 했습니다. UI 요소도 마찬가지로 메쉬로 변환됩니다. Image, Text, RawImage 같은 UI 요소는 대부분 평면이므로, 내부적으로 사각형 메쉬로 변환됩니다.
하나의 UI Image는 정점 4개와 삼각형 2개로 구성된 사각형 메쉬가 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
UI Image의 내부 메쉬 구조
v0 ──────── v1
│ ╲ │
│ ╲ │
│ ╲ │
│ ╲ │
v2 ──────── v3
삼각형 1: v0, v2, v1
삼각형 2: v1, v2, v3
정점 4개, 삼각형 2개
Text는 글자 하나당 하나의 사각형 메쉬를 생성합니다. “Hello”라는 텍스트는 5개의 사각형, 즉 정점 20개와 삼각형 10개로 구성됩니다.
1
2
3
4
5
6
7
8
Text "Hello" 의 내부 메쉬 구조
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│ H │ │ e │ │ l │ │ l │ │ o │
└───┘ └───┘ └───┘ └───┘ └───┘
글자 하나 = 사각형 하나 = 정점 4개, 삼각형 2개
"Hello" = 5글자 = 정점 20개, 삼각형 10개
화면에 버튼 하나가 있다면 그 버튼은 배경 Image(정점 4개) + 라벨 Text(글자 수 x 4개)로 구성됩니다. “확인” 버튼이라면 정점 12개(배경 4 + 글자 2 x 4)입니다.
UI가 복잡해지면 정점 수는 빠르게 증가합니다. 인벤토리 화면에 슬롯 50개가 있고, 각 슬롯에 아이콘과 수량 텍스트가 있다면 정점 수는 수천 개에 달합니다.
Canvas의 메쉬 생성과 배칭
Canvas에 속한 각 UI 요소는 자신만의 메쉬를 생성합니다. Image 하나는 사각형 메쉬 하나를, Text는 문자마다 사각형 메쉬를 만듭니다. Canvas는 이렇게 개별적으로 만들어진 메쉬들을 모아서, 조건이 맞는 요소끼리 하나의 메쉬로 합쳐 GPU에 제출합니다. 이 과정이 배칭(Batching) 이며, 합쳐진 단위 하나가 하나의 드로우콜(Draw Call)에 해당합니다.
배칭의 목적은 드로우콜(Draw Call)과 SetPass Call을 줄이는 것입니다. 게임 루프의 원리 (2) - CPU-bound와 GPU-bound에서 다루었듯이, CPU가 GPU에 렌더링 명령을 보내는 횟수가 드로우콜이며, 드로우콜이 많을수록 CPU 부하가 증가합니다. SetPass Call은 머티리얼이나 셰이더 상태를 GPU에 설정하는 호출로, 머티리얼이 바뀔 때마다 발생합니다. Canvas 배칭은 같은 머티리얼을 사용하는 요소를 하나의 메쉬로 합쳐서, 드로우콜과 SetPass Call을 함께 줄입니다. UI 요소가 100개 있을 때 하나씩 따로 그리면 드로우콜이 100회이지만, 배칭으로 합치면 수 회로 줄일 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
배칭 전후 비교 (같은 머티리얼을 사용하는 경우)
배칭 전:
Image A → Draw Call 1
Image B → Draw Call 2
Image C → Draw Call 3
...
→ 요소 수만큼 드로우콜 발생
배칭 후:
Image A ─┐
Image B ─┼→ 합쳐진 메쉬 → Draw Call 1
Image C ─┘
→ 같은 머티리얼의 요소가 하나의 배치로 합쳐짐
Canvas는 이 배칭을 위해, 먼저 모든 UI 요소의 메쉬 데이터(정점, UV, 색상)를 수집하고, 배칭 규칙에 따라 같은 머티리얼을 사용하는 요소끼리 그룹으로 분류합니다. 그런 다음 각 그룹의 메쉬를 하나의 연속된 버퍼로 합쳐서 GPU에 제출합니다.
1
2
3
4
5
6
7
8
Canvas의 메쉬 배칭 과정
UI 요소 Canvas 내부 처리 GPU
──────── ────────────────── ───
Image ─┐ 1. 메쉬 데이터 수집 (정점, UV, 색상)
Image ─┤ 2. 배칭 규칙에 따라 그룹 분류 합쳐진 메쉬
Text ─┼─→ 3. 그룹별 메쉬를 하나의 버퍼로 합침 ─→ 버퍼를 렌더링
Button ─┘ 4. GPU에 제출
이 전체 과정은 CPU에서 수행됩니다. GPU는 이미 합쳐진 최종 메쉬를 받아서 렌더링만 하고, 메쉬를 수집하고 분류하고 합치는 모든 연산은 CPU의 몫입니다.
리빌드(Rebuild)란
Canvas가 메쉬를 한 번 생성하고 GPU에 제출한 뒤, UI에 아무런 변경이 없으면 Canvas는 추가 작업을 하지 않습니다. 이미 만들어진 메쉬가 GPU에 유지되므로, CPU는 다른 작업에 집중할 수 있습니다.
그러나 Canvas에 속한 UI 요소 중 하나라도 변경되면, Canvas는 변경을 반영하기 위해 메쉬를 재생성하고 배칭을 다시 실행해야 합니다. 이 재처리 과정이 리빌드(Rebuild) 입니다.
1
2
3
4
5
6
리빌드 발생 흐름
프레임 N : UI 변경 없음 → 이전 메쉬 유지 (CPU 비용 0)
프레임 N+1 : Text 변경 ("100" → "99")
→ 변경 감지 → 메쉬 재생성 → 배칭 재실행 → GPU 재제출 (CPU 비용 발생)
리빌드는 Canvas 단위로 발생합니다. Canvas 안에 UI 요소가 100개 있고 그중 1개만 변경되더라도, Canvas는 100개 전체의 메쉬를 다시 수집하고 배칭합니다. 변경되지 않은 99개의 요소도 재배칭 대상에 포함됩니다.
이것이 UI 성능 문제의 핵심 원인입니다. UI 요소 하나의 변경이 Canvas 전체의 CPU 비용을 유발하므로, Canvas에 포함된 요소가 많을수록 리빌드 비용도 증가합니다.
리빌드를 유발하는 변경들
Canvas의 리빌드를 유발하는 변경은 크게 두 종류로 나뉩니다. 레이아웃에 영향을 주는 변경과 시각적 표현에 영향을 주는 변경입니다. Unity 내부에서는 이 두 종류를 각각 Layout Rebuild와 Visual Rebuild(Graphic Rebuild) 로 구분합니다.
1
2
3
4
5
6
7
8
9
10
11
리빌드를 유발하는 변경의 예
변경 유형 예시 리빌드 종류
────────────────────────────────────────────────────────────────────
텍스트 내용 변경 "HP: 100" → "HP: 99" Visual Rebuild
이미지(스프라이트) 교체 아이콘 A → 아이콘 B Visual Rebuild
색상 변경 Color 프로퍼티 변경 Visual Rebuild
RectTransform 크기 변경 Width: 100 → 120 Layout Rebuild
요소 활성화/비활성화 SetActive(true/false) Layout + Visual
요소의 Hierarchy 순서 변경 SetSiblingIndex() 호출 Layout + Visual
LayoutGroup 자식 추가/제거 Instantiate / Destroy Layout + Visual
UI에서 흔히 수행하는 거의 모든 조작이 리빌드를 유발합니다. 체력바의 숫자가 바뀌는 것, 아이템 아이콘이 교체되는 것, 버프 효과로 아이콘 색상이 변하는 것 모두 Canvas 리빌드의 트리거입니다.
Layout Rebuild
Layout Rebuild는 UI 요소의 크기와 위치를 재계산하는 과정입니다.
Unity의 UI 레이아웃 시스템은 RectTransform과 LayoutGroup을 기반으로 동작합니다. RectTransform은 모든 UI 요소가 갖는 Transform 컴포넌트로, 위치, 크기, 앵커 정보를 담고 있습니다.
LayoutGroup(HorizontalLayoutGroup, VerticalLayoutGroup, GridLayoutGroup)은 자식 요소들의 크기와 위치를 자동으로 배치하는 컴포넌트입니다. 예를 들어 VerticalLayoutGroup은 자식 요소들을 위에서 아래로 나열하고, 각 요소 사이에 지정된 간격을 배치합니다. Layout Rebuild는 이런 배치 규칙을 다시 적용하는 과정입니다.
레이아웃 업데이트의 전체 흐름은 Unity의 Layout Update Cycle에서 다루고 있습니다.
Layout Rebuild는 다음과 같은 변경이 발생할 때 트리거됩니다.
- RectTransform의 크기(Width, Height) 변경
- UI 요소의 활성화/비활성화
- LayoutGroup 내부에서 자식 요소의 추가/제거
- ContentSizeFitter가 감지하는 콘텐츠 크기 변화
Layout Rebuild는 dirty flag 방식으로 동작합니다. dirty flag란 “이 요소는 변경되었으므로 갱신이 필요하다”는 표시입니다. 변경이 발생한 요소에 dirty 플래그가 설정되고, Unity는 프레임의 Layout 업데이트 시점에서 dirty 플래그가 설정된 요소들만 재계산합니다.
1
2
3
4
5
6
7
Layout Rebuild 의 dirty flag 흐름
sizeDelta 변경 → dirty flag 설정 → CanvasUpdateRegistry에 등록
│
프레임 끝, Layout 업데이트 시점
│
dirty 요소만 크기/위치 재계산 → dirty flag 해제
dirty flag 방식 덕분에 변경되지 않은 요소의 레이아웃은 재계산되지 않으므로, 매 프레임 모든 요소를 재계산하는 것에 비해 CPU 비용이 크게 줄어듭니다.
하지만 LayoutGroup 안에서는 한 자식의 크기가 바뀌면 나머지 자식들의 위치도 밀려야 하므로, 요소 하나의 변경이 그룹 전체의 재계산으로 이어집니다.
자식 요소가 많은 LayoutGroup에서 빈번한 변경이 발생하면, dirty flag의 효율성이 상쇄되어 Layout Rebuild 비용이 증가합니다.
1
2
3
4
5
6
7
8
9
LayoutGroup 재계산 범위 (VerticalLayoutGroup 예시)
슬롯 1 (변경 없음)
슬롯 2 ← 크기 변경!
슬롯 3 (변경 없음)
슬롯 4 (변경 없음)
→ 슬롯 2의 크기가 커지면 슬롯 3, 4의 위치가 밀림
→ 슬롯 1~4 전체의 위치를 다시 계산
슬롯 3과 4는 실제로 위치가 밀리므로 재계산이 필요합니다. 슬롯 1은 위치가 변하지 않지만, LayoutGroup은 개별 슬롯이 영향을 받았는지를 판단하지 않고 자식 전체의 배치를 처음부터 다시 계산합니다. 이것이 LayoutGroup의 Layout Rebuild 비용이 자식 수에 비례하는 이유입니다.
Layout Rebuild는 bottom-up(자식 → 부모) 방향으로 크기를 먼저 계산한 뒤, top-down(부모 → 자식) 방향으로 위치를 적용합니다.
자식의 크기가 부모의 크기에 영향을 주고(예: ContentSizeFitter), 부모의 크기가 다시 자식의 위치에 영향을 주기 때문에 이 순서가 필요합니다.
1
2
3
4
5
6
7
Layout Rebuild 계산 순서
1단계: Bottom-Up (크기 계산)
자식의 preferredWidth/Height → 부모 LayoutGroup의 전체 크기 결정 → ContentSizeFitter 조정
2단계: Top-Down (위치 적용)
부모의 크기 확정 → 자식들의 anchoredPosition 순서대로 배치 → RectTransform에 최종 적용
Visual Rebuild (Graphic Rebuild)
Visual Rebuild는 UI 요소의 시각적 표현을 구성하는 메쉬 데이터를 재생성하는 과정입니다.
UI 요소의 색상이 바뀌면, 해당 요소의 메쉬에 기록된 정점 색상(Vertex Color) 데이터를 갱신해야 합니다. 텍스트 내용이 바뀌면, 글자 배치를 다시 계산하고 새로운 메쉬를 생성해야 합니다. 이미지의 스프라이트가 교체되면, 텍스처에서 색상을 읽어올 위치를 나타내는 UV 좌표가 달라지므로 메쉬의 UV 데이터를 갱신해야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Visual Rebuild 가 필요한 경우
변경 재생성이 필요한 데이터
─────────────────────────────────────────────────────────
색상 변경 정점 색상 (Vertex Color)
(Color 프로퍼티 변경)
텍스트 내용 변경 전체 메쉬 재생성
("100" → "99") (글자 수, 글자 배치, UV 모두 변경)
스프라이트 교체 UV 좌표 재계산
(sprite 프로퍼티 변경) (아틀라스 내 위치가 다르므로)
fillAmount 변경 메쉬 형태 재생성
(Image.Filled 타입) (채워진 영역의 정점 재계산)
Visual Rebuild에서 가장 비용이 큰 것은 텍스트 재생성입니다. 텍스트는 글자마다 폰트 텍스처에서의 위치를 조회하고, 커닝(글자 간격), 줄바꿈, 정렬을 계산한 뒤 각 글자의 메쉬를 생성해야 합니다.
폰트 시스템에서 하나의 글자를 렌더링한 픽셀 이미지를 글리프(Glyph) 라고 합니다. 영어 알파벳은 대소문자, 숫자, 기호를 합쳐도 약 100자 수준이므로, 빌드 시점에 모든 글리프를 하나의 폰트 텍스처에 미리 렌더링해둘 수 있습니다. 런타임에는 텍스처에서 해당 글자의 UV 좌표만 조회하면 됩니다.
반면, 한글과 같은 경우 조합 가능한 음절이 11,172자(가~힣)이므로 전부를 미리 텍스처에 넣으면 크기가 매우 커집니다. 그래서 Unity는 한글을 동적 폰트(Dynamic Font) 방식으로 처리하는 경우가 많습니다.
폰트 파일(.ttf, .otf)에는 각 글자의 형태가 픽셀 이미지가 아니라 획의 경계선을 점과 곡선 좌표로 표현한 윤곽선 데이터로 저장되어 있습니다. 어떤 크기로든 선명하게 표시할 수 있는 대신, 화면에 표시할 때 해당 크기에 맞춰 윤곽선을 픽셀로 채우는 변환(래스터라이즈) 과정이 필요합니다.
동적 폰트는 텍스트에 새로운 글자가 등장할 때 이 변환을 런타임에 수행하여 글리프를 생성하고, 폰트 텍스처 아틀라스에 추가합니다. 한 번 생성된 글리프는 아틀라스에 남아 있으므로 같은 글자가 다시 등장하면 추가 비용이 없습니다. 하지만 새로운 글자가 계속 등장하여 아틀라스의 빈 공간이 부족해지면, 더 큰 텍스처를 새로 만들고 기존 글리프를 모두 다시 배치해야 하므로 비용이 큽니다.
데미지 숫자, 점수 표시, 채팅 메시지처럼 매 프레임 내용이 바뀌는 텍스트는, 매 프레임 Visual Rebuild가 발생하고, 아틀라스에 없는 글자가 등장하면 글리프 생성까지 추가됩니다.
Layout Rebuild와 Visual Rebuild의 실행 순서
Layout Rebuild와 Visual Rebuild가 모두 필요한 프레임에서, 두 리빌드는 Layout Rebuild가 먼저, Visual Rebuild가 나중에 실행됩니다.
Visual Rebuild에서 메쉬를 생성하려면, 해당 요소의 최종 크기와 위치가 확정되어 있어야 합니다. Text의 메쉬를 생성하려면 텍스트 영역의 너비를 알아야 줄바꿈을 결정할 수 있고, Image의 메쉬를 생성하려면 이미지의 크기를 알아야 정점 좌표를 계산할 수 있습니다.
따라서 Layout Rebuild가 크기와 위치를 먼저 확정하고, Visual Rebuild가 그 결과를 기반으로 메쉬를 생성합니다.
1
2
3
4
5
6
7
8
9
10
리빌드 실행 순서 (프레임의 Late Update 이후, 렌더링 직전)
1. Layout Rebuild : dirty 요소의 크기/위치 재계산 → RectTransform 확정
│
2. Visual Rebuild : 확정된 크기를 기반으로 메쉬/색상/UV 재생성 → 메쉬 데이터 확정
│
3. Canvas 배칭 : 모든 요소의 메쉬를 수집하여 배칭 규칙에 따라 합침 → 최종 메쉬 확정
│
▼
GPU 렌더링
하나의 UI 변경이 Layout Rebuild와 Visual Rebuild를 모두 유발하는 경우도 있습니다. 예를 들어 SetActive(true)로 UI 요소를 활성화하면, 해당 요소의 크기/위치가 레이아웃에 반영되어야 하고(Layout Rebuild), 동시에 해당 요소의 메쉬도 생성되어야 합니다(Visual Rebuild). 이 경우 두 단계가 순서대로 모두 실행됩니다.
Canvas 단위 리빌드의 비용 구조
리빌드가 Canvas 단위로 발생하므로, Canvas에 포함된 요소 수가 곧 리빌드 비용을 결정합니다.
Canvas가 배칭하는 대상은 Canvas에 속한 모든 UI 요소입니다. 요소 하나가 변경되면 배치 그룹 분류가 달라질 수 있고, 각 배치 내의 정점 오프셋도 밀리므로 Canvas는 모든 요소의 메쉬를 다시 수집하고 배칭을 처음부터 재실행해야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
Canvas 단위 재배칭의 범위 (Canvas에 UI 요소 100개 포함)
요소 2의 Text 변경 ("100" → "99")
→ 요소 2: 메쉬 재생성 (Visual Rebuild)
재배칭:
요소 1 ← 변경 없음, 하지만 메쉬 재수집
요소 2 ← 메쉬 재생성됨
요소 3 ← 변경 없음, 하지만 메쉬 재수집
...
요소 100 ← 변경 없음, 하지만 메쉬 재수집
→ 100개 요소 전체를 다시 배칭
요소 2만 변경되었지만 100개 요소 전체가 재배칭 대상입니다. Canvas는 변경의 영향 범위를 요소별로 추적하지 않으므로, 전체 요소의 메쉬를 다시 수집하고 배칭을 처음부터 재실행합니다.
Canvas에 포함된 요소가 10~20개 수준이라면 리빌드 비용은 무시할 수 있습니다. 하지만 하나의 Canvas에 수백 개의 요소가 포함된 상태에서 매 프레임 변경이 발생하면, 리빌드 비용이 프레임 예산의 상당 부분을 차지합니다. 모바일 기기에서 60fps를 유지하려면 한 프레임에 약 16.6ms의 CPU 예산이 있는데, 요소가 많은 Canvas의 리빌드 한 번이 수 ms를 소비하면 전체 예산의 10~30%에 해당합니다.
1
2
3
4
5
6
7
8
9
10
11
Canvas 요소 수에 따른 리빌드 비용 (개념적 비교)
Canvas 내 요소 수 CPU 비용 영향
──────────────────────────────────────────────
10개 0.1ms 이하 (무시 가능)
50개 ~0.5ms
200개 ~1~2ms (프레임 예산의 6~12%)
500개 ~3~5ms (프레임 예산의 18~30%)
※ 수치는 기기와 UI 복잡도에 따라 달라지지만,
요소 수가 지배적 요인이라는 경향은 일관됨
이것이 Canvas 분리가 필요한 근본적인 이유입니다. 자주 변경되는 요소와 변경되지 않는 요소를 다른 Canvas에 배치하면, 변경이 발생했을 때 자주 변경되는 Canvas만 리빌드되고 나머지 Canvas는 영향을 받지 않습니다.
배칭 규칙
Canvas 리빌드의 비용은 요소 수에 비례하지만, 리빌드 결과 GPU에 제출되는 드로우콜의 수는 배칭이 얼마나 이루어지느냐에 달려 있습니다. 배칭에는 규칙이 있고, 이 규칙을 충족하지 못하면 배치가 나뉘어 드로우콜이 늘어납니다.
같은 머티리얼
배칭은 같은 머티리얼(Material)을 사용하는 요소끼리만 합칠 수 있습니다. 하나의 드로우콜은 하나의 렌더링 상태(셰이더 + 텍스처 조합)로 처리되므로, 머티리얼이 다르면 별도의 드로우콜로 나뉩니다. Unity UI의 대부분의 요소는 기본 UI 셰이더를 공유하므로, 배칭 가능 여부는 실질적으로 같은 텍스처를 사용하는지에 따라 결정됩니다.
서로 다른 스프라이트라도 같은 텍스처 아틀라스(Sprite Atlas) 에 속해 있으면 하나의 텍스처를 공유하므로 같은 배치로 합쳐질 수 있습니다. 아틀라스로 묶지 않은 스프라이트는 각각 별도의 텍스처이므로 배칭되지 않습니다. Text 요소 역시 폰트 텍스처를 사용하므로 Image와는 같은 배치로 합쳐지지 않습니다.
Hierarchy 순서와 깊이
같은 머티리얼을 사용하더라도, 요소들이 렌더링 순서상 연속해 있어야 하나의 배치로 합쳐질 수 있습니다. Unity UI는 Hierarchy를 깊이 우선(depth-first) 으로 순회하여 기본 렌더링 순서를 결정합니다. 부모가 먼저 그려지고 자식들이 순서대로 그려진 뒤 다음 형제로 넘어가므로, Hierarchy 순서가 렌더링 순서의 기준이 됩니다.
Hierarchy에서 같은 머티리얼의 요소 A와 C 사이에 다른 머티리얼의 요소 B가 있고, 이 요소들이 화면상에서 겹치면 렌더링 순서를 바꿀 수 없으므로 A와 C는 별도의 배치로 나뉩니다. 이 현상이 배치 브레이킹(Batch Breaking) 입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
배치 브레이킹 예시 (A, B, C가 화면상에서 겹치는 경우)
Hierarchy 순서 (위에서 아래로 렌더링):
Image A (아틀라스 1) → 배치 1
Image B (아틀라스 2) → 배치 2 (다른 머티리얼)
Image C (아틀라스 1) → 배치 3 (A와 같은 머티리얼이지만 B가 중간에 끼어 합칠 수 없음)
→ 드로우콜 3회
Hierarchy 순서를 조정한 경우:
Image A (아틀라스 1) ─┐
├→ 배치 1 (같은 머티리얼, 연속)
Image C (아틀라스 1) ─┘
Image B (아틀라스 2) → 배치 2
→ 드로우콜 2회
A와 C를 Hierarchy에서 연속으로 배치하면 같은 배치로 합쳐져 드로우콜이 3회에서 2회로 줄어듭니다. 실제 UI에서는 수십 개의 요소가 복잡하게 겹치므로 배치 브레이킹이 빈번하게 발생합니다.
겹침(Overlap) 조건
배치 브레이킹이 발생하는 조건을 좀 더 구체적으로 살펴보면, 핵심은 화면상의 겹침입니다. 화면에서 서로 겹치지 않는 요소끼리는 Hierarchy 순서를 바꿔도 시각적 결과가 같으므로, Unity가 순서를 재배치하여 배칭할 수 있습니다. 화면상에서 겹치는 요소들만 Hierarchy 순서대로 그려야 앞뒤 관계가 유지됩니다.
따라서 Hierarchy에서 다른 머티리얼의 요소가 끼어 있더라도, 해당 요소들이 화면상에서 겹치지 않으면 배치 브레이킹이 발생하지 않습니다. 반대로 겹쳐 있으면 Unity는 렌더링 순서를 보장하기 위해 배치를 나눕니다.
리빌드 비용 요약
Canvas의 리빌드 비용은 세 단계의 합산입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Canvas 리빌드의 CPU 비용 구성
1. Layout Rebuild
- dirty flag가 설정된 요소의 크기/위치 재계산
- LayoutGroup 내부라면 그룹 전체 재계산
2. Visual Rebuild
- 변경된 요소의 메쉬/색상/UV 재생성
- 텍스트 재생성이 가장 비용이 큼
3. Canvas 재배칭
- Canvas 내 모든 요소의 메쉬를 재수집
- 배칭 규칙에 따라 다시 그룹화
- GPU에 재제출
비용 ∝ Canvas 내 요소 수 × 변경 빈도
리빌드 비용을 결정하는 요인은 두 가지입니다. Canvas에 포함된 요소의 수와 변경이 발생하는 빈도입니다. 요소가 많은 Canvas에서 매 프레임 변경이 일어나는 것이 최악의 조합이며, 모바일에서 UI 프레임 저하의 가장 흔한 원인이기도 합니다.
마무리
- UI 요소(Image, Text 등)는 내부적으로 사각형 메쉬(정점 4개, 삼각형 2개)로 표현되며, Canvas는 이 메쉬들을 배칭하여 GPU에 제출합니다.
- Canvas 내 UI 요소 하나라도 변경되면, 해당 요소의 Layout Rebuild(크기/위치)와 Visual Rebuild(메쉬/색상/UV) 이후 Canvas 전체의 재배칭이 발생합니다.
- 배칭은 같은 텍스처를 사용하고 렌더링 순서상 연속해 있는 요소끼리 동작하며, 겹치는 요소 사이에 다른 머티리얼이 끼면 배치가 나뉘어 드로우콜이 증가합니다.
- 리빌드 비용은 Canvas 내 요소 수와 변경 빈도에 비례하며, 이것이 Canvas 분리가 필요한 근본적인 이유입니다.
UI 변경이 발생하는 프레임마다 이 비용이 반복되므로, Canvas의 리빌드 구조를 이해하는 것이 UI 최적화의 출발점입니다.
이 글에서는 Canvas가 UI를 어떻게 그리는지, 어떤 변경이 비용을 발생시키는지 살펴봤습니다. 비용 구조를 이해했으니, 이 비용을 줄이는 구체적인 방법을 알아볼 차례입니다.
Part 2에서는 Canvas 분리 전략, ScrollRect 풀링, TextMeshPro 활용, 오버드로우 최소화 등 실전에서 적용할 수 있는 UI 최적화 기법을 다룹니다.
관련 글
시리즈
- UI 최적화 (1) - 캔버스와 리빌드 시스템 (현재 글)
- UI 최적화 (2) - UI 최적화 전략
전체 시리즈
- 게임 루프의 원리 (1) - 프레임의 구조
- 게임 루프의 원리 (2) - CPU-bound와 GPU-bound
- 렌더링 기초 (1) - 메쉬의 구조
- 렌더링 기초 (2) - 텍스처와 압축
- 렌더링 기초 (3) - 머티리얼과 셰이더 기초
- GPU 아키텍처 (1) - GPU 병렬 처리와 렌더링 파이프라인
- GPU 아키텍처 (2) - 모바일 GPU와 TBDR
- Unity 렌더 파이프라인 (1) - Built-in과 URP의 구조
- Unity 렌더 파이프라인 (2) - 드로우콜과 배칭
- Unity 렌더 파이프라인 (3) - 컬링과 오클루전
- 스크립트 최적화 (1) - C# 실행과 메모리 할당
- 스크립트 최적화 (2) - Unity API와 실행 비용
- 메모리 관리 (1) - 가비지 컬렉션의 원리
- 메모리 관리 (2) - 네이티브 메모리와 에셋
- 메모리 관리 (3) - Addressables와 에셋 전략
- UI 최적화 (1) - 캔버스와 리빌드 시스템 (현재 글)
- UI 최적화 (2) - UI 최적화 전략
- 조명과 그림자 (1) - 실시간 조명과 베이크
- 조명과 그림자 (2) - 그림자와 후처리
- 셰이더 최적화 (1) - 셰이더 성능의 원리
- 셰이더 최적화 (2) - 셰이더 배리언트와 모바일 기법
- 물리 최적화 (1) - 물리 엔진의 실행 구조
- 물리 최적화 (2) - 물리 최적화 전략
- 파티클과 애니메이션 (1) - 파티클 시스템 최적화
- 파티클과 애니메이션 (2) - 애니메이션 최적화
- 프로파일링 (1) - Unity Profiler와 Frame Debugger
- 프로파일링 (2) - 모바일 프로파일링
- 모바일 전략 (1) - 발열과 배터리
- 모바일 전략 (2) - 빌드와 품질 전략