블로그로 돌아가기

Ghost Actor 재출현: 파괴 가능한 그리드 구조물을 위한 Multiplayer Network Replication Desync 해결 방법

게시일 2026년 6월 21일
Ghost Actor 재출현: 파괴 가능한 그리드 구조물을 위한 Multiplayer Network Replication Desync 해결 방법

핵심 요약

본 아티클은 파괴 가능한 그리드 구조물에서 발생하는 고스트 빌드(Ghost Build) 시각적 기형 현상의 원인인 client-side prediction mismatch와 out-of-order replication 문제를 분석합니다. C++ 기반의 predictive state buffer 구현을 통해 클라이언트가 예측 파괴한 액터에 대한 서버 프로퍼티 업데이트를 일정 시간 억제하고 패킷 유실 및 채널 종료 지연을 해결하는 방안을 제시합니다. 아울러 horizOn 백엔드를 활용한 low-latency 세션 및 로비 동기화 최적화와 함께, 실전 멀티플레이어 환경에서 적용할 수 있는 액터 수명 관리 최적화 가이드를 제공합니다.

Ghost Actor 재출현: 파괴 가능한 그리드 구조물을 위한 Multiplayer Network Replication Desync 해결 방법

플레이어가 채집 도구를 휘두르면, 화면에서는 나무 벽이 즉시 산산조각이 나지만, 80ms 후에 체력이 가득 찬 상태로 잠시 다시 나타났다가 400ms 후에 영구적으로 사라집니다. 흔히 "ghost build"라고 불리는 이 시각적 이상 현상은 client-side prediction mismatch와 out-of-order replication이 맞물려 발생하는 전형적인 사례입니다. 빠른 템포의 Multiplayer 환경에서 이러한 짧은 상태 rollback은 플레이어의 몰입을 방해하고 시각적인 혼란을 유발합니다. 이를 해결하려면 즉각적인 로컬 피드백과 authoritative server 검증을 정밀하게 조정하는 강력한 Multiplayer Network Replication Desync 해결책을 구현해야 합니다.

Ghost Build의 분석: Prediction 대 Replication

네트워크 게임플레이를 개발할 때, 개발자는 반응성과 server authority 간의 균형을 조율해야 합니다. 이동과 파괴가 즉각적으로 느껴지도록 하기 위해, client는 server의 승인을 받기 전에 먼저 행동의 결과를 예측(predict)하여 화면에 반영합니다. 예를 들어 플레이어가 파괴 가능한 구조물을 공격하면, client-side 게임 로직이 즉시 데미지를 계산하고 collision을 비활성화하며 파티클 이펙트를 트리거합니다.

내부적으로는 이로 인해 client 시뮬레이션이 server보다 한발 앞서가는 아주 짧은 시간의 상태 차이(divergence)가 발생합니다. 일반적인 Netcode 모델에서는 server가 client의 입력 RPC를 처리하고 새로운 상태를 다시 replication하여 돌려보낼 때 이 차이가 해결됩니다. 하지만 packet이 지연되거나 server tick rate(일반적으로 20Hz30Hz)가 client 프레임 레이트(60Hz120Hz)에 비해 뒤처지면 race condition이 발생합니다. client-side prediction은 actor를 제거하지만, server의 다음 replication 업데이트에는 여전히 actor의 이전 상태(체력이 남아 있는 생존 상태)가 포함되어 있습니다.

이 특정 race condition은 특히 나무 구조물에서 빈번하게 관찰됩니다. 석재나 금속에 비해 목재는 체력 임계값(예: 90 HP 대 300 HP)이 낮아 한 번의 타격으로 쉽게 파괴되기 때문입니다. 이로 인해 플레이어의 행동과 server의 acknowledgment 사이의 시간 차이(time window)가 극도로 좁아집니다. 이 상황에서 아주 미세한 replication 지연만 발생해도 client의 network driver는 강제로 상태를 조정(reconcile)하게 되며, server가 여전히 살아있는 것으로 보고하기 때문에 해당 actor를 월드에 다시 복원(reconstruct)하게 됩니다.

