구매 UI 처리 및 실제 구매 로직 구조

목차

1. 요구 사항

2. 설계 목표

3. 흐름도

4. 구현

        4.1. 구매 UI 설정

       4.2. 수량 조절과 이벤트 기반 UI 갱신

       4.3. 구매 UI 갱신

       4.4. 실제 구매 로직과 검증 순서

       4.5. 오류 피드백과 코루틴

5. 개발 의도

1. 시스템 요구 사항

상점에서 아이템을 클릭하면 단순히 목록에서 선택 상태로 끝나는 것이 아니라, 별도의 구매 패널이 열리고 해당 아이템의 상세 정보와 수량 조절 UI가 함께 표시되어야 한다.

플레이어는 구매 수량을 직접 조절할 수 있어야 하며, 수량이 변경될 때마다 총 구매 금액이 즉시 재계산되어 화면에 반영되어야 한다.

구매 버튼을 눌렀을 때는 반드시 현재 보유 골드가 충분한지를 먼저 검증해야 하며, 골드가 부족한 경우에는 어떤 데이터도 변경되지 않은 채 오류 메시지만 표시되어야 한다.

구매가 성공하면 인벤토리에 아이템이 추가되고, 골드는 차감되며, 인벤토리 슬롯 UI와 HUD상의 골드 표시가 즉시 갱신되어야 한다.

이 과정에서 중요한 점은 구매 UI가 인벤토리의 내부 구조를 직접 다루지 않아야 한다는 것이다.

아이템이 실제로 인벤토리에 추가 가능한지(스택 한도, 슬롯 여유 공간 등)에 대한 최종 판단은 InventoryManager가 수행해야 하며, 구매 UI는 단지 선택된 아이템과 수량을 전달하는 역할만 수행해야 한다.

또한 무기와 물약은 서로 다른 데이터 타입이지만, 구매 UI에서는 동일한 처리 흐름으로 다뤄져야 하므로 InventoryItemData를 공통 기준 타입으로 사용해야 한다.

상점은 아이템을 고르는 역할을 하고, 구매 UI는 수량 계산과 결제 검증 및 실행을 담당하며, 인벤토리는 실제 데이터 구조를 관리하는 구조가 되어야 한다.

2. 설계  목표

- 선택된 아이템을 InventoryItemData기준으로 통합 관리

- 수량 조절과 금액 계산을 UI 내부에서 독립 처리

- 구매 전 골드 검증 선행

- 구매 성공 시 인벤토리와 HUD 동기화

- 상점 목록 로직과 구매 로직의 책임 분리

3. 흐름도

이 흐름의 핵심은 ‘선택(표시)’, ‘계산(UI 상태)’, ‘검증(결제 가능 여부)’, ‘실행(데이터 변경)’ 을 섞지 않고 단계별로 분리하는 것이다.

계산은 구매 UI가 담당하지만, 아이템이 실제로 추가 가능한지는 InventoryManager가 최종 판단한다.

또한 데이터 변경 이후 UI를 명시적으로 재렌더링하는 구조를 사용해 상태 전이를 명확하게 유지한다.

4. 구현

4.1. 구매 UI 설정
// ShopManager.cs
public void WeaponBtnClick()
{
    ...  
    for (int i = 0; i < weapons.Length; i++)
    {
        slotWDInstance.GetComponent<Button>().onClick.AddListener(() => { BuyPanelActive(); buyUiManager.WeaponBuySell(weapons[index]); });
    }
}

public void PotionBtnClick()
{
    ...
    for (int i = 0; i < potions.Length; i++)
    {
        slotWDInstance.GetComponent<Button>().onClick.AddListener(() => { BuyPanelActive(); buyUiManager.PotionBuySell(potions[index]); });
    }
}
// BuyUIManager.cs
public void WeaponBuySell(EquipmentItmeData item)
{
    selectedData = item;

    icon.sprite = item.Icon;
    itemName.text = item.ItemName;
    itemDescription.text = item.Tooltip + "\n" + "마력 : " + item.MagicAttack.ToString();

    amount = 1;
    price = item.PurchasePrice * amount;

    TextUpdate();
}

