TIL

[UE5] ProjectFT #16 퀘스트 보상과 데이터 구조 확장

think95592 2026. 7. 27. 20:48

퀘스트 시스템을 처음 구현했을 때는 퀘스트 완료 조건과 아이템 보상 정도만 있으면 충분하다고 생각했다.

하지만 실제 퀘스트 데이터를 만들기 시작하자 필요한 정보가 계속 늘어났다.

  • 아이템이 아닌 화폐 보상
  • 퀘스트별 이벤트 조건
  • 여러 줄로 표시되는 목표 문구
  • 상점 아이템 해금
  • 다음 퀘스트 연결
  • 메인 HUD에 표시할 진행 정보

이번 작업에서는 단순했던 퀘스트 구조체를 확장하고, 퀘스트 완료부터 보상 지급과 UI 갱신까지 하나의 흐름으로 정리했다.


1. 아이템 보상과 화폐 보상을 분리한 이유

기존에는 퀘스트 보상을 모두 RewardItems 배열에 넣는 방향으로 생각했다.

UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FTCraftIngredientStruct> RewardItems;

ProjectFT에서는 화폐도 인벤토리에 들어가는 일반 아이템으로 관리한다.

그렇다면 코인도 다음과 같이 아이템 보상에 넣을 수 있다.

ID_Common_Coin:100

실제 지급 방식만 보면 이것도 가능하다. 하지만 퀘스트 데이터의 의미가 명확하지 않다는 문제가 있었다.

RewardItems
├─ 생수 2개
├─ 비누 1개
└─ 코인 100개

기획자가 데이터를 볼 때 어떤 것이 실제 아이템 보상이고, 어떤 것이 화폐 보상인지 바로 구분하기 어렵다.

UI에서도 문제가 생긴다.

아이템 보상은 아이콘과 수량을 표시해야 하지만, 화폐는 보통 별도의 코인 아이콘과 금액으로 표시한다. 같은 배열에 들어 있으면 UI가 아이템 ID를 확인해 화폐인지 다시 판단해야 한다.

그래서 데이터 단계에서는 둘을 분리했다.

UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FTCraftIngredientStruct> RewardItems;

UPROPERTY(EditAnywhere, BlueprintReadOnly)
int32 CurrencyReward = 0;

최종 구조는 다음과 같다.

퀘스트 보상
├─ RewardItems
│  ├─ 아이템 ID
│  └─ 수량
│
└─ CurrencyReward
   └─ 화폐 수량

런타임에서는 화폐도 인벤토리 아이템이지만, 퀘스트 데이터에서는 화폐라는 의미를 분명하게 표현한 것이다.

이번 작업을 통해 같은 방식으로 저장되는 데이터라도 게임에서의 역할이 다르면 데이터 구조에서는 분리하는 편이 관리하기 좋다는 것을 배웠다.


2. CurrencyReward 데이터 추가

확장된 퀘스트 구조는 다음과 같다.

USTRUCT(BlueprintType)
struct PROJECTFT_API FTQuestStruct : public FTableRowBase
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FName QuestID;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FText QuestName;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FText SenderName;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FText Description;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TArray<FText> ObjectiveLines;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TArray<FTCraftIngredientStruct> RequiredItems;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TArray<FFTQuestConditionStruct> EventConditions;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TArray<FTCraftIngredientStruct> RewardItems;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    int32 CurrencyReward = 0;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TArray<FName> UnlockedShopItemIDs;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TArray<FName> NextQuestIDs;
};

CurrencyReward의 기본값은 0으로 설정했다.

따라서 화폐 보상이 없는 퀘스트는 별도의 값을 넣지 않아도 된다.

CurrencyReward = 0

화폐 보상이 있는 퀘스트만 수량을 지정한다.

CurrencyReward = 80

배열이 아닌 정수로 관리하기 때문에 기획 데이터에서도 단순하게 입력할 수 있고, 잘못된 화폐 아이템 ID를 퀘스트마다 반복해서 입력할 필요도 없다.


3. 퀘스트 완료 시 인벤토리로 보상 지급하기

퀘스트 완료 처리는 UFTObjectiveSubsystem::TryCompleteQuest()가 담당한다.

전체 흐름은 다음과 같다.

퀘스트 완료 요청
        ↓
활성 퀘스트인지 확인
        ↓
아이템 및 이벤트 조건 확인
        ↓
