판매 UI 처리 및 다중 슬롯 아이템 제거 구조

목차

1. 요구 사항

2. 설계 목표

3. 흐름도

4. 구현

        4.1. 판매 진입점과 아이템 선택 처리

       4.2. 전체 인벤토리 기준 수량 합산

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

       4.4. 판매 UI 갱신

       4.5. 판매 실행과 다중 슬롯 제거

       4.6. 오류 피드백과 코루틴

5. 개발 의도

1. 시스템 요구 사항

플레이어는 인벤토리 슬롯에서 아이템을 선택하여 판매할 수 있어야 한다.

판매 UI는 특정 슬롯의 수량만 처리하는 것이 아니라, 인벤토리 전체에서 동일한 아이템이 몇 개 존재하는지를 기준으로 판매 수량을 결정해야 한다.

즉, 같은 아이템이 여러 슬롯에 나누어 들어 있는 경우에도 총 보유 수량을 정확히 계산해야 한다.

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

판매 버튼이 눌리면 선택된 수량만큼 아이템이 인벤토리에서 제거되고, 그에 해당하는 골드가 증가해야 한다.

보유 수량이 부족한 경우에는 어떤 데이터도 변경되지 않아야 하며, 오류 메시지만 일정 시간 표시되어야 한다.

판매 UI는 인벤토리의 슬롯 구조를 직접 수정하는 주체가 아니라, 제거 요청을 전달하는 계층으로 설계되어야 한다.

실제 제거 로직은 InventoryManager가 담당한다.

2. 설계  목표

- 인벤토리 전체를 기준으로 동일 아이템 수량 합산

- 다중 슬롯에 걸친 부분 제거 지원

- 판매 전 보유 수량 검증

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

- 판매 실패 시 상태 전이 차단

3. 흐름도

(Inventory Slot Right Click)


SellUIManager.SellSetting(slotData)

├─(slotData 유효성 검사 실패)──► UI 초기화(TextUpdate) ─► return


currentItemData 설정/amount 초기화(또는 증가)


GetTotalItemCount(currentItemData) ──► totalCount 산출


amount 보정(amount ≤ totalCount)


sellPrice = SellingPrice × amount


TextUpdate(수량/금액 표시)


(수량 버튼 입력) ─► amount 변경 ─► totalCount 기준 상/하한 보정 ─► sellPrice 재계산 ─► TextUpdate


SellBtn 클릭

├─(totalCount <amount or amount ≤ 0)──► NotEnoughItem ─► Coroutine로 메시지 표시 ─► return


InventoryManager.RemoveItem(currentItemData, amount)


gold += sellPrice ─► PoplateSlots ─► HUD 골드 갱신


상태 초기화 + 패널 닫기(SetActive(false))

이 흐름은 '선택 → 합산 → 수량 동기화 → 검증 → 실행 → UI 반영 → 상태 초기화' 의 단계를 명확히 분리한다.

데이터 변경은 오직 SellBtn 내부에서만 발생한다.

4. 구현

4.1. 판매 진입점과 아이템 선택 처리
// InventorySlot.cs
public void OnPointerClick(PointerEventData eventData)
{
    ...
    
    if (eventData.button == PointerEventData.InputButton.Right)
    {
        if (UI_Manager.instance.shopUI.activeSelf)
        {
            sellUimanager.SellSetting(slotData);
        }
    }
    ...
}
public void SellSetting(InventorySlotData slotData)
{
    gameObject.SetActive(true);

    if (slotData == null || slotData.IsEmpty || slotData.data == null)
    {
        currentItemData = null;
        amount = 0;
        sellPrice = 0;
        TextUpdate();
        return;
    }

    var data = slotData.data;

    if (currentItemData != data)
        amount = 1;
    else
        amount++;

    currentItemData = data;

    int totalCount = GetTotalItemCount(currentItemData);

    if (amount > totalCount)
        amount = totalCount;

    itemIcon.sprite = currentItemData.Icon;
    itemName.text = currentItemData.ItemName;

    sellPrice = currentItemData.SellingPrice * amount;
    TextUpdate();
}

SellSetting 함수는 판매 UI의 단일 진입점이다.

인벤토리에서 상점 UI가 활성화되어있을 때, 슬롯을 우클릭하면 호출되며, 현재 판매 대상 아이템을 확정한다.

gameObject.SetActive(true)는 Unity에서 오브젝트를 재사용하는 전형적인 방식이다.

Destroy 대신 활성/비활성 전환을 사용하면 메모리 할당과 참조 재연결 비용을 줄일 수 있다.

