7. Gameplay Ability

골두·2024년 7월 27일

Unreal GAS Framework

목록 보기
7/8
post-thumbnail

해당 글은 https://github.com/tranek/GASDocumentation 의 설명을 한글로 번역 후 첨언 및 요약 해 나 보려고 내 입맛대로 작성한 글이다.

Gameplay Ability

Gameplay Ability(GA)는 Actor가 게임에서 수행할 수 있는 모든 액션과 스킬을 의미한다. 더 나아가서는 GA는 총을 쏘거나, 달리는 기능을 만드는 것도 가능하다. GA는 C++과 BP 둘다 개발할 수 있다.

GA 사용 예시

  • 점프 기능
  • 달리기
  • 총 발사
  • N초 마다 X번의 공격을 막는 패시브 기능
  • 포션 사용
  • 문 열기
  • 자원 수집
  • 빌딩 제작

단순 RPG 스킬 기능 뿐만 아니라 다양한 부분의 액션에 대해서도 GA를 이용해 개발이 가능하다라는 것이 핵심이다.

GA로 구현하지 않는 것을 권장하는 예시

  • 기본 이동 기능
  • 일부 UI에 대한 상호작용

(규칙이 아닌 제안이며, 디자인과 구현은 다양하게 할 수 있다)

GA는 속성의 변경에 기여하거나, GA의 기능을 변경하는 레벨 기능을 기본적으로 제공한다.

GA는 Net Execution Policy(Net 실행 정책, NEP)에 따라 실행 위치가 클라이언트, 서버 중 어느 곳 or 둘다 에서 이루어질지 결정하지만 Simulation Proxy에서는 실행되지 않는다.

NEP는 GA가 로컬에서의 CSPredict가 되는지 결정하는데 이 과정을 통해서 GE에서는 선택적인 비용과 쿨타임을 포함하곤 한다.

GA는 속성의 변경, 플레이어의 타겟 설정의 대기 혹은 Root Motion Source 기반의 캐릭터 움직임 등의 특정 이벤트 이후의 동작 처리(비정기적인 시간 이후의 동작)에 대해서 GameplayAbilityTask를 사용한다. 여기서 Simulated Client는 동작하지 않기 때문에 서버에서 GA가 실행될 때 시각적인 효과(AnimMontage, Particle 등)은 AbilityTask나 GameplayCues를 통해 Replicated 되거나 RPC 통신을 통해 전달된다.

모든 GA는 ActivateAbility() 함수를 통해서 로직이 재정의 된다. 그리고 능력 수행이 완료된 시점에서 EndAbility() 호출을 통해 Ability 실행이 완료되거나 취소되는 경우에 대한 부가적인 효과를 재정의해 사용이 가능하다.

GA의 일반적인 flowchart

간단한 GA 구현

조금 복잡한 GA 구현

TODO: https://github.com/tranek/GASDocumentation?tab=readme-ov-file#4611-replication-policy 부터 진행

Replication Policy

해당 옵션은 사용하면 안되는 옵션이기에 짦막하게 설명만 첨언하면, GASpec은 원래 서버에서 소유한 클라이언트에만 Replicated되었는데 해당 옵션을 통해 AbilityTask나 GameplayCue를 Simulated Proxy에 Replicated하거나 RPC통신 과정을 거치도록 하는데 이 옵션은 Epic의 Dave Ratti가 제거하고자 하는 의사를 밝혀 사용하지 않는 것이 좋아보임.

Server Respects Remote Ability Cancellation

서버 원격 능력 취소에 대한 존중 기능인데 이 옵션 또한 문제를 일으킬 수 있기 때문에 대부분은 비활성화를 하는 것을 권장한다. 이 옵션이 활성화 된다면 Client의 GA가 취소되거나 완료되면 동일한 능력이 서버에서의 완료 여부와 관계없이 강제로 종료된다. 지연 시간이 높은 플레이어가 사용하게 되면 로컬 CSPredict에 영향을 끼치기 때문에 사용하지 않는 것이 좋다.

Replicate Input Directly

입력 버튼에 대한 이벤트를 항상 서버에 Replicated하는 기능인데 Epics에서는 해당 기능의 사용 보다는 기존 Input에 관련된 GATask에 내장된 일반 Replicated Event를 사용하는 것을 권장한다. 특히 ASC에 이미 Input을 바인딩 한 경우는 더욱 이 부분에 대해 권장한다.

// Epic의 권장문
/** Direct Input state replication. These will be called if bReplicateInputDirectly is true on the ability and is generally not a good thing to use. (Instead, prefer to use Generic Replicated Events). */
UAbilitySystemComponent::ServerSetInputPressed()

ASC Input Binding

ASC는 Input Action을 직접 Binding 하고 Ability를 부여 시 GA에 연결하는 것이 가능한데, GA에 할당된 Input Action은 해당 능력 요구 사항인 Gameplay Tag 조건이 충족되는 경우 버튼을 누르게 되면 바로 활성화 되는데, 내장된 Input 관련 AbilityTask를 사용하기 위해서는 할당된 Input Action이 필요하다. ASC는 GA 활성화를 위한 Input Action 이외에도 일반적인 Confirm, Cancel 입력도 허용된다. 이러한 특수한 Input은 Target Actor 같이 확인이나 취소와 같은 작업을 위해 GATask에서 활용된다. ASC에 Input을 Binding하려면 먼저 Input Action의 이름을 Byte로 변환하는 Enum을 만들어야하는데, Enum 이름은 프로젝트 설정에서 사용하는 Input Action의 이름과 정확하게 일치해야한다. (Umeta 속성의 DisplayName과는 연관이 없다)

// 예시 Enum 코드
UENUM(BlueprintType)
enum class EGDAbilityInputID : uint8
{
	// 0 None
	None			UMETA(DisplayName = "None"),
	// 1 Confirm
	Confirm			UMETA(DisplayName = "Confirm"),
	// 2 Cancel
	Cancel			UMETA(DisplayName = "Cancel"),
	// 3 LMB
	Ability1		UMETA(DisplayName = "Ability1"),
	// 4 RMB
	Ability2		UMETA(DisplayName = "Ability2"),
	// 5 Q
	Ability3		UMETA(DisplayName = "Ability3"),
	// 6 E
	Ability4		UMETA(DisplayName = "Ability4"),
	// 7 R
	Ability5		UMETA(DisplayName = "Ability5"),
	// 8 Sprint
	Sprint			UMETA(DisplayName = "Sprint"),
	// 9 Jump
	Jump			UMETA(DisplayName = "Jump")
};

