Unity ScriptableObject 아이템 데이터베이스와 인스펙터 툴 확장 구현법
Unity에서 ScriptableObject로 아이템 원본 데이터를 분리하고 GUID 기반 런타임 참조와 커스텀 인스펙터 검증을 적용하는 방법을 다룹니다. 중복 데이터와 잘못된 설정을 줄이는 실전 구조를 코드로 설명합니다.

핵심 요약
ScriptableObject 기반 아이템 데이터베이스는 변하지 않는 아이템 정의 데이터를 에셋으로 관리하고 인벤토리에는 itemId와 수량처럼 변하는 상태만 저장하는 구조다. 여기에 OnValidate()와 CustomEditor를 더하면 중복 ID, 누락된 아이콘, 잘못된 스택 수처럼 플레이 중 발견하기 쉬운 설정 오류를 에디터에서 먼저 막을 수 있다.
ScriptableObject 아이템 데이터베이스는 왜 필요한가?
아이템 이름, 아이콘, 가격, 최대 스택 수를 프리팹이나 인벤토리 슬롯마다 들고 있으면 같은 정보를 여러 곳에서 수정하게 된다. 밸런스 조정 시 누락이 생기고 세이브 데이터도 불필요하게 커진다.
| 구분 | 아이템 정의 데이터 | 인벤토리 런타임 상태 |
|---|---|---|
| 예시 | 이름, 아이콘, 등급, 가격, 최대 스택 | itemId, 수량, 내구도 |
| 변경 시점 | 에디터 제작·밸런싱 | 플레이 중 |
| 저장 방식 | ScriptableObject 에셋 | 세이브 파일 또는 서버 데이터 |
| 권장 참조 | 데이터베이스의 ID 조회 | ID 기반 직렬화 |
핵심은 아이템 정의를 한 번만 만들고 모든 시스템이 이를 공유하는 것이다. 인벤토리, 상점 UI, 퀘스트 보상, 드롭 테이블은 같은 ItemDefinition을 조회하면 된다.

Unity 아이템 데이터베이스를 어떻게 설계할까?
1. 아이템 정의 ScriptableObject 만들기
ItemDefinition에는 런타임에 바뀌지 않는 값만 둔다. itemId는 표시 이름이 아니라 안정적인 식별자이므로 이름 변경과 무관하게 유지해야 한다.
using UnityEngine;
public enum ItemRarity
{
Common,
Rare,
Epic,
Legendary
}
[CreateAssetMenu(menuName = "Game/Items/Item Definition")]
public sealed partial class ItemDefinition : ScriptableObject
{
[SerializeField] private string itemId;
[SerializeField] private string displayName;
[TextArea]
[SerializeField] private string description;
[SerializeField] private Sprite icon;
[Min(1)]
[SerializeField] private int maxStack = 1;
[Min(0)]
[SerializeField] private int sellPrice;
[SerializeField] private ItemRarity rarity;
public string ItemId => itemId;
public string DisplayName => displayName;
public Sprite Icon => icon;
public int MaxStack => maxStack;
public int SellPrice => sellPrice;
public ItemRarity Rarity => rarity;
}
소모품, 장비, 퀘스트 아이템의 필드가 크게 다르다면 처음부터 거대한 단일 클래스에 모든 옵션을 넣기보다 공통 필드만 유지하고 실제 요구가 생긴 시점에 타입을 분리하는 편이 관리하기 쉽다.
2. 데이터베이스에서 ID로 아이템 찾기
목록은 인스펙터에서 편하게 편집하고 게임 시작 시 Dictionary<string, ItemDefinition>으로 변환해 조회한다. 딕셔너리 조회의 평균 시간 복잡도는 다음과 같다.
using System;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Game/Items/Item Database")]
public sealed class ItemDatabase : ScriptableObject
{
[SerializeField] private List<ItemDefinition> items = new();
private Dictionary<string, ItemDefinition> byId;
public void Initialize()
{
byId = new Dictionary<string, ItemDefinition>(items.Count);
foreach (var item in items)
{
if (item == null || string.IsNullOrWhiteSpace(item.ItemId))
continue;
byId[item.ItemId] = item;
}
}
public bool TryGet(string itemId, out ItemDefinition item)
{
if (byId == null)
Initialize();
return byId.TryGetValue(itemId, out item);
}
}
동일 ID가 존재할 때 마지막 항목으로 덮어쓰는 동작은 런타임 오류를 숨긴다. 따라서 다음 단계에서 에디터 검증으로 중복을 명시적으로 잡아야 한다.
3. 인벤토리에는 ID와 상태만 저장하기
ItemDefinition 자체를 세이브 데이터에 직렬화하지 말고 itemId를 저장한다. 그러면 아이콘이나 설명을 바꿔도 기존 세이브는 같은 아이템을 가리킨다.
[System.Serializable]
public sealed class InventoryEntry
{
public string itemId;
public int amount;
public int durability;
}
public bool TryResolve(ItemDatabase database, out ItemDefinition item)
{
return database.TryGet(itemId, out item);
}
커스텀 인스펙터 검증은 어떻게 구현할까?
OnValidate()로 개별 아이템의 기본 오류 막기
OnValidate()는 인스펙터에서 값이 변경될 때 호출된다. 실행 파일의 게임 로직으로 쓰기보다 에디터 입력을 정리하는 용도로 제한하는 것이 안전하다.
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;
public sealed partial class ItemDefinition
{
private void OnValidate()
{
itemId = itemId?.Trim();
maxStack = Mathf.Max(1, maxStack);
sellPrice = Mathf.Max(0, sellPrice);
}
}
이 코드는 빈 ID나 중복 ID 자체를 해결하지 않는다. 빈 값의 자동 생성이나 중복 값의 자동 수정은 협업 중 예기치 않은 에셋 변경을 만들 수 있으므로 경고와 명시적인 수정 도구를 제공하는 편이 낫다.
데이터베이스 인스펙터에서 중복 ID를 표시하기
Editor 폴더에 아래 파일을 만들면 ItemDatabase 에셋 선택 시 중복과 누락을 확인할 수 있다.
#if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
[CustomEditor(typeof(ItemDatabase))]
public sealed class ItemDatabaseEditor : Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
var database = (ItemDatabase)target;
var ids = new HashSet<string>();
var errors = new List<string>();
foreach (var item in database.Items)
{
if (item == null)
{
errors.Add("비어 있는 아이템 참조가 있습니다.");
continue;
}
if (string.IsNullOrWhiteSpace(item.ItemId))
errors.Add($"{item.name}: itemId가 비어 있습니다.");
else if (!ids.Add(item.ItemId))
errors.Add($"중복 itemId: {item.ItemId}");
}
if (errors.Count == 0)
{
EditorGUILayout.HelpBox("아이템 데이터베이스 검증을 통과했습니다.", MessageType.Info);
return;
}
foreach (var error in errors)
EditorGUILayout.HelpBox(error, MessageType.Error);
}
}
#endif
위 코드에서 Items 접근자가 필요하다.
public IReadOnlyList<ItemDefinition> Items => items;
ItemDatabase에 이 한 줄을 추가하면 커스텀 에디터가 목록을 읽을 수 있다.

