Game Development, 게임개발/디자인패턴

Service Locator, 서비스 중개자 [디자인패턴](디커플링)

게임이 더 좋아 2021. 10. 20. 22:01
반응형
728x170

 

이 패턴을 쓰는 의도는

서비스를 구현한 구체 클래스는 숨긴 채로 어디에서나 서비스에 접근할 수 있게 하기 위함이다.

무슨 말인지는 알아보도록 하자

 

https://www.c-sharpcorner.com/UploadFile/dacca2/service-locator-design-pattern/

 

 


 

 

객체나 시스템 중에서는 거의 모든 코드에서 사용되는 것들이 있다.

게임 코드 중에서 메모리 할당, 로그, 난수 생성을 쓰지 않는 곳을 찾아보기는 어렵다.

(패턴을 쓰게 되는 계기)

이런 시스템은 게임 전체에서 사용 가능해야 하는 일종의 서비스라고 할 수 있다.

 

예를 들자면

오디오 시스템 같은 경우도

메모리 할당같은 저수준까지는 아니지만 여러 게임 시스템과 연결되어 있다.

(당연히 소리가 날만한 동작들은 무지하게 많다.)

돌이 굴러 떨어지는 소리라든가,

총을 쏘는 소리라든가,

사용자가 인터페이스를 조작한다는 등

많은 시스템과 연관되어 있다.

즉, 이런 코드에서는 오디오 시스템을 호출할 수 있어야 한다.

// Use a static class?
AudioSystem::playSound(VERY_LOUD_BANG);

// Or maybe a singleton?
AudioSystem::instance()->playSound(VERY_LOUD_BANG);

 

정적 클래스를 쓰든가.. 싱글톤으로 불러내던가.. 상관 없다.

하지만 여전히 커플링이 생기는 것은 막을 수 없다.

(패턴을 쓰는 이유 2)

오디오 시스템을 접근하는 모든 곳에서 AudioSystem이라는 구체 클래스뿐만 아니라

정적 클래스 또는 싱글톤으로 만든 접근 메커니즘까지 직접 참조해야 한다.

 

물론 사운드를 출력하려면 커플링이 발생하는 것은 맞으나..

사운드 하나를 출력하기 위해서 오디오를 구현한 구체 클래스를 바로 접근하는 것은 비효율적이다.

그보다는 좋은 방법이 있다.

 

호출하는 쪽에서 해당 시스템을 찾기 위한 중간에 중개자를 두는 것이다.

그렇게 함으로써 해당 시스템을 찾기 위해서 모든 클래스가 해당 구체 클래스를 접근하는 것이 아니라

중개자를 통해 접근하도록 한다.

즉, 접근하는 방법을 중개자를 통해 관리하는 것이다.

 

이것이 서비스 중개자 패턴의 핵심으로

서비스를 이용하는 코드로부터 서비스가 무엇인지(구체 클래스 자료형이 무엇인지),

또는 어디에 있는지(클래스 인스턴스를 어떻게 얻을 것인지)를 몰라도 이용할 수 있게 된다.

 

**예를 들면 정적 클래스일 때 해당 클래스의 이름이 무엇인지 알아야 했고

객체를 부르려면 객체의 인스턴스를 어떻게 접근하거나 얻을 것인지에 대한 고민을 클래스마다 했다면서비스 중개자를 통하면 걱정하지 않아도 된다.

 


 

어떤 식으로 하느냐?

 

서비스여러 기능을 추상 인터페이스로 정의하고

구체 서비스 제공자는 이런 서비스 인터페이스를 상속받아 구현한다.

이와 별도인 서비스 중개자서비스 제공자의 실제 자료형과 이를 등록하는 과정은 숨긴 채

적절한 서비스 제공자를 찾아 서비스에 대한 접근을 제공한다.

 


 

언제 쓸 것이냐?

 

무엇이든.. 전역에서 접근하게 하는 것은 문제가 생기기 쉽다.

