TIL

[UE5] ProjectFT #11 중고거래 게시글 자동 생성 시스템

think95592 2026. 7. 20. 20:54

프로젝트의 허브에는 플레이어가 아이템을 사고팔 수 있는 중고거래 시스템이 있다.

처음에는 거래 게시글마다 제목, 설명, 아이템, 수량, 가격을 직접 작성하는 방식을 사용했다. 게시글 수가 적을 때는 문제가 없었지만, 아이템 종류가 늘어나면서 같은 작업을 반복해야 했다.

또한 매번 동일한 게시글만 표시되기 때문에 중고거래 시스템이 정적인 상점 목록처럼 보이는 문제도 있었다.

이번 작업에서는 아이템마다 게시글을 직접 작성하는 대신 다음 데이터를 조합해 거래 게시글을 자동 생성하도록 구조를 변경했다.

Prefix
+ 아이템 이름
+ 거래 이유
+ Ending
+ 아이템 가격
+ 랜덤 수량

게시글 생성 규칙과 문구는 UFTShopDataAsset에서 설정하고, 실제 런타임 게시글 생성과 거래 처리는 UFTShopSubsystem이 담당하도록 구성했다.


게시글을 직접 작성할 때의 문제

기존에는 하나의 거래 게시글을 만들기 위해 다음 정보를 모두 입력해야 했다.

  • 게시글 ID
  • 게시글 제목
  • 게시글 설명
  • 거래 아이템
  • 거래 수량
  • 가격
  • 구매 요청인지 판매 제안인지
  • 거래 완료 여부

예를 들어 물, 설탕, 음료를 거래하는 게시글을 만든다면 각각 별도의 데이터를 작성해야 한다.

게시글 1
제목: 급하게 물을 구합니다
설명: 허브 작업에 필요합니다. 연락주세요.
아이템: Water
수량: 2
가격: 100

게시글 2
제목: 남는 설탕 판매합니다
설명: 창고 정리 중입니다. 빠른 거래 선호합니다.
아이템: Sugar
수량: 1
가격: 80

아이템이 10개라면 최소 10개의 게시글이 필요하고, 구매와 판매 방향을 모두 만들면 그보다 더 많은 데이터를 작성해야 한다.

1. 같은 문장을 반복해서 작성한다

아이템 이름만 다르고 게시글의 전체적인 문장 구조는 비슷했다.

급하게 [아이템]을 구합니다.
창고 정리 중이라 [아이템]을 판매합니다.
[아이템] 필요하신 분 연락주세요.

이처럼 반복되는 문장을 아이템마다 직접 작성하는 것은 작업량에 비해 얻는 효과가 적었다.

2. 아이템이 추가될 때마다 게시글도 추가해야 한다

새로운 ItemDataAsset이 추가되더라도 거래 게시글은 자동으로 생기지 않는다.

따라서 아이템 추가 작업과 게시글 추가 작업이 항상 함께 진행되어야 했다.

새로운 아이템 추가
→ ItemDataAsset 생성
→ 거래 게시글 생성
→ 가격 설정
→ 구매 게시글 생성
→ 판매 게시글 생성

게시글 추가를 빠뜨리면 해당 아이템은 중고거래 시스템에 등장하지 않는다.

3. 게시글이 항상 동일하다

수동으로 작성한 게시글만 사용하면 게임을 다시 시작하거나 레이드에서 복귀해도 같은 게시글이 반복된다.

중고거래 게시판이 실시간으로 여러 사용자가 글을 올리는 공간처럼 보이지 않고, 고정된 상점 목록처럼 느껴질 수 있다.

4. 데이터 관리 위치가 분산된다

일부 게시글은 Actor에 있고, 일부는 UI에 있고, 일부는 DataAsset에 들어가면 어느 데이터가 실제 원본인지 알기 어려워진다.

따라서 게시글 생성 규칙과 런타임 상태를 명확하게 분리할 필요가 있었다.


자동 생성 시스템의 목표

이번 작업에서는 다음 구조를 목표로 했다.

UFTItemDataAsset
- 거래할 아이템 정보
- 아이템 이름
- 아이템 기본 가격

UFTShopDataAsset
- 게시글 개수
- 거래 수량 범위
- 가격 배율
- Prefix 문구
- 구매 이유 문구
- 판매 이유 문구
- Ending 문구

UFTShopSubsystem
- 거래 아이템 후보 수집
- 게시글 랜덤 생성
- 가격 및 수량 계산
- 거래 완료 상태 관리
- 실제 구매와 판매 처리

UFTMarketViewModel
- 게시글을 UI용 객체로 변환
- 구매 요청과 판매 제안 탭 관리
- 선택한 게시글의 상세 정보 제공
- 거래 가능 여부 판단 요청
- 거래 명령 전달

핵심은 문장 전체를 랜덤하게 생성하는 것이 아니라, 기획자가 작성한 문구 조각을 정해진 규칙으로 조합하는 것이다.


거래 게시글 데이터 구조 설계

거래 게시글은 FTTradePostStruct로 표현했다.

