Skip to content

UE5 C++ 멀티캐스트 & 이벤트 심화

개요 — 이벤트 시스템 설계 목표

Section titled “개요 — 이벤트 시스템 설계 목표”

좋은 이벤트 시스템은 **발행자(Publisher)**가 **구독자(Subscriber)**를 직접 알 필요 없이 통신하는 느슨한 결합(Loose Coupling)을 달성합니다. UE의 멀티캐스트 델리게이트는 이를 위한 핵심 도구입니다.

이 문서는 기초 선언·바인딩 방법은 델리게이트 가이드를 참고하고, 실전 설계 패턴과 수명 관리에 초점을 맞춥니다.


1. FDelegateHandle — 구독 핸들 관리

Section titled “1. FDelegateHandle — 구독 핸들 관리”

AddUObject / AddLambdaFDelegateHandle을 반환합니다. 이 핸들로 특정 구독만 선택적으로 제거할 수 있습니다.

EventPublisher.h
DECLARE_MULTICAST_DELEGATE_OneParam(FOnScoreChanged, int32);
UCLASS()
class MYGAME_API AGameManager : public AActor
{
GENERATED_BODY()
public:
FOnScoreChanged OnScoreChanged;
void AddScore(int32 Delta);
private:
int32 Score = 0;
};
EventSubscriber.h
UCLASS()
class MYGAME_API AScoreDisplay : public AActor
{
GENERATED_BODY()
protected:
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
private:
// 핸들 저장 — 정확한 제거를 위해 필수
FDelegateHandle ScoreChangedHandle;
void OnScoreChanged(int32 NewScore);
};
EventSubscriber.cpp
void AScoreDisplay::BeginPlay()
{
Super::BeginPlay();
// 게임 매니저 참조 획득 (예시)
if (AGameManager* GM = GetWorld()->GetAuthGameMode<AGameManager>())
{
// 핸들 저장
ScoreChangedHandle = GM->OnScoreChanged.AddUObject(
this, &AScoreDisplay::OnScoreChanged);
}
}
void AScoreDisplay::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
// 핸들로 정확한 구독 해제 — 객체 소멸 전 반드시 해제
if (AGameManager* GM = GetWorld()->GetAuthGameMode<AGameManager>())
{
GM->OnScoreChanged.Remove(ScoreChangedHandle);
}
// 또는: GM->OnScoreChanged.RemoveAll(this); — this로 바인딩된 모든 제거
Super::EndPlay(EndPlayReason);
}
void AScoreDisplay::OnScoreChanged(int32 NewScore)
{
UE_LOG(LogTemp, Log, TEXT("Score updated: %d"), NewScore);
// UI 업데이트...
}

2. 옵저버 패턴 — 컴포넌트 기반 이벤트 버스

Section titled “2. 옵저버 패턴 — 컴포넌트 기반 이벤트 버스”
EventBusComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "EventBusComponent.generated.h"
DECLARE_MULTICAST_DELEGATE_OneParam(FOnDamageTaken, float /*DamageAmount*/);
DECLARE_MULTICAST_DELEGATE_TwoParams(FOnHealthChanged, float /*NewHP*/, float /*MaxHP*/);
DECLARE_MULTICAST_DELEGATE_OneParam(FOnActorDied, AActor* /*Killer*/);
DECLARE_MULTICAST_DELEGATE(FOnActorRevived);
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class MYGAME_API UEventBusComponent : public UActorComponent
{
GENERATED_BODY()
public:
// 이벤트 채널들
FOnDamageTaken OnDamageTaken;
FOnHealthChanged OnHealthChanged;
FOnActorDied OnActorDied;
FOnActorRevived OnActorRevived;
// 이벤트 발행 헬퍼 (Owner 내부에서 호출)
void PublishDamageTaken(float Amount) { OnDamageTaken.Broadcast(Amount); }
void PublishHealthChanged(float NewHP, float MaxHP) { OnHealthChanged.Broadcast(NewHP, MaxHP); }
void PublishDied(AActor* Killer) { OnActorDied.Broadcast(Killer); }
void PublishRevived() { OnActorRevived.Broadcast(); }
};
// HealthComponent.cpp — 이벤트 발행자
void UHealthComponent::ApplyDamage(float Amount, AActor* DamageCauser)
{
if (bIsDead || Amount <= 0.f) return;
CurrentHealth = FMath::Clamp(CurrentHealth - Amount, 0.f, MaxHealth);
// EventBus를 통해 이벤트 전파
if (UEventBusComponent* Bus = GetOwner()->FindComponentByClass<UEventBusComponent>())
{
Bus->PublishDamageTaken(Amount);
Bus->PublishHealthChanged(CurrentHealth, MaxHealth);
if (CurrentHealth <= 0.f && !bIsDead)
{
bIsDead = true;
Bus->PublishDied(DamageCauser);
}
}
}
// UIHealthBar.cpp — 이벤트 구독자
void UUIHealthBar::SetTrackedActor(AActor* Actor)
{
// 이전 구독 해제
CleanupSubscriptions();
TrackedActor = Actor;
if (!Actor) return;
UEventBusComponent* Bus = Actor->FindComponentByClass<UEventBusComponent>();
if (!Bus) return;
// 구독 및 핸들 저장
HealthChangedHandle = Bus->OnHealthChanged.AddUObject(
this, &UUIHealthBar::HandleHealthChanged);
DiedHandle = Bus->OnActorDied.AddUObject(
this, &UUIHealthBar::HandleActorDied);
}
void UUIHealthBar::CleanupSubscriptions()
{
if (!TrackedActor.IsValid()) return;
if (UEventBusComponent* Bus = TrackedActor->FindComponentByClass<UEventBusComponent>())
{
Bus->OnHealthChanged.Remove(HealthChangedHandle);
Bus->OnActorDied.Remove(DiedHandle);
}
}
void UUIHealthBar::HandleHealthChanged(float NewHP, float MaxHP)
{
SetPercent(MaxHP > 0.f ? NewHP / MaxHP : 0.f);
}
void UUIHealthBar::HandleActorDied(AActor* Killer)
{
SetVisibility(ESlateVisibility::Hidden);
}