싱글턴 패턴에서 배웠다시피 서비스 중개자 패턴도 마찬가지다.

쓸 만한 곳에서만 써야 효율적이다.

 

해당 패턴을 쓰기 전에 몇 가지 생각해보자.

 

접근해야 할 객체가 있다면

전역 메커니즘 대신, 필요한 객체를 인수로 넘겨줄 수는 없는지부터 생각해보자.

이 방법은 굉장히 쉬운 데다가 커플링을 명확하게 보여줄 수 있다.

 

하지만 직접 객체를 넘기는 방식이 불필요하거나 반대로 코드를 읽기 어렵게 하기도 한다.

로그나 메모리 관리 같은 시스템이 모듈의 공개 API에 포함되어 있어서는 안된다.

렌더링 함수 매개변수에는 렌더링 관련된 것만 있어야지 로그 같은 게 섞여 있어서는 곤란하다.

 

또한 어떤 시스템은 본질적으로 하나뿐이다.

대부분의 게임 플랫폼에는 오디오나 디스플레이 시스템이 하나만 존재한다.

이런 환경적인 특징을 계층을 통해 전달하는 것은 비효율적이다.

 

이럴 때 서비스 중개자 패턴을 써서 효율적으로 만들 수 있다.

이 패턴이 조금 더 유용한 싱글턴 패턴이라고 생각하면 된다.

 


 

하지만 주의사항도 여전히 존재한다.

서비스 중개자 패턴에서는 두 코드가 커플링되는 의존성을 런타임 시점까지 미루는 부분이 가장 어렵다.

유연성은 얻지만 어떤 의존성을 사용하는지를 알기 어렵다.

 

싱글턴이나 정적 클래스에서는 인스턴스가 항상 준비되어 있다.

즉, 코드를 호출할 때 그 객체가 존재하는 것이 보장된다.

 

하지만 서비스 중개자 패턴에서는 서비스 객체를 등록해야 한다.

즉, 등록하지 않은 객체의 서비스를 받는 것이 문제가 된다.

 

또한 서비스 중개자 자체는 전역에서 접근 가능하기 때문에 모든 코드에서 서비스를 요청하고 접근할 수 있다.

즉, 서비스는 어느 환경에서나 문제 없이 작동해야 한다.

어떤 클래스를 게임 루프에서 시뮬레이션할 때만 사용해야 하고 렌더링 중에 사용하면 안 된다면

서비스로는 부적합하다.

서비스는 정확히 정해진 곳에서만 실행된다고 보장할 수 없기 때문이다.

 

때문에 어떤 클래스가 특정 상황에서만 실행되어야 한다면

전체 코드에 노출되는 서비스 중개자 패턴은 적용하지 않는 것이 좋다.

 

 


예제를 통해 알아보자.

 

서비스는 오디오다.

오디오 API는 오디오 서비스가 제공할 인터페이스를 말한다.

class Audio
{
public:
  virtual ~Audio() {}
  virtual void playSound(int soundID) = 0;
  virtual void stopSound(int soundID) = 0;
  virtual void stopAllSounds() = 0;
};

++위는 오디오 시스템을 간략하게 만들어 본 것이다.

 

서비스 중개자는 아래와 같이 만든다.

여기서는 오디오 인터페이스만으로는 아무것도 할 수 없다.

구체 클래스를 구현해야 한다.

실제 코드는 함수 본체에 있다고 가정한다. 

class ConsoleAudio : public Audio
{
public:
  virtual void playSound(int soundID)
  {
    // Play sound using console audio api...
  }

  virtual void stopSound(int soundID)
  {
    // Stop sound using console audio api...
  }

  virtual void stopAllSounds()
  {
    // Stop all sounds using console audio api...
  }
};

 

인터페이스와 구현 클래스는 준비되었다.

이제 서비스 중개자를 봐보자.

 


단순한 중개자 코드를 보자

class Locator
{
public:
  static Audio* getAudio() { return service_; }