아이템 ID는 이름 대신 GUID를 써야 할까?
프로젝트 규모와 데이터 배포 방식에 따라 다르다.
| 방식 | 장점 | 주의점 | 적합한 경우 |
|---|---|---|---|
| 사람이 읽는 ID | 디버깅과 운영 확인이 쉽다 | 이름 규칙과 변경 정책이 필요하다 | 소규모·중규모 게임 |
| GUID 문자열 | 충돌 가능성이 매우 낮다 | 로그와 운영 데이터의 가독성이 낮다 | 라이브 서비스·외부 데이터 연동 |
| Unity 에셋 GUID | 에셋 이동에 강하다 | 런타임 직접 사용 구조는 별도 설계가 필요하다 | 에디터 파이프라인 도구 |
예를 들어 potion_health_small처럼 사람이 읽을 수 있는 ID도 충분히 실용적이다. 단, 출시 후 세이브 호환이 필요한 ID는 표시 이름 변경과 별개로 취급하고 함부로 바꾸지 않아야 한다.
자주 묻는 질문 (FAQ)
ScriptableObject를 런타임에 수정해도 되는가?
에디터 플레이 모드에서는 에셋이 바뀐 것처럼 보일 수 있지만 런타임 상태 저장 용도로 사용하면 안 된다. 수량, 내구도, 장착 여부는 별도 런타임 객체와 세이브 데이터에 둔다.
Resources.Load로 아이템을 매번 불러오면 안 되는가?
가능하지만 의존성과 로딩 범위를 추적하기 어렵다. 초기에는 ItemDatabase 하나를 명시적으로 참조하고 초기화하는 구조가 단순하다. Addressables가 실제로 필요한 로딩 규모가 생기면 그때 전환한다.
중복 ID를 자동으로 고치면 더 편하지 않은가?
협업 환경에서는 자동 수정이 세이브 호환을 깨거나 의도하지 않은 변경을 만들 수 있다. 오류를 표시하고 개발자가 수정하는 방식이 더 안전하다.
정리
ScriptableObject는 아이템 정의를 한 곳에 모으는 데 적합하고 인벤토리에는 ID와 상태만 저장하면 데이터 중복과 세이브 결합을 줄일 수 있다. Dictionary 조회와 인스펙터 검증만 추가해도 대부분의 아이템 데이터 오류를 게임 실행 전에 발견할 수 있다.
초기에는 단일 ItemDefinition과 ItemDatabase로 시작하자. 장비 옵션, 제작 재료, 로컬라이제이션처럼 실제로 다른 데이터 모델이 필요해질 때만 타입 분리나 Addressables 파이프라인을 확장하면 된다.



