상세 컨텐츠

본문 제목

팀프로젝트 15. 도구 표시 정보 데이터 에셋화

Unreal C++

by hyunjunstar 2026. 8. 25. 23:37

본문

장비 패널은 지금 들고 있는 도구의 이름을 띄운다. "빗자루", "안쪽 채우기 롤러" 같은 것. 이번 작업은 그 이름을 어디에 두느냐 였다.

시작은 이 한 줄이었다.

enum ECWToolType 에 이미 한글 이름이 있는데,
WBP_EquipmentPanel 의 Switch 가 같은 문구를 두 번째로 적고 있다.

같은 정보가 두 곳에 있으면, 도구를 추가할 때 두 곳을 고쳐야 하고 한쪽을 빠뜨리면 화면에 빈칸이 나온다. 그걸 한 곳으로 모으는 작업이다.

주제 문제 해결
도구 이름 enum 과 BP Switch 두 곳에 중복 DataAsset 한 곳으로
패키징 UMETA 이름이 영문으로 떨어짐 런타임 문구를 FText 로 분리
누락 감지 enum 추가 시 조용히 빈칸 IsDataValid 로 저장 시 검출
컴포넌트 참조 Construct 캐싱 → 리스폰 후 낡음 매 프레임 다시 조회

1. UMETA 이름은 런타임에 못 쓴다

처음엔 간단해 보였다. enum 에 이미 이름이 있으니까.

enum class ECWToolType : uint8
{
    Broom     UMETA(DisplayName = "빗자루"),
    Liner     UMETA(DisplayName = "라인 테두리 도구"),
    ...
};

UEnum::GetDisplayNameTextByValue() 로 이걸 그냥 꺼내 쓰면 될 줄 알았다. 그런데 엔진 소스를 보니 안 된다.

// UEnum::GetDisplayNameTextByIndex
#if WITH_EDITOR
    // 에디터에서만 DisplayName 메타데이터를 읽는다
    FText LocalizedDisplayName = GetMetaDataText(TEXT("DisplayName"), ...);
    if (!LocalizedDisplayName.IsEmpty())
    {
        return LocalizedDisplayName;
    }
#endif
    // 에디터가 아니면 내부 이름을 사람이 읽기 좋게 변환
    return FText::FromString(FName::NameToDisplayString(RawName, bIsBool));

DisplayName 을 읽는 경로가 #if WITH_EDITOR 안에 있다. 에디터와 PIE 에서는 한글로 잘 나오지만, 패키징 빌드에서는 이 경로를 안 타서 내부 이름으로 떨어진다.

빗자루 → Broom
안쪽 채우기 롤러 → WhiteRoller

우리 프로젝트에는 로컬라이제이션 수집 설정이 없으니 패키징하면 반드시 영문이 된다. 에디터에서 잘 되는 걸 보고 넘어갔다가 패키징 때 깨지는 종류다.

그래서 화면 문구는 enum 메타데이터가 아니라 DataAsset 에 FText 로 따로 둔다. 에디터 라벨(UMETA)과 화면 문구는 원래 목적이 다르기도 하다. 드롭다운의 "없음" 과 화면의 "도구를 들고있지 않습니다." 는 같을 이유가 없다.

2. DataAsset — 프로젝트에 이미 패턴이 있었다

새로 만들려다 보니, 이 프로젝트에 똑같은 구조가 이미 있었다. CWWearableCatalog — 착용물을 DataAsset 으로 관리하고 IsDataValid 로 검증한다. 그대로 따라갔다.

USTRUCT(BlueprintType)
struct CROSSWALK_API FCWToolUIData
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "CW|UI|Tool")
    FText DisplayName;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "CW|UI|Tool")
    TObjectPtr<UTexture2D> Icon = nullptr;
};

UCLASS(BlueprintType)
class CROSSWALK_API UCWToolUIDataAsset : public UPrimaryDataAsset
{
    // ToolUIDataMap : TMap<ECWToolType, FCWToolUIData>
    // FindToolUIData(ToolType, OutData) — 없으면 로그 남기고 false
    // IsDataValid — 저장 시 누락 항목·빈 이름 검출
};

