언리얼엔진/구현·실습

Unreal Engine 무기 장착 시스템 확장성 개선하기

write76465 2026. 7. 8. 15:31

프로젝트에서 무기 시스템을 구현하면서 처음에는 권총, 라이플, 샷건처럼 정해진 무기만 처리하면 충분했다. 하지만 무기가 늘어날수록 문제가 생겼다. 새 무기를 추가할 때마다 캐릭터 코드의 switch 문을 수정하고, 무기별 데이터 변수를 새로 만들고, 애니메이션 처리도 계속 추가해야 했다.

이번 글에서는 기존 무기 장착 구조의 문제점을 정리하고, DataTable과 TMap을 이용해 무기 추가가 더 쉬운 구조로 개선한 과정을 정리한다.

기존 구조의 문제

기존 코드는 무기 타입에 따라 각각 다른 FDataTableRowHandle을 선택하는 방식이었다.

FDataTableRowHandle SelectedWeaponData;

switch (NewWeaponType)
{
case EWeaponTypes::Pistol:
    SelectedWeaponData = PistolWeaponData;
    break;

case EWeaponTypes::Rifle:
    SelectedWeaponData = RifleWeaponData;
    break;

case EWeaponTypes::Shotgun:
    SelectedWeaponData = ShotgunWeaponData;
    break;
}

이 방식의 가장 큰 문제는 새 무기를 추가할 때마다 C++ 코드를 계속 수정해야 한다는 점이다.

예를 들어 Sniper를 추가하려면 다음 작업이 필요했다.

1. EWeaponTypes에 Sniper 추가
2. SniperWeaponData 변수 추가
3. EquipWeaponActor switch에 Sniper case 추가
4. 몽타주 switch에도 Sniper case 추가

무기 데이터는 DataTable에 있는데, 실제 선택 로직은 코드에 박혀 있는 구조였다. 즉 DataTable을 사용하고 있어도 데이터 기반 구조의 장점이 충분히 살아나지 않았다.

enum 공용 헤더로 분리

먼저 무기 타입 enum을 캐릭터 파일에 직접 두지 않고, 별도의 헤더로 분리했다.

#pragma once

#include "CoreMinimal.h"
#include "WeaponType.generated.h"

UENUM(BlueprintType)
enum class EWeaponType : uint8
{
    Rifle   UMETA(DisplayName = "Rifle"),
    Pistol  UMETA(DisplayName = "Pistol"),
    Shotgun UMETA(DisplayName = "Shotgun"),
    Sniper  UMETA(DisplayName = "Sniper"),
    Grenade UMETA(DisplayName = "Grenade")
};

이렇게 하면 PlayerCharacter, WeaponBase, UI, AnimBP 등 여러 시스템에서 같은 enum을 공유할 수 있다.

캐릭터 쪽에서는 다음처럼 include해서 사용한다.

#include "Weapon/WeaponType.h"

TMap으로 무기 데이터 매핑

기존에는 무기마다 개별 변수를 가지고 있었다.

FDataTableRowHandle PistolWeaponData;
FDataTableRowHandle RifleWeaponData;
FDataTableRowHandle ShotgunWeaponData;

이를 아래처럼 TMap으로 변경했다.

UPROPERTY(EditDefaultsOnly, Category = "Weapon")
TMap<EWeaponType, FDataTableRowHandle> WeaponDataMap;

구조는 단순하다.

EWeaponType::Pistol  -> DT_Weapon / Glock Row
EWeaponType::Rifle   -> DT_Weapon / AK47 Row
EWeaponType::Shotgun -> DT_Weapon / Shotgun Row

이제 캐릭터 블루프린트의 Class Defaults에서 WeaponDataMap에 항목을 추가하면 된다.

Key: Pistol
Data Table: DT_Weapon
Row Name: Glock

Key: Rifle
Data Table: DT_Weapon
Row Name: AK47

FDataTableRowHandle을 사용하면 언리얼 에디터가 자동으로 Data Table, Row Name 입력 UI를 보여준다.

EquipWeaponActor 개선

기존 switch 기반 선택 코드를 제거하고, WeaponDataMap에서 무기 데이터를 찾도록 변경했다.

bool APlayerCharacter::EquipWeaponActor(EWeaponType NewWeaponType)
{
    if (!HasAuthority()) return false;
    if (!WeaponBaseClass) return false;

    const FDataTableRowHandle* FoundWeaponData = WeaponDataMap.Find(NewWeaponType);

    if (!FoundWeaponData || FoundWeaponData->IsNull())
    {
        return false;
    }

    if (CurrentWeapon)
    {
        CurrentWeapon->Destroy();
        CurrentWeapon = nullptr;
    }

    CurrentWeapon = GetWorld()->SpawnActorDeferred<AWeaponBase>(
        WeaponBaseClass,
        FTransform::Identity,
        this,
        this,
        ESpawnActorCollisionHandlingMethod::AlwaysSpawn
    );

    if (!CurrentWeapon) return false;

    CurrentWeapon->WeaponDataRow = *FoundWeaponData;

    UGameplayStatics::FinishSpawningActor(CurrentWeapon, FTransform::Identity);

    CurrentWeapon->SetOwnerCharacter(this);

    CurrentWeapon->AttachToComponent(
        GetMesh(),
        FAttachmentTransformRules::SnapToTargetNotIncludingScale,
        TEXT("WeaponSocket")
    );

    return true;
}