USTRUCT(BlueprintType)
struct FTTradePostStruct
{
    GENERATED_BODY()

public:
    FName GetResolvedItemID() const;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FName PostID;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FText Title;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FText Description;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TSoftObjectPtr<UFTItemDataAsset>
        ItemDataAsset;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FName ItemID;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    int32 Count = 1;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    int32 Price = 0;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    bool bBuyRequest = true;
};

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

필드역할
PostID 게시글을 구분하는 고유 ID
Title 게시글 제목
Description 게시글 내용
ItemDataAsset 거래할 아이템 에셋
ItemID 에셋이 없을 때 사용할 아이템 ID
Count 거래 수량
Price 전체 거래 가격
bBuyRequest 구매 요청과 판매 제안 구분

구매 요청과 판매 제안 구분

중고거래에는 두 종류의 게시글이 있다.

구매 요청

NPC가 아이템을 구한다는 게시글이다.

플레이어 입장에서는 아이템을 판매하는 거래다.

NPC: 물 2개를 구합니다.
플레이어: 물 2개를 넘깁니다.
플레이어: 거래 대금을 받습니다.

코드에서는 bBuyRequest = true로 표현한다.

판매 제안

NPC가 아이템을 판매한다는 게시글이다.

플레이어 입장에서는 아이템을 구매하는 거래다.

NPC: 설탕 1개를 판매합니다.
플레이어: 거래 대금을 지불합니다.
플레이어: 설탕 1개를 받습니다.

코드에서는 bBuyRequest = false로 표현한다.

이름이 헷갈릴 수 있기 때문에 항상 게시글 작성자의 관점으로 해석해야 한다.

bBuyRequest == true
= 게시글 작성자가 구매를 요청
= 플레이어는 판매

bBuyRequest == false
= 게시글 작성자가 판매를 제안
= 플레이어는 구매

ItemDataAsset과 ItemID를 함께 지원하기

거래 게시글은 ItemDataAsset과 ItemID를 모두 지원한다.

에디터에서 게시글을 직접 작성할 때는 ItemDataAsset을 선택하는 것이 편하지만, 이전 데이터나 런타임 생성 과정에서는 ItemID만 존재할 수도 있기 때문이다.

실제 거래에 사용할 ItemID는 GetResolvedItemID()로 결정한다.

FName FTTradePostStruct::GetResolvedItemID() const
{
    if (!ItemDataAsset.IsNull())
    {
        if (const UFTItemDataAsset* LoadedItemData =
            ItemDataAsset.LoadSynchronous())
        {
            if (!LoadedItemData
                    ->ItemData
                    .ItemId
                    .IsNone())
            {
                return LoadedItemData
                    ->ItemData
                    .ItemId;
            }
        }
    }

    return ItemID;
}

ItemDataAsset이 있으면 해당 에셋의 ItemID를 우선 사용하고, 에셋이 없으면 구조체에 저장된 ItemID를 사용한다.


ShopDataAsset에서 생성 규칙 관리하기

게시글 자동 생성과 관련된 설정은 UFTShopDataAsset에 배치했다.

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
bool bGenerateMarketPostsFromTemplates = true;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
int32 GeneratedMarketBuyPostCount = 6;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
int32 GeneratedMarketSellPostCount = 6;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
int32 MinGeneratedBuyRequestItemCount = 1;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
int32 MaxGeneratedBuyRequestItemCount = 1;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
int32 MinGeneratedSellOfferItemCount = 1;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
int32 MaxGeneratedSellOfferItemCount = 1;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
float MarketBuyRequestPriceMultiplier = 0.75f;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
float MarketSellOfferPriceMultiplier = 1.25f;

문구 템플릿도 같은 DataAsset에서 관리한다.

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TArray<FText> PostPrefixes;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TArray<FText> BuyRequestReasons;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TArray<FText> SellOfferReasons;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TArray<FText> PostEndings;

이 설정을 통해 C++ 코드를 수정하지 않고 다음 항목을 조절할 수 있다.

  • 생성할 구매 요청 게시글 개수
  • 생성할 판매 제안 게시글 개수
  • 거래 수량 범위
  • 구매 요청 가격 배율
  • 판매 제안 가격 배율
  • 게시글 제목 문구
  • 구매 이유
  • 판매 이유
  • 마무리 문구

문구 템플릿 구성

게시글 문장은 네 가지 요소를 조합해 만든다.

제목
= Prefix + 아이템 이름

본문
= 거래 이유 + Ending

예를 들어 DataAsset에 다음 문구가 들어 있다고 가정해 보자.

PostPrefixes
- 급처
- 창고 정리
- 거의 새것
- 빠르게 거래

BuyRequestReasons
- 허브 작업에 필요해서 구합니다
- 급하게 필요합니다
- 남는 물건이 있다면 거래 부탁드립니다

SellOfferReasons
- 창고를 정리하면서 판매합니다
- 사용하지 않아서 내놓습니다
- 여분이 생겨 판매합니다

PostEndings
- 연락주세요
- 빠른 거래 선호합니다
- 네고 가능합니다

선택한 아이템이 물이라면 다음과 같은 게시글을 생성할 수 있다.

