본문 바로가기
Unreal Engine/멀티플레이어 게임 개발

[언리얼 멀티플레이 기초 개념] 언리얼 네트워크 심화 메커니즘

by 4.41p._.m 2026. 8. 3.

1. 네트워크 권한 및 역할(Role) 아키텍처

 

1-1. 왜 이것이 중요한가

멀티플레이어 게임의 핵심 로직을 구현할 때, "내가 스폰한 액터의 권한이 서버와 클라이언트에서 왜 다르게 작동하는지" 이해하는 것은 필수적입니다. 언리얼 엔진의 데디케이티드 서버 아키텍처는 절대적인 서버 권한 중심 모델입니다. 서버에 스폰된 액터가 가지는 ROLE_Authority의 물리적 의미를 정확히 정의하고, 클라이언트의 프록시 분류(Autonomous, Simulated)를 코드 수준에서 판별할 수 있어야만 댕글링 포인터나 권한 침해(Cheat)가 없는 안전한 멀티플레이어 프레임워크를 팀 프로젝트에 적용할 수 있습니다.

 

1-2. Core Concepts

1-2-1. ROLE_Authority의 정의와 서버 중심 아키텍처

언리얼 엔진 멀티플레이어 아키텍처에서 모든 게임 상태의 진실(Truth)은 서버가 통제합니다. 액터가 네트워크 상에 복제(Replication)되도록 설정(bReplicates = true)된 경우, 데디케이티드 서버에서 해당 액터의 로컬 권한(Local Role)은 항상 ROLE_Authority가 됩니다.

  • 서버의 절대 권한: 서버만이 게임모드 세션을 제어하고, 캐릭터를 스폰하며, 점수를 수정하거나 데미지를 판정할 수 있습니다.
  • 클라이언트의 한계: 클라이언트는 절대 복제 액터에 대한 ROLE_Authority를 가질 수 없으며, 서버가 복제해 준 데이터를 받아 화면에 재현하는 프록시(Proxy) 역할만 수행합니다.

 

1-2-2. 클라이언트 프록시 분류 (Autonomous Proxy vs Simulated Proxy)

서버가 액터를 복제하여 클라이언트들에게 전파할 때, 각 클라이언트가 해당 액터와 맺고 있는 제어 관계에 따라 원격 역할(Remote Role)이 다르게 부여됩니다.

  1. ROLE_AutonomousProxy (자율 프록시): 내가 직접 조종하는 액터. 로컬 클라이언트가 컨트롤러를 통해 직접 입력(Input)을 전달하고 물리적으로 제어하는 본인의 캐릭터(Pawn)입니다. 서버의 승인 하에 클라이언트 측 예측(Client-side Prediction)을 수행할 수 있습니다.
  2. ROLE_SimulatedProxy (시뮬레이션 프록시): 타인이 조종하거나 서버가 통제하는 액터. 다른 플레이어가 조종하는 캐릭터나 월드에 배치된 상자, 총알 등입니다. 입력을 직접 처리하지 않으며, 서버가 전송하는 위치와 속도 데이터를 받아 부드럽게 보간(Interpolation) 연산만 처리합니다.

 

1-3. Worked Example: GetLocalRole() 판별을 통한 서버 권한 검증 매커니즘

ACXPlayerController 프레임워크 아키텍처 위에, 현재 실행 중인 호스트의 권한 환경을 명확히 판별하고 안전하게 멀티플레이어 제어 흐름을 분기하는 C++ 코드를 구현해 보겠습니다.

 

1-3-1. C++ 헤더 파일 내 함수 선언

ACXPlayerController.h 파일에 서버 권한 및 프록시 상태를 체크하여 로그를 출력할 디버그 함수를 선언합니다.

// ACXPlayerController.h
public:
    void CheckNetworkRoleAndAuthority();

 

1-3-2. 소스 파일 구현 및 HasAuthority() 검증

ACXPlayerController.cpp에서 GetLocalRole() 열거형 값을 검사하여 서버(Authority)와 클라이언트를 분기하는 안전한 제어 흐름을 작성합니다.

// ACXPlayerController.cpp
#include "Player/ACXPlayerController.h"
#include "Kismet/KismetSystemLibrary.h"

