언리얼엔진/구현·실습

UE5 멀티플레이 환경에서 인벤토리–무기 장착 파이프라인 연결하기

write76465 2026. 7. 11. 01:35

인벤토리의 무기 아이템을 장비 슬롯으로 이동하면 UI 아이콘뿐 아니라 캐릭터의 실제 무기 Actor까지 교체되도록 구현했다.

단순히 위젯에서 무기를 생성하는 방식은 피했다. UI는 무기의 식별자인 RowName만 전달하고, 실제 검증과 Actor 생성은 서버 권한을 가진 캐릭터가 담당하도록 책임을 분리했다.

DT_Item
→ WeaponRowName 전달
→ PlayerCharacter 서버 RPC
→ 서버 데이터 검증
→ 기존 무기 제거
→ 새 무기 생성 및 부착
→ 장착 상태 복제

 

 


1. 아이템 데이터와 무기 데이터의 결합도 낮추기

프로젝트에서는 아이템 데이터와 무기 데이터를 별도의 데이터테이블로 관리한다.

  • DT_Item: 인벤토리 표시, 아이콘, 가격, 아이템 분류
  • DT_Weapon: 무기 성능, 무기 타입, 무기 Actor 초기화 데이터

두 테이블의 RowName을 강제로 동일하게 맞추는 대신, DT_Item에 WeaponRowName 필드를 추가해 명시적으로 연결했다.

DT_Item
RowName: Item_Rifle_001
ItemType: Weapon
WeaponRowName: AK47
DT_Weapon
RowName: AK47

따라서 실제 일치 조건은 다음과 같다.

DT_Item.WeaponRowName == DT_Weapon.RowName

이 구조는 인벤토리용 식별자와 전투 시스템용 식별자를 분리할 수 있다는 장점이 있다. 아이템 테이블의 네이밍 규칙이 바뀌더라도 무기 테이블까지 연쇄적으로 수정할 필요가 없다.

 


2. UI는 RowName만 전달하고, 장착 권한은 서버가 소유하도록 구성

장비 UI에서 무기 Actor를 직접 생성하면 클라이언트별 상태 불일치가 발생할 수 있다. 따라서 UI는 WeaponRowName만 캐릭터에게 전달한다.

UFUNCTION(BlueprintCallable, Category = "Weapon")
void EquipWeaponByRowName(FName WeaponRowName);
void APlayerCharacter::EquipWeaponByRowName(FName WeaponRowName)
{
    if (WeaponRowName.IsNone())
    {
        return;
    }

    if (HasAuthority())
    {
        EquipWeaponByRowNameInternal(WeaponRowName);
    }
    else
    {
        ServerEquipWeaponByRowName(WeaponRowName);
    }
}

void APlayerCharacter::ServerEquipWeaponByRowName_Implementation(
    FName WeaponRowName
)
{
    EquipWeaponByRowNameInternal(WeaponRowName);
}

클라이언트는 장착을 요청할 수 있지만, 기존 무기 제거와 새 무기 생성은 서버에서만 실행된다.

이렇게 역할을 나눴다.

Widget
→ 장착 의도 및 WeaponRowName 전달

PlayerCharacter
→ 서버 RPC, 데이터 검증, 현재 무기 상태 관리

WeaponBase
→ 발사, 재장전 등 무기 동작

3. 클라이언트가 보낸 RowName을 서버에서 검증

클라이언트가 보낸 RowName을 그대로 신뢰하지 않고, 캐릭터에 등록된 WeaponDataMap에서 장착 가능한 데이터인지 검증했다.

bool APlayerCharacter::FindWeaponByRowName(
    FName WeaponRowName,
    EWeaponType& OutWeaponType,
    FDataTableRowHandle& OutWeaponData
) const
{
    if (WeaponRowName.IsNone())
    {
        return false;
    }

    for (const TPair<EWeaponType, FDataTableRowHandle>& Pair :
        WeaponDataMap)
    {
        const FDataTableRowHandle& RowHandle = Pair.Value;

        if (RowHandle.IsNull() ||
            RowHandle.RowName != WeaponRowName)
        {
            continue;
        }

        if (!RowHandle.DataTable ||
            !RowHandle.DataTable->FindRowUnchecked(WeaponRowName))
        {
            return false;
        }

        OutWeaponType = Pair.Key;
        OutWeaponData = RowHandle;
        return true;
    }

    return false;
}

이 검증은 다음 두 가지를 확인한다.

  • 캐릭터에게 허용된 무기 Row인지
  • 해당 Row가 데이터테이블에 실제로 존재하는지