제목: 급처 물
본문: 사용하지 않아서 내놓습니다. 빠른 거래 선호합니다.

다른 조합이 선택되면 같은 아이템으로도 다른 게시글이 만들어진다.

제목: 창고 정리 물
본문: 여분이 생겨 판매합니다. 연락주세요.

완전한 자연어 생성 대신 기획자가 검수한 문구 조각만 사용하기 때문에 문장 품질을 어느 정도 제어할 수 있다.


랜덤 문구 선택

템플릿 배열에서 문장을 고르는 함수는 단순하게 구성했다.

FText UFTShopSubsystem::PickTemplateText(
    const TArray<FText>& Templates,
    const FText& FallbackText
) const
{
    if (Templates.IsEmpty())
    {
        return FallbackText;
    }

    return Templates[
        FMath::RandRange(
            0,
            Templates.Num() - 1
        )
    ];
}

템플릿이 존재하면 배열에서 하나를 랜덤하게 선택한다.

배열이 비어 있으면 Fallback 문구를 사용한다.

이 처리가 없으면 DataAsset 설정이 비어 있을 때 제목이나 설명이 완전히 빈 게시글이 만들어질 수 있다.


ItemDataAsset에서 거래 아이템 선택하기

게시글을 자동 생성하려면 먼저 거래 대상으로 사용할 ItemDataAsset 목록이 필요하다.

ShopSubsystem의 CollectItemDataAssets()가 프로젝트에서 사용 가능한 아이템 에셋을 수집한다.

현재 아이템 후보는 다음 순서로 수집한다.

1. 허브 레벨의 Inventory Preload 목록
2. AssetManager의 FTItemItem Primary Asset 목록
3. AssetRegistry의 아이템 데이터 폴더
4. GameDataAsset의 ItemDataAssets 목록

ItemDataAsset은 FTItemItem Primary Asset Type을 사용한다.

FPrimaryAssetId
UFTItemDataAsset::GetPrimaryAssetId() const
{
    return FPrimaryAssetId(
        FName("FTItemItem"),
        ItemData.ItemId
    );
}

AssetManager에서 해당 타입의 아이템 목록을 가져올 수 있다.

TArray<FPrimaryAssetId> ItemAssetIDs;

AssetManager.GetPrimaryAssetIdList(
    FPrimaryAssetType(TEXT("FTItemItem")),
    ItemAssetIDs
);

각 Primary Asset ID에서 경로를 얻고, 필요한 경우 에셋을 로드한다.

for (const FPrimaryAssetId& ItemAssetID
    : ItemAssetIDs)
{
    UObject* AssetObject =
        AssetManager.GetPrimaryAssetObject(
            ItemAssetID
        );

    if (!AssetObject)
    {
        const FSoftObjectPath AssetPath =
            AssetManager.GetPrimaryAssetPath(
                ItemAssetID
            );

        if (AssetPath.IsValid())
        {
            AssetObject = AssetPath.TryLoad();
        }
    }

    TryAddItemDataAsset(
        Cast<UFTItemDataAsset>(AssetObject)
    );
}

거래 후보에서 제외할 아이템

모든 ItemDataAsset을 거래 대상으로 사용할 수 있는 것은 아니다.

현재 구현에서는 최소한 다음 아이템을 제외한다.

  • ItemID가 없는 아이템
  • 화폐 아이템
  • 이미 추가된 중복 아이템
if (ItemID.IsNone() ||
    ItemID == CurrencyItemID ||
    AddedItemIDs.Contains(ItemID))
{
    return false;
}

화폐를 거래 대상으로 허용하면 코인을 코인으로 사고파는 이상한 게시글이 생성될 수 있기 때문이다.

동일한 아이템이 여러 수집 경로에서 발견되더라도 TSet<FName>으로 중복을 제거한다.

TSet<FName> AddedItemIDs;

다만 현재는 아이템의 용도까지 세밀하게 검사하지 않는다.

따라서 퀘스트 전용 아이템이나 개발용 아이템이 후보에 들어올 가능성이 있다. 이후에는 ItemDataAsset에 거래 가능 여부를 추가하거나, ShopDataAsset에서 후보를 제한할 필요가 있다.


게시글 목록 생성

자동 게시글 생성은 GenerateMarketPostsFromTemplates()에서 처리한다.

void UFTShopSubsystem::
GenerateMarketPostsFromTemplates()
{
    MarketBuyPosts.Reset();
    MarketSellPosts.Reset();

    TArray<UFTItemDataAsset*> CandidateItems;
    CollectItemDataAssets(CandidateItems);

    if (CandidateItems.IsEmpty())
    {
        return;
    }

    const int32 BuyPostCount =
        FMath::Max(
            0,
            GeneratedMarketBuyPostCount
        );

    for (int32 Index = 0;
         Index < BuyPostCount;
         ++Index)
    {
        const int32 RandomIndex =
            FMath::RandRange(
                0,
                CandidateItems.Num() - 1
            );

        MarketBuyPosts.Add(
            BuildGeneratedMarketPost(
                *CandidateItems[RandomIndex],
                Index,
                true
            )
        );
    }

    const int32 SellPostCount =
        FMath::Max(
            0,
            GeneratedMarketSellPostCount
        );

    for (int32 Index = 0;
         Index < SellPostCount;
         ++Index)
    {
        const int32 RandomIndex =
            FMath::RandRange(
                0,
                CandidateItems.Num() - 1
            );

        MarketSellPosts.Add(
            BuildGeneratedMarketPost(
                *CandidateItems[RandomIndex],
                Index,
                false
            )
        );
    }
}

