본문 바로가기

언리얼 네트워킹 기본 개념

권한을 가진 서버가 클라이언트 입력을 검증하고 확정 상태를 Replication으로 동기화하는 구조를 이해합니다.

혼자 하는 게임에서는 캐릭터의 체력이 하나뿐이지만, 네 명이 함께 하는 게임에서는 같은 캐릭터의 체력이 네 대의 컴퓨터 메모리에 각각 있습니다. 한 플레이어의 화면에서 적이 쓰러졌는데 다른 플레이어의 화면에서는 아직 서 있다면, 어느 쪽이 진짜일까요? 또 어떤 플레이어가 자기 컴퓨터의 메모리를 고쳐 체력을 무한으로 만든다면 그것을 막을 수 있을까요? 멀티플레이어 게임은 결국 "누가 진짜 상태를 정하고, 그 상태를 어떻게 모두에게 나눠 주는가"라는 문제입니다.

언리얼 엔진은 이 문제를 권한(authority)을 가진 서버가 상태를 확정하고, 그 상태를 복제(replication)로 클라이언트에 나눠 주는 구조로 풉니다. 클라이언트의 입력은 서버 RPC(Remote Procedure Call)로 서버에 전달되어 검증되고, 서버가 확정한 상태는 복제로 클라이언트에 동기화됩니다. 이 글에서는 클라이언트-서버 모델, 액터와 변수의 복제, RPC, 소유권(ownership), 그리고 상태를 둘 클래스를 차례로 살펴봅니다.


클라이언트-서버 모델

언리얼 엔진의 멀티플레이어 게임은 기본적으로 클라이언트-서버(client-server) 모델을 따릅니다. 게임의 확정된 상태를 관리하는 서버(server) 하나와, 서버에서 받은 상태를 자기 화면에 그리고 사용자 입력을 서버로 보내는 여러 클라이언트(client)가 있습니다.

서버는 캐릭터의 정확한 위치, 아이템의 재고, 총알의 궤적처럼 함께 쓰는 게임플레이의 기준 상태를 가지고 관리하며, 그 결과를 정하는 규칙을 처리합니다. 클라이언트에서 입력을 받아 처리하고, 바뀐 상태를 클라이언트에 복제해 동기화합니다. 클라이언트가 조작한 데이터를 보내도 서버가 검증해 무시할 수 있으므로 치트를 막는 데도 중요합니다. 서버 전용인 AGameModeBase와 달리 AGameStateBase와 APlayerState는 클라이언트에도 복제됩니다.

클라이언트는 서버에서 받은 상태를 화면에 그리고, 플레이어에게 시각과 청각의 피드백을 줍니다. 키보드, 마우스, 게임패드 입력을 감지해 서버로 보내고, 서버의 응답을 기다리는 동안 예측(prediction)과 보간(interpolation)으로 게임이 부드럽게 보이게 합니다. 클라이언트도 UI, 효과, 예측 같은 로컬 로직은 직접 실행하며, 자신이 조종하는 플레이어 캐릭터 같은 액터에 대해서는 소유권을 가집니다.

서버를 실행하는 방식은 두 가지입니다. 전용 서버(dedicated server)는 게임 클라이언트 없이 따로 실행되는 서버 프로그램으로, 안정성과 보안이 높아 대규모 온라인 게임에 주로 쓰입니다. 리슨 서버(listen server)는 플레이어 한 명의 컴퓨터가 서버와 클라이언트를 함께 맡는 방식으로, 친구들과의 소규모 플레이에 알맞습니다. 에디터에서 리슨 서버로 시험하려면 플레이 인원과 함께 Net Mode를 Play As Listen Server로 정해야 하며, 인원 수만으로 서버 방식이 정해지지는 않습니다.


복제: 서버의 상태를 클라이언트로

복제는 언리얼 네트워킹의 중심입니다. 서버에 있는 데이터나 함수 호출을 네트워크로 클라이언트에 보내, 클라이언트의 게임 상태를 서버와 맞추는 과정입니다. 액터의 위치, 체력, 인벤토리 아이템처럼 게임의 중요한 상태는 복제를 거쳐 클라이언트에 전달되어야 합니다.

