Home [게임 프로그래밍 패턴] Decoupling Patterns: Service Locator
Post
Cancel

[게임 프로그래밍 패턴] Decoupling Patterns: Service Locator

Service Locator


  • 서비스를 구현하는 구체적인 클래스에 사용자를 커플링하지 않고, 어디에서나 서비스에 접근할 수 있게한다.

동기


  • 거의 모든 코드에서 사용되는 것들.
    • 메모리 할당, 로그, 난수 생성
    • 일종의 서비스
싱글턴 등의 사용 => 강한 커플링
  • ex. 오디오 시스템은 다음과 같이 호출할 수 있어야함.

    1
    2
    3
    4
    5
    
    // Use a static class?
    AudioSystem::playSound(VERY_LOUD_BANG);
    
      // Or maybe a singleton?
      AudioSystem::instance()->playSound(VERY_LOUD_BANG);
    
  • 이는 강한 커플링도 생기게 한다.

  • 오디오를 구현한 구체 클래스를 바로 접근할 수 있게 하는건, 우편 물 하나 받겠다고 수많은 이방인에게 집 주소를 알려주는 것과 같음.

    • 개인정보가 너무 노출됨.
    • 주소가 바뀌면 모두에게 바뀐 주소를 알려줘야함.
    • 전화번호부가 있으면, 이름과 주소를 찾을 수 있음.
    • 호출하는 쪽에서 전화번호부를 통해서 찾게 함으로써, 우리를 찾을 방법을 한곳에서 편리하게 관리할 수 있다.
  • 핵심: 서비스를 사용하는 코드로부터 서비스가 누구인지(서비스를 구현한 구체 클래스 자료형이 무엇인지), 어디에 있는지(클래스 인스턴스를 어떻게 얻을지)를 몰라도 되게 해줌.

The pattern


  • 서비스(service): 여러 기능을 추상 인터페이스로 정의
  • 구체적인 서비스 제공자(service provider): 인터페이스를 상속받아 구현
  • 서비스 중개자(service locator): 서비스 제공자의 실제 타입과 이를 등록하는 과정은 숨긴채 적절한 서비스 제공자를 찾아 서비스에 대한 접근을 제공.

언제 사용?

  • 싱글턴 패턴에서의 문제와 마찬가지로 절제해서 사용하는 게 좋다.

  • 접근해야 할 객체가 있다면 전역 메커니즘 대신, 필요한 객체를 인수로 넘겨줄 수는 없는지부터 생각해야함.

    • 이 방법은 커플링을 명확하게 보여줌
    • 하지만, 넘기는 방식이 불필요하거나, 코드를 읽기 어렵게 하기도 한다.
    • 또한 로그나 메모리 관리 같은 시스템이 모듈의 공개 API에 포함되어 있어선 안 된다.
  • 어떤 시스템은 본질적으로 하나임.
    • 오디오, 디스플레이 등.
    • 이를 깊숙이 들어있는 함수에 전달하는 것은 복잡성만 증가시킴
  • 이 패턴은 더 유연하고 더 설정히기 좋은 싱글턴 패턴이다.
    • 잘만 사용하면, 런타임 비용은 거의 들이지 않고도 코드를 훨씬 유연하게 만들 수 있다

      반대로 잘못 사용하면 싱글턴 패턴의 나쁜 점은 전부 있으면서, 실행 성능까지 떨어진다.

주의사항


  • 두 코드가 커플링되는 의존성을 런타임 시점까지 미루는 부분이 어려움.
  • 유연성은 얻을 수 있지만, 코드만 봐서는 어떤 의존성을 사용하는지를 알기 어려움.

서비스가 실제로 등록되어 있어야함

  • 싱글턴, 정적 클래스는 항상 인스턴스가 준비되어 있음.
  • 서비스 중개자 패턴에서는 서비스 객체를 등록해야 하기 때문에, 필요한 객체가 없을 때를 대비해야 한다.

서비스는 누가 자기를 가져다 놓는지 모른다

  • 서비스 중개자 == 전역에서 접근 가능.

    • 모든 코드에서 서비스를 요청하고 접근할 수 있음.
    • 즉, 서비스는 어느 환경에서나 문제없이 동작해야한다.
    • 서비스는 정확히 정해진 곳에서만 실행되는 걸 보장할 수 없음.
    • 어떤 클래스가 특정 상황에서만 실행되어야 한다면, 전체 코드에 노출되는 이 패턴을 적용하지 않는게 안전함.

예제 코드


  • 이벤트 큐에서 다룬 오디오 시스템 문제로 돌아가보자.
    • 이번에는 서비스 중개자를 통해서 오디오 시스템을 제공하게 할것.

