[TIL] 37일 차 - 여러가지 Unity 구조 설계

ChangBeom·2025년 3월 19일

TIL

목록 보기
38/53
post-thumbnail

Unity에서 게임을 개발할 때, 처음에 구조를 잘 설계하면 유지보수성과 확장성이 크게 향상된다. 반대로 처음에 구조설계를 안하고 대충 개발하면 프로젝트가 진행될 수록 코드가 복잡해져 관리가 어려워지면서 협업할 때 효율성이 매우 떨어질 수 있다. 그래서 오늘은 여러가지 유용한 구조설계에 대해 알아보려고 한다.


[1. 커스텀 Enum 데이터 관리]

Enum을 활용하면 코드의 가독성이 높아지고, 값의 무결성을 유지할 수 있다. 특히 문자열이나 숫자 값으로 직접 비교하는 방식과 비교했을 때, 실수를 줄이고 유지 보수가 쉬워진다.

<장점>

  • 코드 자동완성을 활용할 수 있어 실수를 줄일 수 있다. (오타로 인한 버그 방지)
  • 가독성이 높아진다.
  • 데이터 타입이 명확하게 정의되므로 유지보수가 용이하다.

<단점>

  • 변경이 잦은 경우, Enum을 수정해야 하므로 코드 수정이 필요하다.
  • 확장이 필요한 경우 기존 Enum을 변경해야 하는 부담이 있다.

<예제>

namespace EnumTypes
{
    public enum AttackTypes { None, Melee, Range }
    public enum CardRanks { Normal, Special, Rare }
    public enum CardHowToUses { Normal, TargetGround, TargetEntity }
    public enum CardAfterUses { Discard, Destruct, Spawn }
    public enum GameFlowState { InitGame, SelectStage, Setting, Wave, EventFlow, Ending }
}

[2. 커스텀 Struct 데이터 타입 관리]

Struct는 Class와 다르게 값 타입이므로 힙이 아닌 스택 메모리에 저장되며, GC(가비지 컬렉션)의 부담이 줄어든다. 따라서 참조 타입인 클래스보다 메모리 할당이 빠르고, 값 복사가 이루어지므로 데이터의 변경이 독립적으로 이루어진다.

<장점>

  • 값 타입이므로 GC 부담이 적고, 성능이 중요한 경우 유리하다.
  • 불필요한 참조를 방지하여 데이터 일관성을 유지할 수 있다.

<단점>

  • Struct로 큰 데이터 구조를 만들면 오히려 성능이 저하된다.
  • 값 타입이기 때문에 변경 시 복사가 일어나 메모리 사용량이 증가할 수 있다.

<예시>

namespace Structs
{
    [Serializable]
    public struct AttackData
    {
        public AttackTypes attackType;
        public int attackAnimationIndex;
    }

    [Serializable]
    public struct StatModifierData
    {
        public StatTypes statType;
        public ModifierTypes modifierType;
        public float value;
    }
}

[3. 유틸리티 함수 관리]

유틸리티 함수는 자주 사용되는 기능을 하나의 클래스로 모아서 재사용성을 높이는 방식이다. 일반적으로 static 클래스로 정의하여 인스턴스 생성을 방지하고, 전역적으로 접근할 수 있도록 한다.

<장점>

  • 중복 코드가 줄어들고 유지보수가 쉬워진다.
  • 인스턴스를 만들 필요 없이 어디서든 접근 가능하다.
  • 자주 사용하는 기능을 모듈화하여 코드의 가독성이 높아진다.

<단점>

  • static 함수로만 구성되므로 다형성을 활용하기 어렵다.
  • 전역적으로 접근할 수 있어 남용할 경우 의존성이 증가할 위험이 있다.

<예시>

public static class Utils
{
    public static float DirectionToAngle(float x, float y)
    {
        return Mathf.Atan2(y, x) * Mathf.Rad2Deg;
    }

    public static int GenerateID<T>()
    {
        return Animator.StringToHash(typeof(T).Name);
    }
}

[4. 글로벌 변수 관리]

전역 변수를 한 곳에서 관리하면 여러 곳에서 동일한 값을 사용할 때 유지보수가 쉬워진다. 하지만 변하는 값을 전역으로 관리하면 프로그램의 흐름을 예측하기 어려워지므로, 반드시 불변 값만 글로벌 변수로 지정해야 한다.

