GameplayMessageRouter
서로 직접 참조하지 않는 객체들이 같은 GameplayTag 채널을 기준으로USTRUCT인 payload를 주고받는 메시지 라우터다.
FGameplayTag
Unreal Engine의 GameplayTags 시스템에서 제공하는 공용 태그 타입
GameplayTags 시스템
└─ FGameplayTag / FGameplayTagContainer 제공
├─ GAS에서 Ability, Effect, Cue, State 구분에 사용
├─ GameplayMessageSubsystem에서 메시지 채널로 사용
├─ Lyra에서 UI, Inventory, Input, GameFeature 구분에 사용
└─ 프로젝트 전반의 상태/분류 태그로 사용 가능
GameplayTags는 프로젝트에서 매크로로 등록하거나
UE_DEFINE_GAMEPLAY_TAG_STATIC(TAG_Lyra_Damage_Message, "Lyra.Damage.Message");
.ini에 등록해두고 사용한다.
[/Script/GameplayTags.GameplayTagsSettings] : GameplayTags 시스템 설정 + 기본 태그 목록
[/Script/GameplayTags.GameplayTagsList] : 별도 태그 리스트 파일에서 태그 목록 정의
[/Script/GameplayTags.RestrictedGameplayTagsList] : 제한 태그 목록
[/Script/GameplayTags.GameplayTagsList]
GameplayTagList=(Tag="Ability.ActivateFail.MagazineFull",DevComment="")
GameplayTagList=(Tag="Ability.ActivateFail.NoSpareAmmo",DevComment="")
...
[/Script/GameplayTags.GameplayTagsSettings]
ImportTagsFromConfig=True
WarnOnInvalidTags=True
...
구조
Runtime 구조
UGameplayMessageSubsystem : 브로드캐스트, 리스너 등록하는 Subsystem
게임 인스턴스 전역 메시지 허브
UCLASS(MinimalAPI)
class UGameplayMessageSubsystem : public UGameInstanceSubsystem
World에서 GameInstance를 찾고, 거기서 Subsystem을 꺼내서 사용
UGameplayMessageSubsystem& UGameplayMessageSubsystem::Get(const UObject* WorldContextObject)
{
UWorld* World = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::Assert);
check(World);
UGameplayMessageSubsystem* Router = UGameInstance::GetSubsystem<UGameplayMessageSubsystem>(World->GetGameInstance());
check(Router);
return *Router;
}
// 사용
UGameplayMessageSubsystem::Get(WorldContextObject);
Nodes 구조
UAsyncAction_ListenForGameplayMessage는 대리자의 출력은 Proxy Object로,
트리거 시 GetPayload의 후속 호출에 사용된다.
Proxy Object(Async Action) 객체는 아래와 같은 작업을 할 수 있다.
- GameplayMessageSubsystem에 RegisterListener
- 메시지 수신
- 수신한 Payload 저장/변환
- Blueprint Delegate 호출
- 필요 시 Unregister / SetReadyToDestroy
런타임에서 위 작업들을 비동기로 수행할 수 있다.
UAsyncAction_ListenForGameplayMessage의 ListenForGameplayMessages는Proxy Object를 생성한다. Proxy Object에 Payload와 정보를 담는다.
K2Node_AsyncAction_ListenForGameplayMessages가 UAsyncAction_ListenForGameplayMessage를 에디터에 Node로 동작하게 한다.
[Editor]
Blueprint 우클릭 메뉴에서 Listen For Gameplay Messages 노드 생성
→ K2Node_AsyncAction_ListenForGameplayMessages
[Editor]
Payload Type 선택
→ K2Node가 Payload 출력 핀 타입을 갱신
→ RefreshOutputPayloadType()
------------------------------------
[Compile]
Blueprint 컴파일
→ K2Node가 내부 함수 호출 노드로 확장
→ ListenForGameplayMessages() 호출 생성
→ Proxy 반환 핀 내부 사용
→ Delegate 바인딩
→ Activate 호출
→ GetPayload 연결
------------------------------------
[Runtime]
ListenForGameplayMessages() 실행
→ UAsyncAction_ListenForGameplayMessage Proxy Object 생성
[Runtime]
Activate() 실행
→ GameplayMessageSubsystem.RegisterListener()
[Runtime]
메시지 수신
→ Proxy가 Payload 수신
→ Blueprint On Message 실행
→ Payload 출력 핀으로 Struct 데이터 사용
블루프린트에서는 메시지가 도착하면 먼저 이벤트가 오고,
그 다음 GetPayload()를 통해 Payload를 읽는다.
ReceivedMessagePayloadPtr = Payload;
OnMessageReceived.Broadcast(this, Channel);
ReceivedMessagePayloadPtr = nullptr;
그리고 GetPayload()는 현재 보관된 포인터와 블루프린트에서 요청한 struct 타입이 일치할 때만 복사한다.
if ((StructProp != nullptr) && (StructProp->Struct != nullptr) &&
(MessagePtr != nullptr) &&
(StructProp->Struct == P_THIS->MessageStructType.Get()) &&
(P_THIS->ReceivedMessagePayloadPtr != nullptr))
{
StructProp->Struct->CopyScriptStruct(MessagePtr, P_THIS->ReceivedMessagePayloadPtr);
bSuccess = true;
}