보상을 인벤토리에 넣을 수 있는지 확인
        ↓
요구 아이템 소비
        ↓
아이템 보상 지급
        ↓
화폐 보상 지급
        ↓
상점 아이템 및 다음 퀘스트 해금
        ↓
퀘스트 완료 상태로 변경

먼저 CanCompleteQuest()에서 모든 조건과 보상 지급 가능 여부를 검사한다.

bool UFTObjectiveSubsystem::CanCompleteQuest(
    const FTQuestStruct& Quest,
    UFTInventoryComponent* PlayerInventory) const
{
    if (!AreQuestEventConditionsCompleted(Quest))
    {
        return false;
    }

    for (const FTCraftIngredientStruct& RequiredItem : Quest.RequiredItems)
    {
        if (GetCombinedItemCount(PlayerInventory, RequiredItem.ItemID)
            < RequiredItem.Count)
        {
            return false;
        }
    }

    return CanGrantQuestRewards(Quest, PlayerInventory);
}

중요한 점은 요구 아이템만 검사하는 것이 아니라 보상을 받을 공간도 미리 검사한다는 것이다.

보상을 받을 수 없는 상태에서 요구 아이템부터 제거하면 플레이어가 아이템만 잃을 수 있기 때문이다.

아이템 보상 지급

일반 아이템 보상은 RewardItems를 순회하면서 플레이어 인벤토리에 추가한다.

for (const FTCraftIngredientStruct& RewardItem : Quest->RewardItems)
{
    if (!PlayerInventory->AddItem(
        RewardItem.ItemID,
        RewardItem.Count))
    {
        RollbackQuestTransaction();
        return false;
    }

    GrantedRewardItems.Add(RewardItem);
}

보상은 창고가 아닌 플레이어 인벤토리에 지급한다.

퀘스트 비용은 플레이어 인벤토리와 창고를 함께 사용할 수 있지만, 보상까지 창고로 자동 분산하면 플레이어가 보상을 어디에서 받았는지 알기 어려워지기 때문이다.

화폐 보상 지급

CurrencyReward는 데이터에서는 정수지만, 실제 지급할 때는 프로젝트에서 사용하는 화폐 아이템 ID로 변환한다.

if (Quest->CurrencyReward > 0)
{
    FName CurrencyItemID = TEXT("ID_Common_Coin");

    if (UFTShopSubsystem* ShopSubsystem =
        GetGameInstance()->GetSubsystem<UFTShopSubsystem>())
    {
        CurrencyItemID = ShopSubsystem->GetCurrencyItemID();
    }

    if (CurrencyItemID.IsNone()
        || !PlayerInventory->AddItem(
            CurrencyItemID,
            Quest->CurrencyReward))
    {
        RollbackQuestTransaction();
        return false;
    }
}

화폐 ID는 ShopSubsystem의 설정을 우선 사용한다.

설정을 가져오지 못했을 때는 ID_Common_Coin을 기본값으로 사용한다.

Quest CurrencyReward
        ↓
ShopSubsystem의 CurrencyItemID 조회
        ↓
플레이어 InventoryComponent에 추가

덕분에 퀘스트 시스템이 특정 화폐 아이템을 직접 관리하지 않아도 된다.


4. 퀘스트 완료를 하나의 트랜잭션으로 처리하기

퀘스트 완료에는 여러 데이터 변경이 포함된다.

  • 플레이어 인벤토리의 요구 아이템 제거
  • 창고의 요구 아이템 제거
  • 아이템 보상 지급
  • 화폐 보상 지급
  • 상점 아이템 해금
  • 퀘스트 상태 변경
  • 다음 퀘스트 해금

이 과정 중간에 실패하면 일부 데이터만 변경된 상태가 될 수 있다.

예를 들어 다음 상황이 발생할 수 있다.

요구 아이템 제거 성공
아이템 보상 지급 성공
화폐 보상 지급 실패

이 상태로 함수를 종료하면 플레이어는 요구 아이템을 잃었지만 퀘스트는 완료되지 않는다.

이를 막기 위해 완료 처리 중 소비한 아이템과 지급한 아이템을 기록하고, 실패하면 되돌리도록 구성했다.

struct FConsumedQuestItem
{
    FName ItemID = NAME_None;
    int32 PlayerCount = 0;
    int32 StorageCount = 0;
};