액터 복제

AActor를 상속한 클래스는 복제될 수 있습니다. 서버의 액터에 bReplicates를 켜면 그 액터가 복제 대상이 되는데, 이것이 모든 클라이언트에 늘 똑같이 전송된다는 뜻은 아닙니다. 실제로 연결마다 무엇을 보낼지는 관련성(relevancy), 휴면(dormancy), 복제 조건, 업데이트 시점의 영향을 받고, 멤버 변수도 저절로 모두 전송되지는 않습니다. 관련성은 연결마다 이 액터를 보낼 필요가 있는지 고르는 규칙이고, 휴면은 잘 바뀌지 않는 액터를 복제 검사에서 잠시 빼 두는 기능입니다. 두 가지는 멀티플레이어 게임 최적화에서 자세히 다룹니다.

MyReplicatedActor.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyReplicatedActor.generated.h"

UCLASS()
class MYPROJECT_API AMyReplicatedActor : public AActor
{
    GENERATED_BODY()

public:
    AMyReplicatedActor();

protected:
    virtual void BeginPlay() override;

    // 액터가 네트워크를 통해 복제될 수 있도록 설정
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
MyReplicatedActor.cpp
#include "MyReplicatedActor.h"
#include "Net/UnrealNetwork.h" // DOREPLIFETIME 매크로를 위해 포함

AMyReplicatedActor::AMyReplicatedActor()
{
    // 이 액터가 네트워크를 통해 복제될 것임을 명시
    bReplicates = true;

    // 루트 컴포넌트의 이동 상태 복제 활성화 (이 클래스는 루트 생성을 생략한 부분 예제)
    SetReplicateMovement(true);
}

void AMyReplicatedActor::BeginPlay()
{
    Super::BeginPlay();

    // 이 액터 인스턴스의 Authority 역할 확인
    if (HasAuthority())
    {
        UE_LOG(LogTemp, Warning, TEXT("AMyReplicatedActor: Authority role"));
    }
    else
    {
        UE_LOG(LogTemp, Warning, TEXT("AMyReplicatedActor: Non-authority role"));
    }
}

// 이 함수를 오버라이드하여 복제할 변수들을 정의합니다.
void AMyReplicatedActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    // DOREPLIFETIME(클래스명, 변수명): 해당 변수가 네트워크를 통해 복제되도록 선언
    // DOREPLIFETIME(AMyReplicatedActor, MyReplicatedVariable); // 아래에서 예시 변수 추가 예정
}

생성자에서 bReplicates = true로 이 액터를 복제 대상으로 정하고, SetReplicateMovement(true)로 루트 컴포넌트의 이동 상태도 복제하게 했습니다. 이 클래스는 루트 컴포넌트를 만드는 부분을 생략한 부분 예제입니다. BeginPlay의 HasAuthority()는 이 액터 인스턴스의 로컬 역할이 권한(Authority)인지 확인해 로그를 남기며, GetLifetimeReplicatedProps는 복제할 변수를 등록하는 함수로 아래에서 채웁니다.

변수 복제

액터의 특정 변수를 복제하려면 그 변수의 UPROPERTY()에 Replicated나 ReplicatedUsing 지정자를 붙이고, GetLifetimeReplicatedProps() 안에서 DOREPLIFETIME() 매크로로 등록합니다. 아래 헤더는 두 지정자를 비교하려고 변수를 하나씩 둡니다. ReplicatedInteger는 값만 복제하고, ReplicatedFloat는 값이 도착했을 때 OnRep_ReplicatedFloat를 부르게 합니다. 헤더에 지정자를 붙이는 것은 절반일 뿐이고, 이어지는 cpp에서 등록까지 해야 복제된다는 점을 자주 놓칩니다.

MyReplicatedActor.h (변수 추가)
#pragma once

// ...
#include "GameFramework/Actor.h"
#include "MyReplicatedActor.generated.h"

UCLASS()
class MYPROJECT_API AMyReplicatedActor : public AActor
{
    GENERATED_BODY()

public:
    // ...

protected:
    // 서버에서만 값을 변경하고 클라이언트로 복제할 변수
    UPROPERTY(Replicated, BlueprintReadOnly, Category = "Replication")
    int32 ReplicatedInteger;

