TIL - 파츠 시스템 전환, 설계 및 구현

Kyu_·2026년 6월 29일

32주차

목록 보기
1/5

파츠 슬롯 시스템 전환


1. 처음 구조 — enum 기반 슬롯

어떤 구조였나

UENUM(BlueprintType)
enum class ENSPartSlot : uint8
{
    Body,
    Arm,
    Leg
};

슬롯은 C++ enum으로 고정. 파츠 관련 모든 구조체/컴포넌트/UI가 이 enum에 의존하고 있었다.

// 세이브 데이터
TSet<ENSPartSlot> UnlockedSlots;   // 캐릭터별
TArray<FNSPartSaveData> OwnedParts; // 캐릭터별

// UI
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* BodySlotButton;
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* ArmSlotButton;
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* LegSlotButton;

// VisualComponent
for (const ENSPartSlot Slot : { ENSPartSlot::Body, ENSPartSlot::Arm, ENSPartSlot::Leg })

무엇이 문제였나

"슬롯을 하나 추가하면 어떻게 되는가?"를 생각했을 때 문제가 명확해졌다:

  1. C++ enum에 값 추가 → 리컴파일 필요
  2. switch/if 분기 전부 수정 → 빠뜨리면 런타임 버그
  3. UI 위젯에 버튼 UPROPERTY 추가 → UMG 에디터 열어서 바인딩
  4. VisualComponent 하드코딩 순회 수정 → 까먹으면 새 슬롯 메시 안 붙음

슬롯 3개인 지금도 관리하기 불편한데, 슬롯이 늘어날수록 이 비용이 선형으로 증가한다.


2. 첫 번째 전환 결정 — FGameplayTag + DataTable

왜 FGameplayTag인가

증강 시스템이 이미 FGameplayTag로 모든 카테고리를 구분하고 있었다. 슬롯도 같은 체계를 쓰면:

  • 에디터 드롭다운 필터: meta=(Categories="Part.Slot") 한 줄로 Part.Slot.* 네임스페이스만 표시
  • 신규 슬롯 추가 워크플로: 태그 1줄 등록 + DT 행 1개 추가. C++ 수정 없음
  • 빌드 없이 슬롯 확장 가능

DataTable은 왜 필요한가

태그만으로는 "이 슬롯의 해금 비용이 얼마인가", "기본 해금 상태인가"를 알 수 없다.
DT_PartSlot이 이 수치를 보관하고, 코드는 DataSubsystem을 통해 캐시된 맵으로 조회한다.

// DT 행 구조체
USTRUCT(BlueprintType)
struct FNSPartSlotRow : public FTableRowBase
{
    FGameplayTag SlotTag;       // 슬롯 정체성
    int64 UnlockCost;           // 해금 비용
    bool bUnlockedByDefault;    // 기본 해금 여부
    bool bEnabled;              // 활성화 여부
};

// DataSubsystem 캐시
TMap<FGameplayTag, FNSPartSlotRow> CachedSlotRowsBySlot;

무엇이 바뀌었나

항목변경 전변경 후
슬롯 식별자ENSPartSlotFGameplayTag
슬롯 수치(비용 등)코드 하드코딩DT_PartSlot DataTable
FNSPartSlotRow.SlotENSPartSlot SlotFGameplayTag SlotTag
FNSPartDefinitionRow.PartSlotENSPartSlotFGameplayTag
FNSPartData.SlotENSPartSlotFGameplayTag
UnlockedSlots 세이브 키TSet<ENSPartSlot>TSet<FGameplayTag>
UI 슬롯 버튼3개 하드코딩 UPROPERTYDT 기반 런타임 동적 생성
VisualComponent 순회{Body, Arm, Leg} 리터럴DataSS->GetAllSlotRows()

UI 동적화가 어떤 의미인가

// 변경 전 — UMG에 버튼 3개 고정 배치, C++에서 이름으로 참조
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* BodySlotButton;
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* ArmSlotButton;
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* LegSlotButton;

void HandlePartChanged(ENSPartSlot Slot, ...)
{
    switch(Slot)
    {
        case Body: BodySlotButton->...; break;
        case Arm:  ArmSlotButton->...;  break;
        case Leg:  LegSlotButton->...;  break;
        // 슬롯 추가 시 여기도 수정 필요
    }
}

