드래그 & 드롭 기반 스택 분할
목차
1. 시스템 요구 사항
드래그 앤 드롭과 스택 병합까지 구현되었다고 해서, 인벤토리 상호작용이 완성되는 것은 아니었다.
스택형 인벤토리에서 플레이어가 가장 자주 요구하는 기능 중 하나는, 아이템을 나눠서 옮기는 행위, 즉 스택 분할이다.
스택 분할은 단순히 수량을 둘로 나누는 기능처럼 보이지만, 실제로는 인벤토리 시스템에서 가장 위험한 입력 중 하나다.
이 기능은 'UI 입력 → 수량 계산 → 슬롯 배치 → 데이터 정합성 유지' 가 동시에 맞아야 하며, 조금이라도 설계가 흐트러지면 수량 증식, 소실, 음수 값 같은 치명적인 버그로 이어질 수 있다.
슬롯 개수는 고정되어 있고, 슬롯은 항상 인덱스로 접근되며, 기존 이동·병합·스왑 규칙은 이미 확정되어 있고, UI는 결과를 보여줄 뿐, 규칙을 판단하지 않는다는 전게를 가진다.
따라서 스택 분할은 새로운 규칙을 추가하는 기능이 아니라, 이미 존재하는 인벤토리 규칙 위에 입력 방식만 확장하는 기능이어야 했다.
2. 설계 목표
- 분할 과정에서 확정 전까지 인벤토리 데이터는 절대 변경하지 않을 것
- 분할 결과는 기존 슬롯 규칙(병합·스왑 구조)을 그대로 따를 것
- 우클릭 기반 입력으로 기존 드래그 흐름과 충돌하지 않도록 할 것
- UI는 수량 선택만 담당하고, 실제 분할 처리는 데이터 로직에서 수행할 것
3. 흐름도

