본문 바로가기

동적 UI 요소 생성 및 기본 관리

런타임에 위젯을 생성해 패널에 추가하고 데이터 변경에 맞춰 갱신·제거하는 동적 UI 관리 흐름을 익힙니다.

앞 글의 "게임 시작" 버튼은 디자이너에서 미리 배치해 두면 충분했습니다. 하지만 인벤토리 창은 다릅니다. 플레이어가 몇 개의 아이템을 갖게 될지, 각 칸에 어떤 아이템이 들어갈지는 게임을 하는 동안 계속 바뀝니다. 슬롯 백 개를 미리 디자이너에 놓아 두고 숨겼다 보였다 하는 방식으로는 개수가 바뀔 때마다 위젯 블루프린트를 고쳐야 하고, 쓰지 않는 칸까지 모두 만들어 두게 됩니다.

그래서 실제 게임의 UI 상당 부분은 게임 중에 만들어집니다. 이 글에서는 아이템 슬롯 하나를 그리는 위젯을 템플릿으로 만들고, 인벤토리 배열이 바뀔 때마다 그 위젯을 만들어 Grid Panel에 채우는 방법을 구현합니다. 배열의 순서를 화면의 칸 좌표로 바꾸는 계산과, 화면을 다시 그릴 때 자주 생기는 실수도 함께 살펴봅니다.


게임 중에 만들어지는 UI

동적 UI 요소 생성은 위젯 블루프린트에 위젯을 고정해 배치하는 대신, 블루프린트 로직으로 게임 실행 중에 위젯을 만들어 화면에 추가하는 방식입니다. 인벤토리를 예로 들면, 슬롯의 수가 정해져 있더라도 각 슬롯에 어떤 아이템이 들어갈지는 계속 바뀝니다. 획득하는 아이템의 종류와 개수도 미리 정해져 있지 않으므로, 아이템을 얻을 때마다 그 아이템의 아이콘과 이름이 인벤토리 화면에 추가되어야 합니다.

이 방식은 몇 가지 이유로 쓰입니다. 인벤토리, 체력, 점수, 퀘스트 목록처럼 실행 중에 바뀌는 데이터를 화면에 그대로 반영할 수 있고, 아이템 슬롯 위젯 하나를 만들어 두고 필요한 만큼 여러 번 생성해 재사용할 수 있습니다. 가능한 모든 UI 요소를 미리 만들어 두지 않고 지금 필요한 요소만 만들므로 메모리도 아낄 수 있습니다. 만든 위젯을 Wrap Box나 Vertical Box 같은 패널에 넣으면 개수에 따라 배치가 알아서 바뀌고, Grid Panel에 넣을 때는 각 위젯이 들어갈 행과 열을 정해 줍니다.

동적 UI가 안정적으로 동작하려면 네 단계가 한 흐름으로 이어져야 합니다. 데이터가 바뀌었다는 이벤트, 위젯 생성, 패널에 추가, 그리고 만든 위젯에 값을 넣는 갱신입니다. 이 가운데 하나라도 빠지면 슬롯이 늘지 않거나, 늘었는데 비어 있게 됩니다.


슬롯 하나를 그리는 위젯 만들기

먼저 게임 중에 반복해서 만들 개별 UI 요소의 템플릿을 만듭니다. 콘텐츠 브라우저에서 User Interface 하위 메뉴의 Widget Blueprint를 선택하고, 부모 클래스로 User Widget을 골라 이름을 WBP_InventorySlot으로 짓습니다.

WBP_InventorySlot을 열고 아이템 아이콘을 표시할 Image와 이름을 표시할 Text를 배치합니다. 두 위젯을 각각 선택해 디테일 패널에서 Is Variable을 켜고, 이름을 ItemIcon과 ItemNameText로 정합니다. 5장의 F_ItemData에 아이콘 필드가 없다면 이번에 ItemIcon(Texture 2D Soft Object Reference) 멤버를 추가하고, 데이터 테이블의 각 행에 사용할 텍스처를 지정해 둡니다.

다음으로 이 슬롯이 아이템 데이터를 받아 스스로 표시를 바꾸는 함수를 만듭니다. WBP_InventorySlot의 그래프에서 F_ItemData를 입력으로 받는 UpdateSlot 함수를 추가하고, 그 안에서 ItemNameText의 Set Text로 이름을 넣고, ItemIcon의 Set Brush from Soft Texture에 아이콘의 소프트 참조를 넘깁니다. 아이콘은 소프트 참조이므로 하드 텍스처를 받는 입력에 그대로 꽂지 않고, 소프트 텍스처를 받는 이 노드를 씁니다. 컴파일하고 저장합니다.


