SO에 대해서 몇번 TIL로 작성하며 정리한적도 있고 하지만 요즘 기본을 좀 더 탄탄하게 다지는게 좋을것같다는 생각이 들어 다시 한번 자세히 정리해보려고 한다.
ScriptableObject는 Unity 에디터 상에서 쉽게 관리 가능한 에셋 형태의 객체이며, 다음과 같은 장점을 가진다.
- 런타임 데이터와 에디터 데이터를 명확히 분리하여 관리할 수 있다.
- Inspector를 통한 직관적 데이터 세팅이 가능해 디자이너나 기획자와 협업이 용이해진다.
- Prefab이나 Scene에 귀속되지 않고 독립적인 데이터 리소스로 활용 가능하며, 재사용성이 뛰어나다.
- 주소값으로 참조를 공유하기 때문에 메모리 사용 측면에서도 유리하고, 상태 공유가 가능하다.
- 또한 에셋형태이기때문에 플레이모드 도중 수정하여도 내용이 저장되는것으로 알고있다 (시네머신도 SO를 사용하기때문에 플레이모드에서 수정한 내용이 저장됨)
- 오딘과 함께 사용하면 "자 이게 클릭이야" 를 줄일 수 있다.
특히 규모가 커지는 프로젝트에서 ScriptableObject 기반으로 게임 밸런싱 데이터를 관리하면,
코드를 수정하지 않고도 에디터를 통해 체력, 공격력, 이동 속도, 경험치 테이블, 아이템 효과 등의 값을 손쉽게 조정할 수 있어 개발 효율과 유지보수성을 향상시킬 수 있다.
아래 예제는 ScriptableObject를 통한 게임 능력치 데이터 및 아이템 데이터를 관리하고, 이러한 데이터들을 실제 게임 로직에서 주입받아 활용하는 방식을 보여준다. 또한, 단순히 데이터 관리 차원을 넘어, ScriptableObject로 팩토리(Factory) 패턴이나 Strategy 패턴을 구현해 객체 생성 및 행동 방식을 유연하게 만드는 구조를 예시로 든다.
예제의 시나리오
- 플레이어 캐릭터가 다양한 능력치(Health, Mana, MoveSpeed)를 가진다고 가정한다. 이 때 플레이어의 능력치는 ScriptableObject로 정의한 PlayerStatsData에서 관리한다.
- 아이템 시스템에서 ItemData ScriptableObject를 통해 아이템 속성(아이템 이름, 효과, 아이콘)을 관리한다.
- 무기 공격 로직을 구현할 때, 각 무기의 공격 특성을 ScriptableObject로 구현한 WeaponData를 통해 관리하고, 이를 WeaponFactory(마찬가지로 ScriptableObject 기반)에서 참조해 런타임에 무기 인스턴스를 생성하거나 공격 전략을 결정한다.
예제
using UnityEngine;
// 플레이어 능력치 관리용 ScriptableObject
[CreateAssetMenu(fileName = "PlayerStatsData", menuName = "GameData/PlayerStats")]
public class PlayerStatsData : ScriptableObject
{
[Header("기본 스탯")]
public int baseHealth = 100;
public int baseMana = 50;
public float moveSpeed = 5f;
// 레벨업 곡선, 경험치 테이블 등을 포함해 확장 가능
}
using UnityEngine;
[CreateAssetMenu(fileName = "ItemData", menuName = "GameData/Item")]
public class ItemData : ScriptableObject
{
public string itemName;
public Sprite icon;
public string description;
public bool isConsumable;
}
using UnityEngine;
public enum WeaponType { Melee, Ranged }
[CreateAssetMenu(fileName = "WeaponData", menuName = "GameData/Weapon")]
public class WeaponData : ScriptableObject
{
public WeaponType weaponType;
public string weaponName;
public int damage;
public float attackRange;
public float attackSpeed;
}
public interface IWeapon
{
void Attack();
string GetWeaponName();
}
using UnityEngine;
public class Sword : IWeapon
{
private WeaponData _data;
public Sword(WeaponData data)
{
_data = data;
}
public void Attack()
{
Debug.Log($"{_data.weaponName} 공격! 근접 범위 {_data.attackRange} 내 적 타격, 피해량: {_data.damage}");
}
public string GetWeaponName()
{
return _data.weaponName;
}
}
using UnityEngine;
public class Gun : IWeapon
{
private WeaponData _data;
public Gun(WeaponData data)
{
_data = data;
}
public void Attack()
{
Debug.Log($"{_data.weaponName} 공격! 원거리 공격. 공격 속도: {_data.attackSpeed}, 피해량: {_data.damage}");
}
public string GetWeaponName()
{
return _data.weaponName;
}
}
using UnityEngine;
// WeaponFactory는 무기 데이터를 기반으로 무기 인스턴스를 생성하는 로직을 관리
[CreateAssetMenu(fileName = "WeaponFactory", menuName = "GameData/WeaponFactory")]
public class WeaponFactory : ScriptableObject
{
[Header("생성 가능한 무기들")]
public WeaponData[] weaponDatas;
// 무기 이름으로 무기 생성
public IWeapon CreateWeapon(string weaponName)
{
WeaponData data = FindWeaponData(weaponName);
if (data == null)
{
Debug.LogError($"무기 데이터 {weaponName}를 찾지 못했습니다.");
return null;
}
switch (data.weaponType)
{
case WeaponType.Melee:
return new Sword(data);
case WeaponType.Ranged:
return new Gun(data);
default:
Debug.LogError("알 수 없는 무기 타입입니다.");
return null;
}
}
private WeaponData FindWeaponData(string weaponName)
{
foreach (var wd in weaponDatas)
{
if (wd.weaponName == weaponName)
return wd;
}
return null;
}
}
using UnityEngine;
using Zenject;
// 플레이어는 PlayerStatsData와 WeaponFactory를 주입받고, 이 데이터를 활용해 동작
public class PlayerController : MonoBehaviour
{
private PlayerStatsData _playerStatsData;
private WeaponFactory _weaponFactory;
private IWeapon _currentWeapon;
private float _currentMoveSpeed;
private int _currentHealth;
private int _currentMana;
[Inject]
public void Construct(PlayerStatsData playerStatsData, WeaponFactory weaponFactory)
{
_playerStatsData = playerStatsData;
_weaponFactory = weaponFactory;
}
private void Start()
{
// ScriptableObject 데이터를 기반으로 초기 상태 설정
_currentHealth = _playerStatsData.baseHealth;
_currentMana = _playerStatsData.baseMana;
_currentMoveSpeed = _playerStatsData.moveSpeed;
// 초기 무기 설정 (Sword)
_currentWeapon = _weaponFactory.CreateWeapon("Sword");
}
private void Update()
{
// 이동 (간단한 예시)
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
transform.Translate(new Vector3(h, 0, v) * _currentMoveSpeed * Time.deltaTime);
// 공격
if (Input.GetMouseButtonDown(0) && _currentWeapon != null)
{
_currentWeapon.Attack();
}
// 1번 키로 총 변경
if (Input.GetKeyDown(KeyCode.Alpha1))
{
_currentWeapon = _weaponFactory.CreateWeapon("Gun");
Debug.Log("무기 교체: Gun");
}
// 체력 감소 테스트 (H키)
if (Input.GetKeyDown(KeyCode.H))
{
TakeDamage(10);
}
}
private void TakeDamage(int damage)
{
_currentHealth = Mathf.Max(0, _currentHealth - damage);
Debug.Log($"플레이어 피해: 현재 체력 {_currentHealth}");
}
}
using Zenject;
using UnityEngine;
public class GameInstaller : MonoInstaller
{
[SerializeField] private PlayerStatsData playerStatsData;
[SerializeField] private WeaponFactory weaponFactory;
public override void InstallBindings()
{
// ScriptableObject 주입
Container.Bind<PlayerStatsData>().FromInstance(playerStatsData).AsSingle();
Container.Bind<WeaponFactory>().FromInstance(weaponFactory).AsSingle();
}
}
코드 구조 및 패턴 분석
- ScriptableObject를 통한 데이터 관리:
- PlayerStatsData, WeaponData, ItemData 등 다양한 게임 관련 데이터를 ScriptableObject로 관리하여, 코드 수정 없이 Inspector에서 변경 및 관리가 가능하다.
- 팩토리 패턴(Factory Pattern)과 ScriptableObject 결합:
- WeaponFactory ScriptableObject를 통해 무기 인스턴스를 생성하는 로직을 캡슐화.
- 무기 데이터가 변경되면 Factory는 해당 데이터를 기반으로 무기를 생성하기 때문에 코드를 수정하지 않고도 무기 밸런스, 속성 수정 가능.
- 의존성 주입(Dependency Injection):
- Zenject를 통해 PlayerController에 PlayerStatsData와 WeaponFactory를 주입.
- Scene이나 Prefab에 종속되지 않고 데이터 구조를 모듈화하여 유지보수성 향상.
- OOP 설계 원칙 반영:
- 인터페이스(IWeapon)를 통해 무기 타입을 추상화.
- ScriptableObject를 활용해 구현체(검, 총)를 외부 데이터로 관리 → OCP(개방-폐쇄 원칙) 적용 용이.
- 실용성:
- 디자이너나 비프로그래머 팀원이 쉽게 밸런스를 조정.
- 새로운 무기, 아이템, 플레이어 스탯 구조가 필요할 때 ScriptableObject 에셋만 복제 및 수정하면 된다.
결론 및 학습 정리
오늘은 ScriptableObject를 활용하는 방법을 찾아보며 예제 프로젝트를 만들어보았다.
단순히 데이터 컨테이너에 그치지 않고, 팩토리 패턴, 전략 패턴 등을 ScriptableObject와 결합하여 다양한 방식으로 게임 시스템을 확장할 수 있음을 확인하였다.
- 런타임 데이터와 에디터 데이터를 분리해 관리할 수 있어 개발 효율성이 향상된다.
- 코드 수정 없이 데이터 에셋 수정만으로 게임 밸런싱이 가능하다.
- DI와 결합해 구조적 유연성을 강화하고, 책임 분리를 통해 유지보수성을 높일 수 있다.
이러한 ScriptableObject 활용 아키텍처 패턴은 규모가 큰 Unity 프로젝트에서 특히 유용하며, 팀원 간의 역할 분담과 워크플로우 개선에도 큰 도움이 된다.