컨텐츠 검색
[UE5 C++] 아이템 인터페이스 설계와 생성자 델리게이트 바인딩의 함정

2026. 1. 15. 21:10Unreal Engine/개념

1. 오늘의 학습 목표

게임 내에는 코인, 회복 아이템, 지뢰 등 다양한 상호작용 오브젝트가 존재합니다. 이들을 개별 클래스로 관리하면 플레이어 코드에 수많은 Cast와 분기문이 생깁니다. 이를 방지하기 위해 Interface를 활용한 확장성 있는 아이템 시스템을 구축하는 것이 목표입니다.

또한, C++ 클래스를 상속받은 블루프린트와의 연동 과정에서 발생한 치명적인 오버랩 이벤트 버그를 해결하며 언리얼 엔진의 액터 생명주기를 깊이 이해하고자 합니다.


2. 아이템 시스템 구조 설계 (Interface & Inheritance)

객체 지향의 다형성을 활용하여 유지보수가 용이한 구조를 짰습니다.

 

A. 인터페이스 (IItemInterface) 가장 먼저, 모든 아이템이 공통으로 가져야 할 행동을 정의한 IItemInterface를 작성했습니다. 리플렉션을 위해 UINTERFACE를 사용했고, 순수 가상 함수로 규격을 잡았습니다.

  • OnItemOverlap / OnItemEndOverlap: 충돌 감지
  • ActivateItem: 아이템 고유 효과 발동
  • GetItemType: 아이템 식별 (FName 사용으로 오버헤드 최소화)
// ItemInterface.h

// 아이템 오버랩 시 호출되는 함수
UFUNCTION()
virtual void OnItemOverlap(
    UPrimitiveComponent* OverlappedComp, // 오버랩된 컴포넌트      : 아이템의 콜리전 컴포넌트
    AActor* OtherActor,                  // 오버랩한 액터          : 플레이어 또는 기타 액터
    UPrimitiveComponent* OtherComp,      // 오버랩한 액터의 컴포넌트: 플레이어의 콜리전 컴포넌트 등
    int32 OtherBodyIndex,                // 오버랩한 바디 인덱스    : 여러 바디가 있을 때 구분용
    bool bFromSweep,                     // 스윕 여부             : 스윕 이동에 의한 오버랩인지 여부
    const FHitResult& SweepResult) = 0;  // 충돌 정보             : 스윕 충돌 시의 상세 정보

// 아이템 오버랩 종료 시 호출되는 함수
UFUNCTION()
virtual void OnItemEndOverlap(
    UPrimitiveComponent* OverlappedComp,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex) = 0;

// 아이템 활성화 시 호출되는 함수
virtual void ActivateItem(AActor* Activator) = 0;

// 아이템 타입을 반환하는 함수
virtual FName GetItemType() const = 0;

B. 기본 아이템 (ABaseItem) AActor와 IItemInterface를 상속받은 추상 클래스 성격의 부모 클래스입니다.

  • 컴포넌트 구성: Scene (Root) -> Sphere (Collision) -> StaticMesh
  • 역할: 실제 충돌 처리 로직(OnItemOverlap 구현)과 컴포넌트 세팅을 담당합니다.
// BaseItem.cpp

ABaseItem::ABaseItem()
{
    PrimaryActorTick.bCanEverTick = false;

    Scene = CreateDefaultSubobject<USceneComponent>(TEXT("Scene"));
    SetRootComponent(Scene);

    Collision = CreateDefaultSubobject<USphereComponent>(TEXT("Collision"));
    Collision->SetCollisionProfileName(TEXT("OverlapAllDynamic"));
    Collision->SetupAttachment(Scene);

    StaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMesh"));
    StaticMesh->SetupAttachment(Collision);
}

C. 구체적인 아이템 구현 BaseItem을 상속받아 각기 다른 데이터를 가진 자식 클래스들을 구현했습니다.

  • CoinItem: PointValue를 가짐. (상속: BigCoinItem, SmallCoinItem)
  • HealingItem: HealAmount를 가짐.
  • MineItem: ExplosionDelay, ExplosionRadius, ExplosionDamage 을 가짐.
// BigCoinItem.cpp

#include "BigCoinItem.h"

ABigCoinItem::ABigCoinItem()
{
    PointValue = 50;
    ItemType = "BigCoin";
}

void ABigCoinItem::ActivateItem(AActor* Activator)
{
    DestroyItem();
}

3. 문제 발생: "왜 로그가 안 뜨지?" (The Problem)

다음으로, BaseItem의 생성자(ABaseItem::ABaseItem)에서 콜리전 컴포넌트에 오버랩 델리게이트를 바인딩했습니다.

// BaseItem.cpp (문제의 코드)
ABaseItem::ABaseItem()
{
    // ... 컴포넌트 생성 코드 ...
    
    // 생성자에서 바인딩 시도
    Collision->OnComponentBeginOverlap.AddDynamic(this, &ABaseItem::OnItemOverlap);
}

 

