언리얼 엔진

[언리얼 엔진] 액터 컴포넌트로 체력 시스템 분리하기

dhlee-dev 2026. 5. 10. 21:33

이번에는 언리얼 엔진의 Actor Component에 대해 알아보려고 한다.

액터 컴포넌트가 무엇인지, 왜 필요한지 정리하고,
실제로 체력 시스템을 액터 컴포넌트로 분리했던 예시를 함께 살펴보려고 한다.


Actor Component란?

언리얼 엔진에서 게임 월드에 존재하는 기본 단위는 Actor이다.
예를 들면 플레이어, 적, 아이템, 문, 투사체, 방 같은 오브젝트들이 모두 Actor가 될 수 있다.

그런데 하나의 Actor가 모든 기능을 직접 가지고 있으면 클래스가 점점 커진다.

예를 들어 플레이어 클래스가 아래 기능을 전부 직접 가진다고 생각해보면,

class APlayerCharacter
{
    // 이동
    // 공격
    // 체력
    // 데미지 처리
    // 사망 처리
    // UI 갱신
    // 인벤토리
    // 퀘스트
    // 상호작용
    // 버프
    // 상태 이상
};

프로젝트가 커질수록 PlayerCharacter는 점점 비대한 클래스가 될 것이다.

그래서 언리얼에서는 기능을 컴포넌트 단위로 나누고, 필요한 Actor에 붙여서 사용하는 방식을 자주 사용한다.

PlayerCharacter
 ├─ HealthComponent
 ├─ InventoryComponent
 ├─ QuestComponent
 ├─ InteractionComponent
 └─ CombatComponent

즉, Actor는 여러 기능을 조립해서 가지는 객체가 되고, 실제 세부 기능은 각각의 컴포넌트가 담당하는 구조이다.


Actor Component를 사용하는 이유

Actor Component를 사용하는 가장 큰 이유는 클래스가 비대해지는 것을 막고, 기능을 재사용하기 위해서이다.
체력 기능을 플레이어 클래스 안에 직접 작성하면 처음에는 단순하다.

class APlayerCharacter
{
private:
    int HP;
    int MaxHP;

public:
    void TakeDamage();
    void Die();
    void UpdateHPUI();
};

하지만 나중에 적, 보스, 파괴 가능한 오브젝트도 체력이 필요해지면
같은 코드를 적, 보스, 파괴 가능한 오브젝트 등에 반복해서 작성하게 될 수 있다.

이럴 때 체력 기능을 컴포넌트로 분리하면 여러 Actor에서 같은 기능을 재사용할 수 있다.

PlayerCharacter
 └─ HealthComponent

EnemyCharacter
 └─ HealthComponent

BossCharacter
 └─ HealthComponent

BreakableObject
 └─ HealthComponent

즉, 체력이라는 기능을 Actor에서 분리해서 필요한 곳에 붙일 수 있게 된다.


HealthManager 컴포넌트 만들기

이번에 작성한 체력 컴포넌트는 UActorComponent를 상속받는 UHealthManager 클래스이다.

// 사망, 피격 델리게이트 선언
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FDeathSignature, AController*, Instigator);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FDamagedSignature, float, CurrentHealth, float, MaxHealth, float, ActualDamage);

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class SIMPLESHOOTER_API UHealthManager : public UActorComponent
{
    GENERATED_BODY()

public:
    UHealthManager();

    UPROPERTY(BlueprintAssignable)
    FDeathSignature OnDeath;

    UPROPERTY(BlueprintAssignable)
    FDamagedSignature OnDamaged;

protected:
    virtual void BeginPlay() override;

    UFUNCTION()
    void TakeDamage(
        AActor* DamagedActor,
        float Damage,
        const UDamageType* DamageType,
        AController* Instigator,
        AActor* Causer
    );

protected:
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Health")
    float MaxHealth;

    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Health")
    float CurrentHealth;

    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Health")
    bool bIsDead;
};

이 컴포넌트의 역할은 단순하다.

  • 최대 체력 저장
  • 현재 체력 저장
  • 데미지 처리
  • 사망 여부 판단
  • 체력 변경 이벤트 발생
  • 사망 이벤트 발생

여기서 중요한 점은 HealthManager가 UI 갱신이나 사망 애니메이션 재생을 직접 하지 않는다는 것이다.

PlayerCharacter->UpdateUI() 와 같이 컴포넌트 내에서 직접 플레이어를 알고 호출하는 것이 아니라
체력 컴포넌트는 체력에 대한 책임만 가지고, 체력이 변경되었거나 사망했다는 사실만 외부에 알려준다.


