UE/멀티플레이

[Unreal 9기] 2026-07-31 UE 넷모드, 넷드라이브, 리플리케이션

김인철_ 2026. 7. 31. 18:48

 

언리얼 멀티플레이의 실행 위치와 동기화

언리얼 멀티플레이 코드를 작성할 때 가장 헷갈리는 점은 같은 클래스와 같은 함수가 여러 컴퓨터에 각각 존재하고 실행될 수 있다는 것이다. 싱글플레이에서는 함수를 호출한 프로세스에서 그대로 실행하면 끝이지만, 멀티플레이에서는 다음 질문을 먼저 해결해야 한다.

  1. 지금 코드는 서버 프로세스에서 실행 중인가, 클라이언트 프로세스에서 실행 중인가?
  2. 지금 보고 있는 액터는 원본 Authority인가, 복제된 Proxy인가?
  3. 이 액터는 어느 연결의 소유이며, RPC를 어느 컴퓨터로 보낼 수 있는가?
  4. 전달하려는 것이 일회성 사건인가, 계속 유지되어야 하는 상태인가?

이 네 질문은 각각 NetMode, NetRole, Ownership, RPC와 Property Replication으로 이어진다. 숫자 야구 프로젝트에서는 이 개념을 이용해 멀티플레이 채팅과 플레이어 접속 알림을 구현했다.

NetMode: 현재 월드가 맡은 네트워크 역할

NetMode는 개별 액터가 아니라 현재 UWorld가 실행되는 프로세스의 네트워크 역할을 나타낸다.

NetMode 의미 로컬 플레이어 원격 연결
NM_Standalone 네트워크 연결이 없는 단독 실행 있음 없음
NM_ListenServer 서버이면서 직접 플레이에도 참여 있음 클라이언트 연결을 받음
NM_DedicatedServer 플레이하지 않고 게임 상태만 관리하는 전용 서버 없음 클라이언트 연결을 받음
NM_Client 원격 서버에 접속한 클라이언트 있음 서버 연결 하나를 가짐

멀티플레이 게임의 핵심 판정은 서버에서 수행해야 한다. 클라이언트가 최종 데미지, 점수, 정답 여부 같은 값을 결정하게 두면 조작된 입력을 그대로 신뢰하게 되기 때문이다. 클라이언트는 의도를 서버에 요청하고, 서버가 규칙을 검증한 뒤 결과를 확정하는 구조가 기본이다.

다만 NetMode는 월드 단위의 정보이므로, 액터 하나의 권한을 판단할 때마다 무조건 GetNetMode()를 사용할 필요는 없다. 용도를 나누면 다음과 같다.

  • 프로세스가 클라이언트인지, 리슨 서버인지, 데디케이티드 서버인지 구분: GetNetMode()
  • 해당 액터 인스턴스가 권한을 가졌는지 확인: HasAuthority()
  • 해당 컨트롤러가 이 컴퓨터의 로컬 플레이어 것인지 확인: IsLocalController()
  • 해당 폰이 로컬 입력으로 조종되는지 확인: IsLocallyControlled()

프로젝트의 NumberBaseballFunctionLibrary는 로그 앞에 실행 위치를 붙일 수 있도록 GetNetMode()를 문자열로 변환한다.

static FString GetNetModeString(const AActor* InWorldContextActor)
{
    FString NetModeString = TEXT("None");

    if (IsValid(InWorldContextActor))
    {
        const ENetMode NetMode = InWorldContextActor->GetNetMode();

        if (NetMode == NM_Client)
        {
            NetModeString = TEXT("Client");
        }
        else if (NetMode == NM_Standalone)
        {
            NetModeString = TEXT("StandAlone");
        }
        else
        {
            NetModeString = TEXT("Server");
        }
    }

    return NetModeString;
}

이런 로그는 같은 함수가 여러 PIE 인스턴스에서 호출될 때 특히 유용하다. 함수명만 출력하면 어느 실행 주체의 로그인지 알기 어렵지만, Client, Server 같은 문맥을 함께 남기면 RPC 경로를 역추적하기 쉬워진다.

