[CommonUI] UIMapDataAsset, UIManageSubsystem, HUD (GameplayTag 쓰는 방식)

김여울·2025년 11월 19일

내일배움캠프

목록 보기
117/139

호영 튜터님께서 만들어주신 프로젝트 공부하기

구조

CommonUI_Test/
├── Config/
│   ├── DefaultEditor.ini
│   ├── DefaultEditorPerProjectUserSettings.ini
│   ├── DefaultEngine.ini
│   ├── DefaultGame.ini
│   ├── DefaultGameplayTags.ini - 태그 추가
│   └── DefaultInput.ini
│
└── Source/
    └── CommonUI_Test/
        ├── DataAssets/
        │   ├── UIMapDataAsset.cpp
        │   └── UIMapDataAsset.h - 에디터에서 데이터만 관리하는 에셋으로 사용됨
        │
        ├── UI/
        │   ├── Subsystem/
        │   │   ├── UIManageSubsystem.cpp
        │   │   └── UIManageSubsystem.h
        │   │
        │   ├── PrimaryLayout.cpp
        │   └── PrimaryLayout.h
        │
        ├── CommonUI_Test.cpp
        ├── CommonUI_Test.h
        │
        ├── CommonUI_Test.Build.cs
        │
        ├── CommonUI_TestCharacter.cpp
        ├── CommonUI_TestCharacter.h
        │
        ├── CommonUI_TestGameMode.cpp
        └── CommonUI_TestGameMode.h
        │
        ├── MyHUD.cpp
        ├── MyHUD.h
        │
        ├── MyPlayerController.cpp
        └── MyPlayerController.h
        │
        ├── CommonUI_Test.Target.cs
        └── CommonUI_TestEditor.Target.cs
  • DefaultGameplayTags.ini

    • 태그 추가
    • “요청한 UI를 어떤 위젯으로, 어떤 레이어에서 열지”를 정의하는 구조체
  • UIMapDataAsset.h

    • 에디터에서 데이터만 관리하는 에셋으로 사용됨
    • GameplayTag → UI 정책(struct)의 매핑 테이블
  • UIManageSubsystem.h

    • 게임 전체에서 공통으로 UI를 열고 닫는 중앙 컨트롤러 역할
    • UI 요청 태그 → UI 위젯 생성 → 적절한 레이어에 배치
    • 모든 UI 열기 로직을 관리하는 게임 전체 단위 UI 컨트롤러
  • MyHUD


흐름

[UI Request (GameplayTag)]
        |
        v
 --------------------------
|  UUIManageSubsystem      |
|                          |
|  - UIMapDataAsset        |
|  - CachedWidgetMap       |
|  - RootLayout            |
 --------------------------
        |
        v
[PrimaryLayout] -- contains --> [Multiple Layers]
        |
        v
[Spawn Widget on that Layer]

UIMapDataAsset

“요청한 UI를 어떤 위젯으로, 어떤 레이어에서 열지”를 정의하는 구조체

구분USTRUCT (구조체)UCLASS (클래스)
기본 접근 지정자publicprivate
상속 시 기본 접근publicprivate
특징경량 데이터 묶음 (Value Type)UObject 기반 오브젝트 (LifeCycle)
복사값 복사 가능복사 불가 (포인터/레퍼런스 기반)
엔진 관리GC 대상 아님 (가벼움)GC 관리 대상, 엔진 시스템과 완전 연동
상속불가능가능 (이벤트/가상함수/오버라이드)
주 용도스탯/설정 값/정책/DT RowActor, Component, Widget, Subsystem, DataAsset,
GameMode, HUD, UObject 기반 오브젝트 등
  • UObject 기반의 클래스는 복사 불가능
    • UObject는 Garbage Collection으로 관리됨
    • 고유한 ObjectID/Outer/Flags 등을 가짐
    • 복사 생성자가 삭제된 상태 → “값(value)”이 아니라 “엔진 시스템 내부에서 관리하는 객체”라서 복사 자체가 불가능

USTRUCT(BlueprintType)
struct FUIWidgetPolicy
{
	GENERATED_BODY()

	// 1. 무엇을
	//TODO 소프트 클래스 포인터 찾아보고 공부할 것! (<=> TSubclassOf, 하드 포인터)
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI Policy")
	TSoftClassPtr<UCommonActivatableWidget> WidgetClass;