  static void provide(Audio* service)
  {
    service_ = service;
  }

private:
  static Audio* service_;
};

 

정적 함수인 getAudio()가 중개 역할을 한다.

어디에서나 부를 수 있는 getAudio 함수는 다음과 같이 Audio 서비스 인스턴스 반환한다.

 

Audio *audio = Locator::getAudio();
audio->playSound(VERY_LOUD_BANG);

 

Locator가 오디오 서비스를 "등록"하는 방법은 굉장히 단순하다.

어디에서 getAudio를 호출하기 전에 먼저 서비스 제공자를 외부 코드에서 등록해주면 된다.

게임이 시작될 때 아래처럼 실행하면 된다.

ConsoleAudio *audio = new ConsoleAudio();
Locator::provide(audio);

 

playSound() 를 호출하는 쪽에서는 Audio 라는 추상 인터페이스만 알 뿐 ConsoleAudio 라는 구체 클래스에 대해서는 전혀 모른다는 것이 핵심이다.

Locator 클래스 역시 서비스 제공자의 구체 클래스와는 커플링되지 않는다.

어떤 구체 클래스가 실제로 사용되는지는 서비스를 제공하는 초기화 코드에서만 알 수 있다.

 

디커플링은 이뿐만이 아니다.

Audio 인터페이스도 자기가 서비스 중개자를 통해서 여기저기로부터 접근된다는 사실을 모른다.

Audio 인터페이스만 놓고 보면 일반적인 추상 상위 클래스다.

즉, 꼭 서비스 중개자 패턴용으로 만들지 않은 기존 클래스에도 이 패턴을 적용할 수 있다.

 

 


 

NULL 서비스

지금 위에서 본 코드도 충분히 유연하다.

다만 주의사항에서 말했듯이

서비스 제공자가 서비스를 등록하기 전서비스를 사용하려고 시도하면 NULL을 반환한다.

이대 NULL 체크를 하지 않으면 크래시가 난다.

 

이럴 때는 Null 객체 디자인 패턴이 있다.

이는 객체를 찾지 못하거나 만들지 못해서 NULL을 반환해야 할 때

대신 같은 인터페이스를 구현한 특수한 객체를 반환하는 데 있다.

 

이런 특수 객체에는 아무런 구현이 되어있지는 않지만, 객체를 반환받는 쪽에서는 "진짜"객체를 받은 것처럼 안전하게 작업을 진행할 수 있다.

**안전하다는 것은 요구할 당시에 반환 받을 때는 크래시가 안난다는 말이다.

 

널 서비스 제공자를 따로 정의해서 구현한다.

class NullAudio: public Audio
{
public:
  virtual void playSound(int soundID) { /* Do nothing. */ }
  virtual void stopSound(int soundID) { /* Do nothing. */ }
  virtual void stopAllSounds()        { /* Do nothing. */ }
};

 

보다시피 NullAudio는 Audio 서비스 인터페이스를 상속받기만 할 뿐 기능이 없다.

이제 Locator 클래스를 아래와 같이 바꾼다.

class Locator
{
public:
  static void initialize() { service_ = &nullService_; }

  static Audio& getAudio() { return *service_; }

  static void provide(Audio* service)
  {
    if (service == NULL)
    {
      // Revert to null service.
      service_ = &nullService_;
    }
    else
    {
      service_ = service;
    }
  }

private:
  static Audio* service_;
  static NullAudio nullService_;
};

 

호출하는 쪽에서는 "진짜" 서비스가 준비되어 있는지 신경 쓰지 않아도 되고

NULL 반환 처리도 필요 없다.

Locator에서 항상 유효한 객체를 반환하게끔 보장하는 것이다.

 

NULL 서비스는 의도적으로 특정 서비스를 못찾게 하고 싶을 때 유용하다.

어떤 시스템을 잠시 쓰지 못하게 하려면 그냥 서비스를 등록하지 않으면 된다.

