네트워크 보안

서버 권한 설계, 입력 검증, 치트 방지 - 안전한 멀티플레이어 환경 구축

학습 목표

1. 서버 권한 설계 원칙

1.1 절대 신뢰하지 않는 클라이언트

네트워크 보안의 첫 번째 원칙: 클라이언트로부터 오는 모든 데이터는 조작될 수 있습니다.

┌─────────────────────────────────────────────────────────────────┐
│                     보안 아키텍처 원칙                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  [클라이언트]                    [서버]                          │
│       │                           │                              │
│       │  "포션 사용 요청"  ──────►│  1. 포션 소유 확인            │
│       │                           │  2. 쿨다운 확인              │
│       │                           │  3. 효과 적용 (서버 권한)      │
│       │  ◄────── 결과 복제 ───────│  4. 클라이언트에 결과 전송     │
│                                                                  │
│  ========== 잘못된 설계 (절대 금지) ==========                    │
│                                                                  │
│  [클라이언트]                    [서버]                          │
│       │                           │                              │
│       │  "체력을 100으로"  ───────►│  그대로 적용 ← 위험!          │
│       │                           │                              │
└─────────────────────────────────────────────────────────────────┘

1.2 안전한 RPC 설계

// SecurePlayerController.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "SecurePlayerController.generated.h"

UCLASS()
class MYGAME_API ASecurePlayerController : public APlayerController
{
    GENERATED_BODY()

public:
    // ========================================
    // ===== 잘못된 설계 (절대 하지 마세요) =====
    // ========================================

    // 클라이언트가 자신의 체력을 직접 설정 - 치터가 악용!
    // UFUNCTION(Server, Reliable)
    // void Server_SetMyHealth(float NewHealth);  // 위험!

    // 클라이언트가 자신의 위치를 직접 설정 - 텔레포트 치트!
    // UFUNCTION(Server, Reliable)
    // void Server_SetMyPosition(FVector NewPosition);  // 위험!

    // 클라이언트가 데미지 양을 직접 전달 - 원킬 치트!
    // UFUNCTION(Server, Reliable)
    // void Server_DealDamage(AActor* Target, float Damage);  // 위험!

    // ========================================
    // ===== 올바른 설계 (서버 권한) =====
    // ========================================

    // 클라이언트는 "행동"만 요청, 서버가 결과 결정
    UFUNCTION(Server, Reliable, WithValidation)
    void Server_RequestUseItem(int32 ItemSlotIndex);

    // 공격 요청 - 서버가 히트 판정 및 데미지 계산
    UFUNCTION(Server, Reliable, WithValidation)
    void Server_RequestAttack(const FVector& AimLocation, float ClientTimestamp);

    // 상호작용 요청 - 서버가 거리 및 유효성 검증
    UFUNCTION(Server, Reliable, WithValidation)
    void Server_RequestInteraction(AActor* TargetActor);

    // 스킬 사용 요청 - 서버가 마나/쿨다운 확인
    UFUNCTION(Server, Reliable, WithValidation)
    void Server_RequestUseSkill(int32 SkillId, const FVector& TargetLocation);

protected:
    // 마지막 요청 시간 (스팸 방지)
    float LastItemUseTime;
    float LastAttackTime;

    static constexpr float MIN_ITEM_USE_INTERVAL = 0.5f;
    static constexpr float MIN_ATTACK_INTERVAL = 0.1f;
};

1.3 Validation 함수 구현

// SecurePlayerController.cpp
#include "SecurePlayerController.h"
#include "MyCharacter.h"
#include "InventoryComponent.h"

// ===== 아이템 사용 검증 =====

bool ASecurePlayerController::Server_RequestUseItem_Validate(int32 ItemSlotIndex)
{
    // 1. 슬롯 인덱스 범위 검증
    if (ItemSlotIndex < 0 || ItemSlotIndex >= MAX_INVENTORY_SLOTS)
    {
        UE_LOG(LogSecurity, Warning,
               TEXT("[%s] Invalid item slot: %d (max: %d)"),
               *GetNameSafe(this), ItemSlotIndex, MAX_INVENTORY_SLOTS);
        return false;  // 비정상 요청 -> 연결 해제 가능
    }

    // 2. 요청 빈도 검증 (스팸 방지)
    float CurrentTime = GetWorld()->GetTimeSeconds();
    if (CurrentTime - LastItemUseTime < MIN_ITEM_USE_INTERVAL * 0.5f)
    {
        // 너무 빠른 요청 - 약간의 여유 허용 (네트워크 지연)
        UE_LOG(LogSecurity, Warning,
               TEXT("[%s] Item use spam detected"),
               *GetNameSafe(this));
        return false;
    }

    return true;
}

void ASecurePlayerController::Server_RequestUseItem_Implementation(int32 ItemSlotIndex)
{
    // Implementation에서 추가 검증 (연결 해제 없이)
    AMyCharacter* MyChar = Cast(GetPawn());
    if (!MyChar)
    {
        return;
    }

    UInventoryComponent* Inventory = MyChar->GetInventory();
    if (!Inventory)
    {
        return;
    }

    // 3. 아이템 존재 여부 확인
    FInventoryItem* Item = Inventory->GetItemAt(ItemSlotIndex);
    if (!Item || !Item->IsValid())
    {
        // 클라이언트가 거짓 요청 - 무시 (연결 해제는 과함)
        UE_LOG(LogSecurity, Log,
               TEXT("[%s] No valid item at slot %d"),
               *GetNameSafe(this), ItemSlotIndex);
        return;
    }

    // 4. 아이템 사용 가능 여부 (쿨다운 등)
    if (!Inventory->CanUseItem(ItemSlotIndex))
    {
        return;
    }

    // 5. 아이템 효과 적용 (서버 권한)
    LastItemUseTime = GetWorld()->GetTimeSeconds();
    Inventory->UseItem(ItemSlotIndex);

    UE_LOG(LogSecurity, Log,
           TEXT("[%s] Used item at slot %d"),
           *GetNameSafe(this), ItemSlotIndex);
}