Delegate로 체력 변화 알리기

체력 변경과 사망 처리를 외부에 알리기 위해 델리게이트를 사용했다.

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(
    FDeathSignature,
    AController*,
    Instigator
);

DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(
    FDamagedSignature,
    float,
    CurrentHealth,
    float,
    MaxHealth,
    float,
    ActualDamage
);

OnDamaged는 체력이 감소했을 때 호출할 이벤트이고, OnDeath는 체력이 0 이하가 되었을 때 호출할 이벤트이다.

UPROPERTY(BlueprintAssignable)
FDeathSignature OnDeath;

UPROPERTY(BlueprintAssignable)
FDamagedSignature OnDamaged;

이렇게 작성하면 컴포넌트는 데미지를 처리한 뒤 이벤트를 발생시킬 수 있고,
캐릭터나 UI는 해당 이벤트에 바인딩해서 필요한 동작을 수행할 수 있다.


Owner의 데미지 이벤트에 바인딩하기

UActorComponent는 혼자 월드에 존재할 수 없다. 반드시 어떤 Actor에 붙어서 동작한다.

컴포넌트 입장에서 자신을 소유한 Actor는 GetOwner()로 가져올 수 있다.

이번 코드에서는 BeginPlay에서 Owner의 OnTakeAnyDamage 이벤트에 HealthManagerTakeDamage 함수를 바인딩했다.

void UHealthManager::BeginPlay()
{
    Super::BeginPlay();

    CurrentHealth = MaxHealth;

    GetOwner()->OnTakeAnyDamage.AddDynamic(
        this,
        &UHealthManager::TakeDamage
    );
}

이 코드는 의미상 이렇게 볼 수 있다.

이 컴포넌트를 소유한 Actor가 데미지를 받으면,
HealthManager의 TakeDamage 함수를 호출한다.

즉, 이 컴포넌트를 플레이어에게 붙이면 플레이어가 받은 피해를 처리하고, 적에게 붙이면 적이 받은 피해를 처리할 수 있다.

컴포넌트는 자신이 플레이어에게 붙었는지, 적에게 붙었는지 몰라도 된다.
그냥 자신의 Owner가 데미지를 받으면 체력을 감소시키는 역할만 수행한다.


데미지 처리하기

실제 데미지 처리는 TakeDamage 함수에서 이루어진다.

void UHealthManager::TakeDamage(
    AActor* DamagedActor,
    float Damage,
    const UDamageType* DamageType,
    AController* Instigator,
    AActor* Causer
)
{
    if (bIsDead) return;
    if (Damage <= 0.f) return;

    float ActualDamage = Damage;

    CurrentHealth -= ActualDamage;
    CurrentHealth = FMath::Max(CurrentHealth, 0.f);

    OnDamaged.Broadcast(CurrentHealth, MaxHealth, ActualDamage);

    if (CurrentHealth <= 0.f)
    {
        bIsDead = true;
        OnDeath.Broadcast(Instigator);
    }
}

이미 죽은 상태라면 더 이상 데미지를 처리하지 않고, 데미지가 0 이하인 경우도 무시한다.
그 후 현재 체력에서 데미지를 빼고, 체력이 0 아래로 내려가지 않도록 FMath::Max를 사용했다.

체력이 변경되면 OnDamaged 이벤트를 발생시키고, 체력이 0 이하가 되면 사망 상태로 바꾼 뒤 OnDeath 이벤트를 발생시킨다.

여기서 중요한 점은 HealthManager가 직접 UI를 갱신하거나 사망 애니메이션을 재생하지 않는다는 것이다.
그냥 “체력이 변경되었다”, “사망했다”는 사실만 이벤트로 알려준다.


Character에서 이벤트 바인딩하기

캐릭터 클래스에서는 UHealthManager를 멤버 변수로 가지고 있고, 생성자에서 컴포넌트를 생성한다.

UPROPERTY(VisibleAnywhere)
TObjectPtr<UHealthManager> HealthComponent;
// 생성자 내부에서 체력 관리 컴포넌트 생성
HealthComponent = CreateDefaultSubobject<UHealthManager>(TEXT("HealthComponent"));

그리고 BeginPlay에서 HealthManager의 이벤트에 캐릭터 함수를 바인딩했다.

