TIL

[Unreal Engine 멀티플레이] 서버 권위 기반 숫자 야구 구현

think95592 2026. 8. 3. 20:57

오늘의 학습 목표

오늘은 언리얼 엔진의 서버 권위 구조를 기반으로 멀티플레이 숫자 야구를 구현했다.

주요 구현 내용은 다음과 같다.

  • GameMode와 GameState의 책임 분리
  • PlayerController의 Server RPC를 통한 입력 전달
  • 서버에서 투수의 정답과 타자의 추측 검증
  • GameState Replication을 통한 TRY, HIT, OUT 동기화
  • 3아웃 달성 시 공수 교대
  • 클라이언트 조작과 잘못된 RPC 요청 방지

1. 숫자 야구의 네트워크 흐름

이번 숫자 야구에서는 투수가 정답 숫자를 가지고 타자가 숫자를 추측한다.

게임 규칙은 다음과 같이 구성했다.

  1. 서버가 투수의 정답을 관리한다.
  2. 타자가 숫자를 입력한다.
  3. 타자의 PlayerController가 Server RPC를 호출한다.
  4. 서버가 입력 형식과 플레이어 역할을 검증한다.
  5. 추측이 정답이면 HIT를 증가시킨다.
  6. 추측이 틀리면 OUT을 증가시킨다.
  7. 시도할 때마다 TRY를 증가시킨다.
  8. OUT이 3이 되면 투수와 타자의 역할을 교대한다.
  9. 변경된 결과는 GameState를 통해 모든 클라이언트에 복제한다.

전체 흐름을 정리하면 다음과 같다.

타자의 채팅 또는 입력 UI
        ↓
로컬 PlayerController
        ↓ Server RPC
서버의 PlayerController
        ↓
서버 GameMode
├─ 현재 타자인지 검사
├─ 입력 형식 검사
├─ 정답과 추측 비교
└─ 3아웃 검사
        ↓
서버 GameState
├─ TRY 변경
├─ HIT 변경
├─ OUT 변경
└─ 공수 상태 변경
        ↓ Replication
모든 클라이언트 UI 갱신

2. 왜 서버 권위 구조가 필요한가?

멀티플레이 게임에서 클라이언트가 직접 정답 판정이나 점수를 변경하게 만들면 치팅에 매우 취약해진다.

예를 들어 클라이언트가 다음 값을 직접 변경할 수 있으면 안 된다.

정답 숫자
HIT 횟수
OUT 횟수
현재 투수와 타자
공수 교대 여부

클라이언트는 자신이 입력한 숫자만 서버에 전달해야 한다.

클라이언트
"123을 추측했습니다."

서버
"이 플레이어가 정말 현재 타자인가?"
"123은 유효한 숫자인가?"
"서버에 저장된 정답과 일치하는가?"

최종 판정과 상태 변경은 모두 서버가 담당한다.


3. GameMode와 GameState의 책임 분리

멀티플레이 게임을 구현할 때 GameMode와 GameState의 역할을 정확히 구분해야 한다.

GameMode의 역할

GameMode는 서버에만 존재한다.

따라서 외부에 공개되면 안 되는 정보와 게임 규칙을 관리하기에 적합하다.

이번 숫자 야구에서 GameMode는 다음 역할을 담당한다.

  • 투수의 정답 숫자 보관
  • 타자의 추측 검증
  • 현재 플레이어가 타자인지 검사
  • HIT와 OUT 판정
  • 3아웃 검사
  • 공수 교대 처리
  • 새로운 정답 생성

투수의 정답은 클라이언트에 복제하면 안 된다.

UCLASS()
class ABaseballGameMode : public AGameModeBase
{
    GENERATED_BODY()

private:
    // 서버에만 존재하며 클라이언트에 복제되지 않는다.
    FString SecretNumber;

public:
    void HandleGuess(
        class ABaseballPlayerController* RequestingPlayer,
        const FString& Guess
    );

private:
    bool IsValidGuess(const FString& Guess) const;
    void GenerateSecretNumber();
    void SwitchSides();
};

GameState의 역할

GameState는 서버와 모든 클라이언트에 존재한다.

전체 플레이어가 알아야 하는 공용 상태를 저장하고 복제하는 데 적합하다.

이번 숫자 야구에서 GameState는 다음 정보를 관리한다.

  • 현재 시도 횟수 TRY
  • 현재 안타 횟수 HIT
  • 현재 아웃 횟수 OUT
  • 현재 투수
  • 현재 타자
UCLASS()
class ABaseballGameState : public AGameStateBase
{
    GENERATED_BODY()

public:
    UPROPERTY(ReplicatedUsing = OnRep_RoundState)
    int32 TryCount = 0;

    UPROPERTY(ReplicatedUsing = OnRep_RoundState)
    int32 HitCount = 0;