// ===== 공격 요청 검증 =====

bool ASecurePlayerController::Server_RequestAttack_Validate(
    const FVector& AimLocation, float ClientTimestamp)
{
    // 1. 타임스탬프 유효성 (미래 시간 불가)
    float ServerTime = GetWorld()->GetGameState()->GetServerWorldTimeSeconds();
    if (ClientTimestamp > ServerTime + 0.5f)  // 0.5초 여유
    {
        UE_LOG(LogSecurity, Warning,
               TEXT("[%s] Invalid timestamp: %f > %f"),
               *GetNameSafe(this), ClientTimestamp, ServerTime);
        return false;
    }

    // 2. AimLocation 유효성 (맵 범위 내)
    if (AimLocation.ContainsNaN() ||
        AimLocation.Size() > 1000000.0f)  // 비정상적으로 먼 위치
    {
        UE_LOG(LogSecurity, Warning,
               TEXT("[%s] Invalid aim location"),
               *GetNameSafe(this));
        return false;
    }

    return true;
}

void ASecurePlayerController::Server_RequestAttack_Implementation(
    const FVector& AimLocation, float ClientTimestamp)
{
    AMyCharacter* MyChar = Cast(GetPawn());
    if (!MyChar) return;

    // 공격 빈도 검증
    float CurrentTime = GetWorld()->GetTimeSeconds();
    float TimeSinceLastAttack = CurrentTime - LastAttackTime;

    float MinAttackInterval = MyChar->GetMinAttackInterval();
    if (TimeSinceLastAttack < MinAttackInterval * 0.9f)  // 10% 오차 허용
    {
        UE_LOG(LogSecurity, Log,
               TEXT("[%s] Attack too fast: %f < %f"),
               *GetNameSafe(this), TimeSinceLastAttack, MinAttackInterval);
        return;
    }

    // 서버에서 공격 수행
    LastAttackTime = CurrentTime;
    MyChar->PerformAttack(AimLocation, ClientTimestamp);
}

// ===== 상호작용 요청 검증 =====

bool ASecurePlayerController::Server_RequestInteraction_Validate(AActor* TargetActor)
{
    // 1. 대상 유효성
    if (!IsValid(TargetActor))
    {
        return false;
    }

    // 2. 상호작용 가능한 액터인지
    if (!TargetActor->Implements())
    {
        UE_LOG(LogSecurity, Warning,
               TEXT("[%s] Target is not interactable: %s"),
               *GetNameSafe(this), *GetNameSafe(TargetActor));
        return false;
    }

    return true;
}

void ASecurePlayerController::Server_RequestInteraction_Implementation(AActor* TargetActor)
{
    APawn* MyPawn = GetPawn();
    if (!MyPawn) return;

    // 거리 검증
    float Distance = FVector::Dist(MyPawn->GetActorLocation(),
                                    TargetActor->GetActorLocation());

    const float MaxInteractionDistance = 500.0f;  // 5m
    if (Distance > MaxInteractionDistance)
    {
        UE_LOG(LogSecurity, Log,
               TEXT("[%s] Too far to interact: %f > %f"),
               *GetNameSafe(this), Distance, MaxInteractionDistance);
        return;
    }

    // 시야 검증 (선택사항)
    FHitResult Hit;
    FCollisionQueryParams Params;
    Params.AddIgnoredActor(MyPawn);

    bool bHasLineOfSight = !GetWorld()->LineTraceSingleByChannel(
        Hit,
        MyPawn->GetActorLocation() + FVector(0, 0, 50),
        TargetActor->GetActorLocation(),
        ECC_Visibility,
        Params
    ) || Hit.GetActor() == TargetActor;

    if (!bHasLineOfSight)
    {
        UE_LOG(LogSecurity, Log,
               TEXT("[%s] No line of sight to %s"),
               *GetNameSafe(this), *GetNameSafe(TargetActor));
        return;
    }

    // 상호작용 수행
    IInteractable::Execute_OnInteract(TargetActor, this);
}

2. 입력 검증 시스템

2.1 입력 검증 유틸리티

// InputValidator.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"

/**
 * 클라이언트 입력 검증 유틸리티
 * 모든 검증은 서버에서 수행
 */
class MYGAME_API FInputValidator
{
public:
    // ===== 이동 검증 =====

    /**
     * 이동 속도 검증
     * @param Character - 검증할 캐릭터
     * @param ReportedVelocity - 클라이언트가 보고한 속도
     * @return 유효한 속도인지 여부
     */
    static bool ValidateMovementSpeed(const ACharacter* Character,
                                       const FVector& ReportedVelocity)
    {
        if (!Character) return false;

        UCharacterMovementComponent* Movement = Character->GetCharacterMovement();
        if (!Movement) return false;

        float MaxSpeed = Movement->GetMaxSpeed();
        float ReportedSpeed = ReportedVelocity.Size();

        // 네트워크 지연 및 가속/감속 고려 - 20% 오차 허용
        const float Tolerance = 1.2f;

        if (ReportedSpeed > MaxSpeed * Tolerance)
        {
            UE_LOG(LogSecurity, Warning,
                   TEXT("Suspicious speed: %f (max: %f) for %s"),
                   ReportedSpeed, MaxSpeed, *GetNameSafe(Character));
            return false;
        }

        return true;
    }

