본문 바로가기
Unity • C#

[Unity] 편리한 데이터 관리 - Data Scriptable Object

by SAENS 2023. 4. 21.

데이터를 수정하게 되면 거의 대부분 UI에 표시하기, 값에 따라 어떤 이벤트 발생시키기 등의 추가 작업이 동반 됩니다. Update함수를 통해 매 프레임마다 데이터의 값을 확인하고 반영하는 작업을 수행할 수 있지만, 이 방법이 권장되지 않는다는 것은 많은 분들이 아실 것입니다.

데이터 자체에 값 변경시 필요한 작업을 등록(Register)하고, 값을 변경할 때 자동으로 등록된 작업들을 수행하도록(Invoke)할 수 있다면, 또 데이터 자체를 인스펙터에서 블록 형태로 등록하고 관리할 수 있다면 굉장히 편리할 것입니다.

데이터 하나에 대해서 그 자체와, 변경에 대한 콜백을 담는 Action 객체를 또 하나의 객체로 묶어 관리하고, 그 객체를 Scriptable Object(이하 SO)로 만들면 이러한 목적을 달성할 수 있습니다.

필수 개념 : 객체지향, Generic, Component, ScriptableObject

목차

  1. Data<T>
  2. 타입을 명시한 하위 클래스
  3. 응용 - DataTextView<T>
  4. 응용 - PlayerPrefsData<T>

 

1. Data<T>

using UnityEngine;

public abstract class Data<T> : ScriptableObject where T : IEquatable<T>
{
    protected T v;
    public Action<T> onChange;
    public virtual T val {
        get {
            return v;
        }
        set {
            if((v != null || value != null) && v.Equals(value)) return;
            v = value;
            onChange.Invoke(v);
        }
    }
}

코드가 곧 내용, 코곧내입니다. 핵심은 값 v를 프로퍼티 val을 통해 접근하며, val의 set에서 onChange를 Invoke하는 구조라는 점입니다. 별 거 없죠?

프로퍼티 val 앞에 붙은 virtual 키워드는, 아래 4. 응용 - PlayerPrefsData<T> 에서 확인하실 수 있다시피 파생되는 클래스에서 val을 재정의할 수 있게 하기 위함입니다. (프로퍼티는 변수가 아니라 메서드입니다.)

추가로 setter에서 파라미터로 들어온 값이 기존 값과 일치하는 경우 업데이트를 건너뛰기 위해 T가 Eqaul(T) 함수를 가지도록 제네릭 T 타입이 IEquatable<T> 인터페이스를 구현한다는 조건을 추가했습니다.

 

2. 타입을 명시한 하위 클래스

클래스에 제네릭 표현이 있는 상태로는 유니티에서 SO로 써먹을 수 없습니다. 그래서 타입을 명시한 하위 클래스를 새로 만들어줘야 하는데, 다음과 같은 아주 짧은 코드를 가진 스크립트를 만들면 됩니다. 스크립트의 이름은 클래스의 이름과 동일해야 한다는 점, 아시죠?

using UnityEngine;

[CreateAssetMenu(menuName = "SAENS/DataSO/String")]
public class DataString : Data<string> {}
using UnityEngine;

[CreateAssetMenu(menuName = "SAENS/DataSO/Int")]
public class DataInt : Data<int> {}

이렇게 타입이 명시된 Data의 하위 클래스들을 만들고 나면 유니티의 Project 탭에서 다음과 같이 SO 인스턴스를 만들 수 있습니다.

데이터 하나에 SO 하나인 셈입니다. 그래서 사실 관리해야 하는 데이터가 많다면 귀찮아질 수 있습니다. 하지만 처음에만 잠깐 견딘다면 성능, 확장성, 재사용성 등의 커다란 이득을 볼 수 있습니다.

이제 Unity의 Projects 탭에서, 타입이 명시된 Data SO 파일을 만들어 이를 이용해 필요한 작업(Register, Invoke)을 수행하면 됩니다.

using UnityEngine;
using TMPro;
 
public class NameView
{
    [SerializeField] DataString name;
//  [SerializeField] Data<string> name; 으로 작성해도 무방합니다.
    
    void OnEnable() // 등록 Register
    {
    	name.onChange += OnChangeName;
    }  
    void OnDisable() // 해제 Unregister
    {
    	name.onChange -= OnChangeName;
    }
    
    [SerializeField] TMP_Text nameText;
    
    void OnChangeName(string value)
    {
    	nameText.text = value;
    }
}
using UnityEngine;

public class NameController
{
    [SerializeField] DataString name;
    
    void Start()
    {
    	ChangeName("SAENS");
    }
    
    void ChangeName(string n)
    {
    	name.val = n;
    }
}

DataString SO를 인스펙터에서 받아 View에서는 값이 변경될 때 수행할 UI 작업을 등록/해제하고, Controller에서는 이를 Invoke시킵니다.   Controller → Model(SO) ← View  이러한 의존성을 띄며 Controller와 View가 서로 의존하지 않은 채로 필요한 작업을 적절히 수행할 수 있습니다.

이 때, Controller에 해당하는 클래스를 따로 작성할 필요가 없을 수도 있습니다. 다음과 같이 유니티 기본 UI Button과 같이 UnityEvent 객체를 필드로 가지는 컴포넌트라면, 해당 필드에 SO도 등록할 수가 있기 때문입니다. 다음과 같이 Data<T>의 프로퍼티 val의 set 메서드를 등록할 수 있습니다. 이 경우에는 Button 컴포넌트의 onClick이 Controller 라고 볼 수 있겠네요.

 