    // 복제 수신으로 값 변경을 알릴 변수 (중간 변경마다 호출되는 것은 아님)
    UPROPERTY(ReplicatedUsing = OnRep_ReplicatedFloat, BlueprintReadOnly, Category = "Replication")
    float ReplicatedFloat;

    // ReplicatedUsing에 지정된 함수 (OnRep_ 접두사 권장)
    UFUNCTION()
    void OnRep_ReplicatedFloat();

public:
    // 복제된 변수 값을 변경하는 서버 전용 함수 (블루프린트 호출 가능)
    UFUNCTION(BlueprintCallable, Category = "Replication")
    void Server_SetReplicatedValues(int32 NewInt, float NewFloat);
};

Replicated는 값만 복제하고, ReplicatedUsing = OnRep_ReplicatedFloat는 복제된 값을 받았을 때 지정한 함수를 부르게 합니다. 이 알림 함수를 RepNotify라고 부르며, 이름에는 OnRep_ 접두사를 붙이는 것이 관례입니다.

MyReplicatedActor.cpp (변수 복제 로직 및 함수 구현)
#include "MyReplicatedActor.h"
#include "Net/UnrealNetwork.h"

// ... 생성자 및 BeginPlay ...

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

    // Replicated 지정된 변수 복제
    DOREPLIFETIME(AMyReplicatedActor, ReplicatedInteger);

    // ReplicatedUsing 지정된 변수 복제 (OnRep_ 함수와 연결)
    DOREPLIFETIME(AMyReplicatedActor, ReplicatedFloat);
}

void AMyReplicatedActor::OnRep_ReplicatedFloat()
{
    // 이 함수는 ReplicatedFloat 변수의 값이 클라이언트로 복제될 때 클라이언트에서 호출됩니다.
    // 서버에서는 호출되지 않습니다.
    UE_LOG(LogTemp, Warning, TEXT("Client: ReplicatedFloat changed to: %f"), ReplicatedFloat);
    // 여기에 클라이언트에서만 필요한 시각적 효과나 사운드 재생 등을 구현할 수 있습니다.
}

void AMyReplicatedActor::Server_SetReplicatedValues(int32 NewInt, float NewFloat)
{
    // 일반 함수이며 RPC가 아닙니다. 해당 액터의 Authority 역할에서만 값을 바꿉니다.
    if (HasAuthority())
    {
        ReplicatedInteger = NewInt;
        ReplicatedFloat = NewFloat; // 복제 조건·시점에 따라 전송되며 중간 값은 합쳐질 수 있습니다.
        UE_LOG(LogTemp, Warning, TEXT("Server: Replicated values set to Int: %d, Float: %f"), ReplicatedInteger, ReplicatedFloat);
    }
}

GetLifetimeReplicatedProps에서 두 변수를 DOREPLIFETIME으로 등록합니다. OnRep_ReplicatedFloat는 ReplicatedFloat의 새 값이 클라이언트로 복제되었을 때 그 클라이언트에서 불립니다. C++에서는 서버가 값을 대입하는 것만으로 서버 쪽 OnRep가 저절로 실행되지 않으며, 클라이언트에서도 중간에 바뀐 값마다 알림이 오는 것은 아닙니다. 서버에서 값이 여러 번 바뀌어도 전송 시점에 합쳐져 마지막 값만 도착할 수 있기 때문입니다.

Server_SetReplicatedValues는 이름과 달리 Server 지정자가 없는 일반 함수입니다. 그래서 클라이언트에서 불러도 서버로 요청이 가지 않고, 그 액터 인스턴스의 역할이 권한일 때만 값을 바꿉니다. 여기서 HasAuthority()는 이 액터의 로컬 역할이 권한인지 검사할 뿐이라는 점도 주의합니다. 클라이언트가 자기 쪽에서만 만든 액터도 그 클라이언트에서는 권한을 가지므로, 이 검사는 실행 중인 프로세스가 서버인지 묻는 것과 같지 않습니다.

함수 복제: RPC