3. 람다 구독 — 안전한 UObject 캡처

Section titled “3. 람다 구독 — 안전한 UObject 캡처”
// 위험: this를 직접 캡처하면 객체 소멸 후 댕글링 포인터
void AMyActor::SubscribeUnsafe(FOnScoreChanged& Delegate)
{
Delegate.AddLambda([this](int32 Score)
{
// this가 이미 GC되었을 수 있음 — 크래시 위험
UpdateDisplay(Score);
});
}
// 안전: TWeakObjectPtr로 캡처 후 유효성 확인
void AMyActor::SubscribeSafe(FOnScoreChanged& Delegate)
{
TWeakObjectPtr<AMyActor> WeakThis(this);
LambdaHandle = Delegate.AddLambda([WeakThis](int32 Score)
{
if (WeakThis.IsValid())
{
WeakThis->UpdateDisplay(Score);
}
});
}
// 람다 구독도 핸들로 해제 가능
void AMyActor::Unsubscribe(FOnScoreChanged& Delegate)
{
Delegate.Remove(LambdaHandle);
}

4. 이벤트 큐 — 프레임 단위 배치 처리

Section titled “4. 이벤트 큐 — 프레임 단위 배치 처리”

대량의 이벤트가 한 프레임에 발생할 때 즉시 처리 대신 큐에 쌓아 다음 Tick에서 일괄 처리합니다.

EventQueueComponent.h
UCLASS()
class MYGAME_API UEventQueueComponent : public UActorComponent
{
GENERATED_BODY()
public:
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* Fn) override;
void EnqueueDamage(float Amount, AActor* Cause);
FOnDamageTaken OnDamageTaken;
private:
struct FDamageEvent { float Amount; TWeakObjectPtr<AActor> Cause; };
TQueue<FDamageEvent> PendingDamageEvents;
FCriticalSection QueueLock;
};
void UEventQueueComponent::EnqueueDamage(float Amount, AActor* Cause)
{
FScopeLock Lock(&QueueLock);
PendingDamageEvents.Enqueue({ Amount, Cause });
}
void UEventQueueComponent::TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* Fn)
{
Super::TickComponent(DeltaTime, TickType, Fn);
FDamageEvent Event;
while (PendingDamageEvents.Dequeue(Event))
{
// 게임 스레드에서 안전하게 처리
OnDamageTaken.Broadcast(Event.Amount);
}
}

이벤트를 딱 한 번만 받고 자동 해제하는 패턴입니다.

// 일회성 구독 헬퍼 함수
template<typename TDelegate>
FDelegateHandle SubscribeOnce(TDelegate& Delegate, typename TDelegate::FDelegate::TFuncType&& Func)
{
TSharedPtr<FDelegateHandle> HandlePtr = MakeShared<FDelegateHandle>();
*HandlePtr = Delegate.AddLambda([&Delegate, HandlePtr, Func = MoveTemp(Func)](auto&&... Args) mutable
{
Func(Forward<decltype(Args)>(Args)...);
Delegate.Remove(*HandlePtr); // 첫 호출 후 자동 해제
});
return *HandlePtr;
}
// 사용 예시
void AMyActor::WaitForDeath(AActor* Target)
{
if (UEventBusComponent* Bus = Target->FindComponentByClass<UEventBusComponent>())
{
TWeakObjectPtr<AMyActor> WeakThis(this);
Bus->OnActorDied.AddLambda([WeakThis](AActor* Killer) mutable
{
if (WeakThis.IsValid())
{
WeakThis->OnTargetDied();
}
// 자동 해제는 별도 핸들 관리 필요 — 단순 패턴에서는 RemoveAll(this)
});
}
}

// Remove: 특정 핸들 하나만 해제
Delegate.Remove(SpecificHandle);
// RemoveAll: 특정 UObject에 바인딩된 모든 항목 해제
Delegate.RemoveAll(this);
// Clear: 모든 구독 해제 (발행자가 소멸할 때)
Delegate.Clear();
// IsBound: 구독자가 하나라도 있는지 확인
if (Delegate.IsBound())
{
Delegate.Broadcast();
}

상황권장 패턴
특정 구독만 해제FDelegateHandle 저장 후 Remove(Handle)
객체의 모든 구독 해제RemoveAll(this)
람다에서 UObject 참조TWeakObjectPtr 캡처 후 IsValid() 확인
멀티스레드 이벤트 큐TQueue + FCriticalSection + Tick 배치 처리
컴포넌트 이벤트 버스UEventBusComponent 분리 패턴

핵심 규칙:

  • EndPlay에서 반드시 구독을 해제합니다. UObject 델리게이트는 GC 시 자동 해제되지만 명시적 해제가 안전합니다.
  • 람다에서 this를 직접 캡처하면 객체 소멸 후 크래시 위험 — TWeakObjectPtr 사용
  • Broadcast() 도중 구독 목록이 변경되는 시나리오를 고려합니다