학습일지/언리얼

언리얼 프레임워크

Tsukino Ren 2026. 5. 4. 17:19

언리얼 엔진 GameMode / GameState / GameInstance 완전 정복

언리얼 엔진으로 게임을 만들다 보면 반드시 마주치는 세 가지 클래스가 있습니다.
GameMode, GameState, GameInstance.
이름은 비슷해 보이지만 역할, 생명주기, 네트워크 동작이 전혀 다릅니다.

이 글에서는 각 클래스가 왜 그렇게 설계됐는지부터, 실제 멀티플레이어 게임에서 어떻게 협력하는지까지 구체적인 코드와 함께 설명합니다.


전체 구조 한눈에 보기

세 클래스의 가장 중요한 차이는 "어디에 존재하고, 언제 사라지는가" 입니다.

클래스 존재 위치 레벨 전환 시 네트워크 복제 주요 역할
GameMode 서버 전용 소멸 후 재생성 없음 게임 규칙, 스폰, 승패 판정
GameState 서버 + 전체 클라이언트 소멸 후 재생성 자동 복제 공유 게임 데이터 (점수, 타이머)
GameInstance 클라이언트 1개 유지됨 없음 (로컬) 영속 데이터, 세션 관리, 세이브

⚠️ 흔한 실수
클라이언트에서 GetAuthGameMode()를 호출하면 nullptr을 반환합니다.
GameMode는 서버에만 존재합니다. 클라이언트에서 게임 데이터를 읽어야 한다면 반드시 GameState를 사용해야 합니다.


1. GameMode — 서버의 심판

한 줄 요약: 게임이 어떻게 진행되는지에 대한 규칙과 로직을 담당합니다.
플레이어 입장, 스폰, 승패 조건 판정 모두 여기서 이루어집니다.
클라이언트는 접근할 수 없으므로 치트 방지에 유리합니다.

AGameModeBase vs AGameMode 차이

언리얼은 두 가지 베이스 클래스를 제공합니다.

  • AGameModeBase — 최소한의 기능만 포함. 싱글플레이어, 간단한 게임에 적합.
  • AGameMode매치 상태 머신 포함. "대기 → 게임중 → 종료" 흐름이 필요할 때 사용.

매치 상태는 다음 순서로 전이됩니다.

WaitingToStart  →  InProgress  →  WaitingPostMatch  →  LeavingMap

헤더 파일

// MyGameMode.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameMode.h"
#include "MyGameMode.generated.h"

UCLASS()
class MYGAME_API AMyGameMode : public AGameMode
{
    GENERATED_BODY()

public:
    AMyGameMode();

    // ─── 플레이어 입장 / 퇴장 ────────────────────────────
    virtual void PostLogin(APlayerController* NewPlayer) override;
    virtual void Logout(AController* Exiting) override;

    // ─── 스폰 관련 ──────────────────────────────────────
    // 플레이어별로 다른 Pawn 클래스를 쓰고 싶을 때
    virtual UClass* GetDefaultPawnClassForController_Implementation(
        AController* InController) override;

    // 어떤 PlayerStart에서 스폰할지 결정
    virtual AActor* ChoosePlayerStart_Implementation(
        AController* Player) override;

    // ─── 매치 상태 머신 콜백 ────────────────────────────
    virtual void HandleMatchIsWaitingToStart() override;
    virtual void HandleMatchHasStarted() override;
    virtual void HandleMatchHasEnded() override;

    // ─── 커스텀 게임 로직 ────────────────────────────────
    UFUNCTION(BlueprintCallable, Category="Game")
    void OnPlayerKilled(APlayerController* Killer, APlayerController* Victim);

private:
    void CheckVictoryCondition(APlayerController* Scorer);
    void OnMatchTimeUp();

    UPROPERTY(EditDefaultsOnly, Category="Rules")
    int32 KillsToWin = 30;

    UPROPERTY(EditDefaultsOnly, Category="Rules")
    float MatchTimeLimit = 600.f; // 10분

