Unity

[Unity][팁] 유니티 기본 개념 및 설계

usingsystem 2022. 8. 10. 17:01
728x90

유니티 설계 경험

유니티를 이용하면서 내가 경험한 내용을 정리한 글이다. 이 글은 유니티를 개발하는 과정 속에서 지속적으로 업데이트 될 예정이다.

 

핵심 개념

다음 개념들은 알고 시작하는 것이 좋다.

 

유니티 버전단위 문서

유니티 버전에 따른 매뉴얼이다. 이 문서를 통해 대부분의 유니티 관련한 이용방법을 숙지할 수 있다.

https://docs.unity3d.com/2021.2/Documentation/Manual/index.html

 

Unity - Manual: Unity User Manual 2021.2

Use the Unity Editor to create 2D and 3D games, apps and experiences. Download the Editor at unity3d.com. The Unity User Manual helps you learn how to use the Unity Editor and its associated services. You can read it from start to finish, or use it as a re

docs.unity3d.com

 

유니티 핵심 객체

유니티로 개발을 시작하기 전에 반드시 숙지해야하는 객체들에 대한 문서이다.

https://docs.unity3d.com/Manual/ScriptingImportantClasses.html

 

Unity - Manual: Important Classes

Null Reference Exceptions Important Classes - GameObject Important Classes This section provides an overview of some of the most commonly used and important built-in classes in Unity that you may want to use when scripting. These pages serve as a starting

docs.unity3d.com

 

유니티 이벤트 플로우

유니티는 코어 시스템이 모두 C++로 개발되어있고 이 부분은 유니티 C# 개발자들에게는 감춰져있다. 그렇기 때문에 유니티에서 여러 코어 로직들이 실행되는 타이밍을 C# 에서 활용할 수 있는 인터페이스가 필요하다. 예를 들면, 모든 물리 로직이 끝난 직후, 모든 렌더링 작업이 끝난 직후 등등과 같은 타이밍들이 있을 것이다. 다음 링크는 이 이벤트를 정리해둔 문서이다.

https://docs.unity3d.com/Manual/ExecutionOrder.html

 

Unity - Manual: Order of execution for event functions

Instantiating Prefabs at run time Order of execution for event functions Running a Unity script executes a number of event functions in a predetermined order. This page describes those event functions and explains how they fit into the execution sequence.

docs.unity3d.com

 

유니티 아키텍쳐

유니티에 전반적인 설계 구조에 대한 문서이다.

https://docs.unity3d.com/Manual/unity-architecture.html

 

Unity - Manual: Unity architecture

Important Classes - Gizmos & Handles Overview of .NET in Unity Unity architecture The Unity engine is built with native C/C++ internally, however it has a C# wrapper that you use to interact with it. As such, you need to be familiar with some of the key co

docs.unity3d.com

 

 

 

설계 규칙

개인적으로 유니티를 사용하면서 정리한 설계 규칙이다.

 

유니티 C# 프로그래밍 컨벤션은 Microsoft C# 컨벤션을 이용한다.

https://docs.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions

 

C# Coding Conventions

Learn about coding conventions in C#. Coding conventions create a consistent look to the code and facilitate copying, changing, and maintaining the code.

docs.microsoft.com

 

 

프로젝트의 커스텀 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
	}
}

 

프로젝트의 커스텀 Struct 데이터 타입들을 모아놓을 클래스 파일을 생성한다.

열거형 타입과 마찬가지의 이유로 구조체 타입들을 모아놓을 공간이 필요하다.

 

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

		public ScriptableObjects.MeleeTrace meleeTrace;
		public ScriptableObjects.Projectile projectile;
	}

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

	//..
}

 

프로젝트의 유틸리티 함수를 모아놓을 클래스를 생성한다.

언리얼에서 작업을 진행하게 되면, 굉장히 많은 기능들과 방대한 코드 속에서 그때그때 예제를 만들며 많은 시간을 낭비하게 된다. 시간을 좀 더 효율적으로 활용하기 위해 제작해본 예제들을 기능 단위로 유틸리티 함수에 쌓아두어야 할 것이다. 유틸리티 함수는 일반화된 작업들을 수행하는 함수들로, 모두 static함수로 구성되어 있고 클래스 또한 static class로 정의한다. 이렇게 하면 인스턴싱으로 인한 부분은 아예 신경쓸 일이 없을 것이다.

 

public static class Utils
{
	public static void SetTimeScale(float timescale)
	{
		Time.timeScale = timescale;
		Time.fixedDeltaTime = 0.02f * Time.timeScale;
	}

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

	public static float DirectionToAngle(float x, float y)
	{
		float cos = x;
		float sin = y;
		return Mathf.Atan2(sin, cos) * Mathf.Rad2Deg;
	}
    // ..
}

 