플레이어와 창고에서 각각 몇 개를 소비했는지 기록하는 이유는 롤백할 때 원래 위치로 돌려놓기 위해서다.

auto RollbackQuestTransaction = [&]()
{
    for (const FTCraftIngredientStruct& GrantedReward
        : GrantedRewardItems)
    {
        PlayerInventory->RemoveItem(
            GrantedReward.ItemID,
            GrantedReward.Count);
    }

    for (const FConsumedQuestItem& ConsumedItem
        : ConsumedItems)
    {
        if (ConsumedItem.PlayerCount > 0)
        {
            PlayerInventory->AddItem(
                ConsumedItem.ItemID,
                ConsumedItem.PlayerCount);
        }

        if (ConsumedItem.StorageCount > 0)
        {
            StorageSubsystem->AddStorageItem(
                StorageInventory,
                ConsumedItem.ItemID,
                ConsumedItem.StorageCount);
        }
    }
};

퀘스트 완료는 단순히 상태값 하나를 바꾸는 작업이 아니라 경제 데이터 전체를 변경하는 작업이다.

따라서 중간 실패를 고려한 트랜잭션 단위로 처리해야 한다는 것을 알게 됐다.


5. UI용 보상 텍스트 가공하기

DataTable에 저장된 데이터는 UI에서 바로 사용하기 어렵다.

예를 들어 아이템 보상은 다음과 같은 구조다.

RewardItems
├─ ID_Healing_Water:2
└─ ID_Common_Soap:1

하지만 UI에는 내부 ID가 아니라 실제 아이템 이름이 보여야 한다.

80 Coin, 생수 x2, 비누 x1

이 가공은 Widget이 아니라 UFTQuestListObject에서 처리하도록 했다.

FString RewardText;

if (Quest.CurrencyReward > 0)
{
    RewardText = FString::Printf(
        TEXT("%d Coin"),
        Quest.CurrencyReward);
}

const FString ItemRewardText =
    BuildIngredientSummary(this, Quest.RewardItems);

if (!ItemRewardText.IsEmpty())
{
    if (!RewardText.IsEmpty())
    {
        RewardText += TEXT(", ");
    }

    RewardText += ItemRewardText;
}

RewardSummary = RewardText.IsEmpty()
    ? FText::GetEmpty()
    : FText::FromString(RewardText);

아이템 이름은 ItemDataAsset을 조회해 가져온다.

const UFTItemDataAsset* ItemDataAsset =
    UFTItemFunctionLibrary::FindItemData(
        WorldContextObject,
        ItemID);

아이템 데이터를 찾지 못하면 최소한 ItemID라도 표시하도록 fallback을 둔다.

return ItemDataAsset
    ? ItemDataAsset->ItemData.ItemName
    : FText::FromName(ItemID);

역할은 다음처럼 나뉜다.

Quest DataTable
    원본 보상 데이터 보관

QuestListObject
    아이템 이름과 수량을 UI 문자열로 가공

Quest EntryWidget
    완성된 텍스트 표시

Widget에서 매번 아이템 데이터를 조회하고 문자열을 조립하지 않기 때문에 화면 코드도 단순해졌다.

다만 현재 코드의 Coin 문자열은 직접 작성되어 있다. 향후 다국어를 지원하려면 FText 포맷과 String Table을 이용해 현지화 가능한 형태로 바꿀 필요가 있다.


6. 퀘스트 조건 구조 확장

기존 퀘스트는 요구 아이템만 검사했다.

생수 2개 보유
식빵 1개 보유

하지만 퀘스트 종류가 늘면서 아이템 수량만으로 표현할 수 없는 조건이 필요해졌다.

  • 선반 파괴하기
  • 아이템 판매하기
  • NPC에게 발각되기
  • 경비 호출하기
  • 추격에서 벗어나기
  • 레이드에서 탈출하기

이를 위해 FFTQuestConditionStruct를 추가했다.

USTRUCT(BlueprintType)
struct PROJECTFT_API FFTQuestConditionStruct
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FGameplayTag EventTag;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    int32 RequiredCount = 1;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FName ItemID = NAME_None;

    bool IsValid() const
    {
        return EventTag.IsValid()
            && RequiredCount > 0;
    }
};

각 필드의 역할은 다음과 같다.

필드역할

EventTag 진행에 사용할 Gameplay Message 채널
RequiredCount 필요한 이벤트 발생 횟수
ItemID 특정 아이템 이벤트만 추적할 때 사용하는 선택 필터