The service

  • 오디오 API부터 시작.
1
2
3
4
5
6
7
8
class Audio
{
public:
  virtual ~Audio() {}
  virtual void playSound(int soundID) = 0;
  virtual void stopSound(int soundID) = 0;
  virtual void stopAllSounds() = 0;
};
  • 위와 같이 간단히 인터페이스를 구현할 수 있다.

The service provider

  • 인터페이스를 구체적인 클래스를 구현해야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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...
  }
};

A simple locator

의존성 주입: Locator 클래스는 Audio 서비스의 인스턴스가 필요하다. 보통은 중개자가 서비스 인스턴스를 직접 생성해준다. 이를 의존성 주입에서는 특정 객체가 필요로 하는 의존 객체를 외부 코드에서 주입해준다고 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Locator
{
public:
  static Audio* getAudio() { return service_; }

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

private:
  static Audio* service_;
};
  • 정적인 함수 getAudio()가 중개 역할을 한다.
1
2
Audio *audio = Locator::getAudio();
audio->playSound(VERY_LOUD_BANG);
  • Locator가 오디오 서비스를 ‘등록하는’ 방법은 굉장히 단순하다.
    • 게임이 시작될 때 외부 코드에서 등록
1
2
ConsoleAudio *audio = new ConsoleAudio();
Locator::provide(audio);
  • playSound()를 호출하는 쪽에서는 Audio라는 추상 인터페이스만 알 뿐 구체 클래스에 대해서는 전혀 모름.

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

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

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

    • 싱글턴과 정반대(‘서비스’를 제공하는 클래스의 형태 자체에 영향을 미침)

널 서비스

  • 위 예제의 단점

    • 서비스 등록 전 서비스 사용 시도 => NULL 반환
    • 호출하는 쪽에서 NULL을 검사하지 않으면 크래시 일어남.

      시간적 결합(temporal coupling): 두 가지 다른 코드를 정해진 순서대로 실행해야만 제대로 동작하는것. 제거하는것이 유지보수하기 좋다.

  • 널 객체 패턴을 사용할 수 있음

    • NULL 반환 대신 인터페이스를 구현한 특수한 객체를 반환
1
2
3
4
5
6
7
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 클래스를 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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_;
};

getAudio()가 서비스를 포인터가 아닌 레퍼런스로 반환하게 바뀜

C++에서 레퍼런스는 절대 NULL이 될 수 없음, 레퍼런스를 반환한다는 것은 코드를 사용하는 쪽에서 항상 객체를 받을 수 있다고 기대해도 된다는것.

초기화 함수를 먼저 호출해야함, NULL 검사 분기문을 provide에 둘 수 있어서 서비스에 접근할 때마다 생길 수 있는 CPU 낭비를 조금 줄일 수 있다.

  • 호출하는 쪽에서 진짜 서비스가 준비되어 있는지를 신경 쓰지 않아도 되고 NULL 반환 처리도 필요 없다.

  • Locator는 항상 유효한 객체를 반환한다는 점을 보장한다.

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

개발하는 동안 오디오 기능을 끌 수 있음.

로그 데커레이터

  • 서비스 중개자 패턴을 활용한 ‘데커레이션으로 감싼 서비스(decorated service)’

  • 로그를 남겨 무슨 일이 벌어지는지 확인하고 싶은 경우.

  • log()함수를 코드 여기저기 집어넣어야한다.
    • 이러다 로그가 너무 많아지는 문제가 발생한다.
  • 원하는 로그만 볼 수 있게해야하고, 최종 빌드에는 로그를 전부 제거하는 것이 이상적이다.
    • 조건적으로 로그를 남기고 싶은 시스템이 서비스로 노출되어 있다면 GoF의 데커레이터 패턴(장식자 패턴)으로 해결할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class LoggedAudio : public Audio
{
public:
  LoggedAudio(Audio &wrapped)
  : wrapped_(wrapped)
  {}

  virtual void playSound(int soundID)
  {
    log("play sound");
    wrapped_.playSound(soundID);
  }

  virtual void stopSound(int soundID)
  {
    log("stop sound");
    wrapped_.stopSound(soundID);
  }

  virtual void stopAllSounds()
  {
    log("stop all sounds");
    wrapped_.stopAllSounds();
  }

private:
  void log(const char* message)
  {
    // Code to log message...
  }

  Audio &wrapped_;
};
  • LoggedAudio 클래스는 다른 오디오 서비스 제공자를 래핑하는 동시에 같은 인테페이스를 상속받는다.
  • 로그를 남기면서, 실제 기능 요청은 참조하는 서비스에 전달한다.

  • 다음과같이 로그 기능을 킬 수 있다.