프로젝트의 글로벌 변수들을 모아놓을 클래스를 정의한다.

개발 과정중에는 종종 글로벌 변수를 이용해야 하는 상황이 생긴다. 특히 문자열 같은 경우 여러 클래스에 걸쳐 같은 문자열이 사용된다면 이를 글로벌 변수로 정의하여 공유하도록 하는것이 현명하다. 단, 여기서 말하는 글로벌 변수들은 변하지 않는 변수들을 의미하므로 모두 const와 readonly로 정의한다. (값이 변할 수 있는 글로벌 변수는 되도록이면 이용하지 않도록 한다.) 그리고 클래스는 Utils와 마찬가지의 이유로 클래스와 변수를 static으로 정의한다. 

public static class Globals
{
	public const int WorldSpaceUISortingOrder = 1;
	public const int CharacterStartSortingOrder = 10;

	public static class LayerName
	{
		public static readonly string Default = "Default";
		public static readonly string UI = "UI";
		public static readonly string Card = "Card";
		public static readonly string Obstacle = "Obstacle";
	}
    
    // ..
}

 

 

생성될 객체들을 관리할 관리자 클래스를 정의한다.

게임 내에 정의될 모든 객체들을 Child로 갖는 하나의 관리자 클래스를 정의한다. 만약 관리자 클래스가 여러 개가 정의되어야 한다면 꼭대기의 가장 핵심적인 관리자 클래스를 두고 그 Child로 파생 관리자 객체들을 두면 된다. 아래 예제는 가장 꼭대기에 GameManager 클래스가 하위 관리자로 CharacterManager, UIManager, CameraController, MapManager, EffectManager를 갖는 형태로 설계되어 있다. 이렇게 설계를 해 두고 GameManager에 대한 역참조만 정의해둔다면, 모든 클래스가 다른 모든 클래스에 접근할 수 있게 된다. 이 클래스는 Scene의 가장 루트 GameObject로 단 하나만 미리 인스턴싱 해두도록 한다.

public class GameManager : MonoBehaviour, IGame
{
	[SerializeField]
	private CharacterManager _characterManager;

	[SerializeField]
	private UIManager _uiManager;

	[SerializeField]
	private CameraController _cameraController;

	[SerializeField]
	private MapManager _mapManager;

	[SerializeField]
	private EffectManager _effectManager;
    
    // ..
}

 

GameManager의 하위 관리자는 GameManager로의 역참조를 갖도록 한다.

public class CharacterManager : MonoBehaviour
{
	private IGame _game;
	private List<BaseCharacter> _guardians = new List<BaseCharacter>();
    
	public IGame GameManager
	{
		get { return _game; }
	}
	
	public void Init(IGame game)
	{
		_game = game;
	}
	// ..
}

 

CharacterManager의 하위 객체는 CharacterManager로의 역참조를 갖는다. 이렇게 되면 모든 객체는 다른 모든 객체를 참조해올 수 있다.

public class BaseCharacter : MonoBehaviour
{
	protected CharacterManager _manager;
	public void Init(CharacterManager manager)
	{
		_manager = manager;
	}
}

 

시니어 개발자 중에는 관리자 클래스에 대해 부정적인 의견을 갖는 경우가 많다. 하지만 개인적인 경험으로는 관리자 클래스를 이용하는 것이 가성비가 더 좋다. 물론 프레임워크의 크기가 거대하고 객체들의 종류와 종속성이 복잡하다면 좀 다른 방법을 써야겠지만, 유니티를 이용하는 정도 사이즈의 게임들은 매니저 클래스를 이용하는 정도로 충분하다. 만약 충분하지 않더라도 중간에 리펙토링을 하면 되고, 초반부터 오버 아키텍쳐링을 하는것이 더 비효율적이라 본다. 빠르고 직관적이게 개발을 하기 위해 이런 트리 형태의 참조, 역참조 관계를 만들어 두고 추후에 차근차근 리펙토링을 하면서 종속성을 제거해 나가는 것이 효과적이다.

 

Scene 전환에 걸쳐서도 GameObject를 갖으며 존재하는 단 하나의 객체를 정의하여, Scene 전환에 걸쳐서도 유지되어야 하는 데이터들을 관리하도록 한다.

위에서 언급한 GameManager의 경우 Scene의 루트 오브젝트로서 존재한다. 즉, Scene이 변경되면 파괴되는 객체이다. 하지만 게임개발을 진행하다 보면 Scene의 변경과 상관없이 관리되어야 하는 객체들과 데이터들이 존재한다. 예를 들면 서버로부터 받은 계정 정보를 저장한다거나, 게임의 Scene 전환 후에도 이전 데이터를 참조해야하는 경우 등이 있을 것이다. 이를 위해 MonoBehaviour를 포함하는 싱글턴 오브젝트를 정의하도록 한다. 추가적으로 관리되어야 하는 객체들이 있다면 이 객체의 하위로 보내면 되므로 싱글턴 객체는 하나 이상은 필요 없다. 

 