    /**
     * 위치 변화량 검증 (텔레포트 감지)
     * @param OldPos - 이전 위치
     * @param NewPos - 새 위치
     * @param DeltaTime - 경과 시간
     * @param MaxSpeed - 최대 이동 속도
     * @return 유효한 이동인지 여부
     */
    static bool ValidatePositionDelta(const FVector& OldPos,
                                       const FVector& NewPos,
                                       float DeltaTime,
                                       float MaxSpeed)
    {
        float Distance = FVector::Dist(OldPos, NewPos);

        // 최대 이동 가능 거리 (여유 있게)
        float MaxPossibleDistance = MaxSpeed * DeltaTime * 1.5f;

        // 최소 임계값 (짧은 텔레포트는 무시)
        const float MinThreshold = 100.0f;

        if (Distance > FMath::Max(MaxPossibleDistance, MinThreshold))
        {
            UE_LOG(LogSecurity, Warning,
                   TEXT("Suspicious teleport: %f units in %f seconds"),
                   Distance, DeltaTime);
            return false;
        }

        return true;
    }

    // ===== 전투 검증 =====

    /**
     * 발사 빈도 검증
     * @param LastFireTime - 마지막 발사 시간
     * @param CurrentTime - 현재 시간
     * @param MinFireInterval - 최소 발사 간격
     * @return 유효한 발사인지 여부
     */
    static bool ValidateFireRate(float LastFireTime,
                                  float CurrentTime,
                                  float MinFireInterval)
    {
        float TimeSinceLastFire = CurrentTime - LastFireTime;

        // 10% 오차 허용 (네트워크 지연)
        const float Tolerance = 0.9f;

        if (TimeSinceLastFire < MinFireInterval * Tolerance)
        {
            UE_LOG(LogSecurity, Warning,
                   TEXT("Suspicious fire rate: %f (min interval: %f)"),
                   TimeSinceLastFire, MinFireInterval);
            return false;
        }

        return true;
    }

    /**
     * 탄약 소비 검증
     * @param CurrentAmmo - 현재 탄약
     * @param RequiredAmmo - 필요 탄약
     * @return 탄약이 충분한지 여부
     */
    static bool ValidateAmmoConsumption(int32 CurrentAmmo, int32 RequiredAmmo)
    {
        return CurrentAmmo >= RequiredAmmo;
    }

    /**
     * 공격 사거리 검증
     * @param AttackerLocation - 공격자 위치
     * @param TargetLocation - 대상 위치
     * @param MaxRange - 최대 사거리
     * @return 사거리 내인지 여부
     */
    static bool ValidateAttackRange(const FVector& AttackerLocation,
                                     const FVector& TargetLocation,
                                     float MaxRange)
    {
        float Distance = FVector::Dist(AttackerLocation, TargetLocation);

        // 약간의 오차 허용
        const float Tolerance = 1.1f;

        if (Distance > MaxRange * Tolerance)
        {
            UE_LOG(LogSecurity, Warning,
                   TEXT("Attack out of range: %f > %f"),
                   Distance, MaxRange);
            return false;
        }

        return true;
    }

    // ===== 자원 검증 =====

    /**
     * 인벤토리 조작 검증
     * @param Inventory - 인벤토리 컴포넌트
     * @param ItemId - 아이템 ID
     * @param RequiredCount - 필요 수량
     * @return 아이템이 충분한지 여부
     */
    static bool ValidateItemCount(UInventoryComponent* Inventory,
                                   int32 ItemId,
                                   int32 RequiredCount)
    {
        if (!Inventory) return false;

        int32 ActualCount = Inventory->GetItemCount(ItemId);
        return ActualCount >= RequiredCount;
    }

    /**
     * 통화 검증
     * @param CurrentGold - 현재 골드
     * @param RequiredGold - 필요 골드
     * @return 골드가 충분한지 여부
     */
    static bool ValidateCurrency(int64 CurrentGold, int64 RequiredGold)
    {
        // 음수 검증
        if (RequiredGold < 0)
        {
            UE_LOG(LogSecurity, Warning,
                   TEXT("Negative gold requirement: %lld"), RequiredGold);
            return false;
        }

        // 오버플로우 방지
        if (RequiredGold > MAX_int64 - 1000000)
        {
            return false;
        }

        return CurrentGold >= RequiredGold;
    }

    // ===== 타이밍 검증 =====

    /**
     * 타임스탬프 유효성 검증
     * @param ClientTimestamp - 클라이언트 타임스탬프
     * @param ServerTimestamp - 서버 타임스탬프
     * @param MaxLatency - 허용 최대 레이턴시 (기본 1초)
     * @return 유효한 타임스탬프인지 여부
     */
    static bool ValidateTimestamp(float ClientTimestamp,
                                   float ServerTimestamp,
                                   float MaxLatency = 1.0f)
    {
        // 미래 시간 불가
        if (ClientTimestamp > ServerTimestamp + 0.1f)
        {
            UE_LOG(LogSecurity, Warning,
                   TEXT("Future timestamp: %f > %f"),
                   ClientTimestamp, ServerTimestamp);
            return false;
        }

        // 너무 오래된 시간 불가
        if (ClientTimestamp < ServerTimestamp - MaxLatency)
        {
            UE_LOG(LogSecurity, Warning,
                   TEXT("Stale timestamp: %f < %f - %f"),
                   ClientTimestamp, ServerTimestamp, MaxLatency);
            return false;
        }

        return true;
    }
};

