2023년 1월 1일
08:00 AM
Buffering ...

최근 글 👑

Unreal Engine GameMode와 GameState 차이: 서버 권한과 게임 상태를 어디에 둘까?: 규칙은 GameMode, 공유 상태는 GameState

2026. 7. 3. 06:00ㆍUnreal Engine
반응형

 줄 요약: Unreal Engine에서 GameMode는 서버에서 게임 규칙과 입장 흐름을 결정하고, GameState는 모든 클라이언트가 알아야 하는 게임 전체 상태를 복제해 공유하는 역할을 맡습니다.

먼저 알아야 할 배경

Unreal 멀티플레이 코드를 처음 작성할 때 가장 자주 헷갈리는 지점이 GameMode와 GameState의 책임입니다. 둘 다 ‘게임 전체’와 관련되어 보이지만, 네트워크 관점에서는 존재 위치와 접근 가능한 대상이 다릅니다.

Epic 공식 문서 기준으로 GameMode 인스턴스는 서버에만 존재하며 원격 클라이언트로 복제되지 않습니다. 반대로 GameState는 서버와 클라이언트에 존재하고, 게임 진행 중 모두가 알아야 하는 상태를 동기화하는 데 사용됩니다.

이 구분을 놓치면 클라이언트 UI에서 GameMode 변수를 읽으려 하거나, 점수와 라운드 상태를 서버 전용 객체에 넣어 놓고 왜 보이지 않는지 디버깅하게 됩니다. 규칙과 상태의 경계를 처음부터 나누면 서버 권한, 복제, UI 업데이트 흐름이 훨씬 단순해집니다.

핵심 개념

GameMode는 서버 권한을 가진 규칙 관리자다

GameMode는 어떤 Pawn을 기본으로 사용할지, 플레이어가 들어왔을 때 무엇을 할지, 언제 라운드를 시작하거나 끝낼지 같은 규칙을 정의합니다. `AGameModeBase`는 UE 4.14 이후 기본이 된 간단한 GameMode 기반 클래스이고, `AGameMode`는 match state 같은 더 전통적인 경기 흐름 기능을 포함합니다.

중요한 점은 GameMode 인스턴스가 원격 클라이언트에 복제되지 않는다는 사실입니다. 서버에서만 실제 인스턴스를 가지고 있으므로, 클라이언트가 GameMode의 런타임 변수에 접근해 UI를 갱신하는 구조는 맞지 않습니다. 클라이언트가 알아야 할 정보라면 GameMode가 아니라 GameState나 PlayerState로 옮겨야 합니다.

GameMode에는 서버가 최종 판단해야 하는 로직을 둡니다. 접속 허용, 팀 배정, 스폰 규칙, 승패 판정, 점수 변경의 권한 같은 작업이 대표적입니다. 클라이언트 요청이 들어와도 최종 변경은 서버의 GameMode 또는 서버 권한 객체를 통해 검증되어야 치트나 상태 불일치를 줄일 수 있습니다.

GameState는 모두가 알아야 하는 게임 상태를 공유한다

GameState는 현재 게임의 공통 상태를 클라이언트가 관찰할 수 있게 만드는 Actor입니다. 예를 들어 경기 경과 시간, 팀 점수, 라운드 단계, 완료된 미션, 현재 GameMode 클래스 정보처럼 특정 플레이어 한 명이 아니라 게임 전체에 속한 상태를 담기에 적합합니다.

공식 문서는 GameState가 서버와 클라이언트에 존재하며, 서버의 변경 사항이 클라이언트로 복제된다고 설명합니다. 그래서 UI, 스코어보드, 관전 화면처럼 모든 플레이어에게 같은 정보를 보여줘야 하는 기능은 GameState를 읽는 흐름으로 설계하는 편이 자연스럽습니다.

