개발 노트 — 데이터 아키텍처
데이터가 코드에 박혀있으면 생기는 문제
게임 개발 초반에는 이런 식으로 스탯을 코드에 직접 박아넣기 쉽습니다.
public class Enemy : MonoBehaviour
{
private float maxHp = 80f;
private float moveSpeed = 2f;
private float attackDamage = 5f;
}
몬스터가 하나뿐이라면 문제없습니다. 하지만 몬스터 종류가 10종, 20종으로 늘어나고, 밸런스 수치를 하루에도 몇 번씩 조정해야 하는 상황이 오면 문제가 커집니다.
- 수치 하나 바꾸려고 코드를 수정하고 재컴파일해야 합니다
- 기획자가 수치를 조정하고 싶어도 코드를 건드릴 수밖에 없습니다
- 몬스터 종류가 늘어날수록 비슷한 클래스가 계속 늘어납니다
이 문제를 푸는 방법이 ScriptableObject + Registry 패턴입니다.
1단계: 데이터를 ScriptableObject로 분리하기
ScriptableObject는 Unity에서 “코드가 아닌 에셋 형태로 데이터를 저장”할 수 있게 해주는 클래스입니다.
[CreateAssetMenu(fileName = "EnemyData", menuName = "Data/Enemy")]
public class EnemyData : ScriptableObject
{
public string enemyId;
public string displayName;
public float maxHp;
public float moveSpeed;
public float attackDamage;
}
이렇게 정의하면, Unity 에디터에서 “Create → Data → Enemy” 메뉴로 몬스터마다 데이터 에셋을 하나씩 만들 수 있습니다. 인스펙터 창에서 값을 바로 조정할 수 있고, 코드는 전혀 건드리지 않아도 됩니다.
몬스터를 스폰할 때는 이 데이터를 참조해서 초기화합니다.
public class Enemy : MonoBehaviour
{
private EnemyData _data;
private float _currentHp;
public void Initialize(EnemyData data)
{
_data = data;
_currentHp = data.maxHp;
}
}
Enemy 클래스 자체에는 수치가 하나도 없습니다. 전부 EnemyData라는 별도 에셋에서 가져옵니다.
2단계: Registry로 여러 데이터를 한곳에서 관리하기
몬스터 데이터가 개별 에셋으로 흩어져 있으면, “ID로 특정 몬스터 데이터를 찾고 싶을 때” 매번 참조를 일일이 연결해야 합니다. 이럴 때 Registry 패턴을 씁니다 — 여러 데이터를 한곳에 모아두고, ID로 조회할 수 있게 하는 역할입니다.
[CreateAssetMenu(fileName = "EnemyRegistry", menuName = "Data/EnemyRegistry")]
public class EnemyRegistry : ScriptableObject
{
public List<EnemyData> allEnemies;
private Dictionary<string, EnemyData> _lookup;
public EnemyData GetById(string enemyId)
{
if (_lookup == null)
{
_lookup = allEnemies.ToDictionary(e => e.enemyId, e => e);
}
return _lookup.TryGetValue(enemyId, out var data) ? data : null;
}
}
이제 몬스터 스포너는 Registry 하나만 참조하면, 어떤 몬스터든 ID로 바로 조회할 수 있습니다.
EnemyData data = enemyRegistry.GetById("CHR_001");
Enemy enemy = enemyFactory.Create(data, spawnPosition);
3단계: 스프레드시트와 연동하기 (선택)
팀에 기획자가 있다면, 기획자가 Unity 에디터를 직접 다루지 않고도 수치를 조정할 수 있게 하는 게 이상적입니다. 이때 CSV(스프레드시트) → ScriptableObject 변환기를 만들어두면 유용합니다.
기본 아이디어는 이렇습니다.
#if UNITY_EDITOR
public class CsvToSoConverter
{
[MenuItem("Tools/Convert CSV to EnemyData")]
public static void Convert()
{
string[] lines = File.ReadAllLines("Assets/Data/enemies.csv");
foreach (var line in lines.Skip(1)) // 헤더 제외
{
var columns = line.Split(',');
var data = ScriptableObject.CreateInstance<EnemyData>();
data.enemyId = columns[0];
data.displayName = columns[1];
data.maxHp = float.Parse(columns[2]);
// ...
AssetDatabase.CreateAsset(data, $"Assets/Data/Enemies/{columns[0]}.asset");
}
AssetDatabase.SaveAssets();
}
}
이렇게 해두면 기획자는 구글 시트나 엑셀에서 수치를 조정하고, CSV로 내보낸 뒤 메뉴 하나로 전체 데이터를 갱신할 수 있습니다. 이 스크립트는 #if UNITY_EDITOR로 감싸서, 실제 빌드에는 포함되지 않도록 하는 걸 잊지 말아야 합니다. (참고: 이 부분을 놓치면 어떤 문제가 생기는지는 [이전 글 — Unity Standalone 빌드 컴파일 에러]에서 다룬 적이 있습니다.)
이 구조로 얻는 것
- 기획-개발 분리: 수치 조정에 코드 재컴파일이 필요 없어집니다.
- 런타임 중 즉시 반영: 에디터에서 값을 바꾸면 플레이 모드에서 바로 확인할 수 있습니다.
- 확장성: 몬스터가 늘어나도 클래스를 새로 만들 필요 없이 데이터 에셋만 추가하면 됩니다.
- 테스트 용이성: 특정 데이터만 따로 만들어서 극단적인 케이스(초고데미지, 초저체력 등)를 쉽게 테스트할 수 있습니다.
이 패턴이 어울리지 않는 경우
모든 것을 ScriptableObject로 뺄 필요는 없습니다. 다음과 같은 경우엔 오히려 과설계가 될 수 있습니다.
- 게임 내내 절대 변하지 않는 상수값 (변경 가능성이 없다면 그냥 코드 상수로 충분합니다)
- 데이터 종류가 몇 개 안 되고, 확장 계획도 없는 경우
몬스터, 아이템, 스킬처럼 종류가 많고 자주 조정되며 팀원들이 함께 다뤄야 하는 데이터라면, 이 구조를 도입할 가치가 충분합니다.