2.2 보안 캐릭터 기본 클래스

// SecureCharacter.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "SecureCharacter.generated.h"

/**
 * 보안이 강화된 캐릭터 기본 클래스
 * 서버 권한 원칙을 준수
 */
UCLASS()
class MYGAME_API ASecureCharacter : public ACharacter
{
    GENERATED_BODY()

public:
    ASecureCharacter();

    // ===== 체력 시스템 (서버 권한) =====

    // 현재 체력 (읽기 전용)
    UFUNCTION(BlueprintPure, Category = "Health")
    float GetCurrentHealth() const { return Health; }

    UFUNCTION(BlueprintPure, Category = "Health")
    float GetMaxHealth() const { return MaxHealth; }

    UFUNCTION(BlueprintPure, Category = "Health")
    bool IsAlive() const { return Health > 0.0f; }

    // 데미지 적용 (서버만 호출 가능)
    UFUNCTION(BlueprintCallable, Category = "Health")
    void ApplyDamage(float DamageAmount, AController* InstigatorController,
                     AActor* DamageCauser, const FHitResult& HitResult);

    // 힐 적용 (서버만 호출 가능)
    UFUNCTION(BlueprintCallable, Category = "Health")
    void ApplyHeal(float HealAmount, AActor* Healer);

protected:
    // 체력 (서버에서만 직접 수정)
    UPROPERTY(ReplicatedUsing = OnRep_Health, BlueprintReadOnly, Category = "Health")
    float Health;

    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Health")
    float MaxHealth;

    UFUNCTION()
    void OnRep_Health(float OldHealth);

    // 마지막 검증된 위치 (서버용)
    FVector LastValidatedLocation;
    float LastValidationTime;

    // 위치 검증
    virtual void ServerValidatePosition();

    virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override;
    virtual void Tick(float DeltaTime) override;
};

// SecureCharacter.cpp
#include "SecureCharacter.h"
#include "InputValidator.h"
#include "Net/UnrealNetwork.h"
#include "GameFramework/CharacterMovementComponent.h"

ASecureCharacter::ASecureCharacter()
{
    MaxHealth = 100.0f;
    Health = MaxHealth;

    PrimaryActorTick.bCanEverTick = true;
}

void ASecureCharacter::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ASecureCharacter, Health);
}

void ASecureCharacter::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    // 서버에서만 위치 검증
    if (HasAuthority())
    {
        ServerValidatePosition();
    }
}

void ASecureCharacter::ServerValidatePosition()
{
    float CurrentTime = GetWorld()->GetTimeSeconds();
    float DeltaTime = CurrentTime - LastValidationTime;

    // 0.5초마다 검증
    if (DeltaTime < 0.5f)
    {
        return;
    }

    FVector CurrentLocation = GetActorLocation();
    float MaxSpeed = GetCharacterMovement()->GetMaxSpeed();

    // 위치 변화 검증
    if (LastValidationTime > 0.0f)  // 첫 검증 제외
    {
        if (!FInputValidator::ValidatePositionDelta(
            LastValidatedLocation, CurrentLocation, DeltaTime, MaxSpeed))
        {
            // 의심스러운 이동 감지 - 로그 및 처리
            UE_LOG(LogSecurity, Warning,
                   TEXT("%s: Suspicious movement detected, resetting position"),
                   *GetName());

            // 옵션 1: 마지막 유효 위치로 복원
            SetActorLocation(LastValidatedLocation);

            // 옵션 2: 경고 누적 후 처리
            // IncrementWarningCount();
        }
    }

    LastValidatedLocation = GetActorLocation();  // 복원 후 위치
    LastValidationTime = CurrentTime;
}

void ASecureCharacter::ApplyDamage(float DamageAmount, AController* InstigatorController,
                                    AActor* DamageCauser, const FHitResult& HitResult)
{
    // 서버에서만 실행
    if (!HasAuthority())
    {
        UE_LOG(LogSecurity, Error,
               TEXT("ApplyDamage called on client! This is a security violation."));
        return;
    }

    // 이미 사망 시 무시
    if (!IsAlive())
    {
        return;
    }

    // 데미지 검증
    if (DamageAmount < 0.0f)
    {
        UE_LOG(LogSecurity, Warning,
               TEXT("Negative damage attempted: %f"), DamageAmount);
        return;
    }

    // 데미지 상한 검증 (무한 데미지 방지)
    const float MAX_SINGLE_DAMAGE = 10000.0f;
    DamageAmount = FMath::Min(DamageAmount, MAX_SINGLE_DAMAGE);

    // 데미지 적용
    float OldHealth = Health;
    Health = FMath::Max(0.0f, Health - DamageAmount);

    UE_LOG(LogGame, Log,
           TEXT("%s took %f damage (%.0f -> %.0f)"),
           *GetName(), DamageAmount, OldHealth, Health);

    // 사망 처리
    if (Health <= 0.0f)
    {
        OnDeath(InstigatorController, DamageCauser);
    }
}

