컨텐츠 검색
[UE5 C++] 인터페이스 기반 아이템 시스템과 데이터 주도적 설계, 그리고 UFUNCTION의 중요성

2026. 1. 27. 19:25Unreal Engine/개념

1. 오늘의 학습 목표

오늘의 목표는 지난 시간에 이어 Interface를 활용하여 확장성 있는 아이템 시스템을 구축을 복습하고, Data Table을 이용해 기획 데이터 변경에 유연하게 대처하는 환경을 만드는 것입니다. 또한 개발 과정에서 다시금 깨달은 언리얼 리플렉션 시스템과 델리게이트의 관계를 정리합니다.

게임에는 코인, 포션, 지뢰 등 수많은 상호작용 오브젝트가 존재합니다. 이를 Cast<ACoin>, Cast<AMine> 처럼 일일이 캐스팅하고 분기문으로 처리하면 코드가 난잡해지고 유지보수가 불가능해집니다.


2. 아키텍처 설계: Interface & Inheritance

객체 지향의 다형성(Polymorphism)을 극대화하기 위해 "인터페이스"와 "상속"을 혼합한 구조를 설계했습니다.

classDiagram
    %% 1. Unreal Engine Parent Class
    class AActor {
        <<Engine Class>>
    }

    %% 2. Interface Definition
    class IItemInterface {
        <<Interface>>
        +OnItemOverlap(AActor OverlapActor)
        +OnItemEndOverlap(AActor OverlapActor)
        +ActivateItem(AActor Activator)
        +GetItemType() FName
    }

    %% 3. Base Item Class (Implements Interface)
    class ABaseItem {
        +USceneComponent Scene
        +USphereComponent Collision
        +UStaticMeshComponent StaticMesh
        +FName ItemType
        +OnItemOverlap()
        +OnItemEndOverlap()
        +ActivateItem()
        +GetItemType()
        +DestroyItem()
    }

    %% Relationships for BaseItem
    AActor <|-- ABaseItem : Inherits
    IItemInterface <|.. ABaseItem : Implements

    %% 4. Coin Branch
    class ACoinItem {
        +int32 PointValue
        +ActivateItem()
    }
    ABaseItem <|-- ACoinItem

    class ABigCoinItem {
        +ActivateItem()
    }
    class ASmallCoinItem {
        +ActivateItem()
    }
    ACoinItem <|-- ABigCoinItem
    ACoinItem <|-- ASmallCoinItem

    %% 5. Mine Item Branch
    class AMineItem {
        +float ExplosionDelay
        +float ExplosionRadius
        +float ExplosionDamage
        +ActivateItem()
        +Explode()
    }
    ABaseItem <|-- AMineItem

    %% 6. Healing Item Branch
    class AHealingItem {
        +int32 HealAmount
        +ActivateItem()
    }
    ABaseItem <|-- AHealingItem

코드에서 아이템의 종류(Coin, Mine 등)를 구분할 때 FString이 아닌 FName을 사용했습니다. 그 이유는 다음과 같습니다.

  • 비교 연산 속도: FString은 문자열을 비교할 때 문자 하나하나를 확인해야 하지만, FName은 내부적으로 해시값(Index)으로 저장됩니다. 따라서 타입을 비교할 때 단순 정수 비교와 거의 동일한 속도(O(1))가 나와 빈번한 호출에도 성능 저하가 없습니다.
  • 메모리 효율: FName은 같은 문자열을 전역 테이블에 한 번만 저장하고 이를 공유해서 사용합니다. "Coin"이라는 타입을 가진 아이템이 수백 개 생성되어도, 실제 문자열 메모리는 중복 할당되지 않아 가볍습니다.

A. 인터페이스 (IItemInterface)

모든 아이템이 공통으로 가져야 할 '행동 규약'입니다. UINTERFACE 매크로를 사용하여 리플렉션 시스템에 등록했습니다.

  • OnItemOverlap / OnItemEndOverlap: 충돌 감지 로직
  • ActivateItem: 아이템 고유 효과 발동 (코인 획득, 힐링, 폭발 등)
  • 설계 의도: 플레이어는 충돌한 액터가 무엇인지 몰라도 IItemInterface로 캐스팅하여 ActivateItem만 호출하면 됩니다. 이를 통해 결합도(Coupling)를 획기적으로 낮췄습니다.
// 인터페이스를 UObject 시스템에서 사용하기 위한 기본 매크로
UINTERFACE(MinimalAPI)
class UItemInterface : public UInterface
{
    GENERATED_BODY()
};

// 실제 C++ 레벨에서 사용할 함수 원형(시그니처)를 정의
class HIGHSCORE_API IItemInterface
{
    GENERATED_BODY()
public:
    // 플레이어가 이 아이템의 범위에 들어왔을 때 호출
    virtual void OnItemOverlap(
        UPrimitiveComponent* OverlappedComp,
        AActor* OtherActor,
        UPrimitiveComponent* OtherComp,
        int32 OtherBodyIndex,
        bool bFromSweep,
        const FHitResult& SweepResult) = 0;

    // 플레이어가 이 아이템의 범위를 벗어났을 때 호출
    virtual void OnItemEndOverlap(
        UPrimitiveComponent* OverlappedComp,
        AActor* OtherActor,
        UPrimitiveComponent* OtherComp,
        int32 OtherBodyIndex) = 0;

    // 아이템이 사용되었을 때 호출
    virtual void ActivateItem(AActor* Activator) = 0;

    // 이 아이템의 유형(타입)을 반환 (예: "Coin", "Mine" 등)
    virtual FName GetItemType() const = 0;
};

B. 기본 아이템 (ABaseItem)

