---
title: "어디서든 부를 수 있다는 것은 어디서나 묶인다는 뜻이다"
description: "싱글톤은 유일성과 전역 접근 두 가지를 한꺼번에 보장합니다. 편한 것은 두 번째인데 대가도 전부 두 번째에서 나옵니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/design-patterns"
tags: ["Unity"]
canonical: https://blog.unialgames.com/posts/unity-singleton/
---
총알이 벽에 부딪힐 때 소리를 내려고 합니다. 소리는 `AudioManager` 가 냅니다.
그럼 총알은 `AudioManager` 를 어떻게 찾을까요.

인스펙터에 필드를 만들어 끌어다 놓을 수 있습니다. 그런데 총알은 실행 중에 만들어집니다.
프리팹에 미리 끌어다 놓을 수 있는 것은 프리팹뿐이고, 신 위의 오브젝트는 못 끌어다 놓습니다.
`GameObject.Find` 로 이름을 찾을 수도 있지만 느리고, 이름을 바꾸는 순간 조용히 깨집니다.

여기서 대부분 같은 답에 도달합니다.

```csharp
AudioManager.Instance.PlaySfx(hitClip);
```

## 싱글톤은 이렇게 생겼다

```csharp
public class AudioManager : MonoBehaviour
{
    public static AudioManager Instance;

    private void Awake()
    {
        if (Instance == null)
            Instance = this;      // 내가 1호다
        else
            Destroy(gameObject);  // 이미 있으니 나는 사라진다
    }
}
```

이게 전부입니다. 신에 이 컴포넌트를 붙인 오브젝트를 몇 개 올려놓든,
각자의 `Awake` 가 차례로 돌면서 **첫 번째만 자리를 잡고 나머지는 스스로 파괴됩니다.**

이 열 줄이 두 가지를 동시에 보장합니다.

<figure class="diagram">
<svg viewBox="0 0 680 230" role="img" aria-label="여러 인스턴스가 있어도 static 필드는 타입당 한 칸뿐이라 모두 같은 곳을 가리킨다는 그림">
<text x="30" y="28" font-size="13" fill="var(--text-muted)">신 위의 오브젝트</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="30" y="42" width="120" height="40" rx="8"></rect>
<rect x="30" y="94" width="120" height="40" rx="8"></rect>
<rect x="30" y="146" width="120" height="40" rx="8"></rect>
</g>
<g font-family="var(--font-mono)" font-size="13" text-anchor="middle" fill="var(--text)">
<text x="90" y="67">AudioManager</text>
<text x="90" y="119">AudioManager</text>
<text x="90" y="171">AudioManager</text>
</g>
<g stroke="var(--text-muted)" stroke-width="1.3" fill="none">
<path d="M156 62 L300 108"></path>
<path d="M156 114 L300 112"></path>
<path d="M156 166 L300 118"></path>
</g>
<text x="320" y="90" font-size="13" fill="var(--text-muted)">static 필드는 타입당 한 칸</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="320" y="98" width="160" height="40" rx="8"></rect>
</g>
<text x="400" y="123" font-family="var(--font-mono)" font-size="13" text-anchor="middle" fill="var(--accent)">Instance</text>
<g stroke="var(--text-muted)" stroke-width="1.3" fill="none">
<path d="M486 118 L536 118"></path>
<path d="M530 113 L536 118 L530 123"></path>
</g>
<text x="548" y="112" font-size="13" fill="var(--text)">먼저 온 하나만</text>
<text x="548" y="130" font-size="13" fill="var(--text-muted)">나머지는 Destroy</text>
<text x="30" y="216" font-size="13" fill="var(--text-muted)">인스턴스가 몇 개든 그들이 바라보는 Instance 는 물리적으로 같은 한 칸입니다.</text>
</svg>
<figcaption>유일성을 만드는 것은 <code>if</code> 문이 아니라 <code>static</code> 입니다.</figcaption>
</figure>

- **유일성**: 아무리 여러 개를 만들어도 결국 하나만 남는다
- **전역 접근**: 참조를 주고받지 않아도 `AudioManager.Instance` 로 어디서든 닿는다

인스턴스 필드는 객체마다 하나씩 생기지만 `static` 필드는 **타입당 하나**입니다.
객체가 백 개 생겨도 그 백 개가 바라보는 `Instance` 는 같은 칸입니다.
싱글톤 구현이 이렇게 짧은 이유가 여기 있습니다.

## 소박한 버전에는 구멍이 둘 있다

**첫째, 신을 바꾸면 사라집니다.** 매니저는 보통 게임 내내 살아 있어야 하는데
새 신을 로드하면 이 오브젝트가 통째로 파괴됩니다.

**둘째, `Awake` 순서가 보장되지 않습니다.** 이게 더 자주 물립니다.