NetDriver와 NetConnection: 통신이 지나가는 실제 경로

UNetDriver는 월드의 네트워크 통신을 관리하고, UNetConnection은 통신 상대 하나와의 연결을 표현한다.

서버와 클라이언트의 연결 구조는 서로 다르다.

Server NetDriver
└─ ClientConnections
   ├─ ClientConnection A
   ├─ ClientConnection B
   └─ ClientConnection C

Client NetDriver
└─ ServerConnection
  • 서버의 UNetDriver는 접속한 클라이언트 수만큼 ClientConnections를 관리한다.
  • 클라이언트의 UNetDriver는 자신이 접속한 서버를 가리키는 ServerConnection 하나를 관리한다.
  • Standalone 월드에는 네트워크 통신이 필요하지 않으므로 일반적으로 게임용 UNetDriver가 없다.

이 구조는 NetMode 판정과도 연결된다. 클라이언트의 드라이버에는 ServerConnection이 있지만 서버의 드라이버에는 없기 때문에, 엔진은 이 차이를 이용해 서버와 클라이언트를 구분할 수 있다.

Ownership: RPC의 목적지를 결정하는 소유 관계

RPC에서 말하는 소유권은 단순히 객체의 수명 관리자가 누구인지 나타내는 개념이 아니다. 어느 UNetConnection으로 RPC를 전달해야 하는지 찾는 네트워크 경로다.

대표적인 소유 관계는 다음과 같다.

ClientConnection
└─ PlayerController
   └─ Pawn
      └─ Weapon Actor

서버의 각 ClientConnection은 그 클라이언트의 PlayerController와 연결된다. 폰의 Owner가 해당 컨트롤러이고 무기 액터의 Owner가 폰이라면, 무기에서도 Owner 체인을 따라 자신의 Owning Connection을 찾을 수 있다.

이 관계가 중요한 이유는 다음과 같다.

  • 클라이언트가 Server RPC를 호출하려면 일반적으로 그 클라이언트가 소유한 replicated actor에서 호출해야 한다.
  • 서버가 Client RPC를 호출하면 해당 액터의 Owning Connection을 따라 소유 클라이언트로 전달된다.
  • 소유권이 없는 서버 액터에서 호출한 Client RPC는 보낼 대상 클라이언트를 결정할 수 없다.

PlayerController는 이 구조를 이해하기 좋은 액터다. 서버에는 모든 플레이어의 PlayerController가 있지만, 각 클라이언트에는 기본적으로 자신의 PlayerController만 존재한다. 따라서 서버가 특정 서버 측 PlayerController에서 Client RPC를 호출하면 그 컨트롤러의 소유 클라이언트에 정확히 도달한다.

NetRole: 액터 인스턴스의 권한과 복제 형태

NetMode가 월드의 역할이라면 NetRole현재 컴퓨터에 존재하는 액터 인스턴스의 역할이다.

Local Role 의미 일반적인 예
ROLE_Authority 해당 액터의 권한을 가진 인스턴스 서버에 스폰된 replicated actor의 서버 원본
ROLE_AutonomousProxy 서버에서 복제됐지만 로컬 입력을 서버로 보낼 수 있는 프록시 자신이 조종하는 Pawn
ROLE_SimulatedProxy 서버 상태를 받아 표현하는 프록시 다른 플레이어의 Pawn
ROLE_None 반대편에 복제 대상이 없거나 네트워크 역할이 없음 서버에만 존재하는 비복제 액터의 반대편 역할

Local Role은 현재 컴퓨터 기준의 역할, Remote Role은 연결 반대편에서 기대되는 역할이다. 예를 들어 클라이언트가 조종하는 Pawn은 서버에서 Local Role이 Authority이고 Remote Role이 AutonomousProxy가 될 수 있다. 같은 Pawn의 소유 클라이언트 복제본은 Local Role이 AutonomousProxy다.