	// 2. 어디에 
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI Policy", meta = (Categories = "UI.Layer"))
	FGameplayTag TargetLayer;

};

항목타입기능
WidgetClassTSoftClassPtr<UCommonActivatableWidget>열고 싶은 위젯의 소프트 레퍼런스
TargetLayerFGameplayTag위젯을 어떤 레이어(UI.Layer.*)에 붙일지

UPROPERTY(EditDefaultsOnly, Category = "UI Policy", meta = (Categories = "UI.Request"))
TMap<FGameplayTag, FUIWidgetPolicy> WidgetMap;
  • UI 요청 → 실제로 어떤 위젯을 열지 결정하는 매핑 테이블 패턴
  • UIMapDataAsset = “UI 요청(tag)을 → 실제 UI 위젯・레이어로 변환하는 데이터 테이블(DataAsset)”
  • UI를 열어달라는 GameplayTag 요청을 받으면,
    그 태그에 해당하는 UI 위젯 클래스와 UI 레이어 정보를 찾아주는 매핑용 데이터 에셋.

소프트 클래스 포인터

용어실체UE 공식 의미
하드 포인터UClass*, UCommonActivatableWidget*참조하는 객체가 반드시 메모리에 로드됨
TSubclassOf클래스 타입 제한 포인터
(하드 레퍼런스)“이 클래스 또는 자식 클래스만 허용”
TSoftClassPtr*소프트 레퍼런스
(Soft Object Path)*실제 로드 없이 경로만 저장, 필요할 때 로드

종류저장하는 대상타입 제한용도
하드 포인터(UClass*)어떤 클래스든 UClass 포인터❌ 제한 없음런타임에 특정 클래스를 가리키기
TSubclassOfT 또는 T의 자식만 가리킴✔ 타입 제한 있음“T로 생성 가능한 클래스만 허용”

타입로딩 시점종속성메모리
하드 포인터즉시강한 의존성높음
TSubclassOf즉시강한 의존성중간
TSoftClassPtr필요할 때약한 의존성매우 적음

1. 하드 포인터 (Hard Reference)

UPROPERTY()
UCommonActivatableWidget* Widget;

특징

  • 참조된 객체가 반드시 로드되어야 함
  • 의존성 강함
  • 가비지 컬렉터가 이 객체를 반드시 유지
  • 메모리 용량 증가

사용 예시

  • 이미 로드된 객체를 직접 조작할 때
  • 컴포넌트, Actor 간 직접 참조


2. TSubclassOf (하드 레퍼런스 + 타입 제한)

UPROPERTY(EditAnywhere)
TSubclassOf<UCommonActivatableWidget> WidgetClass;

특징

  • 클래스 타입만 저장
  • 하드 레퍼런스 → 참조하는 클래스(블루프린트) 패키지가 항상 로드됨
  • 타입 검사 O (해당 부모 클래스의 자식만 허용)

사용 예시

  • ActorSpawn 시 스폰할 클래스를 고를 때
  • UI 위젯을 생성할 때 - 로딩 비용 크게 중요하지 않을 때


3. TSoftClassPtr (소프트 래퍼런스 + Lazy Loading)

UPROPERTY(EditAnywhere)
TSoftClassPtr<UCommonActivatableWidget> WidgetClass;

특징

  • 패키지를 즉시 로드하지 않음
  • 경로만 저장됨
  • 필요할 때만 LoadSynchronous() 또는 Streamer 로딩
  • 메모리 절약됨
  • 의존성이 Hard Reference로 묶이지 않음 → 패키지간 결합도 ↓
  • UI, 맵 간 전환에 적극적으로 권장되는 방식

사용 예시

  • UI Navigation Map
  • 맵에 따라 다른 UI를 조건부 로드
  • 대형 프로젝트에서 로딩 최적화
  • 런처/메인 메뉴 같은 구간


UIMapDataAsset 에 TSoftClassPtr을 써야 하는 이유

  1. UI는 처음부터 전부 로드할 필요 없음

    메인 메뉴, HUD, 팝업 등 모두 한 번에 메모리에 올릴 이유 없음

  2. 레벨이 바뀔 때 로딩 시간 줄임

    SoftClass는 필요한 레벨에서만 로드하도록 설계

  3. DataAsset에 Hard Reference 넣었다면

    → DataAsset이 참조하는 모든 BP UI가 항상 로드됨
    → 옵션UI, 상점UI, 인벤UI까지 전부 로드됨
    → 프레임 드랍 + 로딩 시간 증가 발생 