데이터 복제 외에도, 함수 호출 자체를 네트워크로 보내 서버나 클라이언트에서 실행되게 할 수 있습니다. 이것을 RPC라고 합니다. Server RPC는 클라이언트가 서버에 함수 실행을 요청할 때 씁니다. 플레이어가 총을 쐈을 때 서버에 발사 로직을 실행해 달라고 요청하는 경우가 그 예입니다. Client RPC는 서버가 원격의 소유 클라이언트에 알림을 보낼 때 쓰는데, 액터를 서버가 소유하거나 소유 연결이 없으면 서버에서 실행되므로 모두에게 알리는 수단이 아닙니다. NetMulticast RPC는 서버에서 부르면 서버와, 그 액터가 지금 관련 있는 클라이언트에 전달됩니다. 나중에 접속한 클라이언트를 위해 상태를 저장해 두는 기능은 아닙니다.

MyReplicatedActor.h (RPC 함수 추가)
#pragma once

// ...

UCLASS()
class MYPROJECT_API AMyReplicatedActor : public AActor
{
    GENERATED_BODY()

public:
    // ...

protected:
    // 서버에서만 실행될 RPC (클라이언트 -> 서버)
    // Validate: Optional, 클라이언트에서 보낸 인자가 유효한지 서버에서 검증하는 함수
    // Reliable: Guaranteed delivery, 신뢰할 수 있는 전송 (패킷 손실 시 재전송)
    UFUNCTION(Server, Reliable, WithValidation)
    void Server_DoSomething(int32 Value);
    bool Server_DoSomething_Validate(int32 Value); // Validate 함수는 _Validate 접미사 필수
    void Server_DoSomething_Implementation(int32 Value); // Implementation 함수는 _Implementation 접미사 필수

    // 클라이언트에서 실행될 RPC (서버 -> 클라이언트)
    // Client: 서버 호출 시 원격 소유 클라이언트, 소유 연결이 없으면 서버에서 실행
    UFUNCTION(Client, Reliable)
    void Client_NotifySomethingHappened(const FString& Message);

    // 모든 클라이언트에서 실행될 RPC (서버 -> 모든 클라이언트)
    // NetMulticast: 서버 호출 시 서버와 현재 관련 있는 클라이언트에 전달
    UFUNCTION(NetMulticast, Unreliable) // Unreliable: Non-guaranteed delivery (패킷 손실 무시, 빠름)
    void Multicast_PlayEffectAtLocation(FVector Location);

public:
    // 클라이언트에서 호출하여 서버 RPC를 트리거하는 함수 (블루프린트 호출 가능)
    UFUNCTION(BlueprintCallable, Category = "Replication")
    void TriggerServerFunction(int32 MyValue);

    // 서버에서 호출하여 클라이언트 RPC를 트리거하는 함수 (블루프린트 호출 가능)
    UFUNCTION(BlueprintCallable, Category = "Replication")
    void TriggerClientFunction(const FString& NotificationMessage, bool bTriggerMulticast, FVector EffectLoc);
};
MyReplicatedActor.cpp (RPC 함수 구현)
#include "MyReplicatedActor.h"

// ...

// Server RPC 구현
bool AMyReplicatedActor::Server_DoSomething_Validate(int32 Value)
{
    // 입력 값 유효성 검사 (예: Value가 허용 범위 내에 있는지 확인)
    if (Value < 0 || Value > 100)
    {
        UE_LOG(LogTemp, Warning, TEXT("Server_DoSomething_Validate: Invalid Value %d from client!"), Value);
        return false; // 검증 실패: 호출한 클라이언트의 연결도 끊습니다.
    }
    return true; // 유효하면 실행 허용
}

void AMyReplicatedActor::Server_DoSomething_Implementation(int32 Value)
{
    // 이 로직은 서버에서만 실행됩니다.
    UE_LOG(LogTemp, Warning, TEXT("Server: Received RPC Server_DoSomething with Value: %d"), Value);
    // 여기서 게임 로직(예: 데미지 계산, 아이템 사용 등)을 처리합니다.

    // 서버가 다시 클라이언트에게 알림을 보낼 수도 있습니다.
    Client_NotifySomethingHappened(FString::Printf(TEXT("Server processed your request with value: %d"), Value));
}

