[UE5] 추출 슈터 5-1. 데드코드였던 아이템 계층 되살리기

게시:     수정

카테고리:

태그: , ,

📌 EmploymentProj 5단계 루팅·인벤토리 첫 번째 글입니다. 👾 깃허브 · 📚 시리즈 목차 · ← 4-6. HUD, 그리고 GAS 이관 결산


시작 지점: 전부 데드코드였다

2-3편에서 아이템을 세 겹으로 나눴다. 그런데 5단계를 시작하며 확인해 보니 그 계층 전체를 읽는 코드가 하나도 없었다.

심볼 상태
FEPItemData 선언만 있고 참조 0
DT_Items.uasset 존재하지만 읽는 코드 0
UEPItemDefinition 클래스와 무기 DA 3종 존재
UEPItemInstance::CreateInstance() 호출처 0
UEPWeaponInstance::CreateWeaponInstance() 호출처 0

무기는 EPGameMode.cpp에서 DefaultWeaponClass를 직접 스폰하고 있었다. 데이터 계층을 경유하지 않았다.

이 단계의 목표는 게임플레이 기능을 하나도 추가하지 않는 것이다. ItemId → Definition → 상태 경로를 처음으로 실제로 돌게 만들고, 콘솔 커맨드 하나로 독립 검증한다. 눈에 보이는 결과가 없는 대신 다음 단계 전부가 이 위에 얹힌다.


1. 개체 상태를 값 타입으로 되돌렸다

원래 설계는 UEPItemInstance라는 UObject였다. 런타임 상태를 담을 자리가 필요했고, UE에서 그런 건 보통 오브젝트니까.

그런데 UObject를 고르는 순간 따라오는 질문이 넷이다.

  • Outer를 누구로 잡을 것인가
  • 수명은 누가 관리하는가
  • 복제되는가, 서브오브젝트 등록은 어디서 하는가
  • 인벤토리 엔트리에서 어떻게 참조하는가 (포인터? 핸들?)

넷 중 어느 것도 게임 규칙과 관계가 없다. 전부 “UObject라서 생긴 문제”다.

그래서 8바이트짜리 구조체로 바꿨다.

USTRUCT(BlueprintType)
struct FEPItemState
{
    GENERATED_BODY()

    // 무기: 장전된 발수 / 탄약상자: 남은 발수
    // 현금뭉치: 금액      / 소모품: 남은 사용 횟수
    UPROPERTY(BlueprintReadWrite, Category = "Item")
    int32 Charges = 0;

    UPROPERTY(BlueprintReadWrite, Category = "Item")
    float Durability = 100.f;
};

Outer도, 핸들도, 소유 서브시스템도, 수명 관리도 없다. 픽업과 인벤토리 엔트리에 값으로 내장되고, 이관은 대입 한 줄이다.

필드 이름을 Ammo로 안 지은 이유

같은 값을 탄약상자, 현금뭉치, 소모품이 공유한다.

특히 이 게임에는 스택이 없다. 붕대 3개는 엔트리 3개다. 그러면 돈을 아이템으로 두려면 현금뭉치 하나가 금액을 통째로 들고 있어야 한다. 안 그러면 10,000원이 엔트리 10,000개가 된다. 그 필드가 Ammo면 안 된다.

무엇이 사라졌는가

UEPItemInstance / UEPWeaponInstance는 파일째 지웠다. 호출처가 0이라 비용이 없었다.

기존 필드 행선지
CurrentAmmo / Durability FEPItemState의 두 필드
ItemId 인벤토리 엔트리와 픽업이 각자 가진다
CachedDefinition 없어진다. 서브시스템 조회가 TMap 룩업 한 번이라 캐시할 이유가 없다
Quantity 없어진다. 스택이 없다
InstanceId (FGuid) 없어진다. 읽는 코드가 없었고, DB 영구 식별자는 저장 시점에 발급한다
SchemaVersion 없어진다. 아이템이 아니라 세이브 포맷의 속성이다

CachedDefinition이 사라지는 게 이 전환의 축소판이다. 그 필드는 성능 때문이 아니라 “인스턴스가 오브젝트라서 뭔가를 들고 있어야 한다”는 사실 때문에 존재했다.


2. DataTable과 DataAsset, 판정선 한 줄

