개인프로젝트/AFO_Refactor

[AFO_Refactor #3] GAS 도입 — 왜, 무엇을, 어떻게 (Phase 1 셋업)

Client Side 2026. 6. 17. 17:29

AFO_Refactor 개발일지 #3

GAS 도입 — 왜, 무엇을, 어떻게

Gameplay Ability System · ASC · AttributeSet · Phase 1 셋업

🎯 왜 GAS를 도입했나

팀 프로젝트 AFO에서 HP/Mana 관련 코드가 이렇게 흩어져 있었다.

// 기존 — 4곳에 분산된 HP/Mana 관리
UAFAttributeComponent    ← HP 실제 저장/계산
AAFPlayerState           ← HP/Mana 복제 변수 (수동 동기화)
AAFPlayerCharacter       ← 마나 소모 체크
UAFStatusEffectComponent ← 슬로우 타이머

문제가 명확했다.

  • HP가 바뀔 때마다 SyncHealthToPlayerState()를 수동으로 호출해야 했다
  • 마나 소모 검증을 서버에서 직접 처리하는 코드가 캐릭터 클래스에 하드코딩되어 있었다
  • 슬로우는 타이머 기반이라 캐릭터가 죽어도 타이머가 계속 돌았다
  • 새 캐릭터/스킬 추가 시 이 흩어진 코드를 전부 수정해야 했다

GAS 도입 목표: 전투 관련 수치/스킬/상태효과를 하나의 프레임워크로 통합하고, 네트워크 복제를 자동화한다.

📚 GAS란 무엇인가

Gameplay Ability System은 언리얼 공식 플러그인으로, 캐릭터의 능력(스킬), 속성(HP/마나), 상태효과(버프/디버프)를 데이터 중심으로 관리하는 프레임워크다.

핵심 구성요소 5가지:

UAbilitySystemComponent (ASC)
 ├── UAttributeSet        ← HP, Mana, 공격력 등 수치 관리
 ├── UGameplayAbility     ← 스킬 (Q, E, 기본공격)
 ├── UGameplayEffect      ← 수치 변화 (데미지, 힐, 버프)
 └── FGameplayTag         ← 상태 태그 (공격중, 스턴, 사망)

AFO 기존 코드와 1:1로 대응하면 이렇게 된다:

기존 AFO GAS 교체 후
UAFAttributeComponent UAttributeSet
PlayerState HP/Mana + 수동 OnRep FGameplayAttributeData + 자동 복제
DealDamage() 직접 HP 감소 UGameplayEffect (Instant)
ConsumeMana() 직접 차감 UGameplayEffect (Instant + SetByCaller)
AFStatusEffectComponent 슬로우 타이머 UGameplayEffect (Duration)

✅ GAS를 쓰면 뭐가 좋아지나

① 네트워크 복제 자동화

BEFORE — 수동 동기화

void UAFAttributeComponent::ApplyDamage(...)
{
    Health -= Damage;
    SyncHealthToPlayerState();  // 매번 수동 호출 필요
}

AFTER — 선언 한 번으로 자동 복제

UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_Health)
FGameplayAttributeData Health;
// 엔진이 자동으로 복제 처리

② 조건 검증 내장

마나 부족 시 스킬 차단을 직접 코딩할 필요 없이, GameplayEffect Cost로 선언하면 자동으로 검증된다.

③ 상태 관리 통합

기존에 흩어져 있던 bIsAttacking, bIsUsingSkill, bIsHeavyAttacking 플래그들을 GameplayTag로 통합 관리할 수 있다.

🔧 Phase 1 — GAS 기반 셋업

Step 1 — 모듈 추가

// AFO.Build.cs
PublicDependencyModuleNames.AddRange(new string[] {
    "GameplayAbilities",
    "GameplayTags",
    "GameplayTasks"
});

Step 2 — UAFAttributeSet 생성

HP, MaxHP, Mana, MaxMana, AttackPower 5개 속성을 정의했다. ATTRIBUTE_ACCESSORS 매크로로 Getter/Setter/Initter를 자동 생성한다.

// AFAttributeSet.h
#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \
    GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
    GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \
    GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \
    GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)

UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_Health)
FGameplayAttributeData Health;
ATTRIBUTE_ACCESSORS(UAFAttributeSet, Health)

UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_MaxHealth)
FGameplayAttributeData MaxHealth;
ATTRIBUTE_ACCESSORS(UAFAttributeSet, MaxHealth)

Step 3 — ASC를 PlayerState에 부착

ASC(AbilitySystemComponent)를 PlayerState에 붙이는 게 멀티플레이 표준 패턴이다. 캐릭터가 죽고 리스폰되어도 PlayerState는 유지되기 때문에 ASC와 어트리뷰트가 보존된다.

// AFPlayerState.h — IAbilitySystemInterface 구현
class AAFPlayerState : public APlayerState, public IAbilitySystemInterface
{
    virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override;

    UPROPERTY(VisibleAnywhere, Category="GAS")
    TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;

    UPROPERTY(VisibleAnywhere, Category="GAS")
    TObjectPtr<UAFAttributeSet> AttributeSet;
};

Step 4 — InitAbilityActorInfo 호출

GAS가 "이 ASC의 소유자(Owner)는 PlayerState, 아바타(Avatar)는 Character"라는 걸 알 수 있도록 초기화해줘야 한다. 서버와 클라이언트 각각 다른 시점에 호출해야 한다.

// 서버 — PossessedBy()에서 초기화
void AAFPlayerCharacter::PossessedBy(AController* NewController)
{
    Super::PossessedBy(NewController);
    if (AAFPlayerState* PS = GetPlayerState<AAFPlayerState>())
    {
        PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, this);
    }
}

// 클라이언트 — OnRep_PlayerState()에서 초기화
void AAFPlayerCharacter::OnRep_PlayerState()
{
    Super::OnRep_PlayerState();
    if (AAFPlayerState* PS = GetPlayerState<AAFPlayerState>())
    {
        PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, this);
    }
}

💡 서버와 클라이언트 분리가 핵심: 서버는 PossessedBy에서, 클라이언트는 OnRep_PlayerState에서 초기화해야 한다. 어느 한쪽만 하면 반대편에서 ASC가 작동하지 않는다.

📡 ASC 리플리케이션 모드 선택

GAS에서 ASC 리플리케이션 모드는 3가지다. AFO는 Mixed를 선택했다.

모드 GameplayEffect 복제 적합한 경우
Full 모든 클라이언트 싱글플레이
Mixed ✅ 소유 클라이언트만 플레이어 캐릭터 멀티플레이
Minimal 복제 안 함 AI/NPC

🏷️ GameplayTag 등록

GAS에서 데이터를 런타임에 전달하거나 상태를 표현할 때 GameplayTag를 사용한다. Config/DefaultGameplayTags.ini에 등록한다.

# Config/DefaultGameplayTags.ini
[/Script/GameplayTags.GameplayTagsSettings]
+GameplayTagList=(Tag="Data.Damage",DevComment="데미지 전달용")
+GameplayTagList=(Tag="Data.ManaCost",DevComment="마나 소모 전달용")
+GameplayTagList=(Tag="Data.SlowDuration",DevComment="슬로우 지속시간")
+GameplayTagList=(Tag="Effect.Slowed",DevComment="슬로우 상태")
+GameplayTagList=(Tag="State.IsDead",DevComment="사망 상태")

📌 이번 작업에서 배운 것

ASC는 PlayerState에 붙여야 한다.

캐릭터에 붙이면 사망/리스폰 시 캐릭터가 Destroy되면서 ASC도 사라진다. PlayerState는 리스폰 후에도 유지되므로 HP, 마나, 어빌리티가 보존된다. 멀티플레이에서 플레이어 캐릭터의 ASC는 반드시 PlayerState에 붙이는 것이 표준 패턴이다.

InitAbilityActorInfo는 서버/클라이언트 각각 호출해야 한다.

서버는 PossessedBy, 클라이언트는 OnRep_PlayerState. 이 두 시점을 놓치면 한쪽에서 GAS가 동작하지 않는다. 처음 GAS를 도입할 때 가장 실수하기 쉬운 부분이다.

📌 다음 포스팅

[AFO_Refactor #4] GAS 실전 적용 — 데미지/마나/슬로우 GameplayEffect 교체

ApplyDamage → AFGE_Damage, ConsumeMana → AFGE_ManaCost, 슬로우 타이머 → AFGE_Slow Duration. PostGameplayEffectExecute로 사망 처리 연결까지.