공용 스크립트 제작기🎈 - 싱글톤 편

Unity 프로젝트의 공용으로 사용할 수 있는 스크립트를 제작하면서 어떻게 만들었는지, 어떤 점에 신경썼는지 설명하는 글입니다.
Singleton 클래스를 상속받아서 Manager 클래스를 싱글톤으로 만드는 건 이미 흔한 기법이다. 이를 매번 새로 만들어쓰자니 역시 귀찮아서, 이 참에 단단하게 한 번 다지고 지나가보려 한다. 이 글에서 따로 싱글톤 패턴이 어떠니 저쩌니 떠들지는 않겠다. 간단하게 왜 이렇게 만들었는지를 정리해놓으려 한다.
분류하기
크게 두 종류를 개발하려 한다.
- 일반 C# Singleton
- MonoBehaviour를 상속받는 Singleton
C# Singleton 특징
- Unity의 생명주기와 독립적으로 동작
- 데이터를 관리할 때 혹은 순수 로직만 담당하는 시스템에 적합
MonoBehaviour Singleton 특징
- GameObject에 컴포넌트로 부착됨
- Unity의 생명주기 메서드 사용가능
- Scene관리가 필요한 시스템 혹은 Unity의 시스템이 필요한 경우에 사용
그리고 각각은 또 EagerSingleton과 LazySingleton 두 가지로 나뉜다.
EagerSingleton 특징
- 타입 로드 시에 인스턴스 즉시 생성
- lock을 사용하지 않아도 되어 성능이 우수함
- 게임 전체에서 사용되는 핵심 매니저에 적합
LazySingleton 특징
- 실제로 필요할 때까지 인스턴스 생성을 미룸
- 메모리 효율적
- 게임 전체에서 사용되지 않을 수도 있는 시스템 및 초기화 비용이 클 때 적합
분류 이유
성능의 최적화를 위해서 이렇게 세분화 하였다. 순수 C# 싱글톤은 MonoBehaviour의 오버헤드 없이 가볍게 동작할 수 있고, 메모리를 최적화 하기 위해 Lazy라는 선택지를 두었다. 또한 이렇게 함으로써 명확하게 책임 분리가 된다.
주의사항
- MonoBehaviour를 상속받는 개체는
DontDestroyOnLoad가 없으면 씬이 전환될 때 파괴된다. 때문에IsPersistence등의bool값을 사용하여 씬 전환 시 파괴가 되지 않는 옵션도 제공할 예정이다 - 반면 순수 C# Singleton은 씬과 무관하게 계속 메모리에 존재한다. 따라서 메모리 관리에 주의가 필요하다
- EagerMonoSingleton는 씬에 반드시 미리 배치되어야 한다 (런타임에 자동 생성 불가)
- LazyMonoSingleton은 씬에 배치되지 않았다면 자동으로 생성한다
실제 구현
C# Singleton - Eager
public abstract class EagerSingleton<T> where T : class, new()
{
private static readonly T _instance;
/// <summary>
/// 정적 생성자는 CLR이 타입 최초 접근 시 단 한 번만 실행함을 보장 (내부적으로 lock 사용)
/// = 멀티스레드 환경에서도 lock 없이 안전
/// </summary>
static EagerSingleton()
{
_instance = new T();
(_instance as EagerSingleton<T>)?.Initialize();
}
public static T Instance => _instance;
/// <summary>
/// 싱글톤 인스턴스가 생성되어 있는지 확인
/// Eager 싱글톤은 타입 로드 시 즉시 생성되므로 항상 true
/// </summary>
/// <returns>항상 true</returns>
public static bool IsAlive() => _instance != null;
/// <summary>
/// 파생 클래스에서 직접 생성하지 못하도록 protected
/// </summary>
protected EagerSingleton()
{
}
/// <summary>
/// 싱글톤 인스턴스 초기화
/// 파생 클래스에서 오버라이드하여 초기화 로직 구현
/// </summary>
protected virtual void Initialize()
{
}
// Eager 싱글톤은 한번 생성되면 해제할 수 없음
}
C# Singleton - Lazy
public abstract class LazySingleton<T> where T : class, new()
{
private static T _instance;
private static readonly object _lock = new object();
public static T Instance
{
get
{
// lock 건 이후에도 _instance가 생성되었을 수도 있으니
// Double-checked locking 패턴으로 스레드 세이프 보장
if (_instance == null)
{
lock (_lock)
{
// 초기화 메서드 호출
if (_instance == null)
{
_instance = new T();
(_instance as LazySingleton<T>)?.Initialize();
}
}
}
return _instance;
}
}
/// <summary>
/// 싱글톤 인스턴스가 생성되어 있는지 확인
/// </summary>
/// <returns>인스턴스가 존재하면 true, 아니면 false</returns>
public static bool IsAlive() => _instance != null;
/// <summary>
/// 파생 클래스에서 직접 생성하지 못하도록 protected
/// </summary>
protected LazySingleton()
{
}
/// <summary>
/// 싱글톤 인스턴스 초기화
/// 파생 클래스에서 오버라이드하여 초기화 로직 구현
/// </summary>
protected virtual void Initialize()
{
}
/// <summary>
/// 싱글톤 인스턴스 해제
/// 주로 테스트나 씬 전환 시 사용
/// </summary>
public static void DestroyInstance()
{
lock (_lock)
{
_instance = null;
}
}
}
MonoBehaviour Singleton - Eager
public abstract class EagerMonoSingleton<T> : MonoBehaviour where T : MonoBehaviour
{
private static T _instance;
private static bool isInitialized = false;
private static bool isApplicationQuitting = false;
public static T Instance
{
get
{
// 애플리케이션 종료 중에는 null 반환
if (isApplicationQuitting)
{
return null;
}
// Awake 전 접근 시에만 한 번 찾기
if (_instance == null && !isInitialized)
{
_instance = FindFirstObjectByType<T>();
}
// 여전히 null이면 경고
if (_instance == null)
{
Debug.LogError($"[EagerMonoSingleton] {typeof(T).Name}이(가) 씬에 존재하지 않습니다. " +
$"씬에 GameObject를 배치하고 {typeof(T).Name} 컴포넌트를 추가해주세요.");
}
return _instance;
}
}
/// <summary>
/// 싱글톤 인스턴스가 생성되어 있고 파괴되지 않았는지 확인
/// </summary>
/// <returns>인스턴스가 존재하고 살아있으면 true, 아니면 false</returns>
public static bool IsAlive() => _instance != null && !isApplicationQuitting;
/// <summary>
/// DontDestroyOnLoad 적용 여부 (기본값: true)
/// 파생 클래스에서 오버라이드하여 변경 가능
/// </summary>
protected virtual bool IsPersistent => true;
protected virtual void Awake()
{
// 이미 인스턴스가 존재하는 경우
if (_instance != null && _instance != this)
{
Debug.LogWarning($"[EagerMonoSingleton] {typeof(T).Name}이(가) 씬에 중복으로 존재합니다. " +
$"중복 인스턴스를 제거합니다.");
Destroy(gameObject);
return;
}
_instance = this as T;
isInitialized = true;
// DontDestroyOnLoad 설정
if (IsPersistent)
{
DontDestroyOnLoad(gameObject);
}
// 초기화
Initialize();
}
/// <summary>
/// 초기화 메서드 파생 클래스에서 오버라이드하여 변경 가능
/// </summary>
protected virtual void Initialize()
{
}
protected virtual void OnApplicationQuit()
{
isApplicationQuitting = true;
}
protected virtual void OnDestroy()
{
// 자신이 싱글톤 인스턴스인 경우에만 해제
if (_instance == this)
{
isApplicationQuitting = true;
isInitialized = false;
}
}
}
MonoBehaviour Singleton - Lazy
public abstract class LazyMonoSingleton<T> : MonoBehaviour where T : MonoBehaviour
{
private static T _instance;
private static readonly object _lock = new object();
private static bool isApplicationQuitting = false;
public static T Instance
{
get
{
// 애플리케이션 종료 중에는 null 반환 (워닝 방지)
if (isApplicationQuitting)
{
return null;
}
lock (_lock)
{
// 기존 인스턴스가 없으면 씬에서 찾기
_instance ??= FindFirstObjectByType<T>();
// 씬에도 없으면 새로 생성
if (_instance == null)
{
GameObject singletonObject = new GameObject($"[Singleton] {typeof(T).Name}");
_instance = singletonObject.AddComponent<T>();
}
return _instance;
}
}
}
/// <summary>
/// 싱글톤 인스턴스가 생성되어 있고 파괴되지 않았는지 확인
/// </summary>
/// <returns>인스턴스가 존재하고 살아있으면 true, 아니면 false</returns>
public static bool IsAlive() => _instance != null && !isApplicationQuitting;
/// <summary>
/// DontDestroyOnLoad 적용 여부 (기본값: true)
/// 파생 클래스에서 오버라이드하여 변경 가능
/// </summary>
protected virtual bool IsPersistent => true;
protected virtual void Awake()
{
// 이미 인스턴스가 존재하는 경우
if (_instance != null && _instance != this)
{
// 중복 인스턴스 제거
Destroy(gameObject);
return;
}
_instance = this as T;
// DontDestroyOnLoad 설정
if (IsPersistent)
{
DontDestroyOnLoad(gameObject);
}
// 초기화
Initialize();
}
/// <summary>
/// 초기화 메서드 파생 클래스에서 오버라이드하여 변경 가능
/// </summary>
protected virtual void Initialize()
{
}
protected virtual void OnApplicationQuit()
{
isApplicationQuitting = true;
}
protected virtual void OnDestroy()
{
// 자신이 싱글톤 인스턴스인 경우에만 해제
if (_instance == this)
{
isApplicationQuitting = true;
}
}
}
결론
사실 별거 없긴하다. 요샌 AI가 잘 만들어주기도 하고, 많이 보편화 되어있기도 하고. 그래도 이렇게 하나쯤은 계속 쓰는거 가지고 있어야지 싶어서 정리해봤다. 누가볼까 싶지만 혹시 틀린 부분이나 더 개선할 부분이 있다면 댓글로 알려주시길.