예를 들어 선반을 세 번 파괴하는 조건은 다음과 같이 표현할 수 있다.

Event.Shelf.Destroyed@3

특정 아이템을 두 개 판매하는 조건은 다음처럼 표현할 수 있다.

Event.Shop.Sold@2@ID_Healing_Water

ItemID가 비어 있으면 이벤트 태그만 검사하고, 값이 있으면 메시지에 들어 있는 아이템 ID까지 함께 비교한다.

이 구조를 사용하면서 아이템 목표와 행동 목표를 하나의 퀘스트에 함께 넣을 수 있게 됐다.

퀘스트 완료 조건
├─ RequiredItems
│  └─ 현재 인벤토리와 창고 상태로 검사
│
└─ EventConditions
   └─ 수락 후 발생한 이벤트 횟수로 검사

두 조건이 모두 존재하면 현재 구조에서는 모든 조건을 만족해야 완료할 수 있다. 아직 OR 조건이나 조건 그룹은 지원하지 않는다.


7. ObjectiveLines와 실제 조건의 연결

퀘스트 조건이 늘어나면서 HUD에 표시할 문구도 필요해졌다.

내부 데이터를 그대로 출력하면 다음처럼 보이게 된다.

ID_Healing_Water 1 / 2
Event.Raid.Escaped 0 / 1

플레이어에게 보여주기에는 적절하지 않다.

그래서 퀘스트 데이터에 ObjectiveLines를 추가했다.

UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FText> ObjectiveLines;

예시는 다음과 같다.

RequiredItems
- ID_Healing_Water:2

EventConditions
- Event.Raid.Escaped@1

ObjectiveLines
- 생수 확보
- 마트에서 탈출
- 터미널에서 의뢰 완료

ObjectiveSubsystem은 다음 순서로 목표 문구를 연결한다.

1. RequiredItems 순서
2. EventConditions 순서
3. 남은 ObjectiveLines는 안내 문구

HUD에서는 실제 진행도를 조합해 다음처럼 표시한다.

생수 확보  1 / 2
마트에서 탈출  0 / 1
터미널에서 의뢰 완료

데이터의 판정 정보와 플레이어에게 보여줄 문구를 분리한 덕분에 내부 ID를 변경하지 않고도 자연스러운 문장을 작성할 수 있게 됐다.


8. 메인 HUD에 활성 퀘스트 미리보기 연결

퀘스트 터미널을 닫은 뒤에도 현재 목표를 확인할 수 있도록 메인 HUD에 활성 퀘스트 목록을 연결했다.

메인 HUD는 UFTQuestListWidget에서 관리한다.

TArray<FTQuestStruct> ActiveQuests;

ObjectiveSubsystem->GetQuestListByState(
    EFTQuestStateType::Active,
    ActiveQuests);

아직 수락하지 않은 Available 퀘스트는 표시하지 않고, 플레이어가 수락한 Active 퀘스트만 가져온다.

현재 HUD는 고정된 세 개의 행을 사용한다.

const TArray<UHorizontalBox*> QuestRows =
{
    HB_QuestRow_0,
    HB_QuestRow_1,
    HB_QuestRow_2
};

따라서 활성 퀘스트는 최대 세 개까지 미리보기로 표시된다.

각 행에는 다음 정보가 들어간다.

퀘스트 이름
목표별 현재 수량 / 필요 수량
전체 목표 달성 체크 상태

상세 목표는 ObjectiveSubsystem에서 가공한다.

const FText DetailText =
    ObjectiveSubsystem->GetQuestObjectiveProgressText(
        Quest.QuestID);

완료 여부는 전체 진행률로 표시한다.

QuestCheckBox->SetIsChecked(
    ObjectiveSubsystem->GetQuestProgress(Quest.QuestID)
    >= 1.0f);

여기서 주의할 점은 진행률이 100%라고 해서 자동으로 보상을 지급하지 않는다는 것이다.

HUD 체크 완료
    = 모든 조건 달성

퀘스트 완료
    = 허브 터미널에서 명시적으로 제출

조건 달성과 보상 수령을 분리해 레이드 도중 갑자기 아이템이 차감되거나 보상이 지급되는 일을 막았다.


9. HUD가 갱신되는 시점

