Runtime에서 Blueprint Defaults 수정이 실패하는 이유: 안전한 Unreal Engine 업그레이드 샵 아키텍처 설계
핵심 요약
Unreal Engine에서 Runtime 중 CDO(Class Default Object)를 수정하면 예기치 못한 serialization 에러와 복제(replication) 문제가 발생하므로, 무기 업그레이드 stats를 시각적 Actor로부터 완전히 분리해야 합니다. 무기의 핵심 데이터를 `APlayerState`와 같은 persistent 클래스에 struct 형식으로 저장하면 무기가 파괴되거나 교체되어도 업그레이드 상태를 안정적으로 유지할 수 있습니다. Multiplayer 환경에서는 변조를 방지하기 위해 Server-Authoritative 검증 방식을 구현해야 하며, 영구적인 진행도 저장을 위해 Backend 데이터베이스 또는 [horizOn](https://horizon.pm)과 같은 SDK를 연동하여 안전하게 관리해야 합니다.
Unreal Engine에서 모듈형 무기 시스템을 구축하느라 몇 주를 보내고, 소총을 위한 자식 Blueprint를 인스턴스화하고, 재장전 속도를 업그레이드할 수 있는 깔끔한 UMG 상점 메뉴를 설정했습니다. 하지만 플레이어가 무기를 교체하거나 레벨을 재시작하는 순간 업그레이드가 사라지는 현상을 발견하게 됩니다. 설상가상으로 widget 메뉴에서 Blueprint 클래스로 캐스팅하여 변수를 수정하려고 해도 아무런 반응이 없습니다. 이는 progression 시스템을 구축하는 개발자들에게 악명 높은 병목 현상입니다.
이번 튜토리얼에서는 Runtime Blueprint 수정이 실패하는 이유를 파헤쳐보고, persistent 무기 시스템을 설계하는 방법과 이러한 업그레이드 데이터를 데이터베이스에 안전하게 저장하는 방법을 알아보겠습니다.
핵심 문제: Runtime에서 Blueprint Class Defaults 수정이 실패하는 이유
Unreal Editor 내부의 Blueprint에서 변수를 편집할 때, 여러분은 CDO(Class Default Object)를 수정하고 있는 것입니다. CDO는 게임 월드에 스폰되는 해당 클래스의 모든 인스턴스에 대한 마스터 템플릿 역할을 합니다. 하지만 Runtime에서 CDO를 직접 수정하는 것은 강력히 제한되어 있으며, 여기에는 합당한 이유가 있습니다. CDO 변수를 변경하면 향후 스폰되는 모든 인스턴스의 기본값이 변경될 위험이 있으며, 이로 인해 serialization 문제가 발생하고 replication이 깨질 수 있습니다.
가장 흔히 하는 실수는 클래스 레퍼런스(TSubclassOf<AActor>)를 해당 클래스의 인스턴스로 캐스팅하려고 시도하는 것입니다. 만약 widget 메뉴에 "Weapon Class" 유형의 변수(예: BP_Rifle_Child)가 있고 이 변수를 직접 설정하려고 하면, 플레이어의 손에 들려 있는 활성화된 Actor가 아니라 클래스 템플릿을 타겟팅하게 됩니다. 설령 활성화된 Actor 인스턴스(예: 현재 장착된 소총)로 캐스팅하는 데 성공하여 BaseDamage를 25에서 50으로 변경하더라도, 그 변경 사항은 일시적(transient)일 뿐입니다.
플레이어가 소총을 넣고 권총으로 전환한 후 다시 소총을 장착하는 순간, 게임은 기존 소총 Actor를 파괴하고 새로운 Actor를 스폰합니다. 새로운 Actor는 CDO 템플릿으로부터 새로 생성되므로, 업그레이드했던 BaseDamage는 다시 기본값인 25로 리셋됩니다. 업그레이드를 유지하려면 무기의 stats를 월드에 스폰되는 시각적 Actor로부터 분리해야 합니다.
Persistent 무기 시스템의 아키텍처
이를 해결하려면 State(무기 stats)와 Presentation(무기를 렌더링하고 투사체 스폰을 처리하는 Actor)을 분리해야 합니다. 무기 Actor 자체에 결정 권한이 있는 damage, reload speed, ammo capacity를 저장하는 대신, persistent 데이터 구조에 저장해야 합니다. 이 구조는 Actor가 파괴되어도 유지되는 클래스 내, 즉 APlayerState, AGameState 또는 커스텀 인벤토리 컴포넌트 내에 위치해야 합니다.
Multiplayer 게임의 경우, 이러한 변수들을 커스텀 컴포넌트에 저장하려면 소유권(ownership) 관리에 주의해야 합니다. 무기 상태를 커스텀 ActorComponent에 저장한다면, replication 중 Actor 컴포넌트 소유권 불일치로 인해 발생하는 multiplayer inventory nightmares 문제를 겪지 않도록 주의해야 합니다. 신뢰할 수 있는 데이터를 APlayerState에 보관하면 플레이어가 사망하거나, 레벨을 변경하거나, 무기를 교체하더라도 데이터가 보존됩니다.
플레이어가 업그레이드 상점을 열면 UI widget은 플레이어의 데이터 상태와 직접 상호작용합니다. 업그레이드를 구매하면 무기 Actor가 아닌 persistent struct가 수정됩니다. 플레이어가 무기를 장착하면 캐릭터 클래스가 무기 Actor를 스폰하고, 플레이어의 상태에 저장된 데이터 구조를 사용하여 즉시 초기화합니다. 이렇게 하면 새로 스폰된 모든 무기가 올바르게 업그레이드된 stats를 상속받도록 보장할 수 있습니다.
단계별 구현: 데이터와 Actor 로직 분리하기
이 분리된 아키텍처를 C++로 깔끔하게 구현해 보겠습니다. 업그레이드 가능한 값을 가지는 FWeaponStats struct와 동적으로 설정할 수 있는 베이스 무기 클래스를 정의할 것입니다.
1. 무기 stats Struct 정의
먼저 데이터 구조를 정의합니다. 이 구조체는 Blueprint에서 접근 가능하므로, UI widget 및 디자이너용 자식 Blueprint에서 stats를 쉽게 읽고 쓸 수 있습니다.
#pragma once
#include "CoreMinimal.h"
#include "WeaponStats.generated.h"
USTRUCT(BlueprintType)
struct FWeaponStats
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float BaseDamage = 25.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float ReloadSpeedModifier = 1.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
int32 MaxAmmo = 30;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
int32 CurrentUpgradeLevel = 0;
};
무기 stats를 단일 FWeaponStats struct로 캡슐화함으로써 serialize, replicate 및 데이터 전달을 매우 간단하게 처리할 수 있습니다. 5개의 개별 replicated float 변수를 관리하는 대신 단일 struct만 replicate하므로 replication 오버헤드가 줄어듭니다.
2. 베이스 무기 클래스 생성
다음으로 베이스 무기 클래스인 AWeaponBase를 생성합니다. 이 클래스는 월드 내의 물리적인 Actor를 나타내며, 새로운 stats로 자신을 초기화하는 함수를 포함합니다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "WeaponStats.h"
#include "WeaponBase.generated.h"
UCLASS()
class SHOOTER_API AWeaponBase : public AActor
{
GENERATED_BODY()
public:
AWeaponBase();
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Weapon")
USkeletalMeshComponent* WeaponMesh;
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Weapon")
FWeaponStats WeaponStats;
UFUNCTION(BlueprintCallable, Category = "Weapon")
void InitializeWeapon(const FWeaponStats& NewStats);
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
#include "WeaponBase.h"
#include "Net/UnrealNetwork.h"
AWeaponBase::AWeaponBase()
{
PrimaryActorTick.bCanEverTick = false;
bReplicates = true;
WeaponMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("WeaponMesh"));
RootComponent = WeaponMesh;
}
void AWeaponBase::InitializeWeapon(const FWeaponStats& NewStats)
{
WeaponStats = NewStats;
// Apply changes dynamically to the active actor
// e.g., Adjust weapon mesh scale, update firing rate variables, or UI indicators
}
void AWeaponBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AWeaponBase, WeaponStats);
}
초기화 함수에서는 전달받은 stats를 replicated WeaponStats 변수에 할당합니다. 실제 프로젝트에서는 능력치 변경에 따라 탄창 메쉬 크기 조정, 발사 속도 타이머 변경, 파티클 시스템 스케일 수정 등 시각적 또는 기능적 조정도 함께 트리거하게 됩니다. 능력치가 변경될 때마다 20MB 크기의 Blueprint 에셋을 다시 로드하는 대신 48바이트 크기의 C++ struct를 전달하므로 상당한 메모리 오버헤드를 아낄 수 있습니다.
3. 업그레이드 상점 Widget과 Player State 통합
업그레이드를 확실한 권한 하에 제어(authoritative)하려면, UI 상점 메뉴는 일시적인 Actor 인스턴스로 직접 캐스팅을 시도하는 대신 PlayerState와 상호작용해야 합니다. 플레이어 stats를 관리하고 업그레이드를 검증하는 AShooterPlayerState 클래스를 설계해 보겠습니다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerState.h"
#include "WeaponStats.h"
#include "ShooterPlayerState.generated.h"
UCLASS()
class SHOOTER_API AShooterPlayerState : public APlayerState
{
GENERATED_BODY()
public:
AShooterPlayerState();
UPROPERTY(ReplicatedUsing = OnRep_WeaponInventory, BlueprintReadOnly, Category = "Inventory")
TArray<FWeaponStats> WeaponInventory;
UFUNCTION(BlueprintCallable, Category = "Inventory")
FWeaponStats GetWeaponStats(int32 WeaponIndex) const;
UFUNCTION(Server, Reliable, WithValidation, BlueprintCallable, Category = "Inventory")
void Server_UpgradeWeapon(int32 WeaponIndex);
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Economy")
int32 PlayerGold = 500;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
protected:
UFUNCTION()
void OnRep_WeaponInventory();
};
#include "ShooterPlayerState.h"
#include "Net/UnrealNetwork.h"
AShooterPlayerState::AShooterPlayerState()
{
bReplicates = true;
}
FWeaponStats AShooterPlayerState::GetWeaponStats(int32 WeaponIndex) const
{
if (WeaponInventory.IsValidIndex(WeaponIndex))
{
return WeaponInventory[WeaponIndex];
}
return FWeaponStats();
}
bool AShooterPlayerState::Server_UpgradeWeapon_Validate(int32 WeaponIndex)
{
if (!WeaponInventory.IsValidIndex(WeaponIndex)) return false;
int32 UpgradeCost = (WeaponInventory[WeaponIndex].CurrentUpgradeLevel + 1) * 100;
return PlayerGold >= UpgradeCost;
}
void AShooterPlayerState::Server_UpgradeWeapon_Implementation(int32 WeaponIndex)
{
int32 UpgradeCost = (WeaponInventory[WeaponIndex].CurrentUpgradeLevel + 1) * 100;
PlayerGold -= UpgradeCost;
FWeaponStats& Stats = WeaponInventory[WeaponIndex];
Stats.CurrentUpgradeLevel++;
Stats.BaseDamage += 10.0f;
Stats.ReloadSpeedModifier *= 0.9f;
}
void AShooterPlayerState::OnRep_WeaponInventory()
{
// Update local UI representation or bind to UI delegates
}
void AShooterPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AShooterPlayerState, WeaponInventory);
DOREPLIFETIME(AShooterPlayerState, PlayerGold);
}
AShooterPlayerState 클래스는 플레이어의 재화와 무기 stats 인벤토리를 관리합니다. RPC(Server_UpgradeWeapon) 및 replication을 활용하면 서버가 플레이어 stats에 대한 유일한 신뢰 원천(single source of truth)으로 유지됩니다.
Server_UpgradeWeapon_Validate 함수를 주의 깊게 살펴보세요. Unreal의 RPC validation 시스템은 골드가 부족한데도 무기 업그레이드를 시도하는 등 잘못된 요청을 보내는 클라이언트의 연결을 자동으로 종료(kick)시킵니다.
서버가 업그레이드를 처리하면 골드를 차감하고 stats를 업데이트한 후 변경 사항을 클라이언트로 복제(replicate)합니다. 이 복제 과정에서 replication 콜백을 통해 Client-Side UI 업데이트가 자동으로 트리거됩니다.
업그레이드 상점의 Client-Side 취약점 예방하기
게임이 Multiplayer이거나 싱글 플레이어 경제를 메모리 에디터로부터 보호하고 싶다면, 클라이언트가 직접 업그레이드를 관리하도록 신뢰해서는 안 됩니다. Client-Side UI widget이 Server_UpgradeWeapon(int32 NewDamage)을 직접 호출하도록 허용하면, 해커가 네트워크 패킷을 가로채거나 Cheat Engine 같은 도구를 사용하여 무기 데미지가 999,999라고 주장하는 패킷을 쉽게 보낼 수 있습니다.
엄격한 Server-Authoritative 검증이 없다면, 클라이언트의 로컬 UI는 업그레이드된 무기를 가지고 있다고 생각하지만 서버는 기존의 레벨 1 stats를 기준으로 데미지를 처리하는 치명적인 multiplayer state desyncs 현상이 발생합니다. 이를 방지하려면 클라이언트는 Server_RequestUpgrade(FName WeaponID)와 같이 업그레이드를 진행하겠다는 의도만 전달하고, 서버가 권한을 가지고 트랜잭션을 실행해야 합니다.
서버의 검증 흐름은 다음과 같은 단계를 따라야 합니다.
- 자원 검증(Verify Resources): 플레이어가 업그레이드를 구매하는 데 필요한 충분한 골드나 스크랩을 실제로 보유하고 있는지 확인합니다.
- 업그레이드 경로 검증(Validate Upgrade Path): 요청된 업그레이드가 올바른 순서대로 진행되는지 확인합니다 (예: 레벨 2에서 레벨 3으로 이동하는지 확인하며, 갑자기 레벨 10으로 건너뛰지 않는지 검증).
- 비용 차감 및 적용(Deduct Cost and Apply): 서버에서 재화를 차감하고 플레이어의 persistent stats struct를 업데이트합니다.
- Replicate 및 동기화(Replicate and Sync): 업데이트된 struct를 클라이언트로 다시 replicate하여 장착된 무기가 자동으로 업데이트되도록 합니다.
Backend 데이터베이스에 업그레이드 유지하기
stats를 PlayerState에 유지하는 방식은 단일 매치 중에는 잘 작동하지만, 플레이어가 게임을 종료하거나 서버가 재시작되는 순간 모두 사라집니다. 진정한 progression 루프를 만들기 위해서는 이러한 동적 변수 변경 사항을 영구적인 Backend 데이터베이스에 저장해야 합니다.
이를 직접 수동으로 구축하는 것은 대규모 작업입니다. SQL 또는 NoSQL 데이터베이스를 호스팅하고, OAuth2 인증을 지원하는 API gateway를 설정하고, JSON 페이로드를 파싱할 커스텀 서버 로직을 구현해야 하며, 연결 타임아웃이나 데이터베이스 샤딩 같은 예외 상황도 처리해야 합니다. 이러한 인프라 설정은 핵심 게임플레이 루프 개발에 집중하는 시간을 빼앗아 가며, 최소 4~6주의 전담 개발 기간이 소요될 수 있습니다.
이럴 때 horizOn을 사용하면 Backend 데이터베이스 코드를 작성하지 않고도 플레이어의 progression 데이터를 클라우드에 안전하게 저장할 수 있습니다. Backend SDK를 사용하여 FWeaponStats struct를 JSON으로 직렬화(serialize)하고 Server-Authoritative 보안 규칙과 함께 플레이어의 클라우드 프로필에 직접 저장할 수 있습니다. 일반적인 무기 stats JSON 페이로드는 500바이트 미만(기본 로드아웃의 경우 약 320바이트)으로, 데이터베이스 연산이 15ms 내에 실행됩니다. 이러한 처리 속도 덕분에 UI 전환이 매끄럽게 유지되며, 플레이어가 상점 메뉴를 열거나 거래를 완료할 때 버벅거림(stuttering)을 겪지 않도록 보장합니다.
예를 들어, Backend에서 업그레이드 구매를 검증하는 안전한 Cloud Code 함수를 작성할 수 있습니다. 플레이어가 widget 메뉴에서 "Buy Upgrade"를 클릭하면 게임은 안전한 API 요청을 보냅니다. 데이터베이스는 플레이어의 인벤토리를 확인하고 재화를 차감한 다음, 새로운 무기 레벨을 기록하고 업데이트된 stat 페이로드를 반환합니다. 이를 통해 플레이어가 로컬 메모리를 변조하더라도 권한이 있는 클라우드 데이터베이스의 안전성이 손상되지 않도록 보장합니다.
Unreal Engine 업그레이드 시스템 모범 사례
업그레이드 상점이 안정적이고 우수한 성능을 발휘하도록 하려면 다음 핵심 원칙을 준수해야 합니다.
- Never Edit Class Default Objects (CDOs) at Runtime: CDO는 읽기 전용 Blueprint로 취급하십시오. 기본 비주얼 메쉬 및 초기 템플릿을 스폰하는 목적으로만 사용해야 합니다.
- Actor로부터 stats 분리: 결정 권한이 있는 stats 정보는
APlayerState나GameInstance와 같은 persistent 클래스에 보관하고, Actor가 스폰될 때 전달해 줍니다. - 서버 권한 적용 (Enforce Server Authority): 클라이언트가 직접 stats 변경을 결정하지 못하도록 하세요. 클라이언트는 업그레이드를 요청하고, 서버는 자원을 검증한 후 변경 사항을 적용합니다.
- Serialization을 위해 Struct 사용: 업그레이드 가능한 stats를 USTRUCT로 그룹화합니다. 이렇게 하면 로컬 저장이나 Backend 데이터베이스 호출 시 복잡함 없이 매끄러운 serialization이 가능합니다.
- 데이터베이스 페이로드 최적화: 플레이어 프로필을 가볍게 유지하세요. 데이터베이스에는 업그레이드 레벨(예:
WeaponLevel: 3)만 저장하고, 실제 연산에 필요한 float 값(예:Damage: 45.0f)은 게임 서버에서 재구성합니다.
요약 및 다음 단계
자식 Blueprint의 동적 변수 업데이트 문제를 해결하려면 Actor 중심 설계에서 데이터 중심 설계로 전환해야 합니다. PlayerState 내의 persistent struct에 stats를 보관하고 무기를 동적으로 초기화함으로써, 무기를 교체할 때 업그레이드가 유실되는 현상을 방지할 수 있습니다. 이 progression 시스템을 Multiplayer로 확장하고 클라우드 보안을 연동할 준비가 되었다면, 검증된 Backend 데이터베이스를 연동하는 작업이 필수적입니다.
progression 시스템을 확장하고 플레이어 인벤토리를 안전하게 구축할 준비가 되셨나요? 지금 바로 horizOn을 무료로 시작하거나 API 문서를 확인하여 backend 구축을 시작해 보세요.