3. 응용 - DataView<T>

기존에는 데이터가 코드상에서만 존재했기 때문에 데이터를 UI에 표시하려 할 때마다 비슷한 데이터끼리 묶어 한꺼번에 업데이트 해주기 위한 스크립트를 작성했었습니다. 하지만 이제 데이터가 Component화 되었기 때문에 (MonoBehaviour, Scriptable Object는 Component 클래스의 하위 클래스이기 때문에 Inspector에서 참조가 가능) 타입과 특정 동작만 정의한다면 데이터가 정확히 무엇인지 알 필요 없이 타입에 맞는 Data SO를 필드에 집어 넣기만 하면 알아서 UI에 표시해주는 기능을 만들 수 있습니다.

a) 다음과 같이 Data<T>를 상속받는, 추상 메서드 UpdateView를 등록/해제하는 작업만 정의한 추상 클래스를 만듭니다.

using UnityEngine;
using System;

public abstract class DataView<T> : MonoBehaviour where T : IEquatable<T>
{
    [SerializeField] Data<T> data;

    void OnEnable()
    {
        data.onChange += UpdateView;
        UpdateView(data.val);
    }

    void OnDisable()
    {
        data.onChange -= UpdateView;
    }
    
    protected abstract void UpdateView(T s);
}

b) 그 다음에는 UpdateView의 동작만을 정의한 DataTextView<T> 클래스를 만듭니다.

using UnityEngine;
using System;
using TMPro;

public abstract class DataTextView<T> : DataView<T> where T : IEquatable<T>
{
    [SerializeField] TMP_Text text;
    [SerializeField] string prefix;
    [SerializeField] string suffix;
    
    protected override void UpdateView(T s)
    {
    	text.text = $"{prefix}{s.ToString()}{suffix}";
    }
}

c) 필요한 타입만 명시한 채로 DataTextView{Type} 클래스들을 만듭니다. Data<T> 의 타입을 명시한 것과 같은 이유입니다. DataView<T>가 MonoBehaviour이지만 제네릭 표현이 있을 경우에는 인스펙터에서 사용할 수 없기 때문입니다.

public class DataTextViewString : DataTextView<string> {}
public class DataTextViewInt : DataTextView<int> {}

d) 마지막으로 다음과 같이 컴포넌트를 추가한 후 적절히 필드를 채워주면 완성입니다.

이 타입과 동작에 한해 앞으로 데이터가 무엇인지에 상관 없이 얼마든지 컴포넌트를 재사용할 수 있습니다. 또한 동작과 타입을 재정의하는 클래스를 작성하는 것 역시 위에서 보셨다시피 그리 어렵지 않습니다.

 

4. 응용 - PlayerPrefsData<T>

[Unity] PlayerPrefs - 암호화 및 임의 객체 저장에서는, PlayerPrefs에 JSON 변환과 암호화를 묻혀 좀더 유연하고 안전하게 PlayerPrefs를 사용할 수 있도록 하는 확장 코드를 소개했습니다. 해당 글의 기능을 사용하면서 Data<T>의 val 프로퍼티를 적절히 재정의하면 PlayerPrefs를 훨씬 편하게 관리할 수 있습니다.

using UnityEngine;
using System;

public class PlayerPrefsData<T> : Data<T> where T : IEquatable<T>
{
    [SerializeField] string key;
    [SerializeField] T defaultValue;

    public override T val
    {
        get => v == null ? PlayerPrefsExt.GetObject<T>(key, defaultValue) : v;
        set
        {
            base.val = value;
            PlayerPrefsExt.SetObject(key, v);
        }
    }
}
using UnityEngine;

[CreateAssetMenu(menuName = "SAENS/DataSO/Prefs/String")]
public class PlayerPrefsDataString : PlayerPrefsData<string> { }
using UnityEngine;

[CreateAssetMenu(menuName = "SAENS/DataSO/Prefs/Int")]
public class PlayerPrefsDataInt : PlayerPrefsData<int> { }

Data<T>의 자식오브젝트이기 때문에, 3. 응용 - DataView<T>에서 작성한 DataView<T>의 data field를 굳이 PlayerPrefsData<T> 타입으로 바꿔주지 않아도 다음과 같이 PlayerPrefsData SO를 바로 넣어줄 수 있습니다.

이제 HP 값을 수정하기만 해도 별 다른 작업 없이 PlayerPrefs에 암호화되어 저장됩니다.


이 기술(?)은 확장할 때 더 빛납니다. 이 글에 있는 응용 예제들은 제 프로젝트에 Data<T>가 적용된 사례를 일부만 소개했을 뿐입니다. 데이터와 뷰를 바인딩하는 일 뿐만 아니라 게임 로직, 네트워크 비동기 작업에서 Event처럼 사용될 수 있습니다. (Event에 값을 가진 객체 개념을 더한 것과 다름이 없기는 합니다.)

그래서 객체지향 개념을 제대로 모르신다면 오히려 더 헷갈리고 어려울 수 있지만, 잘 알고 실제로 적극 활용하고 있는 분들에게는 굉장히 유용할 것이라고 확신합니다.

+ 데이터를 한 번 더 감싸 에셋 형태로 만든 것이기 때문에 오버헤드가 발생합니다.

댓글