void ASimpleShooterCharacter::BeginPlay()
{
    Super::BeginPlay();

    if (HealthComponent)
    {
        HealthComponent->OnDamaged.AddDynamic(
            this,
            &ASimpleShooterCharacter::HandleHealthChanged
        );

        HealthComponent->OnDeath.AddDynamic(
            this,
            &ASimpleShooterCharacter::HandlePlayerDeath
        );
    }
}

이 구조는 다음과 같이 해석할 수 있다.

HealthManager에서 체력 변경 이벤트가 발생하면
ASimpleShooterCharacter::HandleHealthChanged를 호출한다.

HealthManager에서 사망 이벤트가 발생하면
ASimpleShooterCharacter::HandlePlayerDeath를 호출한다.

즉, 체력 계산은 HealthManager가 하고, 체력 변경에 대한 플레이어 전용 반응은 Character가 처리한다.


체력 UI 갱신하기

체력이 변경되면 캐릭터의 HandleHealthChanged 함수가 호출된다.
이전에 인자 3개를 받는 델리게이트를 사용했으므로 해당 함수는 인자를 3개 사용한다.

void ASimpleShooterCharacter::HandleHealthChanged(
    float CurrentHealth,
    float MaxHealth,
    float ActualDamage
)
{
    if (!HPBar) return;

    UHPBarWidget* HPBarWidget = Cast<UHPBarWidget>(
        HPBar->GetUserWidgetObject()
    );

    if (HPBarWidget)
    {
        HPBarWidget->UpdateHPBar(CurrentHealth, MaxHealth);
    }
}

여기서는 WidgetComponent에서 실제 UserWidget 객체를 꺼내고,
UHPBarWidget으로 캐스팅한 뒤 체력바를 갱신한다.

체력바 위젯 쪽에서는 현재 체력과 최대 체력을 이용해서 ProgressBar의 비율을 변경한다.

void UHPBarWidget::UpdateHPBar(float CurrentHealth, float MaxHealth)
{
    if (!HPBar) return;

    float percent = MaxHealth <= 0.f ? 0.f : CurrentHealth / MaxHealth;

    HPBar->SetPercent(percent);
}

이렇게 하면 HealthManager는 UI를 직접 알 필요가 없다.
HealthManager는 체력 변경 이벤트만 발생시키고, 캐릭터가 그 이벤트를 받아 UI를 갱신한다.


사망 처리하기

체력이 0 이하가 되면 HealthManager에서 OnDeath 이벤트가 발생하고, 캐릭터의 HandlePlayerDeath 함수가 호출된다.

void ASimpleShooterCharacter::HandlePlayerDeath(AController* InstigatorActor)
{
    bIsReloading = false;
    bIsAiming = false;

    ClearCameraRecoil();
    ChangeAimCamera(false);

    APlayerController* PlayerController = Cast<APlayerController>(Controller);
    if (PlayerController)
    {
        DisableInput(PlayerController);
    }

    GetCharacterMovement()->DisableMovement();

    if (DeathMontage)
    {
        PlayAnimMontage(DeathMontage);
    }
}

사망 시에는 재장전 상태와 조준 상태를 해제하고, 카메라 반동을 정리했다.
그리고 플레이어 입력과 이동을 비활성화한 뒤 사망 애니메이션 몽타주를 재생한다.

이 부분도 HealthManager가 직접 처리하지 않는다.
입력 비활성화, 조준 해제, 사망 애니메이션 재생은 플레이어 캐릭터에게 필요한 처리이기 때문이다.

만약 같은 HealthManager를 적에게 붙인다면 적은 AI를 멈추거나, 아이템을 드랍하거나,
일정 시간 뒤 Destroy되는 식으로 다른 사망 처리를 할 수 있다.

그래서 HealthManager 안에서 사망 애니메이션이나 입력 비활성화를 직접 처리하지 않는 것이 더 좋다.


전체 흐름 정리

이번에 구현한 체력 처리 흐름은 다음과 같다.

1. Actor가 데미지를 받음
   ↓
2. Actor의 OnTakeAnyDamage 이벤트 발생
   ↓
3. HealthManager::TakeDamage 호출
   ↓
4. CurrentHealth 감소
   ↓
5. OnDamaged.Broadcast()
   ↓
6. Character::HandleHealthChanged 호출
   ↓
7. HPBarWidget 갱신
   ↓
8. 체력이 0 이하라면 OnDeath.Broadcast()
   ↓
9. Character::HandlePlayerDeath 호출
   ↓
10. 입력 비활성화, 이동 비활성화, 사망 애니메이션 재생

이 구조에서 각 클래스의 역할은 다음과 같이 나눌 수 있다.