다만 GameState가 클라이언트에서도 보인다고 해서 클라이언트가 마음대로 상태를 바꾸는 저장소라는 뜻은 아닙니다. 권한 있는 변경은 서버에서 수행하고, GameState는 그 결과를 복제해 알려 주는 역할에 가깝습니다. 클라이언트 입력은 보통 PlayerController의 Server RPC 같은 경로로 서버에 요청하고, 서버가 검증한 뒤 GameState 값을 갱신합니다.

PlayerState와의 경계도 함께 생각해야 한다

GameState에 모든 변수를 넣으면 처음에는 편해 보이지만, 플레이어 개별 상태까지 섞이면 구조가 금방 흐려집니다. 특정 플레이어의 이름, 개인 점수, 팀 번호, 준비 완료 여부처럼 플레이어마다 다른 정보는 PlayerState가 더 잘 맞습니다.

반대로 팀 전체 점수, 현재 라운드 번호, 매치 시작 여부처럼 모든 사람이 같은 값을 봐야 하는 정보는 GameState에 두는 편이 좋습니다. GameState는 PlayerArray를 통해 PlayerState 목록을 다룰 수 있으므로, 전체 상황을 보여주는 UI가 각 플레이어 상태를 모아 표시하기에도 적합합니다.

결국 선택 기준은 ‘이 상태의 주인이 누구인가’입니다. 규칙과 판정은 GameMode, 게임 전체가 공유할 현재 상태는 GameState, 플레이어 개인의 복제 상태는 PlayerState로 나누면 대부분의 구조가 정리됩니다.

엔진 버전과 프로젝트 설정을 함께 확인한다

이 글은 UE 5.x 계열의 일반적인 Gameplay Framework 구조를 기준으로 설명합니다. 공식 문서에는 `AGameModeBase`가 UE 4.14부터 도입되었고, 새 코드 프로젝트의 기본 GameMode 기반으로 쓰인다는 설명이 있습니다. 오래된 프로젝트나 `AGameMode` 기반 매치 흐름을 쓰는 프로젝트라면 match state 관련 동작도 함께 확인해야 합니다.

GameMode 설정은 프로젝트 기본값, 월드 설정의 GameMode Override, URL 옵션, 맵 prefix 설정 등에 의해 달라질 수 있습니다. 코드에서 GameStateClass를 지정했더라도 실제 맵이 다른 GameMode를 사용하면 예상한 GameState가 생성되지 않을 수 있습니다.

복제 동작은 C++ 코드만으로 끝나지 않습니다. Actor 복제 설정, UPROPERTY Replicated 선언, `GetLifetimeReplicatedProps`, 네트워크 모드, PIE 설정, 전용 서버 여부가 모두 영향을 줍니다. 확실하지 않은 동작은 사용 중인 엔진 버전과 프로젝트 설정에서 직접 확인해야 합니다.

메모리와 객체 관점에서 이해하기

서버에는 GameMode와 GameState가 모두 있습니다. GameMode는 규칙을 판단하고 GameState 값을 갱신하며, GameState는 복제 대상 상태를 보관합니다.

클라이언트에는 실제 GameMode 인스턴스가 복제되지 않습니다. 클라이언트 UI나 HUD가 게임 전체 상태를 읽어야 한다면 GameState를 읽는 구조가 맞습니다.

PlayerController는 클라이언트 입력과 서버 요청을 연결하는 지점이 될 수 있습니다. 클라이언트가 점수 변경을 요청하면 서버 RPC를 거쳐 GameMode가 검증하고, 결과는 GameState 복제로 클라이언트에 도달하는 흐름을 만들 수 있습니다.

코드로 확인하기

GameMode에서 사용할 GameState 클래스를 지정하기

다음 예제는 UE 5.x C++ 프로젝트 안에 들어갈 수 있는 구조 예시입니다. GameMode는 서버 전용 규칙 진입점을 갖고, 생성자에서 이 모드와 함께 사용할 GameState 클래스를 지정합니다.

// MyRoundGameMode.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "MyRoundGameMode.generated.h"

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