    FTimerHandle MatchTimerHandle;
};

구현 파일

// MyGameMode.cpp

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

    int32 PlayerCount = GetNumPlayers();
    UE_LOG(LogTemp, Log, TEXT("[GameMode] Player joined. Total: %d"), PlayerCount);

    // 2명 이상 접속하면 게임 시작 (WaitingToStart → InProgress)
    if (PlayerCount >= 2 && GetMatchState() == MatchState::WaitingToStart)
    {
        StartMatch(); // → HandleMatchHasStarted() 자동 호출
    }
}

void AMyGameMode::HandleMatchHasStarted()
{
    Super::HandleMatchHasStarted();

    // GameState에 기록 → DOREPLIFETIME 설정 시 모든 클라이언트 자동 동기화
    AMyGameState* GS = GetGameState<AMyGameState>();
    if (GS)
    {
        GS->MatchStartTime = GetWorld()->GetTimeSeconds();
        GS->MatchPhase = TEXT("InProgress"); // 클라이언트에서 OnRep_MatchPhase() 호출됨
    }

    // 서버 전용 타이머: 제한시간 체크
    GetWorldTimerManager().SetTimer(
        MatchTimerHandle,
        this,
        &AMyGameMode::OnMatchTimeUp,
        MatchTimeLimit,
        false
    );
}

void AMyGameMode::OnPlayerKilled(
    APlayerController* Killer, APlayerController* Victim)
{
    // 킬한 플레이어의 킬카운트 증가
    if (AMyPlayerState* PS = Killer->GetPlayerState<AMyPlayerState>())
    {
        PS->AddKill();
    }

    // 죽은 플레이어를 3초 후 리스폰
    FTimerHandle RespawnTimer;
    GetWorldTimerManager().SetTimer(
        RespawnTimer,
        [this, Victim]() { RestartPlayer(Victim); },
        3.0f, false
    );

    CheckVictoryCondition(Killer);
}

void AMyGameMode::CheckVictoryCondition(APlayerController* Scorer)
{
    AMyPlayerState* PS = Scorer->GetPlayerState<AMyPlayerState>();
    if (!PS) return;

    if (PS->GetKillCount() >= KillsToWin)
    {
        AMyGameState* GS = GetGameState<AMyGameState>();
        if (GS) GS->WinnerName = PS->GetPlayerName(); // 복제 → 전체 클라이언트 알림

        EndMatch(); // → HandleMatchHasEnded() 호출
    }
}

void AMyGameMode::HandleMatchHasEnded()
{
    Super::HandleMatchHasEnded();

    // 5초 후 로비로 이동
    FTimerHandle EndTimer;
    GetWorldTimerManager().SetTimer(
        EndTimer,
        [this]() {
            GetWorld()->ServerTravel(TEXT("/Game/Maps/Lobby?listen"));
        },
        5.0f, false
    );
}

핵심 포인트
GameMode에서 직접 점수를 저장하지 않습니다.
"점수가 몇 점이 됐다"는 사실은 GameState에 써야 합니다.
GameMode는 판정만 하고, GameState가 데이터를 보유합니다.


2. GameState — 공유 게시판

한 줄 요약: 서버와 모든 클라이언트에 동시에 존재하며,
서버가 값을 바꾸면 자동으로 모든 클라이언트에 복제됩니다.
UI에 점수나 타이머를 표시할 때 여기서 읽습니다.

복제(Replication) 3가지 방법

매크로 동작 사용 시나리오
UPROPERTY(Replicated) 값이 바뀌면 자동 전송 점수, 체력 등 단순 수치
UPROPERTY(ReplicatedUsing=OnRep_XXX) 값 변경 시 클라이언트에서 함수 호출 UI 갱신, 이펙트 재생 등
DOREPLIFETIME_CONDITION 특정 조건에서만 전송 성능 최적화

헤더 파일

