아이템 추가·삭제 흐름과 스택 계산의 기준점
목차
1. 시스템 요구 사항
인벤토리 슬롯 구조와 데이터 흐름의 기준이 정리되었다고 해서, 인벤토리 시스템이 곧바로 안정적으로 동작하는 것은 아니었다.
슬롯 개수가 고정되어 있고 데이터와 UI가 분리되어 있더라도, 아이템을 어떤 규칙으로 추가하고 제거할 것인가가 명확하지 않다면 인벤토리는 쉽게 무너질 수 있다.
이 프로젝트의 인벤토리는 스택형 아이템을 기본 전제로 한다.
하나의 아이템은 여러 슬롯에 나뉘어 들어갈 수 있고, 하나의 슬롯은 최대 스택 수를 가진다.
따라서 아이템 추가는 단순히 빈 슬롯에 넣는다는 방식으로 해결할 수 없었고, 기존 스택을 우선 채우고, 부족한 경우에만 새로운 슬롯을 사용하는 규칙이 필요했다.
또한 아이템 획득은 전투 보상, 퀘스트 보상, 제작 결과, 상점 구매 등 다양한 시스템에서 발생한다.
이때 인벤토리가 가득 찬 상태에서도 무조건 아이템 추가를 시도하는 구조라면, 일부만 들어가거나 초과분이 유실되는 문제가 발생할 수 있다.
따라서 아이템을 실제로 추가하기 전에, 이 요청이 전부 성공 가능한지 사전에 판단할 수 있는 구조가 필요했다.
아이템 삭제 역시 마찬가지다.
여러 슬롯에 나뉘어 들어 있는 동일 아이템을 지정된 수량만큼 제거해야 하며, 수량이 0이 되는 슬롯은 명확하게 비워져야 한다.
이 과정 역시 UI 상태와 무관하게, 데이터 기준으로 일관되게 처리될 필요가 있었다.
2. 설계 목표
- 아이템 추가·삭제가 항상 데이터 기준으로 처리되도록 할 것
- 스택 아이템의 누적·분산 규칙을 명확히 정의할 것
- 아이템 추가 가능 여부를 사전에 판단할 수 있는 단일 경로를 제공할 것
- UI는 결과만 반영하고, 로직에는 관여하지 않도록 할 것
3. 흐름도
3.1. 아이템 추가

이 흐름에서 가장 중요한 점은, 아이템을 일단 추가한 뒤 실패하면 되돌리는 방식이 아니라, 가능할 때만 추가를 시작한다는 점이다.
이 구조를 통해 인벤토리 데이터는 처리 중간 단계에서도 항상 일관된 상태를 유지하며, 롤백이나 임시 상태를 고려할 필요가 없어진다.
AddItem은 수용 가능 여부가 이미 확정된 상태에서 호출되며,내부에서는 기존 스택을 우선 채운 뒤 남은 수량을 빈 슬롯에 배치하는 역할만 수행한다.
remaining이 남는 경우는 정상 흐름에서는 발생하지 않으며,로그 출력은 예외 상황을 감지하기 위한 안전 장치다.
3.2. 아이템 삭제

