아이템 추가 시스템 – 사전 수용 검증으로 데이터 무결성을 보장하는 구조
1. 시스템 요구 사항
인벤토리 시스템에서 가장 먼저 고민했던 부분은, 아이템 추가 시스템이었다.
아이템을 획득했을 때 인벤토리가 가득 찬 상태라면, 일부만 들어가거나 초과분이 조용히 사라지는 동작은 플레이어 신뢰를 크게 해친다.
특히 퀘스트 보상이나 희귀 아이템처럼 수량이 한정된 아이템인 경우, 이런 동작은 치명적인 버그로 확장된다.
또한 스택형 인벤토리에서는 동일 아이템이 여러 슬롯에 나뉘어 존재할 수 있고, 추가 과정에서 스택을 채우는 순서와 빈 슬롯 생성 순서가 얽히면서 일부만 들어가고 나머지는 사라지는 것처럼 보이는 문제가 발생하기 쉽다.
따라서 이 인벤토리 시스템에서는 아이템을 실제로 추가하기 전에, 현재 인벤토리 상태에서 해당 수량을 전부 수용할 수 있는지 먼저 판단하고, 가능할 때만 데이터 변경이 일어나도록 설계할 필요가 있었다.
이는 단순한 UX 문제가 아니라, 인벤토리 데이터가 항상 예측 가능한 상태를 유지하도록 만들기 위한 구조적 필요사항이었다.
2. 설계 목표
- 아이템은 전량 추가 가능할 때만 인벤토리에 들어가도록 할 것
- 일부만 추가되거나 초과분이 유실되는 상황을 구조적으로 차단할 것
- 동일 아이템은 기존 스택을 우선적으로 채우고, 남은 수량만 빈 슬롯에 새 스택으로 배치할 것
- 인벤토리 공간이 부족한 경우 데이터 변경 없이 즉시 실패 처리하고, 시각적 피드백을 제공할 것
3. 흐름도