public class GameInstance : Singleton<GameInstance>
{
	private LogGUI _logGUI;
	private DebugStatGUI _debugStatGUI;

	private ScriptableObjects.GamePrefabs _gamePrefabs;

	private HttpManager _httpManager;
    
    // ..
}

 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

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();
					obj.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);
		}
	}
}

 

싱글턴 객체에 대해서도 부정적인 의견이 많다. 하지만 제대로 관리를 해줄 수 있다면 싱글턴 객체를 이용함으로써 얻는 이점이 더 많다. 싱글턴의 단점을 알아보자.

 

1. 전역 변수로서의 문제

전역 변수가 갖는 문제는 자명하다. 변수를 어디에서나 접근할 수 있기 때문에 모든 영역에 걸쳐서 코드가 변경될 수 있는 가능성이 생긴다. 그러므로 변수에 이상한 값이 들어가 있을 때 이 변수를 사용하는 모든 부분들을 의심해봐야 한다. 하지만 이 문제는 전역변수의 값이 수정 불가능하다면 애초에 발생하지 않을 문제이다. 싱글턴 객체 내부의 리소스들은 마음껏 Read 가능하도록 하되, Write에 대해서는 철저하게 관리하도록 설계한다면 이 문제는 해결될 수 있다. 특히 GamePrefabs와 같은 객체는 게임 내 대부분의 리소스를 참조할 수 있는 통로인데, 어차피 이 객체의 프로퍼티는 에디터에서만 변경되면 충분하기 때문에 모든 하위 객체를 포함하여 property로 set을 막아두도록 한다. 

하지만, HttpManager와 같이 내부 변수들이 변하는 객체들도 분명 존재할 것이다. 이런 객체들의 값 변경은 최대한 이벤트를 이용해 내부적으로 처리하도록 하고, 외부에서 값을 변경시키는 상황은 철저하게 관리해야 할 것이다.

 

2. 코드 결합도(Coupling) 문제

모든 기능을 싱글턴 객체로 만들고 사방에서 싱글턴 객체를 호출하는 형태로 코드를 작성하면 프레임워크의 규모가 커질 때 관리가 불가능해질 것이다. 현재 코드로 이 상황을 완전히 피할 수는 없다. 하지만 최소화시킬 방법은 있는데, 싱글턴 객체를 딱 하나만 정의하고 그 외의 모든 싱글턴으로서 이용하고 싶은 객체들을 하위 객체로 넣는 것이다. 이렇게 설계하면 최소 싱글턴의 상호참조로 인한 커플링은 막을 수 있다. 그리고 개인적으로는 이 정도 구조로도 웬만한 게임을 개발하기에는 충분하다. 만약 이보다 좀 더 일반화된 구조를 원한다면 언리얼 엔진의 GameInstance와 SubSystem을 참고하도록 하자.

 

3. 단일 책임 원칙 위배

하나의 객체는 하나의 책임을 담당하며, 그 책임을 완전히 캡슐화해야한다는 원칙이다. 현재 단 하나의 싱글턴 객체를 정의했기 때문에, 이 객체가 많은 책임을 갖게 될 수 있다. 그러므로 새로운 책임이 생길 때마다 새로운 하위 객체를 갖는 형태로 구성하도록 한다. 위에 예제로 보면 GamePrefabs 객체를 통해 리소스 참조의 책임을 넘겼고, HttpManager 객체를 통해 REST API 네트워킹에 대한 책임을 넘겼다. 이처럼 싱글턴을 이용하더라도 단일 책임 원칙을 위배하지 않도록 설계할 방법은 있다.

 

4. 초기화와 소멸 타이밍을 잡기 어려운 문제

서버개발자들이 싱글턴 객체를 싫어하는 이유 중 하나는 멀티스레드 환경에서 공유자원의 영역이 커진다는 것이고, 다른 하나는 초기화와 소멸 타이밍을 잡기 어렵다는 것이다. 먼저 유니티의 게임 루프는 싱글 스레드 기반이기 때문에 첫번째 문제는 배제할 수 있다. 그렇다면 초기화와 소멸 타이밍은 어떻게 잡을 수 있을까? 유니티 엔진이 제공하는 기능들을 활용하면 된다. 다음 함수를 GameInstance 객체에 넣어서 초기화 타이밍을 잡기 위해 활용한다.

// 어떤 Scene이 시작되기 전 호출
[RuntimeInitializeOnLoadMethod]
static void OnBeforeSceneLoadRuntimeMethod()
{
	// 초기화가 필요한 경우 여기서 싱글턴을 초기화한다.
    // GameInstance.Instance.Init();
}

 