구매 요청과 판매 제안 게시글을 각각 설정된 개수만큼 만든다.

현재는 게시글마다 후보 배열 전체에서 아이템을 다시 선택한다. 따라서 같은 아이템이 서로 다른 게시글에 등장할 수 있다.

이는 실제 중고거래 게시판에서도 같은 물건을 여러 사람이 사고팔 수 있다는 점에서는 자연스럽지만, 다양성을 강제하고 싶다면 선택한 후보를 임시 배열에서 제거하는 방식으로 변경할 수 있다.


개별 게시글 생성 과정

개별 게시글은 BuildGeneratedMarketPost()에서 만든다.

전체 과정은 다음과 같다.

1. ItemDataAsset에서 ItemID와 이름 조회
2. 거래 방향에 맞는 수량 범위 선택
3. 범위 안에서 수량 랜덤 결정
4. 아이템 기본 가격에 가격 배율 적용
5. Prefix 랜덤 선택
6. 거래 이유 랜덤 선택
7. Ending 랜덤 선택
8. 제목과 설명 조합
9. FTTradePostStruct 생성

아이템 이름은 ItemDataAsset의 표시 이름을 우선 사용한다.

const FName ItemID =
    ItemDataAsset.ItemData.ItemId;

const FString ItemName =
    ItemDataAsset.ItemData.ItemName.IsEmpty()
    ? ItemID.ToString()
    : ItemDataAsset
        .ItemData
        .ItemName
        .ToString();

표시 이름이 없으면 ItemID를 대신 사용한다.


거래 수량 랜덤 생성

구매 요청과 판매 제안은 서로 다른 수량 범위를 사용할 수 있다.

const int32 MinCount =
    FMath::Max(
        1,
        bBuyRequest
        ? MinGeneratedBuyRequestItemCount
        : MinGeneratedSellOfferItemCount
    );

const int32 MaxCount =
    FMath::Max(
        MinCount,
        bBuyRequest
        ? MaxGeneratedBuyRequestItemCount
        : MaxGeneratedSellOfferItemCount
    );

const int32 Count =
    FMath::RandRange(
        MinCount,
        MaxCount
    );

최소 수량은 항상 1 이상으로 보정한다.

최대 수량이 최소 수량보다 작게 설정되더라도 FMath::Max()를 이용해 잘못된 범위를 방지한다.

구매 요청 수량: 1~3개
판매 제안 수량: 1~2개

이처럼 거래 방향에 따라 서로 다른 수량 규칙을 사용할 수 있다.


아이템 기본 가격으로 거래 가격 계산하기

거래 가격은 ItemDataAsset의 Cost를 기준으로 계산한다.

const float PriceMultiplier =
    bBuyRequest
    ? MarketBuyRequestPriceMultiplier
    : MarketSellOfferPriceMultiplier;

현재 기본 설정은 다음과 같다.

구매 요청 가격 배율: 0.75
판매 제안 가격 배율: 1.25

아이템의 기본 가격이 100이라면 다음과 같이 계산된다.

NPC 구매 요청 단가
= 100 × 0.75
= 75

NPC 판매 제안 단가
= 100 × 1.25
= 125

전체 가격은 단가에 수량을 곱해서 계산한다.

const int32 UnitPrice =
    FMath::Max(
        1,
        FMath::RoundToInt(
            FMath::Max(
                1,
                ItemDataAsset.ItemData.Cost
            )
            * FMath::Max(
                0.0f,
                PriceMultiplier
            )
        )
    );

GeneratedPost.Price =
    UnitPrice * Count;

가격이 0 이하가 되지 않도록 최소 단가는 1로 보정한다.


Prefix, 아이템명, Reason, Ending 조합

문구 조합은 다음과 같이 이루어진다.

const FText Prefix =
    PickTemplateText(
        PostPrefixes,
        FText::FromString(TEXT("거래"))
    );

const FText Reason =
    bBuyRequest
    ? PickTemplateText(
        BuyRequestReasons,
        FText::FromString(
            TEXT("필요해서 구합니다")
        )
    )
    : PickTemplateText(
        SellOfferReasons,
        FText::FromString(
            TEXT("사용하지 않아 판매합니다")
        )
    );

const FText Ending =
    PickTemplateText(
        PostEndings,
        FText::FromString(TEXT("연락주세요"))
    );

제목은 Prefix와 아이템 이름을 결합한다.

GeneratedPost.Title =
    FText::FromString(
        FString::Printf(
            TEXT("%s %s"),
            *Prefix.ToString(),
            *ItemName
        )
    );

본문은 거래 이유와 Ending을 결합한다.