또한 캐릭터에 ASC가 존재한다면 SetupPlayerInputComponent() 함수내에서 ASC 바인딩 함수를 호출해줘야 한다.

// Bind to AbilitySystemComponent
FTopLevelAssetPath AbilityEnumAssetPath = FTopLevelAssetPath(FName("/Script/GASDocumentation"), FName("EGDAbilityInputID"));
AbilitySystemComponent->BindAbilityActivationToInputComponent(PlayerInputComponent, FGameplayAbilityInputBinds(FString("ConfirmTarget"),
	FString("CancelTarget"), AbilityEnumAssetPath, static_cast<int32>(EGDAbilityInputID::Confirm), static_cast<int32>(EGDAbilityInputID::Cancel)));

ASC가 PlayerState에 존재하는 경우는 SetupPlayerInputComponent()에서 문제가 발생할 수 있는데, Client에게 PlayerState가 아직 Replicated되지 않은 상태에서 Binding 함수를 호출할 수 있는 "race condition(경합 조건)"이라는 이슈가 발생하기에 SetupPlayerInputComponent()OnRep_PlayerState() 두 곳에서 Binding을 시도하는 것이 좋다. OnRep_PlayerState() 만으로 충분하다고 생각하는 경우가 있지만 사실이 아닌데, 그 이유는 PlayerController가 Client에게 ClientRestart()를 호출하도록 지시하기 전에 PlayerState가 복제될 수 있는 경우 또한 있기 때문이다. 이 함수는 InputComponent를 생성하기 때문에 Client Actor의 InputComponent가 null이 되는 경우가 있기 때문에 같이 사용해야한다.

샘플 프로젝트의 경우는 Client, Server 둘다 바인딩을 시도하나 실제로는 Input 정보를 한번만 바인딩하기 위해 별도의 boolean값을 사용한다. 이 것을 통해 Race condition을 방지하고 안정적으로 Input Binding이 가능해진다.

부가 설명
샘플 프로젝트에서는 Confirm과 Cancel 두 개의 키도 Enum에 들어가있는데, 이 두개가 비록 실제로 사용하는 GA 함수인 (ConfirmTarget, CancelTarget)과는 일치하지 않지만 BindAbilityActivationToInputComponent 메소드를 통해서 Mapping이 가능하다 라는 것을 기억하면 좋다. 이말은 즉슨 굳이 무리해서 이름을 동일하게 하지 않아도 Mapping하는 것이 가능하다 라는 것이지만 그렇다고 Mapping이 가능하니 이름 막 쓰자 이건 또 아니라서 웬만해서는 동일하게 해야한다. (confirm과 cancel만 mapping이 가능하다 라는 예시를 보여준 것 같다)

한번의 입력으로만 바로 활성화되는 (MOBA 장르 처럼 특정 캐릭터에 고유한 슬롯에 고정되어 존재하는 - RPG 캐릭터의 동적으로 할당해 사용하는 스킬과 다른 LOL 같은 장르의 특정 키에 고정된 특정 캐릭터의 스킬 같은 느낌이라 보면 된다.) GA의 경우는 Input에 넣을 변수를 UGameplayAbility타입의 Subclass로 할당하는 것을 선호하는데, 이렇게 된다면 컴파일 과정에서 CDO에서 이 값을 읽어 미리 할당할 수 있다. (이것은 개인의 취향 차이기 때문에 편한대로 하면 좋지만 글쓴이의 취향이 이렇다라는 것을 의미하기도 하다)

만약 입력이 눌릴 때 GameplayAbilities가 자동으로 활성화되지 않고, 여전히 AbilityTasks와 함께 사용하기 위해 입력에 바인딩하려면, UGameplayAbility 서브클래스에 기본값이 true인 새로운 불리언 변수 bActivateOnInput를 추가하고 UAbilitySystemComponent::AbilityLocalInputPressed()를 오버라이드할 수 있습니다.

만약 Input이 들어오는 상황에도 GA가 자동으로 활성화되지 않은 상태이길 윈해서 AbilityTask를 사용해 연동하고 있는 상태라면,
UGameplayAbility 에 존재하는 새로운 bool 타입의 변수인 bActiveOnInput을 활용해보는 것도 좋다. 이 변수는 true가 default 상태인데, UAbilitySystemComponent::AbilityLocalInputPressed()할 때 사용이 가능하다는 것이 특징이다. 해당 변수는 특별한 역할을 수행하기 보다 기본적으로 내장하고 있는 변수값이기 때문에 필요하에 사용하면 좋아보인다.

void UGSAbilitySystemComponent::AbilityLocalInputPressed(int32 InputID)
{
  if (IsGenericConfirmInputBound(InputID))
  {
	LocalInputConfirm();
	return;
  }

  if (IsGenericCancelInputBound(InputID))
  {
	LocalInputCancel();
	return;
  }
  
  ABILITYLIST_SCOPE_LOCK();
  for (FGameplayAbilitySpec& Spec : ActivatableAbilities.Items)
  {
	if (Spec.InputID == InputID)
	{
	  if (Spec.Ability)
	  {
		Spec.InputPressed = true;
		if (Spec.IsActive())
		{
		  if (Spec.Ability->bReplicateInputDirectly && IsOwnerActorAuthoritative() == false)
		  {
			ServerSetInputPressed(Spec.Handle);
		  }
		  AbilitySpecInputPressed(Spec);
		  InvokeReplicatedEvent(
          	EAbilityGenericReplicatedEvent::InputPressed,
          	Spec.Handle, Spec.ActivationInfo.GetActivationPredictionKey()
          );
        }
        else
        {
          UGSGameplayAbility* GA = Cast<UGSGameplayAbility>(Spec.Ability);
		  // GA에 내장된 bActivateOnInput의 값이 True인 상태에 한해 Ability를 Active하게 처리한다.
          if (GA && GA->bActivateOnInput)
          {
            TryActivateAbility(Spec.Handle);
          }
        }
      }
    }
  }
}

GA 부여하기

ASC에 GA를 부여하게 된다면 ASC에 있는 ActivatableAbilities라는 데이터 목록에 추가되어 GameplayTag 요구 조건에만 충족한다면 언제든지 활성화할 수 있다. GA들은 모두 서버에서 부여되며 자동으로 자신의 클라이언트에 GASpec 인스턴스를 Replicate하게 된다. 다른 클라이언트나 Simulated 기반의 Proxy 환경들에서는 GASpec을 받을 수 없다는 것을 기억하면 좋다.