2.1 아이콘은 브러시가 아니라 텍스처

코드 리뷰에서 하나 배웠다. 아이콘을 FSlateBrush 로 두려다가 TObjectPtr<UTexture2D> 로 바꿨다.

FSlateBrush    ImageSize 가 0,0 이면 아이콘이 화면에 안 나온다
               텍스처만 꽂고 크기를 안 채우기 쉽다. 에러도 경고도 없다
UTexture2D     크기는 Image 위젯이 정하고, 데이터는 "어떤 그림인지"만 갖는다

역할이 갈린다. 크기는 레이아웃의 몫, 데이터는 내용만. 나중에 머티리얼이나 아틀라스가 필요하면 그때 브러시로 바꾸면 된다.

3. IsDataValid — 안전장치는 동작을 확인해야 안전장치다

ECWToolType 은 내 파일이 아니다(다른 팀원 담당). 그래서 도구가 추가되는 건 그 사람 손이다.

enum 에 Shovel 추가
→ DataAsset 에 Shovel 항목이 없음
→ 이름·아이콘이 빈칸
→ 컴파일 에러도 경고도 없음

switch 라면 컴파일이 깨져서 바로 알았을 텐데, TMap 조회는 조용히 넘어간다. 이게 DataAsset 의 유일한 약점이다. 그걸 IsDataValid 로 메운다.

#if WITH_EDITOR
EDataValidationResult UCWToolUIDataAsset::IsDataValid(
    FDataValidationContext& Context
) const
{
    const UEnum* ToolEnum = StaticEnum<ECWToolType>();
    const int64 MaxEnumValue = ToolEnum->GetMaxEnumValue();

    for (int32 Index = 0; Index < ToolEnum->NumEnums(); ++Index)
    {
        const int64 EnumValue = ToolEnum->GetValueByIndex(Index);
        if (EnumValue == MaxEnumValue) continue;   // _MAX 제외

        const ECWToolType ToolType = static_cast<ECWToolType>(EnumValue);
        const FCWToolUIData* Data = ToolUIDataMap.Find(ToolType);

        if (Data == nullptr)         // 항목 누락
        {
            Context.AddError(...);
            continue;
        }
        if (Data->DisplayName.IsEmpty())   // 이름 비어 있음
        {
            Context.AddError(...);
        }
    }
    return Result;
}
#endif

NumEnums() - 1 로 마지막 항목을 자르는 대신 GetMaxEnumValue() 로 실제 최대값을 제외했다. 마지막이 항상 _MAX 라는 보장이 없어서다. enum 에 UMETA(Hidden) 이 섞이거나 순서가 바뀌면 -1 방식은 깨진다.

3.1 일부러 깨뜨려서 확인하기

이게 이번에 제일 신경 쓴 부분이다. 안전장치를 넣고 동작 확인을 안 하면 껍데기다.

에셋을 만들 때, 일부러 세 가지를 깨뜨려 봤다.

1  항목 3개만 넣고 저장 → 나머지 3개 "항목이 없습니다" 뜨는지
2  한 항목 이름을 비우고 저장 → "DisplayName이 비어 있습니다" 뜨는지
3  둘 다 확인되면 → 6개 다 채우고 통과

세 개가 다 떠야 두 종류 검사가 다 동작하는 거다. 하나만 테스트하고 넘어가면 다른 하나는 안 잡힐 수도 있다. 실제로 검증기가 6개를 다 잡아내는 걸 보고 나서야 채웠다.

한 가지 함정도 있었다. 이름을 채울 때 DisplayName 에 enum 의 영문 이름을 그대로 복사했다. Broom, WhiteRoller. 검증은 통과했다 — 비어 있지 않으니까. 하지만 화면에는 영문이 나온다. IsDataValid 는 "비었는지"만 보지 "한글인지"는 못 본다. 내용이 맞는지는 사람이 봐야 한다. 다 한글로 고쳤다.

4. 컴포넌트를 캐싱하지 않는다

장비 패널은 WorkerComponent 에서 현재 도구를 읽는다. 그 컴포넌트를 어떻게 얻느냐가 문제였다.

이전 코드(체온 게이지 버그를 잡으면서 배운 것)의 교훈을 그대로 적용했다.

