어디서든 부를 수 있다는 것은 어디서나 묶인다는 뜻이다
총알이 벽에 부딪힐 때 소리를 내려고 합니다. 소리는 AudioManager 가 냅니다.
그럼 총알은 AudioManager 를 어떻게 찾을까요.
인스펙터에 필드를 만들어 끌어다 놓을 수 있습니다. 그런데 총알은 실행 중에 만들어집니다.
프리팹에 미리 끌어다 놓을 수 있는 것은 프리팹뿐이고, 신 위의 오브젝트는 못 끌어다 놓습니다.
GameObject.Find 로 이름을 찾을 수도 있지만 느리고, 이름을 바꾸는 순간 조용히 깨집니다.
여기서 대부분 같은 답에 도달합니다.
AudioManager.Instance.PlaySfx(hitClip);
싱글톤은 이렇게 생겼다
public class AudioManager : MonoBehaviour
{
public static AudioManager Instance;
private void Awake()
{
if (Instance == null)
Instance = this; // 내가 1호다
else
Destroy(gameObject); // 이미 있으니 나는 사라진다
}
}
이게 전부입니다. 신에 이 컴포넌트를 붙인 오브젝트를 몇 개 올려놓든,
각자의 Awake 가 차례로 돌면서 첫 번째만 자리를 잡고 나머지는 스스로 파괴됩니다.
이 열 줄이 두 가지를 동시에 보장합니다.
if 문이 아니라 static 입니다.- 유일성: 아무리 여러 개를 만들어도 결국 하나만 남는다
- 전역 접근: 참조를 주고받지 않아도
AudioManager.Instance로 어디서든 닿는다
인스턴스 필드는 객체마다 하나씩 생기지만 static 필드는 타입당 하나입니다.
객체가 백 개 생겨도 그 백 개가 바라보는 Instance 는 같은 칸입니다.
싱글톤 구현이 이렇게 짧은 이유가 여기 있습니다.
소박한 버전에는 구멍이 둘 있다
첫째, 신을 바꾸면 사라집니다. 매니저는 보통 게임 내내 살아 있어야 하는데 새 신을 로드하면 이 오브젝트가 통째로 파괴됩니다.
둘째, Awake 순서가 보장되지 않습니다. 이게 더 자주 물립니다.
같은 코드가 어떤 날은 되고 어떤 날은 안 됩니다. 재현이 안 되는 버그가 여기서 나옵니다.
없으면 스스로 만드는 버전
두 구멍을 메우면 이렇게 됩니다.
public class AudioManager : MonoBehaviour
{
private static AudioManager instance;
public static AudioManager Instance
{
get
{
if (instance == null)
SetupInstance(); // 없으면 스스로 준비한다
return instance;
}
}
private void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject); // 신이 바뀌어도 살아남는다
}
else
{
Destroy(gameObject);
}
}
private static void SetupInstance()
{
instance = FindAnyObjectByType<AudioManager>();
if (instance == null) // 신에 아무도 없으면 직접 만든다
{
GameObject go = new GameObject("AudioManager");
instance = go.AddComponent<AudioManager>();
DontDestroyOnLoad(go);
}
}
}
필드를 private 으로 내리고 프로퍼티를 앞에 세운 것이 핵심입니다.
접근하는 순간이 곧 준비하는 순간이 되어, Awake 순서를 신경 쓸 필요가 없어집니다.
FindObjectOfType은 쓰지 마세요. Unity 2023.1 과 Unity 6 에서 사용 중단됐고FindAnyObjectByType또는FindFirstObjectByType으로 바뀌었습니다. 정렬이 필요 없으면Any쪽이 빠릅니다. 어느 쪽이든 매우 느린 API 라 첫 한 번만 타도록 설계해야 합니다.
DontDestroyOnLoad는 루트 오브젝트에만 먹습니다. 자식으로 들어가 있으면 조용히 무시됩니다. 부모가 있다면transform.SetParent(null)로 루트에 올린 뒤 호출해야 합니다. 문서에는 적혀 있는데 실무에서 자주 밟습니다.
매니저마다 복붙하지 않기
AudioManager, UIManager, PoolManager 마다 같은 코드를 복사하면
고칠 일이 생겼을 때 전부 고쳐야 합니다. 제네릭 베이스 클래스로 한 번만 씁니다.
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
private static T instance;
public static T Instance
{
get
{
if (instance == null) SetupInstance();
return instance;
}
}
protected virtual void Awake()
{
if (instance == null)
{
instance = this as T;
DontDestroyOnLoad(gameObject);
}
else if (instance != this as T)
{
Destroy(gameObject);
}
}
private static void SetupInstance()
{
instance = FindAnyObjectByType<T>();
if (instance == null)
{
GameObject go = new GameObject(typeof(T).Name);
instance = go.AddComponent<T>();
DontDestroyOnLoad(go);
}
}
}
// 쓰는 쪽은 상속만 하면 끝입니다
public class AudioManager : Singleton<AudioManager>
{
public void PlaySfx(AudioClip clip) { }
}
Singleton<AudioManager> 처럼 자기 자신을 타입 인자로 넘기는 모양이 낯설 수 있는데,
이렇게 해야 AudioManager.Instance 가 AudioManager 타입으로 정확히 돌아옵니다.
캐스팅 없이 바로 쓸 수 있습니다.
함정 하나. 파생 클래스에서
Awake를 재정의할 때base.Awake()를 빼먹으면 싱글톤 등록 자체가 안 됩니다. 컴파일도 되고 경고도 없고 그냥Instance가 이상하게 됩니다.protected virtual로 열어 두는 이상 이건 규칙으로 못 박는 수밖에 없습니다.
그래서 무엇을 잃는가
여기까지가 “편한 이야기” 입니다. 이제 대가를 봅니다.
싱글톤이 편한 이유는 어디서든 한 줄로 부를 수 있어서입니다. 그리고 문제도 전부 거기서 나옵니다. 부를 수 있다는 것은 곧 묶인다는 뜻입니다.
구체적으로 넷입니다.
- 숨은 의존성.
Bullet의 필드와 시그니처만 봐서는 이 클래스가AudioManager를 필요로 한다는 사실을 알 수 없습니다. 메서드 본문을 전부 읽어야 드러납니다 - 테스트가 어렵다.
Bullet하나만 시험해 보고 싶어도AudioManager가 실제로 살아 있어야 합니다. 가짜로 갈아 끼울 자리가 없습니다 - 상태가 새어 나간다. 싱글톤은 전역 가변 상태입니다. 앞 테스트가 바꿔 놓은 값이 뒤 테스트에 그대로 남아, 실행 순서에 따라 결과가 달라집니다
- 죽는 순서를 통제할 수 없다. 특히 게임을 끌 때 어떤 매니저가 먼저 파괴되는지 정할 방법이 없습니다
그래서 싱글톤은 디자인 패턴 목록과 안티패턴 목록에 동시에 올라 있습니다. 쓰지 말라는 뜻이 아니라, 쓸 때마다 값을 치르고 있다는 것을 알고 쓰라는 뜻입니다.
판단 기준은 질문 하나로 정리됩니다.
이게 진짜 하나여야만 하는가, 아니면 그냥 하나 두고 쓰기가 편해서인가.
전자라면 싱글톤이 맞습니다. 후자라면 다른 방법이 있습니다.
MonoBehaviour 가 아니라면
신 위에 올라가지 않는 순수 C# 클래스로 만들 때는 이야기가 하나 달라집니다. 여러 스레드가 동시에 접근할 수 있습니다.
if (instance == null)
instance = new ConfigService(); // 두 스레드가 동시에 여기 들어오면
둘 다 null 을 보고 둘 다 new 를 실행합니다. 유일해야 할 것이 두 개가 됩니다.
lock 과 이중 검사로 막을 수 있지만, 직접 짤 이유는 없습니다.
public sealed class ConfigService
{
private static readonly Lazy<ConfigService> lazy =
new Lazy<ConfigService>(() => new ConfigService(),
LazyThreadSafetyMode.ExecutionAndPublication);
public static ConfigService Instance => lazy.Value;
private ConfigService() { } // 외부에서 new 하는 길을 막는다
}
Lazy<T> 가 “필요할 때 딱 한 번만, 스레드에 안전하게” 를 이미 해 줍니다.
생성자를 private 으로 막고 클래스를 sealed 로 닫아야 유일성이 실제로 지켜집니다.
이걸 안 하면 아무나 new 할 수 있어서 싱글톤이라고 부를 수가 없습니다.
MonoBehaviour 싱글톤에는 이 고민이 없습니다. 유니티 API 자체가 메인 스레드 전용이라 경쟁 자체가 일어나지 않습니다.
실무에서 밟는 지뢰 셋
1. 매 프레임 .Instance 를 부르지 않습니다.
void Start() => pool = PoolManager.Instance; // 한 번만 조회
void Update() => pool.Tick(this); // 이후로는 필드로
프로퍼티 게터 안의 null 검사가 매 프레임 반복되는 것을 없앨 수 있습니다.
2. 도메인 리로드를 끄면 static 이 살아남습니다.
에디터의 Enter Play Mode Options 에서 Domain Reload 를 끄면 시작이 빨라집니다.
대신 플레이를 멈춰도 static 필드가 초기화되지 않습니다.
두 번째 플레이에서 이미 파괴된 오브젝트를 가리키는 instance 가 그대로 남아
에디터에서만 나는 버그가 됩니다. [RuntimeInitializeOnLoadMethod] 로
정적 상태를 직접 되돌려야 합니다.
3. 종료 중에는 부활시키지 않습니다.
게임을 끄는 중에 다른 객체의 OnDestroy 가 Instance 를 건드리면,
“없으면 만든다” 로직이 새 오브젝트를 만들어 버립니다.
하이어라키에 유령이 남고 콘솔에 경고가 뜹니다.
private static bool isQuitting;
private void OnApplicationQuit() => isQuitting = true;
public static T Instance
{
get
{
if (isQuitting) return null; // 종료 중이면 만들지 않는다
...
}
}
정리
- 싱글톤은 유일성과 전역 접근 두 가지를 한꺼번에 보장하는 패턴이다.
- 유일성을 만드는 것은
if가 아니라static이다. 타입당 한 칸이라 모두가 같은 곳을 본다. - 소박한 구현에는 구멍이 둘이다. 신을 바꾸면 사라지고,
Awake순서가 보장되지 않는다. - 프로퍼티에서 “없으면 준비한다” 를 하면 순서 문제가 사라진다.
- 매니저가 여럿이면 제네릭 베이스로 한 번만 쓴다.
base.Awake()를 빼먹으면 조용히 안 된다. - 대가는 전부 전역 접근에서 나온다. 숨은 의존성, 테스트 난이도, 상태 오염, 파괴 순서.
- 순수 C# 이면 스레드 문제가 생긴다. 직접 락을 짜지 말고
Lazy<T>를 쓴다. - 기준은 하나다. 진짜 하나여야 하는가, 그냥 편해서인가.