UI와 아키텍처의 중요성
게임 UI 시스템은:- 크고 복잡함
물론 예외도 있겠지만, 대부분 또는 많은 게임은 깊이감 있는 성장 시스템, 퀘스트 시스템, 스탯 시스템, 리텐션 시스템 등을 가지고 있습니다. 제 경험상 "UI 팀"은 사용자에게 보이는 UI뿐만 아니라, 계정 시스템과 클라우드 기반 데이터 저장소에 저장되는 모든 데이터를 추적하는 시스템을 담당하기도 하며, 그 데이터를 모으는 게임플레이 시스템의 후크까지 맡게됩니다.
- 휘발성
게임에 적합한 형태를 찾기 위해 게임 개발 중에도 개선과 변경 작업을 하는 경우가 많습니다.
- 뉘앙스
스타일링, 룩 앤 필, 레이아웃, 프레젠테이션이 다양한 실력과 게임 배경을 가진 사용자의 여러 의견과 결과물을 수렴하는 중 조정되거나 바뀌는 일이 많습니다.
그러므로 미리 시간을 들여 다음의 조건을 만족하는 강력한 아키텍처를 만드는 것이 좋습니다.
- 비즈니스 로직과 UI의 형태를 구분할 것
- 레이아웃과 형태를 빠르게 반복처리할 수 있을 것
- 효과적인 비즈니스 로직 디버그가 가능할 것
- 퍼포먼스가 좋을 것
이러한 목표를 만족하는 아주 효과적인 패턴을 아래 실제 예제로 알려 드리겠습니다.
예제
게임을 제작하고 있다고 칩시다. 아무거나 골라볼까요... 포트나이트가 좋겠네요. ;)아이템 상점을 다음과 같이 만들고자 합니다:
이 예제를 살펴보며 아래 항목을 알아보겠습니다.
- 클래스 아키텍처
- C++ 코드
- 블루프린트
우선방법부터 빠르게 살펴보고 보여 드린 코드에 대해 설명하면서 그 이유를 살펴보겠습니다.
클래스 아키텍처
제가 보통 추천하는 아키텍처 패턴은 다음과 같습니다.UMyData는 UObject를 상속하는 C++ 클래스입니다.
목표는 UI가 사용자에게 전달하려는 것에 대한 모든 정보를 포함한 데이터 클래스를 생성하는 것입니다.UObject로 공개(public)/비공개(private), 설정자(getter)/획득자(setter)를 통해 데이터 접근 방식을 조절할 수 있으며 유용한 API도 활용할 수 있습니다.
이 방법은 UI와 데이터의 생명주기를 분리한 접근으로 아주 이상적인 형태입니다. UI 오브젝트의 생존주기를 공유할 일이 거의없는 데이터들, 예를 들어 상점의 상품 제안, 인벤토리 아이템, 플레이어 스탯 등에 적절합니다. 게다가 같은 데이터 오브젝트를 여러 위젯이 함께 사용할 수도 있습니다. 이러한 클래스가 이미 존재할 수도 있는데, 다른 분야의 엔지니어들이 게임플레이나 스토어 백엔드 관련 작업을 하면서 만들었겠죠. 그러나 이런 때에도 UI에는 게임플레이 데이터와 UI에 필요한 데이터를 모두 포함하는 자체 데이터 클래스가 필요한 경우가 많습니다.
UMyWidget은 UUserWidget을 상속하는 C++ 클래스입니다.
이런 C++ 클래스는 블루프린트에 사용할 위젯용 API를 정의하거나 기반 시스템과 정상적으로 상호작용하기위해 블루프린트가 반드시 따라야 하는 Blueprintable 이벤트를 정의하는 용도로 사용됩니다.MyBlueprint는 UMyWidget을 상속하는 위젯 블루프린트입니다.
위젯 블루프린트에서는 보이는 모든 UI를 생성하고 레이아웃과 스타일을 만들고, UMyData와 UMyWidget이 제공한 API를 활용해 모든 UI 프리미티브(텍스트 박스, 이미지 등)을 필요한 데이터로 채웁니다. UMyWidget이 제공하는 이벤트를 확인해 해당 UI를 언제 갱신해야 알 수 있습니다. 대화식이라면 버튼 클릭에 반응하여 UMyData 혹은 UMyWidget이 제공하은 API를 호출할 수 있습니다.코드 짜기
데이터 클래스
먼저 상점에 상품 제안용으로 데이터를 만들어야 합니다.UOfferInfo는 UObject를 상속 받았습니다. 블루프린트에 노출해서 UFUNCTIONS으로 데이터에 접근할 수 있도록 하였습니다. 이번 예제에서는 비교적 간단하게 하겠지만 가격, 이미지 등이 있다고 상상하세요.
UENUM(BlueprintType)
enum class EOfferType : uint8
{
Normal,
Featured
};
UCLASS(BlueprintType)
class UOfferInfo : public UObject
{
GENERATED_UCLASS_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "OfferInfo")
FText GetName() const;
UFUNCTION(BlueprintCallable, Category = "OfferInfo")
FText GetDescription() const;
UFUNCTION(BlueprintCallable, Category = "OfferInfo")
EOfferType GetOfferType() const;
private:
// UProperties를 포함한 모든 데이터
};
상점 위젯
다음으로 상점 위젯을 살펴보겠습니다. 모든 상품 제안이 표시될 스크린의 인터페이스와 동작을 제공하는 역할을 하죠. 각 제안의 상세한 정보를 보여주는 것은 UOfferWidgetBase UserWidget에 맡기도록 하겠습니다. 그 부분은 나중에 다시 살펴보죠.// 이 클래스를 베이스로 블루프린트 클래스를 만들기를 바라기 때문에 추상 클래스로 선언합니다
// 사용자가 베이스 클래스를 직접 인스턴싱하면 안 됩니다
UCLASS(Abstract, Blueprintable, BlueprintType, ClassGroup = UI)
class UOfferShopWidgetBase : public UUserWidget
{
GENERATED_UCLASS_BODY()
public:
// 제안을 읽기 시작했다고 사용자에게 알리는 함수입니다
// 블루프린트로 데이터 다운로드 중이라는 아이콘과 텍스트를 표시하는데 사용합니다
UFUNCTION(BlueprintImplementableEvent, Category = "OfferShopWidget")
void OnStartReadingOffers();
// 특정 제안에 사용할 UI를 생성하겠다고 블루프린트에게 전달합니다
// 블루프린트는 제안 위젯을 만들고 UOfferInfo*가 처리되도록 전달합니다
// 이런 API는 블루프린트로 레이아웃 전체를 제어하도록 하는 게 좋습니다
// 현재는 세로 박스를 쓰는 디자인이다음 주에는 가로 스크롤이 될 수도 있습니다
// 아니면 타일 그리드가 되던지요... 뭐든지 될 수 있습니다 ;)
UFUNCTION(BlueprintImplementableEvent, Category = "OfferShopWidget")
void GenerateOffer(UOfferInfo* OfferData);
// 제안 생성을 완료했다고 사용자에게 알리는 함수입니다
// 블루프린트로 로딩 표시를 숨기고 화면을 준비합니다
UFUNCTION(BlueprintImplementableEvent, Category = "OfferShopWidget")
void OnOfferGenerationCompleted();
// 블루프린트가 사용할 모든 데이터를 얻는 유틸리티 함수입니다
UFUNCTION(BlueprintCallable, Category = "OfferShopWidget")
FDateTime GetStoreRefreshDate() const;
protected:
// 모든 내부 함수는 최소한 protected로 선언하세요
private:
// 추가 내부 함수
// UProperties를 포함한 모든 데이터!
UPROPERTY(transient)
TArray<UOfferInfo*>CurrentOffers;
};
제안 위젯
마지막으로 개별 상품 제안 위젯들의 베이스 C++ 클래스를 살펴보겠습니다.목업 이미지와 같은 형태를 위해서는, 다른 타일이 사용된 레이아웃이 다른 블루프린트 2개로 파생되어야 합니다.
// 이 클래스를 베이스로 블루프린트 클래스를 만들기를 바라기 때문에 추상 클래스로 선언합니다
// 사용자가 베이스 클래스를 직접 인스턴싱스하면 안 됩니다
UCLASS(Abstract, Blueprintable, BlueprintType, ClassGroup = UI)
class UOfferWidgetBase : public UUserWidget
{
GENERATED_UCLASS_BODY()
public:
// 위젯의 새 인스턴스를 만들고 나서 호출해야 하는 함수입니다// 이 패턴에서는 다른 것을 표시하고 싶을 경우 SetupOffer를 다시 호출하면 됩니다
// 완전히 새로운 위젯을 만들지 않고도 인벤토리나 UI의 속도를 높이기 위해
// 위젯을 유지하거나 재사용할 수 있어 퍼포먼스에 중요합니다
UFUNCTION(BlueprintCallable, Category = "OfferWidget")
void SetupOffer(UOfferInfo* InOfferData)
{
// BluprintNativeEvents를 사용하지 않는 이유를 설명하려고 구현한 부분입니다
// 주된 이유는 잘못된 위치에서 부모 함수를 호출하거나 아예 호출하지 않는
// 사용자 오류가 발생할 수 있어서입니다.
// BlueprintImplementableEvent가 사용자 경험 측면에서도 더 단순합니다.
OfferData = InOfferData;
OnOfferSet();
}
// 보여줄 데이터를 받았다고 블루프린트에 알려줍니다
// 블루프린트는 순서대로 GetOfferInfo(), GetName, GetDescription 등을 호출해
// 텍스트 필드, 텍스처 등을 채울 것입니다
UFUNCTION(BlueprintImplementableEvent, Category = "OfferWidget")
void OnOfferSet();
// 보여줄 데이터를 받았다고 블루프린트에 알려줍니다
// 블루프린트는 순서대로 GetOfferInfo(), GetName, GetDescription 등을 호출해
// 텍스트 필드, 텍스처 등을 채울 것입니다
UFUNCTION(BlueprintImplementableEvent, Category = "OfferWidget")
void OnOfferSet();
// 블루프린트가 사용할 모든 데이터를 얻는 유틸리티 함수입니다
UFUNCTION(BlueprintCallable, Category = "OfferWidget")
UOfferInfo* GetOfferInfo() const;
private:
UPROPERTY(transient)UOfferInfo* OfferData;
};
블루프린트 하기
UOfferShopWidgetBase에서 파생된 UMG 위젯을 만들고 런타임에 채울 데이터가 들어갈 레이아웃을 만들 시간입니다.- FeaturedOffers_HorBox
- 화면 왼쪽의 반을 차지하며 추천 상품을 표시할 자리입니다.
- NormalOffers_WrapBox
- 화면 오른쪽의 반을 차지하며 일반 상품을 표시할 자리입니다.
- RefreshTime_TextBlock
- 새로고침 타이머가 들어갈 텍스트 블록입니다.
다음은 그래프입니다.
위젯을 생성하여 적절한 컨테이너에 넣는 일은 블루프린트가 합니다. 이 덕분에 다음 작업을 할 수 있습니다.
- 레이아웃을 쉽게 변경
- 다른 상황에서 쓰인 위젯 변경
- “Add Child to…” 호출에서 반환된 슬롯의 프로퍼티를 수정
이제 제안 위젯을 만들겠습니다. C++에서 만든 UOfferWidgetBase 클래스에서 파생되는 UMG 위젯 2개를 만들겠습니다. 이를 OfferTileSmallWidget과 OfferTileLargeWidget이라고 하겠습니다. 위에서 말한 상점 위젯 블루프린트에 있는 GenerateOffer 이벤트는 데이터에 저장된 타입 정보를 기반으로 위젯 2개를 생성합니다.
다음으로 가장 위에 있는 “목업” 이미지와 비슷하도록 작은 위젯과 큰 위젯을 구성한 후 그래프를 만들 수 있는데, 가장 단순한 양식으로 표현하자면 다음과 같습니다.
OfferTileSmallWidget과 OfferTileLargeWidget을 생성해 상점 화면을 만들었지만, 이 아키텍처를 이용하면 이후 과정은 더 쉽습니다. 이제 게임의 다른 화면에서 특가 상품(hot offer)을 보여주고 싶다고 해보죠!
사실은 2단계만 거치면 됩니다.
1. C++에서 특가 상품용 함수를 만들면 다음처럼 되겠죠.
UOfferInfo* MyLibrary::GetTheHotOffer()
UOfferInfo* MyLibrary::GetTheHotOffer()
OfferInfo 데이터 클래스의 인스턴스를 반환할 뿐입니다.
2. 이미 있는 위젯 둘 중 하나를 화면에 넣고 GetTheHotOffer()를 반환하는 SetupOffer()를 호출하면 됩니다.
또는 불 이펙트가 들어간 “HotOfferWidget”라는 새로운 블루프린트를 만들어 넣고 SetupOffer()를 호출하는 방법도 있습니다.
또는 불 이펙트가 들어간 “HotOfferWidget”라는 새로운 블루프린트를 만들어 넣고 SetupOffer()를 호출하는 방법도 있습니다.
결론
이 접근 방법은 비즈니스 로직을 C++에 유지해 관리, 디버그, 수정하기가 쉽습니다. 강력한 이벤트 기반 API를 블루프린트에 노출해 원하는 UX와 시각 경험을 만들 수 있습니다.블루프린트 틱, 프로퍼티 바인딩에 지나친 애니메이션까지 어느정도 피할 수 있죠. 이 접근 방식은 시스템과 스테이트의 중요한 에지를 노출하는 이벤트 세트를 제공하는 거나 다름없습니다. 이벤트가 있으니 블루프린트 틱과 프로퍼티 바인딩의 효과가 자연스레 떨어지는 거죠.
애니메이션은 유용하긴 해도 비용이 높아서 신중하게 사용해야 합니다. 특히 UI 예산이 마치 0이나 다름없는 HUD와 같이 퍼포먼스가 중요할 때는 더욱 그렇죠. UI 작업을 해보셨다면 잘 알 겁니다!