블로그로 돌아가기

World States를 망치는 Unreal Engine Multiplayer Sync Bug (및 해결 방법)

게시일 2026년 2월 23일
World States를 망치는 Unreal Engine Multiplayer Sync Bug (및 해결 방법)

거대하고 시네마틱한 월드 트랜스포메이션을 구축하는 데 수개월을 보냈다고 가정해 봅시다. 싱글 플레이어에서는 완벽하게 실행됩니다. 오래된 유령의 집은 지형 아래로 가라앉고, 깨끗한 새 버전이 타이밍에 맞춰 심연에서 솟아오릅니다. 하지만 두 번째 플레이어가 서버에 접속하는 순간, 당신의 걸작은 오브젝트가 겹쳐버리는 플레이 불가능한 악몽으로 변합니다. 집들이 합쳐지고, Collision이 깨지며, 플레이어들은 지오메트리 연옥에 갇히게 됩니다.

모든 멀티플레이어 인디 개발자는 결국 클라이언트 측 비주얼 로직이 Server-Authoritative한 현실과 격렬하게 충돌하는 벽에 부딪힙니다. 만약 Cinematic Sequence Device나 로컬 플레이어 이벤트로 트리거되는 timeline 애니메이션을 사용해 수백 개의 에셋을 이동시키려 한다면, 당신은 사실상 Unreal Engine Multiplayer Sync Bug를 자초하고 있는 것입니다.

이 튜토리얼에서는 방대한 양의 Actor를 이동시키는 것이 왜 치명적인 desync를 유발하는지, 왜 Cinematic Sequences가 Late-joiners에게 실패하는지, 그리고 C++와 Unreal Engine 5의 Data Layers를 사용하여 어떻게 견고한 Server-Authoritative 월드 스테이트 매니저를 설계하는지 분석해 보겠습니다.

Desync의 해부: 왜 집들이 합쳐지는가

문제를 해결하려면 먼저 Unreal Engine의 Replication 시스템이 왜 당신의 트랜스포메이션 시퀀스에서 과부하가 걸리는지 그 배후의 수학을 이해해야 합니다.

당신의 시퀀스가 "집 1"을 "집 2"로 교체하기 위해 약 450개의 개별 에셋(벽, 프롭, 조명)을 이동시킨다고 가정해 봅시다. Replicated Actor를 이동할 때, Unreal Engine은 네트워크를 통해 위치, 회전, 속도를 동기화하기 위해 FRepMovement 구조체를 사용합니다.

표준 압축 이동 업데이트는 Actor당 약 40~50바이트의 비용이 듭니다.

450개의 Actor가 5초 동안의 시네마틱 시퀀스 중에 동시에 이동하며 초당 30회의 빈도로 업데이트된다면, 계산은 다음과 같습니다. 450 actors × 50 bytes × 30 updates/sec = 675,000 bytes per second (675 KB/s).

Unreal Engine의 기본 MaxClientRate(서버가 단일 클라이언트에 보낼 수 있는 최대 대역폭)는 일반적으로 초당 15,000에서 100,000바이트 사이로 제한됩니다.

당신의 시퀀스는 가용 대역폭의 거의 7배를 요구하고 있습니다. 네트워크 채널은 즉시 포화 상태가 됩니다. 서버는 업데이트를 공격적으로 제한(throttling)하기 시작하고, 패킷을 드롭하며, NetPriority에 따라 다른 Actor의 우선순위를 정합니다. 그 결과, 집 1 에셋의 절반은 땅속 중간에서 멈추고, 집 2 에셋의 절반은 지표면에 도달하지 못합니다. 결국 영구적으로 합쳐지고 동기화가 깨진 엉망진창인 상태가 됩니다.

게다가, 이 시퀀스를 클라이언트 측 이벤트(예: 플레이어가 trigger box를 밟는 경우)를 통해 로컬에서 트리거하면, 10분 후에 서버에 접속한 플레이어는 시퀀스를 절대 실행하지 않습니다. 첫 번째 플레이어는 변형된 상태를 보지만, 나중에 온 플레이어는 기본 맵 상태를 보게 됩니다.