public void PotionBuySell(PotionItemData item)
{
    selectedData = item;

    icon.sprite = item.Icon;
    itemName.text = item.ItemName;
    itemDescription.text = item.Tooltip;

    amount = 1;
    price = item.PurchasePrice * amount;

    TextUpdate();
}

슬롯 클릭 시 ShopManager는 구매 패널을 활성화하고 BuyUIManager에 선택된 아이템을 전달한다.

이 부분은 이전 게시글에서 상점 슬롯 생성 및 클릭 이벤트 처리 구조를 상세히 설명했으므로, 여기서는 구매 UI와의 연결 관점에서만 다룬다.

BuyUIManager에서는 선택된 데이터를 EquipmentItemData와 PotionItemData로 따로 보관하지 않고, 공통 부모 타입인 InventoryItemData로 selectedData에 저장한다.

이는 C#의 다형성을 활용한 설계다.

하위 타입을 상위 타입으로 참조하면, 이후 로직은 구체 타입에 의존하지 않고 동일한 인터페이스를 기준으로 동작할 수 있다.

덕분에 수량 계산, 가격 계산, 구매 실행 로직은 이 아이템이 무기인지 물약인지를 다시 판단할 필요가 없다.

타입 분기(if-else 또는 switch)를 제거함으로써 확장성을 확보한 구조다.

con.sprite에 Sprite를 할당하는 부분은 UnityEngine.UI.Image 컴포넌트의 sprite 프로퍼티를 사용하는 것이다.

이 프로퍼티는 즉시 화면에 반영되는 렌더링 바인딩 지점이다.

TextMeshProUGUI.text 역시 문자열을 설정하면 즉시 렌더링이 갱신된다.

장점은 구현이 직관적이고 흐름이 명확하다는 점이다.

단점은 UI 구조가 변경되면 코드 수정이 필요하다는 점이지만, 이 프로젝트에서는 직접 바인딩을 통해 제어 지점을 명확히 드러내는 방식을 선택했다.

amount를 1로 초기화한 것은 구매는 최소 1개부터라는 규칙을 UI 정책으로 고정하기 위함이다.

또한 price는 단가(PurchasePrice)와 수량(amount)을 곱해 매번 재계산한다.

여기서 중요한 점은 가격을 누적해서 더하지 않고, 항상 '단가 × 수량' 으로 다시 계산한다는 것이다.

누적 방식은 버튼 입력 순서나 이전 상태가 남아 있을 때 값이 오염될 위험이 커지지만, 재계산 방식은 항상 같은 입력에 대해 같은 결과가 나오므로 상태 일관성이 높다.

마지막의 TextUpdate 호출은 계산과 출력 책임을 분리하기 위한 장치로, UI 텍스트 갱신을 한 곳으로 모아 수량이 바뀌는 모든 지점에서 동일한 규칙으로 갱신되도록 했다.

4.2. 수량 조절과 이벤트 기반 UI 갱신
public void Minus1Btn()
{
    if (selectedData == null) return;
    if (amount <= 1) return;

    amount--;
    price = selectedData.PurchasePrice * amount;
    TextUpdate();
}

public void Minus10Btn()
{
    if (selectedData == null) return;

    if (amount > 10)
    {
        amount -= 10;
    }
    else
    {
        amount = 1;
    }

    price = selectedData.PurchasePrice * amount;
    TextUpdate();
}

public void Plus1Btn()
{
    if (selectedData == null) return;

    amount++;
    price = selectedData.PurchasePrice * amount;
    TextUpdate();
}

public void Plus10Btn()
{
    if (selectedData == null) return;

    amount += 10;
    price = selectedData.PurchasePrice * amount;
    TextUpdate();
}