Packet Loss 및 Tick Rate의 영향

packet loss가 발생하면 client가 예측한 파괴 상태는 불안정한 모호한 상태(limbo)에 빠집니다. 만약 client가 전송한 데미지 packet이 중간에 유실(drop)되면 server는 이를 처리하지 못하지만, client는 데미지가 정상 적용되었다고 가정합니다. 이에 따라 client는 해당 actor가 사라졌다는 잘못된 가정을 바탕으로 시뮬레이션을 계속 진행합니다. 이후 server가 다음 상태 업데이트를 전송하면 상태 불일치가 드러나며, 결국 client는 actor를 월드에 다시 스폰하게 됩니다. 이러한 조정(reconciliation) 과정은 시각적으로 부자연스럽게 튀는(pop) 현상을 유발하며, 특히 packet loss가 1.5%~3% 수준으로 발생해 패킷 유실이 잦을 때 더욱 두드러집니다.

Actor Lifecycle 및 Channel Teardown의 작동 원리

Unreal Engine을 포함한 현대적인 Multiplayer 엔진들은 전용 네트워크 연결 채널을 사용하여 actor의 존재 여부를 동기화합니다. replicated actor 마다 고유의 actor channel이 할당됩니다. server에서 actor가 파괴되면 server는 해당 채널을 닫고, 채널 종료 제어 메시지(NetGUID retirement)를 client에 보냅니다.

여기서 치명적인 문제는 property replication과 채널 종료가 동일한 replication 경로를 거치지 않는다는 점입니다. 구조물의 Health 변수 변경과 같은 Property 업데이트는 직렬화되어 actor의 일반 replication 번들에 포함되어 전송됩니다. 만약 server가 데미지 이벤트를 처리했으나 아직 해당 actor를 Garbage Collection하지 않은 경우, actor가 완전히 파괴 대상으로 표기되기 전에 마지막 property 업데이트를 먼저 직렬화하여 전송할 수 있습니다. 만약 property 업데이트가 포함된 UDP packet이 채널 종료를 제어하는 packet보다 먼저 도착하면, client는 actor의 체력을 업데이트하여 로컬에서 예측했던 파괴 상태를 덮어써 버립니다.

이러한 현상은 당사 가이드인 multiplayer desyncs fixing the Unreal Engine RPC replication issue breaking your states에서 다룬 다양한 Netcode 동기화 문제와 밀접한 연관이 있습니다. 해당 가이드에서는 RPC와 property 간의 실행 순서 불일치가 어떻게 월드 상태를 오염시키는지 분석합니다. 이와 유사하게, 플레이어 위치 지정(positioning)을 처리할 때 발생하는 오차 역시 흔히 겪는 문제이며, 자세한 해결 방법은 how to fix player location desync in Uefn and Unreal Engine multiplayer 가이드에 잘 정리되어 있습니다.

client가 순서가 뒤섞인 replication packet을 처리하는 과정에서 server의 actor가 아직 살아있다고 인지하면, 해당 actor를 다시 강제로 활성 풀(active pool)로 가져옵니다. 이에 따라 client는 대개 0.4초 후에나 도달하는 채널 종료 packet을 기다린 뒤에야 비로소 해당 actor를 월드에서 완전히 삭제하게 됩니다.