    UPROPERTY(ReplicatedUsing = OnRep_RoundState)
    int32 OutCount = 0;

    UPROPERTY(ReplicatedUsing = OnRep_PlayerRoles)
    TObjectPtr<APlayerState> PitcherPlayerState;

    UPROPERTY(ReplicatedUsing = OnRep_PlayerRoles)
    TObjectPtr<APlayerState> BatterPlayerState;

    UFUNCTION()
    void OnRep_RoundState();

    UFUNCTION()
    void OnRep_PlayerRoles();

    virtual void GetLifetimeReplicatedProps(
        TArray<FLifetimeProperty>& OutLifetimeProps
    ) const override;
};

두 클래스의 책임을 요약하면 다음과 같다.

클래스실행 위치담당 역할

GameMode 서버에만 존재 정답, 규칙, 판정, 공수 교대
GameState 서버와 모든 클라이언트 TRY, HIT, OUT, 투수·타자 상태
PlayerController 서버와 소유 클라이언트 입력 수집, Server RPC 전달
Widget 각 클라이언트 입력과 결과 표시

4. PlayerController로 입력 전달하기

타자의 숫자 입력은 로컬 UI에서 발생한다.

하지만 Widget은 네트워크 액터가 아니기 때문에 Widget에서 직접 RPC를 호출하는 구조는 적합하지 않다.

입력은 로컬 PlayerController에 전달하고, PlayerController가 Server RPC를 호출하도록 구성한다.

Widget
→ PlayerController
→ Server RPC
→ GameMode

PlayerController 헤더

UCLASS()
class ABaseballPlayerController : public APlayerController
{
    GENERATED_BODY()

public:
    void SubmitGuess(const FString& Guess);

protected:
    UFUNCTION(Server, Reliable)
    void ServerSubmitGuess(const FString& Guess);
};

로컬 입력 처리

void ABaseballPlayerController::SubmitGuess(const FString& Guess)
{
    if (!IsLocalController())
    {
        return;
    }

    ServerSubmitGuess(Guess);
}

SubmitGuess()는 자신의 화면에서 조작 중인 로컬 PlayerController만 실행하도록 검사한다.


5. Server RPC에서 GameMode 호출하기

Server RPC가 서버에 도착하면 서버의 GameMode에 판정을 요청한다.

void ABaseballPlayerController::ServerSubmitGuess_Implementation(
    const FString& Guess)
{
    ABaseballGameMode* GameMode =
        GetWorld()->GetAuthGameMode<ABaseballGameMode>();

    if (!IsValid(GameMode))
    {
        return;
    }

    GameMode->HandleGuess(this, Guess);
}

GetAuthGameMode()는 Authority가 있는 서버에서 GameMode를 가져올 때 사용한다.

GameMode는 클라이언트에 존재하지 않으므로 클라이언트에서 호출하면 유효한 GameMode를 얻을 수 없다.

PlayerController는 해당 클라이언트가 소유하는 액터이기 때문에 클라이언트에서 서버로 RPC를 전달하는 진입점으로 사용할 수 있다.


6. 서버에서 입력 검증하기

클라이언트가 보낸 문자열은 절대 그대로 신뢰하면 안 된다.

서버는 최소한 다음 내용을 확인해야 한다.

  • 요청한 플레이어가 현재 타자인가?
  • 숫자의 길이가 올바른가?
  • 모든 문자가 숫자인가?
  • 중복된 숫자가 없는가?
  • 현재 게임이 추측 가능한 상태인가?
  • 너무 빠르게 입력을 반복하고 있지는 않은가?

숫자 형식 검사

다음 예시는 세 자리 숫자이며 중복 숫자를 허용하지 않는 규칙이다.

bool ABaseballGameMode::IsValidGuess(const FString& Guess) const
{
    constexpr int32 RequiredLength = 3;

    if (Guess.Len() != RequiredLength)
    {
        return false;
    }

    TSet<TCHAR> UsedDigits;

    for (const TCHAR Character : Guess)
    {
        if (!FChar::IsDigit(Character))
        {
            return false;
        }

        if (UsedDigits.Contains(Character))
        {
            return false;
        }

        UsedDigits.Add(Character);
    }

    return true;
}

클라이언트 UI에서도 입력을 검사할 수 있지만, 그것은 사용자의 실수를 빠르게 알려주기 위한 검사일 뿐이다.

보안을 위한 실제 검증은 반드시 서버에서 다시 수행해야 한다.


7. 서버에서 타자 권한 검사하기

Server RPC를 호출할 수 있다는 사실만으로 해당 플레이어가 현재 타자라는 의미는 아니다.

서버는 RPC를 요청한 PlayerController의 PlayerState와 GameState에 저장된 현재 타자를 비교해야 한다.