단점은 이전 상태가 남을 수 있다는 점인데, 이 함수에서 amount와 sellPrice를 항상 재설정함으로써 상태 잔존 문제를 차단한다.

초반에 null/IsEmpty 검사는 판매 UI가 잘못된 슬롯 입력으로 인해 예외를 터뜨리는 것을 막는 방어 장치다.

Unity에서는 이벤트 연결이 남아 있거나 슬롯이 동적으로 재구성되는 상황에서 비어 있는 슬롯이 클릭 입력을 받을 수 있기 때문에, 이 단계에서 잘못된 입력을 차단하고 UI를 안전한 초기 상태로 되돌린다.

currentItemData에 데이터를 저장하는 방식은 구매와 동일하게 ‘데이터 타입 통합’ 전략을 유지한다.

판매 UI는 인벤토리 내부 구조를 몰라도 되고, 아이템 공통 데이터(아이콘, 이름, 판매가)를 기준으로 동작한다.

icon.sprite와 itemName.text는 Unity UI 바인딩의 직접 세팅 방식인데, 즉시 반영되고 구현이 단순하다는 장점이 있다.

반대로 UI 계층이나 컴포넌트 참조가 바뀌면 코드 수정이 필요하다는 단점이 있으나, 이 프로젝트는 UI 구조가 비교적 고정되어 있고 데이터가 UI에 어떻게 들어가는지를 명확히 보여주는 것이 목적이므로 이 방식을 선택한 것으로 설명할 수 있다.

다만 이 함수의 amount 증가 정책은 반드시 의도를 명확히 해야 한다.

현재 코드는 같은 아이템을 다시 클릭하면 amount를 +1로 올리는데, 수량 조절을 버튼으로 제공하는 구조와 혼합되면 사용자 입장에서는 수량이 예기치 않게 바뀌는 경험이 될 수 있다.

이 함수의 amount 증가 정책은 의도를 명확히 설명할 필요가 있다.

현재 구조에서는 이전에 선택된 아이템과 동일하지 않은 아이템을 클릭할 경우에는 수량을 1로 초기화한다.

이전에 선택된 아이템과 동일한 아이템을 다시 클릭할 경우 amount를 1씩 증가시킨다.

이는 단순한 중복 선택 처리가 아니라, 슬롯 반복 클릭을 빠른 수량 증가 인터랙션으로 활용하기 위한 설계다.

플레이어는 동일 아이템을 여러 번 클릭하여 대략적인 판매 수량을 빠르게 설정할 수 있고, 이후 Plus/Minus 버튼을 통해 세밀하게 보정할 수 있다.

즉, 판매 수량 조절은 두 단계의 입력 경로를 가진다.

슬롯 반복 클릭은 거친 단위 증가를 담당하고, 버튼 입력은 정밀 조절을 담당한다.

이렇게 역할을 분리함으로써 입력 속도와 제어 정밀도를 동시에 확보했다.

물론 이 구조는 잘못 설계될 경우 예기치 않은 수량 증가로 이어질 수 있다.

이를 방지하기 위해 amount는 항상 GetTotalItemCount를 통해 상한 보정을 거치며, SellBtn 단계에서도 total < amount 검증을 다시 수행한다.

따라서 반복 클릭을 통해 수량을 증가시키더라도 보유 수량을 초과하는 상태는 구조적으로 차단된다.

gameObject.SetActive(true)는 패널을 파괴하지 않고 재사용하는 Unity 방식이다.

Instantiate/Destroy 비용을 줄이고 참조 연결을 유지할 수 있다는 장점이 있다.

대신 상태 초기화를 명확히 하지 않으면 이전 값이 남을 수 있으므로, 이 함수에서 currentItemData와 amount, sellPrice를 항상 재설정하여 상태 일관성을 유지한다.

4.2. 전체 인벤토리 기준 수량 합산
int GetTotalItemCount(InventoryItemData data)
{
    if (data == null) return 0;

    int total = 0;

    foreach (var slot in inventoryManager.Slots)
    {
        if (slot == null || slot.IsEmpty) continue;

        if (slot.data == data)
            total += slot.count;
    }

    return total;
}

GetTotalItemCount 함수는 판매 시스템의 핵심이다.

인벤토리의 슬롯 리스트를 전부 순회하면서 동일 아이템을 가진 슬롯의 count를 합산하고, 그 합산값을 판매 가능한 최대 수량으로 사용한다.

foreach를 사용한 이유는 이 로직이 인덱스 제어가 목적이 아니라 컬렉션 전체를 순회한다는 목적을 가진 코드라는 점을 명확하게 보여주기 위함이다.

또한 continue를 통해 빈 슬롯을 건너뛰면, 합산 과정에서 불필요한 비교가 줄고 코드 흐름도 자연스럽다.