로레벨에서 보면 replication packet은 일반적으로 1400바이트 크기인 MTU(Maximum Transmission Unit)의 한계를 갖습니다. 만약 게임의 대역폭 제한(예: MaxClientRate가 15000 bytes/sec로 설정된 경우)이 적용되어 있다면, 업데이트 데이터가 큐에 대기하고 여러 UDP packet으로 쪼개져 발송됩니다. 채널 종료 제어 메시지는 신뢰성 있는(reliable) 통신으로 전송되어 반드시 수신 확인(acknowledgment) 과정을 거쳐야 하는 반면, property 업데이트는 대개 비신뢰성(unreliable) 전송 방식을 사용합니다. 네트워크 지연이나 packet loss가 발생하면 신뢰성 있는 채널 종료 메시지가 이미 이전에 보낸 비신뢰성 property packet들보다 더 늦게 도착하는 상황이 연출되고, 결국 client가 actor를 재구축하는 모순이 생기게 됩니다.

Implementing a Predictive State Buffer in C++

Ghost build 문제를 근본적으로 해결하기 위해서는 client단에서 이미 파괴가 예측된 actor에 대해 들어오는 replication 업데이트를 가로채 차단해야 합니다. client-side prediction buffer를 구성하면 특정 대기 시간(예: 500ms) 동안 property reconciliation 과정을 차단함으로써 server의 채널 종료 packet이 도달할 수 있는 충분한 시간을 벌 수 있습니다. 아래는 이러한 기능을 갖춘 예측 가능한 파괴형 actor의 온전한 C++ 구현 예시입니다. 이 구현은 replication 동작을 재정의(override)하고 로컬 timestamp를 기반으로 replication 억제(suppression) 여부를 제어합니다.

// PredictedDestructibleActor.h
#pragma once

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

UCLASS()
class MULTIPLAYERGAME_API APredictedDestructibleActor : public AActor
{
    GENERATED_BODY()

public:
    APredictedDestructibleActor();

protected:
    virtual void BeginPlay() override;
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
    
    // Server-authoritative health variable
    UPROPERTY(ReplicatedUsing = OnRep_Health)
    float Health;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Destruction")
    float MaxHealth;

    // Triggered when health changes on the client
    UFUNCTION()
    void OnRep_Health();

    // Client-side prediction tracking flags
    bool bClientPredictedDestroyed;
    float ClientPredictionTime;
    
    // Maximum time (in seconds) the client will suppress server updates
    UPROPERTY(EditAnywhere, Category = "Networking")
    float PredictionTimeout;

    // Visual effect helper function
    void TriggerDestructionEffects();

public:
    // Called when the local player destroys the structure client-side
    UFUNCTION(BlueprintCallable, Category = "Destruction")
    void PredictDestruction();

    // Resets the predicted state if the server rejects the destruction
    void ResetPredictionState();

    virtual void Tick(float DeltaTime) override;
};

다음은 이에 대응하는 구현 파일로, 유입되는 server 상태들을 필터링하는 과정을 보여줍니다:

// PredictedDestructibleActor.cpp
#include "PredictedDestructibleActor.h"
#include "Net/UnrealNetwork.h"
#include "Kismet/GameplayStatics.h"

APredictedDestructibleActor::APredictedDestructibleActor()
{
    PrimaryActorTick.bCanEverTick = true;
    bReplicates = true;
    
    // Set a moderate update frequency to balance bandwidth and responsiveness
    NetUpdateFrequency = 33.0f; 
    
    MaxHealth = 100.0f;
    Health = MaxHealth;
    bClientPredictedDestroyed = false;
    ClientPredictionTime = 0.0f;
    PredictionTimeout = 0.5f; // 500ms safety window
}

void APredictedDestructibleActor::BeginPlay()
{
    Super::BeginPlay();
}

void APredictedDestructibleActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(APredictedDestructibleActor, Health);
}

void APredictedDestructibleActor::OnRep_Health()
{
    // If the client has predicted this actor's death, suppress server property updates
    if (bClientPredictedDestroyed)
    {
        return;
    }

    if (Health <= 0.0f)
    {
        TriggerDestructionEffects();
    }
}

