데이터 초기화 및 슬롯 렌더링 구조

목차

1. 요구 사항

2. 설계 목표

3. 흐름도

4. 구현

        4.1. 상점 초기 진입점과 데이터 로딩

       4.2. 원본 데이터 보호와 정렬된 복제본 생성

       4.3. 정렬 보조 함수

       4.4. 카테고리 버튼에 따른 슬롯 재생성과 UI 바인딩

5. 개발 의도

1. 시스템 요구 사항

상점 시스템은 게임 내 존재하는 모든 판매 가능 아이템을 화면에 목록 형태로 출력해야 한다.

이때 아이템 데이터는 하드코딩된 값이 아니라 ScriptableObject 기반의 게임 데이터 에셋에서 동적으로 로드되어야 하며, 무기와 소비 아이템을 분리해 관리할 수 있어야 한다.

또한 상점 UI는 씬 진입과 동시에 초기화되어야 하고, 특정 카테고리 버튼(무기 / 물약)에 따라 판매 목록이 교체되어야 한다.

아이템 목록은 단순히 나열되는 것이 아니라, 아이디 기준으로 정렬된 상태로 표시되어야 하며, 슬롯 프리팹을 동적으로 생성하여 아이콘, 이름, 가격 정보를 세팅해야 한다.

이때 각 슬롯은 클릭 시 구매 패널을 열고 해당 아이템을 구매 대상으로 설정해야 한다.

이 시스템은 인벤토리와 직접 결합되지 않고, 상점은 오직 판매 목록 렌더링과 선택 이벤트 전달까지만 담당해야 한다.

2. 설계  목표

- ScriptableObject 기반 데이터 동적 로딩

- 카테고리별 아이템 배열 분리

- 아이디 기준 정렬 보장

- 슬롯 프리팹 기반 동적 UI 생성

- 버튼 클릭 시 구매 UI로 데이터 전달

- 상점과 인벤토리 시스템의 책임 분리

3. 흐름도

Start()

  ↓

Initialize()

  ↓

Resources.LoadAll()

  ↓

ScriptableObject.Instantiate()

  ↓

ID 기준 정렬

  ↓

WeaponBtnClick()

  ↓

슬롯 프리팹 Instantiate

  ↓

아이콘 / 이름 / 가격 세팅

  ↓

Button.onClick.AddListener()

상점은 Start 시점에 Initialize를 호출한다.

Initialize는 데이터를 로드하고, 정렬하고, UI 렌더링을 준비한다.

이 구조에서 핵심은 데이터 준비 단계와 UI 출력 단계를 명확히 분리했다는 점이다.

데이터는 먼저 완성된 배열 형태로 정리되고, 이후 버튼 클릭에 따라 해당 배열을 기반으로 슬롯을 생성한다.

4. 구현

4.1. 상점 초기 진입점과 데이터 로딩
private void Start()
{
    Initialize();
}

public void Initialize()
{
    EquipmentItmeData[] equipmentDataArray =
        Resources.LoadAll<EquipmentItmeData>("GameData/Item/Equipment");

    PotionItemData[] potionDataArray =
        Resources.LoadAll<PotionItemData>("GameData/Item/Potion");

    weapons = InstantiateSortedWeaponArrayById(equipmentDataArray);
    potions = InstantiateSortedPotionArrayById(potionDataArray);

    buyUiManager = buyPanel.transform.GetComponent<BuyUIManager>();

    WeaponBtnClick();
}

상점 시스템의 초기 진입점은 Start()이다.

Start는 Unity의 생명주기 함수로, GameObject가 활성화된 후 첫 프레임에 호출된다.

Awake보다 호출 시점이 늦기 때문에 씬 내 다른 오브젝트들이 Awake에서 준비한 상태를 기반으로 참조를 가져오거나 UI를 구성할 때 안전한 편이다.

상점에서는 Start에서 Initialize를 호출해, 씬 진입과 동시에 데이터 로딩과 초기 렌더링이 이루어지도록 한다.

Initialize 내부에서 사용한 Resources.LoadAll()는 Unity가 제공하는 런타임 리소스 로딩 API다.

지정된 Resources 경로 아래에 존재하는 타입 T의 에셋을 배열로 모두 로드한다.

이 방식의 장점은 인스펙터에 직접 레퍼런스를 박지 않아도 경로 규칙만 맞추면 데이터가 자동으로 수집된다는 점이며, 상점 품목이 늘어나더라도 코드 수정 없이 에셋 추가만으로 확장할 수 있다.

반면 Resources 폴더 기반 로딩은 프로젝트 규모가 커질수록 메모리 관리가 불리해질 수 있고, Addressables 같은 체계적 로딩 구조에 비해 리소스 제어가 제한적인 단점이 있다.