그렇게 되면 서비스 중개자는 기본값으로 NULL을 반환한다.

 


 

 

구현하는 방법은 알았다.

그렇다면 어떤 방식을 통해서 구현을 해야 될까?

 

처음으로 서비스의 등록을 어디서할까?

 

1. 외부 코드에서 등록한다.

예제에서 본 방식이다.

- 빠르고 간단하다. getAudio()는 단순히 포인터만 반환하면 된다.

- 또한 서비스 제공자를 어떻게 만들지 제어가 가능하다.

게임 컨트롤러 서비스를 구현하는 구체 클래스가 두 종류 있다고 해보자.

하나는 일반 게임용이고 다른 하나는 온라인용이다.

온라인용 서비스 제공자는 컨트롤러 입력을 네트워크를 통해 반대편에 전달하지만 다른 코드에서는 온라인용 컨트롤러와 로컬 컨트롤러를 구별하지 못한다.

온라인 플레이 서비스 제공자는 원격 플레이어의 IP 주소를 알아야 한다.

서비스 중개자 안에서 서비스 제공자 객체를 직접 생성할 수 있을까?

Locator 클래스는 다른 유저 IP는 커녕 아무것도 모르기 때문에

서비스 제공자에 어떤 값을 전달해야 하는지를 알 수 없다.

외부에서 서비스 제공자를 등록하면 이런 문제를 해결할 수 있다.

서비스 중개자에서 클래스를 생성하지 않고, 게임 네트워크 코드에서 온라인용 서비스 제공자 객체를 IP 주소와 함께 생성한 뒤에 서비스 중개자에 전달하면 된다.

이러면 서비스의 추상 인터페이스만 알지 어떤 구체 클래스인지는 전혀 모르는 서비스 중개자도 문제없이 온라인 플레이 서비스를 중개할 수 있다.

 

-게임 실행 도중에 서비스를 교체 가능하다.

개발 중에 서비스 오디오를 널 서비스로 교체하면 게임 실행 도중 서비스를 잠시 끌 수 있는 것과 같다.

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

서비스를 사용하는 코드에서는 이미 어디에선가 다른 코드에서 서비스를 등록해놓았을 것이라고 생각한다.

초기화가 제대로 안되어있다면 크래시가 나거나 서비스가 동작하지 않을 것이다.

 

 

2. 컴파일할 때 바인딩

아래처럼 전처리기 매크로를 통해 등록한다.

 

이런 식으로 서비스를 등록할 때의 결과는 다음과 같다.

 

-빠르다. 

모든 작업이 컴파일할 때 끝나기 때문에 런타임에 할 것이 없다.

 

-서비스는 항상 사용가능하다.

서비스 중개자는 컴파일할 때 서비스를 소유하기 때문에 항상 준비되어 있다.

 

-서비스를 쉽게 변경 할 수 없다.

가장 큰 단점이다. 서비스를 바꾸고 싶다면 리컴파일을 해야하는게 단점이다.

 

class Locator
{
public:
  static Audio& getAudio() { return service_; }

private:
  #if DEBUG
    static DebugAudio service_;
  #else
    static ReleaseAudio service_;
  #endif
};

 

3. 런타임 도중에 설정 값 읽기

 

보통은 서비스 제공자 설정 파일을 로딩한 뒤에

리플렉션으로 원하는 서비스 제공자 클래스 객체를 런타임에 생성한다.

 

-다시 컴파일하지 않아도 서비스 교체가능

 

-프로그래머가 아니어도 서비스 바꾸기 가능기획자들도 서비스를 바꿀 수 있다.

 

-등록 과정을 코드에서 완전히 빼냈기 때문에 하나의 코드로 여러 설정을 동시에 지원 가능다양한 디바이스를 대상으로는 큰 장점이다.

 

-복잡하다파일을 로딩해서 파싱한 뒤에 서비스를 등록하기 때문에 상당히 무겁게 작동한다.

 