// MyGameState.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameState.h"
#include "MyGameState.generated.h"

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnMatchPhaseChanged, const FString&, NewPhase);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnWinnerDecided,     const FString&, WinnerName);

UCLASS()
class MYGAME_API AMyGameState : public AGameState
{
    GENERATED_BODY()

public:
    // ─── 단순 복제 변수 (값만 동기화) ───────────────────
    UPROPERTY(Replicated, BlueprintReadOnly, Category="Match")
    float MatchStartTime = 0.f;

    UPROPERTY(Replicated, BlueprintReadOnly, Category="Match")
    float RemainingTime = 0.f;

    // ─── RepNotify 변수 (값 변경 시 클라이언트 콜백) ────
    UPROPERTY(ReplicatedUsing=OnRep_MatchPhase, BlueprintReadOnly, Category="Match")
    FString MatchPhase = TEXT("WaitingToStart");

    UPROPERTY(ReplicatedUsing=OnRep_WinnerName, BlueprintReadOnly, Category="Match")
    FString WinnerName;

    UPROPERTY(ReplicatedUsing=OnRep_TeamScores, BlueprintReadOnly, Category="Score")
    TArray<int32> TeamScores; // [0] = 팀A, [1] = 팀B

    // ─── 블루프린트 이벤트 디스패처 (UI 바인딩용) ───────
    UPROPERTY(BlueprintAssignable, Category="Events")
    FOnMatchPhaseChanged OnMatchPhaseChanged;

    UPROPERTY(BlueprintAssignable, Category="Events")
    FOnWinnerDecided OnWinnerDecided;

    // ─── RepNotify 콜백 함수 ─────────────────────────────
    UFUNCTION()
    void OnRep_MatchPhase();

    UFUNCTION()
    void OnRep_WinnerName();

    UFUNCTION()
    void OnRep_TeamScores();

    // ─── 서버에서만 호출하는 데이터 수정 함수 ───────────
    void AddTeamScore(int32 TeamIndex, int32 Points);

    // 반드시 구현해야 하는 복제 등록 함수
    virtual void GetLifetimeReplicatedProps(
        TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};

구현 파일

// MyGameState.cpp
#include "Net/UnrealNetwork.h" // DOREPLIFETIME 사용에 필요

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

    // 기본: 조건 없이 모든 클라이언트에 항상 복제
    DOREPLIFETIME(AMyGameState, MatchStartTime);
    DOREPLIFETIME(AMyGameState, RemainingTime);
    DOREPLIFETIME(AMyGameState, MatchPhase);
    DOREPLIFETIME(AMyGameState, TeamScores);
    DOREPLIFETIME(AMyGameState, WinnerName);

    // 조건부 복제 예시 (성능 최적화 시 사용)
    // DOREPLIFETIME_CONDITION(AMyGameState, SomeVar, COND_OwnerOnly);   // 본인에게만
    // DOREPLIFETIME_CONDITION(AMyGameState, SomeVar, COND_InitialOnly);  // 최초 접속 시 1회만
    // DOREPLIFETIME_CONDITION(AMyGameState, SomeVar, COND_SkipOwner);    // 본인 제외 전송
}

// OnRep 함수는 클라이언트에서 자동 호출됨 (서버에서는 호출 안 됨)

void AMyGameState::OnRep_MatchPhase()
{
    // 블루프린트 위젯에서 이 이벤트를 구독하면 UI 자동 갱신 가능
    OnMatchPhaseChanged.Broadcast(MatchPhase);
    UE_LOG(LogTemp, Log, TEXT("[GameState] MatchPhase: %s"), *MatchPhase);
}

void AMyGameState::OnRep_WinnerName()
{
    if (!WinnerName.IsEmpty())
    {
        OnWinnerDecided.Broadcast(WinnerName);
        // UI에서 이 Delegate를 바인딩하면 승리 화면 자동 표시
    }
}