제공하는 샘플 프로젝트의 경우는 자체 캐릭터 class에서 TArray<TSubclassOf<UGDGameplayAbility>>라는 타입으로 GA를 저장하고 있고 이것을 읽어 게임 시작 시에 부여해주는 역할을 수행한다.

void AGDCharacterBase::AddCharacterAbilities()
{
	// Role != ROLE_Authority 라는 조건에서 무조건 서버가 아니면 실행되지 않게 하기 위한 설계가 들어있다.
    // ROLE_Authority 자체가 서버 환경 임을 의미함.
	if (Role != ROLE_Authority || !AbilitySystemComponent.IsValid() || AbilitySystemComponent->bCharacterAbilitiesGiven)
	{
		return;
	}

	for (TSubclassOf<UGDGameplayAbility>& StartupAbility : CharacterAbilities)
	{
		AbilitySystemComponent->GiveAbility(
			FGameplayAbilitySpec(StartupAbility, GetAbilityLevel(StartupAbility.GetDefaultObject()->AbilityID), static_cast<int32>(StartupAbility.GetDefaultObject()->AbilityInputID), this));
	}

	AbilitySystemComponent->bCharacterAbilitiesGiven = true;
}

위와 같이 GA들을 부여할 때 GA의 subClass, GA의 레벨, Binding된 Input값과, (SourceObject 또는 ASC에서 생성한 GA)를 가지고 GASpec 인스턴스를 만들게 된다.

GA 활성화하기

GA는 GA에 할당한 Input Action이 발생된다면, GameplayTag 여부에 대한 조건만 충족하면 자동으로 활성화된다. 그렇다고 Input Action만이 GA를 활성화 시키는 방법이라고는 할 수 없는데 ASC에서는 이 GA를 활성화시키는 총 4가지 방법이 존재한다.

GameplayTag, GA class, GASpec handle 그리고 Event 형식으로 Event 형식에서 GA를 활성화하면 Event 호출과 함께 data 정보도 같이 payload로 넘기는 것이 가능해진다.

UFUNCTION(BlueprintCallable, Category = "Abilities")
bool TryActivateAbilitiesByTag(const FGameplayTagContainer& GameplayTagContainer, bool bAllowRemoteActivation = true);

UFUNCTION(BlueprintCallable, Category = "Abilities")
bool TryActivateAbilityByClass(TSubclassOf<UGameplayAbility> InAbilityToActivate, bool bAllowRemoteActivation = true);

bool TryActivateAbility(FGameplayAbilitySpecHandle AbilityToActivate, bool bAllowRemoteActivation = true);

// 밑의 2개의 함수가 Event 형식이라고 생각하면 된다.
bool TriggerAbilityFromGameplayEvent(
  FGameplayAbilitySpecHandle AbilityToTrigger,
  FGameplayAbilityActorInfo* ActorInfo, FGameplayTag Tag, const FGameplayEventData* Payload,
  UAbilitySystemComponent& Component
);

FGameplayAbilitySpecHandle GiveAbilityAndActivateOnce(
  const FGameplayAbilitySpec& AbilitySpec,
  const FGameplayEventData* GameplayEventData
);

Event를 통해 GA를 활성화하기 위해서는 GA에 Event 발생을 위한 트리거를 설정해야하는데 이 트리거에는 반드시 GameplayTag를 할당해야한다. Event 전달을 위해서 UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(AActor* Actor, FGameplayTag EventTag, FGameplayEventData Payload) 라는 함수를 사용하는데, 해당 함수를 통해 GA가 활성화될 때 들어갈 데이터와 태그, actor 정보를 전달해주게 된다.

GA에서의 Trigger 방식(Event)은 GameplayTag가 추가되거나 제거될 때 GA를 활성화할 수 있도록 해야하는 것을 기억하면 좋다.

추가 정보1: Blueprint에서 GA를 활성화 하려고 할 때
만약 Blueprint에서 GA를 활성화하려고 한다면 ActivateAbilityFromEvent 노드를 사용해야만 한다.

GA 사용 시 공통 주의사항

패시브 능력 같이 상시 활성화 되는 기능이 아닌 이상은 GA가 끝날 때 EndAbility()로 GA를 끝내는 것을 잊지 말아야 한다.