void ASecureCharacter::ApplyHeal(float HealAmount, AActor* Healer)
{
    // 서버에서만 실행
    if (!HasAuthority())
    {
        UE_LOG(LogSecurity, Error,
               TEXT("ApplyHeal called on client! This is a security violation."));
        return;
    }

    // 사망 시 힐 불가
    if (!IsAlive())
    {
        return;
    }

    // 힐량 검증
    if (HealAmount < 0.0f)
    {
        UE_LOG(LogSecurity, Warning,
               TEXT("Negative heal attempted: %f"), HealAmount);
        return;
    }

    // 힐 상한 검증
    const float MAX_SINGLE_HEAL = 1000.0f;
    HealAmount = FMath::Min(HealAmount, MAX_SINGLE_HEAL);

    // 힐 적용
    float OldHealth = Health;
    Health = FMath::Min(MaxHealth, Health + HealAmount);

    UE_LOG(LogGame, Log,
           TEXT("%s healed for %f (%.0f -> %.0f)"),
           *GetName(), HealAmount, OldHealth, Health);
}

void ASecureCharacter::OnRep_Health(float OldHealth)
{
    // 클라이언트에서 체력 변화 시각화
    float Delta = Health - OldHealth;

    if (Delta < 0)
    {
        // 데미지 받음 - 피격 효과
        PlayHitEffect();
    }
    else if (Delta > 0)
    {
        // 힐 받음 - 힐 효과
        PlayHealEffect();
    }

    // 사망 효과
    if (Health <= 0.0f && OldHealth > 0.0f)
    {
        PlayDeathEffect();
    }
}

3. 일반적인 치트 유형과 대응

3.1 치트 유형별 대응

// CheatDetectionManager.h
#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "CheatDetectionManager.generated.h"

// 위반 유형
UENUM(BlueprintType)
enum class ECheatViolationType : uint8
{
    SpeedHack,          // 이동 속도 조작
    Teleport,           // 텔레포트
    RapidFire,          // 발사 속도 조작
    DamageHack,         // 데미지 조작
    ResourceHack,       // 자원/아이템 조작
    WallHack,           // 벽 통과
    AimbotSuspect,      // 에임봇 의심
    PacketManipulation  // 패킷 조작
};

// 위반 기록
USTRUCT()
struct FViolationRecord
{
    GENERATED_BODY()

    ECheatViolationType Type;
    float Timestamp;
    FString Details;
    int32 Severity;  // 1-10
};

// 플레이어별 보안 데이터
USTRUCT()
struct FPlayerSecurityData
{
    GENERATED_BODY()

    TArray Violations;
    int32 TotalViolationScore;
    float LastWarningTime;
    bool bIsBanned;
};

UCLASS()
class MYGAME_API UCheatDetectionManager : public UGameInstanceSubsystem
{
    GENERATED_BODY()

public:
    // 위반 기록
    void RecordViolation(APlayerController* Player,
                          ECheatViolationType Type,
                          const FString& Details,
                          int32 Severity = 5);

    // 플레이어 보안 점수 확인
    int32 GetPlayerViolationScore(APlayerController* Player) const;

    // 플레이어 처리
    void ProcessPlayer(APlayerController* Player);

protected:
    // 플레이어별 보안 데이터
    TMap PlayerSecurityMap;

    // 처리 임계값
    static constexpr int32 KICK_THRESHOLD = 50;
    static constexpr int32 BAN_THRESHOLD = 100;

    // 위반 점수 계산
    int32 CalculateViolationScore(const TArray& Violations) const;

    // 제재 적용
    void KickPlayer(APlayerController* Player, const FString& Reason);
    void BanPlayer(APlayerController* Player, const FString& Reason);
};

// CheatDetectionManager.cpp
#include "CheatDetectionManager.h"
#include "GameFramework/PlayerController.h"
#include "Kismet/GameplayStatics.h"

void UCheatDetectionManager::RecordViolation(APlayerController* Player,
                                              ECheatViolationType Type,
                                              const FString& Details,
                                              int32 Severity)
{
    if (!Player) return;

    FUniqueNetIdRepl PlayerId = Player->GetUniqueNetIdFromCachedControllerId();
    if (!PlayerId.IsValid()) return;

    // 보안 데이터 가져오기 또는 생성
    FPlayerSecurityData& SecurityData = PlayerSecurityMap.FindOrAdd(PlayerId);

    // 이미 밴된 플레이어 무시
    if (SecurityData.bIsBanned) return;

    // 위반 기록 추가
    FViolationRecord Record;
    Record.Type = Type;
    Record.Timestamp = GetWorld()->GetTimeSeconds();
    Record.Details = Details;
    Record.Severity = FMath::Clamp(Severity, 1, 10);

    SecurityData.Violations.Add(Record);

    // 점수 업데이트
    SecurityData.TotalViolationScore = CalculateViolationScore(SecurityData.Violations);

    UE_LOG(LogSecurity, Warning,
           TEXT("Player %s violation: %s (Score: %d) - %s"),
           *Player->GetPlayerState()->GetPlayerName(),
           *UEnum::GetValueAsString(Type),
           SecurityData.TotalViolationScore,
           *Details);

    // 처리
    ProcessPlayer(Player);
}