void ABaseballGameMode::HandleGuess(
    ABaseballPlayerController* RequestingPlayer,
    const FString& Guess)
{
    if (!IsValid(RequestingPlayer))
    {
        return;
    }

    ABaseballGameState* BaseballGameState =
        GetGameState<ABaseballGameState>();

    if (!IsValid(BaseballGameState))
    {
        return;
    }

    APlayerState* RequestingPlayerState =
        RequestingPlayer->GetPlayerState<APlayerState>();

    if (RequestingPlayerState !=
        BaseballGameState->BatterPlayerState)
    {
        // 현재 타자가 아닌 플레이어의 요청
        return;
    }

    if (!IsValidGuess(Guess))
    {
        return;
    }

    const bool bIsHit = Guess == SecretNumber;

    BaseballGameState->ApplyGuessResult(bIsHit);

    if (BaseballGameState->OutCount >= 3)
    {
        SwitchSides();
    }
    else if (bIsHit)
    {
        GenerateSecretNumber();
    }
}

현재 타자인지는 클라이언트가 Boolean 값으로 보내도록 만들지 않았다.

// 좋지 않은 구조
ServerSubmitGuess(Guess, true);

클라이언트가 true를 보내는 것은 아무런 검증 근거가 되지 않는다. 서버가 자신이 보유한 PlayerState를 기준으로 직접 판단해야 한다.


8. GameState에서 TRY, HIT, OUT 변경하기

GameState의 상태도 서버만 변경할 수 있도록 함수 안에서 Authority를 검사한다.

void ABaseballGameState::ApplyGuessResult(bool bIsHit)
{
    if (!HasAuthority())
    {
        return;
    }

    ++TryCount;

    if (bIsHit)
    {
        ++HitCount;
    }
    else
    {
        ++OutCount;
    }

    // Listen Server의 로컬 UI를 위한 갱신
    OnRoundStateChanged.Broadcast(
        TryCount,
        HitCount,
        OutCount
    );
}

GameState 헤더에 함수를 추가한다.

DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(
    FOnRoundStateChanged,
    int32, TryCount,
    int32, HitCount,
    int32, OutCount
);

UPROPERTY(BlueprintAssignable)
FOnRoundStateChanged OnRoundStateChanged;

void ApplyGuessResult(bool bIsHit);

서버가 값을 변경하면 Replication을 통해 클라이언트에 새로운 상태가 전달된다.


9. Replication 등록하기

UPROPERTY(ReplicatedUsing)만 작성해서는 값이 복제되지 않는다.

GetLifetimeReplicatedProps()에서 각 프로퍼티를 등록해야 한다.

#include "Net/UnrealNetwork.h"

void ABaseballGameState::GetLifetimeReplicatedProps(
    TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(
        ABaseballGameState,
        TryCount
    );

    DOREPLIFETIME(
        ABaseballGameState,
        HitCount
    );

    DOREPLIFETIME(
        ABaseballGameState,
        OutCount
    );

    DOREPLIFETIME(
        ABaseballGameState,
        PitcherPlayerState
    );

    DOREPLIFETIME(
        ABaseballGameState,
        BatterPlayerState
    );
}

GameState는 기본적으로 Replication을 지원하지만, 커스텀 프로퍼티는 직접 등록해야 한다.


10. OnRep로 UI 갱신하기

클라이언트에서 복제된 값이 변경되면 OnRep_RoundState()가 호출된다.

void ABaseballGameState::OnRep_RoundState()
{
    OnRoundStateChanged.Broadcast(
        TryCount,
        HitCount,
        OutCount
    );
}

Widget은 GameState의 델리게이트에 이벤트를 연결해 UI를 갱신한다.

TRY : 4
HIT : 1
OUT : 2

복제 변수와 UI를 직접 강하게 연결하기보다 GameState가 델리게이트를 발생시키고 Widget이 이를 구독하도록 만들면 역할을 분리할 수 있다.

GameState
→ 데이터 보관과 변경 알림

Widget
→ 데이터를 화면에 표시

주의할 점은 서버에서 값을 변경했을 때 서버 자신의 OnRep 함수가 자동 호출된다고 기대하면 안 된다는 것이다.

Listen Server의 호스트 UI도 갱신해야 한다면 서버에서 값을 변경한 직후 델리게이트를 직접 호출하거나 별도의 공통 갱신 함수를 사용하는 것이 안전하다.


11. 3아웃 공수 교대 처리

OUT이 3이 되면 서버의 GameMode가 투수와 타자를 교대한다.

void ABaseballGameMode::SwitchSides()
{
    ABaseballGameState* BaseballGameState =
        GetGameState<ABaseballGameState>();

    if (!IsValid(BaseballGameState))
    {
        return;
    }

    Swap(
        BaseballGameState->PitcherPlayerState,
        BaseballGameState->BatterPlayerState
    );

    BaseballGameState->TryCount = 0;
    BaseballGameState->OutCount = 0;

    GenerateSecretNumber();

    BaseballGameState->NotifyRoundStateChanged();
}