void APredictedDestructibleActor::PredictDestruction()
{
    // Prediction only runs on the client that simulated the event
    if (GetNetMode() == NM_Client)
    {
        bClientPredictedDestroyed = true;
        ClientPredictionTime = GetWorld()->GetTimeSeconds();
        
        // Hide the actor and disable collision immediately for responsive local feedback
        SetActorEnableCollision(false);
        SetActorHiddenInGame(true);
        
        // Spawn local particles and audio instantly
        TriggerDestructionEffects();
    }
}

void APredictedDestructibleActor::ResetPredictionState()
{
    bClientPredictedDestroyed = false;
    SetActorEnableCollision(true);
    SetActorHiddenInGame(false);
}

void APredictedDestructibleActor::TriggerDestructionEffects()
{
    // Spawn local visual effects (e.g. wood splinters, dust clouds)
    // and play destruction audio.
}

void APredictedDestructibleActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (GetNetMode() == NM_Client && bClientPredictedDestroyed)
    {
        float CurrentTime = GetWorld()->GetTimeSeconds();
        
        // If the timeout expires and the server hasn't torn down the channel, 
        // the server must have rejected the damage. We must roll back.
        if (CurrentTime - ClientPredictionTime > PredictionTimeout)
        { 
            ResetPredictionState();
        }
    }
}

이 predictive buffer를 적용함으로써 새로 수신된 OnRep_Health 콜백이 해당 actor의 시각적 가시성(visibility)을 원래대로 되돌려놓지 못하도록 막을 수 있습니다. 이를 통해 채널 종료 packet이 마침내 들어오기 전까지 client-side actor는 보이지 않고 collision이 꺼진 상태를 일정 시간 안전하게 유지하게 됩니다. 만약 server가 파괴 처리를 승인하지 않는 경우(예: Anti-Cheat 검증 불일치 등)에는 timeout 기법을 통해 로컬 상태를 복원(rollback)하여 시뮬레이션이 영구적으로 desynchronize되는 상황을 예방합니다.

Rollback 및 Validation Rejection 처리

이 replication 솔루션에서 매우 중요한 또 다른 요소는 server 측에서 플레이어의 판정을 거절(reject)했을 때의 대응 방식입니다. 만약 server의 검증 로직이 플레이어가 구조물을 정상적으로 타격할 수 없는 상태였다고 판단하면 입힌 데미지를 무효화합니다. 이 경우 client는 영구 동기화 이탈을 방지하기 위해 예측 파괴를 취소(rollback)해야 합니다. 이를 위해 500ms 내에 server 측의 승인 패킷이 도착하지 않는다면 timeout에 의해 actor의 collision과 가시성이 강제로 재구동되는 로직이 설계되어 있습니다.

수동 구현의 부담 대 전용 Backend

위에서 소개한 C++ 코드는 개별 actor의 고스트 현상을 잘 대처하지만, 이러한 조치를 게임 월드 전반의 방대한 환경에 일관되게 입히는 과정은 복잡하고 고된 일입니다. 개발자는 파괴될 수 있는 모든 오브젝트 군마다 개별적인 prediction 및 rollback 코드를 수동 작성해야 하고, 활성화된 여러 prediction buffer들을 추적하며, actor tick rate 최적화 및 네트워크 replication 우선순위 관리까지 떠맡아야 합니다. 인디 게임 개발 팀 입장에서는 이러한 자잘한 edge case들을 설계하고 빌드/테스트하는 일에만 전담 네트워크 엔지니어를 붙여 4~6주 이상을 통째로 소모하기 일쑤입니다.

이러한 시스템을 손수 마련하려면 load balancer 설정부터 시작해서 database sharding, 그리고 까다로운 WebSockets/UDP 서버 아키텍처 설계가 동반되어야 합니다. horizOn을 활용하면 이러한 대다수의 Backend 인프라가 이미 사전 세팅되어 배포되므로, 복잡한 인프라 관리 부담을 덜어내고 오롯이 게임 개발과 출시에만 집중할 수 있습니다. horizOn의 실시간 lobby 관리와 session orchestration 기술은 플레이어 상태 및 매치 property 데이터를 50ms 이하의 매우 낮은 latency 환경에서 정교하게 싱크하도록 보장하여, ghost build를 야기하는 replication 지연 문제를 효율적으로 감쇄시킵니다.