// Client RPC 구현
void AMyReplicatedActor::Client_NotifySomethingHappened_Implementation(const FString& Message)
{
    // 실행 위치는 호출 위치와 소유 연결에 따라 정해집니다.
    UE_LOG(LogTemp, Warning, TEXT("Received RPC Client_NotifySomethingHappened: %s"), *Message);
    // 실제 UI 접근은 로컬 플레이어·위젯의 존재를 별도로 확인해야 합니다.
}

// NetMulticast RPC 구현
void AMyReplicatedActor::Multicast_PlayEffectAtLocation_Implementation(FVector Location)
{
    // 서버 호출 시 서버와 현재 관련 있는 클라이언트에서 실행됩니다.
    UE_LOG(LogTemp, Warning, TEXT("All: Received RPC Multicast_PlayEffectAtLocation at %s"), *Location.ToString());
    // 여기에 시각/청각 효과(파티클, 사운드 등) 재생을 구현하여 모든 플레이어가 볼 수 있도록 합니다.
}

// 클라이언트에서 호출할 함수 (RPC 트리거)
void AMyReplicatedActor::TriggerServerFunction(int32 MyValue)
{
    // 이 함수는 클라이언트에서 호출될 수 있습니다.
    // 클라이언트에서 호출되면 Server_DoSomething_Implementation을 서버에서 실행하도록 요청합니다.
    Server_DoSomething(MyValue);
}

// 서버에서 호출할 함수 (RPC 트리거)
void AMyReplicatedActor::TriggerClientFunction(const FString& NotificationMessage, bool bTriggerMulticast, FVector EffectLoc)
{
    // 이 함수는 서버에서 호출되어야 합니다.
    if (HasAuthority())
    {
        Client_NotifySomethingHappened(NotificationMessage); // 특정 클라이언트에게만 알림

        if (bTriggerMulticast)
        {
            Multicast_PlayEffectAtLocation(EffectLoc); // 현재 관련 있는 클라이언트에 효과 재생 요청
        }
    }
}

RPC는 UFUNCTION의 지정자로 선언하고, 실제 내용은 _Implementation 접미사가 붙은 함수에 씁니다. Server_DoSomething처럼 WithValidation을 붙이면 _Validate 함수도 만들어야 합니다. 클라이언트가 TriggerServerFunction을 부르면 Server_DoSomething이 서버로 요청을 보내고, 서버는 _Validate로 인자를 검사한 뒤 _Implementation을 실행하며, 처리한 뒤 Client_NotifySomethingHappened로 결과를 알립니다. TriggerClientFunction은 권한을 가진 쪽에서만 Client RPC를 보내고, 원하면 Multicast_PlayEffectAtLocation으로 관련 있는 클라이언트에 효과 재생을 요청합니다.

Reliable은 연결이 유지되는 동안 확인 응답과 재전송으로 전달을 확인하는 방식입니다. 하지만 그 호출이 게임 규칙에 맞다는 것이나 연결이 끊긴 뒤 다시 재생해 준다는 것을 보장하지는 않습니다. Unreliable은 손실이나 순서 바뀜을 허용하므로, 상태를 되살리는 유일한 경로로 쓰면 안 됩니다. 확인 응답과 재전송을 꼭 필요한 메시지에만 더하는 원리는 TCP vs UDP 판단 기준에서 다룹니다.

WithValidation은 선택 사항입니다. _Validate가 false를 돌려주면 그 호출을 보낸 클라이언트의 연결이 끊기므로, 정상적인 재사용 대기 시간처럼 게임상 거절해야 하는 경우는 _Implementation 안에서 처리하는 편이 알맞습니다. 어느 쪽이든 서버의 게임 규칙 검증은 따로 설계해야 합니다.

호출하는 위치에 따라서도 동작이 달라집니다. 클라이언트에서 Client나 NetMulticast RPC를 직접 부르면 그 클라이언트에서만 실행됩니다. 또 Server RPC가 원격으로 전달되려면 다음에 볼 소유 연결 조건을 갖춰야 합니다.