아이템 삭제는 특정 슬롯을 목표로 하지 않고, 인벤토리 전체를 하나의 연속된 수량 풀로 취급한다.
이를 통해 아이템이 어떤 슬롯에 얼마나 분산되어 있는지와 무관하게, 항상 동일한 제거 결과를 보장할 수 있다.
4. 구현
4.1. 아이템 수용 가능 여부 검사
// InventoryManager.cs
public bool HaveItemSlot(InventoryItemData data, int addCount)
{
if (data == null || addCount <= 0) return false;
int maxStack = data.BundleMaxCount;
int extraCountInStacks = 0;
int emptySlotCount = 0;
foreach (var slot in slots)
{
if (slot.IsEmpty)
{
emptySlotCount++;
continue;
}
if (slot.data == data)
{
extraCountInStacks += (slot.MaxStack - slot.count);
if (extraCountInStacks >= addCount) return true;
}
}
int remain = addCount - extraCountInStacks;
if (remain <= 0) return true;
int needSlotCount = Mathf.CeilToInt((float)remain / maxStack);
return emptySlotCount >= needSlotCount;
}
HaveItemSlot 함수는 현재 인벤토리 상태를 기준으로, 특정 아이템을 지정된 수량만큼 전부 수용할 수 있는지를 판단한다.
HaveItemSlot은 단순한 공간 계산 함수가 아니다.
이 함수의 본질은 인벤토리를 변경하지 않고 결과를 예측하는 비파괴적 검증 단계다.
실제 AddItem을 호출하지 않은 상태에서, 지금 이 요청을 실행하면 성공하는가를 시뮬레이션한다.
이 구조 덕분에 인벤토리 데이터는 항상 일관성을 유지하며, 추가 연산은 트랜잭션처럼 원자적으로 동작하게 된다.
일부만 들어가고 일부가 유실되는 상태를 구조적으로 차단하는 장치가 바로 이 함수다.
스택형 인벤토리에서 아이템이 들어갈 수 있는 경로는 기존 스택의 여유 공간과 완전히 비어 있는 슬롯 두 가지뿐이다.
이 함수는 두 경로를 동시에 계산하되, 실제로는 아무것도 변경하지 않는다.
즉, 인벤토리를 가상으로 스캔해 총 수용 가능 공간을 계산하는 구조다.
슬롯 순회에는 foreach를 사용했다.
C#의 foreach는 컬렉션을 안전하게 순회할 수 있고, 인덱스를 직접 다루지 않아도 된다.
이 함수에서는 슬롯의 순서가 전혀 중요하지 않으며 단순히 전체 상태를 스캔하는 것이 목적이기 때문에, 순서 정책이 포함되는 for 루프보다 의도가 명확한 foreach가 적합하다.
반대로 AddItem과 RemoveItem에서는 슬롯 순서가 정책이 되기 때문에 for를 사용한다.
이 차이는 단순 문법 선택이 아니라, 정책이 존재하는가의 차이를 코드 레벨에서 드러내는 선택이다.
extraCountInStacks는 기존 스택이 추가로 수용할 수 있는 여유 공간의 총합이다.
동일 아이템 슬롯을 발견할 때마다 slot.MaxStack - slot.count를 누적하고, 이 값이 addCount 이상이 되는 순간 즉시 true를 반환한다.
이 조기 반환은 단순한 성능 최적화가 아니라 기존 스택 우선 정책의 표현이다.
이미 기존 스택만으로 충분하다면 빈 슬롯 계산은 고려 대상이 아니다.
기존 스택만으로 수용이 불가능한 경우에는, 남은 수량을 신규 스택으로 생성해야 한다.
이때 남은 수량을 maxStack 기준으로 나누어 몇 개의 슬롯이 필요한지를 계산한다.
Mathf.CeilToInt를 사용하는 이유는 슬롯이 정수 단위로만 존재하기 때문이다.
예를 들어 11개를 스택 최대 10짜리 슬롯에 넣어야 한다면, 1.1 슬롯이 아니라 2개의 슬롯이 필요하다.
Ceil은 이러한 물리적 제약을 정확히 반영한다.
float 캐스팅을 사용하는 방식은 이론적으로 부동소수점 오차 가능성을 가진다.
그러나 이 함수는 실제 데이터를 변경하지 않는 판단 로직이며, 다루는 값도 비교적 작은 정수 범위에 머무른다.
복잡한 정수 나눗셈 분기를 추가하는 것보다 의도가 명확히 드러나는 올림 계산이 가독성과 유지보수 측면에서 더 유리하다고 판단했다.
결과적으로 HaveItemSlot은 인벤토리를 변경하지 않는 예측 전용 함수이며, 성공 여부를 단일한 boolean 값으로 반환함으로써 상위 로직이 명확한 판단을 내릴 수 있도록 한다.
4.2. 아이템 추가
// InventoryManager.cs
public void AddItem(InventoryItemData data, int count)
{
if (data == null || count <= 0) return;
int remaining = count;
int maxStack = data.BundleMaxCount;
// 1) 같은 아이템이 이미 있는 슬롯 채우기
for (int i = 0; i < slots.Count && remaining > 0; i++)
{
var slot = slots[i];
if (slot.IsEmpty) continue;
if (slot.data != data) continue;
int space = slot.MaxStack - slot.count;
if (space <= 0) continue;
int add = Mathf.Min(space, remaining);
slot.count += add;
remaining -= add;
}
// 2) 남은 수량을 비어있는 슬롯에 넣기
for (int i = 0; i < slots.Count && remaining > 0; i++)
{
var slot = slots[i];
if (!slot.IsEmpty) continue;
int add = Mathf.Min(maxStack, remaining);
slot.data = data;
slot.count = add;
remaining -= add;
}
if (remaining > 0)
{
Debug.LogWarning($"인벤토리가 꽉 차서 {data.ItemName} {remaining}개는 들어가지 못했습니다.");
}
}
AddItem은 이미 수용 가능하다는 전제가 확정된 이후에 호출되는 저수준 데이터 처리 함수다.
이 함수는 성공이 보장된 요청만 처리한다는 전제 하에 설계되었다.
그 전제는 HaveItemSlot이 제공한다.
따라서 AddItem은 내부적으로 상태를 단일 방향으로만 변화시키며, 실패 시 되돌리는 흐름을 만들지 않는다.
이는 사전 검증을 통해 변경 가능성이 확정된 요청만 처리하도록 설계했기 때문이다.
remaining은 아직 배치되지 않은 수량을 의미하는 단일 기준점이다.
모든 연산은 이 값을 중심으로 진행되며, 두 단계의 순회를 통해 점진적으로 감소한다.
이 단일 변수 구조 덕분에 상태 분기가 단순해지고, 중간 상태 관리가 필요 없어진다.
첫 번째 for 루프는 기존 스택 채우기 단계다.
여기서는 인덱스 순서가 정책이 된다.
인벤토리는 앞쪽 슬롯부터 채워지는 구조이며, 이는 플레이어가 인지하는 정렬 상태와도 연결된다.
따라서 foreach가 아닌 for를 사용해 순서를 명시적으로 통제한다.
이 단계는 기존 스택 우선이라는 정책을 코드 구조 자체로 강제하는 부분이다.
Mathf.Min을 사용하는 이유는 스택 최대치를 초과하는 상황을 구조적으로 차단하기 위함이다.
별도의 조건 분기를 늘리지 않고도 안전성을 확보할 수 있다.
또한 for 조건에 remaining > 0을 포함시켜 조기 종료 전략을 적용했다.
이미 배치가 완료된 이후에는 추가 슬롯 접근이 의미 없기 때문에, 불필요한 순회를 방지한다.
두 번째 for 루프는 신규 스택 생성 단계다.
기존 스택을 모두 채운 이후에만 실행된다.
이는 동일 아이템이 여러 슬롯에 불필요하게 분산되는 것을 최소화하기 위한 정책적 선택이며, 플레이어가 기대하는 자동 정리 동작과도 일치한다.
슬롯 인스턴스를 교체하지 않고 data와 count만 변경하는 이유는 UI 참조 안정성을 유지하기 위함이다.
UI는 슬롯 객체를 계속 참조한 상태로 유지되며, 값만 갱신하면 된다.
이는 드래그, 병합, 분할 확장 시에도 안정적인 참조 구조를 보장한다.
마지막 Debug.LogWarning은 정상 흐름이 아니라 개발 단계 안전 장치다.
TryAddItem을 통해 호출되는 구조에서는 remaining이 남는 상황이 발생하지 않는 것이 정상이다.
만약 로그가 찍힌다면 사전 검증을 우회했거나 정책 위반이 발생했다는 의미이므로, 디버깅을 위한 경고로 사용된다.
4.3. 아이템 추가 가능 여부 사전 검사
// InventoryManager.cs
public bool TryAddItem(InventoryItemData data, int count)
{
if (!HaveItemSlot(data, count))
{
InventortUIManager.Instance.InventorySlotErrorForSec();
return false;
}
AddItem(data, count);
return true;
}
// InventoryUIManager.cs
public void InventorySlotErrorForSec()
{
StartCoroutine(ActiveError());
}
IEnumerator ActiveError()
{
errorPanel.SetActive(true);
yield return new WaitForSeconds(0.5f);
errorPanel.SetActive(false);
}TryAddItem은 외부 시스템이 인벤토리에 접근하는 단일 진입점이다.
퀘스트 보상, 전투 드롭, 제작, 상점 구매 등 모든 획득 흐름은 이 함수를 통해서만 데이터에 접근하도록 설계되었다.
이 함수의 핵심은 데이터 로직과 UX 로직의 경계를 분리하는 데 있다.
HaveItemSlot은 순수한 데이터 판단, AddItem은 순수한 데이터 변경이다.
반면 에러 패널 출력은 UI 계층의 책임이다.
이 구조는 데이터는 오직 될 수 있는 요청만 처리하고, 실패에 대한 사용자 피드백은 UI 계층에서 담당하도록 역할을 분리한다.
다만 InventoryManager가 UIManager를 직접 참조하는 구조는 완전한 계층 분리라고 보기는 어렵다.
이는 프로젝트 규모와 복잡도를 고려한 현실적 타협이다.
이벤트 기반 구조로 확장할 여지는 있지만, 현재 단계에서는 구현 비용 대비 효율을 우선했다.
이 판단 역시 설계 의도에 포함된다.
코루틴은 Unity에서 시간 지연 처리를 간단히 구현할 수 있는 기능이다.
StartCoroutine과 WaitForSeconds를 사용해 0.5초 동안 에러 패널을 표시한 뒤 자동으로 닫는다.
단발성 UI 피드백에 가장 비용이 적은 방식이다.
이 구조는 완전한 이벤트 기반 아키텍처는 아니지만, 현재 프로젝트 규모에서는 과도한 추상화보다 명확성과 유지보수성을 우선했다.
4.4. 아이템 삭제
public void RemoveItem(InventoryItemData data, int count)
{
if (data == null || count <= 0) return;
int remaining = count;
for (int i = slots.Count - 1; i >= 0 && remaining > 0; i--)
{
var slot = slots[i];
if (slot.IsEmpty) continue;
if (slot.data != data) continue;
if (slot.count > remaining)
{
slot.count -= remaining;
return;
}
else
{
remaining -= slot.count;
slot.Clear();
}
}
}
RemoveItem은 인벤토리 전체 관점에서 수량을 감소시키는 함수다.
추가 로직은 사전 검증을 통해 원자성을 보장하지만, 삭제 로직은 실패를 예외로 처리하지 않고 조용히 가능한 범위만 처리하도록 설계했다.
이는 인벤토리 데이터가 사용자 입력이나 시스템 이벤트에 의해 빈번하게 호출될 수 있기 때문에, 예외 흐름을 최소화하기 위함이다.
슬롯을 뒤에서부터 순회하는 이유는 정책 때문이다.
최근에 획득한 아이템부터 먼저 빠지는 체감을 주기 위함이다.
이는 단순 구현이 아니라 UX 관점에서의 선택이다. 필요하다면 획득 시점 메타데이터를 추가해 다른 전략으로 확장할 수 있다.
remaining은 아직 제거해야 할 수량을 의미한다.
슬롯의 수량이 remaining보다 많은 경우에는, 해당 슬롯에서 필요한 만큼만 감소시키고 즉시 함수를 종료한다.
이 조기 종료 구조는 불필요한 슬롯 접근을 줄이고 성능과 명확성을 동시에 확보한다.
슬롯의 수량이 remaining과 정확히 같은 경우에는 슬롯을 비우고 종료한다.
반대로 슬롯의 수량이 remaining보다 적은 경우에는, 슬롯을 완전히 비운 뒤 remaining에서 그 수량만큼을 차감하고 다음 슬롯으로 이동한다.
이 구조를 통해 여러 슬롯에 나뉘어 있는 동일 아이템도 하나의 연속된 수량처럼 자연스럽게 제거되며, 제거 이후 어떤 슬롯에도 음수 수량이나 잘못된 데이터가 남지 않도록 보장된다.
또한 슬롯 단위 로직이 외부로 노출되지 않기 때문에, 인벤토리 내부 구조 변경 시에도 상위 시스템에 영향을 주지 않는다.
5. 개발 의도
이 게시글에서 보여주고자 한 핵심은, 아이템 추가와 삭제라는 가장 기본적인 기능조차 명확한 규칙과 데이터 중심 흐름 없이는 쉽게 무너질 수 있다는 점이다.
스택 계산, 슬롯 분배, 수용 가능 여부 판단을 모두 데이터 기준으로 정리함으로써, 인벤토리는 UI 상태나 입력 타이밍과 무관하게 안정적인 상태를 유지할 수 있게 되었다.
이 구조 위에서 이후 게시글에서 다루게 될 드래그 앤 드롭, 스택 병합, 분할, 클릭 인터랙션 같은 기능들이 기존 로직을 흔들지 않고 자연스럽게 얹힐 수 있었다.
