드래그 & 드롭 기반 슬롯 이동과 스택 병합의 기준점
목차
1. 시스템 요구 사항
아이템 추가와 삭제 규칙이 데이터 기준으로 정리되었다고 해서, 인벤토리 시스템이 플레이 관점에서도 곧바로 완성되는 것은 아니었다.
플레이어가 인벤토리를 실제로 사용하는 과정에서 가장 빈번하게 발생하는 상호작용은 아이템을 클릭해 사용하는 행위보다도, 슬롯 간 아이템을 옮기는 드래그 앤 드롭이었다.
드래그 앤 드롭은 겉으로 보기에는 단순히 슬롯 위치를 바꾸는 UI 기능처럼 보이지만, 실제로는 인벤토리 데이터 구조를 가장 많이 흔드는 입력 방식이다.
아이템 이동 과정에서 스왑이 발생할 수도 있고, 동일한 아이템일 경우 스택 병합이 일어날 수도 있으며, 이 판단이 흐릿하게 구현되면 수량 손실이나 중복 같은 치명적인 오류로 이어지기 쉽다.
특히 이 프로젝트의 인벤토리는 슬롯 개수가 고정되어 있고, 슬롯은 항상 인덱스를 기준으로 접근된다.
이는 드래그 결과를 UI 좌표가 아닌 데이터 인덱스 기준으로 처리하기 위함이며, UI 레이아웃 변경과 무관하게 동일한 동작을 보장하기 위한 전제다.
동일 아이템일 경우에는 스택 병합이 가능하며, 병합이 불가능한 경우에는 단순 위치 교환이 이루어진다.
그리고 무엇보다 중요한 전제는, 이 모든 판단이 UI가 아니라 데이터 기준으로 이루어져야 한다는 점이었다.
즉 드래그 앤 드롭은 UI 이벤트로 시작되지만, 실제 결과는 반드시 인벤토리 데이터 규칙에 의해 결정되어야 했다.
UI는 그 결과를 시각적으로 표현하는 역할만 수행하고, 어떤 동작이 일어날지는 데이터가 판단하는 구조가 필요했다.
2. 설계 목표
- 드래그 입력은 UI에서 처리하되, 결과 판단은 데이터 로직에 위임할 것
- 슬롯 이동과 스택 병합을 단일 진입점으로 통합할 것
- 동일 아이템일 경우 병합, 그 외에는 스왑이라는 규칙을 명확히 고정할 것
- 슬롯 인덱스를 기준으로 모든 이동을 처리해 UI와 데이터의 1:1 대응을 유지할 것
3. 흐름도

