포스트

언리얼 아이템 데이터·스폰·픽업 파이프라인

DataTable RowStruct는 왜 바꿀 수 없나

아이템 하나가 데이터로 정의되고 바닥에 스폰되고 주워지고, 그 무게가 이동 속도와 소음까지 바꾸는 경로를 한 줄로 연결했다. 도중에 DataTable과 DataAsset을 혼동해 막힌 두 건.

언리얼 아이템 데이터·스폰·픽업 파이프라인

아이템 하나가 게임에 들어오려면 거쳐야 하는 길이 길다. 데이터로 정의되고, 월드에 스폰되고, 플레이어가 줍고, 그 무게가 이동 속도와 소음에 반영되고, 마지막으로 손으로 상호작용할 수 있어야 한다. 이 글에서는 그 아이템 파이프라인을 처음부터 끝까지 연결한 과정을 이야기하려 한다. 구현은 스폰 위치 바닥 안착, 자동 픽업 흐름, 무게에 따른 감속과 소음 배율, Press E 인터랙션이고, 트러블슈팅은 그 앞을 막았던 데이터 에셋 타입 문제 두 건 — DataTable의 RowStruct는 왜 바꿀 수 없는가, 그리고 이 자리가 DataTable이 아니라 DataAsset이었던 이유다.

트러블슈팅 1 — DataTable RowStruct 타입 미스매치

가장 많이 시간을 빼앗긴 삽질. 증상은 단순했는데 원인 후보가 여럿이라 한 시간 가까이 헤맸다.

증상과 원인 파악

에러 로그:

1
2
LogDataTable: Error: UDataTable::FindRow : 'VOIDGameMode' specified
incorrect type for DataTable '/Game/VoidUnreal/Data/DT_VOIDWaveData'.

GameMode가 WaveTable->FindRow<FVOIDWaveData>(RowName, ...)을 호출했는데 요청 타입과 DataTable의 RowStruct가 일치하지 않아 거부된 것.

원인 후보 3가지를 순서대로 의심:

후보검증 방법결과
C++ 구조체 빌드 누락VOIDWaveData.hUSTRUCT(BlueprintType) + GENERATED_BODY() 확인정상
Live Coding 캐시 깨짐Binaries/Intermediate/Saved 삭제 + Rebuild Solution증상 동일
DT 자체의 RowStruct 잘못 지정DT 우클릭 → Properties에서 RowStruct 확인다른 구조체로 잡혀 있었음

DT 생성 시 “Pick Row Structure” 다이얼로그에서 FVOIDWaveData가 아닌 다른 구조체를 골랐거나, 다른 DT를 Duplicate한 흔적이었다.

DataTable RowStruct는 사후 변경 불가

여기서 처음 알게 된 사실:

  • DT의 RowStruct는 생성 시 한 번만 지정 가능
  • DataTable Editor 어디에도 RowStruct를 변경하는 UI가 없음
  • Editor Property Matrix·Source Code 어떤 경로로도 사후 교체 불가
  • DT를 다른 DT로부터 “상속”하거나 “캐스팅”할 수 없음

잘못된 타입의 DT는 삭제·재생성 외에 답이 없다.

해결 절차:

  1. DT_VOIDWaveData 에셋 삭제
  2. Content Browser → Miscellaneous → Data Table → “Pick Row Structure”에서 FVOIDWaveData 명시 선택
  3. Floor_1, Floor_2, Floor_3 행 추가 (TimeLimit / ZombieCount / ItemSpawnCount / FloorTheme / GoalType)
  4. PIE 재실행 → LogVOIDGameMode: WaveData loaded for Floor 1 정상 로그

Live Coding 캐시 의심 → 풀 리빌드

원인 추적 중간에 Live Coding 캐시도 의심해 한 번 풀 리빌드를 했다. 결과적으로는 RowStruct 문제가 본질이었지만, C++ 구조체가 의심될 때 풀 리빌드를 먼저 해두면 “구조체는 이상 없음”을 확정할 수 있다는 점에서 의미 있었다.

1
2
Editor 종료 → Binaries / Intermediate / Saved 삭제 → uproject 우클릭
→ Generate VS project files → Rebuild Solution → Editor 재기동