HasAuthority()는 내부적으로 Local Role이 ROLE_Authority인지 확인한다.

bool AActor::HasAuthority() const
{
    return GetLocalRole() == ROLE_Authority;
}

여기서 주의할 점은 HasAuthority()가 문자 그대로 “이 프로세스는 서버다”를 검사하는 함수는 아니라는 것이다. 클라이언트가 로컬에서만 생성한 비복제 액터도 Authority를 가질 수 있다. 일반적인 서버 스폰 replicated gameplay actor에서는 서버 원본을 확인하는 용도로 사용할 수 있지만, 프로세스 자체를 구분하려면 NetMode가 더 명확하다.

액터 종류에 따라 존재하는 컴퓨터가 다르다

RPC를 설계하려면 먼저 해당 액터가 어느 컴퓨터에 존재하는지 확인해야 한다.

객체 서버 소유 클라이언트 다른 클라이언트
GameMode O X X
GameState O O O
PlayerController O O X
replicated Pawn O O O
로컬 UI X O X

이 차이는 접속 알림 구현에서 중요하다. GameMode는 플레이어 접속을 가장 먼저 알 수 있지만 클라이언트에는 존재하지 않는다. 반대로 GameState는 서버와 모든 클라이언트에 존재한다. 따라서 서버 전용 GameMode가 접속 이벤트를 감지하고, 모든 클라이언트에 존재하는 GameState를 통해 알림을 전달하는 구조가 자연스럽다.

RPC: 다른 컴퓨터에서 함수를 실행시키는 요청

RPC는 함수를 호출한 컴퓨터와 실제 구현부가 실행되는 컴퓨터를 분리한다. 언리얼에서는 UFUNCTION()에 실행 방향과 신뢰성 지정자를 붙인다.

선언 일반적인 호출 위치 실행 위치 핵심 조건
UFUNCTION(Server, ...) 소유 클라이언트 서버 호출 클라이언트가 액터를 소유해야 함
UFUNCTION(Client, ...) 서버 액터의 소유 클라이언트 유효한 Owning Connection이 필요
UFUNCTION(NetMulticast, ...) 서버 서버와 해당 액터가 복제된 관련 클라이언트 서버에서 호출해야 네트워크로 전파됨

Server, Client, NetMulticast는 “반드시 저 위치에서 실행된다”는 단순한 함수 수식어가 아니다. 액터의 복제 여부, 호출 주체, 소유 관계, relevancy 조건이 맞을 때 지정된 원격 실행이 이루어진다.

Reliable과 Unreliable

Reliable RPC는 연결이 유지되는 동안 전달을 재시도하며 순서를 보장하는 대신, 유실된 패킷이 처리될 때까지 뒤의 Reliable RPC가 영향을 받을 수 있다. 따라서 모든 RPC를 Reliable로 만드는 것은 안전한 선택이 아니다.

  • Reliable: 채팅 전송, 확정된 상호작용 요청처럼 누락되면 기능이 깨지는 낮은 빈도의 이벤트
  • Unreliable: 짧은 간격으로 반복되고 일부 누락을 허용할 수 있는 코스메틱 이벤트

또한 Reliable은 보안 검증을 대신하지 않는다. 클라이언트가 보낸 문자열 길이, 호출 빈도, 게임 규칙상 가능한 요청인지 여부는 서버가 별도로 검사해야 한다.

프로젝트의 멀티플레이 채팅 흐름

숫자 야구 프로젝트의 채팅은 클라이언트끼리 직접 통신하지 않는다. 모든 메시지가 서버를 경유한다.

sequenceDiagram
    participant UI as "로컬 채팅 UI"
    participant CPC as "소유 클라이언트의 PlayerController"
    participant SPC as "서버의 PlayerController"
    participant OPC as "각 클라이언트의 PlayerController"

    UI->>CPC: Enter 입력 / SetChatMessageString
    CPC->>SPC: ServerRPCPrintChatMessageString
    Note over SPC: 서버가 모든 PlayerController를 순회
    loop 접속한 플레이어마다
        SPC->>OPC: ClientRPCPrintChatMessageString
        OPC->>OPC: 화면에 메시지 출력
    end