UUIManageSubsystem

UGameInstanceSubsystem 기반의 UI 관리 시스템

게임 전체에서 공통으로 UI를 열고 닫는 중앙 컨트롤러 역할

UI 요청 태그 → UI 위젯 생성 → 적절한 레이어 배치

모든 UI 열기 로직을 관리하는 게임 전체 단위 UI 컨트롤러

Initialize

void UUIManageSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
    Super::Initialize(Collection);

    if (UIMapDataAsset)
    {
        CachedWidgetMap = UIMapDataAsset->WidgetMap;
        UE_LOG(LogTemp, Log, TEXT("UIManageSubsystem: %d개의 UI 맵 로드됨."), CachedWidgetMap.Num());
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("UIManageSubsystem: UIMapDataAsset이 설정필요. BP_UIManageSubsystem 확인바람"));
    }
}

Subsystem 생성 시 초기화

  • GameInstance가 시작되면 자동으로 생성
  • UIMapDataAsset 내용을 캐싱

→ UI 시스템 전부의 시작 지점

UIMapDataAsset

  • DataAsset으로 UI 정책(WidgetClass + Layer) 정의한 정보
  • Subsystem은 여기서만 정보를 읽음
  • UI 요청을 완전히 데이터 기반으로 처리

→ UI 구조를 코드가 아닌 데이터로 관리함

CachedWidgetMap

  • DataAsset 데이터를 런타임에 캐시
  • DataAsset 접근 비용 감소
  • GC 대상 아님

→ UIMap 빌 때 DataAsset 파일을 계속 읽지 않고 빠르게 접근

SetRootLayout(UPrimaryLayout* RootLayout)

void UUIManageSubsystem::SetRootLayout(UPrimaryLayout* InRootLayout)
{
    if (RootLayout == InRootLayout)
    {
        return; // 이미 등록됨
    }
    
    RootLayout = InRootLayout;
    UE_LOG(LogTemp, Log, TEXT("UIManageSubsystem: RootLayout 등록 완료"));
}

UI의 가장 최상위 레이아웃(Primary Layout)을 저장

  • CommonUI 구조에선느 모든 UI는 RootLayout의 Layer 안에 들어감
  • RootLayout이 있어야 어떤 UI도 보여줄 수 있음

→ UI 열기 전에 반드시 RootLayout이 등록되어야 함

→ 여기에선 PrimaryLayout Widget

RootLayout

  • 현재 플레이어의 RootLayout 래퍼런스
  • ShowWidget이 이 RootLayout의 Layer들을 사용해서 UI 생성

→ Subsystem이 UI를 어디에 띄울지 결정하는 기준

  1. Subsystem은 게임 전체에서 단 하나만 존재
    → RootLayout도 딱 1개만 등록해야 함
  2. RootLayout 은 최초 한 번만 등록됨
  • CommonUI 기반 구조에서는

    • GameMode/PlayerController가 UI 초기화할 때
    • PrimaryLayout 만들어서
    • Subsystem에 딱 한 번 전달

    → 같은 Layout을 넣으면 다시 설정할 이유 없으니 return;

  1. RootLayout이 이미 있을 때 다시 세팅하면 문제 발생할 수 있음
    - Layer Pointer 재설정
    - 이미 활성화된 위젯이 있는 상태에서 RootLayout이 바뀌면 충돌 가능
    → 같은 포인터면 return 하는 것이 안정적

ShowWidget(FGameplayTag RequestTag)

  • Subsystem의 가장 핵심 기능