1
2
3
4
5
6
7
8
void enableAudioLogging()
{
  // Decorate the existing service.
  Audio *service = new LoggedAudio(Locator::getAudio());

  // Swap it in.
  Locator::provide(service);
}
  • 사운드를 비활성화 해서 로그만을 보고 확인할 수 있다.

디자인 결정


  • 핵심적인 질문들을 고려해 다양하게 달라질 수 있다.

서비스는 어떻게 등록되는가?

외부 코드에서 등록

  • 예제가 이에 해당.
  • 가장 일반적인 방법

  • 빠르고 간단: getAudio()는 단순히 반환만, 이는 컴파일러에서 인라인으로 성능 손해 없이 깔끔한 추상 계층을 둘 수 있다.
  • 서비스 제공자를 어떻게 만들지 제어할 수 있다:
    • 온라인용, 로컬용 입력
    • 온라인용: 네트워크를 통해 반대편에 전달 가능, 다른 코드는 구별못함, Locator는 IP 주소 같은 것을 알아야함. Locator는 다른 유저 IP 주소는 커녕 원격인지도 모름, 어떤 값을 서비스 제공자에게 전달해야하는지 모름
    • 외부에서 제공: 게임 네트워크 코드에서 온라인용 서비스 제공자 객체를 IP주소와 함께 생성한 뒤에 서비스 중개자에 전달하면 됨.
    • 이러면 서비스 중개자도 문제없이 온라인 플레이 서비스를 중개할 수 있다.
  • 게임 실행 도중에 서비스를 교체할 수 있다
    • 개발중 서비스를 끄는 등 활용
  • 서비스 중개자가 외부 코드에 의존한다는 단점이 있다
    • 초기화가 제대로 안된 상태 == 크래시 위험

컴파일할 때 바인딩

  • 전처리기 매크로를 이용해 컴파일할 대 등록
1
2
3
4
5
6
7
8
9
10
11
12
class Locator
{
public:
  static Audio& getAudio() { return service_; }

private:
  #if DEBUG
    static DebugAudio service_;
  #else
    static ReleaseAudio service_;
  #endif
};
  • 빠르다

    • 모든 작업이 컴파일할 때 긑나기 때문에 런타임에 따로 할 일이 없다.
    • 컴파일러가 getAudio()를 인라인으로 바꾼다면 속도 측면에서도 좋음
  • 서비스는 항상 사용가능:

    • 선택된 서비스를 소유, 컴파일 후면 서비스 준비 완료
  • 서비스를 쉽게 변경 불가능

    • 재컴파일해야함.