GA들의 Local 기반 Predict 환경 활성화 순서

  1. 로컬 클라이언트가 TryActivateAbility() 호출
  2. InternalTryActivateAbility() 호출
  3. CanActivateAbility() 호출하고, GameplayTag 관련 조건 충족 여부, ASC 비용(정확한 얘기는 없지만 아마 처리에 대한 잔여 ASC의 메모리로 추측됨), 쿨타임 여부, 다른 인스턴스 활성에 대한 여부 등을 검사를 진행
  4. CallServerTryActivateAbility()을 호출해 Prediction을 위한 Key값을 생성해 전달해준다.
  5. CallActivateAbility() 호출
  6. PreActivate()을 호출한다. (EpicGames에서는 해당 메소드에 대해 "boilerplate를 위한 초기화 항목"이라고 부른다.
  7. ActivateAbility()을 통해 GA를 활성화 시킨다.

서버 에서 CallServerTryActivateAbility()을 받은 이후 동작
1. ServerTryActivateAbility() 호출
2. InternalServerTryActivateAbility() 호출
3. InternalTryActivateAbility() 호출
4. CanActivateAbility()을 호출해 클라이언트와 동일하게 검사를 진행한다.
5-1. 만약 서버에서 GA의 활성화에 성공하게 된다면 ClientActivateAbilitySucceed()를 호출해 클라이언트에게 ActivationInfo 값을 업데이트 하고, OnConfirmDelegate라는 Delegate를 Broadcast하게 되는데 이 작업은 Input Confirm 동작 과는 다른 동작이다.
5-2. 만약 서버에서 GA의 활성화에 실패하게 된다면 ClientActivateAbilityFailed()을 호출해 클라이언트의 GA를 즉시 종료하고 로컬에서 Predict한 정보들을 전부 롤백처리하고 6번 부터의 모든 동작을 취소한다.
6. CallActivateAbility() 호출
7. PreActivate()을 호출한다. (EpicGames에서는 해당 메소드에 대해 "boilerplate를 위한 초기화 항목"이라고 부른다.
8. ActivateAbility()을 통해 GA를 활성화 시킨다.

패시브 능력 활성화

자동으로 활성화되고 동작하는 패시브 GA를 구현하기 위해서는 GA가 부여된 이후 AvatarActor가 설정될 때 자동으로 호출하게 되는UGameplayAbility::OnAvatarSet()TryActivateAbility()를 호출하도록 재정의해서 사용하면 된다.

GA가 부여될 때 활성화에 대한 여부를 지정하는 custom bool을 생성한 Custom GA에 추가해 관리하는 것을 권장한다. 샘플 프로젝트의 경우 방어막 패시브 능력에 해당 작업을 진행했다.

패시브 GA는 일반적으로 서버 전용 Net Execution Policy를 가진다.

void UGDGameplayAbility::OnAvatarSet(const FGameplayAbilityActorInfo * ActorInfo, const FGameplayAbilitySpec & Spec)
{
	Super::OnAvatarSet(ActorInfo, Spec);
	// 최신 예제 문서에는 변수 이름이 ActivateAbilityOnGranted고,
	// UGDGameplayAbility의 public에 별도의 Property로 저장되어 있다.
	// 참고로 UGDGameplayAbility는 기존의 GA class 대신 예제에서 사용할 공통적인 GA subclass라고 인지하면 된다.
	if (bActivateAbilityOnGranted)
	{
		ActorInfo->AbilitySystemComponent->TryActivateAbility(Spec.Handle, false);
	}
}

EpicGames에서는 해당 함수를 Passive GA를 시작하고 BeginPlay에 대해 설정하기 가장 적합한 함수라고 말하니 참고하면 좋다.

활성화 실패 Tag

GA에는 GA 활성화 실패한 이유에 대해 알려주는 기본 로직이 존재하는데 이 로직을 사용하기 위해서는 실패 사례에 해당되는 GameplayTag를 설정해줘야 한다. 해당 방식은 모든 언어에서 사용하는 Custom Throw Error와 비슷한 원리로 어떤 에러로 나타낼지 내가 직접 표현해주는 방식을 의미하기도 한다.

+GameplayTagList=(Tag="Activation.Fail.BlockedByTags",DevComment="")
+GameplayTagList=(Tag="Activation.Fail.CantAffordCost",DevComment="")
+GameplayTagList=(Tag="Activation.Fail.IsDead",DevComment="")
+GameplayTagList=(Tag="Activation.Fail.MissingTags",DevComment="")
+GameplayTagList=(Tag="Activation.Fail.Networking",DevComment="")
+GameplayTagList=(Tag="Activation.Fail.OnCooldown",DevComment="")

이렇게 설정한 이후 Config/DefaultGame.ini 파일에 해당 값도 넣어준다.

[/Script/GameplayAbilities.AbilitySystemGlobals]
ActivateFailIsDeadName=Activation.Fail.IsDead
ActivateFailCooldownName=Activation.Fail.OnCooldown
ActivateFailCostName=Activation.Fail.CantAffordCost
ActivateFailTagsBlockedName=Activation.Fail.BlockedByTags
ActivateFailTagsMissingName=Activation.Fail.MissingTags
ActivateFailNetworkingName=Activation.Fail.Networking

이렇게 설정한 이후에 GA의 활성화가 실패하게 된다면 GameplayTag가 이에 맞춰서 메세지에 태그 에러가 노출되고 showdebug AbilitySystem를 통해 GAS 디버깅 모드도 활성화 하였다면 그 화면에도 동시에 노출될 것이다.

GA의 Cancel

GA를 중간에 취소하기 위해서는 CancelAbility() 함수를 호출하면서 진행할 수 있다. 해당 함수에서는 EndAbility()함수가 호출되고 WasCancelled라는 Parameter 값을 true로 변경한다.

GA를 외부에서 취소하기 위한 기능 또한 존재하는데 ASC에서 밑에 있는 함수들을 제공해준다.

// 특정 Ability CDO를 취소함
void CancelAbility(UGameplayAbility* Ability);	

// Spec Handle에 적용된 GA를 취소함. 만약 재활성화된 능력 중 Handle을 찾을 수 없으면 아무 일도 발생하지 않는다.
void CancelAbilityHandle(const FGameplayAbilitySpecHandle& AbilityHandle);

// 지정된 태그가 있으면 모든 GA를 취소한다. Ignore 설정된 Instance는 취소 대상에서 제외된다.
void CancelAbilities(const FGameplayTagContainer* WithTags=nullptr, const FGameplayTagContainer* WithoutTags=nullptr, UGameplayAbility* Ignore=nullptr);

// 태그에 관계없이 모든 GA를 취소한다. Ignore 설정된 Instance는 취소 대상에서 제외된다.
void CancelAllAbilities(UGameplayAbility* Ignore=nullptr);

// 모든 GA를 취소하고 인스턴스에 남은 GA들도 모두 제거한다.
virtual void DestroyActiveState();

참고사항
인스턴스화되지 않은 GA들이 있는 경우 CancelAllAbilities가 정상 동작하지 않는 부분이 있는 것으로 보인단. Non-Instanced GA에 부딫쳐 더 이상의 진행을 포기하는 것으로 파악되는데, 이것으로 미루어 볼 때 CancelAbilities는 인스턴스화 되지 않은 GA를 더 잘 처리할 수 있다는 것을 알 수 있다. 실제로 샘플 프로젝트에서도 해당 방식을 사용하고 있다. (물론 상황과 경험에 따라 다를 수 있으니 참고만 하면 좋다)

활성화된 GA들 가져오기

가끔 초보자들이 많이 묻는 질문 중 하나는 "어떻게 활성화된 GA 데이터를 가져올 수 있는가?"다. 보통 이렇게 묻는 이유는 GA의 변수 수정 or 취소에 필요하기 때문에 물어보는 것이라고 생각되는데, 한번의 여러개의 GA가 활성화되는 경우도 있기 때문에 활성화 된 "하나의" GA 정보를 가져오는 개념은 존재하지 않는다. 다만 ASC 안에서 ActivatableAbilities 목록 (ASC가 소유 중인 부여된 GameplayAbilities 정보)를 검색해 Asset이나 부여된 GameplayTag와 일치하는 것을 찾아서 사용하는 것은 가능하다 라는 것을 알아야 한다.

UAbilitySystemComponent::GetActivatableAbilities()를 통해 TArray<FGameplayAbilitySpec>&을 반환 받아 사용하면 된다.

ASC에는 또한 GameplayTagContainer를 Parameter로 받아 GASpec 목록을 수동으로 반복해 특정 GA들을 반환해주는 도우미 함수GetActivatableGameplayAbilitySpecsByAllMatchingTags()도 존재하는데. 해당 함수에는 bOnlyAbilitiesThatSatisfyTagRequirements라는 GameplayTag 요구 사항을 충족하고, 바로 활성화 시키게 할 수 있는 bool 타입의 Parameter가 존재하는데, 예를 들어 무기와 맨손 두 가지 기본 공격에 대한 GA가 있을 수 있으며 무기 장착에 따라 GameplayTag 요구 사항을 설정해 태그에 맞는 GA가 활성화 되게 하는 것 또 가능하다. (더 자세한 정보는 해당 함수에 대한 EpicGames의 주석을 조금 더 상세하게 읽어보면 좋다)

이후 가져오기 위해 검색하던 GASpec을 얻은 이후에는 IsActive() 호출이 가능하다. (즉 외부에서 가져와 활성화 시키기가 굉장히 용이하다)

주석 내용에 대한 번역

원문

Gets all Activatable Gameplay Abilities that match all tags in GameplayTagContainer AND for which DoesAbilitySatisfyTagRequirements() is true. The latter requirement allows this function to find the correct ability without requiring advanced knowledge. For example, if there are two "Melee" abilities, one of which requires a weapon and one of which requires being unarmed, then those abilities can use Blocking and Required tags to determine when they can fire. Using the Satisfying Tags requirements simplifies a lot of usage cases. For example, Behavior Trees can use various decorators to test an ability fetched using this mechanism as well as the Task to execute the ability without needing to know that there even is more than one such ability.

번역본
GetActivatableGameplayAbilitySpecsByAllMatchingTags의 정확한 역할은 GameplayTagContainer에 존재하는 모든 태그와 일치하고 DoesAbilitySatisfyTagRequirements() 함수에 대한 결과 값이 true 인 것을 가져온다. 후자(마지막 parameter인 bOnlyAbilitiesThatSatisfyTagRequirements를 말함)의 값에 대해서는 고급 지식 없이도 정확한 GA를 찾는데 기여하게 된다.

예를 들어 "근접 공격" 능력이 2개가 있다고 가정할 때 하나는 무기가 필요하고 또 하나는 무기가 필요하지 않다라고 가정한다면, 각각의 GA를 실행시키는 시기를 결정하기 위해 각각 요구사항 Tag와 차단에 필요한 Tag를 사용할 수 있다. 굳이 어디선가 조건을 끌고 오기 보다도 태그 요구 사항을 사용하게 된다면 많은 조건들에 대한 간소화가 가능하다. 해당 예시에 대한 사용 사례를 들어보자면 Behaivor Trees(AI 행동 트리)에서는 해당 매커니즘을 활용해 가져온 능력을 테스트하는 다양한 Decorator와 GA를 실행하는 Task를 사용할 수 있고, 굳이 특정 GA가 여러개 존재한다는 것을 알 필요가 없다. (Tag만 있는지만 알면 되니까)

결론
GameplayTag를 적극 활용하자

인스턴스화 정책

GA의 인스턴스화 정책은 GA가 어떻게 인스턴스화 되고, 활성화 되는지를 결정할 수 있는 정책이다.

  • Activation = 활성화 된 GA들을 의미이나 오역의 가능성(Activation이라고만 적혀있어 해당 단어가 활성화된 GA가 맞는지에 대한 의문이 조금 있음)이 있어 해당 번역을 Activation 원문으로 대체
정책 방법설명사용 사례 및 장단점
Instanced Per Actor (액터 별 인스턴스화)각 ASC는 Activation 사이에서 단 하나의 GA Instance만 보유하고 있다.가장 많이 사용하는 인스턴스화 정책으로 어떤 GA에도 사용 가능하며 활성화된 Activation 간의 지속성 또한 제공해준다. 개발자는 필요한 경우 Activation 간에 변수를 수동으로 재설정 해줘야 할 필요가 있다.
Instanced Per Execution (실행 별 인스턴스화)매번 GA가 활성화 될 때 마다 GA의 새로운 인스턴스가 활성화된다.해당 방식의 장점은 매번 활성화될 때 마다 변수가 초기화된다는 것인데, 활성화할 때 마다 새로운 GA를 생성하기 때문에 IPA(Instance Per Actor)보다는 성능이 떨어진다. 샘플 프로젝트에서는 해당 유형을 사용하지 않는다.
Non-Instanced (인스턴스화 하지 않음)GA를 CDO에서 작동시키면서 Instance를 별도로 만들지 않는다.정책 중 가장 좋은 성능을 보유하고 있으나 제한이 많이 걸리게 된다. 비 인스턴스화된 GA는 상태를 저장할 수 없기에 동적 변수나 AbilityTask Delegate에 Binding 하는 것이 불가능하다 라는 것을 의미한다. 해당 방식은 MOBA, RTS에서 미니언의 기본 공격 같은 동적 변수나 Delegate Binding이 필요없는 간단한 스킬에 사용되며, 샘플 프로젝트에서는 점프 GA에서 해당 방식을 채용해 사용하고 있다.

결론

정책 방법성능유연성
Instanced Per Actor21
Instanced Per Execution31
Non-Instanced12

정도로 생각하고 상황에 따라 유연하게 채택하면 될 것 같다.

Net Execution 정책

GA의 Net Execution 정책은 GA가 누가 GA를 실행하고 어떤 순서대로 실행할 지를 결정해준다.

정책 방법설명
Local Only (클라이언트에서만)GA는 본인 클라이언트에서만 실행된다. 로컬에서 진행하는 Cosmetic (캐릭터 생성 시 커스터마이징 or 변경 그리고 캐시샵에서 아바타 장착 같은 케이스 같은 나 혼자만 보면 되는 정보) 능력에 유용하게 사용 가능하다. 다만 싱글플레이 게임의 경우는 Server Only 정책을 사용해야만 한다.
Local Predicted (로컬 예측 기능)본인 클라이언트에서 먼저 GA를 활성화하고 그 다음에 서버에서 활성화 되는 방식으로 서버에서 활성화 될 때는 클라이언트가 잘못 예측한 부분을 수정해 결과를 내준다.
Server Only (서버에서만)GA를 서버에서만 실행시키는 것으로 Passive GA는 일반적으로 서버 전용으로 사용된다. 싱글 플레이어 게임은 무조건 이것을 사용해야 한다.
Server Initialed (서버에서 초기화)서버에서 초기화하는 경우 GA가 서버에서 처음 활성화된 다음 본인 클라이언트에서 활성화되는 방식인데, 일반적으로는 많이 사용하지 않는 방식

왜 싱글플레이인데 Server Only를 사용해야 하는가

혹시 헷갈릴까봐 번역과는 별개로 작성하는 내용이다. 언리얼엔진 빌드 이후 사용할 때 리슨 서버를 기반으로 하는 플레이가 아니더라도 나 자신만 하는 게임이라면 나 자신이 메인 클라이언트지만 나 자신이 메인 서버가 된다. 멀티플레이 게임을 만들어본 사람이라면 Authority에 대해서 알고 있을 텐데, 메인 클라이언트에서 해당 권한에 대해 검색해보면 가장 메인 클라이언트는 서버 권한을 가지고 있음을 확인할 수 있기 때문에 Server Only로 진행하는 것이 이론상 맞다고 할 수 있다.

(지적 환영)

Ability Tags

GA에서는 별도의 로직이 내장되어 있는 GameplayTagContainers객체를 가지고 있다. 이러한 경우는 Gameplay Tag들이 Replicated 되지 않는다.

GameplayTag Container 종류설명
Ability TagGA가 소유한 GameplayTags로 단순히 GA에 대한 설명을 위한 Tag라고 할 수 있다
Cancel Abilities with TagGA가 활성화 된다면 Ability Tags에 Cancel Abilities with Tag에 속한 태그들이 있는 다른 GA들도 동일하게 취소된다.
Block Abilities with Tag다른 GA가 이 Ability Tag가 활성화 되어 있는 동안 동일한 Abilitiy Tag를 가지고 있는 다른 GA의 활성화가 차단된다.
Activation Owned Tags이 GameplayTags 들은 부여한 GA가 활성화되어 있는 동안 GA 소유자에게 해당 태그를 부여한다. 이 태그들은 Replicated 되지 않는다.
Activation Required TagsGA를 활성화 하기 위해서는 GA 소유자가 이 컨테이너에 있는 모든 GameplayTags를 가지고 있어야한다.
Activation Blocked TagsGA의 소유자가 이 컨테이너의 GameplayTag중 하나라도 보유하고 있으면 GA가 활성화되지 않는다.
Source Required TagsGA는 Source 가 이 컨테이너의 모든 태그를 부여하고 있어야만 활성화 된다. Source GameplayTags의 경우 GA 이벤트에 의해 트리거 될 때만 설정된다.
Source Blocked TagsGA는 Source 가 이 컨테이너의 태그 중 하나라도 보유하고 있으면 활성화 되지 않는다. Source GameplayTags의 경우 GA 이벤트에 의해 트리거 될 때만 설정된다.
Target Required TagsGA는 Target 이 이 컨테이너의 모든 태그를 부여하고 있어야만 활성화 된다. Target GameplayTags의 경우 GA 이벤트에 의해 트리거 될 때만 설정된다.
Target Blocked TagsGA는 Target 가 이 컨테이너의 태그 중 하나라도 보유하고 있으면 활성화 되지 않는다. Target GameplayTags의 경우 GA 이벤트에 의해 트리거 될 때만 설정된다.

Gameplay Ability Spec

Gameplay Ability Spec (GASpec)은 뒤에 Spec이 붙어서 이제 짐작할 수 있듯이 GA가 인스턴스화 된 객체라고 생각하면된다. GA가 부여된 후 ASC에 GASpec이 존재하게 되며, 활성화 가능한 GA를 정의하는 기능을 담당한다. GA의 클래스, 레벨, Input Binding, GA 클래스와 별도로 유지되어야 하는 런타임 상태를 주로 저장한다.

GA가 서버에서 부여되게 된다면, 서버는 GASpec을 소유한 클라이언트 들에게 Replicated해 클라이언트가 그 GA를 활성화하게 처리할 수 있다.

GASpec이 활성화되게 된다면, 인스턴스화 정책에 따라 "Non-Instanced" 옵션을 제외하고는 GASpec을 생성하게 된다.

Passing Data to Abilities

GA에 데이터를 넘기는 것에 대해 생각해보면 위에 GA 활성화를 설명할 때 "Event 호출과 함께 data 정보도 같이 payload로 넘기는 것"라고 말했던 것을 볼 수 있다. 이 카테고리가 위의 한줄 설명에 대한 부연 설명이다.

GA의 일반적인 패러다임은 (GA 활성화) -> (데이터 생성) -> (적용) -> (종료) 순서대로 이다. 다만 때때로 기존 데이터에 대해서 작업을 해야하는 경우도 존재하는데, GAS는 외부 데이터를 GA로 가져오기 위한 몇 가지 옵션을 제공한다.

방법설명
Event를 통한 GA 활성화Data payload가 포함된 Event로 활성화된 GA에 포함되어 있다. Event의 Payload로는 GA의 로컬 Predict를 위해 서버에서 클라이언트로 Replicated된다.
Event는 Optional한 Object 혹은 TargetData로 하여금 임의의 데이터를 전달할 수 있다. 다만 이 부분의 단점이라고 하면 Binding을 통한 GA 활성화는 불가능해진다라는 것이다.
Event로 GA 활성화를 하려면 GA의 트리거가 설정이 되어야 하기 때문에 Binding이 들어갈 자리가 없어진다.
GameplayTag를 할당하고 GameplayEvent를 선택해 Event를 전달하기 위해서는 "UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(AActor* Actor, FGameplayTag EventTag, FGameplayEventData Payload)"라는 함수를 사용하면 된다.
WaitGameplayEvent, AbilityTask 사용이 2개의 방법은 GA가 활성화된 이후 Payload 데이터가 포함된 이벤트를 수신하도록 설정하는데, Event payload와 전송 방법은 위의 Event GA 활성화와 동일한 방법을 사용하지만, 해당 기능은 이벤트가 GATask에 의해 Replicated가 되지는 않기 때문에 Local Only 혹은 Server Only GA에서만 사용해야 한다는 점이다. Event Payload를 Replicated하는 GATask 구현은 가능은 하지만 직접 구현해야한다.
TargetData 의 활용Custom한 TargetData 구조체를 사용해 클라이언트와 서버 간의 데이터 통신은 임의의 데이터를 전달하는 가장 좋은 방법이다.
OwnerActor, AvatarActor에 데이터 저장참조된 Owner, Avatar Actor 오브젝트에서 Replicated된 변수를 사용해 데이터 전달을 하는 것도 가능하다. 이 방법은 가장 유연하고 별도의 Event Binding이 있는 것은 아닌지라, Input Binding에도 원활하게 동작할 수 있다. 다만 Replicated된 Data를 사용하는 시점에서 완벽하게 동기화 됬다라고는 할 수 없기 때문에 오차가 생길 수 있다는 것을 주의해야한다. Replicated된 변수를 바로 GA에서 활성화하게 된다면 패킷 손실 등으로 인해 수신 측에서 실행 순서가 달라질 수 있다.

Ability Cost and Cooldown

GA는 상황에 따라 GA 사용에 대한 비용과 재사용 대기시간을 가질 수 있다. 비용(Cost)는 GA를 활성화하기 위해 ASC에서 보유한 Attribute의 값에서 Instant GE를 통해 지불하고 GA를 활성화 시키는 방법을 의미하며, 재사용 대기시간(Cooldown)은 GA가 다시 활성화하기 까지의 걸리는 시간으로 Duration GE를 이용해 구현한다.

Cost와 Cooldown이 존재하는 GA의 활성화 과정

  1. GA가 Activate()함수를 호출해 활성화 하기 전 CanActivateAbility()를 호출한다.
  2. CanActivateAbility() 함수에서 GA 소유자의 ASC에서 Cost만큼 지불할 수 있는지 ASC의 Attribute 정보를 확인, CheckCost()CheckCooldown()을 통해 쿨다운과 비용을 각각 확인
  3. GA가 Activate()를 호출한 이후 상황에 따라 CommitAbility()를 호출하게 되어, 비용과 쿨다운을 확정시키는데, 이 함수에서 CommitCost(), CommitCooldown()을 각각 호출해 비용과 쿨다운을 확정시킨다. 개발자는 비용과 쿨다운을 동시에 확정시키지 않으려면 CommitCost(), CommitCooldown()를 각각 호출하는 것이 가능하고, 비용과 쿨다운이 확정되면 이 두 함수를 다시 한번 호출해준다. 이 과정은 GA가 비용 혹은 쿨다운 설정 관련으로 실패할 수 있는 마지막 과정이다. 그리고 소유한 ASC의 Attribute는 GA가 활성화된 후 변경될 수 있고, 비용 확정 시 조건을 충족하지 못하는 경우도 존재한다. 또한 비용과 쿨다운의 확정은 Predict Key가 유효할 때 Local Predict 과정으로 진행될 수 있다.

Ability Leveling

GA의 레벨업을 하는 방법은 크게 2가지가 있다.

레벨 업 방법설명
Ungrant and Regrant at the New Level (부여 해제 이후 재부여)해당 기능은 ASC에서 새로운 레벨에 도달했을 때 기존에 부여된 GA를 일시적으로 해제시키고, 새로운 레벨에 맞춰 능력을 다시 부여하는 방법을 말함
Increase the GameplayAbilitySpec's Level (GASpec의 레벨 증가)서버에서 GASpec의 레벨을 증가시키고 변경 사항을 클라이언트에 동기화 하는 방법으로, 이 방법은 GA가 활성화된 상태여도 GA 자체를 종료하지는 않는다.

이 2개의 방법의 가장 큰 차이점은 레벨 업 시 능력을 취소할 지 여부다. 대부분의 경우는 둘다 섞어서 사용한다. 즉각적인 효과(액티브 스킬, 돌진, 공격 등)에 대해서는 해제 및 재부여 방식을 사용하고, 지속적인 효과(패시브 or 버프 스킬)의 경우는 레벨 증가를 활용할 수 있다.

Ability Sets

GASet은 Input Binding이나 캐릭터의 초기 능력 목록을 저장해 사용하는 편의성 UDataAssets라고 할 수 있다. 이 class는 GA를 부여하는 로직도 포함하고 있고, Subclass를 통해서 추가적인 로직이나 Attribute를 정의하는 것 또한 가능하다. EpicGames의 Paragon 에서는 각 영웅마다 하나의 GASet을 사용해 모든 GA를 관리했다.

다만 현재까지 확인한 바로는 해당 class가 그렇게까지 필요하다고 여겨지진 않는다. 샘플 프로젝트에서는 BaseCharacter의 Subclass 내에서 GASets의 모든 기능(GA 배열 형태로 저장)을 처리하고 있기 때문이다.

Ability Batching

전통적으로 GA의 LifeCycle(생명 주기로 해당 객체가 생성되고 제거될 때에 대한 관리)은 클라이언트에서 서버로 최소 2~3개의 RPC를 호출하게 된다.

  1. CallServerTryActivateAbility()
  2. ServerSetReplicatedTargetData() - 선택
  3. ServerEndAbility()

만약 하나의 프레임 안에서 이런 모든 작업을 Atomic하게 그룹화(코드 디자인 패턴 중 하나로 최소한의 기능들을 계속 함수 단위로 묶어 재사용하는 행위라 보면 된다.)할 수 있다면 위 2~3개의 함수를 하나의 RPC로 결합해 최적화 하는 것도 방법이다. GAS에서는 이것을 Ability Batching이라고 부른다.

해당 방식을 사용하는 일반적인 예시 중 하나는 HitScan 총기 개발에서 사용하는데, HitScan 총기는 활성화, Linetrace, 타겟 데이터 전송, GA 종료를 모두 하나의 프레임 내에서 Atomic하게 그룹화한다. 예제 프로젝트에서도 이 기술을 잘 시연하고 있으니 참고하면 좋다.

또한 반자동 무기도 Ability Batching에 최적화된 사례 중 하나라고 할 수 있는데, CallServerTryActivateAbility(), ServerSetReplicatedTargetData() (탄환의 명중 결과 데이터 Replicated), ServerEndAbility() 이 3개의 RPC를 하나로 결합한 사례이다.

완전 자동 총기(ex. 돌격 소총)의 경우 CallServerTryActivateAbility()ServerSetReplicatedTargetData() 2개의 RPC를 하나의 RPC로 결합해 Batch에 돌린다. 이후의 탄환 정보에 대해서는 ServerSetReplicatedTargetData() RPC를 사용해 계속 전달하고 사격이 멈춘다면 ServerEndAbility()를 호출(별도의 RPC 호출)하게 된다. 이 경우 첫 번째 발사한 탄환에서만 2개의 RPC를 1개로 줄일 수 있어서 최적화 효과를 많이 볼 수는 없다.

// 12번의 RPC 통신
AS-IS
1. CallServerTryActivateAbility, ServerSetReplicatedTargetData 별도로 존재하기에 2번의 통신
2. ServerSetReplicatedTargetData
3. ServerSetReplicatedTargetData
.
.
.
10. ServerSetReplicatedTargetData
11. ServerEndAbility

// 11번의 RPC 통신
TO-BE
1. (CallServerTryActivateAbility, ServerSetReplicatedTargetData) 결합 -> 한번의 네트워크 통신
2. ServerSetReplicatedTargetData
.
.
11. ServerEndAbility

이런 부분에 대한 대안으로 GA를 GameplayEvent로 활성화하고, 탄환의 TargetData를 EventPayload에 포함해 클라이언트에서 서버로 전달하는 방법도 있다. 계속되는 RPC 통신보다 Event에 대한 response만 실행하면 되서 조금 더 최적화에 도움이 될 수는 있으나 TargetData를 능력 외부에서 생성 해야하는데, 해당 Batching에서는 GA 내부에서 TargetData를 생성 된다.

ASC에서는 기본적으로 Ability Batching이 비활성화 되어있기 때문에 활성화 하기를 원한다면 bool 값을 return하는 ShouldDoServerAbilityRPCBatch()를 재정의해 강제적으로 true를 반환하게 설정하면 된다.

virtual bool ShouldDoServerAbilityRPCBatch() const override { return true; }

Ability Batching이 활성화가 되었다면, 본격적으로 GA 활성화 전, Batching 사용을 위해 FScopedServerAbilityRPCBatcher라는 구조체를 생성해줘야 한다. 이 구조체는 특별한 구조체로 어떤 GA든 현재 scope에서 Batch가 실행되게 하려고 노력하고, 만약 FScopedServerAbilityRPCBatcher의 범위를 벗어나게 된다면 이후에 활성화된 GA는 Batch가 실행되지 않게 된다.

FScopedServerAbilityRPCBatcher는 batch가 가능한 각 함수 내부에 특수한 코드가 있어 RPC 전송을 중간에 intercept(가로채다)하고, 대신 메세지를 Batch 구조체에 보관하게 된다. 그 이후 FScopedServerAbilityRPCBatcher의 범위를 벗어나게 된다면 이 Batch 구조체를 포함한 RPC가 자동으로 UAbilitySystemComponent::EndServerAbilityRPCBatch()를 통해 서버로 전송되고, 서버는 UAbilitySystemComponent::ServerAbilityRPCBatch_Internal(FServerAbilityRPCBatch& BatchInfo) Event 함수를 통해서 배치된 RPC를 수신받게 된다. BatchInfo라는 Parameter는 GA가 종료되어야 하는지, Input Action 활성화 시 정확한 Input을 사용하였는지, TargetData가 포함되었는지 등에 대한 여부 flag 정보도 포함되어 있다. batch가 올바르게 작동하는지 확인하기 위해서는 UAbilitySystemComponent::ServerAbilityRPCBatch_Internal라는 함수에 중단 시점을 설정해 확인하거나,
AbilitySystem.ServerRPCBatching.Log 1 cvar라는 특수한 Batch 로깅을 이용하는 방법이 있다.

bool UGSAbilitySystemComponent::BatchRPCTryActivateAbility(FGameplayAbilitySpecHandle InAbilityHandle, bool EndAbilityImmediately)
{
	bool AbilityActivated = false;
	if (InAbilityHandle.IsValid())
	{
		FScopedServerAbilityRPCBatcher GSAbilityRPCBatcher(this, InAbilityHandle);
		AbilityActivated = TryActivateAbility(InAbilityHandle, true);

		if (EndAbilityImmediately)
		{
			FGameplayAbilitySpec* AbilitySpec = FindAbilitySpecFromHandle(InAbilityHandle);
			if (AbilitySpec)
			{
				UGSGameplayAbility* GSAbility = Cast<UGSGameplayAbility>(AbilitySpec->GetPrimaryInstance());
				GSAbility->ExternalEndAbility();
			}
		}

		return AbilityActivated;
	}

	return AbilityActivated;
}

예제 프로젝트의 경우 반자동, 자동 사격 등 배치된 GA를 재활용해 GA 자체에서 종료 처리를 하는 것이 아닌 입력과 Batch 기반으로 진행되는 GA 발동을 관리하는 별도의 로컬 전용 GA가 관리해준다. 모든 네트워크 통신의 경우 특정한 Scope 내에서 처리되어야 하기 때문에 GA가 종료되는 시점을 조절할 수 있는 옵션을 제공해주는데 이것을 통해 반자동 처럼 즉시 종료하거나, 자동 처럼 나중에 별도의 RPC에서 호출하게 처리할지 정할 수 있다. 예제 프로젝트의 경우 해당 방식을 Blueprint로 처리했는데 로컬 전용 GA이 Batch가 진행중인 GA를 트리거 할 수 있는 별도의 노드 또한 제공해준다.

Net 보안 정책

GA의 Net 보안 정책은 GA가 네트워크 상에서 어디에서 실행되어야 하는지를 결정하는데, 클라이언트가 제한된 GA를 실행할려는 시도로부터 보호해주는 효과를 가지고 있다.

보안 정책설명
Client or Server (클라이언트 혹은 서버)별도의 보안이 필요하지 않고, 클라이언트나 서버가 GA의 실행과 종료를 자유롭게 트리거할 수 있는 정책
ServerOnlyExecution (서버만 실행 가능)클라이언트가 이 GA의 실행을 요청해도 서버에서는 무시한다. 다만 서버에게 GA의 취소 및 종료 요청은 가능하다.
ServerOnlyTermination (서버에서만 종료 가능)클라이언트가 이 GA의 종료나 취소를 요청해도 무시된다. 다만 서버에게 GA의 실행을 요청하면 승인된다.
ServerOnly (서버에서만)GA에 관련된 모든 것을 서버에서 관리하기에 클라이언트의 모든 요청을 무시한다.
profile
나 볼려고 만든 블로그 (블로그 이전: https://goldfrosch.tistory.com/)

0개의 댓글