소유권: 누구의 연결인가

소유권은 언리얼 네트워킹에서 아주 자주 부딪히는 개념입니다. 액터의 Owner는 다른 액터일 수도 있고 nullptr일 수도 있습니다. 소유 관계를 따라 올라가 PlayerController에 이르면, 그 컨트롤러의 연결이 그 액터를 소유한 클라이언트가 됩니다. 서버가 저절로 모든 액터의 Owner가 되는 것은 아닙니다.

소유권은 여러 곳에서 기준이 됩니다. 서버가 원격의 소유 클라이언트에 Client RPC를 보낼 때와, 소유 클라이언트가 Server RPC를 요청할 때 소유 연결로 대상을 정합니다. 특정 변수를 소유한 클라이언트에만 복제하도록 DOREPLIFETIME_COND로 조건을 걸 수도 있고, bOnlyRelevantToOwner를 켠 액터는 관련성 판단에서 소유 연결에만 보내집니다.

소유 관계는 서버에서 AActor::SetOwner(AActor* NewOwner)로 정합니다. 클라이언트에서 자기 쪽 포인터만 바꾼다고 서버 RPC를 보낼 권한이 생기지는 않습니다.


상태를 어디에 둘까

언리얼 엔진은 멀티플레이어 게임의 여러 상태를 나눠 관리하도록 특화된 클래스를 제공합니다. 각 클래스가 어느 컴퓨터에 존재하는지에 따라 무엇을 맡길지가 달라집니다.

UNREAL C++ · CH8-1

공유 상태와 플레이어 제어는 존재 위치가 다릅니다

규칙을 결정하는 GameMode와 클라이언트에 공개할 GameState·PlayerState를 구분합니다. PlayerController는 서버와 해당 소유 클라이언트에 있으며 다른 클라이언트의 제어 객체를 모두 받는 구조가 아닙니다.

두 소유 연결의 PlayerController 존재 위치기본S/A/B구성에서서버에는PC_A와PC_B,클라이언트A에는PC_A,클라이언트B에는PC_B가있다.두화살표는해당소유클라이언트사본으로의대응이며A에서B로요청/전체PC브로드캐스트가아니다.다른공유클래스는원비교표로구분한다.서버 SPC_AA의 소유 연결PC_BB의 소유 연결클라이언트 A클라이언트 BPC_A자기 제어 객체PC_B자기 제어 객체
두 소유 연결의 PlayerController 존재 위치기본S/A/B구성에서서버에는PC_A와PC_B,클라이언트A에는PC_A,클라이언트B에는PC_B가있다.두화살표는해당소유클라이언트사본으로의대응이며A에서B로요청/전체PC브로드캐스트가아니다.다른공유클래스는원비교표로구분한다.서버 S · A 연결PC_AA의 소유 연결클라이언트 APC_A자기 제어 객체서버 S · B 연결PC_BB의 소유 연결클라이언트 BPC_B자기 제어 객체
게임 상태를 두는 클래스와 존재 위치
클래스존재 위치맡기는 정보
GameMode서버에만 존재접속·스폰·점수 부여 등 게임 규칙의 결정. 클라이언트가 직접 읽을 상태 저장소로 쓰지 않습니다.
GameState서버와 클라이언트의 복제 인스턴스라운드·경기 시간 등 공유 상태. 커스텀 속성은 복제 등록과 조건을 지정합니다.
PlayerState서버와 클라이언트의 복제 인스턴스각 플레이어의 이름·점수 등 공유 상태. 일반적인 Pawn 리스폰과 독립적으로 유지합니다.
PlayerController서버와 해당 소유 클라이언트해당 플레이어의 제어·소유 연결. 다른 클라이언트의 PlayerController가 모든 클라이언트에 복제되지는 않습니다.
GameMode
존재 위치: 서버에만 존재
맡기는 정보: 접속·스폰·점수 부여 등 게임 규칙의 결정. 클라이언트가 직접 읽을 상태 저장소로 쓰지 않습니다.
GameState
존재 위치: 서버와 클라이언트의 복제 인스턴스
맡기는 정보: 라운드·경기 시간 등 공유 상태. 커스텀 속성은 복제 등록과 조건을 지정합니다.
PlayerState
존재 위치: 서버와 클라이언트의 복제 인스턴스
맡기는 정보: 각 플레이어의 이름·점수 등 공유 상태. 일반적인 Pawn 리스폰과 독립적으로 유지합니다.
PlayerController
존재 위치: 서버와 해당 소유 클라이언트
맡기는 정보: 해당 플레이어의 제어·소유 연결. 다른 클라이언트의 PlayerController가 모든 클라이언트에 복제되지는 않습니다.

