게임의 기능이 늘어나면 서로 다른 시스템이 같은 사건에 반응해야 하는 경우가 많아진다.
예를 들어 플레이어가 아이템을 획득했을 때 다음 시스템들이 반응할 수 있다.
- InventoryComponent는 아이템을 추가한다.
- 퀘스트 시스템은 목표 진행도를 확인한다.
- HUD는 인벤토리나 퀘스트 표시를 갱신한다.
- 사운드 시스템은 획득 효과음을 재생한다.
- 튜토리얼 시스템은 안내 단계를 진행한다.
- 저장 시스템은 변경된 상태를 기록할 수 있다.
이 기능들을 직접 참조로 연결하면 Item Actor가 여러 시스템을 알아야 한다.
Item Actor
├─ InventoryComponent
├─ ObjectiveSubsystem
├─ HUD Widget
├─ SoundManager
├─ TutorialSubsystem
└─ SaveSubsystem
ProjectFT에서는 이런 의존성을 줄이기 위해 UGameplayMessageSubsystem을 사용했다.
발신자는 특정 수신자를 직접 호출하지 않고 Gameplay Tag 채널에 메시지를 발행한다. 해당 사건이 필요한 시스템만 채널을 구독해 처리한다.
발신자
→ GameplayMessage 발행
→ 구독 중인 시스템들이 수신
이번 글에서는 아이템 획득, 레이드 시작과 탈출, 퀘스트 진행, HUD 갱신 흐름을 통해 Gameplay Message를 어떻게 적용했는지 정리한다.
Actor 간 직접 참조가 증가할 때의 문제
가장 단순한 통신 방식은 필요한 객체를 직접 찾아 함수를 호출하는 것이다.
Inventory->AddItem(ItemID, Count);
QuestSystem->NotifyItemPickedUp(ItemID);
HUD->RefreshInventory();
AudioManager->PlayPickupSound();
기능이 적다면 이 방식이 명확하고 빠르다.
하지만 하나의 사건에 반응하는 시스템이 늘어나면 발신자의 책임도 함께 증가한다.
1. 발신자가 수신자를 모두 알아야 한다
Item Actor가 인벤토리, 퀘스트, HUD 클래스를 직접 참조하면 해당 클래스의 구조를 알아야 한다.
#include "FTInventoryComponent.h"
#include "FTObjectiveSubsystem.h"
#include "FTHUDWidget.h"
시스템 하나를 추가할 때마다 Item Actor 코드도 수정해야 한다.
2. 객체 생성 순서에 영향을 받는다
Item Actor가 HUD를 호출하려는데 HUD가 아직 생성되지 않았을 수 있다.
if (HUDWidget)
{
HUDWidget->Refresh();
}
직접 참조가 많아지면 모든 호출부에 유효성 검사가 필요해진다.
3. 재사용성이 떨어진다
Item Actor를 다른 레벨이나 프로젝트에서 재사용하려 해도 특정 퀘스트 시스템과 UI에 의존하고 있으면 분리하기 어렵다.
4. 순환 의존성이 생길 수 있다
Item Actor → ObjectiveSubsystem
ObjectiveSubsystem → HUD
HUD → InventoryComponent
InventoryComponent → Item Actor
기능이 서로를 직접 호출하기 시작하면 클래스 관계를 이해하고 수정하기 어려워진다.
5. 새로운 반응을 추가하기 어렵다
아이템 획득 시 튜토리얼을 진행하고 싶다면 기존 Item Actor 코드를 다시 수정해야 한다.
메시지 방식에서는 튜토리얼 시스템이 기존 획득 메시지를 새로 구독하면 된다.
GameplayMessageSubsystem을 사용한 이유
UGameplayMessageSubsystem은 Gameplay Tag를 채널로 사용해 메시지를 발행하고 구독하는 구조를 제공한다.
Gameplay Tag
= 어떤 사건인가?
Payload
= 사건과 함께 전달할 데이터
Broadcaster
= 사건을 발생시킨 객체
Listener
= 사건에 반응할 시스템
ProjectFT에서 Gameplay Message를 사용한 주요 이유는 다음과 같다.
- 발신자와 수신자의 직접 참조 제거
- 하나의 사건을 여러 시스템이 수신
- Gameplay Tag 기반의 명확한 채널 구분
- Widget 존재 여부와 관계없는 게임 로직 처리
- 이벤트가 발생한 시점에만 갱신
- 퀘스트 조건의 확장성 확보
- Subsystem 간 결합도 감소
메시지의 기본 구성
ProjectFT의 여러 일반 이벤트는 FFTMessagePayloadStruct를 사용한다.
USTRUCT(BlueprintType)
struct PROJECTFT_API FFTMessagePayloadStruct
{
GENERATED_BODY()
UPROPERTY(BlueprintReadWrite)
TObjectPtr<AActor> InstigatorActor = nullptr;
UPROPERTY(BlueprintReadWrite)
TObjectPtr<AActor> TargetActor = nullptr;
UPROPERTY(BlueprintReadWrite)
float Value = 0.0f;
UPROPERTY(BlueprintReadWrite)
FName ItemId = NAME_None;
UPROPERTY(BlueprintReadWrite)
FName QuestId = NAME_None;
};
각 필드는 이벤트에 따라 의미가 달라진다.
| InstigatorActor | 사건을 발생시킨 주체 |
| TargetActor | 사건의 대상 |
| Value | 수량, 진행률, 상태값 |
| ItemId | 관련 아이템 ID |
| QuestId | 관련 퀘스트 ID |
예를 들어 아이템 획득 이벤트에서는 다음과 같이 사용한다.
InstigatorActor = 아이템을 주운 플레이어
TargetActor = 획득한 Item Actor
ItemId = 획득 아이템 ID
Value = 획득 수량
퀘스트 진행도 변경 이벤트에서는 다음처럼 사용한다.
QuestId = 변경된 퀘스트 ID
Value = 0.0~1.0 진행률
Native Gameplay Tag 선언
메시지 채널은 문자열을 코드 곳곳에서 직접 작성하지 않고 Native Gameplay Tag로 선언했다.
UE_DECLARE_GAMEPLAY_TAG_EXTERN(
TAG_FT_Event_ItemPickedUp
);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(
TAG_FT_Event_RaidEscaped
);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(
TAG_FT_Request_Flow_StartRaid
);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(
TAG_FT_Event_ObjectiveProgressChanged
);
정의 파일에서는 실제 태그 문자열을 연결한다.
UE_DEFINE_GAMEPLAY_TAG(
TAG_FT_Event_ItemPickedUp,
"Event.Item.PickedUp"
);
UE_DEFINE_GAMEPLAY_TAG(
TAG_FT_Event_RaidEscaped,
"Event.Raid.Escaped"
);
UE_DEFINE_GAMEPLAY_TAG(
TAG_FT_Request_Flow_StartRaid,
"Request.Flow.StartRaid"
);
UE_DEFINE_GAMEPLAY_TAG(
TAG_FT_Event_ObjectiveProgressChanged,
"Event.Objective.ProgressChanged"
);
Native Gameplay Tag를 사용하면 코드에서 오타를 줄이고, 태그 정의 위치를 한곳에서 관리할 수 있다.
Event와 Request 구분
ProjectFT의 메시지는 의미에 따라 크게 Event와 Request로 구분한다.
Event
이미 발생한 사실을 알리는 메시지다.
Event.Item.PickedUp
Event.Raid.Started
Event.Raid.Escaped
Event.Craft.Completed
Event.Objective.ProgressChanged
Event.Objective.Completed
Event는 일반적으로 과거형이나 완료된 상태를 표현한다.
아이템을 획득했다.
레이드에서 탈출했다.
제작이 완료됐다.
퀘스트 진행도가 변경됐다.
발신자가 사건을 이미 확정한 뒤 결과를 알리는 용도다.
Request
어떤 시스템에 행동을 요청하는 메시지다.
Request.Flow.StartGame
Request.Flow.StartRaid
Request.Flow.StartEscape
Request.Flow.CompleteEscape
Request.Flow.ReturnToBase
Request는 아직 결과가 확정된 사건이 아니다.
레이드를 시작해 달라.
탈출을 시작해 달라.
허브로 돌아가 달라.
실제 실행 여부는 해당 요청을 담당하는 Subsystem이 검증하고 결정한다.
이벤트 태그 네이밍 규칙
ProjectFT에서 정리한 기본 태그 형식은 다음과 같다.
Event.<Domain>.<Action>
Request.<Domain>.<Action>
Event 예시
Event.Item.PickedUp
Event.Item.Consumed
Event.Craft.Completed
Event.Shop.Purchased
Event.Shop.Sold
Event.Raid.Started
Event.Raid.Failed
Event.Raid.Escaped
Event.Objective.ProgressChanged
Event.Objective.Completed
Request 예시
Request.Flow.StartGame
Request.Flow.StartRaid
Request.Flow.StartEscape
Request.Flow.CancelEscape
Request.Flow.CompleteEscape
Request.Flow.ReturnToBase
Request.Flow.ReturnToMainMenu
각 부분의 의미는 다음과 같다.
Event
= 메시지 성격
Objective
= 메시지 도메인
ProgressChanged
= 구체적인 사건
태그만 보더라도 메시지의 목적을 파악할 수 있도록 이름을 정하는 것이 중요했다.
현재 남아 있는 예외 태그
프로젝트에는 규칙을 정하기 전에 만들어진 태그도 일부 남아 있다.
Gameplay.Request.DropItem
Event.PlayerDead
Event.ForceGunScan
이 태그들은 통일된 규칙을 적용한다면 다음처럼 정리할 수 있다.
Request.Item.Drop
Event.Player.Died
Request.ForceGun.Scan
이미 여러 코드에서 사용 중인 태그 이름을 변경하면 연결된 발행자와 리스너를 모두 수정해야 한다.
따라서 네이밍 규칙은 가능한 프로젝트 초기에 정하는 것이 좋다.
아이템 획득 메시지 흐름
Item Actor는 획득 사실만 발행한다
플레이어가 아이템과 상호작용하면 AFTItemActor가 획득 메시지를 발행한다.
bool AFTItemActor::Interact_Implementation(
AActor* Interactor
)
{
if (!ItemData)
{
return false;
}
UGameplayMessageSubsystem& MessageSubsystem =
UGameplayMessageSubsystem::Get(this);
FFTMessagePayloadStruct Payload;
Payload.ItemId =
ItemData->ItemData.ItemId;
Payload.InstigatorActor = Interactor;
Payload.TargetActor = this;
Payload.Value = 1.0f;
MessageSubsystem.BroadcastMessage(
TAG_FT_Event_ItemPickedUp,
Payload
);
DestroyItem();
return true;
}
Item Actor는 플레이어 InventoryComponent를 직접 수정하지 않는다.
Item Actor의 책임
- 상호작용 가능 여부 확인
- 획득 메시지 발행
- 월드 아이템 제거
InventoryComponent가 메시지를 구독한다
플레이어 InventoryComponent는 BeginPlay에서 아이템 획득 채널을 구독한다.
PickedUpListenerHandle =
MessageSubsystem.RegisterListener<
FFTMessagePayloadStruct
>(
TAG_FT_Event_ItemPickedUp,
this,
&UFTInventoryComponent::
HandleItemPickedUpMessage
);
메시지를 받으면 자신이 획득 주체인지 확인한 뒤 아이템을 추가한다.
void UFTInventoryComponent::
HandleItemPickedUpMessage(
FGameplayTag Channel,
const FFTMessagePayloadStruct& Payload
)
{
AActor* Owner = GetOwner();
if (Payload.InstigatorActor == Owner)
{
const int32 Quantity =
FMath::Max(
1,
FMath::RoundToInt(
Payload.Value
)
);
AddItem(
Payload.ItemId,
Quantity
);
}
}
전체 흐름은 다음과 같다.
Player
→ Item Actor와 상호작용
→ Event.Item.PickedUp 발행
→ InventoryComponent 수신
→ AddItem
→ OnInventoryChanged
→ 인벤토리 UI와 퀘스트 UI 갱신
같은 획득 이벤트를 여러 시스템이 사용할 수 있다
아이템 획득 메시지를 InventoryComponent만 사용하는 것은 아니다.
추후 다른 시스템도 같은 채널을 구독할 수 있다.
Event.Item.PickedUp
├─ InventoryComponent
├─ TutorialSubsystem
├─ AudioSubsystem
├─ AchievementSubsystem
└─ AnalyticsSubsystem
Item Actor를 수정하지 않고도 새로운 반응을 추가할 수 있다는 것이 메시지 방식의 큰 장점이다.
허브에서 레이드 시작 요청하기
Hub Actor가 GameFlow를 직접 제어할 때의 문제
허브의 레이드 입구 Actor가 직접 레벨 이동을 수행하면 다음 책임까지 가지게 된다.
- 현재 게임 흐름 상태 확인
- 레이드 진입 가능 여부 확인
- 저장 실행
- 로딩 레벨 결정
- 목적지 레벨 이동
- FlowState 갱신
레이드 입구는 상호작용 진입점일 뿐, 게임 전체 흐름을 관리하는 객체는 아니다.
따라서 기본 레이드 시작 요청은 메시지로 전달하도록 구성했다.
레이드 시작 요청 발행
AFTHubRaidEntrance는 다음과 같이 시작 요청 메시지를 발행할 수 있다.
void AFTHubRaidEntrance::RequestStartRaid(
AActor* InstigatorActor
)
{
FFTMessagePayloadStruct Payload;
Payload.InstigatorActor =
InstigatorActor;
Payload.TargetActor = this;
UGameplayMessageSubsystem::Get(this)
.BroadcastMessage(
TAG_FT_Request_Flow_StartRaid,
Payload
);
}
레이드 입구는 요청 결과를 직접 처리하지 않는다.
Hub Raid Entrance
→ Request.Flow.StartRaid
→ GameFlowSubsystem
GameFlowSubsystem이 요청을 수신한다
UFTGameFlowSubsystem은 초기화 시 Flow 관련 Request 채널을 구독한다.
FlowRequestListenerHandles.Add(
MessageSubsystem.RegisterListener(
TAG_FT_Request_Flow_StartRaid,
this,
&ThisClass::
HandleFlowRequestMessage
)
);
수신한 태그에 따라 실제 함수를 호출한다.
void UFTGameFlowSubsystem::
HandleFlowRequestMessage(
FGameplayTag Channel,
const FFTMessagePayloadStruct& Payload
)
{
if (Channel ==
TAG_FT_Request_Flow_StartRaid)
{
RequestStartRaid();
}
else if (Channel ==
TAG_FT_Request_Flow_StartEscape)
{
RequestEscapeRaid();
}
else if (Channel ==
TAG_FT_Request_Flow_ReturnToBase)
{
ReturnToBase();
}
}
발신자는 게임 흐름 구현을 모르고, GameFlowSubsystem만 실제 전환 규칙을 가진다.
레이드 선택 화면의 현재 처리
현재 레이드 입구에는 여러 목적지 레벨을 선택하는 기능도 있다.
선택된 레벨을 시작할 때는 RequestStartRaidAtLevel()을 직접 호출한다.
if (!FlowSubsystem
->RequestStartRaidAtLevel(
LevelName
))
{
// 입장 아이템 복구
return false;
}
이유는 현재 공용 FFTMessagePayloadStruct에 목적지 LevelName 필드가 없기 때문이다.
따라서 현재 구조에는 두 경로가 공존한다.
기본 레이드 시작
→ Request.Flow.StartRaid 메시지
선택한 특정 레벨 시작
→ GameFlowSubsystem 직접 호출
추후 요청 Payload에 목적지 레벨과 입장 옵션을 넣으면 특정 레이드 시작도 메시지 기반으로 통일할 수 있다.
레이드 탈출 이벤트
요청과 완료 이벤트 분리
탈출을 시작하는 것은 Request다.
Request.Flow.StartEscape
실제로 탈출 상태에 도달한 것은 Event다.
Event.Raid.Escaped
이 둘을 구분하지 않으면 탈출 요청만 보냈는데 퀘스트가 완료되거나 보상이 지급되는 문제가 생길 수 있다.
Request
= 시도해 달라
Event
= 실제로 발생했다
GameFlowSubsystem이 탈출 이벤트를 발행한다
GameFlowSubsystem이 Escaped 상태에 진입하면 탈출 완료 이벤트를 발행한다.
void UFTGameFlowSubsystem::
HandleFlowStateEntered(
EFTFlowStateType NewFlowState
)
{
switch (NewFlowState)
{
case EFTFlowStateType::RaidInProgress:
BroadcastFlowEvent(
TAG_FT_Event_RaidStarted
);
break;
case EFTFlowStateType::Escaped:
BroadcastFlowEvent(
TAG_FT_Event_RaidEscaped
);
break;
case EFTFlowStateType::Failed:
BroadcastFlowEvent(
TAG_FT_Event_RaidFailed
);
break;
default:
break;
}
}
GameFlowSubsystem은 탈출 후 어떤 퀘스트가 진행되는지 알지 않는다.
단순히 레이드 탈출이 확정됐다는 사실만 알린다.
여러 시스템이 탈출 이벤트에 반응한다
Event.Raid.Escaped는 여러 시스템에서 사용할 수 있다.
Event.Raid.Escaped
├─ ObjectiveSubsystem
│ └─ 탈출 조건 진행
├─ UIManagerSubsystem
│ └─ 탈출 결과 화면
├─ SaveSubsystem
│ └─ 상태 저장
└─ Analytics
└─ 탈출 성공 기록
GameFlowSubsystem은 이 수신자들을 직접 참조할 필요가 없다.
퀘스트 진행 메시지
ObjectiveSubsystem이 게임 이벤트를 구독한다
UFTObjectiveSubsystem은 퀘스트 조건으로 사용할 사건들을 구독한다.
ObjectiveListenerHandles.Add(
MessageSubsystem.RegisterListener(
TAG_FT_Event_RaidEscaped,
this,
&ThisClass::
HandleRaidEscapedMessage
)
);
ObjectiveListenerHandles.Add(
MessageSubsystem.RegisterListener(
TAG_FT_Event_CraftCompleted,
this,
&ThisClass::
HandleCountedItemQuestMessage
)
);
ObjectiveListenerHandles.Add(
MessageSubsystem.RegisterListener(
TAG_FT_Event_ShopPurchased,
this,
&ThisClass::
HandleCountedItemQuestMessage
)
);
ObjectiveSubsystem은 발신자가 누구인지 직접 알지 않아도 된다.
퀘스트의 EventCondition과 수신한 Gameplay Tag가 일치하는지만 확인한다.
활성 퀘스트의 조건만 갱신한다
이벤트를 받으면 현재 활성 퀘스트만 검사한다.
for (const FName& QuestID
: ActiveQuestIDs)
{
const FTQuestStruct* Quest =
FindQuestByID(QuestID);
if (!Quest ||
Quest->EventConditions.IsEmpty())
{
continue;
}
// 조건 검사
}
조건 태그가 일치하지 않으면 무시한다.
if (!Condition.IsValid() ||
Condition.EventTag != EventTag)
{
continue;
}
아이템 필터가 있다면 ItemID도 비교한다.
if (!Condition.ItemID.IsNone() &&
Condition.ItemID != ItemID)
{
continue;
}
Gameplay Message가 전역으로 전달되더라도 실제 진행도는 관련된 활성 퀘스트에서만 변경된다.
퀘스트 진행 변경 알림 발행
진행도가 실제로 변경되면 ObjectiveSubsystem은 다시 메시지를 발행한다.
void UFTObjectiveSubsystem::
BroadcastQuestProgressChanged(
FName QuestID
)
{
FFTMessagePayloadStruct Payload;
Payload.QuestId = QuestID;
Payload.Value =
GetQuestProgress(QuestID);
UGameplayMessageSubsystem::Get(this)
.BroadcastMessage(
TAG_FT_Event_ObjectiveProgressChanged,
Payload
);
}
이 메시지는 게임 사건을 UI 사건으로 변환하는 역할을 한다.
Event.Raid.Escaped
→ ObjectiveSubsystem이 퀘스트 상태 변경
→ Event.Objective.ProgressChanged
→ HUD와 퀘스트 UI 갱신
퀘스트 완료 이벤트
퀘스트 제출과 보상 지급이 성공하면 완료 메시지를 발행한다.
FFTMessagePayloadStruct Payload;
Payload.QuestId = QuestID;
Payload.Value = 1.0f;
MessageSubsystem.BroadcastMessage(
TAG_FT_Event_ObjectiveCompleted,
Payload
);
Event.Objective.Completed
이 메시지를 통해 HUD, Quest Widget, 연출 시스템 등이 퀘스트 완료에 반응할 수 있다.
HUD가 게임 로직을 Polling하지 않는 구조
기존 Polling 방식
HUD가 Tick에서 퀘스트 진행도를 조회할 수도 있다.
void UHUDWidget::NativeTick(...)
{
const float Progress =
ObjectiveSubsystem
->GetQuestProgress(QuestID);
ProgressBar->SetPercent(Progress);
}
이 방식은 진행도가 변하지 않아도 매 프레임 호출된다.
HUD가 ObjectiveSubsystem과 퀘스트 ID를 계속 가지고 있어야 하는 문제도 있다.
메시지 기반 HUD 갱신
UIManagerSubsystem은 퀘스트 진행 메시지를 구독한다.
UIMessageListenerHandles.Add(
MessageSubsystem.RegisterListener(
TAG_FT_Event_ObjectiveProgressChanged,
this,
&ThisClass::
HandleObjectiveProgressChanged
)
);
진행도 변경 시에만 HUD ViewModel을 갱신한다.
void UFTUIManagerSubsystem::
HandleObjectiveProgressChanged(
FGameplayTag Channel,
const FFTMessagePayloadStruct& Payload
)
{
if (!HUDViewModel)
{
return;
}
const int32 ProgressPercent =
FMath::RoundToInt(
FMath::Clamp(
Payload.Value,
0.0f,
1.0f
) * 100.0f
);
HUDViewModel->SetObjectiveText(
FText::FromString(
FString::Printf(
TEXT("Quest %s %d%%"),
*Payload.QuestId.ToString(),
ProgressPercent
)
)
);
}
전체 흐름은 다음과 같다.
게임 사건 발생
→ Gameplay Message
→ ObjectiveSubsystem 진행도 갱신
→ Event.Objective.ProgressChanged
→ UIManagerSubsystem
→ HUD ViewModel
→ HUD 갱신
HUD는 GameFlowSubsystem이나 퀘스트 조건을 직접 확인하지 않는다.
아이템 목표의 UI 갱신
아이템 제출형 퀘스트는 현재 인벤토리 수량을 진행도로 사용한다.
따라서 아이템 목표 UI는 InventoryComponent의 OnInventoryChanged Delegate에도 반응한다.
Event.Item.PickedUp
→ InventoryComponent
→ AddItem
→ OnInventoryChanged
→ Quest Widget 진행도 재조회
이 역시 Tick 기반 Polling이 아니다.
Gameplay Message
= 시스템 사이의 사건 전달
Inventory Delegate
= 특정 데이터 객체의 변경 알림
두 방식을 목적에 맞게 함께 사용했다.
발신자와 수신자의 결합도 낮추기
아이템 획득
발신자:
AFTItemActor
메시지:
Event.Item.PickedUp
수신자:
UFTInventoryComponent
추후 Tutorial, Audio, Achievement 등
Item Actor는 InventoryComponent의 구현을 몰라도 된다.
레이드 시작
발신자:
AFTHubRaidEntrance
메시지:
Request.Flow.StartRaid
수신자:
UFTGameFlowSubsystem
RaidEntrance는 레벨 전환 규칙을 몰라도 된다.
레이드 탈출
발신자:
UFTGameFlowSubsystem
메시지:
Event.Raid.Escaped
수신자:
UFTObjectiveSubsystem
기타 탈출 결과 시스템
GameFlowSubsystem은 퀘스트 데이터를 몰라도 된다.
퀘스트 진행
발신자:
UFTObjectiveSubsystem
메시지:
Event.Objective.ProgressChanged
수신자:
UFTUIManagerSubsystem
UFTQuestListWidget
ObjectiveSubsystem은 HUD 구조를 몰라도 된다.
직접 참조와 메시지의 사용 기준
모든 호출을 Gameplay Message로 바꾸는 것이 항상 좋은 것은 아니다.
메시지가 적합한 경우
- 하나의 사건에 여러 시스템이 반응할 때
- 발신자가 수신자를 몰라도 될 때
- 결과를 즉시 반환받을 필요가 없을 때
- 사건 발생 사실을 알릴 때
- UI와 게임 로직을 분리할 때
아이템을 획득했다.
레이드에서 탈출했다.
퀘스트 진행도가 변경됐다.
직접 호출이 적합한 경우
- 즉시 반환값이 필요할 때
- 명확한 하나의 소유자가 있을 때
- 실패 시 복구가 필요한 트랜잭션일 때
- 호출 순서가 중요한 경우
- 대상 레벨처럼 구체적인 인자가 필요할 때
if (Inventory->CanAddItem(...))
{
Inventory->AddItem(...);
}
const bool bStarted =
FlowSubsystem->RequestStartRaidAtLevel(
LevelName
);
메시지는 결합도를 낮추지만 호출 흐름을 눈으로 추적하기 어려워질 수 있다. 따라서 상태 조회나 거래 처리까지 무조건 메시지로 바꾸지 않았다.
Request 메시지의 반환값 문제
Gameplay Message의 BroadcastMessage()는 일반 함수 호출처럼 성공 여부를 반환하지 않는다.
BroadcastMessage(
TAG_FT_Request_Flow_StartRaid,
Payload
);
발신자는 GameFlowSubsystem이 요청을 성공적으로 처리했는지 즉시 알 수 없다.
성공 여부가 필요하다면 두 가지 방법을 사용할 수 있다.
직접 호출
const bool bSuccess =
FlowSubsystem
->RequestStartRaidAtLevel(
LevelName
);
결과 이벤트 추가
Request.Flow.StartRaid
→ GameFlowSubsystem 처리
→ Event.Flow.RaidStartAccepted
또는
→ Event.Flow.RaidStartRejected
현재 특정 레벨 레이드 입장에서는 입장권 복구와 UI 닫기 여부를 즉시 결정해야 하므로 직접 호출을 사용한다.
리스너 생명주기 관리
메시지를 구독한 객체는 종료 시 리스너를 해제해야 한다.
void UFTGameFlowSubsystem::Deinitialize()
{
UGameplayMessageSubsystem& MessageSubsystem =
UGameplayMessageSubsystem::Get(this);
for (FGameplayMessageListenerHandle& Handle
: FlowRequestListenerHandles)
{
if (Handle.IsValid())
{
MessageSubsystem
.UnregisterListener(Handle);
}
}
FlowRequestListenerHandles.Reset();
Super::Deinitialize();
}
Widget에서도 NativeConstruct()에서 등록하고 NativeDestruct()에서 해제한다.
Subsystem
Initialize → RegisterListener
Deinitialize → UnregisterListener
Widget
NativeConstruct → RegisterListener
NativeDestruct → UnregisterListener
해제하지 않으면 다음 문제가 발생할 수 있다.
- 같은 메시지를 여러 번 처리
- 사라진 Widget에 콜백 발생
- 레벨 전환 후 이전 객체가 계속 반응
- 예측하기 어려운 중복 UI 갱신
Gameplay Message를 적용하면서 생긴 장점
1. Actor 책임이 줄었다
Item Actor는 아이템 획득 이후 어떤 시스템이 반응하는지 알지 않는다.
RaidEntrance도 기본 게임 흐름 구현을 몰라도 된다.
2. 새로운 수신자를 쉽게 추가할 수 있다
새로운 시스템이 기존 메시지를 구독하면 된다.
Event.Raid.Escaped
+ AchievementSubsystem 구독
기존 GameFlowSubsystem 코드는 수정하지 않아도 된다.
3. UI와 게임 로직이 분리됐다
ObjectiveSubsystem은 HUD를 직접 조작하지 않는다.
HUD도 퀘스트 조건을 매 프레임 확인하지 않는다.
4. 퀘스트 조건 확장이 쉬워졌다
새로운 Gameplay Tag를 정의하고 퀘스트 DataTable의 EventCondition에 넣으면 같은 진행 구조를 재사용할 수 있다.
5. 이벤트 의미가 명확해졌다
함수 이름과 객체 참조만 따라가는 대신 Gameplay Tag를 통해 프로젝트의 사건 흐름을 파악할 수 있다.
현재 구조의 한계
Gameplay Message를 사용한다고 모든 결합 문제가 자동으로 해결되는 것은 아니다.
1. 공용 Payload가 커질 수 있다
FFTMessagePayloadStruct에 여러 기능의 필드가 계속 추가되면 어떤 이벤트에서 어떤 필드가 유효한지 알기 어려워진다.
Item 이벤트
→ ItemId, Value 사용
Quest 이벤트
→ QuestId, Value 사용
Flow 이벤트
→ Value 사용
이벤트 성격별 Payload로 분리하는 것이 더 안전할 수 있다.
2. 발신자를 추적하기 어렵다
직접 호출과 달리 코드 검색 한 번으로 전체 흐름이 보이지 않을 수 있다.
태그의 발행 위치와 구독 위치를 함께 검색해야 한다.
3. 잘못된 대상이 이벤트를 받을 수 있다
멀티플레이 환경에서는 Instigator와 Target 검증이 더 중요해진다.
현재 일부 획득 메시지는 대상 정보가 모두 비어 있으면 모든 InventoryComponent가 처리할 수 있는 Single Player용 Fallback이 있다.
4. 처리 순서를 보장하기 어렵다
한 메시지를 여러 시스템이 받을 때 어떤 리스너가 먼저 처리되는지에 의존하면 안 된다.
순서가 중요한 거래나 저장 처리에는 명시적인 오케스트레이션이 필요하다.
5. Request의 결과를 바로 받을 수 없다
성공 여부가 필요한 요청은 직접 호출 또는 별도의 결과 이벤트가 필요하다.
이후 개선할 점
- Event와 Request 태그 네이밍 완전 통일
- 레거시 태그 이름 정리
- 도메인별 Payload 구조체 분리
- 특정 레벨 시작용 Flow Request Payload 추가
- Request ID를 이용한 요청과 결과 연결
- 메시지 발행 및 수신 로그 채널 정리
- 중복 이벤트 방지
- 멀티플레이용 Instigator 검증
- 리스너 자동 해제 래퍼
- 이벤트 문서 자동 생성
- Gameplay Tag별 발신자와 수신자 목록 문서화
- 중요한 메시지의 자동화 테스트
- 순서가 필요한 작업과 브로드캐스트 이벤트 구분
예를 들어 레이드 시작 요청은 다음처럼 별도의 Payload로 확장할 수 있다.
USTRUCT(BlueprintType)
struct FFTRaidStartRequestPayload
{
GENERATED_BODY()
FGuid RequestID;
TObjectPtr<AActor> InstigatorActor;
FName TargetLevelName;
FName RequiredItemID;
};
결과 이벤트도 Request ID를 포함할 수 있다.
USTRUCT(BlueprintType)
struct FFTRaidStartResultPayload
{
GENERATED_BODY()
FGuid RequestID;
bool bSucceeded = false;
FName FailureReason;
};
Request.Flow.StartRaid
→ RequestID: 1001
Event.Flow.StartRaidResult
→ RequestID: 1001
→ Success: true
이렇게 하면 메시지 기반 요청에서도 결과를 연결할 수 있다.
최종 통신 흐름
[아이템 획득]
Item Actor
→ Event.Item.PickedUp
→ InventoryComponent
→ OnInventoryChanged
→ Inventory UI / Quest UI
[레이드 시작]
Hub Raid Entrance
→ Request.Flow.StartRaid
→ GameFlowSubsystem
→ FlowState 변경
→ Event.Raid.Started
[레이드 탈출]
GameFlowSubsystem
→ Event.Raid.Escaped
→ ObjectiveSubsystem
→ 퀘스트 탈출 조건 갱신
→ Event.Objective.ProgressChanged
→ HUD / Quest Widget
[퀘스트 완료]
터미널 완료 요청
→ ObjectiveSubsystem
→ 조건 검사 및 보상 지급
→ Event.Objective.Completed
→ HUD / Quest Widget
마무리
이번 작업을 통해 시스템 간 통신 방식을 다음과 같이 정리했다.
Event
= 이미 발생한 사실을 알린다.
Request
= 특정 행동을 담당 시스템에 요청한다.
Gameplay Tag
= 메시지의 의미와 채널을 정의한다.
Payload
= 사건과 함께 필요한 데이터를 전달한다.
Subsystem
= 메시지를 받아 실제 게임 규칙을 처리한다.
HUD와 Widget
= 상태 변경 알림을 받고 필요한 화면만 갱신한다.
GameplayMessageSubsystem을 도입한 가장 큰 이유는 단순히 전역에서 메시지를 보내기 위해서가 아니었다.
아이템 Actor가 퀘스트를 모르고, GameFlowSubsystem이 HUD를 모르며, ObjectiveSubsystem이 Widget 구조를 몰라도 각 기능이 연결되도록 만들기 위한 선택이었다.
모든 함수를 메시지로 바꾸지는 않았다. 즉시 반환값이 필요하거나 실패 복구가 중요한 작업에는 직접 호출을 유지하고, 여러 시스템에 사건을 알리는 부분에는 Gameplay Message를 사용했다.
결과적으로 시스템 간 의존성을 줄이면서도 아이템 획득, 레이드 흐름, 퀘스트 진행, HUD 갱신을 하나의 이벤트 흐름으로 연결할 수 있었다.
'TIL' 카테고리의 다른 글
| [UE5] ProjectFT #16 퀘스트 보상과 데이터 구조 확장 (0) | 2026.07.27 |
|---|---|
| [UE5] ProjectFT #15 퀘스트 UI를 메일 형태로 설계하기 (0) | 2026.07.24 |
| [UE5] ProjectFT #13 이벤트 기반 퀘스트 진행 시스템 (0) | 2026.07.22 |
| [UE5] ProjectFT #12 허브 경제의 인벤토리 통합 규칙 (0) | 2026.07.21 |
| [UE5] ProjectFT #11 중고거래 게시글 자동 생성 시스템 (0) | 2026.07.20 |