김하연 튜터님 강의 - ‘GAS의 멀티 플레이 예측 시스템’
GAS의 멀티 플레이 예측 시스템에 대하여 알아보자
김하연 튜터님의 Notion 자료를 바탕으로 강의를 들으며
수정 및 재작성한 블로깅용 글
1. 네트워크 기초와 GAS의 접근법 📡
1-1. 전통적 네트워크 방식의 문제점
서버 권위 방식의 한계
- 너무 ‘늦게’ 클라가 반응함
- 렉에 의해 UX가 매우 나빠짐…
void AMyCharacter::Input_FireSkill()
{
// 1. 클라이언트는 아무것도 하지 않고, 즉시 서버에 요청만 보냄.
ServerRPC_TryFireSkill();
}
// ---------------------------------------------------------------
// 네트워크 레이턴시 (RTT: Round Trip Time) 발생구간
// ---------------------------------------------------------------
void AMyCharacter::ServerRPC_TryFireSkill_Implementation()
{
// 2. 서버가 도착한 요청을 검증
if (CurrentMana >= 10 && !bIsCooldown)
{
CurrentMana -= 10;
// 3. 검증 통과! 이제 모든 클라이언트(나 포함)에게 연출을 보여주라고 명령
MulticastRPC_PlaySkillEffect();
}
}
// ---------------------------------------------------------------
// 네트워크 레이턴시 발생구간
// ---------------------------------------------------------------
void AMyCharacter::MulticastRPC_PlaySkillEffect_Implementation()
{
// 4. 클라이언트는 이제서야 이펙트를 재생
SpawnEmitterAtLocation(...);
PlayAnimMontage(...);
}
왜 200ms가 문제인가?
- FPS: 0.2초 동안 적이 이미 이동
- 격투게임: 60fps 기준 12프레임 지연 (약손 3프레임)
- 체감: “내가 누른 게 안 먹혀!”
1-2. 클라이언트 예측 (Client Prediction)의 원리
핵심 아이디어: “서버 응답 기다리지 말고 일단 보여주자!”
// GAS 내부 클라이언트 예측 로직의 단순화 버전
void UMyGameplayAbility::ActivateAbility(const FGameplayAbilitySpecHandle Handle, ...)
{
// 1단계: 로컬 클라이언트인가?
if (IsLocallyControlled())
{
// [예측 실행] 서버 응답을 기다리지 않고 즉시 저지릅니다.
// A. 예측 키(Prediction Key) 발급
FPredictionKey Key = GeneratePredictionKey();
// B. 시각적 피드백 즉시 실행 (가짜 처리)
PlayMontage(AttackAnim); // 칼 휘두르기
PlaySound(SwingSound); // 소리 재생
ModifyAttribute(Mana, -50); // 내 화면의 마나만 일단 깎음 (서버값 아님)
// C. 서버로 전송 (RPC)
ServerRPC_TryActivateAbility(Key);
}
}
// ---------------------------------------------------------------
// 서버 측 처리 (Server Authority)
// ---------------------------------------------------------------
void UMyGameplayAbility::ServerRPC_TryActivateAbility_Implementation(FPredictionKey Key)
{
// 서버가 검증
if (CanActivateAbility())
{
// [승인]
// 실제 서버 데이터(마나, 쿨타임)를 갱신하고 다른 유저들에게 전파합니다.
ClientRPC_PredictionSuccess(Key);
}
else
{
// [거부]
ClientRPC_PredictionFailed(Key);
}
}
-
원래 있는 ‘네트워크 이론’의 일종이며
이를 GAS가 채택! -
클라가 ‘먼저 스킬을’ 발동시킨 것처럼 연출
서버가 검증하여 성공시, 다른 클라에 전파
실패시, 반환하기 -
Key를 통해 서버의 처리 결과를 연동받을 수 있음
-
이러한 Key에 실행한 클라의 행동을 묶어두어
실패 시, 아래의 ‘롤백’을 실행함
1-3. 롤백(Rollback) 메커니즘
예측이 틀렸을 때의 처리
// 📜 GAS 네트워크 타임라인 시뮬레이션
// 상황: 마나 100 보유. 50 소모 스킬(Fireball) 시전.
// 변수: 0.1초 전 피격되어 실제 마나는 10임 (클라이언트는 모름)
// [T+0.00s] Client (예측 시작)
// ---------------------------------------------------------
1. PredictionKey [101] 생성
2. UI 업데이트: 마나 100 -> 50 (가짜 값)
3. GameplayEffect 적용 (Predicted): "Cost_Fireball" (마나 -50)
4. 애니메이션: "Montage_CastFireball" 재생 시작
5. 서버로 전송: ServerRPC_ActivateAbility(Key: 101)
// [T+0.10s] Server (검증 및 판결)
// ---------------------------------------------------------
1. 요청 수신: Key [101] 확인
2. 검증(Validate):
- 현재 서버 마나: 10
- 필요 비용: 50
- 결과: 실패 (Not Enough Resources)
3. 처형(Punishment):
- ClientRPC_PredictionFailed(Key: 101) 전송
- "야, 너 마나 10이야." (Attribute 동기화 패킷 전송)
// [T+0.20s] Client (롤백 집행)
// ---------------------------------------------------------
1. 실패 통보 수신: Key [101]은 무효임.
2. 뒷수습 (Cleanup):
- Key [101]에 묶여있던 GameplayEffect("Cost_Fireball")를 찾아서 강제 제거.
- 마나 값 보정: 50이었던 마나가 서버 값인 10으로 '튀면서' 변경.
- 실행 중이던 어빌리티(Ability) 강제 종료 (CancelAbility).
- Clinet가 Key 생성 및 연출 처리 (UX를 위해)
- Server가 Key 확인 및 가능여부 확인후 최종 판정
- Client가 Key를 다시 확인하여 현재 관련된 작업을 전부 취소 함
- 이러한 롤백이 UX에 좋을까…?
- 일단 CharacterMovement 에서, ‘미끄러진’ 느낌으로 위치를 조정
- GAS에서 StopMontage를 하더라도, blend Time을 주어 애니메이션을 블렌드함
- Particle 등도 투명도를 서서히 낮추는 방식으로 취소
- 일단 CharacterMovement 에서, ‘미끄러진’ 느낌으로 위치를 조정
- 물론 렉 특유의 불쾌함은 있지만 여러 처리를 통해 불쾌함을 최소화 하려 함
1-4. Prediction Key - 예측의 추적 시스템
FPredictionKey의 실제 구조
// FPredictionKey 구조체 (개념적 단순화)
struct FPredictionKey
{
public:
// [1] 현재 내 예측 번호 (My Hope)
int16 Current;
// [2] 서버가 마지막으로 인정한 번호 (Server's Ack)
int16 Base;
// [3] 서버 주도 여부
bool bIsServerInitiated;
};
-
일종의 ‘영수증’
-
bIsServerInitiated 가 true이면
강력한 권한에 따르어야 함
반대로 false면 보통 요청 단계를 뜻함
// 실제 GAS 어빌리티 발동 내부 로직 (Conceptual Code)
void UGameplayAbility::ActivateAbility(const FGameplayAbilitySpecHandle Handle, ...)
{
// 1. 예측 키 생성 (ASC가 알아서 해줌)
// Current Key: 41 -> 42
FPredictionKey ActivationKey = GetPredictionKey();
// 2. [중요] 예측 윈도우(창구) 열기!
// "지금부터 이 중괄호 { } 안에서 일어나는 모든 일은 Key #42 소관이다!"
{
FScopedPredictionWindow ScopedWindow(GetAbilitySystemComponent(), ActivationKey);
// --- 이 아래 모든 함수는 자동으로 Key #42를 달고 실행됩니다 ---
// (A) 마나 감소
// 내부적으로: ApplyGameplayEffect(..., PredictionKey=42);
CommitAbility(Handle, ActorInfo, ActivationInfo);
// (B) 몽타주 재생
// 내부적으로: PlayMontage(..., PredictionKey=42);
MontageTask->ReadyForActivation();
// (C) 이벤트 전송
// 내부적으로: SendGameplayEvent(..., PredictionKey=42);
SendGameplayEvent(...);
} // <--- 윈도우 종료. Key #42의 영향력 끝.
// 3. 서버로 전송
// "Key #42로 이것들 저질렀습니다. 확인 부탁해요."
ServerRPC_TryActivateAbility(ActivationKey);
}
- ASC가 Key를 자동으로 생성함
- Scope를 활용하여 해당 key와 연동될 작업을 결정
RAII!
- Scope를 활용하여 해당 key와 연동될 작업을 결정
// 상황: 0.1초 간격으로 스킬 3개를 난사함 (콤보)
// Client State: Base(40) / Current(43)
// [Time 0.00s] 대시 (Key #41) -> 예측 실행
// [Time 0.10s] 공격 (Key #42) -> 예측 실행
// [Time 0.20s] 점프 (Key #43) -> 예측 실행
// ... 통신 지연 (Ping 100ms) ...
// [Time 0.30s] 서버의 판결 도착!
void OnServerResponse()
{
// [판결 1] "대시(#41)? 문제없어. 승인(ACK)."
// [판결 2] "공격(#42)? 너 그때 기절(Stun) 상태였잖아. 거절(NACK)!"
// [판결 3] "점프(#43)? ...어?" // Re-simulation
// Case A: 점프가 공격의 후딜레이 캔슬이었다면? -> 점프도 불가능해짐 -> 취소
// Case B: 공격과 상관없는 이동 점프였다면? -> 점프는 살아남음!
}
-
여러 Client가 보낸 다양한 Key들을 처리
-
- Re-simulation?
- 특정 시점에서 상태를 재확인
- Re-simulation?
key를 기반으로
- UX를 위한 Client 먼저 연출 출력
- key를 바탕으로 server에서 상태 처리
- 서버의 로직을 따라 roll-back
-> 동기화 코드를 처음부터 고려할 필요없어짐!
2. Ability Activation 정책 심화 🎮
- GAS 동기화 문제가 나오기 매우 쉬운 부분!
2-1. NetExecutionPolicy 완벽 이해
enum class EGameplayAbilityNetExecutionPolicy : uint8
{
LocalOnly, // 로컬 전용
LocalPredicted, // 클라이언트 예측
ServerOnly, // 서버만 실행
ServerInitiated // 서버가 시작
};
각 정책의 사용처
| 정책 | 용도 | 예시 |
|---|---|---|
| LocalOnly | 네트워크 무관 | UI 조작, 카메라 이동 |
| LocalPredicted | 즉각 반응 필요 | 기본 공격, 이동 스킬 |
| ServerOnly | 보안 중요 | 아이템 구매, 레벨업 |
| ServerInitiated | 서버 이벤트 | 보스 패턴, 환경 피해 |
-
UI나 카메라는 애초에 Server에서 생성이 안됨
(아예 통신 시도를 안함) -
이펙트 출력이 필요하다면, 먼저 Local 처리가 필요함
(클라에서 보여야 하며, UX가 중요함) -
보안이 중요한 로직은 무조건 서버에서!
-
모든 플레이어에게 발생시킬 이벤트는 서버가 직접 발생시켜야 함
1. LocalOnly와 LocalPredicted
// 1. 인벤토리 열기 (LocalOnly)
UInventoryAbility::UInventoryAbility()
{
// 정책 설정: 나만 실행함
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalOnly;
}
void UInventoryAbility::ActivateAbility(...)
{
// 서버 체크 불필요
// RPC를 보내지 않으므로 즉각 실행됨. 핑(Ping) 0ms.
if (IsLocallyControlled())
{
ShowInventoryUI();
}
}
// -----------------------------------------------------------
// 2. 대시 스킬 (LocalPredicted)
UDashAbility::UDashAbility()
{
// 정책 설정: 예측 실행
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
}
void UDashAbility::ActivateAbility(...)
{
// 클라이언트의 선 실행 (Prediction)
if (IsLocallyControlled())
{
// 1. 일단 이동시킴 (예측)
PerformDashMovement();
// 2. 쿨타임 UI 돌림 (가짜)
CommitAbilityCost(Handle, ...);
}
// 서버의 검증 (Authority)
// (이 코드는 서버에서 0.1초 뒤에 실행됨)
if (HasAuthority())
{
if (!CanActivateAbility(...))
{
// 롤백 명령 전송
CancelAbility(Handle, ...);
}
}
}
-
인벤토리 UI 여는 것은 사실상 그 Client만 알면 됨
-
대쉬 같은 기능은 Server에 보내주긴 해야 함
(그래도 UX를 위해 클라에서 먼저 연출)
(반응속도가 중요함)
2. ServerOnly와 ServerInitiated: 권위의 영역
UPurchaseAbility::UPurchaseAbility()
{
// 정책 설정: 서버 전용
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::ServerOnly;
}
void UPurchaseAbility::ActivateAbility(...)
{
// 클라이언트가 이 함수를 실행하려고 하면?
if (!HasAuthority())
{
// 아무 일도 안 일어나거나, 로딩 UI만 띄우고 종료.
// 실제 로직은 절대 실행 불가.
ShowLoadingSpinner();
return;
}
// 서버 로직 (여기만 진짜)
if (PlayerMoney >= Cost)
{
PlayerMoney -= Cost;
GrantItem();
// 결과만 클라이언트에게 통보 (RPC)
ClientRPC_PurchaseSuccess();
}
}
-
서버에서 진행하는 것이 좋은 경우에 대한 처리
(경제 관련된 매우 중요한 로직) -
시스템 & 보스 & 기믹 & 컷씬 등은 서버가 재생을 해주는 것이 좋음
2-2. LocalPredicted 심화
실제 구현 패턴
void UMyMeleeAbility::ActivateAbility(...)
{
// 예측 윈도우 열기
FScopedPredictionWindow PredictionWindow(ASC, true);
// 즉시 실행
PlayMontage(AttackMontage);
ExecuteGameplayCue("GameplayCue.Swing");
PlaySound(SwingSound);
// CommitAbility가 비용과 쿨다운 처리
if (!CommitAbility(...))
{
EndAbility(...);
return;
}
}
FScopedPredictionWindow 사용법
FScopedPredictionWindow(ASC, true)- 새 키 생성FScopedPredictionWindow(ASC, ExistingKey)- 기존 키 사용
LocalPredicted 사용 시
키 발급 후, 여러 스탯, 자원 처리 등을 처리하면 됨!
- 어차피 실패시, 서버가 취소해버리므로
자동 복구
void MagicShow()
{
// 1. 상자를 연다 (Scope 시작)
{
FScopedPredictionWindow Box(ASC, true); // <--- Key: #101 발급
DoMagic1(); // Key #101 태그 부착
DoMagic2(); // Key #101 태그 부착
} // <--- 2. 상자를 닫는다 (Scope 끝)
// 자동으로 서버 전송: "Key #101로 Magic1, Magic2 했습니다. 확인 바람."
}
2-3. 서버 권위와 클라이언트 자유의 균형
// 경쟁 게임 (서버 권위 90%)
UMyCompetitiveAbility::UMyCompetitiveAbility()
{
NetExecutionPolicy = ServerOnly;
bServerRespectsRemoteAbilityCancellation = false;
bReplicateInputDirectly = false;
}
// 캐주얼 게임 (클라이언트 자유 70%)
UMyCasualAbility::UMyCasualAbility()
{
NetExecutionPolicy = LocalPredicted;
bServerRespectsRemoteAbilityCancellation = true;
bReplicateInputDirectly = true;
}
// 배틀로얄 (하이브리드)
void UBattleRoyaleAbility::ActivateAbility(...)
{
float DistanceToEnemy = GetDistanceToNearestEnemy();
if (DistanceToEnemy < 1000.0f) // 10미터 이내
{
SetNetExecutionPolicy(ServerOnly); // 근접전: 서버 권위
}
else
{
SetNetExecutionPolicy(LocalPredicted); // 원거리: 예측
}
}
- ESport 게임에서 가장 중요한건 ‘공정성’
- 정말 중요한 판정이라면 서버 권한으로 처리
- 정말 중요한 판정이라면 서버 권한으로 처리
-
하드코어한 경쟁, 스포츠, FPS 등이라면
서버에 권위를 주는 것이 더 공정함
(내가 했는데 왜 안되는거야?) -
캐주얼은 반대로 LocalPredict를 이용하여
UX를 극대화
(사소한 렉은 가볍게 넘기지만, 게임 자체가 느리면 불쾌) - 상황에 따라 양측을 번갈아가면서 써야하는 경우 또한 있음
- 근처에 아무도 없다면 LocalPredicted
- 전투 상황에 따라 서버에 권위를 주어 공정성 판별
- 근처에 아무도 없다면 LocalPredicted
댓글남기기