다음으로 소멸 타이밍은 어떻게 잡아야할까? 결론부터 말하자면 이 부분은 아직 완벽하게 해결할 방법을 찾지 못했다. 사용자가 프로그램을 언제 종료시킬지 모르기 때문에 명시적인 타이밍을 잡기 어렵다. 유니티에서는 OnApplicationQuit 이라는 이벤트가 있는데, 딱 봤을 때 앱이 종료될 때 호출될 것 같지만 공식 문서에서는 항상 호출됨을 보장하지 않는다고 한다. 다행히도 대체할 이벤트가 있다. OnApplicationPause을 이용하면 된다. OnApplicationPause (bool) 이벤트의 경우 모바일에서 창이 전환될 때 바로 호출되기 때문에 종료되기 전에 반드시 호출됨을 보장할 수 있다. 다만 창만 내렸다가 다시 돌아오는 경우가 있기 때문에 bool 인자를 통해 창이 내려가면서 발생된 이벤트인지 창이 올라오면서 발생한 이벤트인지 구분해야한다. 그리고 소멸 메커니즘을 만들었다면 회복 메커니즘도 만들어줘야할 것이다. 다만 모든 상황에 대한 회복 메커니즘을 만드는 작업과, 창을 내렸다가 올릴때마다 다시 로딩하는것이 사용자에게 좋은 경험일지는 모르므로 완벽한 해결방법이라고 하기는 어려울 수 있다.

 

private void OnApplicationPause(bool pause)
{
	Debug.LogError($"GameInstance OnApplicationPause {pause}");
}

 

다음처럼 모바일 환경에서 창을 내렸다가 돌아왔을 때 이벤트를 로그로 남긴 것을 확인할 수 있다.

 

참고 링크

https://answers.unity.com/questions/824790/help-with-onapplicationquit-android.html

 

 

 

 

 

에디터 타임에 수정되며, 게임 타임에는 변하지 않을 데이터들을 ScriptableObject로 정의한다.

게임을 개발하다보면 굉장히 다양한 형태의 데이터들을 정의하게 된다. 그 중에서 에디터 타임에는 변경될 수 있으며 게임 타임에는 변하지 않는 데이터는 어떤 데이터들일까? 스타크래프트를 예로 들어보자. 울트라리스크는 400의 체력을 갖는다. 여기서 울트라리스크가 공격을 받는다면 체력이 줄어들게 된다. 이 줄어드는 체력은 인스턴싱 된 객체의 변수 하나로 표현할 수 있을 것이다. 하지만 최초의 400이라는 정보는 어디에 저장되어 있었을까? 보통 이런 데이터들은 데이터 테이블이라는 리소스에 저장되고, 기획자들에 의해 관리된다. 유니티에서는 이러한 데이터를 다루기 위해 ScriptableObject 객체를 지원한다. 이 객체로 정의된 데이터는 에디터 위에서 인스턴싱될 수 있으며, 에디터 타임에 값을 수정할 수 있다. 그리고 Prefab과 같은 형태로 GameObject를 통해 참조하여 사용될 수 있다.

그러므로 아이템 데이터, 캐릭터 데이터, 적들의 데이터 등등 게임을 기획하며 만들어지는 대부분의 데이터들은 ScriptableObject로 정의하도록 한다. 그리고 이 변수들은 에디터 수준에서만 변경 가능하도록 설계하여 기획자들이 마음껏 수정해볼 수 있도록 하고, 개발자들은 게임 디버깅 과정에서 신경쓰지 않아도 되도록 한다. 또한 네임스페이스로 묶어두어 네임충돌을 피하도록 하고, 자동완성의 이점을 얻도록 한다.

 

namespace ScriptableObjects
{
	[Serializable]
	[CreateAssetMenu(fileName = "Data", menuName = "ScriptableObjects/Artifact", order = 1)]
	public class Artifact : ScriptableObject
	{
		[SerializeField]
		private Sprite _thumbnail;

		[SerializeField]
		private string _name;

		[SerializeField]
		private StatModifierGroupData _statModifierGroup;

		public Sprite Thumbnail
		{
			get { return _thumbnail; }
		}

		public string Name
		{
			get { return _name; }
		}

		public StatModifierGroupData StatModifierGroup
		{
			get { return _statModifierGroup; }
		}

	}

}

 

아무때나 편하게 데이터에 접근할 수 있도록 데이터 덩어리 객체를 정의한다.