GeneratedPost.Description =
    FText::FromString(
        FString::Printf(
            TEXT("%s. %s"),
            *Reason.ToString(),
            *Ending.ToString()
        )
    );

최종 결과는 다음과 같은 형태가 된다.

제목:
창고 정리 설탕

본문:
사용하지 않아 판매합니다. 빠른 거래 선호합니다.

자동 생성 게시글 ID

생성된 게시글은 거래 완료 여부를 추적할 수 있도록 고유한 PostID를 가진다.

GeneratedPost.PostID =
    FName(
        *FString::Printf(
            TEXT("Generated_%s_%d_%s"),
            bBuyRequest
            ? TEXT("Buy")
            : TEXT("Sell"),
            PostIndex,
            *ItemID.ToString()
        )
    );

생성 형식은 다음과 같다.

Generated_Buy_0_ID_Food_Water
Generated_Sell_2_ID_Food_Sugar

거래 방향, 게시글 인덱스, 아이템 ID를 조합하기 때문에 같은 생성 목록 안에서 게시글을 구분할 수 있다.

다만 이 ID는 영구적으로 동일한 게시글을 식별하기 위한 ID가 아니라, 현재 생성된 런타임 게시글을 구분하기 위한 ID에 가깝다.

랜덤 생성 결과를 SaveGame에 저장하려면 생성 시드나 별도의 GUID를 고려할 필요가 있다.


수동 게시글과 자동 생성의 균형

UFTShopDataAsset에는 수동으로 작성한 게시글도 저장할 수 있다.

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TArray<FTTradePostStruct> MarketBuyPosts;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TArray<FTTradePostStruct> MarketSellPosts;

자동 생성 사용 여부는 다음 설정으로 결정한다.

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
bool bGenerateMarketPostsFromTemplates = true;

현재 구현에서는 자동 생성 옵션이 켜져 있으면 기존 런타임 게시글 배열을 초기화하고 새 게시글을 만든다.

MarketBuyPosts.Reset();
MarketSellPosts.Reset();

따라서 현재 동작은 다음과 같다.

자동 생성 ON
→ 템플릿 기반 게시글 사용

자동 생성 OFF
→ ShopDataAsset의 수동 게시글 사용

즉, 수동 게시글과 자동 게시글을 동시에 섞는 구조는 아니다.

두 방식을 함께 사용하고 싶다면 다음처럼 확장할 수 있다.

수동 고정 게시글
+
자동 생성 게시글
=
최종 게시글 목록

이 경우 중복 PostID와 동일 아이템 게시글 개수도 함께 관리해야 한다.


랜덤 생성과 기획 제어의 균형

모든 데이터를 완전히 랜덤하게 만들면 개발은 편하지만 결과를 예측하기 어렵다.

반대로 모든 게시글을 수동으로 작성하면 품질은 통제할 수 있지만 데이터 작성량이 많아진다.

이번 구조에서는 랜덤으로 결정할 범위를 기획 데이터로 제한했다.

프로그래머가 정하는 것
- 문구 조합 규칙
- 가격 계산 방식
- 게시글 생성 흐름
- 거래 검증 방식

기획자가 정하는 것
- 사용할 Prefix
- 구매 이유 문구
- 판매 이유 문구
- Ending 문구
- 게시글 개수
- 수량 범위
- 가격 배율
- 자동 생성 사용 여부

런타임에서 결정하는 것
- 선택되는 아이템
- 선택되는 문구
- 거래 수량
- 최종 게시글 조합

이렇게 하면 랜덤성을 유지하면서도 결과 범위를 DataAsset에서 제어할 수 있다.


허브 복귀 시 게시글 새로 생성하기

ShopSubsystem은 게임 흐름 변경 메시지를 구독한다.

플레이어가 레이드에서 허브로 복귀하면 상점 상품과 중고거래 게시글을 갱신한다.

void UFTShopSubsystem::
RefreshShopAndMarketListings()
{
    EnsureShopDataLoaded();

    RefreshShopItems();
    ConsumedMarketPostIDs.Reset();

    if (bGenerateMarketPostsFromTemplates)
    {
        GenerateMarketPostsFromTemplates();
    }
}

전체 흐름은 다음과 같다.

레이드 진행
→ 플레이어 탈출 또는 실패
→ 허브로 복귀
→ FlowStateChanged 메시지
→ ShopSubsystem 수신
→ 완료된 게시글 상태 초기화
→ 새로운 게시글 생성

이를 통해 허브에 돌아올 때마다 중고거래 게시판의 내용이 바뀐다.


거래가 끝난 게시글 제거하기

거래가 완료된 게시글은 다시 거래할 수 없어야 한다.

ShopSubsystem은 완료된 PostID를 ConsumedMarketPostIDs에 저장한다.

UPROPERTY(Transient)
TSet<FName> ConsumedMarketPostIDs;

거래가 성공하면 PostID를 추가한다.

ConsumedMarketPostIDs.Add(PostID);

게시글 목록을 UI에 전달할 때는 완료된 게시글을 제외한다.