RPC 선언은 다음과 같다.

UFUNCTION(Client, Reliable)
void ClientRPCPrintChatMessageString(const FString& InChatMessageString);

UFUNCTION(Server, Reliable)
void ServerRPCPrintChatMessageString(const FString& InChatMessageString);

UI에서 Enter가 입력되면 GetOwningPlayer()로 로컬 PlayerController를 얻고 SetChatMessageString()을 호출한다. 여기서 IsLocalController() 검사는 서버에 존재하는 다른 플레이어의 컨트롤러 복제 문맥이나 비로컬 컨트롤러에서 UI 입력 경로가 실행되는 것을 막는다.

void ANBPlayerController::SetChatMessageString(
    const FString& InChatMessageString)
{
    ChatMessageString = InChatMessageString;

    if (IsLocalController())
    {
        ServerRPCPrintChatMessageString(InChatMessageString);
    }
}

서버는 자신이 보유한 모든 PlayerController를 순회한다. 각 서버 측 PlayerController에서 Client RPC를 호출하면 서로 다른 Owning Connection을 따라 각 클라이언트로 메시지가 전달된다.

void ANBPlayerController::ServerRPCPrintChatMessageString_Implementation(
    const FString& InChatMessageString)
{
    for (TActorIterator<ANBPlayerController> It(GetWorld()); It; ++It)
    {
        ANBPlayerController* PlayerController = *It;

        if (IsValid(PlayerController))
        {
            PlayerController->ClientRPCPrintChatMessageString(
                InChatMessageString);
        }
    }
}

PlayerController에서 NetMulticast를 쓰지 않은 이유

처음에는 “모든 클라이언트에 보내니 NetMulticast가 더 간단하지 않을까?”라고 생각할 수 있다. 하지만 각 클라이언트에는 자신의 PlayerController만 존재한다. 한 플레이어의 PlayerController에서 Multicast RPC를 호출해도 다른 클라이언트에는 그 액터 자체가 없으므로 전체 채팅 방송 수단이 되지 못한다.

현재 구현처럼 서버가 모든 서버 측 PlayerController를 순회하고 각각 Client RPC를 보내면 다음 장점이 있다.

  • 각 메시지의 목적지가 Owning Connection으로 명확하다.
  • 특정 플레이어만 대상으로 하는 귓속말이나 시스템 메시지로 확장하기 쉽다.
  • PlayerController가 모든 클라이언트에 존재한다고 잘못 가정하지 않는다.

또 하나 중요한 점은 ChatMessageString 멤버 자체는 replicated property가 아니라는 것이다. 이 값에 문자열을 대입했다고 다른 컴퓨터로 전달되는 것이 아니다. 실제 네트워크 전송은 Server RPC와 Client RPC의 매개변수를 통해 이루어진다.

이 방식은 채팅을 일회성 이벤트로 다룬다. 접속 중인 클라이언트는 메시지를 받지만, 나중에 접속한 클라이언트가 이전 메시지를 자동으로 복원하지는 않는다. 채팅 기록까지 상태로 유지하려면 서버가 별도의 기록을 소유하고 필요한 범위만 동기화해야 한다.

접속 알림: GameMode에서 감지하고 GameState에서 전파하기

플레이어가 접속하면 서버의 GameMode::OnPostLogin()이 호출된다. GameMode는 서버에만 있으므로 이벤트 감지에는 적합하지만, 그 자체에서 클라이언트 RPC를 실행할 수 있는 복제본은 클라이언트에 없다.

프로젝트에서는 서버와 클라이언트 모두에 존재하는 GameState로 역할을 넘긴다.