void AMyGameState::OnRep_TeamScores()
{
    // 점수가 바뀔 때마다 HUD 스코어보드 갱신
    if (APlayerController* PC = GetWorld()->GetFirstPlayerController())
    {
        if (AMyHUD* HUD = PC->GetHUD<AMyHUD>())
        {
            HUD->UpdateScoreboard(TeamScores[0], TeamScores[1]);
        }
    }
}

void AMyGameState::AddTeamScore(int32 TeamIndex, int32 Points)
{
    // HasAuthority()로 서버 여부 체크: 서버가 아니면 무시
    if (!HasAuthority()) return;

    if (TeamScores.IsValidIndex(TeamIndex))
    {
        TeamScores[TeamIndex] += Points;
        // DOREPLIFETIME 등록되어 있으므로 자동으로 클라이언트에 동기화
        // → 클라이언트에서 OnRep_TeamScores() 자동 호출됨
    }
}

⚠️ 주의
OnRep_XXX 함수는 클라이언트에서만 자동 호출됩니다.
서버는 값을 직접 변경하기 때문에 콜백이 없습니다.
서버에서도 같은 동작이 필요하다면 값 변경 직후에 직접 호출해야 합니다.


3. GameInstance — 영속 데이터 관리자

한 줄 요약: 레벨이 바뀌어도 소멸되지 않는 전역 객체입니다.
세이브 데이터, 로그인 정보, 온라인 세션 관리처럼
게임 전체 생명주기에 걸쳐 유지해야 하는 것을 여기에 보관합니다.

헤더 파일

// MyGameInstance.h
#pragma once
#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "OnlineSessionSettings.h"
#include "Interfaces/OnlineSessionInterface.h"
#include "MyGameInstance.generated.h"

UCLASS()
class MYGAME_API UMyGameInstance : public UGameInstance
{
    GENERATED_BODY()

public:
    // ─── 생명주기 함수 ──────────────────────────────────
    virtual void Init() override;       // 게임 최초 실행 시 1회
    virtual void Shutdown() override;   // 게임 종료 직전 1회

    // ─── 레벨 전환에도 유지되는 플레이어 데이터 ─────────
    UPROPERTY(BlueprintReadWrite, Category="Player")
    FString LoggedInPlayerName;

    UPROPERTY(BlueprintReadWrite, Category="Player")
    int32 SelectedCharacterIndex = 0;

    UPROPERTY(BlueprintReadWrite, Category="Progress")
    int32 TotalCurrency = 0;

    UPROPERTY(BlueprintReadWrite, Category="Progress")
    TMap<FString, bool> UnlockedLevels; // 레벨명 → 해금 여부

    // ─── 세이브 / 로드 ─────────────────────────────────
    UFUNCTION(BlueprintCallable, Category="Save")
    bool SaveGameData();

    UFUNCTION(BlueprintCallable, Category="Save")
    bool LoadGameData();

    // ─── 레벨 이동 (저장 후 이동) ───────────────────────
    UFUNCTION(BlueprintCallable, Category="Travel")
    void TravelToLobby();

    UFUNCTION(BlueprintCallable, Category="Travel")
    void TravelToGameLevel(const FString& LevelName, bool bIsHost);

    // ─── 온라인 세션 관리 ───────────────────────────────
    UFUNCTION(BlueprintCallable, Category="Session")
    void CreateSession(int32 MaxPlayers, bool bIsLAN);

    UFUNCTION(BlueprintCallable, Category="Session")
    void FindSessions(bool bIsLAN);

private:
    static const FString SaveSlotName;
    static const int32   SaveUserIndex;

    IOnlineSessionPtr SessionInterface;
    TSharedPtr<FOnlineSessionSearch> SessionSearch;

    void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful);
    void OnFindSessionsComplete(bool bWasSuccessful);
    void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result);
};

구현 파일

// MyGameInstance.cpp
#include "Kismet/GameplayStatics.h"
#include "MySaveGame.h"
#include "OnlineSubsystem.h"