언리얼 엔진 워크플로우에서 “Live Coding이 매개변수만 핫리로드하고 USTRUCT 메타데이터는 안 갱신하는” 케이스가 있어서, USTRUCT를 추가/수정한 직후엔 풀 리빌드가 안전하다.



트러블슈팅 2 — 이 자리는 DataTable이 아니라 DataAsset이었다

DT 삽질이 끝나자마자 곧바로 다음 함정. 픽업 BP가 분명히 overlap을 받는데 인벤토리에 안 들어갔다.

증상

디버그 로그:

1
2
[VOID Pickup] BP_Item_Food BeginPlay  bAutoPickup=1  TriggerSphere=OK  ItemData=NULL
[VOID Pickup] overlap by BP_VoidPlayerCharactor

ItemData=NULL 한 줄이 전부였다. HandleBeginOverlap 진입부의 if (!OtherActor || !ItemData) return; 게이트에서 즉시 리턴. 픽업이 닿기는 했는데 데이터를 모르니 무시된 상황.

인지 오류 — “데이터니까 DT여야 한다”

WaveData를 막 DT로 만들고 나서 이걸 ItemData에도 적용하려 했다. “아이템도 데이터니까 행이 여러 개 있는 DT면 되겠지” → 틀렸다.

VOIDItemDataAsset.h 확인:

1
2
UCLASS(BlueprintType)
class VOIDUNREAL_API UVOIDItemDataAsset : public UDataAsset

베이스가 UDataAsset. 픽업 BP의 ItemData 슬롯도 UVOIDItemDataAsset* 포인터라 DataAsset 인스턴스 에셋을 받는 자리지 DT 행을 받는 자리가 아니다.

DataTable vs DataAsset 비교

이번 기회에 두 개념을 명확히 정리:

구분DataTableDataAsset
베이스 클래스UDataTableUDataAsset (UObject 직계)
단위행(Row) — FTableRowBase 구조체 시트단일 인스턴스 — UObject 1개
식별RowName(FName) + RowStruct(UScriptStruct*)에셋 경로 + 클래스
적합한 용도Wave 설정, 캐릭터 스탯, 무기 밸런스 등 형식개별 아이템·머티리얼·장비 등 단품
인스펙터행 그리드 편집UPROPERTY 폼 편집
참조 방식WaveTable->FindRow<T>(RowName, Context)UPROPERTY 포인터 직접 할당
상속RowStruct 상속 가능클래스 상속 가능 (DataAsset 자체가 UObject)

행이 여러 개고 키로 조회 → DT, 객체 1개를 데이터로 직렬화 → DA. WaveData는 층마다 행 → DT, ItemData는 아이템마다 단일 인스턴스 → DA. 두 시스템이 같은 프로젝트에 공존.

6개 DataAsset 인스턴스 생성

Content Browser → Miscellaneous → Data AssetVOIDItemDataAsset 클래스 선택 → 6개 생성:

자산 이름용도WeightType
DA_Item_Food식량0.5 kgConsumable
DA_Item_Medkit응급치료3.0 kgConsumable
DA_Item_Ammo탄약1.0 kgResource
DA_Part_Battery차량 부품 18.0 kgVehiclePart
DA_Part_FuelTank차량 부품 212.0 kgVehiclePart
DA_Part_SparkPlug차량 부품 32.0 kgVehiclePart

각 픽업 BP의 Class Defaults → ItemData 슬롯에 대응 DA를 드래그 연결.

이후 같은 BP에서 ItemData=DA_Item_Food 로그가 찍히고, 인벤토리 누적이 정상 작동했다.



기술 구현 1 — SpawnVolume 라인 트레이스로 바닥 안착

Day 3 C-3(층별 SpawnVolume) 배치 단계에서 SpawnVolume을 두자 픽업이 캐릭터 가슴~머리 높이로 공중에 떠 있는 시각적 버그가 발생.

원인

AVOIDSpawnVolume::GetRandomPointInVolume()이 BoxComponent의 X·Y 범위만 랜덤으로 잡고 Z 오프셋을 0으로 잠그고 있어서, 결과적으로 SpawnVolume 액터 자체의 World Z가 그대로 픽업 위치가 됐다. 액터를 캐릭터 키 높이에 두면 픽업이 그 높이에 그대로 박혔다.