void ANBGameModeBase::OnPostLogin(AController* NewPlayer)
{
    Super::OnPostLogin(NewPlayer);

    ANBGameStateBase* GameState = GetGameState<ANBGameStateBase>();

    if (IsValid(GameState))
    {
        GameState->MulticastRPCBroadcastLoginMessage(TEXT("XXXXXXX"));
    }
}

GameState의 NetMulticast RPC는 서버와 관련 클라이언트에서 실행된다.

UFUNCTION(NetMulticast, Reliable)
void MulticastRPCBroadcastLoginMessage(
    const FString& InNameString = FString(TEXT("XXXXXXX")));

프로젝트의 구현부는 HasAuthority() == false인 경우에만 로컬 PlayerController를 찾아 화면에 출력한다.

void ANBGameStateBase::MulticastRPCBroadcastLoginMessage_Implementation(
    const FString& InNameString)
{
    if (!HasAuthority())
    {
        APlayerController* PC =
            UGameplayStatics::GetPlayerController(GetWorld(), 0);

        if (ANBPlayerController* NBPC = Cast<ANBPlayerController>(PC))
        {
            const FString Notification =
                InNameString + TEXT(" has joined the game.");
            NBPC->PrintChatMessageString(Notification);
        }
    }
}

Multicast 구현부는 서버에서도 실행되지만, 실제 화면 UI는 로컬 클라이언트의 관심사다. Authority 검사는 서버 쪽 출력 경로를 제외하고 클라이언트에서만 알림을 표시하도록 만든다.

Property Replication: 사건이 아니라 상태를 동기화하기

RPC가 “지금 이 함수를 실행하라”는 사건 전달이라면, Property Replication은 “현재 이 값이 무엇인지”를 동기화한다.

구분 RPC Property Replication
표현 대상 일회성 이벤트, 명령, 요청 지속되어야 하는 상태
전송 형태 함수 호출과 매개변수 서버에서 변경된 프로퍼티 값
늦게 접속한 클라이언트 과거 호출을 받지 못함 현재 상태를 초기 복제로 받을 수 있음
대표 예 채팅 알림, 이펙트 재생, 상호작용 요청 점수, 남은 기회, 라운드 상태
기본 방향 RPC 종류와 Ownership에 따라 결정 서버 Authority에서 클라이언트로

숫자 야구로 예를 들면 “정답 제출 버튼을 눌렀다”는 Server RPC 요청이고, 서버가 판정한 “남은 기회가 7회다”는 replicated state에 가깝다.

기본 설정

일반적인 replicated actor에서 프로퍼티를 동기화하려면 세 가지가 필요하다.

  1. 액터의 bReplicatestrue로 설정한다.
  2. 동기화할 프로퍼티에 Replicated 또는 ReplicatedUsing을 지정한다.
  3. GetLifetimeReplicatedProps()에서 DOREPLIFETIME으로 등록한다.
// NBGameStateBase.h

UCLASS()
class NUMBERBASEBALL_API ANBGameStateBase : public AGameStateBase
{
    GENERATED_BODY()

public:
    virtual void GetLifetimeReplicatedProps(
        TArray<FLifetimeProperty>& OutLifetimeProps) const override;

protected:
    UPROPERTY(ReplicatedUsing = OnRep_RemainingAttempts)
    int32 RemainingAttempts = 0;

    UFUNCTION()
    void OnRep_RemainingAttempts();
};
// NBGameStateBase.cpp

#include "Net/UnrealNetwork.h"

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

    DOREPLIFETIME(ANBGameStateBase, RemainingAttempts);
}

void ANBGameStateBase::OnRep_RemainingAttempts()
{
    // 클라이언트 UI 갱신 등, 값 변경에 대한 반응을 처리한다.
}

AGameStateBase는 원래 복제를 전제로 한 프레임워크 액터다. 일반 AActor 기반 클래스로 같은 패턴을 구현할 때는 생성자에서 bReplicates = true 설정까지 직접 확인해야 한다.

