스택 정리 시스템 – 분산된 아이템을 예측 가능한 상태로 수렴시키는 구조
목차
1. 시스템 요구 사항
아이템 추가와 삭제 시스템이 안정적으로 동작하더라도, 실제 플레이가 반복되면 인벤토리는 점점 비정형적인 상태가 된다.
포션을 일부 사용하거나 제작 재료를 소모하는 과정에서 스택이 비워지고, 이후 아이템을 다시 획득하면서 동일한 아이템이 여러 슬롯에 흩어지는 상황이 자연스럽게 발생한다.
이 상태는 데이터 오류는 아니지만, 플레이어 입장에서는 관리 피로도를 크게 증가시킨다.
같은 아이템이 여러 슬롯에 나뉘어 있으면 전체 수량을 직관적으로 파악하기 어렵고, 슬롯을 수동으로 옮기며 정리해야 하는 부담이 생긴다.
문제는 이 정리 동작이 암묵적으로 발생할 경우다.
아이템 추가나 삭제 시 자동으로 병합을 시도하면, 언제 슬롯 구조가 바뀌는지 예측하기 어려워지고, UI 갱신 타이밍이나 입력 중복 상황에서 혼란이 발생할 수 있다.
이를 대비하여, 인벤토리 시스템에는 명시적으로 호출되는 스택 병합 로직이 필요했고, 이 로직은 항상 동일한 규칙으로 동작하며, 예측 가능한 결과를 만들어야 했다.
따라서 이 시스템에서의 정리는 자동으로 발생하는 부수 효과가 아니라, 플레이어의 명시적 입력에 의해 호출되는 독립적인 기능으로 정의되었다.
2. 설계 목표
- 동일 아이템이 여러 슬롯에 흩어져 있을 경우 하나의 규칙으로 병합할 수 있을 것
- 스택 최대치를 초과하지 않는 범위에서 슬롯을 재구성할 것
- 아이템 추가·삭제 로직과 정리 로직을 분리해 책임을 명확히 할 것
- 정리 결과가 항상 동일하도록 결정적(deterministic) 동작을 보장할 것
3. 흐름도
[스택 병합 요청]
↓
[두 슬롯 인덱스 전달]
↓
[아이템 동일 여부 확인]
↓
[목적지 슬롯 여유 공간 계산]
↓
[이동 가능한 수량 계산]
↓
[수량 이동 및 원본 슬롯 갱신]
이 흐름의 핵심은, 병합 가능 여부를 판단하고, 가능할 때만 이동을 수행한다는 점이다.
중간 상태나 롤백을 전제로 하지 않기 때문에 데이터는 항상 일관된 상태를 유지한다.
4. 구현
4.1. 스택 병합 시도 로직
public bool TryMergeStack(int fromIndex, int toIndex)
{
if (fromIndex == toIndex) return false;
var from = GetSlot(fromIndex);
var to = GetSlot(toIndex);
if (from == null || to == null) return false;
if (from.IsEmpty) return false;
if (from.data == null || to.data == null) return false;
if (from.data.ItemId != to.data.ItemId) return false;
int maxStack = to.MaxStack;
int space = maxStack - to.count;
if (space <= 0) return false;
int move = Mathf.Min(space, from.count);
to.count += move;
from.count -= move;
if (from.count <= 0)
from.Clear();
return true;
}
이 함수는 두 슬롯 간 병합이 가능한지 판단하고, 가능하다면 실제로 수량을 이동시키는 책임을 가진다.
반환값을 bool로 설계한 이유는, 상위 시스템에서 병합이 성공했는지 여부를 기준으로 다음 동작을 결정할 수 있도록 하기 위함이다.
먼저 동일 슬롯에 대한 병합 시도를 차단해 불필요한 연산을 제거한다.
이후 두 슬롯이 모두 유효한지, 원본 슬롯이 비어 있지 않은지를 확인해 병합의 전제 조건을 검증한다.
이 단계에서 null 검사와 IsEmpty 검사를 분리한 이유는, 슬롯 자체가 존재하지 않는 경우와 데이터가 없는 경우를 명확히 구분하기 위함이다.
이는 이후 슬롯 구조 변경이나 초기화 로직이 추가되더라도, 병합 조건 판단이 의도치 않게 변하지 않도록 하기 위한 방어적 설계이기도 하다.
핵심 조건은 두 슬롯이 동일한 아이템을 담고 있는지 여부다.
ItemId를 기준으로 비교함으로써, ScriptableObject 인스턴스가 다르더라도 같은 아이템으로 판단할 수 있는 구조를 유지한다.
이는 향후 아이템 데이터 로딩 방식이 변경되더라도 병합 로직이 흔들리지 않도록 하기 위한 선택이다.
이후 목적지 슬롯의 남은 여유 공간을 계산하고, 그 공간과 원본 슬롯의 수량 중 작은 값을 이동 수량으로 결정한다.
Mathf.Min을 사용함으로써 스택 최대치를 초과하는 상황을 조건 분기 없이 구조적으로 방지한다.
이는 수량 처리 과정에서 분기 수를 줄여 가독성을 높이고, 실수로 최대치를 초과하는 코드가 추가될 가능성을 구조적으로 차단하기 위한 선택이다.
수량 이동 이후 원본 슬롯의 수량이 0 이하가 되면 Clear를 호출해 슬롯을 완전히 비운다.
이 과정을 통해 음수 수량이나 데이터는 있는데 수량은 0인 비정상 상태가 남지 않도록 보장한다.
4.2. 병합 실패 시 스왑으로 폴백하는 상위 정책
public void SwapOrMerge(int fromIndex, int toIndex)
{
if (fromIndex == toIndex) return;
if (!InventoryManager.instance.TryMergeStack(fromIndex, toIndex))
{
InventoryManager.instance.SwapSlots(fromIndex, toIndex);
}
PoplateSlots();
}
이 함수는 슬롯 정리의 최상위 정책 함수다.
이 구조에서 가장 중요한 점은, 병합을 먼저 시도하고 실패했을 때만 스왑으로 폴백한다는 점이다.
슬롯을 옮긴다는 행위의 1차적인 의도는 대부분 정리이기 때문에, 같은 아이템이라면 병합이 우선되어야 한다.
이를 'TryMergeStack → SwapSlots' 순서로 고정함으로써, 이 정책이 시스템 전반에서 일관되게 유지된다.
이 함수는 실제 병합이나 스왑의 세부 구현을 알 필요가 없으며, 정리 시 어떤 규칙을 우선할 것인가라는 정책만을 표현하는 계층으로 동작한다.
UI 갱신은 어떤 경로로 처리되었든 한 번만 수행되며, 데이터 변경과 UI 반영이 명확히 분리되어 있다.
이로 인해 병합 정책이 변경되더라도 UI 로직을 수정할 필요가 없어진다.
5. 개발 의도
이 스택 병합 시스템의 핵심 의도는, 슬롯 정리 규칙을 데이터 레이어에서 단일화하는 것이었다.
UI 입력 방식이 드래그이든, 버튼이든, 혹은 추후 패드 입력이 추가되더라도, 두 슬롯을 정리한다는 행위는 항상 동일한 규칙을 따라야 한다.
병합과 스왑을 명확히 분리하고, 병합을 우선 정책으로 고정함으로써, 플레이어는 인벤토리를 직접 정리하지 않아도 자연스럽게 정돈된 상태를 유지할 수 있다.
동시에 개발자 입장에서는 병합 규칙과 스왑 규칙을 독립적으로 수정할 수 있어 유지보수성이 크게 향상된다.
결과적으로 이 시스템은 단순한 편의 기능이 아니라, 인벤토리 데이터 구조를 항상 예측 가능한 상태로 유지하기 위한 핵심 정리 로직으로 설계되었다.