void ACXPlayerController::CheckNetworkRoleAndAuthority()
{
    // HasAuthority()는 GetLocalRole() == ROLE_Authority 인지 검사하는 매크로 함수입니다.
    if (HasAuthority())
    {
        // 데디케이티드 서버 권한 환경 내에서만 실행되는 절대 진실 로직
        UKismetSystemLibrary::PrintString(this, TEXT("Host: Dedicated Server (ROLE_Authority)"), true, true, FLinearColor::Red, 5.0f);
    }
    else
    {
        // 클라이언트 프록시 환경인 경우
        ENetRole LocalRole = GetLocalRole();
        if (LocalRole == ROLE_AutonomousProxy)
        {
            UKismetSystemLibrary::PrintString(this, TEXT("Client: Owning Player (ROLE_AutonomousProxy)"), true, true, FLinearColor::Green, 5.0f);
        }
        else if (LocalRole == ROLE_SimulatedProxy)
        {
            UKismetSystemLibrary::PrintString(this, TEXT("Client: Other Player Proxy (ROLE_SimulatedProxy)"), true, true, FLinearColor::Blue, 5.0f);
        }
    }
}

2. 로우레벨 UNetConnection 및 소유권 체인(Ownership Family)

 

2-1. 왜 이것이 중요한가

멀티플레이어 UI 개발 및 채팅 시스템 실습 과정에서 "클라이언트가 보낸 Server RPC 패킷이 왜 서버로 전송되지 않고 무시되는가?"에 대한 답을 찾으려면 엔진의 로우레벨 네트워크 연결 객체인 UNetConnection과 소유권 구조(Ownership Chain)의 동작 원리를 명확히 이해해야 합니다. 언리얼 엔진의 모든 네트워크 패킷과 RPC는 액터에서 시작하여 소유권 체인을 타고 해당 클라이언트와 연결된 전용 통신 채널을 거쳐 전송됩니다. 이 연결 고리를 정확히 파악해야만 팀 프로젝트에서 패킷 누락이나 동기화 불능 오류가 없는 견고한 C++ 프레임워크를 설계할 수 있습니다.

 

2-2. Core Concepts

2-2-1. UNetDriver와 UNetConnection 아키텍처

언리얼 엔진의 로우레벨 네트워크 계층에서 소켓 통신을 추상화하고 관리하는 핵심 모듈은 UNetDriver와 UNetConnection입니다. 이들은 서버와 클라이언트의 호스트 환경에 따라 서로 다른 구조로 인프라를 형성합니다.

  • UNetDriver (넷 드라이버): 네트워크 드라이버는 호스트의 물리 소켓과 연결되어 하위 네트워크 레이어를 총괄합니다. 데디케이티드 서버는 단 하나의 UNetDriver를 통해 접속된 모든 클라이언트와의 연결을 관리합니다.
  • 서버의 ClientConnections 관리: 서버 측 넷 드라이버는 현재 서버에 접속한 각 원격 클라이언트들을 식별하기 위해 개별 클라이언트당 하나씩 UNetConnection 객체를 인스턴스화하여 배열 형태로 관리합니다.
  • 클라이언트의 ServerConnection: 반면 클라이언트 측 프로세스에 존재하는 넷 드라이버는 오직 본인이 접속해 있는 '진실의 주체'인 서버와의 1:1 통신만을 전담하므로, 단 하나의 ServerConnection 객체만을 소유합니다.

 

2-2-2. 소유권 체인(Ownership Family)과 GetNetConnection()

클라이언트가 서버로 RPC(Remote Procedure Call)를 전송하거나, 서버가 특정 클라이언트에게 데이터를 복제(Replication)할 때 엔진은 "이 액터가 어느 네트워크 연결(통신 채널)에 소속되어 있는가?"를 판별해야 합니다. 이때 기준이 되는 것이 액터의 Owner 속성과 GetNetConnection() 가상 함수입니다.

  1. PlayerController의 커넥션 바인딩: 클라이언트가 서버에 접속하여 인증을 마치면, 서버는 해당 클라이언트를 위해 APlayerController를 스폰하고 로우레벨의 UNetConnection을 이 컨트롤러에 직접 바인딩합니다. APlayerController::GetNetConnection()은 자신에게 연결된 이 커넥션을 직접 반환합니다.
  2. Pawn으로의 소유권 확장: 플레이어가 월드에 존재하는 캐릭터(APawn)를 빙의(Possess)하면, 해당 폰의 Owner는 자신을 제어하는 APlayerController로 설정됩니다. APawn::GetNetConnection()은 내부적으로 자신의 Owner인 컨트롤러를 참조하여 연결된 커넥션을 찾아냅니다.
  3. 무기/인벤토리 액터의 소유권 체인 형성: 캐릭터가 획득한 무기(Weapon)나 아이템 액터의 소유권(SetOwner())을 해당 캐릭터 폰으로 지정하면, [Weapon -> Pawn -> PlayerController -> UNetConnection]으로 이어지는 소유권 체인(Ownership Family)이 완성됩니다.

