본문 바로가기
개발/게임 디자인 패턴

게임 디자인 패턴 12. 서비스 중개자 패턴 (Service Mediator Pattern)

by 석시 2023. 11. 7.



“어디서든” 접근을 할 수 있게 만드려면 보통은 정적 클래스나, 싱글톤 패턴을 생각하게 될 것이다.

하지만 이는 강한 커플링을 발생시킨다.

개인정보가 모두에게 노출되는 느낌이랄까?

따라서 전역 접근이 가능한 서비스한 단계 숨겨서 구체적인 내용은 숨긴 채 서비스를 이용만 할 수 있도록 구현한 패턴이 오늘 정리할 이 서비스 중개자 패턴이다.

읽다보면 느끼는 것이, 분명 싱글톤 패턴과 비슷하다고 느낄 것이다.

게임 디자인 패턴 5. 싱글턴 패턴 (Singleton Pattern)
싱글턴 패턴의 주요 특징1. 전역 접근점을 제공2. 오직 한 개의 클래스 인스턴스만 갖도록 보장싱글턴 패턴의 장점장점 1) 한 번도 사용하지 않을 경우 인스턴스를 아예 생성하지 않는다.장점 2) 런타임에 초기화된다.장점 3) 싱글턴을 상속할 수 있다.싱글턴 패턴의 한계전역 변수로부터 생기는 문제사용 목적에 비해 과도한 변형이 들어가는 문제게으른 초기화로부터 발생하는 문제싱글턴 패턴을 안쓰는 방법한 개의 인스턴스만 갖도록 보장하기인스턴스에 쉽게 접근하도록 만들기싱글턴 대신 다른 디자인 패턴 사용하기하위 클래스 샌드박스 패턴서비스 중개자 패턴 아마도 게임에서는 가장 유명하고 많이 다뤄지는 패턴이 오늘 다루는 바로 이 싱글턴 패턴이 아닐까 싶다. 그래서 놀랍게도 책에서는 싱글턴 패턴을 어떻게 해야 안 쓸 수 ..
https://seoksii.tistory.com/72

하지만 내가 구현하려는 기능이 싱글톤 패턴이 유용한지, 서비스 중개자 패턴이 유용한지는 나의 짬바에 따라 달라질 것이라 생각한다.

잘 고민해보고 필요한 패턴을 알맞게 쓰도록 하자.


서비스 중개자의 형태

몬스터 스폰 서비스를 만든다고 해보자.

public interface IMonsterSpawnService
{
    Monster spawnMonster(int monsterID);
}

중요한 것은 해당 서비스의 실제 구현은 없고 전부 추상 메서드로 이루어진 인터페이스라는 점이다.

이제 이러한 서비스를 제공해는 서비스 제공자를 만든다.

public class MonsterSpawner : MonoBehaviour, IMonsterSpawnService
{
    [SerializeField] private GameObject[] MonsterPrefabs;
    
    public Monster spawnMonster(int monsterID)
    {
        GameObject spawnedMonster = Instantiate(MonsterPrefabs[monsterID]);

        return spawnedMonster.GetComponent<Monster>();
        // 대충 이런 느낌?
    }
}

뭔가 함수 내용이 이상하지만, 그런갑다 하자.

서비스 중개자 패턴을 설명하는데 있어 함수 내용이 중요한 것은 아니다.

이제 가장 중요한 중개자이다.

public class Services
{
    public static IMonsterSpawnService MonsterSpawn { get; set; }
}

핵심은 이거다.

전역 호출이 가능한 서비스를 전달해줄 때, 전달해주는 서비스는 아까 만든 추상 인터페이스가 전달되기 때문에 서비스를 호출하는 곳에서는 서비스의 구체적인 내용에 대해 전혀 모른다!

이런 식으로 추상 인터페이스를 전달해 호출하는 쪽에서 세부내용을 깔 수 없게 만들어 안전하게 만드는 것이 서비스 중개자 패턴의 정수인 것이다.


서비스 중개자 패턴 개선하기

첫번째로 널 서비스(Null Service)이다.

위의 코드대로라면 서비스를 받아올 때 null을 받아올 가능성이 존재한다.

null은 크래시를 일으키니, 아무것도 하지 않는 널 서비스를 생성해 기본적으로 초기화해줄 수가 있다.

internal class NullMonsterSpawner : IMonsterSpawnService
{
    public Monster spawnMonster(int monsterID)
    {
        // 아무것도 하지 않는다!
        return null;
    }
}

public class Services
{
    private static IMonsterSpawnService _monsterSpawn;
    public static IMonsterSpawnService MonsterSpawn
    {
        get
        {
            return _monsterSpawn;
        }
        set
        {
            if (value == null) _monsterSpawn = new NullMonsterSpawner();
            else _monsterSpawn = value;
        }
    }

    public static void Initialize()
    {
        MonsterSpawn = null;
    }
}