void UFTShopSubsystem::GetMarketBuyPosts(
    TArray<FTTradePostStruct>& OutPosts
) const
{
    OutPosts.Reset();

    for (const FTTradePostStruct& Post
        : MarketBuyPosts)
    {
        if (!IsMarketPostConsumed(Post.PostID))
        {
            OutPosts.Add(Post);
        }
    }
}

게시글 검색 함수에서도 이미 거래된 PostID는 반환하지 않는다.

const FTTradePostStruct*
UFTShopSubsystem::FindMarketBuyPost(
    FName PostID
) const
{
    if (IsMarketPostConsumed(PostID))
    {
        return nullptr;
    }

    // 게시글 검색
}

UI에서 게시글이 잠시 남아 있더라도 같은 거래를 다시 실행할 수 없도록 Subsystem에서 한 번 더 막는다.


구매 요청 게시글 거래 처리

구매 요청 게시글은 NPC가 아이템을 구하는 글이다.

플레이어는 요청받은 아이템을 넘기고 화폐를 받는다.

MarketBuyPost
→ 플레이어가 아이템 판매
→ SellMarketItem 실행

Market ViewModel에서는 구매 요청 모드일 때 SellMarketItem()을 호출한다.

const bool bSuccess =
    bBuyRequestMode
    ? ShopSubsystem->SellMarketItem(
        Post->PostID,
        PlayerInventory
    )
    : ShopSubsystem->BuyMarketItem(
        Post->PostID,
        PlayerInventory
    );

ShopSubsystem의 거래 과정은 다음과 같다.

게시글 존재 여부 확인
→ 이미 완료된 게시글인지 확인
→ 아이템과 수량 검증
→ 플레이어와 창고의 보유 수량 확인
→ 아이템 소비
→ 거래 대금 지급
→ 게시글 완료 처리

아이템은 플레이어 인벤토리에서 먼저 소비하고 부족한 수량은 창고에서 소비할 수 있다.

거래 대금은 플레이어 인벤토리에 지급한다.


판매 제안 게시글 거래 처리

판매 제안 게시글은 NPC가 아이템을 판매하는 글이다.

플레이어는 화폐를 지불하고 아이템을 받는다.

MarketSellPost
→ 플레이어가 아이템 구매
→ BuyMarketItem 실행

거래 과정은 다음과 같다.

게시글 존재 여부 확인
→ 이미 완료된 게시글인지 확인
→ 거래 수량 검증
→ 플레이어와 창고의 화폐 확인
→ 화폐 소비
→ 아이템 지급
→ 게시글 완료 처리

아이템 지급에 실패하면 먼저 소비한 화폐를 다시 복구한다.

if (!PlayerInventory->AddItem(
    ResolvedItemID,
    Post->Count
))
{
    AddCurrency(
        PlayerInventory,
        Price
    );

    return false;
}

거래가 성공한 경우에만 게시글을 완료 상태로 만든다.


Market ViewModel의 역할

UFTMarketViewModel은 게시글 생성이나 거래 규칙을 직접 처리하지 않는다.

ShopSubsystem에서 게시글을 가져와 UI용 객체로 변환하고, 사용자의 거래 요청을 다시 Subsystem에 전달한다.

void UFTMarketViewModel::Initialize(
    UFTShopSubsystem* InShopSubsystem,
    UFTInventoryComponent* InPlayerInventory
)
{
    ShopSubsystem = InShopSubsystem;
    PlayerInventory = InPlayerInventory;

    BindInventoryDelegate();
    RefreshAll();
}

ViewModel이 관리하는 UI 상태는 다음과 같다.

  • 현재 구매 요청 탭인지 판매 제안 탭인지
  • 표시할 게시글 목록
  • 선택된 게시글
  • 선택된 거래 아이템
  • 게시글 제목과 설명
  • 아이템 수량과 가격
  • 아이템 아이콘
  • 플레이어 보유 수량
  • 거래 가능 여부
  • 거래 버튼 문구

게시글을 ListObject로 변환하기

ListView에는 원본 구조체를 직접 넣지 않고 UFTTradePostListObject로 변환한다.

void UFTMarketViewModel::RefreshTradePosts()
{
    TradePostObjects.Reset();

    if (!ShopSubsystem)
    {
        return;
    }

    TArray<FTTradePostStruct> Posts;

    if (bBuyRequestMode)
    {
        ShopSubsystem->GetMarketBuyPosts(
            Posts
        );
    }
    else
    {
        ShopSubsystem->GetMarketSellPosts(
            Posts
        );
    }

    for (const FTTradePostStruct& Post : Posts)
    {
        UFTTradePostListObject* PostObject =
            NewObject<UFTTradePostListObject>(
                this
            );

        PostObject->Initialize(Post);
        TradePostObjects.Add(PostObject);
    }
}

목록 EntryWidget은 ListObject를 통해 제목과 미리보기 정보를 표시한다.

FTTradePostStruct
        ↓
UFTTradePostListObject
        ↓
Trade Post EntryWidget

게시글 선택과 상세 정보 표시

사용자가 게시글을 선택하면 ViewModel이 해당 게시글을 현재 선택 상태로 저장한다.