UCommonActivatableWidget* UUIManageSubsystem::ShowWidget(FGameplayTag RequestTag)
{
    // 0. 루트 레이아웃이 유효한지 확인
    if (!RootLayout)
    {
        UE_LOG(LogTemp, Warning, TEXT("ShowWidget(%s): RootLayout이 없다!"), *RequestTag.ToString());
        return nullptr;
    }

    // 1. UI Policy 검색
    const FUIWidgetPolicy* Policy = CachedWidgetMap.Find(RequestTag);
    if (!Policy)
    {
        UE_LOG(LogTemp, Warning, TEXT("ShowWidget(%s): UI Policy에 등록된 태그가 없음"), *RequestTag.ToString());
        return nullptr;
    }

    // 2. 레이어 정보 확인
    const FGameplayTag& LayerTag = Policy->TargetLayer;
    if (!LayerTag.IsValid())
    {
        UE_LOG(LogTemp, Warning, TEXT("ShowWidget(%s): UI Policy Layer 확인 바람"), *RequestTag.ToString());
        return nullptr;
    }

    // 3. RootLayout에서 해당 레이어 스택 검색
    UCommonActivatableWidgetContainerBase* Stack = RootLayout->GetLayerWidget(LayerTag);
    if (!Stack)
    {
        UE_LOG(LogTemp, Warning, TEXT("ShowWidget(%s): RootLayout에서 %s 레이어 스택이 없음"), *RequestTag.ToString(), *LayerTag.ToString());
        return nullptr;
    }

    // 4. 무엇을 띄울지 TSoftClassPtr로 되어있는 위젯 검색
    TSoftClassPtr<UCommonActivatableWidget> WidgetClassPtr = Policy->WidgetClass;
    if (WidgetClassPtr.IsNull())
    {
        UE_LOG(LogTemp, Warning, TEXT("ShowWidget(%s): UI Policy에서 WidgetClass 설정 확인 바람"), *RequestTag.ToString());
        return nullptr;
    }

    // 5. TSoftClassPtr를 동기식으로 로드 (추후 비동기 로드로 개선 가능)
    TSubclassOf<UCommonActivatableWidget> WidgetClass = WidgetClassPtr.LoadSynchronous();
    if (!WidgetClass)
    {
        UE_LOG(LogTemp, Warning, TEXT("ShowWidget(%s): %s 클래스를 로드할 수 없음"), *RequestTag.ToString(), *WidgetClassPtr.ToString());
        return nullptr;
    }

    // 6. 스택에 위젯을 Push
    return Stack->AddWidget<UCommonActivatableWidget>(WidgetClass, [RequestTag, LayerTag, WidgetClass](UCommonActivatableWidget& WidgetToInit) {
            // 이 람다(Lambda)는 위젯이 생성된 직후 화면에 보이기(Construct) 직전에 호출.
            UE_LOG(LogTemp, Log, TEXT("ShowWidget(%s): %s 레이어에 %s 위젯 Push 완료"), *RequestTag.ToString(), *LayerTag.ToString(), *WidgetClass->GetName());
        });
}

주석 5. Async Streamer 사용 예시

FStreamableManager& Loader = UAssetManager::GetStreamableManager();
Loader.RequestAsyncLoad(
    WidgetClassPtr.ToSoftObjectPath(),
    FStreamableDelegate::CreateLambda([Stack, RequestTag, WidgetClassPtr]()
    {
        TSubclassOf<UCommonActivatableWidget> LoadedClass = WidgetClassPtr.Get();
        Stack->AddWidget(LoadedClass);
    })
);

IsLayerBusy(FGameplayTag RequestTag)

이 UI 요청이 표시될 Layer가 이미 다른 UI에 의해 사용중인가?

bool UUIManageSubsystem::IsLayerBusy(FGameplayTag RequestTag)
{
    // 1. UI Policy 검색
    const FUIWidgetPolicy* Policy = CachedWidgetMap.Find(RequestTag);
    if (!Policy)
    {
        return false;
    }

    // 2. 레이어 정보 확인
    const FGameplayTag& LayerTag = Policy->TargetLayer;
    if (!LayerTag.IsValid() || !RootLayout)
    {
        return false;
    }

    // 3. RootLayout에서 해당 레이어 스택 검색
    UCommonActivatableWidgetContainerBase* Stack = RootLayout->GetLayerWidget(LayerTag);
    if (!Stack)
    {
        return false;
    }

		// 4. 스택 위에 활성 위젯이 있는지 체크
    return (Stack->GetActiveWidget() != nullptr);
}

MyHUD

AMyHUD는 게임 시작 시 RootLayout을 생성해 화면에 올리고,

RootLayout이 준비되면 UIManageSubsystem을 통해 첫 UI(MainMenu or HUD)를 띄운다.

BeginPlay()