1단계: Transform 조작 대신 Data Layers 사용하기

450개의 Actor를 이동시키는 것은 CPU 사이클과 네트워크 대역폭을 낭비하는 브루트 포스 방식입니다. Unreal Engine 5에서 거대한 월드 변화를 위한 올바른 아키텍처 접근 방식은 Data Layers(Level Streaming의 진화형)입니다.

"집 1"을 땅속으로 이동시키는 대신, 모든 집 1 에셋을 House1_DataLayer에, 모든 집 2 에셋을 House2_DataLayer에 할당합니다. 타임라인이 전환될 때 첫 번째 레이어를 언로드하고 두 번째 레이어를 로드하기만 하면 됩니다.

이렇게 하면 대역폭 병목 현상이 완전히 제거됩니다. 675 KB/s의 지속적인 이동 데이터를 스트리밍하는 대신, 서버는 "Data Layer 2가 이제 활성화됨"이라는 작고 단일한 상태 업데이트를 보냅니다. 클라이언트의 로컬 엔진은 디스크에서 로딩을 원활하게 처리합니다.

2단계: Server-Authoritative 스테이트 매니저 설계하기

늦게 접속한 플레이어를 포함한 모든 플레이어가 정확히 동일한 World State를 볼 수 있도록 하려면 중앙의 "진실의 원천(source of truth)"이 필요합니다. C++에서 RepNotify 변수를 사용하여 집의 현재 시대를 추적하는 WorldStateManager Actor를 만들 것입니다.

헤더 파일 (WorldStateManager.h)

상태를 정의할 Enum과 ReplicatedUsing 조건이 있는 Replicated 변수가 필요합니다.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Info.h"
#include "WorldDataLayers/WorldDataLayers.h"
#include "WorldStateManager.generated."

UENUM(BlueprintType)
enum class EWorldEraState : uint8
{
    Past_House1 UMETA(DisplayName = "Past (House 1)"),
    Future_House2 UMETA(DisplayName = "Future (House 2)")
};

UCLASS()
class MYGAME_API AWorldStateManager : public AInfo
{
    GENERATED_BODY()

public:
    AWorldStateManager();

    // The server-side function to trigger the transformation
    UFUNCTION(BlueprintCallable, BlueprintAuthorityOnly, Category = "World State")
    void AdvanceWorldEra();

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

    // The replicated variable tracking our current state
    UPROPERTY(ReplicatedUsing = OnRep_CurrentEra, Transient)
    EWorldEraState CurrentEra;

    // The RepNotify function that fires on clients when CurrentEra changes
    UFUNCTION()
    void OnRep_CurrentEra();

    // Helper to toggle Data Layers
    void UpdateDataLayers(EWorldEraState NewState);
};

구현 파일 (WorldStateManager.cpp)

여기서 마법이 일어납니다. DOREPLIFETIME을 사용하여 변수를 등록하는 방법과 OnRep 함수가 비주얼 상태와 논리 상태의 일치를 어떻게 보장하는지 확인하세요.

#include "WorldStateManager.h"
#include "Net/UnrealNetwork.h"
#include "Engine/World.h"
#include "WorldPartition/DataLayer/DataLayerSubsystem.h"

AWorldStateManager::AWorldStateManager()
{
    bReplicates = true;
    bAlwaysRelevant = true; // Ensure all players always receive updates for this actor
    CurrentEra = EWorldEraState::Past_House1;
}

void AWorldStateManager::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    
    // Replicate to all clients
    DOREPLIFETIME(AWorldStateManager, CurrentEra);
}

void AWorldStateManager::BeginPlay()
{
    Super::BeginPlay();
    
    // Ensure the initial state is set correctly on the server
    if (HasAuthority())
    { 
        UpdateDataLayers(CurrentEra);
    }
}