<장점>

  • 동일한 값을 여러 곳에서 사용할 때 일관성을 유지할 수 있다.
  • 값 변경이 불가능하므로 의도하지 않은 수정을 통한 버그 유발을 방지할 수 있다.

<단점>

  • 너무 많은 글로벌 변수를 사용하면 관리가 어려워질 수 있다.
  • 값을 변경해야 할 경우 코드 수정이 필요하다.

<예시>

public static class Globals
{
    public const int WorldSpaceUISortingOrder = 1;
    public static class LayerName
    {
        public static readonly string Default = "Default";
        public static readonly string UI = "UI";
    }
}

[5. 생성될 객체들을 관리하는 관리자 클래스]

Unity에서는 게임의 다양한 오브젝트를 효율적으로 관리하기 위해 관리자 클래스를 도입하는 것이 일반적이다. GameManager 를 최상위 관리자로 두고, 하위 관리자 클래스를 계층적으로 배치하면 모든 오브젝트 간 관계를 명확하게 정리할 수 있다.

<장점>

  • 게임의 핵심 관리 구조를 명확하게 구성할 수 있다.
  • 하위 관리자들이 상위 관리자와 명확한 관계를 유지하므로 유지보수가 쉬워진다.
  • 필요할 때만 특정 관리자에 접근하여 데이터를 효율적으로 처리할 수 있다.

<단점>

  • 계층 구조가 깊어질 경우 접근 방식이 복잡해질 수 있다.
  • 잘못된 설계 시 특정 관리자에 과도한 역할이 집중될 위험이 있다.

<예시>

public class GameManager : MonoBehaviour
{
    [SerializeField] private CharacterManager _characterManager;
    [SerializeField] private UIManager _uiManager;
}

public class CharacterManager : MonoBehaviour
{
    private GameManager _gameManager;
    public void Init(GameManager gameManager)
    {
    	_gameManager = gameManager;
    }
}

[6. Scene 전환과 관계없이 유지되는 싱글톤 객체 관리]

Scene이 변경되더라도 유지되어야하는 사용자 설정, 계정 정보, 네트워크 상태 같은 데이터는 DonDestroyOnLoad 를 사용하여 객체가 Scene전환 시에도 삭제되지 않도록 설정할 수 있다. 이를 관리하기 위해 일반적으로 싱글톤(Singleton)패턴 을 활용한다.

<장점>

  • Scene 변경 시에도 특정 데이터를 유지할 수 있어 게임 흐름이 끊기지 않는다.
  • 전역적으로 접근할 수 있어 관리가 편리하다.
  • 여러 클래스에서 동일한 객체를 공유할 수 있어 데이터의 일관성을 유지할 수 있다.

<단점>

  • 싱글톤을 과하게 사용하면 객체 간 결합도가 높아지고 유지보수가 어려워진다.
  • 테스트 및 멀티스레딩 환경에서 동기화 문제가 발생할 수 있다.

<예시>

public class GameInstance : Singleton<GameInstance>
{
    private LogGUI _logGUI;
    private DebugStatGUI _debugStatGUI;
    private ScriptableObjects.GamePrefabs _gamePrefabs;
    private HttpManager _httpManager;
}

public abstract class Singleton<T> : MonoBehaviour where T : Component
{
    private static T instance;

    public static T Instance
    {
        get
        {
            if (instance == null)
            {
                instance = FindObjectOfType<T>();
                if (instance == null)
                {
                    GameObject obj = new GameObject { name = typeof(T).Name };
                    instance = obj.AddComponent<T>();
                }
            }
            return instance;
        }
    }

    protected virtual void Awake()
    {
        if (instance == null)
        {
            instance = this as T;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }
}

이렇게 하면 GameInstance 라는 객체는 Scene이 변경되더라도 유지된다. 하지만 모든 데이터를 싱글톤으로 관리하면 결합도가 높아지고 유지보수가 어려워질 수 있으므로, 필요한 경우에만 싱글톤 패턴을 적용하는 것이 중요하다.


[7. ScriptableObject를 활용한 데이터 관리]

ScriptableObject는 에디터에서 데이터를 수정하고 런타임에서는 읽기 전용으로 사용하는 정적 데이터 관리 방식에 적합하다. 이는 게임 내 아이템 정보, 레벨 디자인 데이터, 캐릭터 능력치 등의 변하지 않는 데이터를 관리할 때 유용하다.

<장점>

