컨텐츠 검색
[GAS/Day 2] 직접 구현 vs GAS 비교

2026. 1. 26. 18:01Unreal Engine/GAS

1. 서론

오늘은 Gameplay Ability System이 어떻게 나오게 되었는지, 직접 구현 vs GAS의 비교를 통해 알아보겠습니다. 게임 개발을 하다 보면 캐릭터의 HP, MP, 스태미나 같은 수치(Attribute) 관리부터, 스킬 쿨타임, 버프/디버프 처리까지 복잡한 상호작용을 다루게 됩니다. 처음에는 간단한 변수로 시작하지만, 프로젝트가 커질수록 유지보수와 멀티플레이어 동기화가 기하급수적으로 어려워집니다. 이번 포스팅에서는 전통적인 구현 방식의 한계를 직접 코드로 확인하고, 이를 해결하기 위해 Epic Games의 공식 프레임워크인 GAS(Gameplay Ability System)를 도입하는 과정을 정리해 보았습니다.


2. 직접 구현 vs GAS 비교

확실히 비교하기 위해 먼저 구현을 하기 전에 어떤 차이점이 있는지 표로 간단히 정리해봤습니다.

비교 관점 직접 구현 (Manual Implementation) GAS (Gameplay Ability System)
1. 네트워크 복제 변수마다 Replicated 설정, DOREPLIFETIME 등록 필요. 실수 발생 쉬움. AttributeSet과 GE를 통해 값과 효과가 시스템 레벨에서 자동 동기화.
2. 데이터 보호 (Clamp) 데미지, 힐, 부활 등 수치가 변하는 모든 함수에 Math::Clamp 중복 작성. PreAttributeChange 한 곳에서 모든 변경 요청을 검문/보정 (Single Source of Truth).
3. 변경 감지 OnHealthChanged 같은 델리게이트를 직접 선언하고, 변경 시점마다 Broadcast 호출. Attribute 값이 변하면 자동으로 델리게이트가 트리거됨.
4. 버프/디버프 (시간) FTimerHandle 관리 지옥. 버프가 끝나면 타이머 해제하고 스탯 원복하는 로직 작성 필요. GameplayEffect의 Duration 정책으로 자동 처리. 만료 시 Modifier 자동 해제.
5. 데미지 계산 TakeDamage 안에 방어력, 관통력, 크리티컬 계산식이 if/else로 뒤섞임. ExecutionCalculation 클래스로 분리. 복잡한 수식을 모듈화하여 관리.
6. UI 업데이트 UI가 캐릭터의 변수를 직접 참조하거나 델리게이트에 바인딩 (결합도 높음). AbilitySystemComponent의 델리게이트를 리스닝 (느슨한 결합).
7. 확장성 스탯 추가 시(예: Mana) 위 1~6번 작업을 처음부터 다시 반복. AttributeSet에 변수만 추가하면 끝. 로직은 재사용 가능.
8. 상태 관리 (Tags) bIsStunned, bIsSilenced 등 수많은 bool 변수 관리 (bool 지옥). Gameplay Tags로 상태 관리 (State.Debuff.Stun). 태그 유무로 판별.
9. 예측 (Prediction) 클라이언트 반응성을 위한 예측(Prediction) 구현이 매우 난해함. Prediction 시스템 내장. 랙이 있어도 즉각적인 반응성 보장.

2.1. 변수 선언과 네트워크 복제

가장 먼저 캐릭터의 체력(Health) 데이터를 정의하고, 이를 서버-클라이언트 간에 동기화하는 단계입니다.

"단순 값 전달(Value Transfer) vs 상태 동기화(State Synchronization)"

두 방식 모두 헤더에 변수를 선언하고, GetLifetimeReplicatedProps에서 DOREPLIFETIME을 등록하는 절차는 동일합니다.

// MyCharacter.h

UPROPERTY(Replicated)
float Health;

UPROPERTY(Replicated)
float MaxHealth;

UFUNCTION()
void OnRep_Health();

virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
// MyCharacter.cpp

#include "Net/UnrealNetwork.h"

void AMyCharacter::OnRep_Health()
{
    // 단순한 UI 업데이트 코드
    // if (HealthBarWidget)
    // {
    //     HealthBarWidget->SetPercent(Health / MaxHealth);
    // }
}

void AMyCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(AMyCharacter, Health);
    DOREPLIFETIME(AMyCharacter, MaxHealth);
}

직접 구현:

  • float Health는 단순한 4바이트 부동소수점 데이터입니다.
  • 서버가 값을 90으로 바꾸면 클라이언트는 90을 받습니다. 끝입니다.
  • 네트워크 지연(RTT)으로 인해 클라이언트가 "먼저 맞았다"고 판단하고 피격 모션을 취했는데, 0.2초 뒤 서버가 "안 맞았다"며 체력을 롤백해야 한다면? 개발자가 과거 시점의 데이터를 보정하는 로직(Reconciliation)을 직접 수학적으로 계산해야 합니다.

// MyAttributeSet.h

#include "AbilitySystemComponent.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, Category = "Attributes", ReplicatedUsing = OnRep_Health)
FGameplayAttributeData Health;
ATTRIBUTE_ACCESSORS(UMyAttributeSet, Health);

UPROPERTY(BlueprintReadOnly, Category = "Attributes", ReplicatedUsing = OnRep_MaxHealth)
FGameplayAttributeData MaxHealth;
ATTRIBUTE_ACCESSORS(UMyAttributeSet, MaxHealth);

// 네트워크 복제 함수 선언
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

UFUNCTION()
virtual void OnRep_Health(const FGameplayAttributeData& OldHealth);

UFUNCTION()
virtual void OnRep_MaxHealth(const FGameplayAttributeData& OldMaxHealth);
// MyAttributeSet.cpp

#include "Net/UnrealNetwork.h"

void UMyAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(UMyAttributeSet, Health);
    DOREPLIFETIME(UMyAttributeSet, MaxHealth);
}

void UMyAttributeSet::OnRep_Health(const FGameplayAttributeData& OldHealth)
{
    GAMEPLAYATTRIBUTE_REPNOTIFY(UMyAttributeSet, Health, OldHealth);
}

void UMyAttributeSet::OnRep_MaxHealth(const FGameplayAttributeData& OldMaxHealth)
{
    GAMEPLAYATTRIBUTE_REPNOTIFY(UMyAttributeSet, MaxHealth, OldMaxHealth);
}

GAS:

  • FGameplayAttributeData는 구조체입니다. 내부적으로 BaseValue(영구 스탯)와 CurrentValue(버프 포함 현재 값)를 분리하여 관리합니다.
  • GAMEPLAYATTRIBUTE_REPNOTIFY 매크로는 엔진의 예측 시스템(Prediction System)과 연결되어 있습니다.
  • 분석: GAS는 단순한 값 복제가 아니라 타임스탬프 기반의 트랜잭션 동기화에 가깝습니다. 클라이언트가 예측 실행(Prediction)을 하면 임시로 값을 변경하고, 서버의 승인(Server Ack)이 오면 자연스럽게 병합하거나, 거절되면 부드럽게 롤백(Smoothing)합니다. 같은 DOREPLIFETIME을 쓰지만, GAS는 FPS 게임 수준의 정교한 넷코드(Netcode)를 무료로 제공합니다.

직접 구현 방식에서도 ReplicatedUsing을 통해 OnRep 함수를 만들 수 있습니다. 하지만 이 경우 OnRep은 단순히 '값이 변했다'는 알림을 받는 껍데기에 불과합니다.

반면, 일반적인 DOREPLIFETIME은 서버의 값을 클라이언트에게 '통보(Notify)'하는 것에 불과합니다. 하지만 GAS의 GAMEPLAYATTRIBUTE_REPNOTIFY 매크로는 클라이언트가 미리 예측(Prediction)하여 변경한 값과 서버에서 내려온 값의 '오차를 계산(Reconciliation)'하고, 틀렸을 경우 부드럽게 되돌리는 '롤백(Rollback)' 로직까지 내장되어 있습니다. 이 한 줄의 차이가 게임의 반응성을 결정합니다. 즉, 직접 구현은 '단순 복제'이고, GAS는 '예측 보정이 포함된 동기화'입니다.


2.2. 데이터 보호

체력이 0 미만이나 최대치를 초과하지 않도록 막는 방어 로직 구현 단계입니다.

"분산된 방어 코드(Distributed Defense) vs 중앙 집중식 미들웨어(Centralized Middleware)"

// MyCharacher.h