동일 아이템 비교에 slot.data == data 참조 동일성 비교를 사용했다.

이 프로젝트에서는 인벤토리에 들어가는 InventoryItemData는 ScriptableObject 원본 에셋 참조만 사용한다는 정책을 유지한다.

상점에서 복제 인스턴스를 사용하는 경우에도, 인벤토리에 저장되는 데이터는 원본 참조만을 사용하도록 설계되어 있다.

이 정책이 있기 때문에 참조 비교가 안전하게 동작한다.

참조 비교의 장점은 빠르고 명확하다는 점이다.

단점은 런타임 복제 인스턴스를 인벤토리에 직접 저장하는 구조로 확장될 경우 동일성 판단이 깨질 수 있다는 점이다.

그 경우 ItemId 기반 비교로 전환하는 것이 더 안전하다.

현재 구조는 아이템 수가 많지 않은 프로젝트 규모를 기준으로 전체 슬롯 순회를 선택했다.

만약 인벤토리 규모가 수백 슬롯 이상으로 확장된다면, 아이템별 총합을 Dictionary로 캐싱해 O(1) 접근 구조로 확장할 수 있다.

현재 설계는 단순성과 가독성을 우선한 선택이다.

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

    amount--;
    sellPrice = currentItemData.SellingPrice * amount;
    TextUpdate();
}

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

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

    sellPrice = currentItemData.SellingPrice * amount;
    TextUpdate();
}

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

    int total = GetTotalItemCount(currentItemData);
    if (amount >= total) return; // 가진 수량 이상으로는 X

    amount++;
    sellPrice = currentItemData.SellingPrice * amount;
    TextUpdate();
}

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

    int total = GetTotalItemCount(currentItemData);
    amount += 10;
    if (amount > total)
        amount = total;

    sellPrice = currentItemData.SellingPrice * amount;
    TextUpdate();
}

수량 조절 함수들은 Update를 사용하지 않고, 버튼 입력 이벤트가 발생한 순간에만 amount를 바꾸고 sellPrice를 재계산한 뒤 TextUpdate를 호출한다.

Unity의 Update는 매 프레임 호출되기 때문에, 수량이 변하지 않는 프레임에서도 텍스트를 계속 세팅하면 불필요한 UI 갱신이 누적된다.

반면 버튼 이벤트는 값이 변하는 순간만 포착하므로, UI 갱신 빈도를 의도적으로 낮추고 상태 변화 지점을 코드에서 명확히 만들 수 있다.

이 방식은 성능뿐 아니라 디버깅 관점에서도 장점이 있는데, amount와 sellPrice가 바뀌는 지점이 버튼 함수로 한정되기 때문에 상태 추적이 쉬워진다.

Plus 계열 함수에서 totalCount를 다시 계산하는 이유도 의미가 있다.

판매 가능 최대 수량은 인벤토리 전체 합산값이므로, amount가 그 값을 넘지 않도록 상한을 걸어야 한다.

특히 Plus10Btn은 amount를 크게 증가시키는 입력이기 때문에, 초과 가능성이 높아 보정이 반드시 필요하다.

Minus 계열 함수는 1 미만으로 내려가지 않도록 최소값을 1로 고정한다.

이렇게 하면 0개 판매, 음수 판매 같은 비정상 상태를 UI 단계에서 구조적으로 차단한다.

이 구간에서 sellPrice 계산을 항상 'SellingPrice × amount' 로 재계산하는 점도 중요하다.

누적 방식으로 더하고 빼면 버튼 입력이 꼬이거나 상태가 남았을 때 값이 오염될 수 있지만, 재계산 방식은 같은 amount에 대해 항상 같은 sellPrice가 나오므로 결정론적이다.

4.4. 판매 UI 갱신
void TextUpdate()
{
    amountTxt.text = "수량 : " + amount.ToString();
    sellPriceTxt.text = "금액 : " + sellPrice.ToString();
}

TextUpdate는 내부 상태 변수(amount, sellPrice)를 화면에 보여주는 표현 계층(TextMeshProUGUI)으로 전달하는 전용 함수다.

C#에서 ToString은 정수 값을 문자열로 바꾸는 표준 기능이며, Unity UI 텍스트는 문자열만 출력할 수 있기 때문에 필수 변환이다.

이 갱신을 한 함수로 모으면, 향후 “천 단위 콤마”, “단위 표기”, “로컬라이징”처럼 표시 포맷 요구가 생겼을 때 수정 지점을 단일화할 수 있다.

즉, 이 함수는 단순 편의가 아니라 표현 정책을 한곳에 모으는 설계 선택이다.