하지만 이 프로젝트는 상점 데이터 규모가 크지 않고, 구조 단순성과 개발 편의성을 우선하는 것이 합리적이기 때문에 Resources 방식을 선택했다.

로드된 데이터는 곧바로 UI에 사용되지 않고, 정렬 및 복제 과정을 거쳐 weapons와 potions라는 별도 배열로 정리된다.

이렇게 하면 데이터를 준비하는 단계와 데이터를 표시하는 단계가 분리되어 UI 로직이 정렬이나 원본 데이터 보존 같은 책임을 떠안지 않는다.

또한 마지막에 WeaponBtnClick을 한 번 호출해, 상점이 열렸을 때 기본적으로 무기 목록이 즉시 표시되도록 초기 렌더링을 수행한다.

이 초기 호출이 없으면, 사용자가 버튼을 누르기 전까지 판매 목록이 비어 있는 화면이 되어 UX가 불완전해질 수 있다.

4.2. 원본 데이터 보호와 정렬된 복제본 생성
EquipmentItmeData[] InstantiateSortedWeaponArrayById(EquipmentItmeData[] equipmentDataArray)
{
    EquipmentItmeData[] sortedWeaponArray = new EquipmentItmeData[equipmentDataArray.Length];   

    sortedWeaponArray[0] = ScriptableObject.Instantiate(equipmentDataArray[0]); 

    for (int i = 1; i < equipmentDataArray.Length; i++) 
    {
        sortedWeaponArray[i] = ScriptableObject.Instantiate(equipmentDataArray[i]); 

        for (int j = 0; j < i; j++) 
        {
            if (sortedWeaponArray[i].ItemId < sortedWeaponArray[j].ItemId) 
                SwapWeaponData(ref sortedWeaponArray[i], ref sortedWeaponArray[j]);
        }
    }

    return sortedWeaponArray;
}

PotionItemData[] InstantiateSortedPotionArrayById(PotionItemData[] potionDataArray) 
{
    PotionItemData[] sortedPotionArray = new PotionItemData[potionDataArray.Length];    
   
    sortedPotionArray[0] = ScriptableObject.Instantiate(potionDataArray[0]);  

    for (int i = 1; i < potionDataArray.Length; i++)    
    {
        sortedPotionArray[i] = ScriptableObject.Instantiate(potionDataArray[i]);    
        
        for (int j = 0; j < i; j++) 
        {
            if (sortedPotionArray[i].ItemId < sortedPotionArray[j].ItemId) 
                SwapPotionData(ref sortedPotionArray[i], ref sortedPotionArray[j]);
        }
    }

    return sortedPotionArray;
}

상점에서 로드한 ScriptableObject는 프로젝트 전체에서 공유되는 원본 에셋이다.

이 원본을 상점 내부 로직에서 그대로 사용하다가 혹시라도 런타임에 값이 변경되면, 해당 변경이 에셋 참조를 공유하는 다른 시스템에도 파급될 위험이 생긴다.

그래서 이 시스템은 ScriptableObject.Instantiate를 사용해 런타임 복제본을 생성한 뒤, 그 복제본을 정렬해 상점 전용 데이터 배열로 만든다.

여기서 ScriptableObject.Instantiate는 UnityEngine.Object.Instantiate의 ScriptableObject 버전으로, 에셋을 런타임 메모리 상에서 복제하여 독립 인스턴스로 만든다.

장점은 상점이 데이터를 표시하거나 계산할 때 원본 에셋에 영향을 주지 않는다는 점이며, 상점 내부에서만 사용되는 안전한 데이터 집합을 만들 수 있다.

단점은 복제본을 만들기 때문에 메모리 사용량이 증가할 수 있다는 점이다.

그러나 상점 품목 수가 제한적이라면 이 비용은 매우 작고, 원본 데이터 보호 의도를 코드 구조로 보여주는 것이 더 중요하다.

정렬 로직은 형태상 삽입 정렬(insertion sort)에 가까운 구조다.

i번째 요소를 추가한 뒤 0~i-1 구간과 비교하면서 더 작은 ID가 발견되면 위치를 교환하는 방식으로, 결과적으로 ID 오름차순이 유지된다.

이 방식의 장점은 구현이 직관적이고, 상점 데이터처럼 규모가 크지 않은 배열에서는 충분히 간단하게 정렬된 표시 순서를 보장할 수 있다는 점이다.

단점은 아이템 수가 커질수록 비교 횟수가 늘어난다는 점인데, 이 프로젝트에서는 판매 품목이 제한적이므로 정렬의 일반성보다 코드 의도와 단순함을 우선했다.

무기와 물약을 별도의 함수로 분리한 이유는 타입 자체가 다르고, 데이터 소스 경로도 다르며, 상점에서 두 카테고리를 명시적으로 분리해서 관리하는 설계 의도를 코드 구조로 드러내기 위함이다.