const FString UMyGameInstance::SaveSlotName  = TEXT("MySaveSlot");
const int32   UMyGameInstance::SaveUserIndex = 0;

void UMyGameInstance::Init()
{
    Super::Init();

    // 온라인 서브시스템 초기화 (Steam, EOS 등)
    IOnlineSubsystem* OSS = IOnlineSubsystem::Get();
    if (OSS)
    {
        SessionInterface = OSS->GetSessionInterface();
        SessionInterface->OnCreateSessionCompleteDelegates.AddUObject(
            this, &UMyGameInstance::OnCreateSessionComplete);
        SessionInterface->OnFindSessionsCompleteDelegates.AddUObject(
            this, &UMyGameInstance::OnFindSessionsComplete);
    }

    // 자동 로드: 게임 시작 시 저장 데이터 복구
    LoadGameData();
}

void UMyGameInstance::Shutdown()
{
    if (SessionInterface.IsValid())
    {
        SessionInterface->DestroySession(NAME_GameSession);
    }
    Super::Shutdown();
}

bool UMyGameInstance::SaveGameData()
{
    UMySaveGame* SaveGame = Cast<UMySaveGame>(
        UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));
    if (!SaveGame) return false;

    SaveGame->PlayerName     = LoggedInPlayerName;
    SaveGame->CharacterIndex = SelectedCharacterIndex;
    SaveGame->Currency       = TotalCurrency;
    SaveGame->UnlockedLevels = UnlockedLevels;

    bool bSuccess = UGameplayStatics::SaveGameToSlot(
        SaveGame, SaveSlotName, SaveUserIndex);

    UE_LOG(LogTemp, Log, TEXT("[GameInstance] Save %s"),
        bSuccess ? TEXT("succeeded") : TEXT("FAILED"));
    return bSuccess;
}

bool UMyGameInstance::LoadGameData()
{
    if (!UGameplayStatics::DoesSaveGameExist(SaveSlotName, SaveUserIndex))
    {
        UE_LOG(LogTemp, Warning, TEXT("[GameInstance] No save file. Using defaults."));
        return false;
    }

    UMySaveGame* SaveGame = Cast<UMySaveGame>(
        UGameplayStatics::LoadGameFromSlot(SaveSlotName, SaveUserIndex));
    if (!SaveGame) return false;

    LoggedInPlayerName     = SaveGame->PlayerName;
    SelectedCharacterIndex = SaveGame->CharacterIndex;
    TotalCurrency          = SaveGame->Currency;
    UnlockedLevels         = SaveGame->UnlockedLevels;

    UE_LOG(LogTemp, Log, TEXT("[GameInstance] Loaded. Player: %s, Currency: %d"),
        *LoggedInPlayerName, TotalCurrency);
    return true;
}

void UMyGameInstance::TravelToGameLevel(const FString& LevelName, bool bIsHost)
{
    SaveGameData(); // 이동 전 반드시 저장

    if (bIsHost)
    {
        // "?listen" 붙이면 이 클라이언트가 리슨 서버가 됨
        GetWorld()->ServerTravel(LevelName + TEXT("?listen"));
    }
    else
    {
        APlayerController* PC = GetFirstLocalPlayerController();
        if (PC)
        {
            PC->ClientTravel(LevelName, ETravelType::TRAVEL_Absolute);
        }
    }
}

void UMyGameInstance::CreateSession(int32 MaxPlayers, bool bIsLAN)
{
    if (!SessionInterface.IsValid()) return;

    // 기존 세션 있으면 먼저 삭제
    FNamedOnlineSession* Existing =
        SessionInterface->GetNamedSession(NAME_GameSession);
    if (Existing) SessionInterface->DestroySession(NAME_GameSession);

    FOnlineSessionSettings Settings;
    Settings.bIsLANMatch          = bIsLAN;
    Settings.NumPublicConnections = MaxPlayers;
    Settings.bShouldAdvertise     = true;
    Settings.bUsesPresence        = true;
    Settings.Set(FName(TEXT("SERVER_NAME")),
                 LoggedInPlayerName + TEXT("'s Game"),
                 EOnlineDataAdvertisementType::ViaOnlineServiceAndPing);

    SessionInterface->CreateSession(0, NAME_GameSession, Settings);
}