두 계층을 유지하기로 했으면 어느 값이 어디로 가는지를 매번 고민하지 않을 규칙이 필요했다.

① 여러 아이템을 표로 나란히 놓고 조정하는 값은 DataTable. ② 그 아이템 한 종류에만 의미 있는 것은 DataAsset.

판정선은 “모든 아이템이 값을 갖는가”다.

필드 위치 근거
SlotSize / SellPrice / Rarity / bFungible DT 전형적인 밸런싱 열
InitialCharges DT 탄약상자 100, 현금 10000, 붕대 1. 표로 조정한다
ContainerCapacity DT 소형 12, 중형 20. 동일
WorldMesh / Icon DA 에셋 참조
InitState() DA virtual
MaxAmmo DA (Weapon) 무기 전용. DT에 넣으면 나머지 행이 전부 빈칸이 된다

InitialChargesContainerCapacity는 전부 값을 갖는다(대부분 0). MaxAmmo는 무기만 갖는다. 그게 선이다.

상태 초기화는 virtual 하나로

// 기본 구현
void UEPItemDefinition::InitState(const FEPItemData& Data, FEPItemState& State) const
{
    State.Charges = Data.InitialCharges;
}

// 무기 오버라이드. 새로 만든 무기는 만탄
void UEPWeaponDefinition::InitState(const FEPItemData& Data, FEPItemState& State) const
{
    State.Charges = MaxAmmo;
}

호출부는 아이템 타입을 전혀 모른다.

Def->InitState(*Row, NewState);   // 이 한 줄이 전부

NewObject도, Outer 결정도, 핸들 발급도, 실패 경로도 없다. 새 아이템 종류를 추가하는 비용은 Definition 서브클래스 하나이고 기존 코드 수정은 0이다.

무기가 InitialCharges가 아니라 MaxAmmo를 읽는 이유는 단순하다. 무기는 이미 그 값을 갖고 있으니 두 곳에 적게 하지 않는다.

양방향 참조는 IsDataValid로 잡는다

DT 행의 ItemDefinition(소프트 참조)과 DA의 ItemDataRow가 서로를 가리킨다. 손으로 동기화해야 하고, 어긋나도 컴파일과 로드는 통과한다.

#if WITH_EDITOR
EDataValidationResult UEPItemDefinition::IsDataValid(FDataValidationContext& Context) const
{
    EDataValidationResult Result = Super::IsDataValid(Context);

    if (ItemDataRow.RowName != ItemId)
    {
        Context.AddError(/* ... */);
        Result = EDataValidationResult::Invalid;   // ★ 이 줄이 없으면 통과한다
    }
    // DT 행의 역참조가 이 에셋을 가리키는지도 확인
    return Result;
}
#endif

Result 대입을 빼먹으면 안 된다. AddError는 메시지를 담을 뿐이고 통과 여부를 정하는 건 반환값이다. 빠뜨리면 에러 메시지는 뜨는데 검증은 통과한다.

UE 5.3부터 IsDataValid(TArray<FText>&)는 deprecated다. IsDataValid(FDataValidationContext&) const를 쓴다.


3. Definition은 전량 상주시킨다

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

    BuildDataCache();       // ① DT를 값으로 복사
    LoadAllDefinitions();   // ② 디스크에서 로드, 내부에서 ③ 색인
}

①이 ②보다 먼저여야 한다. ③이 “DT에 없는 ItemId“를 경고하려면 DataCache가 이미 채워져 있어야 한다. 순서가 뒤바뀌면 모든 Definition이 경고를 뿜는다.

행 포인터를 캐시하지 않는다

UDataTable::FindRow()가 돌려주는 포인터는 DataTable 내부 RowMap의 메모리를 가리킨다. 에디터에서 DT를 리임포트하면 RowMap이 비워지고 재할당되어 캐시한 포인터가 댕글링한다.

패키지 빌드에서는 재현되지 않고 에디터에서만 나는 종류라 추적이 오래 걸린다. FEPItemData는 작은 POD이므로 값으로 복사해 담는다.

왜 지연 로드하지 않는가

픽업 획득은 RPC 응답 안에서 성패가 결정돼야 하는 동기 경로다. InitState()도, 칸 여유 판정을 위한 SlotSize 조회도 Definition이 메모리에 있어야 한다. 로드를 기다리는 사이 “줍기 성공/실패”를 유보할 수 없다.

