---
title: "무엇을 쓸지는 부르는 쪽이 아니라 등록하는 쪽이 정한다"
description: "서비스 로케이터는 싱글톤에서 전역 접근은 남기고 구현만 떼어냅니다. 갈아 끼울 수 있게 되는 대신 컴파일 에러가 런타임 에러로 밀립니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/design-patterns"
tags: ["Unity"]
canonical: https://blog.unialgames.com/posts/unity-service-locator/
---
앞 글은 질문 하나로 끝났습니다. **진짜 하나여야만 하는가, 그냥 편해서인가.**

"그냥 편해서" 인 경우가 사실 대부분입니다. 총알이 `AudioManager` 를 부르는 것은
소리 내는 물건이 세상에 하나뿐이어야 해서가 아니라, **찾아오기가 귀찮아서**입니다.

그런데 인스펙터로 일일이 끌어다 놓는 자리로 돌아갈 수도 없습니다.
전역 접근이라는 편의는 그대로 두고, **무엇을 돌려줄지만 바꿀 수 있다면** 어떨까요.

## 서비스 로케이터는 이렇게 생겼다

이름이 거창한데 하는 일은 단순합니다. **타입을 주면 그 타입의 구현을 돌려주는 창구**입니다.

```csharp
public static class ServiceLocator
{
    private static readonly Dictionary<Type, object> services = new();

    public static void Register<T>(T service) => services[typeof(T)] = service;

    public static T Get<T>() => (T)services[typeof(T)];
}
```

쓰는 쪽은 이렇습니다.

```csharp
// 게임이 시작될 때 한 번
ServiceLocator.Register<IAudioService>(new AudioManager());

// 필요한 곳 어디서든
ServiceLocator.Get<IAudioService>().PlaySfx(hitClip);
```

<figure class="diagram">
<svg viewBox="0 0 680 250" role="img" aria-label="싱글톤은 부르는 쪽이 구현을 직접 가리키고 서비스 로케이터는 인터페이스를 통해 창구를 거친다는 비교 그림">
<text x="30" y="26" font-size="13" fill="var(--text-muted)">싱글톤</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="30" y="40" width="96" height="38" rx="8"></rect>
</g>
<text x="78" y="64" font-family="var(--font-mono)" font-size="13" text-anchor="middle" fill="var(--text)">Bullet</text>
<g stroke="var(--text-muted)" stroke-width="1.3" fill="none">
<path d="M132 59 L226 59"></path>
<path d="M220 54 L226 59 L220 64"></path>
</g>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="232" y="40" width="150" height="38" rx="8"></rect>
</g>
<text x="307" y="64" font-family="var(--font-mono)" font-size="13" text-anchor="middle" fill="var(--text)">AudioManager</text>
<text x="400" y="64" font-size="13" fill="var(--text-muted)">구현을 직접 가리킵니다</text>
<line x1="30" y1="104" x2="650" y2="104" stroke="var(--border)" stroke-dasharray="4 4"></line>
<text x="30" y="134" font-size="13" fill="var(--text-muted)">서비스 로케이터</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="30" y="148" width="96" height="38" rx="8"></rect>
</g>
<text x="78" y="172" font-family="var(--font-mono)" font-size="13" text-anchor="middle" fill="var(--text)">Bullet</text>
<g stroke="var(--text-muted)" stroke-width="1.3" fill="none">
<path d="M132 167 L226 167"></path>
<path d="M220 162 L226 167 L220 172"></path>
</g>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="232" y="148" width="150" height="38" rx="8"></rect>
</g>
<text x="307" y="172" font-family="var(--font-mono)" font-size="13" text-anchor="middle" fill="var(--accent)">IAudioService</text>
<g stroke="var(--text-muted)" stroke-width="1.3" stroke-dasharray="4 3" fill="none">
<path d="M388 167 L442 167"></path>
</g>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="448" y="130" width="150" height="32" rx="8"></rect>
<rect x="448" y="172" width="150" height="32" rx="8"></rect>
</g>
<g font-family="var(--font-mono)" font-size="12" text-anchor="middle" fill="var(--text-muted)">
<text x="523" y="150">AudioManager</text>
<text x="523" y="192">MuteAudio</text>
</g>
<text x="30" y="236" font-size="13" fill="var(--text-muted)">부르는 쪽이 아는 것은 인터페이스뿐입니다. 실제로 무엇이 오는지는 등록한 쪽이 정합니다.</text>
</svg>
<figcaption>바뀐 것은 접근 방식이 아니라 <strong>무엇에 의존하는가</strong>입니다.</figcaption>
</figure>

