언리얼 서버 권한 숫자 야구
멀티플레이 채팅에 숫자 야구 규칙을 결합하면 메시지를 전달하는 것만으로는 충분하지 않다. 서버가 비밀 숫자를 관리하고, 클라이언트의 입력을 검증하고, 판정 결과와 플레이어별 시도 횟수를 모든 참가자에게 일관되게 보여줘야 한다.
이때 핵심은 게임 규칙과 상태의 소유자를 명확히 분리하는 것이다.
- 비밀 숫자와 승패 판정은 서버 전용
GameMode가 소유한다. - 플레이어 이름과 시도 횟수는
PlayerState에 저장해 복제한다. - 로컬 UI 입력은
PlayerController가 받아 Server RPC로 전달한다. - 판정 결과처럼 즉시 보여줄 사건은 Client RPC로 방송한다.
- 승리·무승부 공지는 각
PlayerController의 replicated property로 전달한다.
기본적인 RPC와 Property Replication의 차이는 이전 글에서 다뤘다. 이번 글에서는 이 요소들이 하나의 서버 권한 게임 루프로 연결되는 과정을 정리한다.
클래스별 책임을 먼저 나누기
언리얼의 네트워크 프레임워크 클래스는 존재 범위와 복제 특성이 서로 다르다. 기능을 어느 클래스에 둘지는 “그 기능을 어느 컴퓨터가 알아야 하는가?”로 결정할 수 있다.
| 클래스 | 존재 범위 | 숫자 야구에서의 책임 |
GameMode |
서버에만 존재 | 비밀 숫자 생성, 입력 검증, S/B 판정, 승패·무승부·리셋 |
GameState |
서버와 모든 클라이언트 | 모든 참가자가 공유하는 접속 이벤트 전달 |
PlayerState |
서버와 모든 클라이언트 | 플레이어 이름, 현재 시도 횟수, 최대 시도 횟수 |
PlayerController |
서버와 해당 소유 클라이언트 | 로컬 입력, Server RPC 진입점, 소유 클라이언트 전용 공지 |
UserWidget |
로컬 클라이언트 | 입력 수집과 복제된 값의 화면 표현 |
비밀 숫자를 PlayerController나 PlayerState에 저장해 복제하면 정답이 클라이언트에 노출될 수 있다. 서버에만 존재하는 GameMode는 클라이언트가 직접 접근할 수 없으므로 비밀 정보와 최종 판정을 보관하기에 적합하다.
반대로 플레이어 이름과 시도 횟수는 모든 참가자가 알아야 한다. 서버에만 존재하는 GameMode 멤버로 두면 클라이언트가 읽을 수 없으므로, 모든 컴퓨터에 복제되는 PlayerState가 자연스러운 저장 위치다.
전체 실행 흐름
현재 프로젝트는 채팅 입력의 마지막 세 글자를 숫자 야구 입력으로 해석한다. 유효한 추측이면 서버가 판정하고, 일반 문자열이면 기존 채팅처럼 방송한다.
sequenceDiagram
participant UI as "로컬 입력 UI"
participant CPC as "클라이언트 PlayerController"
participant SPC as "서버 PlayerController"
participant GM as "서버 GameMode"
participant PS as "PlayerState"
participant Clients as "각 소유 클라이언트"
UI->>CPC: 문자열 입력
CPC->>SPC: Server RPC
SPC->>GM: 입력 처리 요청
GM->>GM: 마지막 3자리 검증
alt 유효한 추측
GM->>GM: Strike / Ball 판정
GM->>PS: CurrentGuessCount 증가
GM->>Clients: Client RPC로 판정 결과 방송
GM->>GM: 승리 또는 무승부 검사
PS-->>Clients: 변경된 시도 횟수 복제
else 일반 채팅
GM->>Clients: Client RPC로 원문 방송
end
여기서 중요한 점은 클라이언트가 입력을 만들지만 결과는 서버가 결정한다는 것이다. 클라이언트는 비밀 숫자를 알지 못하고, 판정 결과나 남은 시도 횟수를 직접 확정하지 않는다.
서버에서만 비밀 숫자 생성하기
게임이 시작되면 서버의 GameMode::BeginPlay()가 비밀 숫자를 생성한다.
void ANBGameModeBase::BeginPlay()
{
Super::BeginPlay();
SecretNumberString = GenerateSecretNumber();
}
비밀 숫자 생성 로직은 1부터 9까지의 후보 중 하나를 고른 뒤 배열에서 제거한다. 같은 숫자를 다시 고를 수 없으므로 서로 다른 세 자리 숫자가 만들어진다.
FString ANBGameModeBase::GenerateSecretNumber()
{
TArray<int32> Numbers;
for (int32 Number = 1; Number <= 9; ++Number)
{
Numbers.Add(Number);
}
FString Result;
for (int32 Index = 0; Index < 3; ++Index)
{
const int32 RandomIndex =
FMath::RandRange(0, Numbers.Num() - 1);
Result.Append(FString::FromInt(Numbers[RandomIndex]));
Numbers.RemoveAt(RandomIndex);
}
return Result;
}
SecretNumberString은 replicated property가 아니다. 복제할 이유가 없을 뿐 아니라 클라이언트에 전달하면 안 되는 서버 전용 상태다. 클라이언트가 알아야 하는 것은 정답 자체가 아니라 서버가 계산한 1S1B, OUT 같은 결과다.
문자열 입력을 게임 명령으로 검증하기
서버는 채팅 전체 문자열에서 마지막 세 글자를 추출한다.
const int32 Index = InChatMessageString.Len() - 3;
const FString GuessNumberString =
InChatMessageString.RightChop(Index);
숫자 야구 입력으로 인정하려면 다음 불변 조건이 필요하다.
- 길이가 정확히 3이어야 한다.
- 모든 문자가 숫자여야 한다.
- 0이 없어야 한다.
- 세 숫자가 서로 달라야 한다.
TSet을 사용하면 중복 여부를 간단히 판정할 수 있다.
bool ANBGameModeBase::IsGuessNumberString(
const FString& InNumberString)
{
if (InNumberString.Len() != 3)
{
return false;
}
TSet<TCHAR> UniqueDigits;
for (const TCHAR Digit : InNumberString)
{
if (!FChar::IsDigit(Digit) || Digit == TEXT('0'))
{
return false;
}
UniqueDigits.Add(Digit);
}
return UniqueDigits.Num() == 3;
}
현재 프로젝트 코드도 TSet에 문자를 추가하지만 집합의 크기를 마지막에 검사하지 않는다. 따라서 112처럼 중복 숫자가 포함된 문자열도 유효한 추측으로 통과할 수 있다. UniqueDigits.Num() == 3은 단순한 최적화가 아니라 게임 규칙을 완성하는 필수 조건이다.
검증은 반드시 서버에서 다시 수행해야 한다. 정상 클라이언트의 UI가 입력을 제한하더라도 변조된 클라이언트는 Server RPC를 직접 호출할 수 있기 때문이다. 클라이언트 검증은 사용자 경험을 위한 사전 검사이고, 서버 검증은 게임 규칙을 지키는 최종 경계다.
Strike와 Ball 판정
검증된 세 자리 문자열은 서버가 정답과 비교한다.
FString ANBGameModeBase::JudgeResult(
const FString& InSecretNumberString,
const FString& InGuessNumberString)
{
int32 StrikeCount = 0;
int32 BallCount = 0;
for (int32 Index = 0; Index < 3; ++Index)
{
if (InSecretNumberString[Index] ==
InGuessNumberString[Index])
{
++StrikeCount;
}
else
{
const FString GuessDigit = FString::Printf(
TEXT("%c"),
InGuessNumberString[Index]);
if (InSecretNumberString.Contains(GuessDigit))
{
++BallCount;
}
}
}
if (StrikeCount == 0 && BallCount == 0)
{
return TEXT("OUT");
}
return FString::Printf(
TEXT("%dS%dB"),
StrikeCount,
BallCount);
}
같은 위치의 숫자가 같으면 Strike다. 위치는 다르지만 정답에 포함된 숫자라면 Ball이다. 입력과 정답 모두 서로 다른 숫자라는 불변 조건을 만족하므로, 하나의 입력 숫자가 여러 번 Ball로 계산되는 문제도 없다.
이 함수는 문자열을 반환하지만 게임 로직 내부에서는 구조체를 사용하는 방법도 있다.
struct FNumberBaseballResult
{
int32 StrikeCount = 0;
int32 BallCount = 0;
bool IsOut() const
{
return StrikeCount == 0 && BallCount == 0;
}
bool IsWin() const
{
return StrikeCount == 3;
}
};
판정 데이터와 화면 표시 문자열을 분리하면 승리 여부를 확인하기 위해 "3S0B"의 첫 글자를 다시 파싱할 필요가 없다. 게임 규칙은 정수 필드로 처리하고, 사용자에게 전송할 때만 문자열로 변환할 수 있다.
PlayerState로 플레이어별 상태 복제하기
서버는 플레이어가 접속한 순서에 따라 이름을 지정한다.
ANBPlayerState* PlayerState =
PlayerController->GetPlayerState<ANBPlayerState>();
if (IsValid(PlayerState))
{
PlayerState->PlayerNameString =
TEXT("Player") +
FString::FromInt(AllPlayerControllers.Num());
}
이 값이 일반 FString 멤버라면 서버에서 변경되어도 클라이언트 복제본에는 전달되지 않는다. 프로젝트에서는 PlayerNameString, CurrentGuessCount, MaxGuessCount를 replicated property로 등록했다.
UCLASS()
class NUMBERBASEBALL_API ANBPlayerState : public APlayerState
{
GENERATED_BODY()
public:
ANBPlayerState();
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const override;
UPROPERTY(Replicated)
FString PlayerNameString;
UPROPERTY(Replicated)
int32 CurrentGuessCount;
UPROPERTY(Replicated)
int32 MaxGuessCount;
};
#include "Net/UnrealNetwork.h"
ANBPlayerState::ANBPlayerState()
: PlayerNameString(TEXT("None"))
, CurrentGuessCount(0)
, MaxGuessCount(3)
{
bReplicates = true;
}
void ANBPlayerState::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ThisClass, PlayerNameString);
DOREPLIFETIME(ThisClass, CurrentGuessCount);
DOREPLIFETIME(ThisClass, MaxGuessCount);
}
APlayerState는 원래 네트워크 게임의 플레이어별 공용 상태를 위해 설계된 프레임워크 액터다. 각 클라이언트는 다른 플레이어의 PlayerController를 갖지 않지만, PlayerState는 모든 참가자에게 복제된다. 점수판이나 플레이어 목록처럼 서로의 정보를 읽어야 하는 기능에 적합한 이유다.
최대 시도 횟수도 복제해야 하는가
MaxGuessCount가 항상 3이고 모든 빌드에 동일한 상수라면 각 클라이언트가 같은 기본값을 갖게 할 수도 있다. 이 경우 네트워크로 매번 복제할 실익은 작다.
반대로 다음 조건에서는 서버의 값을 복제하는 편이 안전하다.
- 게임 모드나 난이도에 따라 최대 횟수가 달라진다.
- 서버 운영 설정으로 런타임에 규칙을 바꿀 수 있다.
- 클라이언트 UI가 서버가 확정한 현재 규칙을 표시해야 한다.
- 라운드 도중 특정 효과로 최대 횟수가 변경될 수 있다.
즉, “값이 상수인가?”보다 서버가 이 규칙의 유일한 소유자인가, 런타임에 달라질 수 있는가?가 결정 기준이다.
복제에는 도착 시간이 존재한다
서버가 PlayerNameString을 변경했다고 해서 같은 프레임에 클라이언트가 즉시 새 값을 읽을 수 있는 것은 아니다. 프로퍼티 복제는 네트워크 업데이트를 거쳐 비동기적으로 도착한다.
값 도착에 맞춰 UI를 갱신해야 한다면 ReplicatedUsing과 OnRep 함수를 사용할 수 있다.
UPROPERTY(ReplicatedUsing = OnRep_PlayerInfo)
FString PlayerNameString;
UFUNCTION()
void OnRep_PlayerInfo();
OnRep_PlayerInfo()에서 위젯을 갱신하면 Tick에서 값을 계속 확인하거나 복제 타이밍을 추측할 필요가 없다.
시도 횟수는 서버가 증가시킨다
유효한 추측이 들어오면 GameMode가 요청한 플레이어의 PlayerState를 찾아 시도 횟수를 증가시킨다.
void ANBGameModeBase::IncreaseGuessCount(
ANBPlayerController* InChattingPlayerController)
{
ANBPlayerState* PlayerState =
InChattingPlayerController
->GetPlayerState<ANBPlayerState>();
if (IsValid(PlayerState))
{
++PlayerState->CurrentGuessCount;
}
}
변경 주체는 서버 Authority다. 클라이언트는 CurrentGuessCount를 직접 올리지 않는다. 서버에서 값이 변경되면 Property Replication이 각 클라이언트의 PlayerState 복제본을 갱신한다.
입력을 받기 전에는 다음 조건도 서버가 확인해야 한다.
const bool bCanGuess =
PlayerState->CurrentGuessCount <
PlayerState->MaxGuessCount;
시도 횟수를 화면에만 표시하고 서버 입력을 계속 허용하면 제한은 실제 게임 규칙이 아니라 장식에 불과하다. 서버는 이미 최대 횟수에 도달한 플레이어의 추가 추측을 거부해야 한다.
PlayerController 프로퍼티로 소유자 전용 공지 전달하기
프로젝트의 NotificationText는 PlayerController에 선언되어 있다.
UPROPERTY(Replicated, BlueprintReadOnly)
FText NotificationText;
void ANBPlayerController::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ThisClass, NotificationText);
}
PlayerController는 서버와 해당 컨트롤러를 소유한 클라이언트에만 존재한다. 서버가 각 플레이어의 서버 측 PlayerController::NotificationText를 변경하면, 그 값은 자연스럽게 해당 소유 클라이언트의 컨트롤러로 전달된다.
이 특성은 두 저장 위치의 차이를 선명하게 보여준다.
| 저장 위치 | 복제 대상 | 적합한 데이터 |
|---|---|---|
PlayerState |
모든 클라이언트 | 이름, 점수, 공개된 시도 횟수 |
PlayerController |
소유 클라이언트 | 개인 공지, 개인 UI 상태, 소유자 전용 정보 |
현재 위젯이 Blueprint 바인딩으로 NotificationText를 읽는다면 Replicated만으로도 표시할 수 있다. C++에서 값이 도착하는 순간 명시적으로 UI를 갱신하려면 ReplicatedUsing = OnRep_NotificationText가 더 직접적인 방식이다.
승리, 무승부, 리셋을 하나의 상태 전이로 보기
유효한 입력을 판정하고 시도 횟수를 증가시킨 다음 서버는 게임 종료 조건을 검사한다.
- Strike가 3이면 입력한 플레이어가 승리한다.
- 승자가 없고 모든 플레이어가 최대 시도 횟수에 도달하면 무승부다.
- 승리 또는 무승부가 확정되면 새 비밀 숫자를 만들고 모든 시도 횟수를 0으로 되돌린다.
stateDiagram-v2
[*] --> Playing: 비밀 숫자 생성
Playing --> Won: Strike == 3
Playing --> Draw: 모든 플레이어가 시도 소진
Playing --> Playing: 아직 종료 조건 미충족
Won --> Playing: 공지 후 ResetGame
Draw --> Playing: 공지 후 ResetGame
리셋은 비밀 숫자와 모든 플레이어의 시도 횟수를 함께 변경한다.
void ANBGameModeBase::ResetGame()
{
SecretNumberString = GenerateSecretNumber();
for (ANBPlayerController* PlayerController
: AllPlayerControllers)
{
if (!IsValid(PlayerController))
{
continue;
}
ANBPlayerState* PlayerState =
PlayerController
->GetPlayerState<ANBPlayerState>();
if (IsValid(PlayerState))
{
PlayerState->CurrentGuessCount = 0;
}
}
}
이 함수는 서버에서만 실행된다. SecretNumberString은 서버에만 남고, 초기화된 CurrentGuessCount는 PlayerState의 Property Replication을 통해 클라이언트에 전달된다.
방송 횟수와 게임 판정 횟수를 분리하기
결과 문자열은 모든 플레이어에게 한 번씩 보내야 하지만, 승패 판정은 입력 한 건당 한 번만 실행해야 한다.
for (TActorIterator<ANBPlayerController> It(GetWorld());
It;
++It)
{
if (ANBPlayerController* PlayerController = *It)
{
PlayerController->ClientRPCPrintChatMessageString(
CombinedMessageString);
}
}
JudgeGame(InChattingPlayerController, StrikeCount);
현재 프로젝트에서는 JudgeGame() 호출이 PlayerController 방송 루프 안에 있다. 접속자가 두 명이면 같은 입력에 대한 승패 판정도 두 번 실행되고, 승리나 무승부일 때 ResetGame()이 반복 호출될 수 있다.
같은 원칙으로 승리·무승부 공지를 각 컨트롤러에 설정하는 루프에서도 ResetGame()은 루프가 끝난 뒤 한 번만 호출해야 한다.
for (ANBPlayerController* PlayerController
: AllPlayerControllers)
{
if (IsValid(PlayerController))
{
PlayerController->NotificationText =
ResultNotification;
}
}
ResetGame();
전송 대상 수에 따라 게임 상태 전이 횟수가 달라지지 않게 만드는 것이 핵심이다. 방송은 N번이어도 판정과 리셋은 한 번이어야 한다.
RPC와 Property Replication의 역할 분리
숫자 야구의 한 라운드에는 RPC와 Property Replication이 함께 사용된다.
| 데이터 또는 동작 | 방식 | 이유 |
| 클라이언트의 문자열 입력 | Server RPC | 클라이언트의 의도를 서버에 전달 |
| 판정 결과 채팅 | Client RPC | 현재 접속자에게 즉시 보여줄 일회성 사건 |
| 플레이어 이름 | PlayerState Property Replication | 모든 참가자가 계속 알아야 하는 공개 상태 |
| 현재·최대 시도 횟수 | PlayerState Property Replication | 현재 게임 진행 상태 |
| 승리·무승부 공지 | PlayerController Property Replication | 소유자 UI가 읽을 현재 공지 상태 |
| 비밀 숫자 | 복제하지 않음 | 서버만 알아야 하는 권한 상태 |
이 구조에서 RPC는 요청과 사건의 이동, Property Replication은 현재 상태의 수렴을 담당한다. 클라이언트가 잠시 패킷을 놓치더라도 다음 프로퍼티 업데이트에서 현재 시도 횟수로 수렴할 수 있고, 늦게 들어온 클라이언트도 복제된 현재 플레이어 상태를 받을 수 있다.
서버 권한을 끝까지 유지하는 입력 프로토콜
현재 PlayerController::SetChatMessageString()은 클라이언트의 PlayerState에서 이름과 시도 횟수를 읽어 표시 문자열을 만든 뒤 Server RPC로 전송한다.
const FString CombinedMessageString =
PlayerState->GetPlayerInfoString() +
TEXT(": ") +
InChatMessageString;
ServerRPCPrintChatMessageString(CombinedMessageString);
학습용 흐름은 확인할 수 있지만, 서버 권한 관점에서는 클라이언트가 플레이어 이름과 횟수가 포함된 문자열을 조작할 수 있다. 서버는 이미 RPC를 호출한 PlayerController를 알고 있으므로 클라이언트가 보낸 표시용 접두사를 신뢰할 필요가 없다.
더 단단한 프로토콜은 클라이언트가 원본 입력만 보내고 서버가 표시 문자열을 조립하는 방식이다.
// Client -> Server
ServerRPCSubmitInput(RawInput);
// Server
ANBPlayerState* PlayerState =
GetPlayerState<ANBPlayerState>();
const FString DisplayMessage =
PlayerState->GetPlayerInfoString() +
TEXT(": ") +
RawInput;
서버가 입력을 받을 때 함께 확인해야 할 항목은 다음과 같다.
- RPC 호출자가 해당
PlayerController의 실제 소유자인가 - 입력 길이가 서버가 허용하는 최대값 이내인가
- 숫자 입력이 세 자리, 1~9, 중복 없음 조건을 만족하는가
- 해당 플레이어에게 시도 횟수가 남아 있는가
- 너무 짧은 간격으로 반복 호출하고 있지 않은가
Reliable RPC는 전달 신뢰성을 높일 뿐 입력의 진위를 보장하지 않는다. 게임 규칙을 지키는 검증은 항상 서버 로직의 책임이다.
데이터 소유권으로 정리한 구조
숫자 야구 게임의 네트워크 구조는 데이터마다 하나의 권한 소유자를 두는 방식으로 정리할 수 있다.
GameMode (Server Only)
├─ SecretNumberString
├─ 입력 유효성 검사
├─ Strike / Ball 계산
├─ 승리 / 무승부 판정
└─ 라운드 리셋
PlayerState (Server Authority -> All Clients)
├─ PlayerNameString
├─ CurrentGuessCount
└─ MaxGuessCount
PlayerController (Server <-> Owning Client)
├─ Server RPC 입력 경로
├─ Client RPC 메시지 경로
└─ NotificationText
서버 전용 정보는 GameMode에 남기고, 공개된 플레이어 상태는 PlayerState로 복제하며, 소유자와의 통신은 PlayerController를 통한다. 이 경계를 유지하면 클라이언트가 입력을 보내더라도 최종 판정과 상태 변경은 항상 서버에서 일어나고, 각 클라이언트는 서버가 확정한 결과만 표현하게 된다.
'UE > 멀티플레이' 카테고리의 다른 글
| UE C++ 멀티플레이 동기화 (0) | 2026.08.07 |
|---|---|
| UE C++ RPC오너십, 프로퍼티 리플리케이션 (1) | 2026.08.06 |
| UE C++ 액터 리플리케이션 (0) | 2026.08.05 |
| UE C++ 멀티플레이 게임 프레임워크 (1) | 2026.08.04 |
| [Unreal 9기] 2026-07-31 UE 넷모드, 넷드라이브, 리플리케이션 (1) | 2026.07.31 |