void UFTMarketViewModel::
SelectTradePostObject(UObject* ItemObject)
{
    SelectedPostObject =
        Cast<UFTTradePostListObject>(
            ItemObject
        );

    RefreshSelectedPostItems();
    NotifyChanged();
}

선택된 게시글의 거래 아이템은 UFTItemTileListObject로 변환한다.

void UFTMarketViewModel::
RefreshSelectedPostItems()
{
    SelectedPostItemObjects.Reset();

    const FTTradePostStruct* Post =
        GetSelectedPost();

    if (!Post ||
        Post->GetResolvedItemID().IsNone() ||
        Post->Count <= 0)
    {
        return;
    }

    UFTItemTileListObject* ItemObject =
        NewObject<UFTItemTileListObject>(this);

    ItemObject->InitializeItem(
        Post->GetResolvedItemID(),
        Post->Count,
        Post->Price
    );

    SelectedPostItemObjects.Add(ItemObject);
}

이 객체를 통해 상세 패널에서 다음 정보를 표시할 수 있다.

  • 아이템 이름
  • 아이템 설명
  • 아이템 종류
  • 아이템 아이콘
  • 거래 수량
  • 거래 가격

거래 방향에 따른 버튼 처리

ViewModel의 bBuyRequestMode는 게시글 작성자의 관점을 나타낸다.

따라서 거래 버튼 문구는 플레이어 행동으로 변환해야 한다.

FText UFTMarketViewModel::
GetTradeActionText() const
{
    return bBuyRequestMode
        ? FText::FromString(TEXT("판매하기"))
        : FText::FromString(TEXT("구매하기"));
}
구매 요청 탭
= NPC가 구매 요청
= 플레이어는 판매하기

판매 제안 탭
= NPC가 판매 제안
= 플레이어는 구매하기

거래 가능 여부도 방향에 따라 다른 함수를 호출한다.

bool UFTMarketViewModel::
CanTradeSelectedPost() const
{
    const FTTradePostStruct* Post =
        GetSelectedPost();

    if (!Post ||
        !ShopSubsystem ||
        !PlayerInventory)
    {
        return false;
    }

    return bBuyRequestMode
        ? ShopSubsystem->CanSellMarketItem(
            Post->PostID,
            PlayerInventory
        )
        : ShopSubsystem->CanBuyMarketItem(
            Post->PostID,
            PlayerInventory
        );
}

거래 후 UI 갱신

거래가 성공하면 ShopSubsystem이 해당 게시글을 완료 처리한다.

ViewModel은 게시글 목록을 다시 가져온다.

bool UFTMarketViewModel::TradeSelectedPost()
{
    const FTTradePostStruct* Post =
        GetSelectedPost();

    if (!Post || !ShopSubsystem)
    {
        return false;
    }

    const bool bSuccess =
        bBuyRequestMode
        ? ShopSubsystem->SellMarketItem(
            Post->PostID,
            PlayerInventory
        )
        : ShopSubsystem->BuyMarketItem(
            Post->PostID,
            PlayerInventory
        );

    if (!bSuccess)
    {
        return false;
    }

    RefreshAll();
    return true;
}

완료된 게시글은 ShopSubsystem에서 제외되므로 새 목록에는 나타나지 않는다.

거래 성공
→ ConsumedMarketPostIDs에 PostID 추가
→ Market ViewModel 목록 갱신
→ 완료된 게시글 제외
→ UI에서 게시글 제거

인벤토리 변경과 UI 갱신

Market ViewModel은 플레이어 InventoryComponent의 OnInventoryChanged Delegate를 구독한다.

void UFTMarketViewModel::
BindInventoryDelegate()
{
    if (PlayerInventory)
    {
        PlayerInventory
            ->OnInventoryChanged
            .AddDynamic(
                this,
                &UFTMarketViewModel::
                    HandleInventoryChanged
            );
    }
}

인벤토리 수량이 변경되면 게시글의 거래 가능 여부와 보유 수량을 다시 계산한다.

void UFTMarketViewModel::
HandleInventoryChanged()
{
    RefreshAll();
}

현재 ViewModel의 보유 수량 텍스트는 플레이어 인벤토리 수량만 표시하지만, 실제 판매 가능 여부는 ShopSubsystem에서 플레이어와 창고의 합산 수량을 기준으로 판단한다.

따라서 이후에는 UI에도 합산 수량을 표시하도록 기준을 통일할 필요가 있다.


최종 게시글 생성 흐름

자동 게시글 생성 흐름은 다음과 같다.

1. ShopSubsystem이 ShopDataAsset을 로드한다.

2. 자동 생성 옵션을 확인한다.

3. ItemDataAsset 후보를 수집한다.

4. ItemID가 없거나 화폐인 아이템을 제외한다.

5. 구매 요청 게시글 개수만큼
   아이템을 랜덤 선택한다.

6. 판매 제안 게시글 개수만큼
   아이템을 랜덤 선택한다.

7. 거래 방향에 맞는 수량 범위에서
   수량을 선택한다.

8. 아이템 Cost에 가격 배율을 적용한다.

9. Prefix를 랜덤 선택한다.

