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) 객체는 아래와 같은 작업을 할 수 있다.

  1. GameplayMessageSubsystem에 RegisterListener
  2. 메시지 수신
  3. 수신한 Payload 저장/변환
  4. Blueprint Delegate 호출
  5. 필요 시 Unregister / SetReadyToDestroy

런타임에서 위 작업들을 비동기로 수행할 수 있다.

UAsyncAction_ListenForGameplayMessageListenForGameplayMessages
Proxy Object를 생성한다. Proxy Object에 Payload와 정보를 담는다.

K2Node_AsyncAction_ListenForGameplayMessagesUAsyncAction_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;
}

스크린샷 2026-06-11 154242


흐름

먼저 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은 상위 카테고리 채널 하나를 잡고
하위 이벤트를 묶어 받을 수 있어서,
이벤트를 카테고리 단위로 관찰하는 데 편하다.


단점

  1. 흐름 추적이 어려워질 수 있다.
    직접 함수 호출은 caller와 callee가 바로 보이지만,
    메시지 라우터는 누가 이 채널을 쏘는지누가 듣는지가 코드에서 바로 연결되지 않는다.
  2. 채널과 payload 설계를 잘못하면 구조가 빠르게 퍼진다는 점이다.
    태그 네이밍이 정리돼 있지 않으면 메시지 체계가 중복되기 쉽다.
  3. 디버깅
    직접 참조 구조보다 이벤트 흐름을 따라가기 어렵기 때문에,
    채널 설계와 로깅 규칙을 같이 잡아야 한다.

쓰기 좋은 경우

이 플러그인은 모든 통신을 다 메시지로 바꾸라고 만든 구조는 아니다.
직접 소유 관계가 분명한 경우에는 그냥 함수 호출이 더 단순하다.

잘 맞는 건 보통 이런 경우다.

  • 발신자와 수신자가 서로를 몰라도 되는 경우
  • 시스템이 여러 개라 한 이벤트를 여러 곳이 들어야 하는 경우
  • 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