급하게 게임 개발을 진행하다 보면 객체마다 데이터를 박아가며 개발을 진행하게 되는데, 이 데이터들을 변경하면 또 다시 에디터에서 핫 리로드를 하게 되는 불편함이 있고, 데이터가 흩어져서 관리하기 어려워진다는 불편함도 있다. 또한 ScriptableObject나 GameObject와 같은 객체를 참조하고자 하는 경우 모두 [SerializeField]로 박아서 사용하기 부담된다는 점도 있다. 그러므로 무지성으로 사용하고싶은 모든 데이터들을 담아둘 덩어리 객체를 정의하여, 자주 사용되던 테스팅으로 사용되던 필요하다면 박아넣고 사용할 수 있는 공간을 확보한다. 이 객체 또한 ScriptableObject로 정의하고 내부적으로는 region을 이용하여 데이터들을 구분하도록 한다.

namespace ScriptableObjects
{
    [System.Serializable]
    [CreateAssetMenu(fileName = "Data", menuName = "ScriptableObjects/GamePrefabs", order = 1)]
    public class GamePrefabs : ScriptableObject
    {
		#region Using
		public ScriptableObjects.StatEffectGroup statEffectGroup;

		public ScriptableObjects.Stage initStage;

		public ScriptableObjects.StartCardDeck startCardDeck;

		public ScriptableObjects.ArtifactGroup startArtifacts;

		public ScriptableObjects.SpriteGroup spriteGroup;

		public List<ScriptableObjects.Card> allSpawnableCards;

		public ScriptableObjects.ArtifactGroup allSpawnableArtifacts;

		public int buildTryCount;

		public int startLife;
		// ..
		#endregion


		#region Test
		public int testValue;
		#endregion

	}

}

 

객체가 정의되었다면 어디서든 이 객체에 접근할 수 있도록 이전에 정의한 싱글턴 오브젝트가 이 객체를 참조하도록 한다. 이제 필요할때마다 GamePrefabs에 프로퍼티로 데이터를 박아넣을 수 있고, 에디터에서 수정가능하며 GameInstance로 어디서든 참조할 수 있을 것이다.

public class GameInstance : Singleton<GameInstance>
{
	// ..
	private ScriptableObjects.GamePrefabs _gamePrefabs;
	// ..
    
	public ScriptableObjects.GamePrefabs GamePrefabs 
	{ 
		get { return _gamePrefabs; } 
	}
	// ..
}

 

 리소스들을 종류별로 그룹지어 묶어둔 데이터 덩어리들을 정의한다.

GamePrefabs와 마찬가지로 게임 내에서 자주 참조되는 리소스들은 그룹지어 묶어두도록 한다. 예를 들면 SpriteGroup이라는 객체를 통해 자주 사용되는 sprite들을 모아둔다거나, ParticleEffectGroup을 통해 자주 사용되는 파티클들을 모아두는 것이다. 게임개발을 하다보면 이런 리소스를 찾아다니며 낭비하는 시간이 적지 않다. 또한 코드 상에서 이 리소스를 참조해오기 위해 낭비되는 시간까지 생각한다면, 그냥 객체 하나에 다 넣어놓고 이용하는 것이 훨씬 편하다. 물론 추후에 최적화 단계에서는 정리를 해야겠지만, 빠르게 개발해나가는 단계에서는 이 방법이 효과적이다. 

이 객체들도 GamePrefabs 객체의 하위 프로퍼티로 넣어서 어디서든 참조될 수 있도록 한다.

 

namespace ScriptableObjects
{
    [Serializable]
    [CreateAssetMenu(fileName = "Data", menuName = "ScriptableObjects/CommonGroup/ParticleGroup", order = 1)]
    public class ParticleGroup : ScriptableObject
    {
        public List<GameObject> particles;
    }
}

 

 

BaseMonoBehaviour 객체를 통해 MonoBehaviour를 래핑한다.

유니티에서는 gameObject에 스크립팅을 위해 MonoBehaviour 객체를 이용한다. 하지만 MonoBehaviour는 부족한 점이 많다. 그래서 몇가지 기능들을 확장해서 사용할 필요가 있다.

1. MonoBehaviour의 리플렉션 이벤트들은(Awake, Start, Update, OnDestroy ..) 굉장히 느리다. 그러므로 자체적인 이벤트 호출루틴을 구축할 필요가 있다.

 

참고 링크

https://blog.theknightsofunity.com/monobehavior-calls-optimization/

 

 

2. 모든 MonoBehaviour 객체에 OnDestroy 시에 레퍼런스를 놓아주는 코드를 넣어주어야 한다. 이 내용은 아래에 자세히 설명되어 있다.

 

 

 

 

 

OnDestroy 시에는 반드시 모든 레퍼런스를 놓아주도록 한다.

유니티에는 가비지 컬렉터가 있으니 메모리는 알아서 관리해줄 것이다.. 라는 착각은 버리자. 가비지 컬렉터를 돌리더라도 계속 해제되지 않고 떠다니는 메모리들이 존재한다. 왜 그럴까? 이는 Ghost Reference가 남아있기 때문인데 구체적인 예시를 보자.