int32 UCheatDetectionManager::CalculateViolationScore(
    const TArray& Violations) const
{
    int32 TotalScore = 0;
    float CurrentTime = GetWorld()->GetTimeSeconds();

    for (const FViolationRecord& Record : Violations)
    {
        // 시간에 따라 점수 감소 (5분마다 반감)
        float Age = CurrentTime - Record.Timestamp;
        float DecayFactor = FMath::Pow(0.5f, Age / 300.0f);

        // 위반 유형별 기본 점수
        int32 BaseScore = 0;
        switch (Record.Type)
        {
            case ECheatViolationType::SpeedHack:
                BaseScore = 10;
                break;
            case ECheatViolationType::Teleport:
                BaseScore = 15;
                break;
            case ECheatViolationType::RapidFire:
                BaseScore = 8;
                break;
            case ECheatViolationType::DamageHack:
                BaseScore = 20;
                break;
            case ECheatViolationType::ResourceHack:
                BaseScore = 25;
                break;
            case ECheatViolationType::WallHack:
                BaseScore = 12;
                break;
            case ECheatViolationType::AimbotSuspect:
                BaseScore = 5;  // 의심만으로는 낮은 점수
                break;
            case ECheatViolationType::PacketManipulation:
                BaseScore = 30;  // 명백한 조작
                break;
        }

        TotalScore += FMath::RoundToInt(BaseScore * Record.Severity * DecayFactor);
    }

    return TotalScore;
}

void UCheatDetectionManager::ProcessPlayer(APlayerController* Player)
{
    FUniqueNetIdRepl PlayerId = Player->GetUniqueNetIdFromCachedControllerId();
    FPlayerSecurityData* SecurityData = PlayerSecurityMap.Find(PlayerId);

    if (!SecurityData) return;

    // 밴 임계값 초과
    if (SecurityData->TotalViolationScore >= BAN_THRESHOLD)
    {
        BanPlayer(Player, TEXT("Security violation threshold exceeded"));
        return;
    }

    // 킥 임계값 초과
    if (SecurityData->TotalViolationScore >= KICK_THRESHOLD)
    {
        KickPlayer(Player, TEXT("Suspicious activity detected"));
        return;
    }

    // 경고 (10초마다 최대)
    float CurrentTime = GetWorld()->GetTimeSeconds();
    if (CurrentTime - SecurityData->LastWarningTime > 10.0f)
    {
        SecurityData->LastWarningTime = CurrentTime;

        // 클라이언트에 경고 메시지 전송
        if (AMyPlayerController* MyPC = Cast(Player))
        {
            MyPC->Client_ShowWarning(
                TEXT("Unusual activity detected. Please ensure you're playing fairly."));
        }
    }
}

void UCheatDetectionManager::KickPlayer(APlayerController* Player, const FString& Reason)
{
    UE_LOG(LogSecurity, Warning,
           TEXT("Kicking player %s: %s"),
           *Player->GetPlayerState()->GetPlayerName(),
           *Reason);

    // 게임 세션에서 킥
    if (AGameModeBase* GameMode = GetWorld()->GetAuthGameMode())
    {
        GameMode->GameSession->KickPlayer(Player, FText::FromString(Reason));
    }
}

void UCheatDetectionManager::BanPlayer(APlayerController* Player, const FString& Reason)
{
    FUniqueNetIdRepl PlayerId = Player->GetUniqueNetIdFromCachedControllerId();

    UE_LOG(LogSecurity, Error,
           TEXT("Banning player %s: %s"),
           *Player->GetPlayerState()->GetPlayerName(),
           *Reason);

    // 밴 상태 설정
    FPlayerSecurityData* SecurityData = PlayerSecurityMap.Find(PlayerId);
    if (SecurityData)
    {
        SecurityData->bIsBanned = true;
    }

    // TODO: 영구 밴을 위해 데이터베이스에 저장

    // 플레이어 킥
    KickPlayer(Player, TEXT("You have been banned for cheating."));
}

3.2 에임봇 감지

// AimbotDetector.h
#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "AimbotDetector.generated.h"

/**
 * 에임봇 감지 컴포넌트
 * 서버에서 플레이어의 조준 패턴을 분석
 */
UCLASS(ClassGroup=(Security), meta=(BlueprintSpawnableComponent))
class MYGAME_API UAimbotDetector : public UActorComponent
{
    GENERATED_BODY()

public:
    UAimbotDetector();

    // 샷 기록
    void RecordShot(const FVector& AimDirection, const FVector& TargetLocation,
                    bool bHit, float ReactionTime);

protected:
    // 샷 데이터
    struct FShotRecord
    {
        FVector AimDirection;
        FVector TargetLocation;
        bool bHit;
        float ReactionTime;
        float Timestamp;
        float AngleToTarget;  // 타겟까지의 조준 각도
    };

    TArray RecentShots;
    static constexpr int32 MAX_SHOTS_TO_ANALYZE = 50;

    // 분석 지표
    float AverageReactionTime;
    float AverageAccuracy;
    float SnapAimCount;  // 급격한 조준 변화 횟수

    // 에임봇 판정 임계값
    static constexpr float SUSPICIOUS_REACTION_TIME = 0.1f;  // 100ms
    static constexpr float SUSPICIOUS_ACCURACY = 0.9f;        // 90%
    static constexpr float SNAP_AIM_THRESHOLD = 45.0f;        // 45도 이상 급변

public:
    // 분석 결과
    float GetSuspicionScore() const;
    bool IsSuspicious() const;

private:
    void AnalyzePattern();
    float CalculateSnapAimRatio() const;
};