그리고 스태틱 메시를 선별하여 블루프린트 클래스로 만들었고 오버랩이 잘 되는지 확인하기 위해 테스트 로그를 띄웠습니다.

// 플레이어가 아이템 범위에 들어왔을 때 동작
void ABaseItem::OnItemOverlap(
    UPrimitiveComponent* OverlappedComp,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex,
    bool bFromSweep,
    const FHitResult& SweepResult)
{
    // OtherActor가 존재하는지, OtherActor가 Player인지 확인.
    if (OtherActor && OtherActor->ActorHasTag("Player"))
    {
        GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT("Overlap!!!")));
        ActivateItem(OtherActor);
    }
}

기대했던 결과는 플레이어가 아이템에 닿았을 때 OnItemOverlap 함수가 호출되어 "Overlap!!!" 로그가 뜨는 것이었으나, 아무런 반응이 없었습니다.

 


4. 가설 설정 및 검증 과정 (Debugging Process)

단순한 실수인지, 엔진 내부의 문제인지 파악하기 위해 단계별로 디버깅을 진행했습니다.

1차 시도: 물리/콜리전 설정 의심 가장 흔한 실수인 콜리전 설정을 점검했습니다.

  • Collision Preset: BlockAllDynamic → OverlapAllDynamic 변경 (실패)
  • Generate Overlap Events: 아이템과 플레이어 양쪽 모두 true 확인 (실패)
  • Collision Size: 스피어 크기가 너무 작아 메시 안쪽에 파묻혔나 싶어 크기 조정 (실패)

2차 시도: 비교 실험 (Blueprint vs C++) "혹시 내 로직이 틀렸나?"를 검증하기 위해 블루프린트 에디터에서 OnComponentBeginOverlap 노드를 직접 연결해 보았습니다.

  • 결과: 블루프린트에서는 정상적으로 로그가 출력됨.
  • 판단: 물리 엔진 설정은 정상이다. C++ 코드에서의 델리게이트 바인딩(Binding)이 제대로 이루어지지 않고 있다.

3차 시도: 엔진 버전과 생명주기(Life Cycle) 가설

현재 사용 중인 UE 5.7 환경에서, "생성자(Constructor)에서의 바인딩이 유실되는 것이 아닐까?"라는 가설을 세웠습니다.

이 내용에 대해 알아보니 생성자는 CDO(Class Default Object)를 만들 때 호출되는데, 블루프린트가 초기화되거나 직렬화(Serialization)되는 과정에서 생성자에서 맺은 바인딩 정보가 초기화되거나 덮어씌워질 가능성이 있다고 합니다.

(특히 라이브 코딩이나 핫 리로드 환경, 혹은 엔진 최적화 과정에서 발생 가능)

 


5. 해결 및 인사이트: 바인딩 시점 변경

가설을 검증하기 위해 바인딩 코드를 생성자가 아닌, 컴포넌트 초기화가 완료된 직후인

PostInitializeComponents 로 옮겼습니다.

// BaseItem.cpp (수정된 코드)
void ABaseItem::PostInitializeComponents()
{
    Super::PostInitializeComponents();

    // 동적인 이벤트 바인딩은 여기서 수행
    if (Collision)
    {
        Collision->OnComponentBeginOverlap.AddDynamic(this, &ABaseItem::OnItemOverlap);
        Collision->OnComponentEndOverlap.AddDynamic(this, &ABaseItem::OnItemEndOverlap);
    }
}

 

결과: 코드를 수정하고 컴파일하자마자 "Overlap!!!" 로그가 정상적으로 출력되었습니다.

오버랩 버그
오버랩 버그 해결

 


6. 오늘 배운 점 요약

 

  • 인터페이스의 강력함: IItemInterface를 통해 플레이어는 Cast<ABaseItem> 없이도 IItemInterface로 캐스팅하여 모든 종류의 아이템(코인, 지뢰 등)과 상호작용할 수 있게 되었습니다. 결합도(Coupling)를 낮추는 핵심입니다.
  • 생성자의 역할 vs 런타임의 역할:
    • 생성자: CreateDefaultSubobject 같은 컴포넌트 생성과 변수 기본값(Default Value) 설정 등 정적인(Static) 데이터 세팅에 집중해야 합니다.
    • BeginPlay / PostInitializeComponents: 델리게이트 바인딩(AddDynamic), 타이머 시작 등 동적인(Dynamic) 로직 연결은 액터의 생명주기가 확실히 시작된 시점에 수행해야 안전합니다.
  • 디버깅 태도: "안 된다"에서 멈추지 않고, 블루프린트와 비교 실험을 통해 문제의 범위를 좁혀나가며(물리 설정 -> 코드 바인딩 -> 생명주기) 원인을 찾은 과정이 주효했습니다.