<x-flow>
<script type="application/json">
{
  "label": "누가 먼저 Awake 를 도느냐",
  "lines": [
    "// AudioManager",
    "void Awake() { Instance = this; }",
    "",
    "// Bullet",
    "void Awake()",
    "{",
    "    AudioManager.Instance.PlaySfx(clip);",
    "}"
  ],
  "cases": [
    {
      "label": "매니저가 먼저 돌면",
      "state": "Instance = null",
      "path": [
        { "line": 1, "why": "매니저가 먼저 자리를 잡습니다.", "state": "Instance = AudioManager" },
        { "line": 6, "why": "총알이 부를 때는 이미 채워져 있습니다.", "state": "Instance = AudioManager", "out": "소리 재생" }
      ],
      "skip": []
    },
    {
      "label": "총알이 먼저 돌면",
      "state": "Instance = null",
      "path": [
        { "line": 6, "why": "아직 아무도 Instance 를 채우지 않았습니다.", "state": "Instance = null", "out": "NullReferenceException" },
        { "line": 1, "why": "매니저는 그다음에 자리를 잡습니다. 이미 늦었습니다.", "state": "Instance = AudioManager" }
      ],
      "skip": []
    }
  ],
  "note": "유니티는 같은 프레임 안에서 어떤 Awake 가 먼저 돌지 보장하지 않습니다. 스크립트 실행 순서를 직접 지정할 수는 있지만, 매니저가 늘어날수록 그 목록을 관리하는 일이 더 큰 짐이 됩니다."
}
</script>
</x-flow>

같은 코드가 어떤 날은 되고 어떤 날은 안 됩니다. 재현이 안 되는 버그가 여기서 나옵니다.

## 없으면 스스로 만드는 버전

두 구멍을 메우면 이렇게 됩니다.

```csharp
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` 마다 같은 코드를 복사하면
고칠 일이 생겼을 때 전부 고쳐야 합니다. 제네릭 베이스 클래스로 한 번만 씁니다.

```csharp
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` 로 열어 두는 이상 이건 규칙으로 못 박는 수밖에 없습니다.

## 그래서 무엇을 잃는가

여기까지가 "편한 이야기" 입니다. 이제 대가를 봅니다.

싱글톤이 편한 이유는 **어디서든 한 줄로 부를 수 있어서**입니다.
그리고 문제도 전부 거기서 나옵니다. 부를 수 있다는 것은 곧 **묶인다**는 뜻입니다.

<x-compare>
<script type="application/json">
{
  "label": "총알이 소리를 내는 두 가지 방법",
  "tabs": [
    { "label": "싱글톤을 부른다", "code": "public class Bullet : MonoBehaviour\n{\n    void OnHit()\n    {\n        AudioManager.Instance.PlaySfx(clip);\n    }\n}", "stats": [["의존성이 보이는가", "본문을 다 읽어야 안다"], ["테스트할 때", "AudioManager 가 살아 있어야 한다"], ["소리를 끄려면", "AudioManager 를 고쳐야 한다"]], "tone": "bad" },
    { "label": "받아서 쓴다", "code": "public class Bullet : MonoBehaviour\n{\n    IAudioService audio;\n    public void Init(IAudioService a) => audio = a;\n\n    void OnHit()\n    {\n        audio.PlaySfx(clip);\n    }\n}", "stats": [["의존성이 보이는가", "Init 시그니처에 적혀 있다"], ["테스트할 때", "가짜를 넣으면 된다"], ["소리를 끄려면", "아무것도 안 하는 것을 넣는다"]], "tone": "good" }
  ]
}
</script>
</x-compare>

구체적으로 넷입니다.

- **숨은 의존성.** `Bullet` 의 필드와 시그니처만 봐서는 이 클래스가 `AudioManager` 를
  필요로 한다는 사실을 알 수 없습니다. 메서드 본문을 전부 읽어야 드러납니다
- **테스트가 어렵다.** `Bullet` 하나만 시험해 보고 싶어도 `AudioManager` 가 실제로
  살아 있어야 합니다. 가짜로 갈아 끼울 자리가 없습니다
- **상태가 새어 나간다.** 싱글톤은 전역 가변 상태입니다. 앞 테스트가 바꿔 놓은 값이
  뒤 테스트에 그대로 남아, 실행 순서에 따라 결과가 달라집니다
- **죽는 순서를 통제할 수 없다.** 특히 게임을 끌 때 어떤 매니저가 먼저 파괴되는지
  정할 방법이 없습니다

그래서 싱글톤은 디자인 패턴 목록과 안티패턴 목록에 **동시에** 올라 있습니다.
쓰지 말라는 뜻이 아니라, 쓸 때마다 값을 치르고 있다는 것을 알고 쓰라는 뜻입니다.

판단 기준은 질문 하나로 정리됩니다.

> **이게 진짜 하나여야만 하는가, 아니면 그냥 하나 두고 쓰기가 편해서인가.**

전자라면 싱글톤이 맞습니다. 후자라면 다른 방법이 있습니다.

## MonoBehaviour 가 아니라면

신 위에 올라가지 않는 순수 C# 클래스로 만들 때는 이야기가 하나 달라집니다.
**여러 스레드가 동시에 접근할 수 있습니다.**

```csharp
if (instance == null)
    instance = new ConfigService();   // 두 스레드가 동시에 여기 들어오면
```

둘 다 `null` 을 보고 둘 다 `new` 를 실행합니다. 유일해야 할 것이 두 개가 됩니다.
`lock` 과 이중 검사로 막을 수 있지만, 직접 짤 이유는 없습니다.

```csharp
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` 를 부르지 않습니다.**

```csharp
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` 를 건드리면,
"없으면 만든다" 로직이 새 오브젝트를 만들어 버립니다.
하이어라키에 유령이 남고 콘솔에 경고가 뜹니다.

```csharp
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>` 를 쓴다.
- 기준은 하나다. **진짜 하나여야 하는가, 그냥 편해서인가.**