런타임에 설정 값 읽기

  • 기업용 소프트웨어 분야 쪽에서 보통 사용하는 방식

  • 중개자는 요청받은 실제 구현을 런타임에 찾아냄.

    일부 프로그래밍 언어에서는 리플렉션을 이용해 런타임에 타입 시스템과 상호작용할 수있다, 이름만으로 클래스를 찾은 뒤에 생성자를 호출해 인스턴스를 생성할 수 있다.(리스프, 파이썬 같은 동적 타이핑 언어, C#, 자바)

  • 보통 설정 파일을 로딩한 뒤, 리플렉션으로 원하는 서비스 제공자 클래스 객체를 런타임에 생성.

  • 다시 컴파일하지 않고도 서비스를 교체할 수 있다
    • 유연하지만, 바꾼 설정 값을 적용하려면 게임 재시작해야함.
    • 실행 도중에 서비스를 교체할 수 있는 방식보다는 덜 유연
  • 프로그래머가 아니어도 서비스를 변경할 수 있다

  • 등록 과정을 코드에서 완전히 빼냈기 때문에 하나의 코드로 여러 설정을 동시에 지원할 수 있다
    • 설정만 건드리면, 여러 다른 서버 환경에서 사용 가능.
  • 복잡하다
    • 파일 로딩=>파싱, 서비스를 동록하는 설정 시스템을 만들어야함.
    • 다른 방식보다 상당히 무거움
  • 서비스 등록에서 시간이 걸린다는 단점
    • 런타임에 설정 값을 사용하려면, 서비스를 등록하기 위해 CPU 사이클을 낭비해야한다.
    • 캐시하면 이런 낭비를 최소화할 수 있을지 몰라도, 처음에는 시간 약간 소모됨
    • 게임분야에서 게임 경험 향상과 상관없는 곳에서 CPU 사이클을 낭비하는 것은 지양됨

서비스를 못 찾으면 어떻게 할 것인가?

사용자가 알아서 처리하게 한다

  • 실패했을 경우 어떻게 처리할지를 사용자가 정할 수 있음
  • 서비스 사용자 쪽에서 실패를 처리해야함
    • 호출하는 쪽에서 거의 같은 방식으로 오류를 처리하다 보면 굉장히 많은 중복 코드가 코드베이스에 퍼짐.
    • 검사를 하나라도 제대로 하지 않으면 크래시가 생길 수 있음

게임을 멈춘다

  • 단언문을 추가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class Locator
{
public:
  static Audio& getAudio()
  {
    Audio* service = NULL;

    // Code here to locate service...

    assert(service != NULL);
    return *service;
  }
};
  • assert()는 서비스를 찾지 못하는 문제를 해결하지 못함.
    • 누구에게 문제가 있는지를 분명하게 보여줌.
    • 중개자에서 버그가 난 것을 분명히 알 수 있음.
  • 사용자 측에서는 서비스가 없는 경우를 처리하지 않아도 된다.
    • 하나의 서비스가 수많은 곳에서 사용될 수 있기 때문에, 코드를 굉장히 줄일 수 있다.
  • 서비스를 찾지 못하면 게임이 중단됨
    • 고치기 전까지 다른 팀원들이 아무것도 할 수없다.
    • 규모가 큰 개발팀에선 다른 프로그래머들이 기다려야함

널 서비스를 반환한다

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

  • 서비스를 찾지 못하면 게임이 중단된다.

    • 장점이자 단점
    • 준비되어 있지 않아도 게임을계속 진행할 수 있다는 점은 장점.
      • 미구현된 다른 시스템에 의존할 때 특히 유리
    • 의도치 않게 서비스를 찾지 못할 때에도 디버깅하기가 쉽지 않다는 점.

결론

  • 단언문을 거는 형태가 제일 많이 사용됨.
    • 서비스를 찾지 못할 가능성은 굉장히 작다.
  • 규모가 큰 팀이면 널 서비스 추천
    • 구현 쉬움, 서비스를 사용하지 못하는 동안 멈추지 않고 계속 개발 가능
    • 서비스에 버그가 있더라도 다른 작업을 하는 동안 서비스가 귀찮게 굴면 쉽게 끌 수 있다.

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

  • 다음과 같이 특정 클래스 및 그 클래스의 하위 클래스에만 접근을 제한할 수있다.
1
2
3
4
5
6
7
8
9
10
11
class Base
{
  // Code to locate service and set service_...

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

private:
  static Audio* service_;
};

전역에서 접근 가능한 경우

  • 전체 코드에서 같은 서비스를 쓰도록한다
    • 보통 이런 서비스(오디오같은)는 단 하나만 존재하는게 맞다.
    • 모든 코드에서 같은 서비스에 접근하게 하고, 제공자에 접근 제한하여 생성하지 못하도록 할 수 있다.
  • 언제 어디에서 서비스가 사용되는지를 제어할 수 없다
    • 전역으로 만들면서 생기는 비용

접근이 특정 클래스에 제한되면

  • 커플링 제어 가능: 특정 클래스를 상속받는 클래스들에게만 제한 => 디커플링 유지
  • 중복 작업을 해야할 가능성:
    • 둘 이상의 서로 상관없는 클래스에서 같은 서비스에 접근해야한다면 각자 그 서비스를 참조해야 한다.
    • 서비스를 찾거나 등록하는 작업을 이들 클래스에 대해 중복으로 해줘야 한다.

결론

  • 게임 분야에서는 하나의 클래스로 접근 범위를 좁히는것이 저자가 선호하는 것.
    • 네트워크 접근하기 위한 서비스 => 온라인 클래스에서만 사용하게
    • 로그같은 다양한 곳에서 사용하는 서비스 => 전역에 둔다.

관련 자료


  • 서비스 중개자 패턴: 싱글턴 패턴과 비슷

  • 유니티 프레임워크에서 GetComponent()에서 컴포넌트 패턴과 함께 서비스 중개자 패턴을 사용한다.

  • 마이크로소프트의 XNA 프레임워크의 핵심 클래스의 Game에 서비스 중개자 패턴이 포함되어있다.

    • Game.Services 속성 => 어떤 종류의 서비스라도 등록 가능

출처


service-locator

This post is licensed under CC BY 4.0 by the author.

[게임 프로그래밍 패턴] Decoupling Patterns: Event Queue

[게임 프로그래밍 패턴] Optimization Patterns: Data Locality