[Unity] ScriptableObject 활용하기

네,가능합니다 ㅣ 2024. 12. 11. 21:52

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 프로젝트에서 특히 유용하며, 팀원 간의 역할 분담과 워크플로우 개선에도 큰 도움이 된다.