public:
    AMyRoundGameMode();

    virtual void PostLogin(APlayerController* NewPlayer) override;
    virtual void Logout(AController* Exiting) override;

    void AddTeamScore(int32 TeamId, int32 Amount);
};

// MyRoundGameMode.cpp
#include "MyRoundGameMode.h"

#include "MyRoundGameState.h"

AMyRoundGameMode::AMyRoundGameMode()
{
    GameStateClass = AMyRoundGameState::StaticClass();
}

void AMyRoundGameMode::PostLogin(APlayerController* NewPlayer)
{
    Super::PostLogin(NewPlayer);

    // Server-only rule logic, such as assigning a team, can live here.
}

void AMyRoundGameMode::Logout(AController* Exiting)
{
    Super::Logout(Exiting);

    // Server-only cleanup for a leaving player can live here.
}

void AMyRoundGameMode::AddTeamScore(int32 TeamId, int32 Amount)
{
    AMyRoundGameState* RoundState = GetGameState<AMyRoundGameState>();
    if (RoundState == nullptr)
    {
        return;
    }

    const int32 CurrentScore =
        TeamId == 0 ? RoundState->GetBlueTeamScore()
                    : RoundState->GetRedTeamScore();

    RoundState->SetTeamScore(TeamId, CurrentScore + Amount);
}

`PostLogin`과 `Logout`은 플레이어 접속 흐름에서 서버가 규칙을 적용하기 좋은 지점입니다. `GameStateClass`를 지정하면 이 GameMode가 사용하는 GameState 타입이 명확해집니다. 실제 프로젝트에서는 Build.cs 모듈, 헤더 경로, Blueprint 파생 클래스 사용 여부도 함께 확인해야 합니다.

GameState에 복제되는 팀 점수 넣기

모든 클라이언트가 알아야 하는 팀 점수는 GameState에 두고 복제 대상으로 선언합니다. 아래 코드는 점수 값을 ReplicatedUsing으로 선언하고 `GetLifetimeReplicatedProps`에서 복제 목록에 추가합니다.

// MyRoundGameState.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameStateBase.h"
#include "MyRoundGameState.generated.h"

UCLASS()
class AMyRoundGameState : public AGameStateBase
{
    GENERATED_BODY()

public:
    AMyRoundGameState();

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

    UFUNCTION(BlueprintCallable)
    int32 GetBlueTeamScore() const { return BlueTeamScore; }

    UFUNCTION(BlueprintCallable)
    int32 GetRedTeamScore() const { return RedTeamScore; }

    void SetTeamScore(int32 TeamId, int32 NewScore);

protected:
    UPROPERTY(ReplicatedUsing = OnRep_TeamScore)
    int32 BlueTeamScore = 0;

    UPROPERTY(ReplicatedUsing = OnRep_TeamScore)
    int32 RedTeamScore = 0;

    UFUNCTION()
    void OnRep_TeamScore();
};

// MyRoundGameState.cpp
#include "MyRoundGameState.h"

#include "Net/UnrealNetwork.h"

AMyRoundGameState::AMyRoundGameState()
{
    bReplicates = true;
}

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

    DOREPLIFETIME(AMyRoundGameState, BlueTeamScore);
    DOREPLIFETIME(AMyRoundGameState, RedTeamScore);
}

void AMyRoundGameState::SetTeamScore(int32 TeamId, int32 NewScore)
{
    if (TeamId == 0)
    {
        BlueTeamScore = NewScore;
    }
    else
    {
        RedTeamScore = NewScore;
    }

    OnRep_TeamScore();
}

void AMyRoundGameState::OnRep_TeamScore()
{
    // Update UI through a delegate or Blueprint event in a real project.
}