4. 어디서 어떻게 접근하는가

void AMyCharacter::SomeFunction()
{
    // ─── GameMode: 서버에서만 유효, 클라이언트에서는 nullptr ───
    AMyGameMode* GM = GetWorld()->GetAuthGameMode<AMyGameMode>();
    if (GM) // 반드시 nullptr 체크!
    {
        GM->OnPlayerKilled(this->GetController<APlayerController>(), nullptr);
    }

    // ─── GameState: 서버/클라이언트 모두에서 유효 ─────────────
    AMyGameState* GS = GetWorld()->GetGameState<AMyGameState>();
    if (GS)
    {
        float Remaining = GS->RemainingTime;
        FString Phase   = GS->MatchPhase;
    }

    // ─── GameInstance: 항상 유효, 레벨 전환과 무관 ────────────
    UMyGameInstance* GI = GetGameInstance<UMyGameInstance>();
    if (GI)
    {
        FString PlayerName = GI->LoggedInPlayerName;
        GI->SaveGameData();
    }
}

// GameInstance는 UObject라면 어디서든 접근 가능 (Actor 아니어도 됨)
void UMyWidget::OnSaveButtonClicked()
{
    UMyGameInstance* GI = Cast<UMyGameInstance>(
        UGameplayStatics::GetGameInstance(this));
    if (GI) GI->SaveGameData();
}

5. 세 클래스가 협력하는 전체 흐름

실제 멀티플레이어 게임에서 킬 → 승리 판정 → 레벨 이동이 어떻게 이루어지는지 전체 흐름입니다.

1. [클라이언트] 캐릭터가 적을 처치
      │
      ▼
2. [클라이언트 → 서버] Pawn에서 ServerRPC 호출
      │
      ▼
3. [서버] GameMode.OnPlayerKilled() 호출
      │   - Victim 리스폰 타이머 시작
      │   - Killer의 PlayerState.KillCount++
      ▼
4. [서버] GameMode.CheckVictoryCondition() 판정
      │   KillCount >= KillsToWin → 승리 확정
      ▼
5. [서버] GameState.WinnerName = Killer 이름
      │
      │   ← DOREPLIFETIME 자동 복제 (모든 클라이언트)
      ▼
6. [클라이언트] OnRep_WinnerName() 자동 호출
      │   → UI Widget 승리 화면 표시
      ▼
7. [서버] EndMatch() → HandleMatchHasEnded()
      │   5초 후 ServerTravel() 호출
      ▼
8. 레벨 이동
      │   GameMode, GameState → 소멸 후 새 레벨에서 재생성
      │   GameInstance → 레벨 전환과 무관하게 유지
      ▼
9. [GameInstance] LoggedInPlayerName, TotalCurrency 등 데이터 보존

6. 정리 — 무엇을 어디에 넣을까

시나리오 어디에? 이유
플레이어 리스폰 규칙 GameMode 서버만 판정하면 됨
현재 남은 시간 UI 표시 GameState 모든 클라이언트가 읽어야 함
팀 점수 스코어보드 GameState 복제되어 자동 동기화
로그인한 유저 이름 GameInstance 레벨 바뀌어도 유지해야 함
획득한 재화 / 아이템 GameInstance + SaveGame 재시작에도 유지
온라인 세션 생성/탐색 GameInstance 레벨 독립적인 전역 처리
그래픽/사운드 설정 GameInstance 게임 전체에 걸쳐 유지

설계 원칙 3줄 요약
"서버만 알면 되는가?" → GameMode
"모든 클라이언트가 알아야 하는가?" → GameState
"레벨이 바뀌어도 기억해야 하는가?" → GameInstance