전역에서 한 줄로 꺼내 쓴다는 점은 싱글톤과 똑같습니다.
**딱 하나가 다릅니다. 부르는 쪽이 구현 클래스 이름을 모릅니다.**

## 이름 하나 안 쓰는 것이 왜 큰가

<x-compare>
<script type="application/json">
{
  "label": "총알이 소리를 내는 코드",
  "tabs": [
    { "label": "싱글톤", "code": "AudioManager.Instance.PlaySfx(clip);", "stats": [["아는 것", "AudioManager 라는 클래스"], ["바꾸려면", "이 줄을 고친다"], ["테스트에서", "진짜 AudioManager 가 필요"]], "tone": "bad" },
    { "label": "서비스 로케이터", "code": "ServiceLocator.Get<IAudioService>().PlaySfx(clip);", "stats": [["아는 것", "PlaySfx 를 할 수 있다는 사실"], ["바꾸려면", "등록만 바꾼다"], ["테스트에서", "가짜를 등록하면 된다"]], "tone": "good" }
  ]
}
</script>
</x-compare>

싱글톤에서 소리를 끄려면 `AudioManager` 안에 `if (muted) return;` 같은 것을 넣어야 합니다.
로케이터에서는 **아무것도 안 하는 구현을 하나 만들어 등록하면 끝**입니다.
`Bullet` 은 물론이고 `AudioManager` 도 안 건드립니다.

```csharp
public class MuteAudio : IAudioService
{
    public void PlaySfx(AudioClip clip) { }   // 아무것도 안 한다
}

ServiceLocator.Register<IAudioService>(new MuteAudio());
```

이게 실제로 값을 하는 자리가 셋 있습니다.

- **테스트.** 가짜를 등록하고 "소리 재생이 몇 번 불렸는가" 만 세면 됩니다
- **개발 중 대체.** 광고, 결제, 서버 통신처럼 개발할 때 실제로 부르면 곤란한 것들을
  더미 구현으로 바꿔 둡니다. 빌드 설정에 따라 등록만 갈아 끼웁니다
- **플랫폼별 구현.** 모바일과 PC 가 다르게 동작해야 할 때, 부르는 쪽 코드는 하나로 두고
  등록 시점에 갈립니다

## 등록은 어디서 하나

이 패턴의 성패는 **등록을 한 곳에서만 하느냐**에 달려 있습니다.
여기저기서 등록하기 시작하면 "지금 뭐가 등록돼 있는지" 를 아무도 모르게 됩니다.

<x-steps>
<script type="application/json">
{
  "steps": [
    { "tag": "STEP 1", "title": "부트 신", "text": "게임이 가장 먼저 여는 빈 신입니다. 여기 진입점 스크립트 하나만 올려 둡니다." },
    { "tag": "STEP 2", "title": "구현체를 만든다", "text": "빌드 설정이나 플랫폼을 보고 진짜를 쓸지 더미를 쓸지 여기서 정합니다." },
    { "tag": "STEP 3", "title": "로케이터에 등록", "text": "인터페이스와 구현을 짝지어 넣습니다. 이 파일 하나만 읽으면 전체 구성이 보입니다." },
    { "tag": "STEP 4", "title": "게임 신 로드", "text": "이후로는 어디서든 Get 으로 꺼내 씁니다. 등록은 다시 일어나지 않습니다." }
  ]
}
</script>
</x-steps>

이렇게 등록이 한 군데로 모인 자리를 **조합 루트(composition root)** 라고 부릅니다.
이 파일 하나가 "이 게임은 무엇으로 조립되어 있는가" 를 알려주는 명세가 됩니다.

```csharp
public class Bootstrap : MonoBehaviour
{
    private void Awake()
    {
        DontDestroyOnLoad(gameObject);

        ServiceLocator.Register<IAudioService>(new AudioManager());
        ServiceLocator.Register<ISaveService>(new JsonSaveService());
#if UNITY_EDITOR
        ServiceLocator.Register<IAdService>(new FakeAdService());
#else
        ServiceLocator.Register<IAdService>(new AdMobService());
#endif

        SceneManager.LoadScene("Title");
    }
}
```