ReplicatedUsingOnRep 함수는 클라이언트가 새 값을 받은 뒤 반응해야 할 때 유용하다. 단순히 숫자를 동기화하는 것과 그 숫자에 맞춰 UI를 갱신하는 책임을 분리할 수 있다.

서버가 프로퍼티를 직접 변경했을 때 서버 자신의 OnRep가 클라이언트와 똑같이 자동 호출된다고 가정해서는 안 된다. 서버에서도 같은 후처리가 필요하다면 공통 처리 함수를 두고 서버 변경 경로와 OnRep 양쪽에서 호출하는 편이 명확하다.

클라이언트의 값 변경은 서버 요청이 아니다

클라이언트가 로컬 복제본의 replicated property를 직접 바꿔도 그 값이 서버로 역복제되지 않는다. 서버-클라이언트 모델에서 프로퍼티 복제의 기본 방향은 서버에서 클라이언트다.

따라서 상태 변경은 보통 다음 경로를 따른다.

flowchart LR
    A["클라이언트 입력"] --> B["Server RPC 요청"]
    B --> C["서버가 요청 검증"]
    C --> D["서버 Authority가 상태 변경"]
    D --> E["Property Replication"]
    E --> F["클라이언트 OnRep / UI 반영"]

이 흐름은 서버 권한 모델의 핵심이다. 클라이언트는 결과값을 선언하지 않고 의도를 전송한다. 서버는 유효성 검사와 게임 규칙을 적용해 상태를 변경하고, 확정된 상태만 각 클라이언트에 복제한다.

이벤트와 상태를 구분하는 설계 기준

RPC와 Property Replication 중 무엇을 사용할지 애매하다면 “나중에 접속한 플레이어도 이것을 알아야 하는가?”라고 질문하면 도움이 된다.

  • 폭발 이펙트를 지금 재생해야 한다: 일회성 사건이므로 RPC가 자연스럽다.
  • 문이 현재 열려 있다: 지속 상태이므로 replicated property가 자연스럽다.
  • 플레이어가 숫자를 제출했다: 클라이언트의 의도이므로 Server RPC가 자연스럽다.
  • 판정 결과와 남은 시도 횟수가 현재 얼마다: 서버가 확정한 상태이므로 Property Replication이 자연스럽다.
  • 접속한 플레이어에게 즉시 알림을 띄운다: 접속 시점의 사건이므로 RPC가 자연스럽다.

실전에서는 두 방식을 함께 사용한다. Server RPC가 상태 변경을 요청하고, 서버가 값을 바꾼 뒤 Property Replication이 최종 상태를 배포하는 조합이 가장 흔하다.

네트워크 실행 경로 설계 체크리스트

멀티플레이 로직을 작성할 때는 함수부터 만들기보다 실행 주체와 데이터 방향을 먼저 결정해야 한다.

  1. NetMode로 현재 월드가 서버인지 클라이언트인지 구분한다.
  2. NetRole로 현재 액터가 Authority인지 Proxy인지 구분한다.
  3. Actor Ownership으로 Server/Client RPC가 이동할 연결을 확인한다.
  4. 일회성 사건은 RPC, 유지되어야 하는 상태는 Property Replication으로 표현한다.
  5. 클라이언트는 의도를 요청하고, 최종 게임 상태는 서버가 결정한다.
  6. Reliable 여부와 별개로 서버는 입력값과 호출 빈도를 검증한다.

숫자 야구 프로젝트의 채팅은 이 규칙을 작게 검증한 사례다. UI 입력은 소유 PlayerController를 통해 Server RPC로 올라가고, 서버는 각 컨트롤러의 Owning Connection을 이용해 Client RPC를 내려보낸다. 접속 알림은 서버 전용 GameMode가 감지하고, 모든 컴퓨터에 존재하는 GameState가 Multicast로 전파한다. 게임 진행 상태까지 다룰 때는 Property Replication으로 현재 값을 배포해, 이벤트 전달과 상태 동기화의 책임을 분리하는 구조를 사용한다.