public class SomeObject : MonoBehaviour
{
    public Sprite _sprite;
}

 

SomeObject는 어떤 sprite를 참조중이다. 대부분의 유니티 개발자들은 이때 이 SomeObject가 파괴된다면 sprite에 대한 참조도 사라질 것이라 예상한다. 하지만 그렇지 않다. 파괴되더라도 c++객체인 gameObject는 바로 제거되지만 C#객체인 MonoBehaviour는 GC되기 전까지는 메모리에 남아있다. 그러므로 이 객체가 참조하고 있는 Sprite에도 레퍼런스가 남아있게 된다. 이처럼 파괴되더라도 참조가 남기 때문에 GC의 부담이 커지게 된다. 그러므로 OnDestroy에서 모든 참조 변수들을 null로 세팅하여 GC가 쉽게 메모리를 해제할 수 있도록 한다.

 

public class SomeObject : MonoBehaviour
{
    public Sprite _sprite;
	private void OnDestroy()
	{
		_sprite = null;
	}
}

 

그러나 멤버 객체 변수를 사용할 때마다 매번 OnDestroy 시에 null로 세팅해주는 일은 귀찮은 일이다. 이런 경우 Reflection을 이용하여 쉽게 이 작업을 자동화시킬 수 있다.

public class BaseMonoBehaviour : MonoBehaviour
{
    void OnDestroy()
    {
		FieldInfo[] info = GetType().GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
		foreach (FieldInfo field in info)
        {
            Type fieldType = field.FieldType;
 
            if (typeof(IList).IsAssignableFrom(fieldType))
            {
                IList list = field.GetValue(this) as IList;
                if (list != null)
                {
                    list.Clear();
                }
            }
 
            if (typeof(IDictionary).IsAssignableFrom(fieldType))
            {
                IDictionary dictionary = field.GetValue(this) as IDictionary;
                if (dictionary != null)
                {
                    dictionary.Clear();
                }
            }
 
            if (!fieldType.IsPrimitive)
            {
                field.SetValue(this, null);
            }
        }
    }
}

이제 모든 MonoBehaviour 객체는 이 클래스를 상속받도록 한다.

 

 

 

참고 문서

https://answers.unity.com/questions/377510/does-destroy-remove-all-instances.html

 

Does Destroy remove all instances? - Unity Answers

 

answers.unity.com

 

https://medium.com/@lynxelia/unity-memory-management-how-to-manage-memory-and-reduce-the-time-it-takes-to-enter-play-mode-fd07b43c1daa

 

Unity Memory Management: How to manage memory and reduce the time it takes to enter play mode

A lot of people have been running into the issue where the editor will take a really long time to load the scene after clicking “Play”…

medium.com

 

 

 

Unity에서 Reflection을 이용할 때에는 주의해서 이용하도록 한다.

Unity 엔진의 C#에서도 동일하게 Reflection을 사용할 수 있다. 하지만, iOS의 경우에는 자체 저수준 가상 머신인 LLVM 환경 에서 제한적으로만 Reflection을 허용한다. 이 환경에서는 Mono Runtime에 맞도록 미리 컴파일하는 AOT(Ahead of Time) 컴파일 방식을 채용하고 있어서 Reflection 기능 중에 매우 큰 강점인 동적인 코드 생성을 할 수 없다. 다행히도 AOT를 이용하더라도 Field 타입과 Method 리스트를 받아오거나, Method를 실행시키는 등의 기본적인 Reflection 기능들은 모두 동작한다. 그러므로 Unity에서 Reflection을 사용해야하는 상황이 생긴다면 되도록이면 이 점을 숙지하고 사용하는 것이 좋을 것이다. 위에서 언급한 OnDestroy 상황처럼 사용해도 되고, 디버깅이나 테스팅을 위해 모든 객체를 래핑하는 방향으로 사용되기도 한다.

 

 

 

Unity의 GameObject의 OnDestroy 순서는 숙지해두도록 한다.

객체의 OnDestroy 순서를 알아야 적절한 순서로 객체를 정리할 수 있다. 이미 파괴된 객체의 레퍼런스를 건드리면 문제가 생기기 때문이다. 그러므로 다음과 같은 상황들에 대한 파괴 순서를 알아둘 필요가 있다.

 

1. Parent와 같은 Parent 를 둔 GameObject들 사이에서의 파괴 순서

예를들어 A라는 GameObject의 자식으로 B, C 가 순서대로 생성되었다고 가정해보자. OnDestroy 이벤트는 A, C, B 순서로 일어난다. 

 

2. 같은 GameObject에 여러 컴포넌트가 붙어있을 때, 이 컴포넌트들 사이의 파괴 순서