인벤토리 화면에서 슬롯을 만들어 채우기

이제 슬롯들을 담을 인벤토리 화면 WBP_InventoryScreen을 새 위젯 블루프린트로 만듭니다. 디자이너 탭에서 루트 Canvas Panel 안에 Grid Panel을 배치하고, Is Variable을 켜 이름을 InventoryGrid로 정합니다. 게임 중에 만든 슬롯들은 이 그리드에 추가됩니다.

그래프 탭에서는 인벤토리 배열을 받아 화면을 새로 그리는 RefreshInventoryUI 커스텀 이벤트를 만듭니다. 이 이벤트는 플레이어의 인벤토리 배열(F_ItemData의 배열)이 바뀔 때마다 호출하고, 화면을 처음 열 때도 호출해 초기 상태를 그립니다. 노드의 흐름을 글로 적으면 다음과 같습니다.

// WBP_InventoryScreen의 그래프 탭

// Custom Event: RefreshInventoryUI
// Input: PlayerInventory (Array of F_ItemData)
// ----------------------------------------------------
// Clear Children of InventoryGrid (기존 슬롯 모두 제거)
// For Each Loop (PlayerInventory 배열 순회)
//    Create Widget (Class: WBP_InventorySlot, Owning Player: Get Owning Player)
//    Add Child to Grid (Target: InventoryGrid, Content: Create Widget Return Value)
//       Row = Index / 5 (정수 나눗셈), Column = Index % 5
//
//    // 새로 생성된 슬롯 위젯에 아이템 데이터 적용
//    // Create Widget Return Value에서 바로 UpdateSlot 호출
//    //    (WBP_InventorySlot에 미리 정의한 함수)
//    //    Input: F_ItemData (For Each Loop의 Array Element)

이 흐름을 노드로 옮겨 봅니다. 먼저 InventoryGrid 변수에서 끌어 Clear Children을 호출해, 그리드에 이미 있던 자식 위젯을 모두 제거합니다. 그다음 For Each Loop로 PlayerInventory 배열의 아이템을 하나씩 돌며, 반복마다 Create Widget의 Class에 WBP_InventorySlot을 고르고 Owning Player에는 Get Owning Player를 연결해 슬롯을 만듭니다.

만든 슬롯은 Add Child to Grid로 그리드에 넣습니다. Target에는 InventoryGrid를, Content에는 Create Widget의 Return Value를 연결하고, 행과 열 입력에는 그 아이템이 들어갈 칸을 계산해 넣습니다. 5열 그리드라면 행은 Index / 5의 정수 몫, 열은 Index % 5의 나머지입니다. 행과 열을 모두 기본값 0으로 두면 모든 슬롯이 같은 칸에 겹쳐 하나만 보입니다.

마지막으로 가장 중요한 단계는 만든 슬롯에 실제 아이템 데이터를 넘기는 것입니다. Create Widget의 Return Value는 Class에 고른 WBP_InventorySlot 타입으로 나오므로, 캐스트를 거치지 않고 이 핀에서 바로 UpdateSlot을 호출해 For Each Loop의 Array Element를 연결합니다. 이 단계를 빠뜨리면 슬롯의 개수는 맞는데 이름과 아이콘이 모두 비어 있는 화면이 나옵니다.


배열 Index를 칸 좌표로 바꾸기

ARRAY INDEX · GRID ROW / COLUMN

5열 Grid의 여섯 번째 아이템은 다음 행의 첫 칸입니다

인벤토리 배열에 아이템 6개가 있는 예입니다. 칸 안 숫자는 배열 Index, 위 숫자는 Column, 왼쪽 숫자는 Row입니다. 행과 열은 0부터 시작합니다.

Index 0부터 5까지의 5열 배치첫 행 Row0의 Column0부터4에 Index0부터4가 들어간다. Index5는 두 번째 행 Row1의 Column0에 배치된다. 나머지 네 칸은 아직 비어 있다.0123401012345
Index 0부터 5까지의 같은5열배치첫행 Row0의Column0부터4에Index0부터4를놓는다.Index5는Row1/Column0이며나머지4칸은빈칸이다.모바일에서도열수5와행열방향은그대로유지한다.0051122334401