해결 — 바닥 트레이스

VOIDSpawnVolume.cpp에 라인 트레이스 추가:

1
2
3
4
5
6
7
8
9
10
11
12
const FVector Top = Origin + FVector(RX, RY, BoxExtent.Z);
const FVector End = Top - FVector(0, 0, BoxExtent.Z * 2.f + 500.f);

FHitResult Hit;
FCollisionQueryParams Params;
Params.AddIgnoredActor(this);

if (GetWorld()->LineTraceSingleByChannel(Hit, Top, End, ECC_Visibility, Params))
{
    return Hit.ImpactPoint + FVector(0, 0, 2.f);  // 살짝 띄움
}
return Origin;  // 폴백

흐름: 볼륨 천장에서 시작 → 아래로 Extent.Z * 2 + 500까지 트레이스 → 바닥(StaticMesh 등)에 닿은 ImpactPoint를 사용.

라인 트레이스로 바닥을 검출하면 볼륨이 어느 높이에 있든, 바닥이 경사라도, 계단 위라도 동일하게 안착. 볼륨 Z를 천장 근처로 띄워두면 트레이스 거리도 자동 확보된다.

메시 피벗이 액터 중심이 아니어서 픽업이 바닥에 박혀 보이는 건 SpawnVolume이 아니라 BP의 MeshComp Z 오프셋으로 해결. 책임 분리.



기술 구현 2 — 자동 픽업 작동 흐름

Day 3 D-1·D-3에서 6개 픽업 BP를 만들면서 베이스 클래스의 작동 흐름을 정확히 이해할 필요가 생겼다.

자동 바인딩 vs BP 중복 등록 함정

VOIDPickupBase.cpp:28 부근:

1
2
3
4
5
6
7
8
9
void AVOIDPickupBase::BeginPlay()
{
    Super::BeginPlay();
    if (bAutoPickup && TriggerSphere)
    {
        TriggerSphere->OnComponentBeginOverlap.AddDynamic(
            this, &AVOIDPickupBase::HandleBeginOverlap);
    }
}

bAutoPickup=true 시 BeginPlay에서 자동 바인딩된다.

함정: BP에서 별도로 OnComponentBeginOverlap 이벤트를 등록하면 같은 콜백이 두 번 호출된다. 같은 액터가 한 프레임에 두 번 픽업되거나, 한 번 Destroy된 후 두 번째 호출에서 이미 파괴된 컴포넌트에 접근해 크래시 위험.

BP에서는 OnComponentBeginOverlap을 절대 등록하지 않는다. C++가 알아서 한다.

HandleBeginOverlap 가드 흐름

1
2
3
4
5
6
7
8
9
10
11
void AVOIDPickupBase::HandleBeginOverlap(...)
{
    if (!OtherActor || !ItemData) return;                              // (1)
    auto* Inv = OtherActor->FindComponentByClass<UVOIDInventoryComponent>();
    if (!Inv) return;                                                  // (2)
    if (!Inv->TryAddItem(ItemData)) return;                            // (3)

    Execute_OnPickedUp(this, OtherActor);                              // (4)
    if (NoiseComp) NoiseComp->EmitNoise(...);                          // (5)
    Destroy();                                                         // (6)
}
단계게이트실패 시
(1)OtherActor·ItemData NULL 검사무시
(2)인벤토리 컴포넌트 부착 여부무시 (좀비 등이 닿아도 픽업 안 됨)
(3)TryAddItem 무게 게이트무게 초과 시 false → 픽업 안 됨, 액터 유지
(4)인터페이스 콜백 (BP에서 추가 효과)
(5)소음 발신
(6)Destroy

검증 로그(디버그용 추가):

1
2
3
[VOID Pickup] BP_Item_Food BeginPlay  bAutoPickup=1  TriggerSphere=OK  ItemData=DA_Item_Food
[VOID Pickup] overlap by BP_VoidPlayerCharactor
[VOID Inventory] TryAddItem returned 1  (Weight now 8.00 / 30.00)

만재(MaxCarry 30kg) 도달 후엔 TryAddItem returned 0이 정상 출력 — 게이트가 정상 거부.

MeshComp 콜리전 함정