void TakeDamage(float DamageAmount);
void Heal(float HealAmount);
// MyCharacter.cpp

void AMyCharacter::TakeDamage(float DamageAmount)
{
    Health -= DamageAmount;
    // 실수 포인트: 여기서 Clamp를 빼먹으면 버그 발생
    Health = FMath::Clamp(Health, 0.0f, MaxHealth);

    if (Health <= 0.0f)
    {
        // Die();
    }
    else
    {
        // PlayHitReact();
    }
}

void AMyCharacter::Heal(float HealAmount)
{
    Health += HealAmount;
    // 또 작성해야 함. 중복 코드 발생.
    Health = FMath::Clamp(Health, 0.0f, MaxHealth);
}

직접 구현:

  • TakeDamage, Heal, Revive 등 체력을 건드리는 모든 함수에 FMath::Clamp 코드를 작성해야 합니다.
  • 이는 '개발자의 기억력'에 의존하는 방어 방식입니다. 팀원 중 누군가 급하게 DrainLife() 함수를 만들면서 Clamp를 빼먹는 순간, 데이터 무결성은 깨지고 버그가 발생합니다.

// MyAttributeSet.h

virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) override;
// MyAttributeSet.cpp

void UMyAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
    Super::PreAttributeChange(Attribute, NewValue);

        if (Attribute == GetHealthAttribute())
        {
            NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxHealth());
        }
}

GAS:

  • PreAttributeChange 함수는 데이터베이스의 트리거(Trigger)나 미들웨어 역할을 수행합니다.
  • 어떤 소스(데미지, 힐, 도트 딜)에서 변경 요청이 오든, 값이 적용되기 직전(Pre)에 이 관문을 강제로 통과해야 합니다.
  • 분석: 이는 소프트웨어 공학의 '단일 진실 공급원(Single Source of Truth)' 원칙을 준수합니다. 데이터 변조 로직을 단 한 곳에 격리(Isolation)함으로써, 추후 유지보수 시 "체력 버그가 났다"면 수십 개의 함수를 뒤질 필요 없이 이곳만 확인하면 됩니다.

2.3. 사망 처리 및 게임플레이 효과

값이 실제로 변경된 후, 사망하여 입력을 막거나 피격 애니메이션을 재생하는 단계입니다.

"강한 결합(Tightly Coupled) vs 이벤트 주도(Event-Driven)"

직접 구현:

  • TakeDamage 함수 안에서 Health -= Damage (연산), if (Health <= 0) (검사), PlayAnim (연출)이 뒤섞여 있습니다.
  • 전투 로직이 바뀔 때마다 이 거대한 함수("God Function")를 수정해야 하며, 스파게티 코드가 되기 쉽습니다.

// MyAttributeSet.h

virtual void PostGameplayEffectExecute(const struct FGameplayEffectModCallbackData& Data) override;
// MyAttributeSet.cpp

void UMyAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
    Super::PostGameplayEffectExecute(Data);

    if (Data.EvaluatedData.Attribute == GetHealthAttribute())
    {
        if (GetHealth() <= 0.0f)
        {
            // 캐릭터에게 사망 이벤트를 보내거나 함수 호출
            // 예: Cast<AMyCharacter>(Data.Target.GetAvatarActor())->Die();
        }
    }
}

GAS:

  • PostGameplayEffectExecute는 철저히 결과에 대한 반응(Reaction)만 담당합니다.
  • "얼마나 깎였는가(Calculation)"는 ExecutionCalculation 클래스가, "그래서 어떻게 되었는가"는 AttributeSet이 처리합니다.
  • 분석: 이는 '관심사의 분리(Separation of Concerns)'를 완벽하게 구현합니다. 데미지 계산 공식이 아무리 복잡해져도(방어력, 관통, 속성 등), 사망 처리 로직은 영향을 받지 않습니다. GAS는 로직의 흐름을 계산 -> 적용 -> 반응의 파이프라인으로 명확히 구조화합니다.

2.4. UI 업데이트

체력이 깎였을 때 체력바(WBP)를 갱신하는 단계입니다.

"수동 통지(Manual Notification) vs 리액티브 프로그래밍(Reactive Programming)"

// MyCharacter

// 1. 델리게이트 선언 (Header)
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChanged, float, NewHealth);
UPROPERTY(BlueprintAssignable)
FOnHealthChanged OnHealthChanged;