같은 GameObject 사이에 여러 컴포넌트에 대해서는 딱히 명시된 순서가 없다.

 

 

PlayerPrefs를 이용하여 Application 실행/종료 상황에서도 유지되어야 하는 데이터들을 관리하도록 한다.

PlayerPrefs는 데이터를 유니티 고유 캐시 파일에 저장한다. 물론 캐시가 삭제된다면 이 데이터들도 제거되기 때문에 이 공간에 어떤 데이터를 저장시킬 것인지는 많이 고민해봐야할 것이다. 보통은 게임 옵션, 게임 저장, 로그인 캐시(성공 시)와 같은 클라리언트 레벨에서만 저장되어도 크게 무리없는 데이터들을 저장한다. 파일에 저장되는만큼 저장 키가 겹치지지 않도록 주의하여야 하고, 따로 관련 코드들을 모아두는 공간이 있다면 좀 더 효과적이다.

namespace CacheData
{
	public class GoogleSignInData
	{
		private static class Meta
		{
			public const string AuthCode = "GoogleLogin_AuthCode";
			public const string Email = "GoogleLogin_Email";
			public const string IdToken = "GoogleLogin_IdToken";
			public const string DisplayName = "GoogleLogin_DisplayName";
			public const string GivenName = "GoogleLogin_GivenName";
			public const string FamilyName = "GoogleLogin_FamilyName";
			public const string UserId = "GoogleLogin_UserId";
			public const string SignInStatusCode = "GoogleLogin_SignInStatusCode";
		}

		private string _authCode;
		private string _email;
		private string _idToken;
		private string _displayName;
		private string _givenName;
		private string _familyName;
		private string _userId;

		public string AuthCode
		{
			get { return _authCode; }
		}
		public string Email
		{
			get { return _email; }
		}

		public string IdToken
		{
			get { return _idToken; }
		}

		public string DisplayName
		{
			get { return _displayName; }
		}

		public string GivenName
		{
			get { return _givenName; }
		}

		public string FamilyName
		{
			get { return _familyName; }
		}

		public string UserId
		{
			get { return _userId; }
		}

		public void Set(Google.GoogleSignInUser signIn)
		{
			Set(signIn.AuthCode, signIn.Email, signIn.IdToken, signIn.DisplayName, signIn.GivenName, signIn.FamilyName, signIn.UserId);
		}

		public void Set(string authCode, string email, string idToken, string displayName, string givenName, string familyName, string userId)
		{
			_authCode = authCode;
			_email = email;
			_idToken = idToken;
			_displayName = displayName;
			_givenName = givenName;
			_familyName = familyName;
			_userId = userId;
		}

		public static void Save(GoogleSignInData googleSignIn)
		{
			PlayerPrefs.SetString(Meta.AuthCode, googleSignIn.AuthCode);
			PlayerPrefs.SetString(Meta.Email, googleSignIn.Email);
			PlayerPrefs.SetString(Meta.IdToken, googleSignIn.IdToken);
			PlayerPrefs.SetString(Meta.DisplayName, googleSignIn.DisplayName);
			PlayerPrefs.SetString(Meta.GivenName, googleSignIn.GivenName);
			PlayerPrefs.SetString(Meta.FamilyName, googleSignIn.FamilyName);
			PlayerPrefs.SetString(Meta.UserId, googleSignIn.UserId);
			PlayerPrefs.Save();
		}

		public static GoogleSignInData Load()
		{
			GoogleSignInData googleSignIn = null;

			if (PlayerPrefs.HasKey(Meta.UserId))
			{
				googleSignIn = new GoogleSignInData();

				string authCode = PlayerPrefs.GetString(Meta.AuthCode, "");
				string email = PlayerPrefs.GetString(Meta.Email, "");
				string idToken = PlayerPrefs.GetString(Meta.IdToken, "");
				string displayName = PlayerPrefs.GetString(Meta.DisplayName, "");
				string givenName = PlayerPrefs.GetString(Meta.GivenName, "");
				string familyName = PlayerPrefs.GetString(Meta.FamilyName, "");
				string userId = PlayerPrefs.GetString(Meta.UserId, "");

				googleSignIn.Set(authCode, email, idToken, displayName, givenName, familyName, userId);
			}

			return googleSignIn;
		}

		public static void Remove()
		{
			PlayerPrefs.DeleteKey(Meta.UserId);
		}
	}
}

 

 

유니티에서 사용되지 않는 플러그인과 에셋들을 정리하여 스크립트 리로딩 시간을 단축시킨다.

유니티로 개발을 하다 보면 어느 순간부터 스크립트 리로딩 시간이 30초가 넘어가는 경험을 하게 된다. 작성한 스크립트나 추가한 에셋들이 늘어나고, 사용하지 않는 유니티 플러그인들이 계속 쌓여나가며 리로딩해야하는 대상이 점점 많아지기 때문에 그렇다. 이 시간을 단축시켜야 한다.

 