void AWorldStateManager::AdvanceWorldEra()
{
    // Only the server can change the era
    if (!HasAuthority()) return;

    CurrentEra = EWorldEraState::Future_House2;
    
    // The server updates its own local Data Layers immediately
    UpdateDataLayers(CurrentEra);
}

// This fires automatically on clients when the server changes CurrentEra
void AWorldStateManager::OnRep_CurrentEra()
{
    UpdateDataLayers(CurrentEra);
}

void AWorldStateManager::UpdateDataLayers(EWorldEraState NewState)
{
    UWorld* World = GetWorld();
    if (!World) return;

    UDataLayerSubsystem* DataLayerSubsystem = World->GetSubsystem<UDataLayerSubsystem>();
    if (!DataLayerSubsystem) return;

    // Pseudocode for Data Layer toggling - replace with your specific Data Layer Asset references
    if (NewState == EWorldEraState::Past_House1)
    {
        // Load House 1, Unload House 2
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House1Layer, EDataLayerRuntimeState::Activated);
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House2Layer, EDataLayerRuntimeState::Unloaded);
    }
    else if (NewState == EWorldEraState::Future_House2)
    {
        // Load House 2, Unload House 1
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House2Layer, EDataLayerRuntimeState::Activated);
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House1Layer, EDataLayerRuntimeState::Unloaded);
    }
}

3단계: Late-Joiner 문제 해결하기

개발자들이 Unreal Engine Multiplayer Sync Bug를 해결하려 할 때 저지르는 가장 큰 실수는 월드 이벤트를 트리거하기 위해 Multicast RPCs(Remote Procedure Calls)를 사용하는 것입니다.

Multicast_PlayHouseTransformation()과 같은 Multicast RPC를 사용하면, 해당 밀리초에 서버에 연결되어 있는 클라이언트에서만 실행됩니다. 플레이어의 연결이 끊겼다가 30초 후에 다시 접속하면 RPC를 놓치게 됩니다. 다른 모든 플레이어는 집 2를 보고 있는데, 그 플레이어는 맵에 로드되어 집 1을 보게 될 것입니다.

UPROPERTY(ReplicatedUsing = OnRep_CurrentEra)를 사용하면 Late-joiner 문제를 자동으로 해결할 수 있습니다. 새 플레이어가 접속하면 서버는 CurrentEra의 현재 값을 보냅니다. 수신한 값(Future_House2)이 기본 초기값(Past_House1)과 다르기 때문에, Unreal Engine은 해당 클라이언트가 로드되는 순간 자동으로 OnRep_CurrentEra()를 호출합니다. 즉시 올바른 Data Layer가 로드됩니다. 별도의 접속 로직이 필요하지 않습니다.

더 작은 세션 기반 프로토타입을 제작 중이라면 How To Architect A Local Co Op Shooter Prototype In Unreal Engine Step By Step 가이드를 확인해 보세요.

게임 세션 이후의 월드 스테이트 유지

위의 C++ 솔루션은 단일 실행 서버 인스턴스에는 완벽합니다. 하지만 서버가 다운되면 어떻게 될까요? 또는 모든 플레이어가 로그아웃하고 서버가 꺼진 후에도 몇 주 동안 "시대"가 저장되어야 하는 지속적인 서바이벌 호러 게임을 만들고 있다면 어떨까요?

이런 경우 Unreal Engine의 메모리 내 Replication에만 의존하는 것은 한계가 있습니다. 글로벌 월드 스테이트를 유지하려면 백엔드 데이터베이스가 필요합니다.

이를 직접 구축하려면 PostgreSQL 데이터베이스 설정, 상태 직렬화를 처리할 REST APIs 작성, 서버 인증 관리, auto-scaling 인프라 구성 등 최소 4~6주의 지루한 백엔드 작업이 필요합니다.