  • 에디터에서 데이터를 편집할 수 있어 코드 수정 없이 데이터 변경이 가능하다.
  • 런타임에서는 읽기 전용으로 동작하여 불필요한 메모리 할당을 방지한다.
  • 여러 개의 오브젝트가 동일한 데이터를 공유할 수 있어 메모리 효율적이다.

<단점>

  • 런타임에서 ScriptableObject의 데이터를 변경해도 저장되지 않으며, 재시작 시 초기화된다.
  • 동적으로 변경되는 데이터(경험치, 인벤토리 등)에는 적합하지 않다.

<예시>

namespace ScriptableObjects
{
    [CreateAssetMenu(fileName = "ItemData", menuName = "ScriptableObjects/ItemData", order = 1)]
    public class ItemData : ScriptableObject
    {
        public string itemName;
        public int itemID;
        public Sprite itemIcon;
    }
}

[8. 데이터 덩어리 객체 관리]

게임 개발을 하면 여러 데이터를 한곳에 모아 관리할 필요가 있다. 이를 위해 데이터 덩어리 객체(Data Container Object) 를 활용할 수 있다. 이는 여러 ScriptableObject를 한곳에서 관리할 수 있도록 설계된다.

<장점>

  • 관련된 데이터를 한곳에서 관리하여 접근이 용이하다.
  • 여러 개의 ScriptableObject를 참조하여 데이터 간 연관성을 쉽게 유지할 수 있다.

<단점>

  • 너무 많은 데이터를 한 객체에 모으면 메모리 사용량이 증가할 수 있다.
  • 필요하지 않은 데이터까지 불러올 가능성이 있어 성능 최적화에 주의해햐 한다.

<예시>

namespace ScriptableObjects
{
    [CreateAssetMenu(fileName = "Data", menuName = "ScriptableObjects/GameData", order = 1)]
    public class GameData : ScriptableObject
    {
        public List<CharacterStats> characters;
        public List<ItemData> items;
    }
}

[9. 리소스 그룹화 및 관리]

게임 개발을 할 때 필연적으로 여러 개의 리소스를 효율적으로 관리해야한다. 예를 들면 스프라이트, 사운드, 파티클 등이 있다. 이러한 리소스들을 그룹으로 묶어두면 코드에서 쉽게 참조할 수 있다.

<장점>

  • 리소스를 그룹화하여 접근이 쉬워지고, 관리가 편리해진다.
  • 필요할 때 한 번에 로드할 수 있어 성능 최적화가 가능하다.
  • 스크립트에서 하드코딩 없이 직관적으로 리소스를 할당할 수 있다.

<단점>

  • 한 번에 많은 리소스를 로드하면 메모리 사용량이 증가할 수 있다.
  • 리소스를 정리하지 않으면 사용하지 않는 데이터가 불필요하게 메모리를 차지할 수 있다.

<예시>

namespace ScriptableObjects
{
    [CreateAssetMenu(fileName = "Data", menuName = "ScriptableObjects/ResourceGroup", order = 1)]
    public class ResourceGroup : ScriptableObject
    {
        public List<Sprite> characterSprites;
        public List<AudioClip> soundEffects;
    }
}

이 코드를 사용하면 캐릭터 스프라이트와 사운드 효과를 쉽게 관리할 수 있다. 이를 아까 싱글톤으로 만든 GameInstance 에서 참조하면 프로젝트 전체에서 일관된 리소스 관리를 할 수 있다.


[10. Update 함수의 성능 최적화 및 관리 방안]

Unity에서 Update 함수는 각 프레임마다 호출되며, 게임 로직, 사용자 입력 처리, 타이머, 상태 관리 등에 사용된다. 하지만 수많은 Update 함수가 존재할 경우 성능 저하가 발생할 수 있다. 이는 Unity가 모든 MonoBehaviour 스크립트를 순회하며 Update 함수를 호출하기 때문이다.

<안쓰는 Unity 메시지 함수를 제거해야 하는 이유>

Unity의 MonoBehaviour는 Update, FixedUpdate, LateUpdate, OnEnable, OnDisable 등 다양한 메시지 함수를 제공하는데 Unity는 이러한 함수가 존재하기만 해도 검사하기 때문에 필요하지 않은 함수들은 제거해야한다.

