언리얼 엔진 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
'학습일지 > 언리얼' 카테고리의 다른 글
| 차량 AI 카메라 무빙 + 사운드 구현기 (0) | 2026.05.19 |
|---|---|
| 차량 AI 스플라인 추종 + 브레이크 등 + 헤드라이트 + 사이드미러 구현 (0) | 2026.05.18 |
| Unreal - Pawn 클래스 과제 (0) | 2026.04.14 |
| Unreal - UBT, UHT, CDO, 리플렉션 (0) | 2026.04.10 |
| Unreal - Actor 이동, 회전 등 (1) | 2026.04.09 |