Construct 에서 캐싱     HUD 생성 시 Pawn 이 없으면 영구히 null
                       리스폰·맵 이동 후 이전 컴포넌트를 계속 읽음
매 프레임 다시 조회      Get Owning Player Pawn → WorkerComponent
                       느려 보이지만 위젯 하나짜리라 무료에 가깝다

GetWorkerComponent 함수를 만들어 매번 Pawn 에서 다시 찾게 했다. 그리고 값이 바뀔 때만 UI 를 갱신한다.

Event Tick
→ GetWorkerComponent → Is Valid
   Valid → GetHeldTool → CurrentTool
           (초기화 안 됨 OR CurrentTool != LastTool) 이면
           → RefreshToolDisplay(CurrentTool)
   Not Valid → ClearToolDisplay

4.1 첫 도구가 None 일 때

여기서 함정을 하나 만났다. LastToolType 만 캐싱하면, 게임 시작 시 실제 도구도 None 이면 최초 갱신을 건너뛴다.

LastToolType 기본값 = None
실제 첫 도구 = None
→ 값만 비교하면 같다 → "도구를 들고있지 않습니다." 가 안 그려짐

그래서 bHasInitializedToolDisplay 라는 불리언을 하나 더 뒀다. 조건이 초기화 안 됨 OR 값이 다름 이라, 한 번은 무조건 그린다. Pawn 이 사라졌다 돌아올 때도 이 플래그를 false 로 내려서, 같은 도구여도 다시 그리게 했다.

5. 지우는 것도 절반이다

새 흐름이 동작하는 걸 확인한 뒤, 기존 것을 지웠다. 여기서 규칙 하나를 지켰다. 하나 지우고 컴파일, 하나 지우고 컴파일.

GetToolNameText 바인딩 제거 → 컴파일
GetToolNameText 함수 삭제 → 컴파일
SetEquipmentData 삭제 → 컴파일
ToolBrush / ToolName 변수 삭제 → 컴파일

한 번에 다 지우면 뭐가 깨졌는지 안 보인다. 하나씩 지우면, 컴파일 에러가 나는 순간 "방금 지운 게 아직 쓰이는 곳이 있다" 를 바로 안다.

그리고 삭제 전에 Find References 를 한 번씩 했다. 블루프린트는 호출하는 에셋이 로드 안 된 상태면, 현재 위젯 컴파일만으로 외부 참조가 안 보일 수 있다. 그래서 마지막에 WBP_InGameHUD(장비 패널을 품는 상위 위젯)를 열어 Refresh All Nodes 로 확인했다. SetEquipmentData 를 아무도 안 부르는 걸 grep 으로 미리 확인해 둔 덕에 안심하고 지웠다.

6. 정리

  1. UMETA(DisplayName) 은 런타임 UI 문구로 쓰면 안 된다. 읽는 경로가 #if WITH_EDITOR 안이라 패키징에서 내부 이름으로 떨어진다. 화면 문구는 FText 로 따로 둔다.
  2. 같은 정보가 두 곳에 있으면 한 곳으로 모은다. enum 과 BP Switch 에 이름이 중복돼 있었다. 도구 추가 시 한쪽을 빠뜨리면 빈칸이 나온다.
  3. 컴파일 에러가 안 나는 의존은 다른 걸로 막는다. enum 항목 추가는 DataAsset 을 안 채워도 컴파일이 통과한다. IsDataValid 로 저장 시점에 잡는다.
  4. 안전장치는 일부러 깨뜨려 동작을 확인해야 안전장치다. IsDataValid 를 넣고 검증기가 실제로 누락을 잡는지 세 가지로 테스트했다.
  5. GetMaxEnumValue() 로 최대 항목을 제외한다. NumEnums()-1 은 마지막이 _MAX 라는 가정이라 enum 구성이 바뀌면 깨진다.
  6. 컴포넌트는 캐싱하지 않고 매번 조회한다. Construct 캐싱은 리스폰·맵 이동 후 낡은 참조가 된다. 첫 값이 기본값과 같을 때를 위해 초기화 플래그를 따로 둔다.

 

관련글 더보기