WeaponDataMap은 데이터 매핑과 서버 측 화이트리스트 역할을 동시에 수행한다.

다만 이것만으로 플레이어가 해당 아이템을 실제로 소유하고 있는지까지 검증하지는 못한다. 서버 인벤토리가 완성되면 장착 RPC에서 소유권 검증을 추가할 예정이다.

WeaponDataMap을 에디터에 불러온 모습

 


4. Deferred Spawn을 이용해 생성 전 무기 데이터 주입

무기 Actor는 SpawnActorDeferred로 생성했다.

bool APlayerCharacter::EquipWeaponActorFromRow(
    const FDataTableRowHandle& WeaponData
)
{
    if (!HasAuthority() ||
        !WeaponBaseClass ||
        WeaponData.IsNull())
    {
        return false;
    }

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

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

    if (!CurrentWeapon)
    {
        return false;
    }

    CurrentWeapon->WeaponDataRow = WeaponData;

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

    CurrentWeapon->SetOwnerCharacter(this);

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

    return true;
}

일반적인 SpawnActor 이후 데이터를 설정하는 대신, FinishSpawningActor() 이전에 WeaponDataRow를 주입했다.

덕분에 무기 Actor의 초기화 시점부터 올바른 데이터에 접근할 수 있고, 생성 직후 데이터가 비어 있는 일시적인 상태를 피할 수 있다.


5. 무기 타입이 아니라 RowName을 복제한 이유

기존 구조는 CurrentWeaponType만 복제하고 있었다. 하지만 같은 타입의 다른 무기로 교체하면 타입 값이 변하지 않는다.

예를 들면 다음 두 무기는 모두 Rifle이다.

AK47 → Rifle
M4A1 → Rifle

Rifle → Rifle 교체에서는 CurrentWeaponType의 RepNotify가 발생하지 않을 수 있다. 따라서 현재 장착 무기를 정확하게 식별하기 위해 CurrentWeaponRowName을 별도로 복제했다.

UPROPERTY(
    ReplicatedUsing = OnRep_CurrentWeaponRowName,
    BlueprintReadOnly,
    Category = "Weapon"
)
FName CurrentWeaponRowName = NAME_None;
DOREPLIFETIME(
    APlayerCharacter,
    CurrentWeaponRowName
);

RowName이 변경되면 로컬 UI와 장비 프리뷰를 갱신할 수 있다.

void APlayerCharacter::OnRep_CurrentWeaponRowName()
{
    if (!IsLocallyControlled())
    {
        return;
    }

    EWeaponType WeaponType{};
    FDataTableRowHandle WeaponData;

    if (FindWeaponByRowName(
        CurrentWeaponRowName,
        WeaponType,
        WeaponData
    ))
    {
        BP_OnWeaponChanged(WeaponData);
    }
}

6. 트러블슈팅: UI는 바뀌는데 무기가 장착되지 않았다

증상

인벤토리의 AK47을 장비 슬롯으로 옮기면 아이콘은 정상적으로 표시됐지만, 캐릭터 무기는 변경되지 않았다.

처음에는 WBP_Equipment의 SetWeaponEquipSlot 함수에 장착 호출을 추가했다. 그러나 디버그 출력조차 발생하지 않았다.

가설 분리

문제를 데이터, 캐스팅, 실행 경로 세 가지로 나눠 확인했다.

1. DT_Item 조회에 성공하는가?
2. WeaponRowName이 AK47로 전달되는가?
3. PlayerCharacter Cast가 성공하는가?
4. 해당 Blueprint 함수가 실제로 실행되는가?

Print String을 Row Found 경로에 추가했지만 아무 출력도 발생하지 않았다.

이는 RowName 오류나 Cast 실패보다 앞선 단계, 즉 해당 함수 자체가 실행되지 않는다는 의미였다.

원인

SetWeaponEquipSlot은 정의되어 있었지만 실제 인벤토리 이동 로직에서 호출되지 않는 함수였다.

아이콘 변경은 다른 경로에서 자식 슬롯의 SetEquipSlot을 직접 호출해 처리하고 있었다. 따라서 장착 코드를 아무리 추가해도 실행될 수 없었다.

해결

함수 참조와 실제 슬롯 갱신 호출 위치를 추적해 WBP_InvenEquip의 아이템 이동 분기를 찾았다.

실제 데이터 변경 경로는 다음과 같았다.

Get Data Table Row
→ Break Item Data
→ Switch on EItemType
→ Weapon
→ Set Equip Slot
→ Clear Item Slot

장착 코드를 UI 보조 함수가 아니라 아이템 이동이 확정되는 이 경로에 배치했다.