// 변경 후 — 컨테이너만 UMG에 배치, 버튼은 DT 읽고 런타임 스폰
void BuildSlotButtons()
{
    for (const auto& Pair : DataSS->GetAllSlotRows())
    {
        UNSPartSlotButton* Btn = CreateWidget<UNSPartSlotButton>(this, SlotButtonTemplate);
        SlotButtonContainer->AddChild(Btn);
        SlotButtonMap.Add(Pair.Key, Btn);  // 태그 → 버튼 맵
    }
}

void HandlePartChanged(FGameplayTag Slot, ...)
{
    if (auto* Btn = SlotButtonMap.Find(Slot))
        ApplySlot(Slot, *Btn);   // switch 없음, 슬롯 추가해도 수정 불필요
}

"동적화"란: DT에서 슬롯 행이 늘어나면 버튼도 자동으로 늘어나는 구조.
슬롯 추가 = DT에 행 1개 추가가 전부. UI/C++ 수정 없음.


3. 중간에 발견된 설계 오류 — OwnedParts 범위 문제

무엇이 잘못됐나

구현 중 OwnedParts(파츠 인벤토리)FNSCharacterSaveData(캐릭터별) 안에 있다는 것을 확인했다.

struct FNSCharacterSaveData
{
    TArray<FNSPartSaveData> OwnedParts;  // ← 캐릭터별로 파츠를 따로 가지고 있었음
    TSet<FGameplayTag> UnlockedSlots;
};

이 구조의 문제: 캐릭터 A로 파츠를 구매하면 캐릭터 B에서는 그 파츠가 없다.
원래 의도는 "파츠 인벤토리는 계정 공유"였다.

실제로 OwnedParts는 처음 설계 때 UNSPermanentSaveGame(계정 레벨)에 있었다가,
어느 시점에 캐릭터별로 이사 온 것으로 파악됐다. enum→태그 전환 작업 도중 이 사실이 드러났다.

왜 이게 문제인가

게임 설계 의도:

  • 파츠 인벤토리: 계정 공유. 어떤 캐릭터로 구매해도 전체 캐릭터가 사용 가능.
  • 슬롯 언락: 캐릭터별. 캐릭터마다 독립적으로 슬롯을 해금.
  • 장착 참조: 캐릭터별. 어떤 파츠를 끼고 있는지는 캐릭터마다 다름.

OwnedParts가 캐릭터별이면 "파츠 공유" 자체가 불가능해진다.

무엇을 어떻게 변경했나

세이브 구조:

// 변경 전
struct FNSCharacterSaveData
{
    TArray<FNSPartSaveData> OwnedParts;  // 캐릭터별 (잘못됨)
    TSet<FGameplayTag> UnlockedSlots;    // 캐릭터별 (유지)
};

// 변경 후
struct FNSCharacterSaveData
{
    // OwnedParts 제거됨
    TSet<FGameplayTag> UnlockedSlots;    // 캐릭터별 (유지)
};

class UNSPermanentSaveGame
{
    TArray<FNSPartSaveData> OwnedParts;  // 계정 공유로 복귀
};

ProgressionSubsystem 함수 시그니처:

// 변경 전 — CharacterId가 인벤토리 조회에도 쓰임
bool IsPartOwned(FName CharacterId, TSoftObjectPtr<UNSPartDefinition>, ENSPartRarity) const;
const TArray<FNSPartSaveData>& GetOwnedParts(FName CharacterId) const;

// 변경 후 — 인벤토리는 계정 풀, CharacterId 불필요
bool IsPartOwned(TSoftObjectPtr<UNSPartDefinition>, ENSPartRarity) const;
const TArray<FNSPartSaveData>& GetOwnedParts() const;

PurchasePart는 CharacterId를 유지했다. "이 캐릭터의 슬롯이 해금됐는가"를 검사하기 때문이다. 구매는 캐릭터 슬롯 게이트를 통과해야 하지만, 구매된 파츠는 계정 풀에 쌓인다.

NSPlayerController UploadLocalProgress:

// 변경 전 — 장착 파츠를 캐릭터 인벤토리에서 찾음
const FNSPartSaveData* Owned = CharacterSlot->OwnedParts.FindByPredicate(...);

// 변경 후 — 장착 파츠를 계정 인벤토리에서 찾음
const FNSPartSaveData* Owned = PermanentSave->OwnedParts.FindByPredicate(...);

4. 최종 구조

세이브 데이터 책임 분리

데이터저장 위치이유
OwnedParts (파츠 인벤토리)UNSPermanentSaveGame (계정)계정 공유. 어느 캐릭터로 구매해도 전체 접근 가능
UnlockedSlots (슬롯 해금)FNSCharacterSaveData (캐릭터)캐릭터마다 독립 슬롯 투자
EquippedPartDefinition/Rarity (장착 참조)FNSCharacterSaveData (캐릭터)어떤 파츠를 끼고 있는지는 캐릭터별로 다름

슬롯 추가 워크플로 (최종)

1. Config/DefaultGameplayTags.ini에 Part.Slot.Wing 태그 1줄 추가
2. DT_PartSlot에 행 1개 추가 (SlotTag=Part.Slot.Wing, UnlockCost=500, ...)
3. DT_PartDefinition에 해당 슬롯 파츠 행 추가
완료 — C++ 수정/빌드 없음

수정된 파일 목록

파일변경 내용
Data/Part/NSPartTypes.hENSPartSlot enum 제거, 모든 슬롯 필드 → FGameplayTag
Progression/Save/NSPermanentSaveGame.hOwnedParts → 계정 레벨로 이동, FNSCharacterSaveData에서 제거
Core/.../NSDataSubsystem.h/.cpp슬롯 캐시 키 타입 ENSPartSlotFGameplayTag
Core/.../NSProgressionSubsystem.h/.cppIsPartOwned·GetOwnedParts에서 CharacterId 제거, 계정 풀 조회로 변경
Progression/Part/NSPartEquipComponent.h/.cpp델리게이트·함수 인자 → FGameplayTag
Character/Component/NSPartVisualComponent.h/.cpp하드코딩 순회 → GetAllSlotRows() DT 순회
UI/Part/NSPartPanelWidget.h/.cpp명명 버튼 3개 제거 → 동적 생성 구조 (SlotButtonMap)
UI/Interaction/NSPartEquipWidget.cppIsPartOwned 호출에서 CharacterId 인자 제거
Core/PlayerController/NSPlayerController.cpp장착 파츠 조회: CharacterSlot->OwnedPartsPermanentSave->OwnedParts

5. 핵심 교훈 (TIL)

enum → FGameplayTag 전환이 필요한 시점

enum은 집합이 고정될 때 좋다. "나중에 늘어날 수 있다"는 가능성이 조금이라도 있으면 태그+DT가 낫다.
전환 비용(모든 참조 수정)이 크지만, 한 번 전환하면 이후 확장 비용이 0에 가까워진다.

데이터 소유권은 설계 초반에 명확히

OwnedParts가 계정 공유인지 캐릭터별인지는 게임 설계 의도의 문제다.
이걸 코드 구현 중에 발견한 건 설계 문서에 명시되지 않았기 때문이다.
세이브 데이터 설계 시 모든 필드에 "계정 단위 / 캐릭터 단위"를 명시하는 습관이 필요하다.

PurchasePart의 CharacterId는 왜 남겼나

"파츠를 살 수 있는가"를 검사할 때 "이 캐릭터의 슬롯이 해금됐는가"가 필요하다.
구매 게이트는 캐릭터별, 구매된 파츠의 저장은 계정 공유.
책임이 두 층에 걸쳐 있어서 파라미터가 남아있는 것이 맞다.

UI "동적화"는 DT와 세트로 설계해야 한다

슬롯 버튼을 UMG에 하드코딩하면 슬롯 추가 때 UI도 수정해야 한다.
SlotButtonTemplate(버튼 1개짜리 클래스)을 에디터에서 지정하고,
런타임에 DT를 읽어서 스폰하는 패턴이 DT 기반 설계의 완결이다.

0개의 댓글