빈 칸은 아직 아이템이 없는 위치입니다. 각 배열 요소의 새 슬롯을 해당 Row·Column으로 추가합니다. Index 4는 정수 몫 Row=4/5=0·나머지 Column=4%5=4이고, Index 5는 Row=5/5=1·Column=5%5=0입니다.

배치 좌표와 표시 데이터는 둘 다 필요합니다

모든 Row·Column을 0으로 두면 슬롯이 같은 칸에 겹칩니다. Add Child to Grid 뒤 각 슬롯의 UpdateSlot에 해당 Array Element를 전달해 이름·아이콘도 갱신합니다.

Clear Children은 기존 화면의 자식 위젯을 제거합니다. 인벤토리 배열을 비우는 동작과 구분합니다.

위 그림은 인벤토리 배열에 아이템 6개가 있을 때 5열 그리드에 어떻게 놓이는지 보여 줍니다. 칸 안의 숫자는 배열 Index, 위쪽 숫자는 열(Column), 왼쪽 숫자는 행(Row)이며, 행과 열은 모두 0부터 셉니다. 첫 행 Row 0의 Column 0부터 4까지에 Index 0부터 4가 들어가고, 여섯 번째 아이템인 Index 5는 다음 행 Row 1의 Column 0에 놓이며, 나머지 네 칸은 아직 아이템이 없어 비어 있습니다.

그림 아래의 계산처럼 Index 4는 정수 몫 Row = 4 / 5 = 0, 나머지 Column = 4 % 5 = 4이고, Index 5는 Row = 5 / 5 = 1, Column = 5 % 5 = 0입니다. 그림은 배치 좌표와 표시 데이터가 둘 다 필요하다는 점도 짚습니다. 모든 행과 열을 0으로 두면 슬롯이 같은 칸에 겹치고, 칸을 맞게 넣었더라도 각 슬롯의 UpdateSlot에 해당 Array Element를 넘겨야 이름과 아이콘이 표시됩니다. 또 Clear Children은 화면에 있던 자식 위젯을 지우는 것이지 인벤토리 배열을 비우는 동작이 아니라는 점을 구분합니다.


아이템을 주웠을 때 화면 갱신하기

인벤토리 화면은 로컬 BP_PlayerController에서 만듭니다. Create Widget으로 WBP_InventoryScreen을 만든 뒤 반환 값을 InventoryScreenRef 변수에 저장하고, 화면에 추가합니다. 나중에 화면을 갱신할 때는 위젯을 새로 찾지 않고 이 참조를 씁니다.

플레이어가 아이템을 획득했을 때, 예를 들어 아이템 액터와 겹쳤을 때는 먼저 인벤토리 배열에 아이템을 추가하고, 그 뒤에 RefreshInventoryUI를 호출해 화면을 갱신합니다.

// BP_PlayerCharacter의 그래프

// Event PickedUpItem (Custom Event, Input: ItemData F_ItemData)
// -----------------------------------------------------------------
// Add ItemData to PlayerInventory Array
//
// Get Controller → Cast To BP_PlayerController → Get InventoryScreenRef (컨트롤러가 Create Widget 결과를 저장한 참조)
// Is Valid (InventoryScreenRef)
// Call RefreshInventoryUI (유효한 참조에서 호출)
//    Input: PlayerInventory (PlayerCharacter의 인벤토리 배열)

이 흐름을 한 줄로 정리하면, Event PickedUpItem(ItemData) 이벤트가 들어오면 아이템을 PlayerInventory에 추가하고, 컨트롤러를 BP_PlayerController로 캐스트해 읽은 InventoryScreenRef가 유효한지 Is Valid로 확인한 뒤 RefreshInventoryUI를 호출하며, 그 안에서 Clear Children, Create Widget, Add Child to Grid, UpdateSlot이 차례로 실행됩니다. 결과적으로 화면의 슬롯 개수와 순서가 인벤토리 배열에 맞춰 바뀝니다. 다만 아이콘은 소프트 참조를 불러온 뒤에 표시되므로, 이름보다 조금 늦게 나타날 수 있습니다.

아직 화면을 만들지 않아 InventoryScreenRef가 유효하지 않을 때는 데이터만 배열에 보관해 둡니다. 화면을 여는 순간 최신 배열로 RefreshInventoryUI를 호출하면 그동안 주운 아이템이 모두 표시됩니다.


동적 UI를 관리하는 원칙