액터의 GetNetConnection()은 이 소유권 체인의 최상단에 APlayerController가 존재할 때만 유효한 커넥션 포인터를 반환하며, 최상단에 컨트롤러가 없는 독립 액터는 커넥션이 없으므로(Null) Server RPC를 보낼 수 없습니다.

 

2-3. Worked Example: 소유권 확인을 통한 Server RPC 발신 가능 여부 검증

ACXPlayerController와 채팅 입력 위젯 아키텍처를 기반으로, 소유권 구조를 C++ 단에서 검증하고 안전하게 네트워크 채널을 확인하는 절차를 구현해 보겠습니다.

 

2-3-1. 액터의 커넥션 유효성 검사 함수 정의

무기나 인벤토리 같은 일반 Replicated 액터 내부에서 현재 GetNetConnection()이 정상적인 포인터를 반환하는지 확인하는 논리 블록을 구성합니다.

// CXWeapon.cpp
bool ACXWeapon::CheckNetworkOwnership()
{
    // 최상위 Owner인 PlayerController로부터 이어지는 커넥션을 탐색합니다.
    UNetConnection* MyConnection = GetNetConnection();
    
    if (MyConnection != nullptr)
    {
        // 커넥션이 존재하므로 이 액터는 Server RPC를 보낼 자격이 있습니다.
        return true;
    }
    
    return false;
}

 

2-3-2. 소유권 강제 설정을 통한 통신 채널 활성화

서버에서 무기 액터를 스폰할 때, 반드시 클라이언트가 제어하는 폰을 Owner로 지정하여 소유권 체인을 명시적으로 연결해 줍니다.

// CXGameModeBase.cpp
void ACXGameModeBase::SpawnWeaponForPlayer(APawn* PlayerPawn)
{
    if (IsValid(PlayerPawn) && HasAuthority())
    {
        FActorSpawnParameters SpawnParams;
        SpawnParams.Owner = PlayerPawn; // 폰을 Owner로 지정하여 소유권 체인 형성
        
        ACXWeapon* NewWeapon = GetWorld()->SpawnActor<ACXWeapon>(WeaponClass, PawnLocation, PawnRotation, SpawnParams);
    }
}

3. NetMode 아키텍처 및 서버 중심 데미지 판정

3-1. 왜 이것이 중요한가

멀티플레이어 게임 개발에서 현재 코드가 실행 중인 프로세스의 네트워크 권한(NetMode)을 정확히 식별하는 것은 보안과 동기화의 기본입니다. 특히 클라이언트가 임의로 데미지나 재화 획득을 판정하여 서버로 보낼 경우, 메모리 변조(핵)에 무방비로 노출됩니다. NetMode 아키텍처를 명확히 이해하고 내부 판정 메커니즘을 파악해야만 해킹이 불가능한 서버 권한 중심의 안전한 멀티플레이어 프레임워크를 팀 프로젝트에 구축할 수 있습니다.

 

3-2. Core Concepts

3-2-1. NetMode의 4가지 상태 아키텍처

언리얼 엔진은 현재 실행 중인 게임 프로세스의 네트워크 역할을 ENetMode 열거형으로 정의합니다. 각 모드는 렌더링(UI/비주얼) 여부와 호스트 소켓 개방 여부에 따라 동작이 완전히 달라집니다.

  • NM_Standalone: 네트워크 연결이 없는 싱글 플레이어 상태입니다. 로컬 프로세스가 완벽한 권한(Authority)을 가집니다.
  • NM_DedicatedServer: 그래픽 화면과 UI가 없는 순수 백그라운드 서버 프로세스(Headless)입니다. 절대적인 게임 진실(Truth)을 통제하며 외부 클라이언트의 접속을 수신합니다.
  • NM_ListenServer: 플레이어 중 한 명이 호스트(방장)가 되어 화면을 렌더링하면서 동시에 외부 유저의 접속을 받는 상태입니다.
  • NM_Client: 서버에 접속한 원격 클라이언트입니다. 게임의 핵심 로직을 직접 결정할 권한이 없으며, 서버의 데이터를 받아 화면을 그리는 복제 대상입니다.

 