Definition은 수치와 참조만 담은 메타데이터라 개당 수 KB다. 반면 WorldMeshIcon은 지연이 허용되므로 소프트로 남긴다.

로드는 블로킹으로 건다.

DefinitionHandle = Manager.LoadPrimaryAssets(Ids);
if (DefinitionHandle.IsValid())
{
    DefinitionHandle->WaitUntilComplete();   // 여기서 막는다
}

Initialize()UGameInstance::Init() 안, 즉 로딩 화면 시간이다. 여기서 막는 비용은 게임플레이에 드러나지 않는다. 그리고 WaitUntilComplete()가 끝난 시점이 곧 불변식이 성립하는 시점이다. 이후 어떤 경로도 Definition 부재를 걱정하지 않는다.

비동기로 두면 “언제부터 안전한가”라는 지점이 문서에도 코드에도 없게 된다.


겪은 문제 1: Definitions = 0

커맨드를 만들고 처음 쳤을 때 나온 값이다. 에셋은 분명히 있는데 0이었다.

원인은 로드 핸들이 null인 것을 실패로 판정한 것이었다.

// 초기 구현 (버그)
DefinitionHandle = Manager.LoadPrimaryAssetsWithType(...);
if (!DefinitionHandle.IsValid()) { 로그; return; }   // ← 항상 여기로 빠졌다

엔진을 보면 이유가 나온다.

// AssetManager.cpp:2195-2199  ChangeBundleStateForPrimaryAssets
else if (NameData->CurrentState.IsValid() && NameData->CurrentState.IsSame(NewBundleState, ...))
{
    continue;          // 이미 그 상태면 핸들을 만들지 않고 건너뛴다
}
...
// :2298. AllHandles가 비면 nullptr을 반환한다
return StreamableManager.CreateCombinedHandle(AllHandles, ...);

핸들 null은 두 상황이 합쳐진 값이다.

핸들 null의 의미 언제
타입에 에셋이 0개 Asset Manager 등록 누락. 잡고 싶었던 상황
새로 로드할 게 없음 (이미 전부 상주) 에디터에서 DA를 열어봤거나 이전 PIE가 이미 로드했을 때

에디터 작업 중에는 두 번째가 압도적으로 흔하다.

핸들 null은 “새로 로드할 게 없다”이지 “에셋이 없다”가 아니다.

수정은 판정 수단을 바꾸는 것이었다. 등록 여부는 GetPrimaryAssetIdList()개수로, 결과는 GetPrimaryAssetObjectList()내용으로 판정한다. 핸들은 대기와 상주 유지 용도로만 쓴다.


겪은 문제 2: Definitions = 3 (5여야 한다)

위를 고친 다음에 나온 별개의 숫자다. 그래서 원인도 다르다.

① Definitions = 0     ← 핸들 null 오판정. 무기만이 아니라 5개 전부 없었다
   ↓ 고침
② Definitions = 5, 3  ← ①을 고친 뒤 남은 것. 이때 빠진 3개가 정확히 무기다
   ↓ 고침
③ 5, 5

빠진 셋이 정확히 무기 DA였다. 그리고 원인은 코드가 아니라 에셋 안에 있었다.

PrimaryAssetType.uasset에 구워져 있었다

이 단계 착수 시점에 무기 Definition이 타입을 따로 반환하고 있었다.

// EPItemDefinition.cpp
return FPrimaryAssetId(TEXT("ItemDef"),   GetFName());
// EPWeaponDefinition.cpp
return FPrimaryAssetId(TEXT("WeaponDef"), GetFName());   // 서브클래스가 다른 타입

ItemDef만 등록하면 무기가 로드되지 않고, 둘 다 등록하면 조회할 때마다 타입 분기가 부활한다. 그래서 오버라이드를 제거하고 상속으로 ItemDef를 쓰게 했다.

그런데 코드를 고쳐도 안 고쳐졌다.

// AssetData.cpp:692. 저장된 태그를 읽을 뿐이다
FPrimaryAssetId FAssetData::GetPrimaryAssetId() const
{
    FName PrimaryAssetType = GetTagValueRef<FName>(FPrimaryAssetId::PrimaryAssetTypeTag);
    ...
}
// AssetManager.cpp:1396-1425
if (... || PrimaryAssetId.PrimaryAssetType != PrimaryAssetType)   // WeaponDef != ItemDef
{
    UE_LOG(LogAssetManager, Display, TEXT("Ignoring PrimaryAssetType %s - Conflicts with %s ..."));
    continue;                       // ItemDef 목록에서 빠진다
}

