인벤토리 슬롯 구조와 데이터 흐름의 기준점
목차
1. 시스템 요구 사항
인벤토리 시스템을 구현하면서 가장 먼저 해결해야 했던 문제는, 아이템의 추가·삭제나 UI 상호작용 이전에 인벤토리가 어떤 단위로 관리되고, 어떤 기준으로 상태를 유지할 것인가였다.
인벤토리는 단순히 아이템 목록을 보여주는 UI가 아니라, 게임 전반의 재화 관리, 장비 장착, 소비 아이템 사용, 제작 및 강화 시스템과 모두 연결되는 핵심 데이터 구조다.
따라서 인벤토리의 내부 상태가 언제나 예측 가능하고 일관되게 유지되지 않으면, 이후 시스템들이 연쇄적으로 흔들릴 수밖에 없다.
특히 이 프로젝트에서는 아이템이 스택 단위로 관리되며, 슬롯의 개수가 고정되어 있고, 슬롯 간 이동·병합·분할 같은 상호작용이 빈번하게 발생한다.
이런 상황에서 인벤토리 데이터를 단순한 리스트나 UI 상태에 의존해 관리하면, UI 갱신 타이밍과 실제 데이터 상태가 어긋나는 문제가 발생하기 쉽다.
드래그나 분할 같은 상호작용이 추가될수록, UI를 기준으로 로직이 작성된 구조는 예외 상황에 매우 취약해진다.
그래서 이 인벤토리 시스템에서는 아이템을 화면에 어떻게 보여줄 것인가보다 먼저, 아이템 상태를 어디에서, 어떤 기준으로 관리할 것인가를 명확히 정의할 필요가 있었다.
모든 판단과 계산은 데이터 기준으로 이루어지고, UI는 그 결과를 표현하는 역할만 수행하도록 구조를 잡는 것이 핵심 요구사항이었다.
2. 설계 목표
- 인벤토리의 실제 상태를 UI와 분리된 데이터 구조로 관리할 것
- 슬롯 단위의 상태를 명확한 데이터 객체로 표현할 것
- 슬롯 접근, 추가, 삭제가 항상 동일한 경로를 거치도록 할 것
- 이후 드래그, 병합, 분할, 클릭 로직이 자연스럽게 얹힐 수 있는 기반을 만들 것
3. 흐름도