수량 버튼 함수들은 모두 동일한 패턴을 따른다.

먼저 selectedData 존재 여부를 검사한 뒤 amount를 변경하고, 즉시 price를 '단가 × 수량'으로 재계산하고 TextUpdate를 호출한다.

이 흐름을 강제함으로써 수량과 가격은 항상 동기화된 상태를 유지한다.

여기서 중요한 설계 선택은 Update를 사용하지 않았다는 점이다.

Unity의 Update는 매 프레임 호출되는 생명주기 함수로, 상태가 변하지 않아도 반복 실행된다.

이 UI는 프레임 기반 로직이 아니라 사용자 입력 이벤트에 의해 상태가 변하는 구조다.

따라서 값이 실제로 변한 순간에만 TextUpdate를 호출하는 이벤트 기반 갱신이 더 적합하다.

이 방식은 불필요한 프레임 단위 연산을 줄일 뿐 아니라, UI 갱신 시점이 코드 흐름에 명확하게 드러난다는 장점이 있다.

Minus1Btn에서 amount가 1 이하일 경우 즉시 반환하는 것은 음수 구매나 0개 구매를 구조적으로 차단하기 위한 방어 코드다.

UI 단계에서 유효 범위를 보장하면 이후 결제 로직은 정상 범위만을 전제로 단순하게 구성할 수 있다.

Minus10Btn은 10개 단위 감소 입력을 제공하지만, 10 이하로 내려갈 경우 최소값을 1로 고정한다.

이 역시 상태의 하한을 보장하는 정책이다.

4.3. 구매 UI 갱신
void TextUpdate()
{
    amountTxt.text = "선택된 수량 : " + amount.ToString();
    priceTxt.text = "금액 : " + price.ToString();
}

TextUpdate는 amount와 price를 UI 텍스트에 반영하는 전용 함수다

amount와 price는 내부 상태 변수이고, TextMeshProUGUI는 화면에 출력되는 표현 계층이므로, 이 함수를 통해 상태 변경과 표현 갱신을 분리했다.

ToString은 정수 값을 문자열로 변환하는 C# 표준 기능이며, UI는 문자열 기반 출력이기 때문에 숫자 상태를 화면에 보여주기 위해 반드시 필요한 변환이다.

이러한 갱신을 여러 버튼 함수에 흩뿌리지 않고 한 함수에 모으면, 향후 텍스트 포맷 변경(예: 천 단위 콤마, 단위 추가, 로컬라이징)이 필요할 때 수정 지점을 단일화할 수 있다.

4.4. 실제 구매 로직과 검증 순서
public void BuyBtn()
{
    if (selectedData == null) return;

    if (inventoryManager.gold.CurrentAmount < price)
    {
        NotEnoughGold();
        return;
    }

    inventoryManager.TryAddItem(selectedData, amount);

    SlotManager.instance.PoplateSlots();

    inventoryManager.gold.CurrentAmount -= price;
    HUD_StatManager.instance.GoldTextUpdate();
    gameObject.SetActive(false);
}

BuyBtn은 실제 데이터 변경이 발생하는 결제 실행의 단일 진입점이다.

버튼이 눌리면 가장 먼저 selectedData가 존재하는지 확인하는데, 이는 아무 아이템도 선택되지 않은 상태에서 결제가 실행되는 것을 막는 최소한의 방어 장치다.

이 구조에서 골드 부족은 선차단되므로, 골드 부족 상태에서 데이터 변경이 발생하지는 않는다.

그러나 TryAddItem의 성공 여부를 확인하지 않고 바로 골드를 차감하는 구조라면, 인벤토리 공간 부족과 같은 상황에서 아이템은 추가되지 않았지만 골드는 차감되는 문제가 발생할 가능성이 있다.