반환값은 에셋을 저장하는 순간 애셋 레지스트리 태그로 구워진다. 스캔은 클래스에 물어보지 않고 그 태그를 읽는다. WeaponDef 시절에 저장된 DA는 코드를 고친 뒤에도 계속 배제된다.

증상이 들쭉날쭉했던 이유

// AssetManager.cpp:1089
ARFilter.bIncludeOnlyOnDiskAssets = !GIsEditor || IsRunningCookCommandlet();

에디터에서는 메모리에 올라온 에셋의 정보를 살아 있는 객체로 다시 만든다. 그래서 콘텐츠 브라우저에서 그 DA를 열어보기만 해도 그 실행에서는 정상으로 보인다. 어떤 DA가 로드돼 있었느냐에 따라 숫자가 실행마다 달라졌다.

패키지 빌드에서는 GIsEditor == false라 예외 없이 전부 빠진다. 에디터에서 아무리 돌려도 안 잡히는 부류다.

그리고 로그가 거의 안 남는다.

// AssetManager.cpp:1414
static TSet<...> IssuedWarnings;

타입 쌍당 딱 한 줄, 그것도 Display 레벨이다. 3개가 다 문제였는데 로그는 한 줄이었고 초기화 로그에 묻혀 있었다.

대응

해당 DA를 전부 다시 저장한다. 저장 시 GetPrimaryAssetId()가 다시 불려 태그가 갱신된다. 더티 플래그가 안 붙으면 저장이 안 되므로, 각 DA를 열어 ItemId를 지웠다 다시 입력한 뒤 저장하는 게 확실하다.

파일을 직접 봐도 된다.

strings DA_AK74_FastProj.uasset | grep -E "^(WeaponDef|ItemDef)$"

일반화: 애셋 레지스트리에 구워지는 값을 바꿨다면 기존 에셋을 전부 재저장해야 한다. GetPrimaryAssetId(), GetAssetRegistryTags()가 그런 함수다. 코드 변경만으로는 디스크의 옛 값이 그대로 남는다.

그리고 이 사고 때문에 다음 단계에서 만드는 UEPLootTable의 같은 함수에는 final을 붙였다. 없는 문제를 막는 게 아니라, 생기면 진단이 오래 걸리는 문제를 애초에 못 쓰게 하는 것이다.


4. 검증 수단 두 개

EP.Item.State <ItemId>   // 상태 생성 결과 (Charges / Durability / SlotSize / 실제 Definition 클래스)
EP.Item.Dump             // DataCache 행 수 / Definition 상주 수

둘 다 ECVF_Cheat + #if !(UE_BUILD_SHIPPING || UE_BUILD_TEST) 가드다. 순수 조회라 클라이언트에서 실행해도 된다.

> EP.Item.State Weapon_AK74_HitScan
[ItemCore] Weapon_AK74_HitScan  Charges=30  Durability=100.0  SlotSize=5   (WeaponDefinition)

> EP.Item.State AmmoBox_545
[ItemCore] AmmoBox_545          Charges=100 Durability=100.0  SlotSize=1   (ItemDefinition)

> EP.Item.Dump
[ItemCore] DataCache=9  Definitions=9

마지막 괄호가 클래스 이름인 게 중요하다. 타입 통일이 실제로 먹혔는지를 그 한 칸이 보여준다. Weapon_* 행에서 ItemDefinition이 나오면 DA 클래스를 잘못 만든 것이다.

두 숫자가 어긋날 때 어디를 보는가

Dump9, 9가 아니면 짝이 안 맞는 게 있다는 뜻인데, 어느 행인지는 안 알려준다. 그래서 색인 단계에 로그를 다섯 종류 심었다.

로그 실제 원인
ItemId가 비어 있습니다 DA를 만들고 ItemId를 안 채웠다
ItemId 중복 DA를 복제해 변종을 만들고 ItemId를 안 고쳤다
DT 행이 없습니다 DA만 만들고 DT에 행을 안 넣었다
DT 행에 대응하는 Definition이 없습니다 DT에만 행을 넣었다 / DA ItemId가 다르다 / DA가 스캔에서 배제됐다
Definition이 0개 Asset Manager 등록 누락