서버에서 BlueTeamScore나 RedTeamScore가 바뀌면 복제를 통해 클라이언트의 GameState에도 값이 전달됩니다. `OnRep_TeamScore`는 클라이언트에서 UI 갱신 이벤트를 연결하기 좋은 자리입니다. 서버에서 직접 값을 바꾼 직후에도 UI나 서버 로직을 맞추고 싶다면 예제처럼 OnRep 함수를 명시적으로 호출하거나 별도 공통 함수를 둘 수 있습니다.

클라이언트 UI는 GameMode가 아니라 GameState를 읽기

클라이언트 위젯은 서버 전용 GameMode의 런타임 변수에 의존하면 안 됩니다. 복제된 GameState를 읽으면 서버가 갱신한 공통 상태를 UI에 반영할 수 있습니다.

#include "Kismet/GameplayStatics.h"
#include "MyRoundGameState.h"

void UMyWidget::Refresh()
{
    const AMyRoundGameState* RoundState =
        GetWorld() != nullptr ? GetWorld()->GetGameState<AMyRoundGameState>() : nullptr;

    if (RoundState != nullptr)
    {
        const int32 BlueScore = RoundState->GetBlueTeamScore();
        const int32 RedScore = RoundState->GetRedTeamScore();
        // Update UI with replicated game-wide state.
    }
}

위젯은 `GetWorld()->GetGameState<AMyRoundGameState>()`로 현재 GameState를 얻고 복제된 점수를 읽습니다. 이 값은 서버 권한으로 갱신되어야 하며, 클라이언트는 표시만 담당하는 편이 안전합니다. 실제 UI 갱신은 polling보다 OnRep, delegate, ViewModel 같은 프로젝트 구조에 맞춰 연결할 수 있습니다.

자주 하는 실수

실수 1. 클라이언트에서 GameMode 인스턴스를 읽으려 하기

GameMode는 서버에만 존재하므로 원격 클라이언트에서 런타임 GameMode 상태를 읽어 UI를 갱신하려는 구조는 실패하거나 null을 다루게 됩니다.

문제 코드 — 잘못된 예시:

#include "Kismet/GameplayStatics.h"

void UMyWidget::Refresh()
{
    // 잘못된 예시: 클라이언트에서는 실제 GameMode 인스턴스에 접근할 수 없습니다.
    AGameModeBase* GameMode = UGameplayStatics::GetGameMode(this);
    if (GameMode != nullptr)
    {
        // 서버 전용 규칙 객체를 UI 데이터 저장소처럼 사용하려는 흐름입니다.
    }
}

개선 코드:

#include "Kismet/GameplayStatics.h"
#include "MyRoundGameState.h"

void UMyWidget::Refresh()
{
    const AMyRoundGameState* RoundState =
        GetWorld() != nullptr ? GetWorld()->GetGameState<AMyRoundGameState>() : nullptr;

    if (RoundState != nullptr)
    {
        const int32 BlueScore = RoundState->GetBlueTeamScore();
        const int32 RedScore = RoundState->GetRedTeamScore();
        // Update UI with replicated game-wide state.
    }
}

설명: 클라이언트가 알아야 하는 게임 전체 상태는 GameState에 두고 복제합니다. GameMode는 서버에서 규칙을 실행하고, GameState는 그 결과 중 모두에게 보여 줄 상태를 보관합니다. 이 경계를 지키면 UI 코드가 서버 전용 객체에 의존하지 않습니다.

실수 2. 복제되어야 할 점수를 GameMode 변수에만 보관하기

팀 점수처럼 모든 플레이어가 봐야 하는 값이 GameMode에만 있으면 서버에서는 맞아 보이지만 클라이언트에는 전달되지 않습니다.

문제 코드 — 잘못된 예시:

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

public:
    // 잘못된 예시: GameMode는 원격 클라이언트에 복제되지 않습니다.
    UPROPERTY(BlueprintReadOnly)
    int32 BlueTeamScore = 0;
};

개선 코드:

UCLASS()
class AMyRoundGameState : public AGameStateBase
{
    GENERATED_BODY()

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