게임 중에 만들어지는 요소는 아이템 슬롯이든 퀘스트 항목이든 각각 자기 위젯 블루프린트로 만들어 템플릿으로 씁니다. 만든 위젯을 담을 때는 Vertical Box, Horizontal Box, Wrap Box 같은 패널 위젯을 쓰면 자식이 추가되는 대로 차례로 배치해 주고, Grid Panel을 쓰면 각 자식이 들어갈 행과 열을 직접 넣어 줍니다. 만든 자식 위젯에는 UpdateSlot처럼 데이터를 받는 함수를 두어, 위젯이 받은 데이터로 스스로 표시를 바꾸게 합니다.

화면을 갱신하기 전에 Clear Children으로 기존 자식 위젯을 지우고 새로 그리는 방식은 단순하고 흔하게 쓰입니다. 이전 상태가 남지 않고 중복 생성도 막을 수 있기 때문입니다. 다만 위젯을 아주 많이 만들고 지워야 한다면 매번 모두 다시 만드는 비용이 커지므로, 화면에 보이는 항목만 위젯으로 만드는 가상화(virtualization)를 지원하는 List View나 Tile View를 검토합니다. 이는 이 글의 범위를 넘는 고급 주제입니다.

결국 동적 UI는 데이터가 몇 개인지, 언제 만들어지는지, 한 번 갱신할 때 비용이 얼마인지를 기준으로 관리해야 합니다. 목록, 인벤토리, 상태 표시처럼 반복해서 만들어지는 위젯부터 이 기준으로 점검해 봅니다.

더 깊이: List View는 어떻게 위젯 수를 줄이나

List View는 아이템(item)과 엔트리(entry)를 구분합니다. UListView API 레퍼런스에 따르면 목록은 아이템 n개를 가지지만, 엔트리 위젯은 화면에 들어갈 만큼만 만듭니다. 문서의 예처럼 아이템 200개 가운데 5개가 보이는 스크롤 목록이라면 엔트리 위젯도 5개만 만들어집니다. 이 글의 Grid Panel 방식이 아이템 하나마다 위젯 하나를 만드는 것과 다른 점입니다.

그래서 List View의 아이템은 위젯이 아니라 데이터를 담은 오브젝트이고, 엔트리로 쓸 위젯 블루프린트는 UserObjectListEntry 인터페이스를 구현해야 합니다. 엔트리는 이 인터페이스의 On List Item Object Set 이벤트로 지금 자신이 나타낼 아이템 오브젝트를 넘겨받으며, 이 이벤트는 엔트리에 새 아이템이 배정될 때마다 호출됩니다. 스크롤하는 동안 같은 엔트리가 다른 아이템을 나타낼 수 있으므로, 표시는 매번 이 이벤트에서 다시 그립니다. 엔트리 위젯 안에 특정 아이템의 상태를 따로 보관해 두면, 다른 아이템을 나타내게 된 뒤에도 그 상태가 남아 엉뚱하게 보일 수 있습니다.


화면이 기대와 다를 때

동적 UI에서 자주 보는 증상과 원인, 고치는 방법은 다음과 같습니다.

증상원인고치는 방법
아이템을 주워도 슬롯이 늘지 않음RefreshInventoryUI를 호출하지 않았거나, 저장한 InventoryScreenRef가 유효하지 않음아이템 획득 이벤트의 마지막에 RefreshInventoryUI를 연결하고, 위젯을 만들 때 참조를 저장한 뒤 그 참조로 갱신합니다. 화면을 아직 만들지 않았다면 데이터만 보관하고 화면을 열 때 최신 배열을 넘깁니다.
갱신할 때마다 슬롯이 중복되어 계속 늘어남갱신 전에 Clear Children을 빠뜨림RefreshInventoryUI의 시작 바로 다음에 InventoryGrid의 Clear Children을 둡니다.
슬롯은 보이는데 아이콘과 이름이 비어 있음UpdateSlot을 호출하지 않았거나, 슬롯 안의 위젯을 변수로 만들지 않음Create Widget의 반환 값에서 UpdateSlot을 호출하는지 확인하고, WBP_InventorySlot 안의 Image와 Text에 Is Variable이 켜져 있는지 다시 봅니다.
슬롯이 하나만 보임행과 열을 모두 0으로 넣어 같은 칸에 겹침Index / 5와 Index % 5로 칸 좌표를 계산해 넣습니다.