horizOn을 사용하면 이러한 백엔드 서비스가 미리 구성되어 제공됩니다. SDK를 통해 월드 스테이트 변경 사항을 관리형 Game State 데이터베이스로 직접 푸시할 수 있습니다. 전용 서버가 가동되면 horizOn 백엔드에 쿼리하여 {"CurrentEra": "Future_House2"}를 가져오고 WorldStateManager를 초기화하기만 하면 플레이어는 중단한 지점부터 원활하게 이어서 플레이할 수 있습니다. 데이터베이스 마이그레이션을 작성하는 대신 호러 게임 디자인에 집중할 수 있습니다.

게임에 백엔드와의 즉각적인 양방향 통신이 필요한 경우(예: 패치 없이 전 세계 월드 스테이트를 변경하는 라이브 옵스 이벤트 트리거), Ditch Http Polling An Unreal Engine Websockets Tutorial For Real Time Backends에 대한 분석도 읽어보시기 바랍니다.

멀티플레이어 상태 동기화를 위한 5가지 모범 사례

치명적인 Unreal Engine Multiplayer Sync Bug를 다시는 겪지 않으려면 아키텍처에 다음 규칙을 적용하세요.

  1. 논리적 상태에 Sequences를 사용하지 마세요: Cinematic Sequence Devices와 Timelines는 엄격하게 비주얼 효과(VFX, 카메라 흔들림, 로컬 UI)를 위해서만 사용해야 합니다. 게임플레이에 영향을 주는 변수를 설정할 때 타임라인이 끝나는 것에 의존하지 마세요.
  2. 이벤트에는 RPC, 상태에는 RepNotify: 일시적인 이벤트(수류탄 폭발, 사운드 재생)에는 Multicast RPC를 사용하세요. 지속적인 상태(문 열림, 집 변형, 발전기 가동)에는 RepNotify가 포함된 Replicated 변수를 사용하세요.
  3. 대역폭 제한 준수: 네트워크 프로파일러(Stat Net)를 모니터링하세요. 50~100개 이상의 Actor에 대해 동시에 Transform을 복제하고 있다면 채널이 포화 상태일 가능성이 높습니다. 거의 움직이지 않는 프롭에는 Network Dormancy(ENetDormancy::DORM_Initial)를 사용하세요.
  4. bAlwaysRelevant를 신중하게 설정하세요: 전역 상태 매니저(예: AWorldStateManager)의 경우 bAlwaysRelevant = true를 보장하세요. 이 Actor가 플레이어의 네트워크 컬링 거리(network cull distance)를 벗어나면 업데이트 수신이 중단되어 국지적인 desync가 발생할 수 있습니다.
  5. Server Authority는 절대적입니다: 클라이언트는 서버에 "요청"만 보내야 합니다(예: Server_RequestInteract()). 서버는 요청을 검증하고 Replicated 변수를 업데이트한 다음, Replication 시스템이 비주얼 변경 사항을 모든 클라이언트에 전파하도록 합니다.

엔진과 싸우지 마세요

멀티플레이어 게임 개발은 매우 어렵기로 유명하지만, 동기화 버그의 90%는 클라이언트 측 도구로 서버 측 작업을 수행하려 할 때 발생합니다. 브루트 포스 방식의 Transform 조작에서 Data Layers로 전환하고, 로컬 트리거 대신 RepNotifies를 활용하면 게임을 Unreal Engine이 의도한 네트워크 아키텍처에 맞출 수 있습니다.

인프라 고민 없이 멀티플레이어 백엔드를 확장하고 월드 스테이트를 유지할 준비가 되셨나요? horizOn을 무료로 체험하거나 API docs를 확인하여 Unreal 프로젝트에 지속적인 클라우드 상태를 얼마나 쉽게 통합할 수 있는지 알아보세요.


출처: Houses Merged Weirdly HELPPPP

이 대시보드는 다음에 의해 애정을 담아 만들어졌습니다 Projectmakers

© 2026 projectmakers.de

unknown-v1.91.1 / unknown-v--