복제는 연결별 관련성·조건·시점을 따릅니다. 커스텀 속성은 별도 등록해야 하며 서버의 모든 변경이 모든 클라이언트에 같은 순간 보이지 않습니다. Authority 역할과 소유 연결도 구분합니다.

위 그림의 위쪽은 서버 S에 플레이어 A와 B가 접속한 기본 구성에서 PlayerController가 어디에 있는지 보여 줍니다. 서버 S에는 A의 연결을 위한 PC_A와 B의 연결을 위한 PC_B가 모두 있고, 클라이언트 A에는 자기 제어를 위한 PC_A만, 클라이언트 B에는 PC_B만 있습니다. 두 화살표는 서버의 컨트롤러가 각 소유 클라이언트의 사본과 대응한다는 뜻일 뿐, A가 B에 요청을 보내거나 모든 컨트롤러를 모두에게 뿌린다는 뜻이 아닙니다.

그림의 표는 상태를 둘 클래스와 그 존재 위치를 정리합니다. GameMode는 서버에만 있으며 접속, 스폰, 점수 부여 같은 게임 규칙을 정하는 곳이므로, 클라이언트가 직접 읽을 상태 저장소로 쓰지 않습니다. GameState는 서버와 클라이언트에 복제된 인스턴스가 있어 라운드나 경기 시간 같은 공유 상태를 두며, 직접 만든 속성은 복제 등록과 조건을 정해야 합니다. PlayerState도 서버와 클라이언트에 있어 각 플레이어의 이름과 점수 같은 공유 상태를 두고, 보통 폰이 다시 스폰되어도 따로 유지됩니다. PlayerController는 서버와 그 소유 클라이언트에만 있으며 그 플레이어의 제어와 소유 연결을 맡습니다. 다른 클라이언트의 컨트롤러가 모든 클라이언트에 복제되지는 않습니다.

그림 아래의 설명처럼 복제는 연결마다의 관련성, 조건, 시점을 따르므로, 서버의 모든 변경이 모든 클라이언트에서 같은 순간에 보이지는 않습니다. 권한 역할과 소유 연결도 서로 다른 개념이라는 점을 함께 기억합니다.

예를 들어 다른 플레이어의 이름과 점수는 공유되는 PlayerState에서, 라운드의 남은 시간은 복제 등록한 GameState에서 읽도록 설계할 수 있습니다. 서버에만 있는 GameMode를 클라이언트 UI에서 직접 조회하는 설계와는 구별해야 합니다.

처음 따라 할 때는 서버에서 만든 복제 액터를 대상으로 삼고, 시험할 맵, 게임 모드, 플레이 인원, Net Mode를 먼저 기록해 둡니다. 이 글의 코드 블록은 같은 클래스에 더해 가는 발췌이므로 선언과 함수를 한 정의에 합치고, 시험용 호출도 직접 연결합니다. 속성 복제와 RPC의 소유 연결은 따로 준비해야 하며, 액터를 배치했다고 클라이언트가 호출할 권한이 생기지는 않습니다. 복제나 RPC가 보이지 않으면 액터를 어디서 만들었는지, 소유 연결이 있는지, 복제 등록과 수신 조건이 맞는지를 차례로 확인합니다. 예제의 로그 문자열은 실행 위치를 확인한 결과가 아니라, 실행될 때 그렇게 출력하도록 써 둔 코드입니다.


