무엇을 쓸지는 부르는 쪽이 아니라 등록하는 쪽이 정한다
Esc

    무엇을 쓸지는 부르는 쪽이 아니라 등록하는 쪽이 정한다

    –Unity

    앞 글은 질문 하나로 끝났습니다. 진짜 하나여야만 하는가, 그냥 편해서인가.

    “그냥 편해서” 인 경우가 사실 대부분입니다. 총알이 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);
    싱글톤 Bullet AudioManager 구현을 직접 가리킵니다 서비스 로케이터 Bullet IAudioService AudioManager MuteAudio 부르는 쪽이 아는 것은 인터페이스뿐입니다. 실제로 무엇이 오는지는 등록한 쪽이 정합니다.
    바뀐 것은 접근 방식이 아니라 무엇에 의존하는가입니다.

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

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

    싱글톤에서 소리를 끄려면 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 컨테이너는 등록하는 부분은 로케이터와 비슷하고, 꺼내는 부분이 없습니다. 필요한 것을 생성자나 필드로 넣어 주기 때문에 의존성이 시그니처에 그대로 드러납니다. 로케이터의 마지막 약점이 거기서 사라집니다. 다만 규칙을 하나 더 배워야 하므로, 팀과 프로젝트 크기를 보고 정할 일입니다.

    정리

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

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