HealthManager
- 체력 데이터 관리
- 데미지 처리
- 사망 판정
- 체력 변경 이벤트 발생
- 사망 이벤트 발생

SimpleShooterCharacter
- HealthManager 이벤트 바인딩
- 체력 UI 갱신
- 사망 시 입력 비활성화
- 사망 애니메이션 재생
- 플레이어 전용 처리

HPBarWidget
- ProgressBar 갱신

이렇게 나누면 HealthManager는 체력 관리에만 집중할 수 있고,
캐릭터는 플레이어에게 필요한 반응만 처리할 수 있다.


스크린샷



ActorComponent 외의 컴포넌트들

이번 글에서는 체력 관리를 분리하기 위해 UActorComponent를 사용했다.

UActorComponent는 위치, 회전, 크기 같은 Transform을 가지지 않는 컴포넌트이다.
그래서 체력 관리, 인벤토리, 퀘스트, 전투 로직처럼 월드 상의 위치가 중요하지 않은 기능을 담당하기에 적합하다.

하지만 언리얼 엔진의 컴포넌트가 전부 ActorComponent만 있는 것은 아니다.
대표적으로 USceneComponentUPrimitiveComponent도 있다.

USceneComponent

USceneComponent는 Transform을 가지는 컴포넌트이다.

즉, 위치, 회전, 크기를 가질 수 있기 때문에 월드나 Actor의 특정 위치에 붙어 있어야 하는 기능에 사용된다.

SpringArmComponent
CameraComponent
WidgetComponent
ArrowComponent

예를 들어 캐릭터를 따라다니는 카메라나, 캐릭터 머리 위에 띄우는 체력바 위젯은 특정 위치가 필요하다.
이런 경우에는 USceneComponent 계열 컴포넌트를 사용한다.

이번 예제에서도 체력바를 캐릭터 머리 위에 띄우기 위해 UWidgetComponent를 사용했다.

UPrimitiveComponent

UPrimitiveComponentUSceneComponent를 상속받고, 렌더링이나 충돌 기능을 가질 수 있는 컴포넌트이다.

StaticMeshComponent
SkeletalMeshComponent
BoxComponent
SphereComponent
CapsuleComponent

즉, 실제로 화면에 보이거나 충돌 판정이 필요한 컴포넌트들이 여기에 해당한다.

정리하면 다음과 같이 볼 수 있다.

UActorComponent
- Transform 없음
- 위치가 필요 없는 기능 담당
- 체력, 인벤토리, 퀘스트, 전투 로직 등

USceneComponent
- Transform 있음
- 특정 위치에 배치되어야 하는 기능 담당
- 카메라, 스프링 암, 위젯 컴포넌트 등

UPrimitiveComponent
- Transform 있음
- 렌더링 또는 충돌 기능 포함
- 메시, 박스 콜리전, 캡슐 콜리전 등

이번 글에서 만든 HealthManager는 체력 계산과 사망 판정만 담당하면 되기 때문에 위치 정보가 필요 없다.
그래서 UActorComponent로 구현하는 것이 적절했다.

반면 체력바 UI는 캐릭터 머리 위에 표시되어야 하므로 위치가 필요했고, 그래서 UWidgetComponent를 사용했다.


정리

Actor Component는 Actor에 붙여서 특정 기능을 담당하게 만드는 재사용 가능한 기능 단위이다.

이번에는 체력 시스템을 HealthManager라는 액터 컴포넌트로 분리해서 구현했다.

체력 컴포넌트는 Owner의 OnTakeAnyDamage 이벤트에 바인딩되어 데미지를 처리하고,
체력이 변경되면 OnDamaged, 사망하면 OnDeath 이벤트를 발생시킨다.

캐릭터는 이 이벤트에 자신의 함수를 바인딩해서 체력 UI를 갱신하거나 사망 애니메이션을 재생한다.

이 구조의 핵심은 역할 분리이다.

HealthManager
- 체력 관리
- 데미지 처리
- 사망 판정
- 이벤트 발생

Character
- 이벤트 수신
- UI 갱신
- 입력 비활성화
- 사망 애니메이션 재생

즉, 컴포넌트는 기능을 담당하고, Actor는 그 기능들을 조립해서 캐릭터나 오브젝트의 동작을 완성한다.

앞으로 인벤토리, 퀘스트, 상호작용, 전투 같은 기능도 비슷한 방식으로 컴포넌트로 분리하면 더 깔끔한 구조를 만들 수 있을 것 같다.