정리

  • 언리얼의 멀티플레이어는 권한을 가진 서버가 상태를 확정하고, 클라이언트는 입력을 보내고 받은 상태를 그리는 클라이언트-서버 모델입니다.
  • 서버는 전용 서버나 리슨 서버로 실행하며, 에디터에서는 Net Mode를 따로 정합니다.
  • 액터는 bReplicates로 복제 대상이 되고, 변수는 Replicated나 ReplicatedUsing과 DOREPLIFETIME 등록이 있어야 복제됩니다. 실제 전송은 관련성, 휴면, 조건, 시점의 영향을 받습니다.
  • RepNotify는 클라이언트가 새 값을 받을 때 불리며, C++에서는 서버가 값을 대입해도 서버의 OnRep가 저절로 불리지 않고 중간 값마다 알림이 오지도 않습니다.
  • Server, Client, NetMulticast RPC는 실행 위치와 소유 연결에 따라 동작하며, Reliable은 전달 방식일 뿐 게임 규칙의 유효성을 보장하지 않습니다. _Validate의 false는 연결을 끊습니다.
  • HasAuthority()는 액터의 로컬 역할을 검사하며, 소유권은 서버에서 SetOwner로 정합니다.
  • GameMode는 서버에만, GameState와 PlayerState는 모두에게, PlayerController는 서버와 소유 클라이언트에만 있습니다.

연습 문제

1. 점수판을 만들려면

클라이언트 A의 점수판에 B의 이름과 점수, 그리고 두 플레이어에게 공통인 남은 시간을 보여 주려 합니다. A가 B의 PlayerController와 GameMode를 직접 받는다는 전제로 코드를 짜도 될까요? 어떤 공유 상태를 준비해야 할까요?

해설 보기

그런 전제로 짜면 안 됩니다. 클라이언트 A에는 자기 PlayerController만 있고 B의 컨트롤러는 없으며, GameMode는 서버에만 있습니다. B의 이름과 점수는 B의 PlayerState에, 남은 시간은 GameState에 두고, 각각 복제 등록과 수신 조건을 준비합니다.

2. 이름은 Server인데

클라이언트 A가 공유 액터의 클라이언트 사본에서 Server_SetReplicatedValues를 직접 불렀습니다. 이 호출은 서버로 요청을 보낼까요? 값은 바뀔까요?

해설 보기

서버로 요청을 보내지 않습니다. 이 함수는 이름에 Server_가 붙었을 뿐 Server 지정자가 없는 일반 함수이므로, 호출한 그 클라이언트에서만 실행됩니다. 또 서버에서 만든 공유 액터의 클라이언트 사본은 권한 역할이 아니므로 HasAuthority() 조건도 통과하지 못해 값도 바뀌지 않습니다.

클라이언트가 값을 바꾸고 싶다면, 소유 연결을 갖춘 액터에 Server RPC를 따로 만들고, 서버에서 요청을 검증한 뒤 값을 바꾸도록 구성합니다.

3. 쿨다운 중의 요청

플레이어가 재사용 대기 시간이 끝나지 않은 스킬을 다시 쓰려고 Server RPC를 보냈습니다. 이 요청을 _Validate에서 false로 거절하면 어떻게 될까요? 어디서 거절하는 것이 알맞을까요?

해설 보기

_Validate가 false를 돌려주면 그 클라이언트의 연결이 끊깁니다. 대기 시간 중에 버튼을 누르는 것은 정상적인 플레이에서도 흔히 일어나는 일이므로, 이렇게 처리하면 정직한 플레이어가 게임에서 쫓겨납니다.

이런 게임상 거절은 _Implementation 안에서 대기 시간을 확인하고 요청을 무시하거나 거절 알림을 보내는 식으로 처리합니다. _Validate는 정상적인 클라이언트라면 보낼 수 없는 값처럼, 조작이 분명한 경우에만 씁니다.

네트워킹의 개념과 클래스의 존재 위치는 Epic의 Networking Overview 문서에서, RPC의 실행 위치 규칙은 Remote Procedure Calls 문서에서 확인할 수 있습니다.

다음 글 복제(Replication) 시스템 이해에서는 조건부 복제, RepNotify, 컴포넌트 복제, 네트워크 역할처럼 복제가 무엇을 누구에게 보내는지를 더 자세히 살펴봅니다.