Unreal Engine RPC 최적화: 매 Tick마다 발생하는 네트워크 부하를 방지하는 방법
핵심 요약
본 가이드는 Unreal Engine에서 매 Tick마다 발생하는 RPC 호출로 인한 네트워크 과부하 문제를 해결하기 위한 Accumulator 패턴과 C++ 구현 전략을 상세히 다룹니다. 클라이언트 프레임레이트와 독립적인 네트워크 전송 제어, Struct Batching 및 Quantization과 같은 실무 최적화 기법을 통해 서버 대역폭 효율을 극대화하는 방법을 제시합니다. 아울러 최적화된 게임의 글로벌 확장을 위한 horizOn 인프라 솔루션 활용법을 함께 소개하여 개발자가 안정적인 Multiplayer 환경을 구축할 수 있도록 돕습니다.
모든 Multiplayer 게임 개발자는 결국 동일한 네트워크 Bottleneck 상황에 직면하게 됩니다. 초당 144 프레임을 실행하는 클라이언트가 자신의 커스텀 이동 State를 매 Tick마다 서버로 보내기로 결정하는 순간 말이죠. 단 몇 초 만에 서버의 네트워크 Queue는 불필요한 RPC(Remote Procedure Call)로 가득 차게 되며, 이는 극심한 Lag, Packet Loss, 그리고 필연적인 연결 끊김을 유발합니다. 여러분의 클라이언트가 사실상 자체 서버 Infrastructure에 대해 DDoS(Distributed Denial of Service) 공격을 수행하고 있는 셈입니다.
이 시나리오는 Multiplayer 게임 Architecture에서 가장 흔히 발생하는 실수 중 하나입니다. 개발자가 커스텀 플레이어 입력, 복잡한 Vehicle Physics State 또는 빠른 연사 메커니즘을 전송해야 할 때, 부드러운 반응성을 위해 Tick() 함수 내부에 RPC를 배치하는 것이 논리적인 선택처럼 보일 수 있습니다. 하지만 Unreal Engine의 네트워킹 레이어는 중간 단계의 RPC를 자동으로 제거(Cull)하지 않습니다. 게임이 매 Tick마다 RPC를 호출하면, 그 모든 호출이 Queue에 쌓이고 전송됩니다.
이동 및 위치 업데이트의 경우, 143개의 중간 프레임은 거의 중요하지 않습니다. 다른 클라이언트들에게 Replicate하기 위해 필요한 것은 오직 가장 최신의 State뿐입니다. 이 종합 가이드에서는 Unreal Engine RPC 최적화를 심도 있게 다루며, 이러한 Tick 기반 네트워크 호출을 Throttle하고, 스마트한 State 축적(Accumulation)을 구현하며, Multiplayer Bandwidth Overhead를 획기적으로 줄이는 방법을 정확히 보여드리겠습니다.
Tick 기반 네트워크 이벤트의 위험성
솔루션을 구현하기 전에 문제의 본질을 이해하는 것이 중요합니다. Unreal Engine에서 Server, Client 또는 NetMulticast 등 RPC를 선언할 때, 여러분은 엔진의 네트워크 드라이버에게 함수 파라미터를 Serialize하고 이를 나가는 패킷 Queue에 넣으라고 명령하는 것입니다.
큐잉(Queueing)의 문제점
Unreal Engine은 연결의 NetUpdateFrequency 및 Bandwidth 제한에 따라 나가는 RPC를 패킷으로 배치(Batch) 처리합니다. 클라이언트가 높은 프레임레이트에서 매 Tick마다 Server RPC를 호출하면 엔진은 그 모든 호출을 처리하려고 시도합니다.
만약 RPC가 Reliable로 설정되어 있다면 상황은 재앙 수준이 됩니다. Reliable RPC는 전달과 실행 순서를 보장합니다. 네트워크 채널은 빠르게 가득 찰 것이고, Buffer Overflow가 발생하면 엔진에 의해 연결이 강제로 종료되어 플레이어의 접속이 끊어지게 됩니다.
RPC가 Unreliable로 설정된 경우, Queue가 가득 차면 엔진은 패킷을 드롭합니다. 하드웨어적인 연결 끊김은 방지할 수 있지만, 이는 심각한 Rubber-banding 현상으로 이어집니다. 서버가 1번 프레임, 2번 프레임을 받고 3~100번 프레임을 드롭한 뒤 101번 프레임을 처리할 수도 있기 때문입니다. 결과적으로 움직임이 불규칙하고 끊겨 보여 게임 경험을 망치게 됩니다. 이는 많은 개발팀이 State를 깨뜨리는 Unreal Engine RPC Replication 이슈를 해결할 때 마주하는 흔한 근본 원인입니다.
Bandwidth 계산
구체적인 수치를 살펴봅시다. Server RPC를 통해 간단한 Vector (12 bytes)와 Rotator (12 bytes)를 보낸다고 가정해 보겠습니다. RPC 헤더 Overhead를 포함하여 호출당 약 32 bytes로 추정해 봅시다.
- 30 FPS일 때:
30 * 32 bytes = 960 bytes/second(클라이언트당 약 1 KB/s). - 144 FPS일 때:
144 * 32 bytes = 4,608 bytes/second(클라이언트당 약 4.6 KB/s). - 240 FPS일 때:
240 * 32 bytes = 7,680 bytes/second.
배틀로얄 게임의 64명 플레이어에게 이를 곱하면, 서버는 갑자기 기본적인 이동 추적만을 위해 초당 거의 0.5MB에 달하는 순수 RPC Overhead를 처리하게 됩니다. 이는 Scalability 측면에서 지속 불가능합니다.
Step 1: Accumulator 패턴으로 Tick 의존성 분리하기
Unreal Engine RPC 최적화를 위한 가장 효과적인 전략은 네트워크 전송률을 클라이언트의 렌더링 프레임레이트에서 분리하는 것입니다. Tick()에서 RPC를 직접 호출하는 대신, 매 Tick마다 로컬 변수를 업데이트하고 타이머를 사용하여 고정되고 예측 가능한 간격(예: 초당 10회 또는 20회)으로 해당 데이터를 서버에 Flush해야 합니다.
이를 Accumulator 패턴이라고 부릅니다. 클라이언트는 최신 State를 지속적으로 축적(Accumulate)하지만, 네트워크 게이트가 열릴 때만 전송합니다.
대상 빈도(Target Frequency) 식별
부드러운 Multiplayer 경험을 위해 초당 144회의 업데이트가 반드시 필요한 것은 아닙니다. 대부분의 현대적인 경쟁형 슈팅 게임은 서버를 30Hz 또는 60Hz로 구동합니다. 따라서 적절한 Client-side Prediction과 Server-side Interpolation을 사용한다면 클라이언트 업데이트를 초당 15~30회 보내는 것으로도 충분합니다.
전송률을 제한 없는 144Hz에서 고정된 20Hz로 줄임으로써, 해당 액션에 대한 네트워크 트래픽을 즉시 85% 이상 줄일 수 있습니다.
Step 2: C++에서 Rate-Limiter 구현하기
C++에서 이를 효과적으로 구현하는 방법을 살펴보겠습니다. 클라이언트가 매 Tick마다 목표 위치와 회전값을 추적하지만, 미리 정의된 네트워크 전송률에 따라 Server_UpdateTransform RPC만 보내는 시스템을 만들 것입니다.
헤더 파일 (.h)
먼저 커스텀 APawn 또는 ACharacter 클래스에서 변수와 함수를 정의합니다. 타이머 핸들, 업데이트 속도, 그리고 아직 전송되지 않은 데이터를 보유할 변수가 필요합니다.
UCLASS()
class MYGAME_API AMyCustomPawn : public APawn
{
GENERATED_BODY()
public:
AMyCustomPawn();
virtual void Tick(float DeltaTime) override;
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
protected:
virtual void BeginPlay() override;
// 서버로 데이터를 보내는 RPC. 빠르고 지속적인 업데이트를 위해 Unreliable로 표시.
UFUNCTION(Server, Unreliable, WithValidation)
void Server_SendTransformUpdate(FVector NewLocation, FRotator NewRotation);
private:
// 네트워크 Flush를 위한 타이머 핸들
FTimerHandle NetworkUpdateTimerHandle;
// 초당 몇 번 서버로 업데이트를 보낼지 설정
UPROPERTY(EditDefaultsOnly, Category = "Network")
float NetworkSendRate;
// 아직 전송되지 않은 새 데이터가 있는지 추적하는 플래그
bool bHasPendingNetworkUpdate;
// 전송 대기 중인 축적된 데이터
FVector PendingLocation;
FRotator PendingRotation;
// 데이터를 Flush하기 위해 타이머에 의해 호출되는 함수
void FlushNetworkUpdate();
};
소스 파일 (.cpp)
이제 로직을 구현합니다. BeginPlay에서 타이머를 설정하고, Tick에서 대기 중인 변수를 업데이트하며, 타이머가 실제 네트워크 전송을 처리하도록 합니다.
#include "MyCustomPawn.h"
#include "TimerManager.h"
AMyCustomPawn::AMyCustomPawn()
{
PrimaryActorTick.bCanEverTick = true;
// 기본값으로 초당 20회 업데이트 전송 설정
NetworkSendRate = 20.0f;
bHasPendingNetworkUpdate = false;
}
void AMyCustomPawn::BeginPlay()
{
Super::BeginPlay();
// 로컬에서 컨트롤되는 클라이언트만 네트워크 Flush 타이머를 실행해야 함
if (IsLocallyControlled())
{
float UpdateInterval = 1.0f / NetworkSendRate; // 예: 1.0 / 20.0 = 0.05초
GetWorld()->GetTimerManager().SetTimer(
NetworkUpdateTimerHandle,
this,
&AMyCustomPawn::FlushNetworkUpdate,
UpdateInterval,
true // 반복 루프
);
}
}
void AMyCustomPawn::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// 여기서 커스텀 클라이언트 사이드 이동 로직 실행
// 예: FVector NewLoc = ...; FRotator NewRot = ...;
// SetActorLocationAndRotation(NewLoc, NewRot);
if (IsLocallyControlled())
{
// 여기서 RPC를 직접 호출하는 대신, 최신 State만 저장
PendingLocation = GetActorLocation();
PendingRotation = GetActorRotation();
// 전송 대기 중인 신선한 데이터가 있음을 표시
bHasPendingNetworkUpdate = true;
}
}
void AMyCustomPawn::FlushNetworkUpdate()
{
// 새로운 데이터가 없다면(예: 플레이어가 가만히 서 있음) 대역폭을 낭비하지 않음
if (!bHasPendingNetworkUpdate)
{
return;
}
// 축적된 최신 State를 서버로 전송
Server_SendTransformUpdate(PendingLocation, PendingRotation);
// 다음 Tick에서 State가 다시 수정될 때까지 플래그 리셋
bHasPendingNetworkUpdate = false;
}
bool AMyCustomPawn::Server_SendTransformUpdate_Validate(FVector NewLocation, FRotator NewRotation)
{
// 여기에 Anti-cheat 검증 추가. 위치값이 타당한가?
return true;
}
void AMyCustomPawn::Server_SendTransformUpdate_Implementation(FVector NewLocation, FRotator NewRotation)
{
// 서버는 속도가 제한된 데이터를 수신하고 이를 적용함
SetActorLocationAndRotation(NewLocation, NewRotation);
// 참고: 서버는 이후 표준 Replicated 프로퍼티를 통해 이를 다른 클라이언트에 복제하며,
// 일반적으로 Multicast RPC를 사용하지 않음.
}
이 Architecture가 작동하는 이유
이 설정은 네트워크 범람 문제를 우아하게 해결합니다. 클라이언트가 30 FPS로 실행되든 300 FPS로 실행되든, 서버는 초당 정확히 NetworkSendRate만큼의 업데이트를 받는 것이 보장됩니다(패킷 손실이 없다고 가정할 때).
또한, Early-out 체크(!bHasPendingNetworkUpdate)를 구현했습니다. 플레이어가 커피를 마시러 키보드에서 떨어져 있다면 클라이언트는 RPC 전송을 완전히 중단하여 활성 플레이어를 위한 중요한 Bandwidth를 확보합니다. 이는 일관된 서버 성능을 유지하는 데 큰 도움이 됩니다.
Step 3: 다른 클라이언트의 State Interpolation 처리하기
네트워크 전송률을 줄이면 서버에서의 움직임과 결과적으로 다른 연결된 클라이언트에서의 움직임이 뚝뚝 끊겨 보이게 됩니다. 10Hz로 업데이트를 보낸다면 60 FPS 모니터에서 캐릭터가 초당 10번 순간이동하는 것처럼 보일 것입니다.
이를 해결하기 위해 캐릭터를 단순히 새 위치로 스냅(Snap)해서는 안 됩니다. 반드시 Interpolation(보간)을 사용해야 합니다. 서버가 NewLocation을 Simulated Proxy(플레이어를 관찰하는 다른 클라이언트)로 Replicate할 때, 해당 클라이언트들은 현재 위치에서 Replicate된 목표 위치까지 시간이 지남에 따라 FMath::VInterpTo 등을 통해 부드럽게 보간해야 합니다.
이를 통해 초당 5회 또는 10회와 같은 매우 공격적인 전송 제한 환경에서도 시각적으로는 매우 부드러운 움직임을 유지할 수 있습니다. Interpolation 중에 캐릭터가 비정상적으로 스냅되는 문제로 어려움을 겪고 있다면 UEFN 및 Unreal Engine Multiplayer에서 플레이어 위치 Desync를 해결하는 방법을 참고해 보시기 바랍니다.
Step 4: 복잡한 RPC를 위한 Struct Batching
게임에서 여러 다른 변수를 보내야 하는 경우, 여러 개의 별도 RPC를 보내지 마세요. 모든 RPC에는 기본 헤더 Overhead가 있습니다(최소 1~2 bytes지만, Payload Serialization을 고려하면 실제로는 더 큼).
동일한 네트워크 Flush 타이밍에 Server_SendHealth(), Server_SendArmor(), Server_SendPosition()을 호출한다면 헤더 비용을 세 번 지불하는 셈입니다.
대신 네트워크 Payload를 위한 전용 Struct(구조체)를 만드세요.
USTRUCT()
struct FPlayerNetworkState
{
GENERATED_BODY()
UPROPERTY()
FVector Location;
UPROPERTY()
FRotator Rotation;
UPROPERTY()
uint8 CurrentWeaponIndex;
UPROPERTY()
bool bIsCrouching;
};
이 단일 Struct를 타이머 기반 RPC를 통해 전달하세요. Unreal Engine의 Reflection 시스템은 이러한 변수들을 단일 패킷 Payload에 효율적으로 패킹하여 연결의 Byte 사용량을 최소화합니다.
Unreal Engine RPC 최적화를 위한 5가지 Best Practices
로컬 테스트에서 수천 명의 동시 접속자로 스케일을 키우려면 네트워크 Architecture에 다음 원칙을 도입해야 합니다.
- 게이트 없이 Tick에서 RPC를 보내지 말 것: 이를 철칙으로 삼으세요. RPC가
Tick()내부에 있다면 반드시 시간 체크(예:if (TimeSinceLastRPC > 0.1f))에 의해 보호되거나 루프 타이머를 통해 관리되어야 합니다. - Reliable보다 Unreliable을 우선할 것: 지속적으로 업데이트되는 데이터(이동, 시선 방향, 지속형 빔 무기 등)에는 항상 Unreliable RPC를 사용하세요. 패킷이 드롭되더라도 잠시 후 도착하는 다음 패킷이 어차피 이를 덮어쓰게 됩니다. Reliable RPC는 절대적인 State 변경(예: 무기 발사, 아이템 획득, 플레이어 사망)에만 엄격히 제한되어야 합니다.
- Float과 Vector에 Quantization 적용:
FVector데이터를 보낼 때 전체 부동 소수점 정밀도가 필요한 경우는 드뭅니다. Unreal Engine은 RPC에서 Vector를 양자화(Quantize)할 수 있도록 지원하며(예:FVector_NetQuantize100), 이는 값을 소수점 두 자리로 반올림하여 전송에 필요한 대역폭을 크게 절감합니다. - 다운스트림 데이터에는 표준 Replication 선호: 클라이언트는 데이터를 서버로 올리기 위해 RPC를 사용해야 하지만, 서버가 지속적인 데이터를 다시 내려보낼 때 Multicast RPC를 사용하는 경우는 드물어야 합니다. 서버는
UPROPERTY(Replicated)변수를 업데이트해야 하며, Unreal의 내장 Replication 매니저가 Bandwidth 최적화, 우선순위 지정, Relevancy 정렬을 자동으로 처리하도록 해야 합니다. - 조기에, 자주 프로파일링할 것:
net.DumpRelevantActors명령과 네트워크 프로파일러 툴(NetworkProfiler.exe)을 사용하여 RPC가 프레임당 몇 바이트를 소비하는지 시각화하세요. 최적화 이득을 추측하지 말고 실증적으로 측정하세요.
인프라 및 Backend 스케일링 처리
Unreal Engine Netcode의 복잡함을 마스터하는 것은 거대한 작업입니다. Dedicated Server가 대역폭 제한을 넘지 않고 부드럽게 실행되도록 타이머 핸들을 조정하고, Vector를 양자화하며, Desync를 완화하는 데 수많은 시간을 소비하게 됩니다.
게임 코드가 마침내 최적화되어도, 해당 서버들을 글로벌하게 배포하고 확장해야 하는 숙제가 남습니다. 이를 직접 구축하려면 Fleet Manager, Load Balancer, Database Sharding, SSL 인증서 관리 등을 설정해야 하며, 이는 실제 게임 디자인에서 멀어지게 만드는 4~6주간의 집중적인 인프라 작업을 요합니다.
horizOn을 사용하면 이러한 Backend 서비스가 게임 개발자를 위해 미리 구성된 상태로 제공됩니다. 확장 가능한 Dedicated Server 호스팅, 실시간 데이터베이스 동기화, 강력한 분석 기능을 즉시 사용할 수 있어 인프라 대신 게임 출시에 집중할 수 있습니다.
마치며
Unreal Engine RPC 최적화의 핵심은 네트워크 대역폭이 유한하고 변동성이 큰 자원임을 인식하는 것입니다. 네트워크 레이어를 표준 프레임 버퍼처럼 취급해서는 안 됩니다. Tick 중심의 실행에서 벗어나 Accumulator 패턴을 수용함으로써 게임의 데이터 출력을 완벽하게 제어할 수 있습니다. 이를 통해 서버 부하를 줄이고, 패킷 손실을 완화하며, 불안정한 인터넷 연결을 가진 플레이어들에게 훨씬 더 부드러운 경험을 제공할 수 있습니다.
게임 최적화는 지속적인 프로세스임을 기억하세요. 네트워크 범람으로부터 여러분을 구해줄 엔진의 기본 동작에 의존하지 마세요. 데이터 흐름을 명시적으로 제어하십시오. 현재 프로토타입에 이러한 속도 제한을 구현하고 네트워크 프로파일러로 전후 지표를 모니터링하며 서버 성능이 비약적으로 상승하는 것을 확인해 보시기 바랍니다.
최적화된 Multiplayer Backend를 확장할 준비가 되셨나요? horizOn을 무료로 체험하거나 API 문서를 확인하여 전문적인 게임 인프라가 얼마나 단순해질 수 있는지 확인해 보세요.