이 흐름에서 가장 중요한 점은, 아이템을 일단 추가한 뒤 실패하면 되돌리는 방식이 아니라, 가능할 때만 추가를 시작한다는 점이다.
이 구조를 통해 인벤토리 데이터는 처리 중간 단계에서도 항상 일관된 상태를 유지하며, 롤백이나 임시 상태를 고려할 필요가 없어진다.
4. 구현
4.1. 인벤토리 슬롯 데이터 구조
[System.Serializable]
public class InventorySlotData
{
public InventoryItemData data;
public int count;
public bool IsEmpty => data == null || count <= 0;
public int MaxStack => data != null ? data.BundleMaxCount : 0;
public void Clear()
{
data = null;
count = 0;
}
}
각 슬롯은 단순히 어떤 아이템(data)을 몇 개(count)를 들고 있는지만을 책임지는 순수 데이터 컨테이너로 설계되었다.
이 클래스에는 아이템 추가, 병합, 분할, 수용 가능 여부 판단 같은 로직이 전혀 포함되어 있지 않으며, 오직 상태를 보관하는 역할만 수행하도록 설계되었다.
이러한 역할 분리는 인벤토리 규칙이 한 곳(InventoryManager)에 집중되도록 만들고, 슬롯 단위 데이터가 임의로 상태를 변경하는 것을 구조적으로 방지한다.
이 클래스에 [System.Serializable] 특성을 부여한 이유는, Unity의 직렬화 시스템을 통해 슬롯 상태를 Inspector에서 확인 가능하게 만들고, 디버깅과 데이터 흐름 추적을 용이하게 하기 위함이다.
Serializable을 사용하면 런타임 중에도 슬롯 리스트의 내부 상태를 직접 확인할 수 있어, 아이템 추가·삭제·병합 과정에서 발생할 수 있는 문제를 빠르게 파악할 수 있다.
또한 추후 세이브 데이터로 확장하거나, 테스트용 초기 상태를 구성할 때도 동일한 구조를 그대로 활용할 수 있다는 장점이 있다.
반면 Serializable 클래스는 Unity의 직렬화 규칙에 의존하기 때문에, 복잡한 참조 구조나 순환 참조를 가지는 경우에는 제약이 생길 수 있다는 단점이 있다.
또한 순수 C# 환경에서 사용되는 직렬화 방식과는 다르기 때문에, 엔진 종속성이 생긴다는 점도 고려 대상이다.
그럼에도 불구하고 이 인벤토리 시스템은 Unity 환경 내부에서 동작하는 게임 시스템이며, 슬롯 상태를 시각적으로 확인하고 디버깅하는 요구사항이 컸기 때문에, Serializable을 사용하는 것이 구조적 명확성과 개발 효율 측면에서 가장 적절한 선택이라고 판단했다.
4.2. 아이템 수용 가능 여부 사전 계산
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 함수는 현재 인벤토리 상태에서 특정 아이템을 지정된 수량만큼 전부 수용할 수 있는지 여부를 계산만 수행하는 함수다.
이 함수의 핵심은 슬롯 데이터인벤토리 상태를 전혀 변경하지 않고 계산 결과만 반환한다는 점이다. 즉, 이 함수는 되는지 판단하는 용도이며, 해보는 행위는 포함하지 않는다.
전체 슬롯을 순회하면서 동일 아이템 스택에 추가로 들어갈 수 있는 여유 공간과 완전히 비어 있는 슬롯 수를 동시에 계산한다.
기존 스택의 여유 공간만으로도 추가 수량을 모두 수용할 수 있는 경우에는, 더 이상의 슬롯 계산 없이 즉시 true를 반환하도록 구성해 불필요한 연산을 줄였다.
이는 인벤토리 크기가 커졌을 때도 성능이 불필요하게 악화되지 않도록 하기 위한 판단이다.
남은 수량이 존재하는 경우에는, 스택 최대치 기준으로 몇 개의 신규 슬롯이 필요한지를 계산한다.
이 과정에서 (float)remain / maxStack 연산과 Mathf.CeilToInt를 사용해 올림 처리했다.
이론적으로는 float 연산이 오차 가능성을 가질 수 있지만, 인벤토리 시스템에서 다루는 값은 정수 범위의 매우 제한적인 수량이며, 나눗셈 결과 또한 단순 슬롯 개수 계산에만 사용된다.
이 상황에서 정수 나눗셈을 복잡하게 분기 처리하는 것보다, 의도가 직관적으로 드러나는 CeilToInt 기반 계산이 가독성과 유지보수 측면에서 더 합리적이라고 판단했다.
또한 이 연산 결과는 실제 데이터를 변경하는 로직이 아니라 가능 여부 판단에만 사용되기 때문에, 이론적인 오차가 게임 플레이에 직접적인 부작용을 일으킬 여지가 없다.
따라서 이 함수에서는 정확성보다 의도 전달과 구조적 명확성을 우선시한 계산 방식을 선택했다.
4.3. 실제 아이템 추가 로직
public void AddItem(InventoryItemData data, int count)
{
if (data == null || count <= 0) return;
int remaining = count;
// 1단계: 기존 스택 채우기
foreach (var slot in slots)
{
if (slot.IsEmpty || 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;
if (remaining <= 0) break;
}
// 2단계: 빈 슬롯에 새 스택 생성
if (remaining > 0)
{
foreach (var slot in slots)
{
if (!slot.IsEmpty) continue;
int add = Mathf.Min(data.BundleMaxCount, remaining);
slot.data = data;
slot.count = add;
remaining -= add;
if (remaining <= 0) break;
}
}
if (remaining > 0)
{
Debug.LogWarning($"{data.ItemName} {remaining}개는 인벤토리가 가득 차서 들어가지 못했음");
}
}
AddItem은 이미 수용 가능하다는 전제가 성립한 상태에서만 호출되는 저수준 로직이다.
따라서 이 함수 내부에서는 실패 가능성이나 롤백을 전혀 고려하지 않고, 오직 어떤 순서로 아이템을 배치할 것인가에만 집중한다.
이러한 전제 덕분에 코드 구조가 단순해지고, 중간 상태 관리나 예외 처리로 인한 복잡도가 크게 줄어든다.
remaining 변수는 아직 인벤토리에 배치되지 않은 수량을 추적하는 기준점 역할을 한다.
먼저 동일 아이템이 들어 있는 기존 스택들을 순회하며, 각 슬롯의 남은 여유 공간만큼 수량을 채워 넣는다.
이 과정에서 Mathf.Min을 사용해 슬롯 여유 공간과 remaining 중 작은 값을 선택함으로써, 스택 최대치를 초과하는 상황을 조건 분기 없이 자연스럽게 방지한다.
기존 스택을 모두 채운 이후에도 remaining이 남아 있는 경우에만, 빈 슬롯을 순회하며 새 스택을 생성한다.
이 순서를 고정한 이유는 동일 아이템이 불필요하게 여러 슬롯에 흩어지는 상황을 최소화하고, 플레이어가 기대하는 자동 정리 동작을 일관되게 제공하기 위함이다.
기존 스택을 우선적으로 채우는 방식은 인벤토리 가독성을 유지하고, 이후 아이템 삭제나 병합 로직에서도 예측 가능한 상태를 보장한다.
슬롯 오브젝트 자체를 교체하지 않고 data와 count만 변경하는 방식은, UI 계층이 슬롯 인스턴스를 안정적으로 참조할 수 있도록 하기 위한 선택이다.
이 구조를 통해 슬롯 UI는 데이터 변경 이후에도 재연결이나 재할당 없이 갱신만 수행하면 되며, 인벤토리 데이터와 UI 간의 결합도를 낮출 수 있다.
4.4. 외부에서 사용하는 안전한 추가 API
public bool TryAddItem(InventoryItemData data, int count)
{
if (!HaveItemSlot(data, count))
{
InventortUIManager.Instance.InventorySlotErrorForSec();
return false;
}
AddItem(data, count);
return true;
}
TryAddItem은 외부 시스템이 인벤토리에 접근할 때 사용하는 단일 진입점이다.
먼저 HaveItemSlot을 통해 성공 여부를 확정한 뒤, 가능할 때만 AddItem을 호출하기 때문에 인벤토리 데이터는 항상 일관된 상태를 유지한다.
공간이 부족한 경우에는 데이터 변경 없이 UI 피드백만 발생시키며, 이 피드백은 코루틴을 통해 짧은 시간 동안 표시된다.
코루틴은 메인 스레드 기반으로 UI와 자연스럽게 결합되며, 타이머성 연출을 구현할 때 별도의 상태 관리 없이 사용할 수 있다는 장점이 있어 이 용도에 적합하다고 판단했다.
5. 개발 의도
이 시스템에서 가장 중요하게 고려한 점은, 인벤토리 데이터가 항상 예측 가능한 상태를 유지하도록 만드는 것이었다.
아이템을 먼저 추가한 뒤 실패하면 되돌리는 방식은 구현 자체는 단순해 보일 수 있지만, 처리 중간 단계에서 데이터가 잠시라도 변형되기 때문에 UI 갱신 타이밍이나 입력 중복 상황에서 상태 불일치가 발생할 여지가 크다.
사전 수용 가능 여부를 계산한 뒤 가능할 때만 추가를 시작하는 구조를 선택함으로써, 인벤토리 시스템은 중간 상태를 갖지 않게 되었고, 데이터 무결성을 구조적으로 보장할 수 있게 되었다.
이 설계는 이후 아이템 삭제, 스택 병합, 분할 시스템으로 확장될 때도 동일한 철학을 유지할 수 있는 기반이 되었다.
결과적으로 이 인벤토리 아이템 추가 시스템은 단순히 아이템을 넣는 기능이 아니라, 인벤토리 전체 규칙을 안정적으로 유지하기 위한 핵심 기초 구조로 작동하도록 설계되었다.