HUD에서 퀘스트 상태를 매 프레임 검사하도록 만들면 불필요한 Polling이 발생한다.

그래서 다음 이벤트가 발생할 때만 목록을 갱신한다.

  • 퀘스트 이벤트 진행도 변경
  • 퀘스트 완료
  • 플레이어 인벤토리 변경
  • 허브 창고 인벤토리 변경

Gameplay Message는 다음과 같이 구독한다.

QuestProgressListenerHandle =
    MessageSubsystem.RegisterListener(
        TAG_FT_Event_ObjectiveProgressChanged,
        this,
        &ThisClass::HandleQuestProgressChanged);

QuestCompletedListenerHandle =
    MessageSubsystem.RegisterListener(
        TAG_FT_Event_ObjectiveCompleted,
        this,
        &ThisClass::HandleQuestCompleted);

이벤트를 받으면 현재 활성 퀘스트를 다시 조회한다.

void UFTQuestListWidget::HandleQuestProgressChanged(
    FGameplayTag Channel,
    const FFTMessagePayloadStruct& Payload)
{
    RefreshQuestList();
}

아이템 목표는 현재 인벤토리와 창고 수량을 기준으로 계산되므로 두 인벤토리의 변경 델리게이트도 함께 구독한다.

Gameplay Message
├─ 이벤트 조건 변화
└─ 퀘스트 완료

Inventory Delegate
├─ 플레이어 아이템 변화
└─ 창고 아이템 변화

        ↓

HUD 퀘스트 미리보기 갱신

덕분에 HUD가 게임 로직을 계속 확인하지 않아도 필요한 순간에만 최신 상태를 표시할 수 있다.


10. Quest DataTable 확장 시 하위 호환성 관리

DataTable의 RowStruct에 새로운 필드를 추가하면 기존 데이터가 깨지지 않을지 확인해야 한다.

이번에는 다음 원칙을 적용했다.

새로운 값에 안전한 기본값 지정

CurrencyReward는 기본값을 0으로 설정했다.

int32 CurrencyReward = 0;

배열 필드는 기본적으로 빈 배열이 된다.

TArray<FFTQuestConditionStruct> EventConditions;
TArray<FTCraftIngredientStruct> RewardItems;

따라서 기존 아이템 퀘스트는 다음과 같이 해석된다.

EventConditions = 비어 있음
CurrencyReward  = 0

새로운 조건과 보상이 없을 뿐, 기존 아이템 조건은 그대로 동작한다.

EventConditions 열은 선택 사항으로 처리

Google Sheet 파서에서는 EventConditions를 기존 시트와의 호환을 위한 선택 열로 처리했다.

if (ActualHeaders.Contains(TEXT("EventConditions")))
{
    ParseEventConditionList(
        GetTrimmedValue(RowData, TEXT("EventConditions")),
        NewRow.EventConditions,
        ParseError);
}

기존 시트에 EventConditions 열이 없어도 아이템 기반 퀘스트를 가져올 수 있다.

열이 있더라도 셀이 비어 있으면 빈 조건 배열로 처리된다.

CurrencyReward의 빈 셀은 0으로 처리

const FString CurrencyRewardString =
    GetTrimmedValue(RowData, TEXT("CurrencyReward"));

NewRow.CurrencyReward =
    CurrencyRewardString.IsEmpty()
    ? 0
    : FCString::Atoi(*CurrencyRewardString);

값이 있다면 음수가 아닌 정수인지 검증한다.

if (NewRow.CurrencyReward < 0
    || (!CurrencyRewardString.IsEmpty()
        && !CurrencyRewardString.IsNumeric()))
{
    // 잘못된 데이터로 처리
}

현재 Sheet 파서에서는 CurrencyReward 헤더 자체는 필수다. 따라서 기존 시트를 계속 사용하려면 열을 한 번 추가해야 하지만, 각 퀘스트 행의 셀은 비워 둘 수 있다.

즉, 호환 범위는 다음과 같다.

변경 사항 기존 데이터 처리
기존 DataTable Row CurrencyReward 기본값 0
EventConditions 열 없음 빈 이벤트 조건으로 처리
EventConditions 셀 비어 있음 빈 이벤트 조건으로 처리
CurrencyReward 셀 비어 있음 0으로 처리
CurrencyReward 헤더 없음 현재 파서에서는 가져오기 실패
잘못된 재화 값 전체 가져오기 중단