4.3. 정렬 보조 함수
void SwapWeaponData(ref EquipmentItmeData a, ref EquipmentItmeData b)
{
    EquipmentItmeData temp = a;
    a = b;
    b = temp;
}

void SwapPotionData(ref PotionItemData a, ref PotionItemData b)
{
    PotionItemData temp = a;
    a = b;
    b = temp;
}

SwapWeaponData함수와 SwapPotionData함수는 정렬 보조 함수로, 무기/포션 아이템 데이터의 위치를 교환한다.

정렬 과정에서 위치 교환을 수행하기 위해 Swap 함수를 따로 분리했다.

이 함수는 정렬 알고리즘의 핵심이 아니라 교환이라는 반복되는 작업을 캡슐화해, 정렬 코드의 목적을 흐리지 않도록 만든다.

여기서 C#의 ref는 매개변수를 값 복사가 아니라 참조로 전달하여, 함수 내부에서 교환한 결과가 호출한 배열 요소에 그대로 반영되도록 만든다.

만약 ref 없이 값을 넘기면 함수 내부에서만 교환이 일어나고 원래 배열의 요소는 그대로 남기 때문에, 정렬이라는 목적을 달성할 수 없다.

ref의 장점은 이런 교환 로직을 간단하게 만들 수 있다는 점이고, 단점은 참조 전달 특성상 함수 호출이 데이터 변경을 수반하므로, 코드를 읽는 사람이 이 호출이 실제 상태를 바꾼다는 점을 인지해야 한다는 것이다.

따라서 Swap이라는 명확한 함수명과 함께 사용해 의도를 분명히 했다.

4.4. 카테고리 버튼에 따른 슬롯 재생성과 UI 바인딩
public void WeaponBtnClick()
{
    BuyPanelDeActive();
        
    foreach (Transform child in slotsPanel) Destroy(child.gameObject);  
    
    for (int i = 0; i < weapons.Length; i++)
    {
        int index = i;  
        GameObject slotWDInstance = Instantiate(slotPrefab, slotsPanel);    

        slotWDInstance.transform.Find("Icon").GetComponent<Image>().sprite = weapons[i].Icon;
        slotWDInstance.transform.Find("ItemName").GetComponent<TextMeshProUGUI>().text = weapons[i].ItemName;
        slotWDInstance.transform.Find("buyTxt").GetComponent<TextMeshProUGUI>().text = "구매 : " + weapons[i].PurchasePrice.ToString();

        slotWDInstance.GetComponent<Button>().onClick.AddListener(() => { BuyPanelActive(); buyUiManager.WeaponBuySell(weapons[index]); });
    }
}

public void PotionBtnClick()
{
    BuyPanelDeActive(); 
        
    foreach (Transform child in slotsPanel) Destroy(child.gameObject); 

    for (int i = 0; i < potions.Length; i++)   
    {
        int index = i;  
        GameObject slotWDInstance = Instantiate(slotPrefab, slotsPanel);  

        slotWDInstance.transform.Find("Icon").GetComponent<Image>().sprite = potions[i].Icon;
        slotWDInstance.transform.Find("ItemName").GetComponent<TextMeshProUGUI>().text = potions[i].ItemName;
        slotWDInstance.transform.Find("buyTxt").GetComponent<TextMeshProUGUI>().text = "구매 : " + potions[i].PurchasePrice.ToString();

        slotWDInstance.GetComponent<Button>().onClick.AddListener(() => { BuyPanelActive(); buyUiManager.PotionBuySell(potions[index]); });
        }
}

WeaponBtnClick 함수는 무기 버튼을 클릭하였을 때 호출되고, PotionBtnClick 함수는 포션 버튼을 클릭하였을 때 호출된다.

카테고리 버튼은 결국 해당 카테고리 배열을 기반으로 슬롯을 재렌더링한다는 동일한 책임을 가진다.

WeaponBtnClick과 PotionBtnClick은 데이터 배열만 다르고, 슬롯 생성과 세팅의 흐름은 동일하다.

카테고리 버튼 클릭 시 먼저 BuyPanelDeActive를 호출해 구매 패널을 닫는 이유는, 이전에 선택한 아이템의 구매 UI가 열린 상태에서 카테고리가 바뀌면, 현재 화면에 보이지 않는 아이템의 구매 패널이 남아 있는 UI 불일치가 생길 수 있기 때문이다.

따라서 목록이 바뀌는 순간 구매 패널을 강제로 닫아 UI 상태를 재정렬한다.

그 다음 slotsPanel의 자식들을 Destroy로 제거한다.

여기서 Destroy는 Unity에서 오브젝트를 즉시 제거하는 함수가 아니라, 해당 오브젝트를 파괴 예약 상태로 만들어 프레임 종료 시점에 실제로 제거되도록 한다.