  • Unity는 모든 MonoBehaviour 인스턴스를 순회하며 Update를 호출할 수 있는지 검사한다.
  • 비어 있는 Update 함수도 호출 대상이 되며, 불필요한 오버헤드를 발생시킨다.
  • 프로젝트 내 MonoBehaviour 스크립트가 많아질수록 CPU 사용량이 증가한다.

따라서, 사용하지 않는 메시지 함수는 반드시 제거해야 한다.

<대안 및 관리법>

  • 이벤트 기반 시스템 활용

Update가 필요한 경우만 이벤트를 발생시키는 방식으로 불필요한 호출을 최소화한다.

예를 들어, 상태 변화가 있을 때만 특정 함수를 호출하도록 한다.

  • 매니저 클래스를 통한 중앙 집중식 관리

개별 객체의 Update 호출을 통합하여 관리하면 불필요한 함수 호출을 줄일 수 있다.

  • FixedUpdate와 LateUpdate의 적절한 활용

물리 연산이 필요한 경우 FixedUpdate를 사용하여 일정한 간격으로 실행되도록 한다. 렌더링 후 처리해야할 작업(카메라 위치 보정 등)은 LateUpdate를 활용하여 불필요한 Update 호출을 방지한다.

  • 코루틴(Coroutine) 활용

Update 대신 필요할 때만 실행하는 코루틴을 사용하여 불필요한 함수 호출을 줄일 수 있다.


[11. 오브젝트 풀링(Object Pooling)을 통한 성능 최적화]

오브젝트 풀링은 자주 생성 및 파괴되는 객체를 미리 생성하여 재사용하는 방식이다. 이는 GC의 부담을 줄이고, 성능을 최적화하는데 효과적이다.

<장점>

  • Instantiate와 Destroy는 높은 성능 비용을 유발하므로, 반복적으로 생성/삭제 되는 오브젝트(총알, 몬스터 등)를 풀링하면 성능이 크게 향상된다.
  • 새로 생성되는 객체가 많으면 GC가 자주 호출되어 프레임 드랍이 발생할 수 있는데, 오브젝트 풀링을 사용하면 GC의 부담을 줄여줄 수 있다.

<대안 방법>

  • 오브젝트가 필요할 때마다 Instantiate 를 호출하는 방식 대신, 미리 생성된 객체를 활성화/비활성화하는 방식으로 사용한다.
  • 오브젝트가 필요하지 않을 때는 SetActive(false), 필요할 땐 SetActive(true)하면 된다.

<예시>

public class ObjectPool<T> where T : MonoBehaviour
{
    private Queue<T> pool = new Queue<T>();
    private T prefab;
    private Transform parent;

    public ObjectPool(T prefab, int initialSize, Transform parent = null)
    {
        this.prefab = prefab;
        this.parent = parent;

        for (int i = 0; i < initialSize; i++)
        {
            var obj = GameObject.Instantiate(prefab, parent);
            obj.gameObject.SetActive(false);
            pool.Enqueue(obj);
        }
    }

    public T Get()
    {
        if (pool.Count > 0)
        {
            var obj = pool.Dequeue();
            obj.gameObject.SetActive(true);
            return obj;
        }

        var newObj = GameObject.Instantiate(prefab, parent);
        return newObj;
    }

    public void Return(T obj)
    {
        obj.gameObject.SetActive(false);
        pool.Enqueue(obj);
    }
}

[12. Scene 관리 및 로딩 최적화]

Unity에서는 여러 개의 씬을 사용하여 게임을 구성할 수 있으며, 씬을 효율적으로 관리하는 것이 성능과 유지보수에 중요한 요소이다.

<씬 관리 전략>