4.5. 판매 실행과 다중 슬롯 제거
public void SellBtn()
{
    if (currentItemData == null) return;

    int total = GetTotalItemCount(currentItemData);

    if (total < amount || amount <= 0)
    {
        NotEnoughItem();
        return;
    }

    inventoryManager.RemoveItem(currentItemData, amount);

    inventoryManager.gold.CurrentAmount += sellPrice;

    SlotManager.instance.PoplateSlots();

    if (HUD_StatManager.instance != null)
        HUD_StatManager.instance.GoldTextUpdate();

    currentItemData = null;
    amount = 0;
    sellPrice = 0;

    gameObject.SetActive(false);
}

SellBtn은 판매 시스템에서 실제 데이터 변경이 발생하는 단일 진입점이다.

먼저 currentItemData null 체크를 하는 이유는, 선택되지 않은 상태에서 판매가 실행되는 것을 막기 위한 최소 방어다.

그 다음 total < amount 조건은 판매 가능한 총량보다 요청 수량이 큰가를 검사하는 검증 단계이며, amount <= 0 조건은 UI 입력이나 상태 꼬임으로 인해 비정상 수량이 들어오는 경우를 추가로 차단한다.

이 검증이 선행되기 때문에, 실패 시에는 RemoveItem이나 골드 증가 같은 데이터 변경이 절대 발생하지 않는 구조가 된다.

RemoveItem은 내부적으로 remaining 기반 다중 슬롯 차감을 수행한다.

이 함수는 외부에서 총 보유 수량 검증이 선행된다는 전제 하에 호출되므로, 요청 수량만큼 제거가 가능하다는 조건이 이미 확보된 상태다.

따라서 판매 로직에서는 RemoveItem의 성공 여부를 별도로 반환받지 않아도 상태 일관성이 유지된다.

아이템 제거 이후에 골드를 증가시키는 순서를 유지함으로써 '아이템 제거 성공 → 보상 지급' 의 상태 전이를 명확히 했다.

그 다음 PoplateSlots와 GoldTextUpdate를 호출하는 이유는 Unity UI가 데이터 변경을 자동으로 반영하지 않기 때문이다.

결과적으로 이 함수는 '검증 → 실행 → UI 반영 → 상태 초기화' 라는 전형적인 트랜잭션 흐름을 가진다.

4.6. 오류 피드백과 코루틴
void NotEnoughItem()
{
    errorTxt.text = "아이템이 충분하지 않습니다";
    StartCoroutine(ErrorActive(0.5f));
}

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

NotEnoughItem은 실패 시 상태 변경 없이 피드백만 제공하기 위한 루틴이다.

errorTxt.text에 메시지를 넣고 코루틴을 시작한다.

Unity의 Coroutine은 별도 스레드를 만드는 방식이 아니라, 메인 루프에서 yield 지점을 기준으로 실행을 잠깐 중단했다가 다음 프레임들에서 이어가는 방식이다.

WaitForSeconds는 지정한 시간만큼 대기하도록 하는 대표적인 yield instruction이며, Update에서 시간을 누적해 닫는 타이머를 직접 구현할 수도 있지만, 코루틴을 쓰면 잠깐 보여주고 자동으로 닫는다는 목적이 코드 흐름 자체로 드러난다.

이 방식의 장점은 의도 표현과 구현 단순성이다.

단점은 동일한 오류가 연속 발생했을 때 코루틴이 여러 개 시작될 수 있다는 점인데, 이 경우 마지막 코루틴의 종료 타이밍에 의해 패널이 꺼지는 형태로 수렴한다.

프로젝트 규모에서 문제될 정도는 아니지만, 포트폴리오 문서에서는 연타 시 코루틴 중복 시작 가능 정도를 언급하고, 필요하면 StopCoroutine/StopAllCoroutines로 보강할 수 있다는 개선 포인트로 정리하면 완성도가 올라간다.

5. 개발 의도

이 판매 시스템은 슬롯 단위 제거가 아니라 아이템 단위 제거라는 관점에서 설계하였다.

동일 아이템이 여러 슬롯에 분산되어 존재할 수 있다는 전제를 기반으로 전체 수량을 합산하고, 필요한 만큼만 차감하도록 책임을 InventoryManager에 위임했다.

UI는 데이터 구조를 알지 못하며, 계산과 검증만 수행한다. 실제 데이터 변경은 단일 진입점에서만 이루어지며, 실패 시에는 상태 전이가 완전히 차단된다.

구매 시스템과 동일한 철학을 유지함으로써 상점과 인벤토리 사이의 결합도를 낮추고, 구조적 일관성을 확보했다.