-서비스 등록에 시간이 걸린다.

런타임에 설정 값을 사용하려면 적어도 서비스를 등록하기 위해 CPU 사이클을 낭비해야 한다.

 


 

다음으로는 서비스를 찾지 못할 경우 어떻게 할까?

 

1. 사용자 임의 처리

가장 간단한 방법으로 NULL을 반환한다.

 

-실패했을 때 사용자가 알아서 처리를 한다.

서비스 호출 결과가 실패한다면 적당히 반응하거나 게임을 끝낼 수도 있다.

 

-반드시 서비스 사용자 쪽에서 실패 처리한다.

항상 서비스를 찾았는지를 검사한다.

또한 모든 호출하는 쪽에서 거의 같은 방식으로 오류를 처리하다 보면

많은 중복코드가 코드 베이스 전반적으로 퍼지게 된다.

 

2. 게임 스탑

컴파일 시간에 사용 가능함을 보장하지 못한다.

서비스를 찾지 못하면, 아래 코드에서 서비스를 사용하기 전에 게임이 중단된다.

class Locator
{
public:
  static Audio& getAudio()
  {
    Audio* service = NULL;

    // Code here to locate service...

    assert(service != NULL);
    return *service;
  }
};

** assert() 는 서비스를 찾지 못하는 문제를 해결하지는 못하지만

누구에게 문제가 있는지는 분명하게 보여준다.

 

-이로써 사용자 측에서는 서비스가 없는 경우를 따로 처리하지 않는다.

서비스를 찾지 못하면 게임이 중단되므로 문제가 해결될 때까지 확인하며 작업이 불가능해서 능률이 떨어진다.

 

3. NULL 서비스 반환

 

-외부 코드에서는 서비스가 없는 경우를 처리하지 않아도 된다.

단언문 방식과 마찬가지로 항상 사용 가능한 서비스를 반환하기 때문에, 서비스를 사용하는 쪽의 코드를 간단하게 만들 수 있다.

 

-서비스를 사용할 수 없을 때에도 게임을 진행할 수 있다.

이건 장점이자 단점이다. 서비스가 준비되어 있지 않아도 작동은 된다.

다만 의도치 않게 서비스를 찾지 못하는 상황임에도 이를 에러로 받아들이지 않는다는 것이다.

 

 

마지막으로 서비스를 제공하는 범위는 어떻게 할까?

 

어디에서나 중개자를 통해서 서비스를 접근할 수 있다고 했지만

접근을 제한할 수도 있다.

 

즉, 특정 클래스 및 그 클래스의 하위 클래스에서만 접근을 제한할 수 있다.

class Base
{
  // Code to locate service and set service_...

protected:
  // Derived classes can use service
  static Audio& getAudio() { return *service_; }

private:
  static Audio* service_;
};

 

위와 같이 된다면 Base 클래스를 상속받는 클래스에서만 오디오 서비스에 접근할 수 있다.

 

1. 전역 접근

-전체 코드에서 같은 서비스를 쓰도록 한다.

보통 이런 서비스 객체는 하나만 존재한다. 싱글톤처럼 아무데서나 생성하지 못하게 해야 한다.

 

-언제 어디에서 서비스가 사용되는지를 제어할 수 없다.

전역에서 접근하므로 제어가 어렵다.

 

2. 특정 클래스 제한

 

-커플링은 제어할 수 있다.

주된 장점으로 서비스를 특정 클래스를 상속받는 클래스들에게만 제한함으로써 다른 시스템으로부터는 디커플링 상태를 유지한다.

 

-중복 작업을 해야 할 수 있다는 단점이 있다.

둘 이상의 서로 상관없는 클래스에서 같은 서비스에 접근해야한다면 각자 그 서비스를 참조해야 한다.

방법이야 어떻든지 간에 서비스를 찾거나 등록하는 작업을 이들 클래스에 대해 중복으로 해줘야 한다.

반응형
그리드형