UCLASS()
class SAMPLECHAT_API AYMPlayerState : public APlayerState
{
GENERATED_BODY()
public:
AYMPlayerState();
// ActorReplication 사용하려고 했지만 GetLifetimeReplicatedProps() 구현이 필요함 (DOREPLIFETIME() 추가해야 함).
UPROPERTY(Replicated, VisibleAnywhere, BlueprintReadOnly, Category = "Game")
int32 RemainingAttempts; // 정답을 맞출 남은 기회
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
리플리케이션 개념을 더 알고 싶어서 코드에 직접 적용해보며 어떤 용도로 쓰면 좋을지 파악하려 했다.
하지만 그냥 UPROPERTY에 Replicated를 적는 것만으로는 빌드를 할때 오류가 난다. 저기 LNK2001 오류 이름은 링크처리가 되어 있다. 그걸 타고 들어가 확인할 수 없는 외부 기호가 뭔지 해석했다.
말이 어렵지만 기호 = 함수 OR 변수라고 하니까 어떤 함수나 변수가 선언만 되고 그 함수나 변수가 소스파일에 정의(구현)는 안되었나 보다.
그리고 다시 올라가서 외부기호의 이름을 보니까 GetLifetimeReplicatedProps라는 이름이 보여서 그게 뭔지 gpt에게 물어보았다.
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
위 함수는 언리얼에서 리플리케이션(Replicate) 할 변수들을 등록하는 함수다.
설명을 듣고 리플리케이션을 할 것이라면 필수적으로 구현해줘야 하는 함수라는 걸 알게되었다.
void AYMPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AYMPlayerState, RemainingAttempts);
DOREPLIFETIME(AYMPlayerState, UserId);
}
그래서 이렇게 구현하던 중 또 오류가 떴는데
#include "Net/UnrealNetwork.h" // 네트워크 복제 필수
위 헤더 파일이 소스 파일에 포함되어 있어야 DOREPLIFETIME이란 매크로를 쓸 수 있다.
DOREPLIFETIME
매크로는 GetLifetimeReplicatedProps
함수 안에서 리플리케이션 대상 변수를 등록할 때 사용된다.
이 매크로의 이름은 DO REPlicate LIFE TIME의 줄임말로,
“해당 액터(객체)가 살아있는 동안 이 변수를 리플리케이션하라”는 의미이다.
즉, 이 매크로를 통해 해당 객체가 존재하는 동안 계속해서 클라이언트와 서버 간에 동기화(Replicate)할 변수를 등록하게 되는 것이다.
위의 예시에서는 해당 객체가 AYMPlayerState가 된다.
숫자야구 게임을 만들 때 플레이어 입력 숫자와 컴퓨터가 뽑은 랜덤 숫자가 일치하는지 아닌지 "1S 2B" << 같이 결과를 판단해 문자열을 반환하는 함수가 필요했다.
그런데 플레이어가 숫자를 맞추지 못하면 해당 플레이어의 기회만 깎여야 하는데 자꾸 다른 플레이어의 기회까지 같이 줄어드는 문제가 생겼다.
예를 들어, 게임을 2명이서 Host와 Guest가 하고 있다고 가정하자.
각 플레이어당 정답 숫자를 맞출 기회는 3번 있다.
Host쪽에서 먼저 정답 숫자가 "123"인데 "456"이라고 입력을 하면
Host쪽의 맞출 기회가 1 감소해야 한다.
하지만 Host와 Guest 양쪽의 맞출 기회가 1 감소해버리는 문제가 생겼다.
이것을 해결하기 위해 각 클라이언트의 플레이어 컨트롤러를 가져오는 방법을 생각해야 했다.
왜 그런 방법을 생각해야 했을까?
처음에는 GetWorld()->GetFirstPlayerController()를 사용해 플레이어 컨트롤러에 접근했지만, 이 방식은 GameMode에서 위 코드가 작성된다면 서버에서 무조건 1번째 플레이어 컨트롤러만 접근하기에 멀티플레이어 환경에서 Host 또는 Guest 중 한 쪽만 정보가 갱신되는 문제가 발생했다. 이 문제를 해결하기 위해, 각 클라이언트의 플레이어 컨트롤러에 직접 접근하는 방식으로 로직을 변경해야 했다.
그래서 처음엔 방법을 생각하다 GameMode에서 모든 컨트롤러를 가져와서 그 중 입력을 한 플레이어의 컨트롤러만 쓰는 방식을 생각했는데,
매개변수로 각 플레이어 컨트롤러를 넘겨주면 되는 것이었다.
이해를 위해 밑에 설명을 덧붙였다.
FString AYMGameMode::JudgeGuessNum(APlayerController* PC, const FString& GuessMsg)
{
// 여기도 GuessNum을 못맞출시 기회가 그 컨트롤러에만 있는 기회가 날라가야 해서 매개변수로 추가..
if (!PC) return "";
// 1. 현재 컨트롤러의 플레이어, 게임 스테이트 설정
AYMGameState* YMGameState = Cast<AYMGameState>(GetWorld()->GetGameState());
AYMPlayerState* CurrentPlayerState = PC->GetPlayerState<AYMPlayerState>();
그래서 이렇게 JudgeGuessNum
함수에 플레이어 컨트롤러를 인자로 받아서
해당 플레이어만의 컨트롤러의 플레이어 스테이트에 있는 맞출 기회 정보를 업데이트하거나 가져올 수 있게 되었다!
그럼 플레이어 컨트롤러는 어디서 인자로 넘겨주길래 각 클라이언트의 플레이어 컨트롤러가 인자로 넘어가는 것일까?
void AYMGameMode::Server_GotMessageFromClient_Implementation(APlayerController* PC, const FString& GuessMsg, const FString& UserID)
{
JudgeResult = JudgeGuessNum(PC, GuessMsg);
먼저 JudgeGuessNum의 인자는 Server_GotMessageFromClient
라는 함수에서 인자로 받은 컨트롤러의 값을 받아 사용했다. 그렇다면 Server_GotMessageFromClient
의 인자는 어디서 받는 것일까?
여기 플레이어 컨트롤러 블루프린트에서 플레이어가 메시지를 입력하면 저 빨간 노드가 실행된다. 그리고 C++의 또다른 서버RPC도 호출한다.
사실 서버RPC -> 서버RPC로 중복호출할 이유가 없다고 생각하는데 블루프린트->C++코드로 변환하는 과정이 어려워서 일단 임시로 이렇게 했다.
어쨌든, 플레이어 컨트롤러의 블루프린트 BeginPlay()부터 흐름을 보면..
아래에서 부터 BeginPlay()랑 연결된 부분이다.
위 사진이 Chat Window Widget 블루프린트 내부의 노드들인데 위에서 부터 쭉 보면 어떤 흐름인지 파악이 가능하다.
해당 플레이어의 Chat Window Widget->Call Set message to User Controller로
엔터를 누르면 저 이벤트 디스패쳐가 호출되도록 한다.
각 클라이언트는 자신만의 PlayerController를 갖고 있고,
BeginPlay 시점에 해당 컨트롤러는 자신만의 UI 인스턴스를 생성하고 바인딩을 설정한다.
따라서 어떤 플레이어가 채팅 입력 후 엔터를 누르면,
그 UI 인스턴스에서 바인딩된 해당 로컬 컨트롤러의 서버 RPC가 호출된다.
그런데 여기서..
✨언리얼에서 서버 RPC는 반드시 서버에서 실행되어야 하기 때문에,
호출한 클라이언트의 컨트롤러가 직접 실행하는 것이 아니라,
언리얼은 서버에 존재하는 해당 클라이언트의 PlayerController 인스턴스를 찾아,
그 인스턴스에서 서버 RPC를 실행하게 된다.
이 구조 덕분에 정확하게 클라이언트 → 서버로 향하는 입력 흐름이 자연스럽게 완성된다.
void AYMPlayerController::Server_OnSendPlayerControllerToServer_Implementation(const FString& Msg, const FString& UserID)
{
AYMGameMode* GM = Cast<AYMGameMode>(GetWorld()->GetAuthGameMode());
if (GM)
{
// 현재 입력한 플레이어의 ID, 메시지, 컨트롤러를 서버에 전달
GM->Server_GotMessageFromClient(this, Msg, UserID);
}
}
위 코드에서의 this는 서버에 존재하는 해당 서버 RPC를 호출한 클라이언트의 컨트롤러 인스턴스를 뜻한다.
Server_GotMessageFromClient
의 인자는 어디서 받는 것일까?다시 이 질문에 대답을 해보면 위 함수에서 this로 넘겨준다.
서버에 존재하는 해당 서버 RPC를 호출한 클라이언트의 컨트롤러 인스턴스를 뜻하기에
인자로 받은 PlayerController에서 플레이어 스테이트 값을 변경한다거나 해당 컨트롤러의 UI만 수정한다거나 그런 것을 할 수 있게 된 것이다.
✨결론은 플레이어 컨트롤러를 인자로 넘겨주는 방식을 사용하면
각 플레이어의 컨트롤러가 분리되어 관리되기 때문에
이후에 해당 컨트롤러가 가진 PlayerState나 UI 등에 변경이 생겨도
해당 컨트롤러를 기준으로 직접 접근하고 수정하기가 쉽다.
언리얼에서 네트워크 관련된 게임을 만들며 공부를 하다보면 이런 곳에서 세팅을 만져야 될 때가 있다.
간단하게 리슨 서버 방식으로 멀티플레이어 게임을 구현하려고,
넷 모드(Play As Net Mode)를 "Listen Server"로 설정하고 플레이어 수를 2명으로 설정했다.
그런데 이걸 멀티 프로세스가 아닌 하나의 프로세스(=단일 프로세스)에서 실행하면,
두 플레이어 모두 같은 프로세스 안에서 실행되기 때문에
모두 서버 측에서 처리되는 것처럼 동작하게 된다.
이렇게 되면 코드나 블루프린트를 테스트할 때,
네트워크적으로 서버와 클라이언트 간 분리된 환경이 제대로 반영되지 않아서,
실제 멀티플레이 상황과는 다르게 작동할 수 있다.
그래서 느리더라도, 언리얼에서 네트워크 기반 멀티플레이 게임을 개발할 때는
반드시 "단일 프로세스 하 실행(Run Under One Process)" 옵션을 끄고,
멀티 프로세스 환경으로 실행해야 한다.
이렇게 해야만 서버와 클라이언트가 분리된 실제 네트워크 환경처럼 동작하며,
네트워크 관련 동기화나 리플리케이션 문제를 정확히 확인하고 디버깅할 수 있다.