4. Easy Anti-Cheat (EAC) 통합

4.1 EAC 설정

; DefaultEngine.ini - EAC 설정

[EasyAntiCheat]
bEnabled=true

; DefaultGame.ini
[/Script/Engine.GameSession]
bRequiresPushToTalk=true

; 프로젝트 폴더 구조
; Build/
;   NoRedist/
;     EasyAntiCheat/
;       Settings.json
;       base_private.key   <- Epic Developer Portal에서 다운로드
;       base_public.cer    <- Epic Developer Portal에서 다운로드

4.2 EAC 관리 컴포넌트

// AntiCheatManager.h
#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "AntiCheatManager.generated.h"

// EAC 관련 델리게이트
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnAntiCheatViolation, APlayerController*, Player, const FString&, Reason);

UCLASS()
class MYGAME_API UAntiCheatManager : public UGameInstanceSubsystem
{
    GENERATED_BODY()

public:
    virtual void Initialize(FSubsystemCollectionBase& Collection) override;
    virtual void Deinitialize() override;

    // 플레이어 등록 (서버용)
    UFUNCTION(BlueprintCallable, Category = "AntiCheat")
    void RegisterPlayer(APlayerController* Player);

    // 플레이어 등록 해제
    UFUNCTION(BlueprintCallable, Category = "AntiCheat")
    void UnregisterPlayer(APlayerController* Player);

    // EAC 상태 확인
    UFUNCTION(BlueprintPure, Category = "AntiCheat")
    bool IsAntiCheatEnabled() const;

    // 이벤트
    UPROPERTY(BlueprintAssignable, Category = "AntiCheat")
    FOnAntiCheatViolation OnViolationDetected;

protected:
    // EAC 콜백 처리
    void HandleClientActionRequired(APlayerController* Player, const FString& Action);
    void HandleClientAuthStatusChanged(APlayerController* Player, bool bIsAuthenticated);

    // 등록된 플레이어
    TSet> RegisteredPlayers;
};

// AntiCheatManager.cpp
#include "AntiCheatManager.h"
#include "GameFramework/PlayerController.h"

void UAntiCheatManager::Initialize(FSubsystemCollectionBase& Collection)
{
    Super::Initialize(Collection);

    // EAC 초기화는 엔진 레벨에서 자동 처리
    // 여기서는 게임별 설정만 추가

    UE_LOG(LogSecurity, Log, TEXT("AntiCheatManager initialized"));
}

void UAntiCheatManager::Deinitialize()
{
    // 모든 플레이어 등록 해제
    for (auto& PlayerPtr : RegisteredPlayers)
    {
        if (PlayerPtr.IsValid())
        {
            UnregisterPlayer(PlayerPtr.Get());
        }
    }
    RegisteredPlayers.Empty();

    Super::Deinitialize();
}

void UAntiCheatManager::RegisterPlayer(APlayerController* Player)
{
    if (!Player || !Player->HasAuthority())
    {
        return;
    }

    // 이미 등록되어 있으면 스킵
    if (RegisteredPlayers.Contains(Player))
    {
        return;
    }

    RegisteredPlayers.Add(Player);

    UE_LOG(LogSecurity, Log,
           TEXT("Registered player for anti-cheat: %s"),
           *Player->GetPlayerState()->GetPlayerName());

    // EAC 세션에 플레이어 추가
    // 실제 구현은 EOS Anti-Cheat Server SDK 사용
    // EOS_AntiCheatServer_RegisterClient(...)
}

void UAntiCheatManager::UnregisterPlayer(APlayerController* Player)
{
    if (!Player)
    {
        return;
    }

    RegisteredPlayers.Remove(Player);

    UE_LOG(LogSecurity, Log,
           TEXT("Unregistered player from anti-cheat: %s"),
           *GetNameSafe(Player));

    // EAC 세션에서 플레이어 제거
    // EOS_AntiCheatServer_UnregisterClient(...)
}

bool UAntiCheatManager::IsAntiCheatEnabled() const
{
#if WITH_EOS_SDK
    // EOS SDK가 있는 경우
    return true;
#else
    return false;
#endif
}

void UAntiCheatManager::HandleClientActionRequired(APlayerController* Player,
                                                    const FString& Action)
{
    // EAC에서 클라이언트 조치 필요 시 호출
    // 예: 게임 재시작 필요, 파일 무결성 문제 등

    UE_LOG(LogSecurity, Warning,
           TEXT("EAC action required for %s: %s"),
           *GetNameSafe(Player), *Action);

    OnViolationDetected.Broadcast(Player, Action);
}

void UAntiCheatManager::HandleClientAuthStatusChanged(APlayerController* Player,
                                                       bool bIsAuthenticated)
{
    if (!bIsAuthenticated)
    {
        // 인증 실패 - 플레이어 킥
        UE_LOG(LogSecurity, Error,
               TEXT("EAC authentication failed for %s"),
               *GetNameSafe(Player));

        if (AGameModeBase* GameMode = GetWorld()->GetAuthGameMode())
        {
            GameMode->GameSession->KickPlayer(
                Player,
                FText::FromString(TEXT("Anti-cheat authentication failed"))
            );
        }
    }
}

5. 네트워크 패킷 보안

5.1 패킷 무결성 검사

// PacketValidator.h
#pragma once

#include "CoreMinimal.h"

/**
 * 네트워크 패킷 검증 유틸리티
 */