이 흐름의 핵심은 인벤토리 시스템의 시작점이 UI가 아니라 데이터라는 점이다.
게임이 시작되면 가장 먼저 InventoryManager가 생성되고, 이 시점에서 인벤토리가 가질 슬롯의 구조가 먼저 확정된다.
슬롯 데이터가 준비된 이후에야 UI 슬롯이 생성되며, UI는 항상 데이터 상태를 읽어서 자신의 모습을 결정한다.
이 구조에서는 UI가 인벤토리 상태를 판단하거나 수정하지 않는다.
모든 상태 변화는 InventoryManager 내부 데이터에서 먼저 발생하고, SlotManager는 그 결과를 화면에 반영하는 역할만 수행한다.
이 전제 덕분에 이후 드래그, 병합, 분할, 클릭 인터랙션 같은 복잡한 기능을 추가하더라도
인벤토리의 핵심 상태는 UI 동작에 의해 흔들리지 않는다.
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;
}
}
InventorySlotData는 하나의 슬롯이 어떤 아이템을 얼마만큼 보유하고 있는지를 표현하는 순수 데이터 객체다.
이 클래스는 MonoBehaviour를 상속하지 않으며, 씬 계층 구조나 GameObject 생명주기와 완전히 분리된 상태로 존재한다.
이는 인벤토리 데이터는 특정 오브젝트의 위치나 활성 상태와 무관하게 유지되어야 하기 때문에, 씬 종속적인 컴포넌트가 아니라 독립적인 데이터 구조로 정의했다.
[System.Serializable]을 사용한 이유는, MonoBehaviour를 상속하지 않아도 List<InventorySlotData>의 상태를 인스펙터에서 확인할 수 있게 하기 위함이다.
이는 디버깅 단계에서 매우 유용하다.
다만 직렬화된 구조는 필드 변경 시 데이터 호환성 문제가 발생할 수 있기 때문에, 실제 동작 로직은 직렬화된 값에 의존하지 않고 코드 기준으로 판단하도록 유지했다.
이 클래스에는 UI, 입력 처리, 애니메이션과 관련된 코드가 전혀 포함되어 있지 않으며, 오직 아이템 데이터와 수량만을 보관한다.
이 클래스는 MonoBehaviour를 상속하지 않으며, 씬 계층 구조나 GameObject 생명주기와 완전히 분리된 상태로 존재한다.
이는 인벤토리 데이터는 특정 오브젝트의 위치나 활성 상태와 무관하게 유지되어야 하기 때문에, 씬 종속적인 컴포넌트가 아니라 독립적인 데이터 구조로 정의했다.
IsEmpty와 MaxStack은 필드가 아니라 계산 프로퍼티로 구현되어 있다.
이는 인벤토리 전반에서 슬롯 상태를 판단하는 기준을 단일화하기 위함이다.
슬롯이 비어 있는지, 더 들어갈 수 있는지 같은 판단을 UI나 매니저 클래스마다 따로 구현하면 기준이 분산되고, 버그 가능성이 급격히 높아진다.
이 값을 데이터 내부에 포함시킴으로써, 아이템 추가·삭제, 병합, 분할, 드래그 로직이 모두 동일한 기준을 참조하도록 만들었다.
이때, C#의 계산 프로퍼티를 이용하였다.
이는 필드처럼 접근하지만 내부적으로는 로직을 수행할 수 있는 구조다.
이를 통해 외부 코드에서는 slot.IsEmpty처럼 단순하게 접근하면서도, 실제 판단 기준은 클래스 내부에 캡슐화할 수 있다.
만약 비어 있음의 기준이 변경되거나, MaxStack 계산 방식이 바뀌더라도 외부 로직은 수정할 필요가 없다.
이 구조는 인벤토리 전반에서 동일한 판단 기준을 강제하는 역할을 한다.
Clear() 함수는 슬롯을 초기 상태로 되돌리는 명확한 인터페이스다.
아이템 제거, 스택 소진, 분할 후 잔여 처리 같은 상황에서 슬롯 상태를 직접 조작하지 않고, 항상 동일한 방식으로 정리할 수 있도록 설계했다.
4.2. 인벤토리 데이터 관리
public class InventoryManager : MonoBehaviour
{
public const int INVENTORY_SLOT_MAX_COUNT = 30;
public static InventoryManager instance;
[SerializeField]
private List<InventorySlotData> slots = new List<InventorySlotData>();
public List<InventorySlotData> Slots => slots;
private void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
return;
}
InitializeSlots();
InitializeFromData();
}
}
InventoryManager는 인벤토리 시스템 전체에서 유일하게 데이터 상태를 책임지는 클래스다.
슬롯 개수, 슬롯에 들어 있는 아이템 정보, 수량 계산 등 인벤토리와 관련된 모든 핵심 데이터는 이 클래스 내부에서만 생성되고 변경된다.
UI, 입력 처리, 드래그 로직은 이 클래스에 직접 접근하되, 데이터 수정은 반드시 이 매니저를 통해서만 이루어진다.
이 클래스는 MonoBehaviour 기반의 전역 싱글톤 구조로 설계되었다.
싱글톤을 사용한 이유는 인벤토리 데이터가 씬 전환과 무관하게 유지되어야 했기 때문이다.
상점, 퀘스트 보상, 아이템 드롭, UI 등 다양한 시스템이 동일한 인벤토리 상태를 참조해야 했고, 데이터 출처가 여러 곳으로 분산되면 상태 추적이 어려워진다.
다만 싱글톤은 전역 상태 의존성을 증가시키고, 단위 테스트를 어렵게 만드는 단점이 있다.
의존성 주입 구조로 확장할 여지도 있지만, 현재 프로젝트 규모에서는 전역 단일 인스턴스로 관리하는 편이 유지보수 비용 대비 더 합리적이라고 판단했다.
설계 선택의 결과로 결합도는 증가하지만, 상태 일관성은 강하게 보장된다.
Slots => slots; 프로퍼티는 그대로 노출되고 있다.
현재 구조는 외부에서 Slots 리스트에 직접 접근 가능하다.
이 부분은 완전한 캡슐화는 아니다라는 점을 짚고 가는 것이 좋다.
만약 진짜 통제하려면 IReadOnlyList로 노출하거나, 인덱스 접근만 허용해야 한다.
Awake에서 인스턴스를 확정한 이유도 명확하다.
Unity의 생명주기에서 Awake는 모든 오브젝트의 Start보다 먼저 실행된다.
이 프로젝트에서는 SlotManager가 Start에서 InventoryManager.instance를 참조하기 때문에, 인벤토리 데이터는 반드시 그보다 먼저 초기화되어야 한다.
만약 Start에서 초기화를 했다면, UI가 먼저 실행될 경우 null 참조가 발생할 가능성이 있다.
따라서 Awake를 사용하여 인벤토리는 항상 가장 먼저 준비되는 시스템이라는 전제를 강제했다.
DontDestroyOnLoad는 씬 전환 시 오브젝트를 유지시키는 Unity API이다.
이 기능의 장점은 플레이어 인벤토리를 씬 단위로 재생성하지 않아도 된다는 점이다.
단점은 중복 인스턴스가 생성될 위험이 있다는 점인데, 이를 방지하기 위해 instance 비교 후 Destroy(gameObject)를 수행해 안전성을 확보했다.
Destroy 이후에도 아래 코드가 실행될 수 있기 때문에 return을 넣었다.
슬롯 개수를 const로 선언한 것은 단순한 고정이 아니라 설계적 선언이다.
const는 컴파일 타임 상수이며 런타임 중 변경될 수 없다.
이는 인벤토리 슬롯 수를 동적으로 변경 대상에 두지 않겠다는 명확한 의도다.
슬롯 수가 변하는 구조는 UI 재구성 비용과 예외 처리를 크게 증가시킨다.
이 프로젝트에서는 슬롯 수는 고정하고, 슬롯 내부 상태만 변화하도록 설계하는 것이 전체 복잡도를 낮춘다고 판단했다.
4.3. 고정 슬롯 데이터 초기화
void InitializeSlots()
{
slots = new List<InventorySlotData>(INVENTORY_SLOT_MAX_COUNT);
for (int i = 0; i < INVENTORY_SLOT_MAX_COUNT; i++)
{
slots.Add(new InventorySlotData());
}
}
void InitializeSlots()
{
// slotPrefab 자체는 남기고, 나머지 자식만 삭제
foreach (Transform child in slotsPanel)
{
if (child.gameObject != slotPrefab)
{
Destroy(child.gameObject);
}
}
slotPrefab.SetActive(false);
slots.Clear();
for (int i = 0; i < InventoryManager.INVENTORY_SLOT_MAX_COUNT; i++)
{
GameObject slotObj = Instantiate(slotPrefab, slotsPanel);
slotObj.SetActive(true);
InventorySlot invenSlot = slotObj.GetComponent<InventorySlot>();
if (invenSlot != null)
{
invenSlot.SlotIndex = i;
slots.Add(invenSlot);
}
}
}InitializeSlots는 인벤토리가 가질 슬롯의 구조를 가장 먼저 확정하는 단계다.
List 생성 시 capacity를 미리 지정한 것은 내부 배열 재할당을 줄이기 위함이다.
List는 내부적으로 배열을 사용하기 때문에, 용량을 초과할 때마다 새로운 배열을 할당하고 기존 데이터를 복사한다.
초기 용량을 명확히 지정함으로써 불필요한 메모리 재할당을 방지했다.
이 함수에서 중요한 점은 슬롯 리스트의 길이는 이후 절대 변하지 않는다는 전제를 코드로 확정한다는 것이다.
슬롯이 존재하는가를 검사하는 대신, 항상 존재하는 슬롯의 상태(IsEmpty)만 판단하면 된다.
이 설계는 인덱스 기반 접근(GetSlot, SwapSlots, SplitStack)을 안정적으로 만들고, UI 슬롯과 데이터 슬롯을 1:1로 대응시키는 데 매우 유리하다.
동적 슬롯 구조는 유연해 보이지만, 실제로는 인덱스 재정렬, 드래그 로직 수정, 예외 처리 증가 등 시스템 복잡도를 크게 높인다.
이 프로젝트에서는 구조적 안정성을 우선시해 고정 슬롯 구조를 선택했다.
4.4. 초기 아이템 구성
void InitializeFromData()
{
gold = Instantiate(Resources.Load<GoodsItmeData>("GameData/Item/Goods/GoldData"));
feather = Instantiate(Resources.Load<GoodsItmeData>("GameData/Item/Goods/FeatherData"));
var all = Resources.LoadAll<InventoryItemData>("GameData/Item");
foreach (var data in all)
{
if (data.InitCount > 0)
{
AddItem(data, data.InitCount);
}
}
}InitializeFromData는 인벤토리의 초기 상태를 데이터 기준으로 구성하는 단계다.
아이템 데이터는 ScriptableObject 기반으로 정의되어 있으며, 초기 수량이 정의된 아이템만 AddItem을 통해 인벤토리에 추가한다.
여기서 Resources.Load와 Resources.LoadAll을 사용했다.
Resources는 특정 경로의 에셋을 런타임에 로드하는 Unity API다.
장점은 빠른 프로토타이핑과 데이터 드리븐 초기화다.
폴더 구조만 정리하면 코드 수정 없이 초기 인벤토리 구성을 바꿀 수 있다. 이는 초기 설계 단계에서 매우 효율적이다.
하지만 단점도 분명하다.
Resources 폴더에 포함된 에셋은 빌드 시 모두 포함되며, 메모리 관리가 어렵다.
또한 경로 문자열이 사실상 API가 되기 때문에 폴더 구조 변경 시 코드 수정이 필요하다.
Addressables 같은 체계를 도입하면 이 문제를 해결할 수 있지만, 현재 프로젝트 단계에서는 초기 자동 구성이라는 목표를 가장 빠르게 달성할 수 있는 방식이 Resources였다.
Instantiate를 사용한 이유도 중요하다.
ScriptableObject는 에셋 자체이기 때문에, 런타임 중 값을 수정하면 원본 에셋이 변경될 위험이 있다.
Instantiate를 통해 런타임 전용 인스턴스를 생성하면, 원본 데이터를 보호하면서도 동적 변경이 가능하다. 이는 에셋 오염을 방지하기 위한 안전 장치다.
이 함수에서도 UI를 직접 갱신하지 않고 반드시 AddItem을 통해 데이터를 변경하도록 설계했다.
초기화 단계와 런타임 아이템 획득이 동일한 경로를 사용하도록 만들면, 데이터 흐름이 일관되게 유지되고 예외 케이스가 줄어든다.
4.5. 인덱스 기반 접근과 캡슐화
public InventorySlotData GetSlot(int index)
{
if (index < 0 || index >= slots.Count)
return null;
return slots[index];
}
외부에서 리스트에 직접 접근하지 않고, 반드시 이 메서드를 통해 접근하도록 설계했다.
내부 구조는 감추고, 검증된 접근 경로만 제공하는 것은 객체지향에서 말하는 캡슐화의 기본 원칙이다.
잘못된 인덱스 접근을 내부에서 차단함으로써 예외 상황을 구조적으로 줄인다.
5. 개발 의도
이 게시글에서 보여주고자 한 핵심은, 인벤토리 시스템의 복잡함을 기능이 아니라 구조에서 먼저 해결하려는 접근이다.
아이템 추가, 드래그, 분할, 클릭 같은 기능은 모두 중요하지만, 그보다 더 중요한 것은 이 기능들이 모두 동일한 데이터 기반 위에서 동작하고 있는가였다.
슬롯을 데이터 객체로 분리하고, InventoryManager를 단일 진입점으로 삼음으로써, 인벤토리는 UI나 입력 방식과 무관하게 안정적인 상태를 유지할 수 있게 되었다.
이 구조 덕분에 이후 게시글에서 다루게 될 아이템 추가·삭제, 드래그 앤 드롭, 스택 분할, 툴팁, 클릭 인터랙션 같은 기능들을 기존 구조를 흔들지 않고 자연스럽게 확장할 수 있었다.