정리

  • 동적 UI는 게임 중에 위젯을 만들어 패널에 추가하는 방식이며, 데이터 변경 이벤트, 위젯 생성, 패널 추가, 값 갱신이 한 흐름으로 이어져야 합니다.
  • 반복해서 만들 요소는 WBP_InventorySlot 같은 템플릿 위젯으로 만들고, 데이터를 받아 표시를 바꾸는 함수를 둡니다.
  • Create Widget의 반환 값은 고른 클래스 타입이므로 캐스트 없이 그 위젯의 함수를 호출할 수 있습니다.
  • 5열 그리드에서 배열 Index의 행은 Index / 5의 정수 몫, 열은 Index % 5의 나머지입니다.
  • Clear Children은 화면의 자식 위젯만 지우고 데이터 배열은 건드리지 않으며, 위젯이 아주 많다면 List View나 Tile View의 가상화를 검토합니다.

연습 문제

1. 일곱 번째 아이템의 칸과 자식 수

5열 그리드에 Index 0부터 6까지 아이템 7개를 차례로 표시합니다. 마지막 아이템의 행과 열은 얼마일까요? 또 이 7개 화면을 최신 배열의 아이템 7개로 다시 그릴 때 Clear Children을 빠뜨리고 7개를 그대로 추가하면, 그리드의 자식 위젯 수는 얼마가 될까요? 기존 슬롯을 재사용하거나 따로 제거하는 처리는 없습니다.

해설 보기

Index 6의 행은 6 / 5의 정수 몫인 1이고, 열은 6 % 5의 나머지인 1입니다. 그래서 일곱 번째 아이템은 둘째 행의 둘째 칸에 놓입니다.

Clear Children 없이 다시 추가하면 기존 7개가 남은 채 새 7개가 더해지므로 자식 수는 7 + 7 = 14입니다. 새 슬롯이 기존 슬롯과 같은 행과 열을 받으면 그중 일부가 겹쳐 보여, 눈에 보이는 칸 수와 실제 자식 수가 다를 수 있습니다. 또 화면의 자식을 지우는 일은 PlayerInventory 배열을 지우는 일과 다릅니다.

2. 인벤토리를 비우려고 Clear Children을 호출함

"인벤토리 비우기" 버튼에 InventoryGrid의 Clear Children만 연결했습니다. 버튼을 누르면 화면이 비지만, 다음 아이템을 줍자 이전 아이템들이 다시 모두 나타났습니다. 왜 그럴까요?

해설 보기

Clear Children은 화면에 있던 슬롯 위젯만 지웠고, 데이터인 PlayerInventory 배열은 그대로 남아 있었기 때문입니다. 다음 아이템을 주우면 RefreshInventoryUI가 남아 있던 배열 전체로 화면을 다시 그리므로 이전 아이템이 모두 다시 나타납니다.

인벤토리를 정말 비우려면 PlayerInventory 배열을 Clear로 비운 뒤 RefreshInventoryUI를 호출해야 합니다. 화면은 데이터를 보여 주는 결과일 뿐이므로, 상태를 바꿀 때는 데이터를 먼저 바꾸고 화면을 그 데이터에 맞춰 다시 그립니다.

3. 슬롯 개수는 맞는데 모두 비어 있음

아이템 세 개를 주웠더니 그리드에 슬롯이 세 개 생겼지만, 아이콘과 이름이 모두 비어 있습니다. WBP_InventorySlot의 UpdateSlot 함수는 정상으로 만들어 두었습니다. 무엇부터 확인해야 할까요?

해설 보기

먼저 RefreshInventoryUI의 반복 안에서 Create Widget의 반환 값으로 UpdateSlot을 실제로 호출하고 있는지, 그 입력에 For Each Loop의 Array Element를 연결했는지 확인합니다. 슬롯 개수가 맞다는 것은 생성과 추가는 동작했다는 뜻이므로, 남은 단계는 값을 넘기는 부분입니다.

호출이 정상이라면 WBP_InventorySlot 안의 ItemIcon과 ItemNameText에 Is Variable이 켜져 있는지, UpdateSlot 안에서 그 위젯들을 Target으로 쓰고 있는지 봅니다. 이름은 나오는데 아이콘만 비어 있다면 데이터 테이블 행에 아이콘 텍스처가 지정되어 있는지도 확인합니다.

위젯을 만들어 화면에 추가하고 제거하는 기본 노드는 언리얼 엔진 공식 문서의 위젯 생성에서 확인할 수 있습니다.

다음 글 UI 애니메이션 구현에서는 위젯 블루프린트의 애니메이션 기능으로 UI가 나타나고 사라지는 움직임을 만들고, 그래프에서 재생을 제어하는 방법을 다룹니다.