이 함수는 bool을 반환하도록 만들었다. 무기 데이터가 없거나 스폰에 실패했을 때 CurrentWeaponType만 바뀌는 상황을 막기 위해서다.

내부 장착 함수 분리

RPC의 _Implementation()을 직접 호출하는 대신, 실제 장착 로직은 별도의 내부 함수로 분리했다.

void APlayerCharacter::EquipWeapon(EWeaponType NewWeaponType)
{
    if (CurrentWeaponType == NewWeaponType && CurrentWeapon)
    {
        return;
    }

    if (bIsAiming)
    {
        CancelAimInstant();
    }

    if (HasAuthority())
    {
        EquipWeaponInternal(NewWeaponType);
    }
    else
    {
        ServerSetWeaponType(NewWeaponType);
    }
}

서버 RPC는 내부 함수만 호출한다.

void APlayerCharacter::ServerSetWeaponType_Implementation(EWeaponType NewWeaponType)
{
    EquipWeaponInternal(NewWeaponType);
}

실제 장착 로직은 이 함수에 모았다.

void APlayerCharacter::EquipWeaponInternal(EWeaponType NewWeaponType)
{
    if (CurrentWeaponType == NewWeaponType && CurrentWeapon)
    {
        return;
    }

    bIsAiming = false;

    if (!EquipWeaponActor(NewWeaponType))
    {
        return;
    }

    CurrentWeaponType = NewWeaponType;

    if (IsLocallyControlled())
    {
        BP_OnWeaponChanged(GetCurrentWeaponDataRow());
    }

    MulticastPlayEquipMontage(NewWeaponType);
}

이렇게 하면 서버에서 직접 장착할 때와 클라이언트가 서버에 장착을 요청할 때 같은 로직을 사용한다.

현재 무기 데이터 조회도 TMap 기반으로 변경

UI나 장비 프리뷰에서 현재 무기 데이터를 가져오기 위해 기존에는 다시 switch를 사용하고 있었다.

FDataTableRowHandle APlayerCharacter::GetWeaponDataRowByType(EWeaponType WeaponType) const
{
    switch (WeaponType)
    {
    case EWeaponType::Pistol:
        return PistolWeaponData;

    case EWeaponType::Rifle:
        return RifleWeaponData;

    case EWeaponType::Shotgun:
        return ShotgunWeaponData;

    default:
        return FDataTableRowHandle();
    }
}

이 부분도 WeaponDataMap 기반으로 바꿨다.

FDataTableRowHandle APlayerCharacter::GetWeaponDataRowByType(EWeaponType WeaponType) const
{
    const FDataTableRowHandle* FoundWeaponData = WeaponDataMap.Find(WeaponType);

    if (!FoundWeaponData || FoundWeaponData->IsNull())
    {
        return FDataTableRowHandle();
    }

    return *FoundWeaponData;
}

이제 Sniper, Grenade 같은 새 타입이 추가되어도 이 함수는 수정하지 않아도 된다.

OnRep에서 UI 갱신

서버에서 CurrentWeaponType이 바뀌면 클라이언트는 복제를 통해 변경 사실을 받는다. 따라서 클라이언트 UI 갱신은 OnRep_CurrentWeaponType()에서 처리한다.

void APlayerCharacter::OnRep_CurrentWeaponType()
{
    if (IsLocallyControlled())
    {
        BP_OnWeaponChanged(GetCurrentWeaponDataRow());
    }
}

리스슨 서버처럼 서버이면서 로컬 플레이어인 경우에는 OnRep가 호출되지 않을 수 있기 때문에, EquipWeaponInternal()에서도 같은 갱신 이벤트를 호출했다.

if (IsLocallyControlled())
{
    BP_OnWeaponChanged(GetCurrentWeaponDataRow());
}

발사 RPC 구조 정리

처음에는 클라이언트가 WeaponBase의 서버 RPC를 직접 호출했다.

CurrentWeapon->ServerStartFire();

하지만 서버와 클라이언트를 프로세스 분리해서 실행했을 때, 무기 액터의 소유권과 복제 타이밍 문제로 RPC가 불안정할 수 있었다.

그래서 입력 RPC는 클라이언트가 확실히 소유하고 있는 PlayerCharacter를 통해 서버로 보내도록 변경했다.

void APlayerCharacter::StartFire()
{
    if (!IsLocallyControlled()) return;

    ServerStartFire();
}

서버에서는 현재 장착 무기에 발사를 명령한다.

void APlayerCharacter::ServerStartFire_Implementation()
{
    if (CurrentWeapon)
    {
        CurrentWeapon->StartFire();
    }
}

정지 입력도 같은 방식이다.

void APlayerCharacter::StopFire()
{
    if (!IsLocallyControlled()) return;

    ServerStopFire();
}