// 2. 값 변경 시 호출 (Source)
void AMyCharacter::TakeDamage(float DamageAmount)
{
    // ... 값 변경 ...
    OnHealthChanged.Broadcast(Health);
}

직접 구현:

  • Broadcast를 개발자가 직접 호출해야 합니다. 실수로 호출을 누락하면 "데이터는 50인데 UI는 100인" 디싱크(Desync) 상태가 됩니다.
  • 최악의 경우, Tick에서 매 프레임 체력을 검사하는 폴링(Polling) 방식을 사용하여 CPU를 낭비하기도 합니다.

// MyCharacter.cpp (InitializeAbilitySystem 내부 등에서 바인딩)

void AMyCharacter::BindHealthChanged()
{
    if (AbilitySystemComponent)
    {
        // Health 속성이 변할 때 호출될 콜백 등록
        AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(
            AttributeSet->GetHealthAttribute()
        ).AddUObject(this, &AMyCharacter::OnHealthChangedGAS);
    }
}

void AMyCharacter::OnHealthChangedGAS(const FOnAttributeChangeData& Data)
{
    // 변경된 값(Data.NewValue)으로 UI 업데이트
    // 직접 Broadcast를 호출할 필요가 없음 (자동 감지)
}

GAS:

  • ASC(Ability System Component)는 속성 값이 변하는 순간 내부적으로 델리게이트를 송출합니다.
  • UI는 이 델리게이트를 구독(Subscribe)하기만 하면 됩니다.
  • 분석: 이는 현대적인 프론트엔드 개발의 핵심인 '리액티브 프로그래밍(Reactive Programming)' 패턴입니다. 데이터의 변경이 곧 이벤트 스트림이 되어 UI로 흘러갑니다. 개발자는 "언제 업데이트할지" 고민할 필요 없이, "변하면 반영한다"는 선언적 로직에만 집중할 수 있습니다.

2.5. 상태 관리와 버프/디버프

"기절 시 이동 불가", "5초간 공격력 증가" 등을 구현해야 하는 단계입니다.

"명령형 로직(Imperative Logic) vs 데이터 주도 설계(Data-Driven Design)"

// MyCharacter

// Header
bool bIsStunned;
FTimerHandle StunTimerHandle;

// Source
void AMyCharacter::ApplyStun(float Duration)
{
    bIsStunned = true;
    [cite_start]// 5초 뒤에 Stun을 푸는 타이머 설정 (복잡함 [cite: 39])
    GetWorld()->GetTimerManager().SetTimer(StunTimerHandle, this, &AMyCharacter::ClearStun, Duration, false);
}

직접 구현:

  • bool bIsStunned, FTimerHandle StunTimer 등 변수와 타이머를 남발해야 합니다.
  • "기절 중에 또 기절을 맞으면 시간을 초기화(Refresh)할 것인가, 늘릴(Stack) 것인가?" 같은 중첩 규칙을 구현하려면 수많은 if-else와 타이머 관리 코드가 필요합니다. (Race Condition 발생 위험 높음)

bool bIsStunned = AbilitySystemComponent->HasMatchingGameplayTag(
    FGameplayTag::RequestGameplayTag(FName("State.Debuff.Stun"))
);

if (bIsStunned)
{
    // 이동 로직 차단 등
}

GAS (Gameplay Ability System):

  • HasMatchingGameplayTag 함수 하나로 깔끔하게 상태를 체크할 수 있습니다.
  • GameplayEffect 블루프린트에서 Duration Policy(시간), Stacking Rule(중첩 규칙), Granted Tags(상태 부여)를 설정값으로 입력합니다.
  • State.Debuff.Stun 태그가 붙으면, 이동 어빌리티가 자동으로 차단(Cancel/Block)되도록 태그 필터링을 겁니다.
  • 분석: 복잡한 시분할 로직을 명령형 코드(Imperative Code)로 짜는 것은 버그의 온상입니다. GAS는 이를 데이터 에셋으로 관리하게 함으로써, 프로그래머의 개입 없이 기획자가 직접 스킬의 메커니즘을 조립하고 수정할 수 있는 환경을 제공합니다.

3. 정리

[직접 구현]

 

[GAS]

 

[GAS FlowChart]