1. 어디서 스크립트 리로딩 시간이 많이 소요되는지 파악한다.

일단 스크립트 리로딩의 병목이 무엇인지 파악해야한다.

이를 위해 Edit -> Preferences -> Diagnostics -> EnableDomainReloadTimings를 True로 세팅한다. 이렇게 하면 유니티의 리로드 관련한 에디터 로그를 남길 수 있다. 에디터 로그는 C:/User/AppData/Local/Unity/Editor/Editor.log에 남는다.

로그에서 다음과 같은 부분을 찾는다.

리로딩 시간이 총 2.7초 정도 걸렸다. (원래 20초 가량 나왔었는데 단축시킨 결과이다.) 로그를 내려보면 특히나 시간을 많이 잡아먹는 모듈들을 기억해두자.

 

2. 유니티에서 제공하는 플러그인중 필요하지 않는 플러그인들을 제거한다.

다음으로 사용하지 않는 모듈, 플러그인들을 제거한다. 

Unity -> Window -> Package Manager 에서 제거하면 된다.

 

3. 수정될 일이 거의 없는 에셋들과 스크립트들을 Plugins 폴더로 옮긴다.

플러그인이 아니더라도, 직접 작성한 스크립트 코드가 늘어난다면 리로딩 시간이 증가할 수 있다. 이 시간을 단축시키기 위해서 수정될 일이 거의 없는 라이브러리 코드들과 구매한 에셋들을 Plugins 폴더로 옮기도록 한다. 이렇게 되면 매번 리로딩되는 대상에서 제외되기 때문에 리로딩 시간을 상당 부분 줄일 수 있다. 

 

유니티에서 사용되는 로그파일들이 어디에 생성되는지 확인해둔다.

1. 유니티 콘솔 로그, 에디터 로그

가장 기본적인 에디터 콘솔 로그는 유니티 에디터 상에서 콘솔 패널을 통해 확인할 수 있다. 실제 파일의 위치는 다음 경로에 남는다.

C:/User/username/AppData/Local/Unity/Editor/Editor.log

해당 파일을 통해서 콘솔 패널에 표시되는 로그 뿐만 아니라 에디터 자체적으로 남기는 로그까지 좀 더 디테일하게 로그를 볼 수 있다.

 

2. 유니티 standalone build 로그

다음으로 standalone으로 빌드된 실행파일의 로그가 남는 위치도 확인해볼 필요가 있다. 빌드된 앱의 로그는 다음 경로에 남는다.

C:/Users/username/AppData/LocalLow/companyname/projectname/Player.log

 

유니티 로그관련

유니티에서 모든 로그가 출력되지 않도록 세팅하고싶을 수 있다. 그런 경우 다음 코드를 넣어주면 된다.

Debug.unityLogger.logEnabled = false;

 

오픈소스

개발 전 반드시 참고하는 오픈소스들이다.

 

 

유니티의 수많은 유용한 오픈소스들을 정리해놓은 레포지토리

https://github.com/michidk/Unity-Script-Collection

 

GitHub - michidk/Unity-Script-Collection: A maintained collection of useful & free unity scripts / library's / plugins and exten

A maintained collection of useful & free unity scripts / library's / plugins and extensions - GitHub - michidk/Unity-Script-Collection: A maintained collection of useful & free unity sc...

github.com

 

 

유니티의 심플한 예제들을 정리해놓은 레포지토리

https://github.com/UnityCommunity/UnityLibrary

 

GitHub - UnityCommunity/UnityLibrary: Library of all kind of scripts, snippets & shaders for Unity

:books: Library of all kind of scripts, snippets & shaders for Unity - GitHub - UnityCommunity/UnityLibrary: Library of all kind of scripts, snippets & shaders for Unity

github.com

 

 

#아직 리서치 필요

https://github.com/cmilr/Unity2D-Components

 

GitHub - cmilr/Unity2D-Components: A constantly evolving array of Unity C# components for 2D games, including classes for pixel

A constantly evolving array of Unity C# components for 2D games, including classes for pixel art cameras, events & messaging, saving & loading game data, collision handlers, object pools, a...

github.com

 

https://wonsorang.tistory.com/657

 

[Unity] 잦은 자동빌드, 느리고 멈출 때 해결법

유니티를 사용할 때, 코드 한두줄 바꾸고 유니티 에디터를 잠깐 조작할 일이 종종 생깁니다. 어셋을 확인한다거나 컴포넌트 셋팅을 확인한다거나... 그 때마다 수정된 코드를 변경된 어셋으로

wonsorang.tistory.com

참조 - https://algorfati.tistory.com/170

728x90