물론… 위의 예제에서는 널 서비스가 제공해주는 Monster도 null을 리턴하기 때문에 해당 널 서비스로 몬스터를 스폰해 참조해도 꼼짝없이 크래시를 일으킬 것이긴 하다만…. 몬스터로 인한 null은 다른 쪽에서 처리를 해줘야 할 것이다.

참고로 이렇게 null을 대신하여 아무 일도 일어나지 않는 객체를 넣는 패턴을 널 객체 (Null Obejct) 패턴이라고 한다.

두번째로는 로그 데코레이션이다.

GoF 패턴에는 장식자 (데코레이터) 패턴이라는 것이 있다.

해당 패턴을 이용하여 데코레이션으로 감싼 서비스를 만들 수 있는 것인데, 예를 들어 내가 해당 서비스에 Debug.Log 기능을 추가한 서비스로 바꾸고 싶을 경우가 있을 것이다.

그럴 때 다음과 같은 식으로 구현을 해줄 수 있다.

public class LoggedMonsterSpawner : IMonsterSpawnService
{
    private IMonsterSpawnService _wrapped;

    LoggedMonsterSpawner(IMonsterSpawnService wrapped)
    {
        _wrapped = wrapped;
    }

    public Monster spawnMonster(int monsterID)
    {
        Debug.Log("몬스터 생성!");
        return _wrapped.spawnMonster(monsterID);
    }
}

이러면 내가 중간에 서비스를 로그를 띄워주는 서비스로 바꿔치기가 가능해지는 것이다.

void EnableLogging()
{
    IMonsterSpawnService newService = new LoggedMonsterSpawner(Services.MonsterSpawn);
    Services.MonsterSpawn = newService;
}


디자인 요소

서비스 중개자 패턴도 어떻게 디자인을 하냐에 따라 다양한 모습이 나올 수 있다.

  1. 외부 코드에서 등록 vs 컴파일할 때 바인딩 vs 런타임에 설정 값 읽기

서비스를 어떻게 등록할 지에 대한 이야기이다.

서비스를 외부 코드에서 등록하는 방식은 우리가 위에서 예제로 다룬 방식과 같다.

일단 가장 빠르고 간단하다는 장점이 있다.

게임 실행 중간에도 서비스를 언제든지 교체할 수 있지만, 이는 단점이 될 수도 있다.

서비스 중개자가 외부 코드에 의존한다는 단점이 있다는 것이다.

컴파일할 때 바인딩하는 방식은 전처리기 매크로를 이용하는 것이다.

여전히 빠른 속도를 유지한 채 항상 서비스가 존재하도록 보장해준다는 장점이 있지만, 반대로 서비스를 변경하기가 매우 힘들다는 단점이 존재한다.

마지막으로 런타임에 설정값을 읽어들이는 방식은 유니티에서는 리플렉션으로 구현이 가능하다.

해당 방식은 개발자가 아닌 사람도 서비스를 바꿀 수 있고 하나의 코드로 여러 설정을 동시에 지원하는, 유지보수 측면에서 아주 큰 장점을 가진다.

하지만 그만큼 복잡하고 서비스 등록에 시간이 걸려 CPU 사이클이 낭비된다는 단점도 존재한다.

  1. 서비스를 찾지 못한다면?

제일 고민할 거리가 많은 요소이다.

사용자가 알아서 처리하게 만든다면, 아까의 null 상황처럼 사용자가 직접 null 체크를 해주는 등의 작업을 해줘야 한다는 단점이 존재한다.

게임을 멈춘다면 사용자 입장에서는 서비스가 없는 경우를 처리하지 않아도 된다.

다르게 말하면 서비스가 없는 게임은 진즉 크래시가 났을테니까 아직 크래시가 안났다면 서비스가 있는 것이 보장된다는 소리라는 것이다.

근데 크래시 나는게 순도 100%에 가까운 단점이지 장점이라고 볼 수 있나?

널 서비스를 반환하게 한다면 서비스가 없는 경우를 처리하지 않아도 된다는 장점은 같지만, 반대로 의도치 않게 널 서비스를 반환하는 경우 이를 추적하기가 매우 힘들어진다.

  1. 서비스의 범위는 어떻게 잡을 것인가?

지금까지 서비스를 설명할 때 전역 접근을 전제로 이야기 했지만, 사실 꼭 그렇지 않아도 된다는 것을 어느정도는 파악했을 것이다.

방금의 서비스 접근 코드를 다시 보자.

public static IMonsterSpawnService MonsterSpawn { get; set; }

이걸 protected로 잠가버려서 해당 클래스나 Services 클래스를 상속받는 곳에서만 서비스 호출이 가능하도록 만들 수도 있는 것이다.

protected IMonsterSpawnService MonsterSpawn { get; set; }

이렇게 하면 내가 커플링을 제어할 수 있게 되지만, 대신 전역 접근이라는 아주 편리한 방법을 쓰지 못하게 되는 것은 약간 슬픈 것 같다.


Uploaded by N2T