여기서 RootLayout 생성

  • RootLayoutClass 존재 확인
  • RootLayoutInstance 생성
  • 화면에 AddToViewport
  • = UI 시스템의 기초(Bone)을 세우는 단계
void AMyHUD::BeginPlay()
{
	Super::BeginPlay();

	// 2. RootLayoutClass가 BP_MyHUD에 할당되었는지 확인
	if (!RootLayoutClass)
	{
		UE_LOG(LogTemp, Error, TEXT("AMyHUD: RootLayoutClass가 설정되지 않았습니다! (BP_MyHUD에서 설정 필요)"));
		return;
	}

	// 3. RootLayout을 생성
	RootLayoutInstance = CreateWidget<UPrimaryLayout>(GetOwningPlayerController(), RootLayoutClass);
	if (!RootLayoutInstance)  // 생성 실패 시
	{
		UE_LOG(LogTemp, Error, TEXT("AMyHUD: RootLayout 인스턴스 생성 실패."));
		return;  // UI 띄울 방법 없음 -> 종료
	}

	// 성공했을 때 로그 출력
	UE_LOG(LogTemp, Display, TEXT("AMyHUD: RootLayout 인스턴스 생성 완료."));

	// 4. 추가
	RootLayoutInstance->AddToViewport();

}

OnRootLayoutReady()

RootLayout 생성이 끝났을 때 HUD가 Subsystem에 UI를 띄우라고 지시하는 함수

RootLayout을 화면에 올렸다고 해서 끝이 아님

실제 첫 화면(UI)을 띄우려면 Subsystem에게 알려줘야 함

  • Subsystem 가져옴
  • Frontend 레벨이면 MainMenu UI 표시
  • 아니면 HUD UI 표시
  • = UI 첫 화면을 띄우는 단계
void AMyHUD::OnRootLayoutReady()
{
	FString CurrentLevelName = GetWorld()->GetName();  // 1. 현재 레벨 이름 가져오기
	if (CurrentLevelName.Contains("Frontend"))  // 2. Frontend 레벨인지 검사
	{
		// 3. UIManageSubsystem 가져오기
		if (UUIManageSubsystem* UIManager = GetGameInstance()->GetSubsystem<UUIManageSubsystem>())
		{
			UIManager->ShowWidget(FGameplayTag::RequestGameplayTag(TEXT("UI.Request.MainMenu")));
		}
		else
		{
			UIManager->ShowWidget(FGameplayTag::RequestGameplayTag(TEXT("UI.Request.ShowHUD")));
		}
	}
}

오류났던 부분

  • UIManager null 체크와 Forntend/HUD 조건 분리해야 하고,
    null일 때 ShowWidget을 호출하면 100% 크래시 나므로
    반드시 UIManager 체크 뒤에 레벨 분기(if/else)를 둬야 한다.

  • Frontend 레벨 → MainMenu UI 띄우기

  • 그 외 레벨 → 인게임 HUD UI 띄우기

  • else 위치 잘못된 이유 : UIManager가 nullptr일 때 UIManager→ShowWidget 호출하기 때문

수정 방법

  • UIManager가 nullptr인지 체크는 한 번만 해야 함
  • 레벨에 따라 MainMenu / HUD 나누는 조건이 esle로 가기
    if (UIManager ≠ nullptr) : UIManager 검사용
    if (Frontend 레벨인지) : 레벨 선택용
void AMyHUD::OnRootLayoutReady()
{
    UUIManageSubsystem* UIManager = GetGameInstance()->GetSubsystem<UUIManageSubsystem>();
    if (!UIManager)
    {
        UE_LOG(LogTemp, Error, TEXT("AMyHUD: UIManageSubsystem을 가져올 수 없습니다!"));
        return;
    }

    FString CurrentLevelName = GetWorld()->GetName();

    if (CurrentLevelName.Contains("Frontend"))
    {
        // 프론트엔드면 메인메뉴 UI 띄우기
        UIManager->ShowWidget(FGameplayTag::RequestGameplayTag(TEXT("UI.Request.MainMenu")));
    }
    else  // 레벨 구분
    {
        // 그 외 인게임 레벨이면 HUD 띄우기
        UIManager->ShowWidget(FGameplayTag::RequestGameplayTag(TEXT("UI.Request.ShowHUD")));
    }
}

0개의 댓글