무엇을 쓸지는 부르는 쪽이 아니라 등록하는 쪽이 정한다
앞 글은 질문 하나로 끝났습니다. 진짜 하나여야만 하는가, 그냥 편해서인가.
“그냥 편해서” 인 경우가 사실 대부분입니다. 총알이 AudioManager 를 부르는 것은
소리 내는 물건이 세상에 하나뿐이어야 해서가 아니라, 찾아오기가 귀찮아서입니다.
그런데 인스펙터로 일일이 끌어다 놓는 자리로 돌아갈 수도 없습니다. 전역 접근이라는 편의는 그대로 두고, 무엇을 돌려줄지만 바꿀 수 있다면 어떨까요.
서비스 로케이터는 이렇게 생겼다
이름이 거창한데 하는 일은 단순합니다. 타입을 주면 그 타입의 구현을 돌려주는 창구입니다.
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)];
}
쓰는 쪽은 이렇습니다.
// 게임이 시작될 때 한 번
ServiceLocator.Register<IAudioService>(new AudioManager());
// 필요한 곳 어디서든
ServiceLocator.Get<IAudioService>().PlaySfx(hitClip);
전역에서 한 줄로 꺼내 쓴다는 점은 싱글톤과 똑같습니다. 딱 하나가 다릅니다. 부르는 쪽이 구현 클래스 이름을 모릅니다.
이름 하나 안 쓰는 것이 왜 큰가
싱글톤에서 소리를 끄려면 AudioManager 안에 if (muted) return; 같은 것을 넣어야 합니다.
로케이터에서는 아무것도 안 하는 구현을 하나 만들어 등록하면 끝입니다.
Bullet 은 물론이고 AudioManager 도 안 건드립니다.
public class MuteAudio : IAudioService
{
public void PlaySfx(AudioClip clip) { } // 아무것도 안 한다
}
ServiceLocator.Register<IAudioService>(new MuteAudio());
이게 실제로 값을 하는 자리가 셋 있습니다.
- 테스트. 가짜를 등록하고 “소리 재생이 몇 번 불렸는가” 만 세면 됩니다
- 개발 중 대체. 광고, 결제, 서버 통신처럼 개발할 때 실제로 부르면 곤란한 것들을 더미 구현으로 바꿔 둡니다. 빌드 설정에 따라 등록만 갈아 끼웁니다
- 플랫폼별 구현. 모바일과 PC 가 다르게 동작해야 할 때, 부르는 쪽 코드는 하나로 두고 등록 시점에 갈립니다
등록은 어디서 하나
이 패턴의 성패는 등록을 한 곳에서만 하느냐에 달려 있습니다. 여기저기서 등록하기 시작하면 “지금 뭐가 등록돼 있는지” 를 아무도 모르게 됩니다.
이렇게 등록이 한 군데로 모인 자리를 조합 루트(composition root) 라고 부릅니다. 이 파일 하나가 “이 게임은 무엇으로 조립되어 있는가” 를 알려주는 명세가 됩니다.
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 의 필드와 시그니처만 봐서는 이 클래스가
소리를 필요로 한다는 사실을 여전히 알 수 없습니다. 본문을 읽어야 드러납니다.
앞 글에서 지적한 숨은 의존성은 그대로 남아 있습니다.
그리고 새 비용이 생깁니다. 등록을 빠뜨려도 컴파일이 됩니다.
ServiceLocator.Get<IAudioService>(); // 등록 안 했으면 여기서 터집니다
싱글톤이었다면 클래스 이름을 잘못 쓰는 순간 컴파일러가 잡아 줍니다. 로케이터는 컴파일할 때 잡히던 실수를 실행할 때로 미룹니다. 그것도 그 코드가 실제로 불리는 순간에야 터지므로, 특정 경로로 들어가야만 나는 버그가 됩니다. 최소한 없을 때 알아볼 수 있게는 해 둡니다.
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 컨테이너는 등록하는 부분은 로케이터와 비슷하고, 꺼내는 부분이 없습니다. 필요한 것을 생성자나 필드로 넣어 주기 때문에 의존성이 시그니처에 그대로 드러납니다. 로케이터의 마지막 약점이 거기서 사라집니다. 다만 규칙을 하나 더 배워야 하므로, 팀과 프로젝트 크기를 보고 정할 일입니다.
정리
- 서비스 로케이터는 타입을 주면 구현을 돌려주는 창구다. 전역 접근은 싱글톤과 같다.
- 다른 점은 하나다. 부르는 쪽이 구체 클래스가 아니라 인터페이스만 안다.
- 그래서 등록만 바꾸면 구현이 갈린다. 테스트용 가짜, 개발용 더미, 플랫폼별 구현.
- 등록은 조합 루트 한 곳에서만 한다. 그 파일이 게임의 구성 명세가 된다.
- 숨은 의존성은 그대로 남는다. 시그니처만 봐서는 무엇에 기대는지 여전히 모른다.
- 새 비용도 생긴다. 등록을 빠뜨리면 컴파일이 아니라 실행 중에 터진다.
- 인스펙터로 닿으면 인스펙터로. 로케이터는 못 닿는 것에만 쓴다.
로케이터는 찾아가는 방식입니다. 대상이 하나로 정해져 있고, 인스펙터로는 못 닿을 때 잘 맞습니다.