처음에 픽업 메시가 마법처럼 캐릭터를 막아서 TriggerSphere에 못 닿는 현상. 원인:

  • StaticMesh의 기본 콜리전 프리셋이 BlockAll
  • 캐릭터 캡슐이 메시에 막혀 TriggerSphere 반경 안에 들어오지 못함
  • 동시에 총알도 픽업에 막혀 픽업이 “방패” 역할

콜리전 분담: | 컴포넌트 | Collision Preset | |—|—| | MeshComp | NoCollision | | TriggerSphere | OverlapAllDynamic (또는 커스텀 — Pawn만 Overlap) |

메시는 시각만 담당, 충돌 검출은 TriggerSphere가 단독으로 책임.



기술 구현 3 — 무게가 이동 속도와 소음을 바꾼다

구현계획 D-2에 정의된 무게 기반 동적 효과를 AVOIDPlayerCharacter에 추가했다. Void GDD의 “Noise is Currency” 필러를 구현 단에서 처음 검증하는 단계.

헤더 추가

1
2
3
4
5
6
7
8
9
10
11
// Tick 활성화
PrimaryActorTick.bCanEverTick = true;

UPROPERTY(EditAnywhere, Category = "Void|Tuning")
float BaseWalkSpeed = 450.f;

UPROPERTY(EditAnywhere, Category = "Void|Tuning")
float WeightSpeedPenalty = 0.6f;   // 만재 시 60% 감속

UPROPERTY(EditAnywhere, Category = "Void|Tuning")
float NoiseWeightFactor = 1.2f;    // 만재 시 +120% 소음

Tick — 매 프레임 이동속도 갱신

1
2
3
4
5
6
7
8
9
void AVOIDPlayerCharacter::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    if (!Inventory || !GetCharacterMovement()) return;

    const float Ratio = Inventory->GetWeightRatio();        // 0.0 ~ 1.0
    const float NewSpeed = BaseWalkSpeed * (1.f - Ratio * WeightSpeedPenalty);
    GetCharacterMovement()->MaxWalkSpeed = NewSpeed;
}
무게WeightRatioMaxWalkSpeed
0 kg0.0450 (기본)
15 kg0.5315
30 kg1.0180 (만재, 60% 감속)

Fire — 소음 배율 적용

1
2
3
4
5
6
7
8
9
10
void AVOIDPlayerCharacter::Fire()
{
    // ... 발사 로직 ...
    if (NoiseComponent)
    {
        const float Ratio = Inventory ? Inventory->GetWeightRatio() : 0.f;
        const float Multiplier = 1.f + Ratio * NoiseWeightFactor;
        NoiseComponent->EmitNoise(ENoiseType::Gunshot, Multiplier);
    }
}

UVOIDNoiseComponent::EmitNoise(Type, WeightMultiplier)가 인자로 곱셈 계수를 받아 소음 반경을 함께 키운다. 만재 30kg에서 1발 사격 → 소음 반경 2.2배.

“Noise is Currency” 1차 검증

PIE 흐름:

  1. 식료품·약·부품 픽업 누적 → 무게 25 kg
  2. 이동속도 자동 감속 (450 → 200대)
  3. 좀비 추적 회피 어려움 — 일반 보행이 좀비 보행속도와 비슷
  4. 사격 시 소음 반경 1.83배 → 평소 안 들리던 거리의 좀비도 청각 트리거
  5. LogVOIDPerception: Zombie perceived noise from Player at <World Loc> 출력 확인
  6. 인벤토리가 가득 차면 좀비한테 잡히는 게 정상 결과

게임 컨셉이 시스템 단에서 작동한다는 첫 검증. 무게가 단순 페널티가 아니라 소음·은신·전투 모두에 영향 주는 단일 지표가 됐다.



기술 구현 4 — Press E Sphere Trace 인터랙션

자동 픽업과 별도로, 비자동 픽업(bAutoPickup=false)을 손으로 집을 수 있어야 한다. Press E 인터랙션 구현.