넷째가 역방향 검사다. 메인 루프는 DA에서 출발하니까 DA가 처음부터 목록에 없으면 검사할 대상 자체가 없어 아무 로그도 안 남는다. 위의 WeaponDef 사고가 정확히 이 구멍에 빠졌고, 이 로그가 Weapon_AK74_FastProj 누락을 이름으로 찍어줬다.

중복 검사도 중요하다. TMap::Add는 같은 키를 조용히 덮어쓴다. 기존 에셋을 복제해 변종을 만드는 워크플로에서는 ItemId 수정을 빠뜨리기 쉽고, 그러면 두 무기 중 하나가 사라진다. 로드 순서는 보장되지 않으므로 실행할 때마다 다른 무기가 사라진다.


함정 정리

함정 증상
GetPrimaryAssetId() 변경 후 재저장 누락 해당 DA만 빠진다. 에디터에서 열어두면 되살아나 재현이 들쭉날쭉
로드 핸들 null을 실패로 판정 에디터에서 항상 Definitions = 0
Asset Manager의 Is Editor Only 체크 에디터는 되는데 패키지 빌드에서만 0개
Definition 로드를 비동기로 초반 몇 프레임 동안 조회가 null. 타이밍 의존적
FEPItemData* 캐시 에디터에서 DT 리임포트 후 크래시
IsDataValid에서 Result 대입 누락 에러 메시지는 뜨는데 검증은 통과
모든 행의 SlotSize가 1 칸 수 합산과 엔트리 개수 세기가 구분 안 됨
인스턴스 클래스를 “혹시 몰라” 남겨둠 두 표현이 공존해 어느 쪽이 진실인지 갈린다

결과

EP.Item.DumpDataCache = 9, Definitions = 9. 9행이 전부 Definition을 갖고, 짝 없는 쪽이 양방향 모두 없다.

DT는 무기 3종에서 9행으로 늘렸다. 각 행이 다음 단계에서 검증할 것을 하나씩 맡는다.

검증하는 것
AmmoBox_545 기본 InitState + InitialCharges 경로
Bandage 3개 주우면 엔트리 3개인지 (스택 안 됨)
Cash_10000 둘 주우면 엔트리 1개, Charges = 20000인지
Backpack_Small 본체와 별개로 12칸이 열리는지
Resume 비무기 Definition이 Asset Manager에 잡히는지

무기 SlotSize는 1에서 5로 올렸다. 전부 1이면 “칸 수 합산”과 “엔트리 개수 세기”가 구분되지 않아 나중에 합산 버그가 무증상으로 지나간다.

9, 9가 증명하지 않는 것

Dump짝이 맞는지만 센다. 행의 은 안 본다. 아래 넷은 기본값 그대로여도 9, 9가 나오고, 틀린 게 드러나는 시점이 한참 뒤다.

확인할 것 기본값이면
Cash_10000SellPrice 기본값 100. 1만원짜리가 100원에 팔린다
bFungible false면 합치기 경로를 영영 안 탄다
Backpack_SmallContainerCapacity 0이면 배낭이 아무것도 못 담는다
무기 SlotSize 1이면 칸 합산 검증이 무의미하다

적어두는 이유는 이게 “완료했다”와 “검증했다”가 갈리는 자리이기 때문이다. 커맨드가 통과했다고 데이터가 맞는 건 아니다.


다음

바닥에 아이템을 뿌린다. 루트 테이블(가중치 + 중첩), 스포너, 픽업 액터가 5-2편이다.

이 단계가 만든 MakeItemState()가 거기서 첫 게임플레이 소비자를 얻는다.


참고

  • DOCS/Notes/05/05_Loot_00_ItemCore.md. 구현 전체와 함정표
  • DOCS/Notes/05/LOOT_STATUS.md. 5단계 설계 결정 목록
  • 엔진 AssetManager.cpp:1089, :1396-1425, :2195-2298. 스캔과 로드 핸들
  • 엔진 AssetData.cpp:692. 구워진 태그를 읽는 지점

DevLog 카테고리 내 다른 글 보러가기

댓글남기기