UE5 C++ 멀티캐스트 & 이벤트 심화
개요 — 이벤트 시스템 설계 목표
Section titled “개요 — 이벤트 시스템 설계 목표”좋은 이벤트 시스템은 **발행자(Publisher)**가 **구독자(Subscriber)**를 직접 알 필요 없이 통신하는 느슨한 결합(Loose Coupling)을 달성합니다. UE의 멀티캐스트 델리게이트는 이를 위한 핵심 도구입니다.
이 문서는 기초 선언·바인딩 방법은 델리게이트 가이드를 참고하고, 실전 설계 패턴과 수명 관리에 초점을 맞춥니다.
1. FDelegateHandle — 구독 핸들 관리
Section titled “1. FDelegateHandle — 구독 핸들 관리”AddUObject / AddLambda는 FDelegateHandle을 반환합니다. 이 핸들로 특정 구독만 선택적으로 제거할 수 있습니다.
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;};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);};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. 옵저버 패턴 — 컴포넌트 기반 이벤트 버스”#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에서 일괄 처리합니다.
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); }}5. 일회성 구독 (Once) 패턴
Section titled “5. 일회성 구독 (Once) 패턴”이벤트를 딱 한 번만 받고 자동 해제하는 패턴입니다.
// 일회성 구독 헬퍼 함수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) }); }}6. RemoveAll vs Remove 비교
Section titled “6. RemoveAll vs Remove 비교”// 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()도중 구독 목록이 변경되는 시나리오를 고려합니다