Multiplayer Network Replication Desync 해결을 위한 실전 Best Practices

파괴 오브젝트에 최적화된 Netcode 성능을 구축하고 월드 동기화를 유지하려면 다음 가이드를 참고하십시오:

  1. Actor Lifecycle로부터 Visual Assets의 분리: 즉각적인 화면 피드백을 위해 AActor::Destroy()의 즉각적인 처리에 기대지 마십시오. 대신 bIsDead 같은 boolean형 replication 플래그를 두어 로컬의 파티클 시스템만 즉각 구동하고, server의 클린업 루틴이 끝나기 전에 client에서 collision을 선제 비활성화하는 방식을 취하십시오.
  2. Property 업데이트보다 Channel 소멸을 우선 처리: 파괴형 오브젝트에 bOnlyRelevantToOwner 속성을 부여하거나 NetPriority 값을 올려 네트워크 드라이버가 파괴 관련 변경점을 다른 정보보다 먼저 반영하게 제어하십시오. 일반적인 주변 환경 정보의 property replication 파이프라인에 밀려 반응 속도가 떨어지는 것을 줄여줍니다.
  3. 명확한 Prediction Timeout Window 지정: client-side prediction이 안전장치 없이 상시 구동되도록 내버려 두지 마십시오. server가 특정 공격 판정을 거부했을 때 강제 client rollback이 일어날 수 있도록 항상 안전 timeout(일반적으로 허용 최대 RTT 값의 1.5~2배에 server tick 흔들림 폭을 더한 수치)을 두십시오. 패킷 유실 등으로 인해 특정 actor가 평생 보이지 않는 기형적인 버그를 원천 봉쇄할 수 있습니다.
  4. NetUpdateFrequency 미세 조정: 평상시 파괴 구조물들의 업데이트 주기(rate)는 10-15Hz 정도로 낮게 조율해 두십시오. 그러다가 피해를 입는 이벤트가 포착되었을 때만 동적으로 업데이트 빈도를 33Hz로 일시 가속하면 유휴 대역폭 소모를 최소화하면서도 기민한 반응을 보장할 수 있습니다. 복잡한 멀티플레이 연출 속에서 네트워크 효율의 균형을 잡아줍니다.
  5. Server Validation Pipeline 고속화: server단에서 작동하는 데미지 검증(validation) 연산이 가볍고 빠르게 동작하게 만드십시오. 만약 server가 특정 타격 성립 여부를 가려내는 데 100ms 이상 소요된다면, client prediction buffer가 미리 걸어둔 timeout 시간을 초과해 버리면서 화면이 뚝뚝 끊기는 지터(jitter) 현상이 생길 수 있습니다. 검증 로직을 단순화해 연산 딜레이를 제거하십시오.

요약 및 다음 단계

Replication desync 문제를 완벽히 다스리기 위해서는 게임 엔진의 네트워크 파이프라인에 대한 심도 있는 접근이 필수적입니다. 클라이언트의 예측 파괴 판정이 떨어진 actor들을 대상으로 들어오는 server property 업데이트를 차단 및 우회시킴으로써 고질적인 ghost build 문제를 소거하고, 유저에게 지연 없는 플레이 경험을 선사할 수 있습니다.

Multiplayer Backend 서비스의 체급을 높이고 동기화 딜레마를 잠재우고 싶으신가요? 지금 horizOn 무료 평가판에 등록하시거나 API docs를 통해 여러분의 다음 프로젝트를 위한 low-latency session 동기화 해법을 확인해 보세요.


출처: Ghost builds appear shortly after breaking wooden player build structures

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

© 2026 projectmakers.de

unknown-v1.94.4 / unknown-v--