Interact() 본문

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void AVOIDPlayerCharacter::Interact()
{
    const FVector Start = GetActorLocation() + GetActorForwardVector() * 50.f;
    const FVector End = Start + GetActorForwardVector() * 200.f;

    TArray<FHitResult> Hits;
    FCollisionQueryParams Params;
    Params.AddIgnoredActor(this);

    GetWorld()->SweepMultiByChannel(
        Hits, Start, End, FQuat::Identity,
        ECC_Visibility, FCollisionShape::MakeSphere(80.f), Params);

    AActor* Best = nullptr;
    float BestDistSq = TNumericLimits<float>::Max();

    for (const FHitResult& Hit : Hits)
    {
        AActor* Actor = Hit.GetActor();
        if (!Actor) continue;
        if (!Actor->Implements<UVOIDItemInterface>()) continue;       // (A)

        const float DistSq = FVector::DistSquared(GetActorLocation(), Actor->GetActorLocation());
        if (DistSq < BestDistSq) { BestDistSq = DistSq; Best = Actor; }
    }

    if (!Best) { /* DrawDebugSphere 노란색 */ return; }

    UVOIDItemDataAsset* Data = IVOIDItemInterface::Execute_GetItemData(Best);   // (B)
    if (!Data || !Inventory->TryAddItem(Data)) return;
    IVOIDItemInterface::Execute_OnPickedUp(Best, this);
    if (NoiseComponent) NoiseComponent->EmitNoise(ENoiseType::Pickup, 1.f);
    Best->Destroy();
    /* DrawDebugSphere 녹색 */
}
단계동작
Sweep캐릭터 정면 50~250cm 사이, 반경 80 Sphere
필터 (A)IVOIDItemInterface 구현체만 채택
최단 선택가장 가까운 1개
픽업 (B)Execute_GetItemData → 무게 게이트 → Execute_OnPickedUp → 소음 → Destroy

BlueprintNativeEvent 인터페이스 호출 패턴

이번에 처음 명확해진 부분. IVOIDItemInterfaceBlueprintNativeEvent로 선언돼 있어서 C++ 구현체와 BP 구현체 양쪽을 잡으려면:

안 되는 호출되는 호출
Cast<IVOIDItemInterface>(Actor)Actor->Implements<UVOIDItemInterface>()
IPtr->GetItemData()IVOIDItemInterface::Execute_GetItemData(Actor)

이유:

  • BlueprintNativeEvent는 BP에서 구현하면 C++ 인터페이스 vtable에 등록되지 않음
  • Cast<I>는 vtable 기반이라 BP만 구현한 액터를 놓침
  • Implements<U>()는 UObject reflection 기반이라 BP 구현도 인식
  • Execute_FuncName(Object, Args...)는 정적 디스패처 — vtable과 무관하게 BP/C++ 양쪽 호출

정리:

  • BlueprintNativeEvent 인터페이스 → Implements<UInterfaceClass>() + Execute_FuncName(Object, ...)
  • 순수 C++ 인터페이스(no UFUNCTION) → Cast<IInterface> + IPtr->Func() 사용 가능

자동 픽업과 공존

모드bAutoPickup동작
자동 픽업true닿는 즉시 자동 흡수, 빠른 파밍
수동 픽업falsePress E로만, 의도적 수집 (희귀 부품·트랩성 아이템 등)

하나의 베이스 클래스로 두 픽업 모드 분기. BP의 변수 토글만으로 픽업 정책을 바꾼다. 계획상 차량 부품(DA_Part_*)은 bAutoPickup=false로 두고, 식료품·약은 true로 분리할 예정 (현재 PIE 검증은 모두 자동으로 통일).

디버그 시각화

1
2
3
4
5
6
#if !(UE_BUILD_SHIPPING)
    DrawDebugSphere(GetWorld(), Best ? Best->GetActorLocation() : End,
                    80.f, 12,
                    Best ? FColor::Green : FColor::Yellow,
                    false, 1.f);
#endif

성공 시 녹색, 미발견 시 노란색. 인터랙션 디버깅에서 시각 피드백이 로그 100줄보다 빠르다.



핵심 요약 — DataTable의 RowStruct는 생성 시 한 번 정해지면 사후 변경이 불가능해서, 잘못 만든 DT는 삭제·재생성뿐이다. 그리고 표 형식 데이터는 DataTable, 단일 객체 데이터는 DataAsset — 슬롯의 베이스 클래스를 보고 어느 쪽 자리인지 판단해야 같은 함정에 안 빠진다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.