3-2-2. 내부 NetMode 결정 흐름 메커니즘

언리얼 엔진 내부에서 월드(UWorld)의 NetMode가 어떻게 판정되는지 가상 코드를 통해 분석해 보면 다음과 같습니다.

// UWorld 내부의 NetMode 판정 가상 소스 코드
ENetMode UWorld::GetNetMode() const
{
    // 1. 네트워크 통신을 전담하는 넷 드라이버가 존재하는지 확인
    if (NetDriver != nullptr)
    {
        // 넷 드라이버 인터페이스를 통해 결정된 NetMode를 반환
        return NetDriver->GetNetMode();
    }

    // 2. 넷 드라이버가 없고 standalone 실행 인자가 있다면 NM_Standalone
    if (GIsClient && GIsServer)
    {
        return NM_Standalone;
    }
    
    // 3. 데디케이티드 서버 스위치(-server)로 켜진 경우
    if (IsRunningDedicatedServer())
    {
        return NM_DedicatedServer;
    }

    return NM_Standalone;
}

엔진은 런타임에 단순히 변수를 읽는 것이 아니라, UNetDriver의 활성화 여부와 프로세스 시작 명령어(-server, -game 등)를 조합하여 호스트의 네트워크 실행 환경을 실시간으로 추론합니다.

 

3-3. Worked Example: NetMode 검증을 통한 안전한 서버 데미지 판정 로직

ACXPlayerController 프레임워크 구조 위에, 왜 데미지 판정과 같은 핵심 로직이 서버에서 처리되어야 하는지 검증하는 구조적 제어 흐름을 구현해 보겠습니다.

 

3-3-1. 클라이언트 입력 검증 및 서버 권한 판정 절차

  1. 실행 컨텍스트 판정: 공격 이벤트가 발생했을 때 현재 월드의 NetMode를 GetNetMode()로 호출하여 자신이 NM_Client인지 NM_DedicatedServer / NM_ListenServer인지 확인합니다.
  2. 클라이언트 입력 검증 및 RPC 요청: 만약 로컬 실행 모드가 NM_Client라면 직접 데미지를 깎지 않고, 본인이 소유권을 가진 PlayerController를 통해 서버에 "공격했습니다"라는 RPC 요청 패킷만 전송합니다.
  3. 서버 권한(Authority) 단에서의 최종 판정: 요청을 받은 서버(NM_DedicatedServer)는 클라이언트의 상태를 검증하고, HasAuthority() 블록 내부에서 최종적으로 체력 변수를 차감한 뒤 클라이언트들에게 결과를 리플리케이션합니다.

4. 네트워크 환경별 디버깅 파이프라인 구축

 

4-1. 왜 이것이 중요한가

언리얼 엔진 멀티플레이어 개발을 진행할 때, 현재 실행 중인 코드 블록이 서버(Server)인지, 아니면 특정 클라이언트(Client)인지 명확히 구분하여 디버깅하는 것은 핵심 기술입니다. 특히 멀티플레이어 환경에서는 단 하나의 로직 오류로 인해 서버 프로세스가 크래시되거나, 특정 클라이언트의 UI에만 패킷이 누락되는 현상이 빈번하게 발생합니다. 액터의 GetNetMode() 함수를 활용하여 각 호스트의 네트워크 상태를 판별하고, 안전하게 화면 디버그 메시지와 내부 시스템 로그(UE_LOG)를 출력하는 사용자 정의 디버깅 로그 함수를 구현할 수 있어야만 복잡한 RPC 파이프라인을 트러블슈팅할 수 있습니다.

 

4-2. Core Concepts

4-2-1. GetNetMode()를 통한 네트워크 환경 판별