  • 싱글 씬 vs 멀티 씬 구조
    • 싱글 씬 : 하나의 씬에서 모든 기능을 처리하지만, 규모가 커질수록 유지보수가 어려워진다.
    • 멀티 씬 : UI, 배경, 게임플레이 등을 나누어 관리하면 효율적이다.
  • 씬 전환 방식
    • SceneManager.LoadScene()을 사용하면 기존 씬이 언로드되고 새로운 씬이 로드된다.
    • SceneManager.LoadSceneAsync()를 사용하면 비동기적으로 씬을 로드하여 프레임 드랍을 방지할 수 있다.
    • Additive 씬 로딩을 활용하면 여러 씬을 동시에 유지할 수 있다. (예를 들면 UI 씬을 별도로 유지하여 매번 새로 로드하지 않도록 함)

<예시>

IEnumerator LoadSceneAsync(string sceneName)
{
    AsyncOperation asyncLoad = SceneManager.LoadSceneAsync(sceneName);
    while (!asyncLoad.isDone)
    {
        yield return null;
    }
}

비동기 로딩을 사용하면 로딩 중에도 게임이 끊기지 않고 부드럽게 진행될 수 있다.


[13. UI 최적화 및 효율적인 이벤트 처리]

Unity의 UI 시스템은 잘못 설계하면 불필요한 재렌더링과 성능 저하를 초래할 수 있다.

UI 최적화 전략

  • Canvas의 갱신을 최소화
    • Canvas가 업데이트될 때마다 전체 UI가 다시 렌더링되므로, 빈번한 UI 갱신을 줄이는 것이 중요하다.
    • 변경이 잦은 UI는 별도의 Canvas로 분리하여 불필요한 업데이트를 방지하자.
  • 레이아웃 재계산 방지
    • ContentSizeFitter, Layout Group 등을 남용하면 매 프레임마다 레이아웃을 재계산할 수 있다.
    • 레이아웃 변경이 필요할 때만 수동으로 LayoutRebuilder.ForceRebuildLayoutImmediate() 를 호출하자.
  • 오버레이 Canvas 지양
    • Screen Space-Overlay 모드는 UI가 변경될 때마다 전체 화면을 다시 렌더링해야 하므로 성능이 저하된다. 가능하면 Screen Space-Camera 또는 Screen Space-World 모드를 사용하자.
  • UI 이벤트 처리 최적화
  • EventTrigger를 사용하기보다는 UnityEvent나 AddListener()를 활용하는 것이 성능 면에서 유리하다.
  • UI버튼을 람다 표현식 없이 직접 할당하는 것이 성능적으로 더 효율적이다.

<예시>

public class UIButtonHandler : MonoBehaviour
{
    public Button myButton;

    private void Start()
    {
        myButton.onClick.AddListener(OnButtonClick);
    }

    private void OnButtonClick()
    {
        Debug.Log("버튼이 클릭됨!");
    }
}

[14. 데이터 저장 및 불러오기 방식 (PlayerPrefs, JSON, ScriptableObject)]

게임 데이터 저장 방식에는 여러 가지가 있으며, 각 방식의 장단점이 다르므로 적절하게 선택해야 한다.

<PlayePrefs(간단한 설정 저장)>

  • 장점 : 간단한 값 저장(설정 값, 최고 점수 등)
  • 단점 : 보안 취약, 대용량 데이터 저장에 부적합
PlayerPrefs.SetInt("HighScore", 100);
int score = PlayerPrefs.GetInt("HighScore", 0);

<JSON 파일 저장 (플레이어 데이터, 설정 데이터)>

  • 장점 : 직렬화가 가능하여 복잡한 데이터 구조 저장 가능
  • 단점 : 직접 파일 저장/불러오기 구현 필요
string json = JsonUtility.ToJson(playerData);
File.WriteAllText(Application.persistentDataPath + "/playerData.json", json);

<ScriptableObject활용 (에디터 수정용 데이터)>

  • 장점 : 런타임에서 수정되지 않는 데이터 관리(아이템 목록, 스테이지 정보 등)
  • 단점 : 런타임에서 수정한 데이터를 저장할 수 없음
[CreateAssetMenu(fileName = "Data", menuName = "Game/StageData")]
public class StageData : ScriptableObject
{
    public int stageNumber;
    public string stageName;
}

데이터 저장 방식은 목적에 맞게 선택하는 것이 중요하며, 보안이 필요한 경우 암호화된 저장 방식을 고려해야 한다.

0개의 댓글