class MYGAME_API FPacketValidator
{
public:
    /**
     * RPC 호출 빈도 제한
     */
    struct FRateLimiter
    {
        float WindowSize = 1.0f;      // 측정 윈도우 (초)
        int32 MaxCallsPerWindow = 60; // 윈도우당 최대 호출 수
        TArray CallTimestamps;

        bool CheckAndRecord(float CurrentTime)
        {
            // 오래된 타임스탬프 제거
            CallTimestamps.RemoveAll([&](float Timestamp)
            {
                return CurrentTime - Timestamp > WindowSize;
            });

            // 제한 확인
            if (CallTimestamps.Num() >= MaxCallsPerWindow)
            {
                return false;
            }

            // 현재 호출 기록
            CallTimestamps.Add(CurrentTime);
            return true;
        }
    };

    /**
     * 시퀀스 번호 검증 (리플레이 공격 방지)
     */
    struct FSequenceValidator
    {
        int64 LastSequence = 0;

        bool Validate(int64 Sequence)
        {
            // 시퀀스는 항상 증가해야 함
            if (Sequence <= LastSequence)
            {
                UE_LOG(LogSecurity, Warning,
                       TEXT("Replay attack detected: seq %lld <= %lld"),
                       Sequence, LastSequence);
                return false;
            }

            // 급격한 점프 감지 (패킷 주입 의심)
            if (Sequence > LastSequence + 1000)
            {
                UE_LOG(LogSecurity, Warning,
                       TEXT("Sequence jump detected: %lld -> %lld"),
                       LastSequence, Sequence);
                return false;
            }

            LastSequence = Sequence;
            return true;
        }
    };
};

// 플레이어 컨트롤러에서 사용 예시
// SecurePlayerController.cpp에 추가

// 헤더에 추가
TMap RpcRateLimiters;

// 검증 함수
bool ASecurePlayerController::CheckRpcRateLimit(FName RpcName, int32 MaxPerSecond)
{
    if (!HasAuthority()) return true;  // 서버에서만 체크

    FPacketValidator::FRateLimiter& Limiter = RpcRateLimiters.FindOrAdd(RpcName);
    Limiter.MaxCallsPerWindow = MaxPerSecond;

    float CurrentTime = GetWorld()->GetTimeSeconds();
    if (!Limiter.CheckAndRecord(CurrentTime))
    {
        UE_LOG(LogSecurity, Warning,
               TEXT("%s: RPC rate limit exceeded for %s"),
               *GetName(), *RpcName.ToString());
        return false;
    }

    return true;
}

// Server RPC에서 사용
void ASecurePlayerController::Server_RequestAttack_Implementation(...)
{
    // Rate limit 체크
    if (!CheckRpcRateLimit(GET_FUNCTION_NAME_CHECKED(ASecurePlayerController, Server_RequestAttack), 20))
    {
        return;  // 초당 20회 이상 호출 시 무시
    }

    // ... 실제 로직
}

6. 보안 체크리스트

서버 권한 원칙

  • 클라이언트는 절대 게임 상태를 직접 변경하지 않음
  • 모든 중요 결정은 서버에서 수행
  • 클라이언트는 "요청"만 전송

입력 검증

  • 모든 RPC에 WithValidation 추가
  • 범위, 타입, 유효성 검사
  • Rate limiting 적용
  • 타임스탬프 검증

치트 감지

  • 이동 속도/거리 검증
  • 공격 빈도/사거리 검증
  • 자원 변화량 추적
  • 패턴 분석 (에임봇 등)

대응 조치

  • 경고 시스템 (누적 점수)
  • 자동 킥/밴
  • 로그 기록
  • EAC/EOS 안티치트 통합

7. 실습 과제

과제 1: 안전한 거래 시스템

플레이어 간 아이템 거래 시스템을 서버 권한으로 구현하세요.

  • 거래 요청/수락/거절
  • 아이템 소유 검증
  • 동시 거래 방지
  • 거래 취소 처리

과제 2: 이상 행동 감지 시스템

여러 유형의 치트를 감지하는 시스템을 구현하세요.

  • 스피드핵 감지
  • 월핵 감지 (벽 너머 공격)
  • 자동 조준 패턴 분석
  • 리소스 조작 감지

과제 3: 보안 리포트 시스템

플레이어 신고 및 관리자 검토 시스템을 구현하세요.

  • 신고 제출 UI
  • 증거 수집 (리플레이 등)
  • 관리자 검토 대시보드
  • 제재 적용

핵심 요약

PRACTICE

도전 과제

배운 내용을 직접 실습해보세요

실습 1: 서버 권한 검증 구현

Server RPC에서 클라이언트 요청을 검증하세요. 공격 RPC에서 쿨다운 체크, 사거리 확인, 인벤토리 유효성 검사를 서버에서 수행하여 치트를 방지하세요.

실습 2: 속도 해킹 탐지

서버에서 클라이언트의 이동 속도를 검증하세요. CharacterMovement::ServerCheckClientError()를 활용하고, 비정상 속도 탐지 시 로그 기록 및 경고를 발생시키세요.

심화 과제: 종합 안티치트 전략 수립

RPG 게임의 주요 치트 벡터(속도 해킹, 데미지 해킹, 아이템 복제, 월핵)를 분류하고, 각각에 대한 서버 측 검증 전략을 수립하세요. 서버 권한 모델의 한계와 추가 보안 레이어(암호화, 서명)를 설계하세요.