언리얼 엔진의 UWorld 또는 AActor 클래스는 현재 해당 객체가 어떤 네트워크 컨텍스트 안에서 실행되고 있는지를 나타내는 ENetMode 열거형을 반환합니다. 멀티플레이어 디버깅 함수를 설계할 때 가장 먼저 체크해야 하는 아키텍처적 변수입니다.(위에 설명이 있지만 한 번 더 보면 기억에 남을 겁니다.)

  • NM_Standalone: 네트워크가 비활성화된 싱글 플레이어 모드입니다.
  • NM_DedicatedServer: 그래픽 및 UI 렌더링이 아예 존재하지 않는 순수 백그라운드 서버 프로세스입니다. 이 모드에서는 화면에 PrintString을 호출해도 시각적으로 출력되지 않으므로 반드시 UE_LOG를 통해 파일 로그로 남겨야 합니다.
  • NM_ListenServer: 플레이어 중 한 명이 방장이 되어 클라이언트 화면을 그리면서 동시에 호스트 서버 소켓을 개방한 상태입니다.
  • NM_Client: 서버에 접속하여 폰을 제어하고 리플리케이션 데이터를 수신하는 원격 클라이언트입니다.

 

4-2-2. UKismetSystemLibrary::PrintString과 UE_LOG 파이프라인

C++ 프레임워크 단에서 디버깅 로그를 일관성 있게 관리하기 위해 아래 세 가지 단계를 결합한 사용자 정의 매크로나 함수를 구성합니다.

  1. NetMode 검사 (실행 주체 판별): GetWorld()->GetNetMode()를 호출하여 현재 로직이 실행 중인 머신의 네트워크 권한 상태를 확인합니다.
  2. 접두사(Prefix) 분기 처리 (서버와 클라이언트 구분): 로그의 서두에 [Server], [Client 1], [ListenServer] 등 어떤 환경에서 발생한 로그인지 식별할 수 있는 문자열 접두사를 동적으로 생성합니다.
  3. 렌더링 유무에 따른 동적 출력 (화면 표시 및 파일 저장): NM_DedicatedServer일 경우 화면 출력을 스킵하고 UE_LOG 시스템 로그만 수행하며, 클라이언트나 리슨 서버 환경일 경우 UKismetSystemLibrary::PrintString을 사용해 화면에 지정한 색상(예: 서버는 빨간색, 클라이언트는 파란색)으로 텍스트를 드로우합니다.

 

4-3. Worked Example: 네트워크 모드별 디버그 로그 함수 구현

ACXPlayerController의 채팅 시스템 위에, 실행 컨텍스트에 따라 안전하게 로그를 남기는 디버깅 함수를 구현해 보겠습니다.

 

4-3-1. C++ 헤더 파일 선언

CXPlayerController.h에 디버그 함수를 선언합니다.

// CXPlayerController.h 에 추가할 함수 선언
void MyPrintString(const FString& InMessage);

 

4-3-2. 소스 파일 구현 (접두사 생성, System Log 및 화면 Draw 분기)

ACXPlayerController.cpp에 네트워크 상태를 판별하여 로그를 출력하는 절차를 작성합니다.

void ACXPlayerController::MyPrintString(const FString& InMessage)
{
    // 1. 네트워크 상태 확인 및 접두사 정의
    FString NetModePrefix = TEXT("[Unknown]");
    ENetMode NetMode = GetWorld()->GetNetMode();

    if (NetMode == NM_Client) {
        NetModePrefix = FString::Printf(TEXT("[Client %d]"), GPlayInEditorID);
    } else if (NetMode == NM_DedicatedServer) {
        NetModePrefix = TEXT("[DedicatedServer]");
    } else if (NetMode == NM_ListenServer) {
        NetModePrefix = TEXT("[ListenServer]");
    } else if (NetMode == NM_Standalone) {
        NetModePrefix = TEXT("[Standalone]");
    }

    // 2. 최종 출력 문자열 합성 및 시스템 로그 남기기
    FString FinalMessage = FString::Printf(TEXT("%s %s"), *NetModePrefix, *InMessage);
    UE_LOG(LogTemp, Log, TEXT("%s"), *FinalMessage);

    // 3. 화면 드로우 분기 처리 (Headless 서버가 아닐 때만 PrintString 실행)
    if (NetMode != NM_DedicatedServer) {
        FLinearColor DisplayColor = (NetMode == NM_Client) ? FLinearColor::Blue : FLinearColor::Red;
        UKismetSystemLibrary::PrintString(this, FinalMessage, true, true, DisplayColor, 5.0f);
    }
}