Switch on EItemType: Weapon
→ Set Equip Slot
→ Get Owning Player Pawn
→ Cast To BP_PlayerCharacter
→ Equip Weapon By Row Name
→ Clear Item Slot
→ Clear Selected Item

 

 

WBP_InvenEquip의 아이템 이동 분기부분

 


7. 트러블슈팅: PlayerCharacter Cast가 항상 실패한 문제

실제 장착 경로로 코드를 옮긴 뒤에도 캐릭터 장착 함수가 호출되지 않았다.

Cast 노드를 확인한 결과 Object 핀이 연결되지 않은 상태였다. 위젯에는 캐릭터 참조가 기본으로 존재하지 않기 때문에 소유 플레이어의 Pawn을 명시적으로 전달해야 했다.

Get Owning Player Pawn.ReturnValue
→ Cast To BP_PlayerCharacter.Object

Get Player Character(0)을 사용하면 싱글플레이에서는 동작할 수 있지만, 로컬 플레이어가 여러 명이거나 멀티플레이 환경에서는 올바른 소유자를 보장하기 어렵다.

따라서 위젯을 소유한 플레이어를 기준으로 Pawn을 가져오도록 구성했다.


8. 구현 결과

최종적으로 인벤토리의 AK47을 장비 슬롯으로 이동하면 다음 과정이 실행된다.

DT_Item 조회
→ ItemType이 Weapon인지 확인
→ WeaponRowName 추출
→ 장비 슬롯 UI 갱신
→ 소유 플레이어 Pawn 획득
→ 서버 장착 RPC 호출
→ WeaponDataMap 검증
→ 기존 무기 제거
→ AK47 생성
→ WeaponSocket 부착
→ 현재 무기 RowName 복제

UI는 무기 생성 방법이나 무기 클래스에 대해 알 필요가 없고, 캐릭터 역시 인벤토리 위젯의 내부 구조를 알 필요가 없다. 두 시스템은 WeaponRowName만을 계약으로 사용한다.


9. 남은 개선 사항

장비 해제 시 기본 무기로 복귀

이 프로젝트에서는 장비 해제가 빈손 상태가 아니라 기본 피스톨 복귀를 의미한다.

따라서 별도의 Unequip 로직보다 다음 인터페이스가 자연스럽다.

UFUNCTION(BlueprintCallable, Category = "Weapon")
void EquipDefaultWeapon();
void APlayerCharacter::EquipDefaultWeapon()
{
    const FDataTableRowHandle DefaultWeaponData =
        GetWeaponDataRowByType(EWeaponType::Pistol);

    if (DefaultWeaponData.IsNull())
    {
        return;
    }

    EquipWeaponByRowName(DefaultWeaponData.RowName);
}

장비 슬롯을 완전히 비우는 경로에서만 기본 피스톨을 장착하고, 무기 간 교환에서는 새로운 무기의 RowName을 바로 전달할 예정이다.

데이터 구조 확장

현재 WeaponDataMap은 EWeaponType을 Key로 사용한다.

TMap<EWeaponType, FDataTableRowHandle>

이 구조는 Rifle 타입당 Row 하나만 등록할 수 있으므로 AK47과 M4A1을 동시에 지원하기 어렵다.

무기 종류가 늘어나면 다음과 같은 방향으로 변경할 수 있다.

  • FName → FDataTableRowHandle Map 사용
  • 단일 DT_Weapon을 보유하고 RowName으로 직접 조회
  • Weapon 데이터 구조체에 EWeaponType 포함
  • 서버 인벤토리에서 소유권과 장착 가능 여부를 함께 검증

마무리

이번 작업의 핵심은 단순히 Blueprint 노드를 연결하는 것이 아니라, UI 상태 변경과 게임플레이 상태 변경이 서로 다른 실행 경로에 존재한다는 사실을 찾아낸 것이었다.

특히 디버그 출력이 발생하지 않는 상황에서 데이터 오류를 계속 의심하지 않고, 실행 경로 자체가 호출되는지부터 역으로 추적했다. 그 결과 사용되지 않는 보조 함수가 아니라 실제 인벤토리 상태가 변경되는 지점에 장착 로직을 배치할 수 있었다.

또한 UI가 Actor를 직접 제어하지 않도록 RowName 기반 인터페이스를 두고, 실제 검증과 생성은 서버 권한의 캐릭터가 담당하도록 구성했다. 이를 통해 인벤토리 UI와 전투 시스템 사이의 결합도를 낮추면서 멀티플레이 확장 가능성도 확보했다.