이 흐름의 핵심은, 드래그 입력 자체는 UI에서 처리되지만, 결과 판단은 반드시 InventoryManager를 거친다는 점이다.
어떤 경우에 병합이 되고, 어떤 경우에 스왑이 되는지는 UI가 아니라 데이터 규칙이 결정한다.
이 흐름도에서 TryMergeStack 단계는 단순한 분기 노드가 아니라, 동일 아이템 여부, 대상 슬롯 여유 공간, 실제 이동 가능한 수량 계산까지 포함한 다중 조건 판단 단계를 의미한다.
병합이 실패하는 경우는 단순히 아이템이 다를 때뿐 아니라,대상 슬롯이 이미 최대 스택인 경우도 포함된다.
4. 구현
드래그 기능은 Unity의 EventSystem과 IPointer 계열 인터페이스를 기반으로 구현되었다.
InventorySlot은 IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler를 구현해 입력 흐름을 단계별 콜백 구조로 분리했다.
EventSystem은 매 프레임 입력을 직접 감지하는 방식이 아니라, Raycast를 통해 현재 포인터가 가리키는 UI 오브젝트를 판별한 뒤 해당 오브젝트에 이벤트를 전달한다.
이 구조는 입력을 상태 기반으로 분리해주며, 드래그 시작·이동·종료·드롭이라는 생명주기를 명확한 콜백 단위로 나눌 수 있게 해준다.
이 방식의 장점은 입력 흐름이 구조적으로 분리된다는 점이다.
시각적 연출과 데이터 변경 시점을 자연스럽게 분리할 수 있으며, 입력 처리와 비즈니스 로직이 얽히지 않는다.
반면 Raycast 설정이나 UI 계층 구조에 따라 이벤트 누락이 발생할 수 있다는 단점이 있다.
그러나 인벤토리는 전형적인 UI 상호작용 영역이며, 입력 명확성과 단계 분리가 중요하기 때문에 EventSystem 기반 구현을 선택했다.
4.1 드래그 아이콘 설계
private static GameObject dragIconObj;
private static RectTransform dragIconRT;
private static Image dragIconImage;
private static Canvas rootCanvas;
이 설계는 성능과 구조적 안정성을 동시에 고려한 선택이다.
드래그가 발생할 때마다 GameObject를 생성하고 파괴하면, 인벤토리처럼 상호작용이 잦은 UI에서는 GC 부담과 Instantiate 비용이 누적될 수 있다.
특히 모바일 환경에서는 이 비용이 프레임 드랍으로 이어질 가능성이 있다.
따라서 드래그 아이콘을 한 번만 생성해 재사용하는 구조를 선택했다. 활성화와 비활성화만 반복함으로써 런타임 메모리 할당을 최소화했다.
static으로 선언한 이유는 논리적 제약과도 일치한다.
인벤토리에서는 동시에 여러 슬롯을 드래그할 수 없으므로, 드래그 아이콘 역시 전역 단일 인스턴스로 충분하다.
오히려 이 방식이 시스템 제약을 코드 구조로 명확히 드러낸다.
다만 이 설계는 전제를 가진다.
드래그 아이콘은 rootCanvas를 기준으로 계층에 배치되며, 현재 프로젝트는 인벤토리 UI가 단일 루트 Canvas 하위에 존재한다는 가정을 둔다.
만약 씬마다 다른 Canvas가 존재하거나 팝업 전용 Canvas가 분리된 구조라면, rootCanvas 기준이 꼬일 수 있다.
현재 구조에서는 단일 Screen Space Canvas 환경이기 때문에 안전하게 동작한다는 점을 인지하고 있다.
4.2. 드래그 시작 처리
public void OnBeginDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left &&
eventData.button != PointerEventData.InputButton.Right)
return;
if (slotData == null || slotData.IsEmpty) return;
CloseTooltip();
if (dragIconObj == null)
{
dragIconObj = new GameObject("DragIcon", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
dragIconRT = dragIconObj.GetComponent<RectTransform>();
dragIconImage = dragIconObj.GetComponent<Image>();
dragIconImage.raycastTarget = false;
dragIconRT.SetParent(rootCanvas.transform, false);
dragIconRT.sizeDelta = icon.rectTransform.sizeDelta;
}
dragIconObj.SetActive(true);
dragIconImage.sprite = icon.sprite;
dragIconRT.position = eventData.position;
}
OnBeginDrag는 드래그의 시작 연출만을 담당하는 함수다.
이 함수가 호출되는 시점에서는 이 아이템이 어디로 이동할지, 병합이 될지, 스왑이 될지가 전혀 확정되지 않은 상태다.
따라서 데이터는 전혀 변경하지 않고, 조건 검증과 시각적 준비만 수행한다.
드래그 시작 시점에 데이터 변경을 하지 않는 구조는, 실패 케이스를 자연스럽게 허용하기 위한 의도적인 분리다.
따라서 이 함수는 데이터 변경을 전혀 하지 않고, 드래그 시작에 필요한 조건 검증과 시각적 준비만 담당한다.
마우스 버튼을 검사하는 이유는 좌클릭 드래그와 우클릭 드래그의 정책이 다르기 때문이다.
입력 장치의 종류에 따라 동작 정책을 분기하는 것은 UI 레벨의 책임이며, 데이터 계층으로 이 조건을 전달하지 않는다.
이는 입력 해석과 데이터 정책을 분리하기 위한 구조다.
slotData가 null이거나 비어 있는 경우 즉시 반환하는 것은 UI 차원의 방어가 아니라 데이터 규칙 보호다.
드래그 출발점은 반드시 실제 아이템이 존재하는 슬롯이어야 하며, 그렇지 않으면 이후 모든 로직은 의미가 없다.
이 검사는 입력 단계에서 불완전한 이벤트를 정제하는 1차 필터 역할을 한다.
슬롯 자체를 이동시키지 않고 별도의 드래그 아이콘을 사용하는 이유는 레이아웃 안정성 때문이다.
인벤토리 슬롯은 Grid Layout이나 Horizontal/Vertical Layout Group 하위에 배치될 수 있는데, 슬롯 오브젝트 자체를 이동시키면 레이아웃 시스템이 강제로 위치를 재계산해 UI가 흔들릴 수 있다.
따라서 시각적 복제 아이콘만 이동시키는 방식으로 구조적 안정성을 유지했다.
여기서 중요한 설계 선택은 드래그 아이콘을 static으로 한 번만 생성해 재사용한다는 점이다.
드래그는 인벤토리에서 매우 빈번하게 발생하는 입력이다.
매번 GameObject를 Instantiate하고 Destroy하면 GC와 메모리 단편화가 발생할 수 있다.
따라서 한 번 생성한 아이콘을 활성화/비활성화 방식으로 재사용해 비용을 줄였다.
이 방식은 GC 발생을 최소화하고 드래그 UX의 일관성을 유지한다는 장점이 있다.
다만 이 구현은 전제 조건을 가진다.
드래그 아이콘은 rootCanvas 기준으로 배치된다.
현재 프로젝트에서는 인벤토리 UI가 단일 루트 Canvas 하위에 존재하기 때문에 안전하다.
그러나 팝업 전용 Canvas가 분리되어 있거나 씬마다 Canvas 구조가 달라지는 프로젝트에서는 rootCanvas 기준이 꼬일 수 있다.
이 구조는 인벤 UI는 단일 루트 Canvas라는 전제 하에서 안정적으로 동작한다는 점을 명확히 인지하고 있다.
Image의 raycastTarget을 false로 설정한 것은 EventSystem과의 충돌을 방지하기 위함이다.
드래그 아이콘이 Raycast 대상이 되면, 실제 드롭 대상 슬롯이 아니라 드래그 아이콘이 이벤트를 가로채 OnDrop이 호출되지 않을 수 있다.
이 설정은 입력 전달 경로를 의도적으로 정제하는 안전장치다.
4.3. 드래그 이동 및 종료 처리
OnDrag와 OnEndDrag는 마우스 위치에 맞춰 드래그 아이콘을 이동시키고, 드래그가 종료되면 아이콘을 숨긴다.
public void OnDrag(PointerEventData eventData)
{
if (dragIconObj != null && dragIconObj.activeSelf)
{
dragIconRT.position = eventData.position;
}
}
OnDrag에서는 PointerEventData.position 값을 그대로 RectTransform.position에 적용한다.
eventData.position은 스크린 좌표 기준 값이며, 인벤토리 Canvas가 Screen Space Overlay 또는 Screen Space Camera 모드이기 때문에 추가 좌표 변환 없이 그대로 사용할 수 있다.
만약 World Space Canvas였다면 좌표 변환이 필요했을 것이다.
현재 UI 모드에 맞는 구현이라는 점을 인지하고 있다.
public void OnEndDrag(PointerEventData eventData)
{
if (dragIconObj != null)
{
dragIconObj.SetActive(false);
}
}OnEndDrag는 드래그 아이콘을 비활성화할 뿐, 데이터에는 접근하지 않는다. 드래그 종료는 드롭 성공을 의미하지 않는다.
유효하지 않은 영역에서 종료될 수 있으며, 이 경우 아무 일도 일어나지 않는 것이 정상이다.
데이터 변경은 오직 OnDrop에서만 수행되도록 설계해, 입력 연출 단계와 실제 상태 변경 단계를 명확히 분리했다.
4.4. 드롭 처리와 슬롯 이동 판단
public void OnDrop(PointerEventData eventData)
{
var fromSlot = eventData.pointerDrag?.GetComponent<InventorySlot>();
if (fromSlot == null) return;
if (fromSlot == this) return;
if (fromSlot.slotData == null || fromSlot.slotData.IsEmpty) return;
if (eventData.button == PointerEventData.InputButton.Left)
{
SlotManager.instance.SwapOrMerge(fromSlot.SlotIndex, this.SlotIndex);
}
if (eventData.button == PointerEventData.InputButton.Right)
{
...다음 게시글
}
}
OnDrop은 드래그 흐름에서 유일하게 데이터 로직으로 진입하는 지점이다.
eventData.pointerDrag는 EventSystem이 드래그 시작 시 기록해 둔 원본 UI 오브젝트다.
별도의 전역 상태 없이도 드래그 출발 슬롯을 정확히 추적할 수 있다는 점이 EventSystem 기반 구조의 큰 장점이다.
가장 먼저 fromSlot가 null인지 검사하고 즉시 종료하는 이유는, 드롭 이벤트가 발생했다고 해서 항상 유효한 인벤토리 슬롯에서 시작된 드래그가 보장되지는 않기 때문이다.
EventSystem의 pointerDrag는 현재 드래그 중인 오브젝트를 참조하지만, 드래그 출발점이 슬롯이 아닌 다른 UI였거나, 드래그 도중 오브젝트가 파괴·비활성화되었거나, 혹은 드롭 시점에 참조가 끊긴 경우에는 GetComponent<InventorySlot>() 결과가 null이 될 수 있다.
이 상황에서 이후 로직을 진행하면 인덱스를 읽는 순간 NullReferenceException이 발생하므로, 이 검사는 입력 이벤트의 불완전성을 안전하게 차단하는 1차 방어선이다.
그 다음 fromSlot이 this인지를 검사해 종료하는 것은 자기 자신에 드롭이라는 무의미한 케이스를 제거하기 위함이다.
드래그 UI 관점에서는 같은 슬롯 위로 다시 떨어뜨리는 상황이 자연스럽게 발생할 수 있지만, 데이터 관점에서는 fromIndex와 toIndex가 동일한 이동 요청이 된다.
이 요청을 그대로 통과시키면, 병합·스왑 판단 함수가 불필요하게 호출되거나, 정책에 따라서는 같은 슬롯에 대한 중복 연산으로 이어질 수 있다.
따라서 이 줄은 로직 안정성뿐 아니라 불필요한 연산을 줄이는 최적화이기도 하고, 무엇보다 의미 없는 입력은 초기에 끊는다는 입력 정제 단계다.
마지막으로 fromSlot.slotData가 null이거나 비어 있는지 확인하여, 드래그 출발 슬롯이 실제로 아이템을 들고 있는가를 데이터 기준으로 확정한다.
드래그 시작 시점에 이미 비어 있지 않은지 체크했더라도, 드래그 도중 다른 로직(예: 자동 정렬, 소비, 외부 시스템의 RemoveItem)이 개입하면 슬롯이 비워질 수 있다.
UI는 현재 드래그 아이콘을 보여주고 있어도, 실제 데이터는 이미 사라졌을 수 있다.
이 조건은 그런 시간차로 인해 발생하는 아이템이 없는 드래그를 데이터 단계에서 다시 한 번 차단함으로써, 슬롯 이동이 실제 데이터가 존재할 때만 발생하도록 보장한다.
이후 두 슬롯의 인덱스를 전달해 단일 진입점인 SwapOrMerge로 위임한다.
4.5. 슬롯 이동 단일 진입점
public void SwapOrMerge(int fromIndex, int toIndex)
{
if (fromIndex == toIndex) return;
if (!InventoryManager.instance.TryMergeStack(fromIndex, toIndex))
{
InventoryManager.instance.SwapSlots(fromIndex, toIndex);
}
PoplateSlots();
}
SwapOrMerge는 슬롯 이동과 병합을 하나의 규칙으로 통합한 함수다.
먼저, 같은 인덱스 간 이동은 의미가 없기 때문에 즉시 종료한다.
이 방어 코드는 상위에서 잘못된 호출이 들어와도 로직이 깨지지 않도록 한다.
먼저 병합을 시도하고, 병합이 실패한 경우에만 스왑을 수행한다.
이 흐름 자체가 동일 아이템이면 병합 우선이라는 정책을 코드 구조로 고정한다.
데이터 변경이 모두 끝난 뒤 UI를 갱신함으로써, UI는 항상 최종 확정된 상태만 반영한다.
4.6. 스택 병합 판단 및 처리
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;
}
TryMergeStack은 병합이 가능한지를 판단하고, 가능한 경우에만 실제 병합을 수행하는 함수다.
슬롯 참조가 유효한지, 실제로 옮길 아이템이 존재하는지를 먼저 확정한다.
병합은 데이터 변경을 수반하기 때문에, 전제 조건이 하나라도 어긋나면 즉시 중단한다.
두 슬롯 모두 아이템을 가지고 있어야 하며, ItemId 기준으로 동일한 아이템일 때만 병합을 허용한다.
ItemId 기준 비교를 사용하는 이유는, 동일 아이템이 로딩·복제·세이브 복원 과정에서 다른 인스턴스로 존재할 수 있기 때문이다.
Id 기반 비교는 이러한 경우에도 안정적으로 동작한다.
대상 슬롯에 실제로 여유 공간이 있는지를 수치로 계산한다.
이미 최대 스택이라면 병합은 불가능하다.
이동 가능한 최대 수량만 계산해 병합을 수행한다.
병합 수량 계산에서 Mathf.Min(space, from.count)를 사용하는 것은 매우 중요한 안전장치다.
이 한 줄을 통해 대상 슬롯의 최대 스택을 초과하는 상황과, 출발 슬롯의 수량이 음수가 되는 상황을 동시에 차단한다.
별도의 복잡한 조건 분기 없이 이동 가능한 최대 수량만 계산함으로써, 병합 로직을 단순하면서도 안전하게 유지할 수 있다.
병합 이후 출발 슬롯의 수량이 0 이하가 되면 Clear()를 호출해 슬롯을 완전히 초기화한다.
이 방식은 슬롯 상태 변경 경로를 단일화해, 이후 UI 갱신이나 추가 로직에서도 일관된 상태를 유지하게 해준다.
4.7. 슬롯 스왑 처리
public void SwapSlots(int a, int b)
{
if (a == b) return;
var slotA = slots[a];
var slotB = slots[b];
var tempData = slotA.data;
var tempCount = slotA.count;
slotA.data = slotB.data;
slotA.count = slotB.count;
slotB.data = tempData;
slotB.count = tempCount;
}
스왑은 슬롯 오브젝트 자체를 교체하지 않고, 슬롯 내부의 데이터만 교환하는 방식으로 구현되었다.
이 방식은 UI 구조와의 결합도를 크게 낮춘다.
슬롯 UI는 항상 동일한 슬롯 인스턴스를 참조하며, 데이터만 변경되기 때문에 장착 표시, 쿨타임 UI, 툴팁 같은 슬롯 종속 UI 요소들이 흔들리지 않는다.
4.8. UI 재바인딩
public void PoplateSlots()
{
var list = inventoryManager.Slots;
for (int i = 0; i < slots.Count; i++)
{
if (i < list.Count && list[i] != null && !list[i].IsEmpty)
{
slots[i].SetItem(list[i]);
}
else
{
slots[i].NullSet();
}
}
}
이 함수는 단순한 UI 갱신 함수가 아니다.
이 함수는 데이터를 기준으로 UI를 다시 그리는 단일 진입점이다.
슬롯 이동, 병합, 분할, 쿨타임 변경 등 어떤 데이터 변경이 발생하더라도, 마지막에 PoplateSlots를 호출하면 UI는 항상 데이터 상태를 반영한다.
UI 내부 상태를 개별적으로 수정하지 않고, 항상 데이터 전체를 다시 바인딩하는 구조이기 때문에 중간 상태 불일치가 발생하지 않는다.
이 방식의 장점은 명확하다.
UI가 데이터 변경 과정을 추적하지 않아도 된다.
데이터는 InventoryManager에서만 변경되고, UI는 오직 현재 상태를 읽어서 표현만 한다.
이 설계는 인벤토리 시스템의 핵심 철학과 일치한다. UI는 판단하지 않는다. 표현만 한다.
5. 개발 의도
이 게시글에서 다루고자 한 핵심은, 드래그 앤 드롭이라는 사용자 입력을 데이터 규칙 위에 안전하게 얹는 방법이다.
드래그는 시각적으로 자유로운 입력이지만, 그 결과는 반드시 엄격한 규칙을 따라야 한다.
UI는 드래그를 표현하고, 데이터는 결과를 판단한다.
이 책임 분리를 통해 슬롯 이동, 병합, 스왑이라는 복잡한 상호작용을 단순한 규칙으로 정리할 수 있었다.
이 구조 덕분에 이후 게시글에서 다루게 될 스택 분할, 우클릭 기반 나누기 UI, 툴팁과 클릭 인터랙션 역시 기존 로직을 건드리지 않고 자연스럽게 확장할 수 있었다.