흐름
먼저 GameplayTag 채널을 정한다.
UE_DEFINE_GAMEPLAY_TAG(TAG_Lyra_Damage_Message, "Lyra.Damage.Message");
그 채널에 실어 보낼 USTRUCT payload를 만든다.
수신자는 RegisterListener()나 블루프린트 async node로 리스너를 건다.
void ULyraDamageLogDebuggerComponent::BeginPlay()
{
Super::BeginPlay();
UGameplayMessageSubsystem& MessageSubsystem = UGameplayMessageSubsystem::Get(this);
ListenerHandle = MessageSubsystem.RegisterListener(TAG_Lyra_Damage_Message, this, &ThisClass::OnDamageMessage);
}
여기서 등록 결과로 FGameplayMessageListenerHandle을 돌려준다.
USTRUCT(BlueprintType)
struct FGameplayMessageListenerHandle
{
...
UE_API void Unregister();
...
TWeakObjectPtr Subsystem;
FGameplayTag Channel;
int32 ID = 0;
};
등록은 내부적으로 ListenerMap에 들어간다.
FGameplayMessageListenerHandle은 ID로써 사용되면서 Unregister()하기 위한 객체다.
void ULyraDamageLogDebuggerComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
UGameplayMessageSubsystem& MessageSubsystem = UGameplayMessageSubsystem::Get(this);
MessageSubsystem.UnregisterListener(ListenerHandle);
Super::EndPlay(EndPlayReason);
}
발신자는 BroadcastMessage()로 FGameplayTag을 채널로 사용하고 payload로 USTRUCT 타입을 보낸다.
template <typename FMessageStructType>
void BroadcastMessage(FGameplayTag Channel, const FMessageStructType& Message)
{
const UScriptStruct* StructType = TBaseStructure<FMessageStructType>::Get();
BroadcastMessageInternal(Channel, StructType, &Message);
}
BroadcastMessageInternal()에서는 실제 브로드캐스트가 돈다.
void UGameplayMessageSubsystem::BroadcastMessageInternal(FGameplayTag Channel, const UScriptStruct* StructType, const void* MessageBytes)
{
...
// 먼저 채널 태그를 기준으로 리스너를 찾는다
// 그 다음 부모 태그로 계속 올라간다
for (FGameplayTag Tag = Channel; Tag.IsValid(); Tag = Tag.RequestDirectParent())
{
if (const FChannelListenerList* pList = ListenerMap.Find(Tag))
{
TArray ListenerArray(pList->Listeners);
for (const FGameplayMessageListenerData& Listener : ListenerArray)
{
// `PartialMatch` 리스너면 더 구체적인 자식 채널 메시지도 받는다
if (bOnInitialTag || (Listener.MatchType == EGameplayMessageMatch::PartialMatch))
{
Listener.ReceivedCallback(Channel, StructType, MessageBytes);
}
}
}
bOnInitialTag = false;
}
}
수신자는 채널과 payload를 받아 반응한다.
void ULyraDamageLogDebuggerComponent::OnDamageMessage(FGameplayTag Channel, const FLyraVerbMessage& Payload)
{
if (Payload.Target == GetOwner())
{
FFrameDamageEntry& LogEntry = DamageLog.FindOrAdd(GFrameCounter);
if (LogEntry.TimeOfFirstHit == 0.0)
{
LogEntry.TimeOfFirstHit = GetWorld()->GetTimeSeconds();
LastDamageEntryTime = LogEntry.TimeOfFirstHit;
}
LogEntry.NumImpacts++;
LogEntry.SumDamage += -Payload.Magnitude;
}
}
타입 검사
Struct 타입을 들고 다니면서, 수신 쪽 타입이 맞는지 검사한다.
if (!Listener.bHadValidType || StructType->IsChildOf(Listener.ListenerStructType.Get()))
{
Listener.ReceivedCallback(Channel, StructType, MessageBytes);
}
else
{
UE_LOG(LogGameplayMessageSubsystem, Error, TEXT("Struct type mismatch on channel %s ..."));
}
채널을 쓰더라도 송신 Payload와 수신 Payload struct가 다르면 에러를 낸다.
이 점이 Delegate 브로드캐스트와 다르다.
장점
시스템 간 결합도를 낮춘다.
같은 채널과 payload만 합의하면 메시지를 통해 연결할 수 있다.
또 GameplayTag 채널을 쓰기 때문에
태그 체계를 이미 프로젝트에서 정리하고 있다면 메시지 이름 체계도 같이 가져갈 수 있다.
PartialMatch은 상위 카테고리 채널 하나를 잡고
하위 이벤트를 묶어 받을 수 있어서,
이벤트를 카테고리 단위로 관찰하는 데 편하다.
단점
- 흐름 추적이 어려워질 수 있다.
직접 함수 호출은 caller와 callee가 바로 보이지만,
메시지 라우터는누가 이 채널을 쏘는지와누가 듣는지가 코드에서 바로 연결되지 않는다. - 채널과 payload 설계를 잘못하면 구조가 빠르게 퍼진다는 점이다.
태그 네이밍이 정리돼 있지 않으면 메시지 체계가 중복되기 쉽다. - 디버깅
직접 참조 구조보다 이벤트 흐름을 따라가기 어렵기 때문에,
채널 설계와 로깅 규칙을 같이 잡아야 한다.
쓰기 좋은 경우
이 플러그인은 모든 통신을 다 메시지로 바꾸라고 만든 구조는 아니다.
직접 소유 관계가 분명한 경우에는 그냥 함수 호출이 더 단순하다.
잘 맞는 건 보통 이런 경우다.
- 발신자와 수신자가 서로를 몰라도 되는 경우
- 시스템이 여러 개라 한 이벤트를 여러 곳이 들어야 하는 경우
- UI, 퀘스트, 로그, 효과 재생처럼 반응 계층이 분리돼 있는 경우
- 모듈성이나 확장성을 우선하는 경우
예를 들어 무기 장착, 데미지 발생, 스코어 변경, 라운드 종료 같은 건 메시지 라우터와 잘 맞는다.
반대로 owner가 분명한 내부 상태 갱신은 그냥 함수 호출이 더 읽기 쉽다.
'Unreal Engine' 카테고리의 다른 글
| GameplayMessageRouter Plugin 1 (0) | 2026.06.10 |
|---|---|
| Niagara (0) | 2026.05.20 |
| Delegate (0) | 2026.05.18 |
| State Tree, Behavior Tree (0) | 2026.05.07 |
| Unreal Engine MCP (0) | 2026.04.21 |