공수 교대 과정은 다음과 같다.

OUT 증가
    ↓
OUT이 3 이상인가?
    ├─ 아니오: 현재 공격 유지
    └─ 예
        ├─ 투수와 타자 교환
        ├─ TRY 초기화
        ├─ OUT 초기화
        ├─ 새 정답 생성
        └─ 변경 상태 복제

HIT을 초기화할지는 게임 규칙에 따라 달라진다.

누적 점수로 사용할 경우 유지하고, 이닝 또는 라운드 점수로 사용할 경우 공수 교대 시 초기화할 수 있다.

중요한 점은 공수 교대 판단도 클라이언트가 아니라 서버에서 처리해야 한다는 것이다.


12. 투수의 정답은 복제하지 않는다

이번 구현에서 가장 중요한 보안 요소는 정답을 GameState에 저장하지 않는 것이다.

GameState에 정답을 Replicated 프로퍼티로 저장하면 모든 클라이언트가 해당 값을 받을 수 있다.

// 정답이 클라이언트에 노출될 수 있는 구조
UPROPERTY(Replicated)
FString SecretNumber;

화면에 표시하지 않더라도 클라이언트 메모리나 네트워크 데이터를 분석하면 정답을 확인할 수 있다.

따라서 정답은 서버에만 존재하는 GameMode에 저장한다.

// 서버에만 존재
FString SecretNumber;

클라이언트에는 판정 결과만 전달한다.

클라이언트가 알아야 하는 정보
├─ TRY
├─ HIT
├─ OUT
├─ 현재 투수
└─ 현재 타자

클라이언트가 알면 안 되는 정보
└─ 서버가 관리하는 정답

13. Replication과 RPC의 역할 차이

이번 구현에서는 RPC와 Replication을 서로 다른 목적으로 사용했다.

RPC

플레이어가 숫자를 입력했다는 일회성 요청을 서버에 전달한다.

타자의 추측 입력
→ Server RPC

Replication

서버에서 확정된 현재 게임 상태를 모든 클라이언트와 동기화한다.

TRY, HIT, OUT
→ GameState Replication

정리하면 다음과 같다.

RPC
= 어떤 행동을 요청하거나 이벤트를 전달

Replication
= 서버에서 확정된 상태를 지속적으로 동기화

TRY, HIT, OUT을 Multicast RPC로만 전달할 수도 있지만, 늦게 접속한 플레이어는 이전 RPC를 받을 수 없다.

GameState의 Replication을 사용하면 새로 접속한 클라이언트도 서버의 현재 상태를 전달받을 수 있다.


14. 구현하면서 주의할 점

클라이언트의 판정을 신뢰하지 않는다

// 클라이언트가 판정 결과까지 보내면 안 된다.
ServerSubmitResult(Guess, true);

클라이언트는 추측만 보내고 정답 판정은 서버가 수행해야 한다.

타자가 아닌 플레이어의 RPC를 검사한다

RPC 호출이 서버에 도착했더라도 현재 타자가 아니면 요청을 거절해야 한다.

정답을 GameState에 저장하지 않는다

GameState는 클라이언트에 존재하기 때문에 비밀 데이터를 보관하기에 적합하지 않다.

UI에서 게임 규칙을 처리하지 않는다

Widget은 입력과 출력만 담당한다. 아웃 판정이나 공수 교대는 서버 GameMode가 처리해야 한다.

RPC 입력 횟수를 제한한다

악의적인 클라이언트가 Server RPC를 빠르게 반복 호출할 수 있으므로 서버에서 입력 간격도 검사해야 한다.


15. 오늘 배운 내용

오늘은 서버 권위 기반으로 멀티플레이 숫자 야구를 구현했다.

클라이언트는 자신의 추측만 PlayerController의 Server RPC로 전달하고, GameMode는 서버에서 정답과 추측을 비교했다.

판정 결과인 TRY, HIT, OUT은 GameState에 저장하고 Replication을 통해 모든 클라이언트에 동기화했다.

OUT이 3이 되면 서버가 투수와 타자를 교대하고 새로운 정답을 생성하도록 구현했다.

전체 클래스의 책임은 다음과 같이 정리할 수 있다.

PlayerController
= 클라이언트 입력을 Server RPC로 전달

GameMode
= 서버 전용 정답, 입력 검증, 판정, 공수 교대

GameState
= TRY, HIT, OUT과 플레이어 역할 복제

Widget
= 입력을 받고 복제된 결과를 화면에 표시

이번 구현에서 가장 중요한 원칙은 다음과 같다.

클라이언트는 요청한다.
서버는 검증하고 결정한다.
GameState는 결정된 결과를 공유한다.