## 무엇이 안 고쳐졌나

여기서 멈추면 "싱글톤보다 낫다" 로 끝나는데, 그건 절반만 맞습니다.
서비스 로케이터도 안티패턴으로 불립니다. **문제 하나를 고치고 하나를 새로 만들기 때문**입니다.

**여전히 전역입니다.** `Bullet` 의 필드와 시그니처만 봐서는 이 클래스가
소리를 필요로 한다는 사실을 여전히 알 수 없습니다. 본문을 읽어야 드러납니다.
앞 글에서 지적한 **숨은 의존성은 그대로 남아 있습니다.**

**그리고 새 비용이 생깁니다.** 등록을 빠뜨려도 컴파일이 됩니다.

```csharp
ServiceLocator.Get<IAudioService>();   // 등록 안 했으면 여기서 터집니다
```

싱글톤이었다면 클래스 이름을 잘못 쓰는 순간 컴파일러가 잡아 줍니다.
로케이터는 **컴파일할 때 잡히던 실수를 실행할 때로 미룹니다.**
그것도 그 코드가 실제로 불리는 순간에야 터지므로, 특정 경로로 들어가야만 나는
버그가 됩니다. 최소한 없을 때 알아볼 수 있게는 해 둡니다.

```csharp
public static T Get<T>()
{
    if (!services.TryGetValue(typeof(T), out var s))
        throw new InvalidOperationException($"{typeof(T).Name} 가 등록되지 않았습니다");
    return (T)s;
}
```

## 그럼 무엇을 써야 하나

셋 다 "이 객체를 어떻게 찾아오느냐" 에 대한 답이고, 뒤로 갈수록 느슨해집니다.

| 방법 | 부르는 쪽이 아는 것 | 의존성이 드러나는가 | 갈아 끼우기 |
| --- | --- | --- | --- |
| 인스펙터로 직접 연결 | 구체 클래스 | 인스펙터에 보인다 | 신마다 다시 연결 |
| 싱글톤 | 구체 클래스 | 안 보인다 | 안 된다 |
| 서비스 로케이터 | 인터페이스 | 안 보인다 | 등록만 바꾸면 된다 |
| 의존성 주입 | 인터페이스 | 생성자나 `Init` 에 보인다 | 조립하는 쪽에서 |

**아래로 갈수록 좋은 것이 아니라 아래로 갈수록 준비가 필요합니다.**
인스펙터로 연결되는 것은 그냥 인스펙터로 연결하는 게 맞습니다.
같은 신 안에서 끌어다 놓을 수 있는 참조를 굳이 전역으로 빼는 것이 오히려 손해입니다.

현실적인 기준은 이렇습니다.

> **인스펙터로 닿으면 인스펙터로. 못 닿는데 몇 개 안 되면 로케이터.
> 프로젝트가 커지고 테스트를 쓰기 시작하면 그때 DI 컨테이너.**

VContainer 나 Zenject 같은 DI 컨테이너는 등록하는 부분은 로케이터와 비슷하고,
**꺼내는 부분이 없습니다.** 필요한 것을 생성자나 필드로 넣어 주기 때문에
의존성이 시그니처에 그대로 드러납니다. 로케이터의 마지막 약점이 거기서 사라집니다.
다만 규칙을 하나 더 배워야 하므로, 팀과 프로젝트 크기를 보고 정할 일입니다.

## 정리

- 서비스 로케이터는 타입을 주면 구현을 돌려주는 창구다. 전역 접근은 싱글톤과 같다.
- 다른 점은 하나다. 부르는 쪽이 **구체 클래스가 아니라 인터페이스만** 안다.
- 그래서 등록만 바꾸면 구현이 갈린다. 테스트용 가짜, 개발용 더미, 플랫폼별 구현.
- 등록은 조합 루트 한 곳에서만 한다. 그 파일이 게임의 구성 명세가 된다.
- 숨은 의존성은 그대로 남는다. 시그니처만 봐서는 무엇에 기대는지 여전히 모른다.
- 새 비용도 생긴다. 등록을 빠뜨리면 컴파일이 아니라 실행 중에 터진다.
- 인스펙터로 닿으면 인스펙터로. 로케이터는 못 닿는 것에만 쓴다.

로케이터는 찾아가는 방식입니다. 대상이 하나로 정해져 있고, 인스펙터로는 못 닿을 때 잘 맞습니다.