이 구조에서 중요한 점은, 슬롯을 새로 생성하기 전에 기존 슬롯들을 모두 제거함으로써 중복 렌더링과 클릭 이벤트 중첩을 구조적으로 차단한다는 것이다.

DestroyImmediate를 사용하지 않은 이유는 에디터 전용 API이며 런타임 환경에서는 안전하지 않기 때문이다.

상점 목록은 카테고리 변경 시, 부분 교체가 아니라 전체 재구성을 선택했고, 화면에 실제로 존재하는 슬롯들이 곧 판매 목록의 단일 기준이 되도록 설계했다.

슬롯 생성은 Instantiate(slotPrefab, slotsPanel)을 사용한다.

Instantiate는 Unity 런타임 오브젝트 생성 API이며, 프리팹을 기반으로 UI 요소를 코드에서 동적으로 생성할 수 있게 한다.

이 방식의 장점은 슬롯의 시각적 구조를 코드가 아니라 프리팹에서 관리할 수 있다는 점이다.

즉, 슬롯에 들어가는 아이콘 위치나 텍스트 배치가 바뀌더라도, 코드가 수정되는 것이 아니라 프리팹만 수정하면 된다.

반면 단점은 많은 슬롯을 매번 생성 / 파괴하면 성능 비용이 커질 수 있다는 점인데, 상점 품목 수가 제한적이고 버튼 클릭 빈도가 높지 않다는 점을 고려해 단순한 재생성 방식을 택했다.

이 구조는 오브젝트 풀링을 도입하지 않아도 충분히 감당 가능한 규모이기 때문에 단순성과 가독성을 우선했다.

슬롯 내부 값 세팅은 transform.Find와 GetComponent를 통해 수행한다.

Transform.Find는 이름 기반 탐색이므로 호출 비용이 있고, GetComponent도 런타임 탐색 비용이 발생한다.

하지만 이 코드는 슬롯 생성 직후 한 번만 실행되는 초기화 단계이고, 매 프레임 반복되는 연산이 아니기 때문에 현재 규모에서는 충분히 합리적이다.

만약 품목 수가 크게 늘거나 UI 성능이 민감해진다면, 슬롯 프리팹에 전용 SlotView 스크립트를 붙여 참조를 캐싱하는 구조로 개선할 수 있다.

반복 생성이 많아질 경우, SlotView 컴포넌트에서 Image와 Text 참조를 Awake에서 캐싱하고 외부에서는 데이터만 주입하는 구조로 개선할 수 있다.

이 코드에서 int index = i; 를 따로 두는 이유는 C# 람다 캡처(closure) 특성 때문이다.

반복문 변수 i를 그대로 AddListener의 람다에서 참조하면, 버튼이 눌리는 시점에는 i가 이미 반복이 끝난 값으로 고정되어 모든 슬롯이 같은 인덱스를 가리키는 문제가 발생할 수 있다.

이는 람다가 값이 아니라 변수를 캡처하기 때문에 발생하는 지연 실행 특성이다.이 구조는 오브젝트 풀링을 도입하지 않아도 충분히 감당 가능한 규모이기 때문에 단순성과 가독성을 우선했다

이를 방지하기 위해 반복마다 index라는 지역 변수를 새로 만들어 캡처하게 했고, 결과적으로 각 슬롯의 클릭 이벤트는 자신이 생성된 시점의 인덱스를 정확히 유지한다.

슬롯 클릭 시에는 BuyPanelActive로 구매 패널을 켜고, BuyUIManager의 WeaponBuySell 혹은 PotionBuySell로 데이터를 전달한다.

이때 상점은 구매를 수행하지 않고, 구매 UI가 선택된 아이템을 표시할 수 있도록 데이터만 넘긴다.

즉, 상점은 판매 목록 렌더링과 선택 전달까지만 담당하고, 실제 구매 처리는 구매 UI 및 인벤토리 매니저 쪽에서 책임지도록 역할을 분리했다.

5. 개발 의도

이 상점 구조는 데이터 준비 단계와 UI 출력 단계를 명확히 분리하는 것을 목표로 설계했다.

상점은 아이템을 직접 수정하지 않으며, 인벤토리 내부 구조를 알 필요도 없다.

상점은 단지 판매 목록을 구성하고, 선택된 데이터를 구매 UI로 전달하는 역할만 수행한다.

ScriptableObject 복제, 수동 정렬, 동적 슬롯 생성, 이벤트 연결까지 모든 과정은 단순히 기능 구현이 아니라 데이터 안정성, 책임 분리, 예측 가능한 흐름을 유지하기 위한 설계 선택이다.

이 구조는 이후 아이템 종류가 늘어나더라도 동일한 흐름으로 확장 가능하며, 상점 UI 교체나 인벤토리 구조 변경에도 영향이 최소화되도록 설계하였다.