따라서 결제의 원자성을 보장하려면 TryAddItem이 bool을 반환하도록 구성하고, 성공했을 때만 골드를 차감해야 한다.

결제는 '아이템 추가 + 골드 차감'이 하나의 논리적 트랜잭션처럼 동작해야 한다.

둘 중 하나라도 실패하면 전체가 취소되어야 한다.

이 구조를 명시적으로 보장하는 것이 시스템 안정성 측면에서 더 완성도 높은 설계다.

PoplateSlots를 명시적으로 호출하는 이유는 Unity UI가 데이터 변경을 자동으로 감지하지 않기 때문이다.

이 프로젝트는 이벤트 기반 자동 동기화 대신, 데이터 변경 직후 UI를 직접 재렌더링하는 정책을 선택했다.

이 방식은 흐름이 명확하고 디버깅이 용이하다.

그 다음 골드를 차감하고 HUD 골드를 갱신한다.

골드 차감은 구매가 성공했다는 전제 하에 실행되어야 하므로, 최소한 골드 검증 이후에 위치해야 한다.

또한 GoldTextUpdate를 호출해 화면 상단의 골드 표시를 즉시 동기화한다.

gameObject.SetActive(false)는 오브젝트를 파괴하지 않고 비활성화하는 Unity API다.

Destroy와 달리 메모리 해제 없이 재사용 가능 상태를 유지한다는 장점이 있다.

단점은 이전 상태가 남을 수 있다는 점인데, 이 구조에서는 패널 오픈 시 amount와 price를 항상 초기화하므로 상태 오염은 발생하지 않는다.

4.5. 오류 피드백과 코루틴
void NotEnoughGold()
{
    errorTxt.text = "골드가 부족합니다.";
    StartCoroutine(ErrorActive(0.5f));
}

IEnumerator ErrorActive(float time)
{
    errorPanel.SetActive(true);
    yield return new WaitForSeconds(time);
    errorPanel.SetActive(false);
}

골드가 부족한 경우에는 시스템 상태를 변경하지 않고, 사용자에게 왜 구매가 실패했는지만 전달해야 한다.

골드 부족 시에는 StartCoroutine을 사용해 일정 시간 오류 패널을 표시한다.

Coroutine은 Unity의 비동기 흐름 제어 방식으로, 메인 스레드에서 프레임 단위로 이어 실행된다.

WaitForSeconds를 사용하면 잠시 표시 후 자동 종료라는 의도를 코드 구조로 명확히 표현할 수 있다.

Update에서 타이머를 직접 구현하는 방식보다 가독성이 높다.

5. 개발 의도

이 구매 시스템은 구매 UI가 인벤토리 시스템의 일부가 되지 않도록 경계를 명확히 나누는 것을 목표로 했다.

상점은 판매 목록을 보여주고 어떤 아이템을 살지 선택만 전달하며, 구매 UI는 선택된 아이템을 기준으로 수량과 금액을 계산하고 결제 가능 여부를 검증한 뒤 결제 실행을 호출한다.

하지만 인벤토리에 실제로 아이템을 추가할 수 있는지(스택 제한, 공간 부족 등)의 최종 판단은 InventoryManager가 담당한다.

이 책임 분리를 통해 구매 UI는 인벤토리의 내부 구조를 몰라도 되고, 인벤토리 구조가 바뀌어도 구매 UI는 결제 흐름만 유지하면 되도록 설계했다.

또한 실패 조건(골드 부족)에서는 데이터 변경이 0으로 보장되도록 검증을 최우선으로 배치했고, 성공 시에는 인벤토리 데이터와 UI/HUD가 즉시 동기화되도록 명시적인 갱신 호출 흐름을 구성했다.

결과적으로 이 구조는 ‘선택 → 계산 → 검증 → 실행 → 반영’의 흐름을 안정적으로 유지하면서도, 시스템 간 결합도를 낮추는 방향으로 구매 로직을 정리한 것이다.