    UPROPERTY(ReplicatedUsing = OnRep_BlueTeamScore)
    int32 BlueTeamScore = 0;

protected:
    UFUNCTION()
    void OnRep_BlueTeamScore();
};

설명: 개선 코드처럼 GameState에 Replicated 또는 ReplicatedUsing 속성을 둬야 클라이언트가 값을 받을 수 있습니다. 단순히 UPROPERTY를 붙이는 것만으로 복제되는 것은 아니며, `GetLifetimeReplicatedProps` 설정과 네트워크 환경 확인도 필요합니다.

실수 3. 클라이언트가 GameState 값을 직접 변경하게 만들기

GameState가 클라이언트에 존재한다고 해서 클라이언트가 권한 있는 상태 변경을 직접 수행해도 된다는 뜻은 아닙니다. 서버 권한이 필요한 값은 서버가 검증하고 수정해야 합니다.

문제 코드 — 잘못된 예시:

void UMyWidget::OnScoreButtonClicked()
{
    // 잘못된 예시: 클라이언트 UI가 replicated GameState 값을 직접 고치려 합니다.
    AMyRoundGameState* RoundState = GetWorld()->GetGameState<AMyRoundGameState>();
    if (RoundState != nullptr)
    {
        RoundState->SetTeamScore(0, RoundState->GetBlueTeamScore() + 1);
    }
}

개선 코드:

// MyPlayerController.h
UFUNCTION(Server, Reliable)
void ServerRequestAddScore(int32 TeamId);

// MyPlayerController.cpp
void AMyPlayerController::ServerRequestAddScore_Implementation(int32 TeamId)
{
    AMyRoundGameMode* RoundMode = GetWorld()->GetAuthGameMode<AMyRoundGameMode>();
    if (RoundMode != nullptr)
    {
        RoundMode->AddTeamScore(TeamId, 1);
    }
}

설명: 개선 흐름에서는 클라이언트가 PlayerController의 Server RPC로 요청하고, 서버에서 GameMode를 통해 점수 변경을 처리합니다. 이후 변경 결과가 GameState 복제로 클라이언트에 전달됩니다. 실제 게임에서는 요청 검증, 쿨다운, 팀 권한, 치트 방지 조건을 추가해야 합니다.

언제 어떤 방식을 선택해야 할까

규칙, 판정, 접속 처리, 스폰 흐름처럼 서버가 최종 권한을 가져야 하는 로직은 GameMode에 둡니다. 클라이언트가 직접 읽어야 하는 런타임 데이터는 여기에만 보관하지 않습니다.

팀 점수, 라운드 상태, 매치 시작 여부처럼 모든 클라이언트가 공유해야 하는 게임 전체 상태는 GameState에 둡니다. 특정 플레이어에게 속한 이름, 개인 점수, 팀 번호는 PlayerState를 우선 검토합니다.

클라이언트 입력이 서버 상태를 바꿔야 한다면 PlayerController나 Pawn의 Server RPC를 통해 요청하고, 서버에서 검증한 뒤 GameMode 또는 권한 있는 서버 객체가 GameState를 갱신하게 합니다.

정리

GameMode와 GameState는 둘 다 게임 전체와 관련되지만 네트워크 책임이 다릅니다. GameMode는 서버 전용 규칙 관리자이고, GameState는 모두가 알아야 하는 게임 전체 상태를 복제하는 저장소입니다.

클라이언트 UI가 읽어야 하는 값은 GameState 또는 PlayerState에 두고, 승패 판정과 점수 변경 권한은 서버의 GameMode 쪽에서 관리하는 흐름이 일반적으로 안전합니다.

Unreal Engine은 버전과 프로젝트 설정에 따라 세부 동작이 달라질 수 있으므로, UE 5.x 기준의 공식 문서와 실제 프로젝트의 GameMode 설정, 복제 설정, PIE 네트워크 모드를 함께 확인해야 합니다.

참고 자료

반응형