이 흐름에서 가장 중요한 점은, 분할 UI가 열려 있는 동안 인벤토리 데이터는 단 한 줄도 변경되지 않는다는 것이다.
데이터 변경은 오직 확정이라는 명시적인 사용자 입력 이후에만 발생한다.
4. 구현
4.1. 우클릭 드래그
public void OnDrop(PointerEventData eventData)
{
// ... 이전 게시글
if (eventData.button == PointerEventData.InputButton.Right)
{
var fromData = fromSlot.slotData;
if (fromData.count <= 1) return;
if (fromData.data == null) return;
bool destEmpty = (slotData == null || slotData.IsEmpty || slotData.data == null);
if (!destEmpty && slotData.data != fromData.data) return;
SplitItemUiManager.Instance.Open(fromSlot.SlotIndex, this.SlotIndex);
}
}
우클릭 드래그는 아이템 분할 UI로 진입할 자격이 되는지를 UI 이벤트 단계에서 먼저 걸러내는 구간이다.
여기서 OnDrop(PointerEventData eventData)는 Unity EventSystem이 제공하는 드래그-드롭 이벤트 콜백이며, 마우스 드롭이 일어나는 순간에만 호출된다.
Update에서 매 프레임 입력을 감시하는 방식보다 이벤트 기반이라는 점이 장점인데, 필요한 순간에만 실행되므로 UI 상호작용의 책임이 명확해지고 불필요한 프레임 연산도 줄어든다.
반대로 EventSystem에 의존하므로 프로젝트가 새 Input System 또는 커스텀 입력 처리로 바뀌면 구조를 맞춰야 하는 단점이 있을 수 있다.
그럼에도 인벤토리 슬롯은 UI 상호작용 객체이기 때문에, 폴링보다 이벤트 기반 처리가 설계 상 더 자연스럽고 유지보수에 유리해서 이 방식을 택한 것이다.
이 함수에서 eventData.button == PointerEventData.InputButton.Right로 우클릭 드롭만 분기하는데, 이 조건은 왼쪽 드래그는 스왑/합치기, 오른쪽 드래그는 분할이라는 UX 규칙을 코드로 고정하는 역할을 한다.
그 다음 fromData.count <= 1을 가장 먼저 체크하는 이유는 분할 시스템에서 가장 핵심적인 전제 조건이 원본 스택에 최소 1개는 남겨야 한다는 것이기 때문이다.
수량이 1개면 분할이라는 행위 자체가 성립하지 않으므로, 이후 로직을 실행할 가치가 없다.
이어서 fromData.data == null 체크는 NullReferenceException 방지를 위한 방어 코드인데, UI와 데이터는 항상 완벽히 동기화되어 있다는 가정을 하면 안 된다.
드래그 도중 데이터가 변경되거나, 다른 시스템이 슬롯을 비우는 상황이 있을 수 있으므로 이벤트 진입 시점에서 한 번 더 안전장치를 두는 것이 맞다.
그 다음 destEmpty는 목적지 슬롯이 완전한 빈 슬롯인지 판정하기 위한 조건인데, 여기서 중요한 건 빈 슬롯이란 무엇인가를 UI 표현이 아니라 데이터 상태로 정의한다는 점이다.
slotData == null이거나, slotData.IsEmpty이거나, 혹은 slotData.data == null이면 목적지는 빈 슬롯으로 취급한다.
이렇게 다양한 케이스를 허용하는 이유는 시스템 전반에서 슬롯을 비우는 구현이 단일 방식으로 고정되지 않을 수 있기 때문이다.
마지막으로 !destEmpty && slotData.data != fromData.data는 목적지 슬롯이 비어있지 않다면 반드시 같은 아이템만 허용하겠다는 규칙이다.
분할은 같은 아이템을 두 묶음으로 나누는 행위이므로, 다른 아이템 위로 분할 UI를 띄우면 데이터 측면에서도 불필요한 분기와 예외 처리가 늘어난다.
따라서 UI 단계에서부터 분할이 가능한 목적지 조건을 강제하고, 그 조건이 만족되는 순간에만 Open(fromSlot.SlotIndex, this.SlotIndex) 분할 UI를 연다.
이 호출은 UI 진입만 담당하며, 이 시점에서는 인벤토리 데이터가 전혀 바뀌지 않는다는 점이 핵심이다.
즉, 우클릭 드래그는 데이터 조작이 아니라 데이터 조작을 할 수 있는 UI로 들어갈 자격을 판정하는 단계로 설계되어 있다.
4.2. 분할 UI 초기화 및 활성화
// SplitItemUiManager.cs
public void Open(int fromIndex, int toIndex)
{
var inv = InventoryManager.instance;
var from = inv.GetSlot(fromIndex);
var to = inv.GetSlot(toIndex);
// 원본 슬롯 유효성 검사
if (from == null || from.IsEmpty || from.data == null) return;
if (from.count <= 1) return; // 1개면 분할 의미 없음
// 목적지 슬롯 검사 (비어있거나 같은 아이템만 허용)
bool destEmpty = (to == null || to.IsEmpty || to.data == null);
if (!destEmpty && to.data != from.data) return;
fromSlotIndex = fromIndex;
toSlotIndex = toIndex;
// 분할 최대치 계산 (원본에서 최소 1개는 남겨야 함)
int byCount = from.count - 1;
// 목적지 슬롯 수용량 제한
int byTargetSpace = destEmpty ? from.MaxStack : (to.MaxStack - to.count);
// 실제 UI 상에서 선택 가능한 최대 분할 수량
maxAmount = Mathf.Min(byCount, byTargetSpace);
if (maxAmount <= 0) return;
// UI 정보 세팅
itemIcon.sprite = from.data.Icon;
itemNameTxt.text = from.data.ItemName;
amountSlider.wholeNumbers = true;
amountSlider.minValue = 1;
amountSlider.maxValue = maxAmount;
minValueTxt.text = "1";
maxValueTxt.text = maxAmount.ToString();
// 초기값은 중간값으로 시작
int startValue = Mathf.Clamp(maxAmount / 2, 1, maxAmount);
SetValue(startValue);
gameObject.SetActive(true);
}
SplitItemUiManager.Open은 분할 UI를 열면서, 사용자가 선택할 수 있는 분할 수량의 최대치를 계산해 UI에 반영하는 함수다.
여기서 InventoryManager.instance를 통해 실제 데이터 컨테이너에 접근하고 GetSlot으로 from/to 슬롯을 가져오는데, 이 방식은 싱글톤 접근이기 때문에 어디서든 쉽게 참조할 수 있다는 장점이 있다.
반면 전역 상태 의존이 생겨 테스트가 어려워지고 결합도가 올라갈 수 있다는 단점이 있다.
그런데 인벤토리는 게임 전역에서 일관된 단일 상태로 관리되어야 하는 시스템이고, UI 또한 그 상태를 실시간으로 반영해야 하므로, 이 프로젝트에서는 싱글톤 기반 접근이 현실적인 비용 대비 효율이 높다고 판단한 것으로 이해할 수 있다.
Open 내부에서 원본 슬롯 유효성 검사와 목적지 슬롯 검사 조건이 다시 들어가 있는 이유는, UI 이벤트 단계(4.1)에서 이미 걸렀다 하더라도 이 함수가 다른 경로로도 호출될 수 있는 공용 진입점이기 때문이다.
코드가 커질수록 특정 UI 흐름만으로 호출된다고 가정하는 것은 위험하고, 특히 인벤토리 UI는 여러 패널(상점, 장착, 툴팁, 단축키 등)과 엮이기 쉬우므로 Open 자체가 방어적으로 동작하는 것이 맞다.
또한 from.count <= 1을 다시 검사하는 것은 분할의 의미 조건을 다시 확정하는 과정이다.
핵심 계산은 int byCount = from.count - 1;에서 시작된다.
이것은 원본에 최소 1개를 남겨야 한다는 규칙을 수학적으로 반영한 표현이다.
여기서 -1을 하지 않으면 사용자가 원본을 0개로 만들 수 있고, 그 경우 분할이 아니라 사실상 이동/스왑과 비슷한 행동이 되어 UI 의도가 흐려진다.
다음으로 int byTargetSpace = destEmpty ? from.MaxStack : (to.MaxStack - to.count);는 목적지 슬롯이 비어있으면 새 스택을 만들 수 있으니 최대 스택 크기까지 허용하고, 이미 같은 아이템이 있는 슬롯이면 남은 공간만큼만 허용하겠다는 계산이다.
이렇게 원본 제약과 목적지 수용량 제약을 모두 구한 뒤, 최종적으로 maxAmount = Mathf.Min(byCount, byTargetSpace);로 최대 분할 가능치를 결정한다.
Mathf.Min은 Unity의 수학 유틸이며 여러 제약 조건 중 더 작은 값을 선택해 안전 범위를 강제하는 데 유용하다.
장점은 조건 분기를 늘리지 않고도 명확하게 상한선을 만들 수 있다는 점이고, 단점은 제약 조건이 늘어날수록 식이 길어져 의미가 흐려질 수 있다는 점이다.
하지만 이 경우는 '최대 분할치 = 가능한 것들 중 최솟값' 이라는 정의가 매우 명확하므로, Mathf.Min이 의도 전달에 오히려 적합하다.
이후 슬라이더 UI를 설정할 때 amountSlider.wholeNumbers = true;를 쓰는 것은 슬라이더 값을 정수로 제한하기 위해서다.
슬라이더는 기본적으로 float을 다루기 때문에 정수 수량 시스템에 그대로 연결하면 소수점 문제가 생긴다.
wholeNumbers는 사용자가 조작할 때 값이 정수로만 움직이게 만들어 UI 단계에서부터 수량은 정수라는 규칙을 강제한다.
마지막으로 초기값을 maxAmount / 2로 주는 것은 UX적인 선택이다.
기본값이 1이면 매번 올려야 하고, 기본값이 max면 실수로 많이 나누기 쉽다.
중간값은 평균적인 사용 시나리오에서 조작 비용을 줄인다.
4.3. 값 갱신
void SetValue(int value)
{
value = Mathf.Clamp(value, 1, maxAmount);
updating = true;
amountSlider.SetValueWithoutNotify(value);
amountInput.SetTextWithoutNotify(value.ToString());
if (currentValueTxt != null)
currentValueTxt.text = value.ToString();
updating = false;
}
SetValue는 이 분할 기능에서 가장 중요한 값 갱신의 단일 통로다.
슬라이더를 움직이든, 인풋필드를 입력하든, 마우스 휠을 돌리든, 최종 수량 값은 반드시 SetValue로 들어오게 설계되어 있다.
여기서 Mathf.Clamp(value, 1, maxAmount)를 선행하는 이유는 입력 경로가 여러 개일수록 유효 범위를 벗어난 값이 들어올 가능성이 커지기 때문이다.
Clamp를 한 곳에서 강제하면 이후 코드 어디에서도 value가 범위를 벗어나는지를 따로 걱정할 필요가 없어진다.
이는 방어 코드를 퍼뜨리는 대신, 핵심 경계에서 단 한번에 막는 구조다.
그 다음 updating = true;는 이벤트 재귀 루프 방지를 위한 플래그다.
Unity UI는 슬라이더 값이 바뀌면 onValueChanged가 호출되고, 인풋필드 텍스트가 바뀌면 onValueChanged가 호출된다.
그런데 슬라이더 변경 이벤트 안에서 인풋필드를 갱신하고, 인풋필드 변경 이벤트 안에서 슬라이더를 갱신하면 서로가 서로를 다시 호출하는 형태의 루프가 쉽게 생긴다.
이 문제를 해결하기 위해 Unity는 SetValueWithoutNotify, SetTextWithoutNotify를 제공한다. 이 함수들은 값은 바꾸되 이벤트는 발생시키지 않는다는 의미를 가진다.
장점은 이벤트 루프를 구조적으로 끊어주며, 단점은 값 변경이 이벤트에 의해 추적되지 않으므로 다른 시스템이 이벤트에 의존하고 있다면 놓칠 수 있다는 점이다.
하지만 여기서는 슬라이더/인풋필드의 동기화가 목적이고, 외부 시스템에 이 이벤트를 전파할 필요가 없기 때문에 WithoutNotify가 정확히 맞는 선택이다.
updating 플래그까지 같이 쓰는 것은 혹시라도 다른 코드가 일반 SetValue를 쓰게 되더라도 루프가 생기는 것을 방지하는 이중 안전장치다.
4.4. 슬라이더와 입력 필드 동기화
public void OnSliderChanged(float v)
{
if (updating) return;
SetValue(Mathf.RoundToInt(v));
}
public void OnInputChanged(string s)
{
if (updating) return;
int v;
if (!int.TryParse(s, out v))
v = 1;
SetValue(v);
}
private void Update()
{
if (!gameObject.activeSelf) return;
float wheel = Input.GetAxis("Mouse ScrollWheel");
if (Mathf.Abs(wheel) > 0.001f)
{
int v = Mathf.RoundToInt(amountSlider.value);
v += (wheel > 0f) ? 1 : -1;
SetValue(v);
}
}
슬라이더와 입력 필드는 서로 다른 입력 방식이지만, 결국 하나의 수량 값만을 결정하기 위한 도구다.
슬라이더는 직관적이고 빠르지만 정확한 수치 입력이 어렵고, 입력 필드는 정확하지만 즉각적인 피드백이 부족하다.
그래서 두 입력을 동시에 제공하고, 항상 같은 값을 바라보도록 동기화했다.
Mathf.Clamp를 사용하는 이유는 입력 단계에서 절대 유효 범위를 벗어난 값이 나오지 않도록 보장하기 위함이다.
이로 인해 이후 분할 로직에서는 수량에 대한 방어 코드를 추가로 작성할 필요가 없다.
4.5. 분할 확정 처리
public void OnClickConfirm()
{
int amount = Mathf.RoundToInt(amountSlider.value);
InventoryManager.instance.SplitStack(fromSlotIndex, toSlotIndex, amount);
SlotManager.instance.PoplateSlots();
Close();
}
void Close()
{
gameObject.SetActive(false);
}OnClickConfirm은 분할 기능에서 처음으로 데이터 계층을 건드리는 지점이다.
이 설계가 중요한 이유는, 분할 UI를 열고 값을 바꾸는 동안에는 인벤토리 데이터가 절대 변하지 않기 때문이다.
즉, 사용자가 취소하거나 UI를 닫으면 어떤 흔적도 남지 않는다.
이런 구조는 UI 단계에서 프리뷰/시뮬레이션을 마음껏 하게 해주고, 실제 변경은 confirm에서만 일어나게 해 데이터 무결성을 지키는 데 유리하다.
여기서 SplitStack()함수로 실제 분할을 수행하고, PoplateSlots()로 UI를 갱신한다.
이 갱신은 데이터가 진실이고 UI는 반영이라는 방향성을 유지한다.
마지막으로 Close()는 패널을 비활성화하는데, Unity에서 gameObject.SetActive(false)는 UI 객체를 즉시 화면에서 제거하는 가장 간단한 방법이다.
장점은 확실히 닫히고 Update도 멈춘다는 점이며, 단점은 UI가 자주 열리고 닫힐 때 활성/비활성 비용이 있을 수 있다.
하지만 이 패널은 상시 호출되는 UI가 아니고, 열리는 순간만 중요하므로 Active 토글 방식이 적절하다.
4.6. 실제 스택 분할 처리
// InventoryManager.cs
public void SplitStack(int fromIndex, int toIndex, int amount)
{
if (amount <= 0) return;
var from = GetSlot(fromIndex);
if (from == null || from.IsEmpty) return;
if (from.count <= amount) return;
var to = GetSlot(toIndex);
if (to == null) return;
var data = from.data;
bool destEmpty = (to.data == null || to.IsEmpty);
if (!destEmpty && to.data != data) return;
int maxTargetSpace = destEmpty
? data.BundleMaxCount
: to.MaxStack - to.count;
if (maxTargetSpace <= 0) return;
int moveAmount = Mathf.Min(amount, maxTargetSpace);
if (destEmpty)
{
to.data = data;
to.count = moveAmount;
}
else
{
to.count += moveAmount;
}
from.count -= moveAmount;
if (from.count <= 0)
from.Clear();
}SplitStack은 실제 데이터 분할의 최종 방어선이다.
여기서도 입력값과 슬롯 유효성을 다시 검사하는 이유는 데이터 계층은 UI 계층을 신뢰하지 않는다는 원칙 때문이다.
UI에서 아무리 제한을 걸어도, 디버그 호출, 다른 시스템 호출, 네트워크/세이브 로드, 혹은 버그로 인해 예상치 못한 인자가 들어올 수 있다. 그래서 amount <= 0, from null, from.count <= amount 같은 검사를 통해 분할의 의미 조건을 다시 확정한다.
목적지 슬롯에 대해서도 destEmpty와 to.data != data를 검사한다. 이건 특히 중요하다.
UI에서 같은 아이템만 허용했더라도, 그 사이에 목적지 슬롯이 다른 아이템으로 바뀌었을 가능성을 배제할 수 없다.
데이터 계층에서 이 검증을 생략하면 동시성 문제나 UI 타이밍 문제에서 치명적인 데이터 오염이 생길 수 있다.
이후 maxTargetSpace 계산은 목적지가 비어있으면 새 스택이므로 BundleMaxCount, 이미 같은 아이템이면 남은 공간만큼만 허용한다.
마지막으로 moveAmount = Mathf.Min(amount, maxTargetSpace)를 사용해 요청 수량이 많아도 실제로는 수용량만큼만 이동하도록 만든다.
이 Min은 UI와 데이터 계층 모두에 등장하지만, 의미가 다르다. UI에서는 사용자가 선택 가능한 최대치 제한이고, 데이터 계층에서는 최종 안전장치로서의 제한이다.
겉보기엔 중복 같아도 책임이 다르기 때문에 유지하는 것이 맞다.
실제 이동은 목적지가 비어있으면 to.data를 할당하고 to.count를 설정하며, 이미 같은 아이템이면 to.count에 더한다.
그 다음 원본에서 from.count -= moveAmount로 감소시키고, 0 이하가 되면 Clear()로 슬롯을 완전히 비운다.
여기서 Clear가 중요한 이유는 count만 0으로 만들면 data 레퍼런스가 남아 비어있는데 데이터가 있는 이상 상태가 될 수 있기 때문이다.
UI에서는 IsEmpty가 data == null || count <= 0처럼 정의될 수 있어, 시스템에 따라 서로 다른 판단을 일으킬 수 있다.
그래서 data까지 null로 만드는 Clear는 슬롯 상태를 완전히 정규화해주는 정리 동작이다.
5. 개발 의도
이 게시글에서 보여주고자 한 핵심은, 스택 분할이라는 복잡한 기능을 새로운 규칙 없이 구현하는 방법이다.
분할은 새로운 시스템이 아니라, 기존 인벤토리 설계가 얼마나 확장에 강한지를 검증하는 기능이다.
입력은 UI에서, 판단과 결과는 데이터에서, 확정 전까지 데이터는 불변, 이 원칙들을 지켰기 때문에, 이후 추가되는 기능들도 기존 코드를 흔들지 않고 자연스럽게 이어질 수 있었다.