10. 구매 또는 판매 이유를 선택한다.

11. Ending을 랜덤 선택한다.

12. 제목과 설명을 조합한다.

13. PostID, 아이템, 수량, 가격을 포함한
    FTTradePostStruct를 생성한다.

14. Market ViewModel이 게시글을
    ListObject로 변환한다.

15. Widget이 게시글 목록을 화면에 표시한다.

거래 흐름은 다음과 같다.

Market Widget
→ Market ViewModel
→ ShopSubsystem
→ InventoryComponent / StorageSubsystem
→ 거래 완료 상태 기록
→ Market ViewModel 갱신
→ 완료된 게시글 제거

최종 구조

객체역할
UFTItemDataAsset 아이템 이름, ID, 기본 가격 제공
UFTShopDataAsset 문구와 생성 범위 설정
FTTradePostStruct 개별 거래 게시글 데이터
UFTShopSubsystem 게시글 생성과 실제 거래 처리
UFTTradePostListObject 게시글 목록 UI용 데이터
UFTItemTileListObject 선택한 거래 아이템 UI 데이터
UFTMarketViewModel 게시글 선택과 UI 상태 관리
UFTHubMarketPanelWidget 화면 표시와 사용자 입력
UFTInventoryComponent 실제 아이템과 화폐 데이터
UFTStorageSubsystem 플레이어와 창고의 통합 소비

리팩터링하면서 배운 점

이번 작업에서 가장 크게 배운 점은 랜덤 생성도 데이터 중심으로 설계해야 한다는 것이었다.

처음에는 게시글 제목과 설명을 코드에서 랜덤으로 만들면 된다고 생각했다.

하지만 문구를 C++에 하드코딩하면 표현을 수정할 때마다 코드를 다시 빌드해야 하고, 기획자가 결과를 제어하기 어렵다.

따라서 코드에는 조합 규칙만 두고, 실제 문구와 범위는 DataAsset에서 관리했다.

코드
= 어떻게 조합할 것인가?

DataAsset
= 무엇을 조합할 것인가?

ItemDataAsset
= 어떤 아이템을 거래할 것인가?

Subsystem
= 현재 어떤 게시글을 만들 것인가?

ViewModel
= 게시글을 어떻게 화면에 보여줄 것인가?

또한 자동 생성과 완전한 랜덤 생성은 다르다는 점도 알게 되었다.

이번 구조에서는 기획자가 허용한 문구와 가격 범위 안에서만 랜덤 결과가 만들어진다. 덕분에 반복 작업을 줄이면서도 게임 분위기에 맞는 결과를 유지할 수 있었다.


이후 개선할 점

현재 자동 게시글 생성 시스템에도 개선할 부분이 있다.

  • 거래 가능한 아이템만 후보로 사용하는 태그 추가
  • 퀘스트 및 개발 전용 아이템 제외
  • 같은 아이템이 여러 게시글에 등장하는 빈도 제한
  • 아이템 희귀도에 따른 등장 확률
  • 카테고리별 게시글 생성 비율
  • 생성 결과를 SaveGame에 저장
  • Seed 기반의 재현 가능한 랜덤 생성
  • 수동 게시글과 자동 게시글 혼합
  • 게시글 만료 시간
  • 판매자 이름과 프로필 정보
  • 가격 변동과 시장 시세 시스템
  • 거래 횟수 및 수량 제한
  • 거래 실패 사유를 결과 타입으로 반환
  • 창고 변경 Delegate까지 ViewModel에 연결
  • UI 보유 수량과 실제 합산 수량 기준 통일
  • FText::Format을 이용한 현지화 친화적 문장 구성

현재는 FString::Printf()로 FText를 조합하고 있다.

다국어 지원을 고려한다면 문장 순서가 언어마다 다를 수 있으므로 FText::Format()과 플레이스홀더를 사용하는 것이 더 적합하다.

FText::Format(
    NSLOCTEXT(
        "Market",
        "PostTitle",
        "{0} {1}"
    ),
    Prefix,
    ItemName
);

또한 판매자 성격이나 아이템 종류별로 다른 문구 그룹을 사용하면 게시글의 반복감을 더 줄일 수 있다.


마무리

이번 중고거래 게시글 자동 생성 시스템을 통해 역할을 다음과 같이 정리했다.

ItemDataAsset은 거래 아이템 정보
ShopDataAsset은 문구와 생성 규칙
TradePostStruct는 게시글 데이터
ShopSubsystem은 게시글 생성과 거래 처리
MarketViewModel은 UI용 게시글 상태
Widget은 화면 표시와 사용자 입력

아이템마다 게시글을 직접 작성하는 방식에서 벗어나면서 새로운 아이템이 추가되어도 자동으로 거래 게시글 후보에 포함될 수 있게 되었다.

동시에 모든 결과를 완전히 무작위로 만들지 않고, 기획자가 DataAsset에서 문구와 가격 범위를 제어하도록 구성했다.

결과적으로 반복적인 데이터 작성량을 줄이면서도 허브에 복귀할 때마다 새로운 거래 게시글이 나타나는 동적인 중고거래 시스템을 만들 수 있었다.