AActor와 IItemInterface를 상속받은 추상 클래스 성격의 부모입니다.

  • 컴포넌트: Scene(Root) → Sphere(Collision) → StaticMesh(Visual)
  • 역할: 물리적인 충돌 감지(Overlap)와 컴포넌트 초기화를 담당합니다.

3. Data-Driven Spawning: 데이터 테이블 활용

아이템의 스폰 확률이나 종류를 코드에 하드코딩하지 않고, 데이터 테이블로 분리하여 관리했습니다.

// ItemSpawnRow.h

#include "CoreMinimal.h"
#include "Engine/DataTable.h" // FTableRowBase 정의가 들어있는 헤더
#include "ItemSpawnRow.generated.h"


USTRUCT(BlueprintType)
struct FItemSpawnRow : public FTableRowBase
{
    GENERATED_BODY()

public:
    // 아이템 이름
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FName ItemName;

    // 어떤 아이템 클래스를 스폰할지
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TSubclassOf<AActor> ItemClass;

    // 이 아이템의 스폰 확률
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    float SpawnChance;
};

트러블 슈팅: 파일명 변경과 "데이터 증발"

  • 문제 상황: 데이터 테이블을 관리하던 중 파일명을 변경하고 구조체 타입을 맞추는 작업을 진행했습니다. 컴파일 후 에디터를 확인해보니, Source File 경로는 잘 연결되어 있는데 정작 내부의 Row Data(행 데이터)가 모두 사라져 빈 껍데기만 남는 현상이 발생했습니다.
  • 원인 및 해결: 언리얼 에디터의 데이터 테이블 에셋은 참조하던 원본 파일(CSV 등)이나 구조체 정보가 변경되면, 데이터 무결성을 위해 기존 데이터를 초기화(Reset)하는 경우가 있음을 확인했습니다. 다행히 데이터가 많지 않아 다시 입력하여 복구했습니다.
  • 교훈: "데이터 테이블의 구조(Struct)를 바꾸거나 파일명을 변경하는 리팩토링 시에는 반드시 내부 데이터를 백업(Export as CSV)해두어야 한다."

4. Game Loop: 상태 관리의 분리

싱글 플레이어 게임이지만 데이터의 성격에 따라 관리 주체를 명확히 했습니다.

  • GameState (Level Scope): 현재 레벨의 남은 시간, 스폰된 코인 개수 관리. 레벨이 바뀌면 초기화됩니다.
  • GameInstance (Game Scope): 게임 전체의 누적 점수(Total Score) 관리.
    OpenLevel로 맵이 바뀌어도 데이터가 유지되어야 하기 때문입니다.

5. 핵심 리마인드: UFUNCTION과 AddDynamic

이전에 델리게이트를 공부할 때 겪었던 문제였지만, 오늘 아이템 충돌 로직을 구현하면서 UFUNCTION의 존재 이유를 확실하게 재정립했습니다.

5-1. 왜 AddDynamic에는 UFUNCTION이 필수인가?

아이템의 충돌 감지 및 제거를 위해 OnComponentBeginOverlap, OnComponentEndOverlap 델리게이트에 함수를 바인딩하는 과정입니다.

// BascItem.cpp
void ABaseItem::BeginPlay()
{
    Super::BeginPlay();

    Collision->OnComponentBeginOverlap.AddDynamic(this, &ABaseItem::OnItemOverlap);
    Collision->OnComponentEndOverlap.AddDynamic(this, &ABaseItem::OnItemEndOverlap);
}

여기서 바인딩되는 OnItemOverlap 함수 선언부에 UFUNCTION() 매크로를 붙이지 않으면, 컴파일은 되더라도 런타임에 바인딩이 실패하거나 함수가 호출되지 않습니다.

5-2. 원리 이해 (Reflection System)

  • Dynamic Delegate는 일반적인 C++ 함수 포인터를 사용하지 않고, 함수의 이름(String 문자열)을 통해 호출할 대상을 찾습니다. (이는 직렬화와 블루프린트 연동을 위해서입니다.)
  • 하지만 순수 C++ 함수는 컴파일되면 이름 정보가 사라집니다.
  • 따라서 UFUNCTION() 매크로를 붙여 언리얼 리플렉션 시스템에 "이 함수가 존재한다"는 정보를 등록해야만, 델리게이트가 실행 중에 이름을 통해 함수를 찾아낼 수 있습니다.
// BaseItem.h
UFUNCTION()
virtual void OnItemOverlap(
    UPrimitiveComponent* OverlappedComp,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex,
    bool bFromSweep,
    const FHitResult& SweepResult) override;

UFUNCTION()
virtual void OnItemEndOverlap(
    UPrimitiveComponent* OverlappedComp,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex) override;

오늘 구현에서는 이 원리를 잊지 않고 적용하여, 충돌 이벤트 바인딩을 한 번에 성공시킬 수 있었습니다.


6. 최종 결과 (Result)

  • Random Spawn: SpawnVolume 범위 내 랜덤 위치 생성.
  • Interaction: Coin(점수), Mine(데미지), Healing(회복) 각 기능 정상 작동.
  • Game Loop: 코인을 모두 모으면 GameState가 감지하여 다음 레벨로 이동하며 점수 누적.

7. 마치며

오늘 작업을 통해 "데이터(Data)"와 "로직(Logic)"을 분리하는 아키텍처의 중요성을 확인했습니다.
또한 과거의 트러블 슈팅 경험(UFUNCTION)이 쌓여 오늘 개발의 밑거름이 되는 것을 느끼며, "기록과 복기"의 중요성을 다시금 깨달았습니다.

다음 단계로는 UI(HUD)를 연동하여 시각적인 피드백을 강화할 예정입니다.