검증 완료 후 DataTable 변경

파서는 행을 읽을 때 바로 기존 DataTable을 수정하지 않는다.

Google Sheet 읽기
        ↓
임시 ParsedQuests Map 생성
        ↓
모든 행과 연결 관계 검증
        ↓
오류가 없을 때만 DataTable 반영

중간 행에서 오류가 발생해 DataTable이 절반만 갱신되는 문제를 방지하기 위한 구조다.

새 필드를 추가할 때는 단순히 구조체에 변수를 추가하는 것만으로 끝나지 않는다.

  • 기본값
  • 기존 시트 처리
  • 빈 셀 처리
  • 잘못된 값 검증
  • UI fallback
  • 저장 데이터와 조건 배열 호환

이 항목을 함께 확인해야 실제로 안전한 데이터 확장이 된다.


11. 작업 후 구조

이번 작업을 통해 퀘스트 데이터부터 HUD까지 다음과 같은 구조가 만들어졌다.

Quest DataTable
├─ RequiredItems
├─ EventConditions
├─ RewardItems
├─ CurrencyReward
├─ ObjectiveLines
├─ UnlockedShopItemIDs
└─ NextQuestIDs
        ↓
UFTObjectiveSubsystem
├─ 완료 조건 검사
├─ 요구 아이템 소비
├─ 아이템 및 화폐 보상 지급
├─ 퀘스트 상태 변경
└─ 진행 이벤트 발송
        ↓
ViewModel / ListObject
├─ 보상 아이템 목록 생성
├─ 보상 요약 문자열 가공
└─ UI용 상태 제공
        ↓
Quest UI / Main HUD
├─ 퀘스트 상세 정보
├─ 보상 표시
└─ 활성 퀘스트 진행도 미리보기

DataTable은 기획 데이터를 보관하고, Subsystem은 실제 규칙을 처리한다.

ViewModel과 ListObject는 UI가 사용할 수 있는 형태로 데이터를 가공하고, Widget은 결과를 표시하는 역할만 맡는다.


12. 이번 작업에서 배운 점

같은 아이템 시스템을 사용해도 데이터의 의미는 분리할 수 있다

화폐는 런타임에서 일반 아이템으로 관리하지만, 퀘스트 데이터에서는 CurrencyReward로 분리했다.

저장 방식보다 기획과 UI에서 어떤 의미로 사용되는지를 기준으로 데이터 구조를 설계하는 것이 중요했다.

보상 지급도 실패할 수 있다

퀘스트 조건을 만족했다고 해서 바로 완료 상태로 바꾸면 안 된다.

인벤토리 공간, 잘못된 아이템 ID, 화폐 설정 오류 등으로 보상 지급이 실패할 수 있으므로 지급 가능 여부를 먼저 확인하고 실패 시 롤백해야 한다.

게임 데이터와 UI용 문구는 다르다

ItemID, GameplayTag, 수량 같은 데이터는 시스템이 판정하기 위한 정보다.

플레이어에게 보여줄 아이템 이름, 목표 문장, 보상 요약은 별도로 가공해야 한다.

DataTable 확장은 기본값만으로 끝나지 않는다

구조체에 기본값을 지정해도 외부 시트 파서가 새 헤더를 필수로 요구하면 기존 시트는 그대로 가져올 수 없다.

구조체, 파서, 저장 데이터, UI를 함께 확인해야 하위 호환성을 제대로 관리할 수 있다.


마무리

이번 작업에서는 퀘스트 보상과 조건 데이터를 확장하고, 실제 보상 지급부터 메인 HUD 표시까지 연결했다.

처음에는 RequiredItemsRewardItems만 있던 단순한 퀘스트 구조였지만, 프로젝트가 진행되면서 퀘스트는 여러 시스템을 연결하는 중심 데이터가 됐다.

특히 이번 작업에서 중요했던 것은 필드를 많이 추가하는 것이 아니라 각 데이터의 책임을 명확하게 나누는 것이었다.

DataTable은 퀘스트를 정의하고,
ObjectiveSubsystem은 규칙을 처리하며,
ViewModel은 UI용 데이터를 만들고,
Widget은 그 결과를 표시한다.

퀘스트 구조를 확장하면서도 기존 아이템 퀘스트가 계속 동작하도록 기본값과 파서 호환성을 함께 고려한 것이 이번 리팩터링의 핵심이었다.