void APlayerCharacter::ServerStopFire_Implementation()
{
    if (CurrentWeapon)
    {
        CurrentWeapon->StopFire();
    }
}

이 구조에서는 클라이언트가 직접 총알을 줄이거나 데미지를 계산하지 않는다. 클라이언트는 입력만 보내고, 실제 발사와 데미지 판정은 서버에서 처리한다.

서버에서만 Fire 로그가 찍히는 이유

멀티플레이 구조에서는 탄약, 명중, 데미지 같은 중요한 값은 서버에서 처리하는 것이 일반적이다.

현재 흐름은 다음과 같다.

클라이언트 입력
-> PlayerCharacter::StartFire()
-> ServerStartFire RPC
-> 서버에서 CurrentWeapon->StartFire()
-> 서버에서 WeaponBase::Fire()
-> 서버에서 탄약 감소, 라인트레이스, 데미지 처리

따라서 Fire() 안의 로그는 서버에서만 찍히는 것이 정상이다.

UE_LOG(LogTemp, Warning, TEXT("Fire! Ammo : %d"), CurrentMagazineAmmo);

클라이언트에서 탄약 변화를 확인하려면 Fire() 로그가 아니라 OnRep_CurrentMagazineAmmo()를 확인해야 한다.

void AWeaponBase::OnRep_CurrentMagazineAmmo()
{
    UE_LOG(LogTemp, Warning, TEXT("Client Ammo Updated : %d"), CurrentMagazineAmmo);
}

즉 정상 흐름은 다음과 같다.

서버 로그:
Fire! Ammo : 29

클라이언트 로그:
Client Ammo Updated : 29

DrawDebugLine이 클라이언트에서 안 보이는 이유

라인 트레이스 디버그도 FireTrace() 안에서 실행되고, FireTrace()는 서버에서만 호출된다.

따라서 서버와 클라이언트를 프로세스 분리해서 실행하면 클라이언트 화면에 DrawDebugLine이 보이지 않는 것이 정상이다.

클라이언트에서도 디버그 라인을 보고 싶다면, 서버에서 계산한 결과를 Multicast RPC로 전달해 클라이언트 월드에서도 그려야 한다.

UFUNCTION(NetMulticast, Unreliable)
void MulticastDrawFireTrace(FVector TraceStart, FVector TraceEnd, FVector MuzzleStart, FVector MuzzleEnd);

실제 게임에서 플레이어에게 보여줄 것은 디버그 라인이 아니라 총구 화염, 사운드, 탄흔 같은 이펙트이므로, 이런 시각 효과는 MulticastPlayFireEffects()에서 처리하면 된다.

개선 결과

이번 개선으로 바뀐 점은 다음과 같다.

기존:
무기 추가 시 C++ switch 수정 필요
무기별 DataTable 변수 추가 필요
RPC 흐름이 Weapon Actor에 직접 의존

개선 후:
WeaponDataMap에 매핑만 추가하면 무기 데이터 연결 가능
UI 조회도 TMap 기반으로 변경
장착 로직을 EquipWeaponInternal로 통합
발사 입력 RPC를 PlayerCharacter 기준으로 정리
서버 권위 구조에서 로그와 복제 흐름을 명확히 이해

남은 개선 방향

아직 더 확장할 수 있는 부분도 있다.

첫 번째는 장착 몽타주다. 현재는 여전히 무기 타입별 switch로 몽타주를 선택한다.

switch (NewWeaponType)
{
case EWeaponType::Pistol:
    MontageToPlay = PistolEquipMontage;
    break;

case EWeaponType::Rifle:
    MontageToPlay = RifleEquipMontage;
    break;
}

나중에 DataTable에 EquipMontage를 추가하면 이 switch도 제거할 수 있다.

두 번째는 무기 클래스다. 지금은 공통 WeaponBaseClass로만 스폰하지만, 무기마다 다른 클래스를 사용하려면 DataTable에 TSubclassOf<AWeaponBase>를 넣을 수 있다.

UPROPERTY(EditAnywhere, BlueprintReadOnly)
TSubclassOf<AWeaponBase> WeaponClass;

세 번째는 enum 자체를 없애는 것이다. 더 데이터 중심으로 가려면 EWeaponType 대신 DataTable의 RowName을 FName WeaponId로 사용하는 구조도 가능하다. 이 방식으로 가면 새 무기를 추가할 때 C++ enum 수정도 필요 없어진다.

마무리

이번 개선의 핵심은 switch를 줄이고, 무기 데이터를 코드가 아니라 에디터와 DataTable에서 관리하도록 바꾼 것이다.

완전히 데이터 기반 구조가 된 것은 아니지만, 기존보다 무기 추가 비용이 줄었고, 장착과 발사 흐름도 더 명확해졌다. 특히 멀티플레이에서 서버와 클라이언트가 각각 어떤 역할을 하는지 정리하면서, 서버 권위 방식의 로그와 복제 흐름을 이해할 수 있었다.

앞으로는 몽타주, 무기 클래스, 발사 방식까지 DataTable로 더 옮기면 무기 시스템의 확장성을 더 높일 수 있을 것이다.